Files
xinmi/scripts/verify_manual_editor_loading.cjs
T

213 lines
16 KiB
JavaScript
Raw Normal View History

const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const { pathToFileURL } = require('node:url')
const { chromium } = require('/Users/macclaw/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright')
const directory = '/Volumes/Extreme SSD/yawan-storage/weknora/acceptance'
const output = path.join(directory, `manual-editor-browser-${new Date().toISOString().replace(/[:.]/g, '-')}`)
const frontend = path.resolve(__dirname, '../frontend')
const candidate = process.env.MANUAL_EDITOR_CANDIDATE || '/Volumes/Extreme SSD/yawan-storage/cache/frontend-lazy-editor-mobile-final-20260920-2359'
const apiOrigin = 'http://127.0.0.1:18080'
const report = { started_at: new Date().toISOString(), candidate, checks: [], failures: [], page_errors: [], network_failures: [], blocked_writes: [], screenshots: [], retry: null }
async function main() {
fs.mkdirSync(output, { recursive: true })
const credentials = JSON.parse(fs.readFileSync(path.join(directory, 'super-admin-login.json'), 'utf8'))
const login = await fetch(`${apiOrigin}/api/v1/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: credentials.email, password: credentials.password }) })
assert.equal(login.status, 200)
const auth = await login.json()
const kbResponse = await fetch(`${apiOrigin}/api/v1/knowledge-bases`, { headers: { Authorization: `Bearer ${auth.token}`, 'X-Tenant-ID': '10059' } })
assert.equal(kbResponse.status, 200)
const kb = (await kbResponse.json()).data.find(item => (!item.type || item.type === 'document') && item.summary_model_id && item.embedding_model_id && item.storage_backend_id)
assert.ok(kb, 'An initialized document knowledge base is required for read-only UI navigation')
const manifest = JSON.parse(fs.readFileSync(path.join(candidate, '.vite/manifest.json'), 'utf8'))
const editorFile = '/' + manifest['src/components/manual-knowledge-editor.vue'].file
process.env.VITE_DEV_PROXY_TARGET = apiOrigin
const { preview } = await import(pathToFileURL(path.join(frontend, 'node_modules/vite/dist/node/index.js')).href)
const server = await preview({ root: frontend, configFile: path.join(frontend, 'vite.config.ts'), build: { outDir: candidate }, preview: { host: '127.0.0.1', port: 15201, strictPort: true } })
const origin = server.resolvedUrls.local[0].replace(/\/$/, '')
report.origin = origin
report.editor_module = editorFile
const browser = await chromium.launch({ headless: true, channel: 'chrome' })
let currentPage
const check = (name, passed, detail) => {
report.checks.push({ name, passed: !!passed, ...(detail === undefined ? {} : { detail }) })
assert.ok(passed, name)
console.log(JSON.stringify(report.checks.at(-1)))
}
async function context(label) {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
await ctx.addInitScript(a => {
const entries = { weknora_user: JSON.stringify({ ...a.user, tenant_id: String(a.user.tenant_id) }), weknora_tenant: JSON.stringify({ ...a.active_tenant, id: '10059' }), weknora_token: a.token, weknora_refresh_token: a.refresh_token, weknora_memberships: JSON.stringify(a.memberships) }
for (const [key, value] of Object.entries(entries)) localStorage.setItem(key, value)
for (const key of ['weknora:new-user-guide-done:v1', 'weknora:contextual-guide-kb-list:v2', 'weknora:contextual-guide-kb-create:v3', 'weknora:contextual-guide-tenant-models:v1', 'weknora:contextual-guide-kb-detail:v1']) localStorage.setItem(key, '1')
}, auth)
await ctx.route('**/api/**', route => {
const req = route.request()
if (!['GET', 'HEAD', 'OPTIONS'].includes(req.method())) {
report.blocked_writes.push({ method: req.method(), path: new URL(req.url()).pathname })
return route.fulfill({ status: 409, json: { success: false, message: 'Read-only browser acceptance blocks business writes' } })
}
return route.continue()
})
const page = await ctx.newPage()
currentPage = page
page.setDefaultTimeout(20000)
page.on('pageerror', error => report.page_errors.push({ label, name: error.name, message: error.message }))
page.on('requestfailed', request => report.network_failures.push({ label, path: new URL(request.url()).pathname, error: request.failure()?.errorText }))
return { ctx, page }
}
async function screenshot(page, name) {
const file = path.join(output, `${name}.png`)
await page.screenshot({ path: file, animations: 'disabled' })
report.screenshots.push(file)
}
function feedback(page) { return page.locator('.t-dialog:visible').filter({ has: page.getByText('在线编辑 Markdown 知识', { exact: true }) }) }
function editor(page) { return page.locator('.t-drawer:visible').filter({ has: page.getByText('在线编辑 Markdown 知识', { exact: true }) }) }
async function open(page) {
await page.waitForTimeout(350)
await page.locator('.kb-upload-source-trigger:visible').first().click()
await page.locator('.t-dropdown__item:visible').filter({ has: page.getByText('在线编辑', { exact: true }) }).click()
await page.waitForTimeout(350)
}
async function fits(locator) {
return locator.evaluate(element => {
const rect = element.getBoundingClientRect()
return rect.left >= -1 && rect.right <= innerWidth + 1 && rect.top >= -1 && rect.bottom <= innerHeight + 1 && element.scrollWidth <= element.clientWidth + 1
})
}
try {
const success = await context('delayed-success')
let release, moduleRequests = 0
const delayed = new Promise(resolve => { release = resolve })
await success.ctx.route(`**${editorFile}`, async route => { moduleRequests++; await delayed; await route.continue() })
await success.page.goto(`${origin}/platform/knowledge-bases/${kb.id}`)
await success.page.locator('.kb-upload-source-trigger:visible').first().waitFor()
check('unopened_knowledge_page_does_not_fetch_editor', moduleRequests === 0)
await open(success.page)
await feedback(success.page).getByText('正在准备编辑器', { exact: true }).waitFor()
check('first_open_has_cancelable_loading', await feedback(success.page).getByRole('button', { name: '取消', exact: true }).isVisible())
await screenshot(success.page, 'loading-desktop')
await success.page.setViewportSize({ width: 390, height: 844 })
check('loading_dialog_fits_mobile', await fits(feedback(success.page)))
await screenshot(success.page, 'loading-mobile')
await feedback(success.page).getByRole('button', { name: '取消', exact: true }).click()
await feedback(success.page).waitFor({ state: 'hidden' })
check('cancel_hides_pending_feedback', true)
check('mobile_add_entry_fits_viewport', await fits(success.page.locator('.kb-upload-source-trigger:visible').first()))
check('mobile_knowledge_page_has_no_horizontal_overflow', await success.page.evaluate(() => document.documentElement.scrollWidth <= innerWidth))
const recommendationLabel = await success.page.getByRole('button', { name: 'AI 推荐分类', exact: true }).evaluate(button => {
const label = button.querySelector('.t-button__text')
const range = document.createRange()
range.selectNodeContents(label)
const text = range.getBoundingClientRect(), bounds = button.getBoundingClientRect(), style = getComputedStyle(button)
return { label: label.textContent.trim(), width: bounds.width, text_width: text.width, no_shrink: style.flexShrink === '0', text_fits: label.scrollWidth <= label.clientWidth + 1 && text.left >= bounds.left && text.right <= bounds.right, visible: bounds.left >= 0 && bounds.right <= innerWidth }
})
check('mobile_ai_recommendation_label_fits_without_shrink', recommendationLabel.label === 'AI 推荐分类' && recommendationLabel.no_shrink && recommendationLabel.text_fits && recommendationLabel.visible, recommendationLabel)
await screenshot(success.page, 'knowledge-mobile-folders-open')
await success.page.getByRole('button', { name: '收起目录', exact: true }).click()
await success.page.locator('.kb-folder-tree').waitFor({ state: 'hidden' })
check('mobile_folder_collapse_keeps_add_entry_accessible', await fits(success.page.locator('.kb-upload-source-trigger:visible').first()))
await screenshot(success.page, 'knowledge-mobile-folders-collapsed')
await success.page.getByRole('button', { name: '展开目录', exact: true }).click()
await success.page.locator('.kb-folder-tree').waitFor()
await open(success.page)
await feedback(success.page).getByText('正在准备编辑器', { exact: true }).waitFor()
check('reopen_pending_reuses_module_request', moduleRequests === 1)
release()
await editor(success.page).locator('textarea').waitFor()
const title = editor(success.page).locator('input').first()
await success.page.waitForFunction(() => [...document.querySelectorAll('.t-drawer input')].some(input => input.value.startsWith('新建文档-')))
const initialInputs = await editor(success.page).locator('input').evaluateAll(inputs => inputs.map(input => input.value))
check('first_open_initializes_title_and_selected_kb', initialInputs[0].startsWith('新建文档-') && initialInputs.includes(kb.name))
await success.page.setViewportSize({ width: 390, height: 844 })
await success.page.waitForTimeout(350)
check('opened_editor_fits_mobile', await fits(editor(success.page).locator('.t-drawer__content-wrapper')))
const footer = await editor(success.page).locator('.setting-drawer__footer').evaluate(element => {
const left = element.querySelector('.setting-drawer__footer-left').getBoundingClientRect()
const right = element.querySelector('.setting-drawer__footer-right').getBoundingClientRect()
return { non_overlapping: left.bottom <= right.top + 1 || left.right <= right.left + 1, fits: element.scrollWidth <= element.clientWidth + 1 }
})
check('mobile_editor_footer_has_no_overlap', footer.non_overlapping && footer.fits, footer)
await screenshot(success.page, 'editor-mobile')
await success.page.setViewportSize({ width: 1440, height: 1000 })
await screenshot(success.page, 'editor-desktop')
await title.fill('UNSAVED_BROWSER_ACCEPTANCE')
await editor(success.page).getByRole('button', { name: '取消', exact: true }).click()
await editor(success.page).waitFor({ state: 'hidden' })
await open(success.page)
await editor(success.page).locator('textarea').waitFor()
check('close_reopen_reinitializes_without_redownloading', (await editor(success.page).locator('input').first().inputValue()).startsWith('新建文档-') && moduleRequests === 1)
await success.ctx.close()
const failure = await context('injected-module-failure')
let failedRequests = 0, navigations = 0
failure.page.on('framenavigated', frame => { if (frame === failure.page.mainFrame()) navigations++ })
await failure.ctx.route(`**${editorFile}`, route => { failedRequests++; return failedRequests === 1 ? route.abort('failed') : route.continue() })
await failure.page.goto(`${origin}/platform/knowledge-bases/${kb.id}`)
await failure.page.locator('.kb-upload-source-trigger:visible').first().waitFor()
const preservedInput = 'UNSAVED_BROWSER_ACCEPTANCE'
await failure.page.locator('.doc-search-input input').fill(preservedInput)
const initialNavigations = navigations
await open(failure.page)
await failure.page.getByRole('button', { name: '保留当前页面', exact: true }).waitFor()
await screenshot(failure.page, 'global-reload-consent-desktop')
check('module_failure_uses_existing_reload_consent', await failure.page.getByText('重新加载会清除尚未保存的输入', { exact: false }).isVisible())
await failure.page.setViewportSize({ width: 390, height: 844 })
await screenshot(failure.page, 'global-reload-consent-mobile')
check('global_reload_consent_fits_mobile', await fits(failure.page.locator('.t-dialog:visible').filter({ has: failure.page.getByText('页面文件暂时无法加载', { exact: true }) })))
await failure.page.getByRole('button', { name: '保留当前页面', exact: true }).click()
await feedback(failure.page).getByText('编辑器文件加载失败,请恢复网络后重新加载页面。', { exact: true }).waitFor()
check('failure_clears_spinner_and_offers_reload', await feedback(failure.page).locator('.t-loading').count() === 0 && await feedback(failure.page).getByRole('button', { name: '重新加载页面', exact: true }).isVisible())
await failure.page.setViewportSize({ width: 1440, height: 1000 })
await screenshot(failure.page, 'error-desktop')
await failure.page.setViewportSize({ width: 390, height: 844 })
check('error_dialog_fits_mobile', await fits(feedback(failure.page)))
await screenshot(failure.page, 'error-mobile')
await feedback(failure.page).getByRole('button', { name: '重新加载页面', exact: true }).click()
await failure.page.getByRole('button', { name: '保留当前页面', exact: true }).waitFor()
check('local_reload_action_reopens_unsaved_input_warning', await failure.page.getByText('重新加载会清除尚未保存的输入', { exact: false }).isVisible())
await screenshot(failure.page, 'local-reload-consent-mobile')
await failure.page.getByRole('button', { name: '保留当前页面', exact: true }).click()
check('cancel_reload_preserves_page_and_unsaved_input', navigations === initialNavigations && await failure.page.locator('.doc-search-input input').inputValue() === preservedInput && failedRequests === 1)
await feedback(failure.page).getByRole('button', { name: '取消', exact: true }).click()
await feedback(failure.page).waitFor({ state: 'hidden' })
await open(failure.page)
check('failed_editor_reopen_retains_recovery_without_refetch', await feedback(failure.page).getByRole('button', { name: '重新加载页面', exact: true }).isVisible() && failedRequests === 1)
check('failure_never_forces_reload', navigations === initialNavigations)
await feedback(failure.page).getByRole('button', { name: '重新加载页面', exact: true }).click()
await Promise.all([
failure.page.waitForNavigation({ waitUntil: 'domcontentloaded' }),
failure.page.getByRole('button', { name: '重新加载', exact: true }).click(),
])
await failure.page.locator('.kb-upload-source-trigger:visible').first().waitFor()
check('explicit_reload_confirmation_navigates', navigations > initialNavigations)
await open(failure.page)
await editor(failure.page).locator('textarea').waitFor()
await failure.page.waitForFunction(() => [...document.querySelectorAll('.t-drawer input')].some(input => input.value.startsWith('新建文档-')))
await failure.page.waitForTimeout(350)
report.retry = { module_request_count: failedRequests, browser: await browser.version(), consented_reload_recovered: true, result: 'Local recovery reopens shared consent; cancel preserves page input, confirmed reload clears the cached import failure and the editor opens normally.' }
check('confirmed_reload_recovers_editor', failedRequests === 2 && await editor(failure.page).locator('textarea').isVisible(), report.retry)
await screenshot(failure.page, 'recovered-editor-mobile')
check('no_business_writes_or_model_calls', report.blocked_writes.length === 0)
check('no_unexpected_browser_runtime_errors', report.page_errors.length === 0, report.page_errors)
} catch (error) {
report.failures.push({ name: error.name, message: error.message, stack: error.stack })
if (currentPage && !currentPage.isClosed()) {
await screenshot(currentPage, 'raw-failure').catch(() => {})
fs.writeFileSync(path.join(output, 'failure-visible-text.txt'), await currentPage.locator('body').innerText().catch(() => ''))
}
process.exitCode = 1
} finally {
await browser.close()
await new Promise((resolve, reject) => server.httpServer.close(error => error ? reject(error) : resolve()))
report.finished_at = new Date().toISOString()
report.passed = report.failures.length === 0
fs.writeFileSync(path.join(output, 'report.json'), JSON.stringify(report, null, 2))
console.log(JSON.stringify({ output, passed: report.passed, checks: report.checks.length, failures: report.failures, retry: report.retry }))
}
}
main().catch(error => { console.error(error.name, error.message); process.exitCode = 1 })