Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
213 lines
16 KiB
JavaScript
213 lines
16 KiB
JavaScript
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 })
|