const assert = require('node:assert/strict') const fs = require('node:fs') const path = require('node:path') const { chromium } = require('/Users/macclaw/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright') const directory = path.resolve(__dirname, '../../acceptance') const origin = process.env.VERIFY_FRONTEND_ORIGIN || 'http://127.0.0.1:18088' const apiOrigin = process.env.VERIFY_API_ORIGIN || 'http://127.0.0.1:18080' const reportName = process.env.VERIFY_REPORT_NAME || 'current-user-fixes' assert.match(reportName, /^[a-zA-Z0-9_-]+$/) const checks = [], failures = [], navigation = [], blockedWrites = [], apiTimings = [] const startedAt = new Date().toISOString() let browser, auth, complete = false function check(name, passed, details = {}) { checks.push({ name, passed: !!passed, ...details }) } function writeReport() { const report = { started_at: startedAt, checked_at: new Date().toISOString(), origin, api_origin: apiOrigin, viewport: { width: 1440, height: 1000 }, business_writes_allowed: false, model_calls_allowed: false, checks, failures, navigation, blocked_writes: blockedWrites, api_timings: apiTimings, passed: complete && checks.every(row => row.passed) && !failures.length, } fs.writeFileSync(path.join(directory, `${reportName}.json`), JSON.stringify(report, null, 2)) return report } async function getAPI(endpoint) { const started = Date.now() const response = await fetch(`${apiOrigin}/api/v1${endpoint}`, { headers: { Authorization: `Bearer ${auth.token}`, 'X-Tenant-ID': String(auth.active_tenant.id) }, signal: AbortSignal.timeout(15000), }) apiTimings.push({ path: endpoint, status: response.status, duration_ms: Date.now() - started }) assert.equal(response.status, 200, `GET ${endpoint} failed`) return response.json() } async function main() { 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 }), signal: AbortSignal.timeout(15000), }) assert.equal(login.status, 200, 'Acceptance login failed') auth = await login.json() assert.ok(auth.token && auth.user && auth.active_tenant?.id, 'Incomplete authentication result') const [accountsResult, usersResult, humansResult] = await Promise.all([ getAPI('/workbench/operations/accounts'), getAPI('/access/users'), getAPI('/digital-humans?limit=50'), ]) assert.ok(Array.isArray(accountsResult.data) && Array.isArray(usersResult.data) && Array.isArray(humansResult.data), 'Invalid list response') const accountIDs = accountsResult.data.map(row => row.user_id).sort() const userIDs = usersResult.data.map(row => row.id).sort() check('balance_accounts_equal_access_users', JSON.stringify(accountIDs) === JSON.stringify(userIDs), { accounts: accountIDs.length, access_users: userIDs.length, only_in_balance: accountIDs.filter(id => !userIDs.includes(id)).length, only_in_access: userIDs.filter(id => !accountIDs.includes(id)).length, }) check('balance_accounts_have_no_duplicates', new Set(accountIDs).size === accountIDs.length) browser = await chromium.launch({ headless: true, channel: 'chrome' }) const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, serviceWorkers: 'block' }) context.setDefaultTimeout(12000) context.setDefaultNavigationTimeout(12000) await context.addInitScript(a => { const values = { weknora_user: JSON.stringify({ ...a.user, tenant_id: String(a.user.tenant_id) }), weknora_tenant: JSON.stringify({ ...a.active_tenant, id: String(a.active_tenant.id) }), weknora_token: a.token, weknora_refresh_token: a.refresh_token, weknora_memberships: JSON.stringify(a.memberships), locale: 'zh-CN', } for (const [key, value] of Object.entries(values)) 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']) localStorage.setItem(key, '1') }, auth) await context.route('**/api/**', async route => { const request = route.request(), endpoint = new URL(request.url()).pathname if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method()) && !endpoint.startsWith('/api/v1/auth/')) { blockedWrites.push({ path: endpoint, method: request.method() }) return route.fulfill({ status: 409, json: { success: false, message: 'Read-only desktop verification' } }) } return route.continue() }) const routes = [ { path: '/platform/workbench/operations/balance', heading: '余额', api: '/api/v1/workbench/operations/accounts' }, { path: '/platform/workbench/access', heading: '访问控制', api: '/api/v1/access/users' }, { path: '/platform/digital-humans', heading: '数字人', api: '/api/v1/digital-humans' }, { path: '/platform/training-maps', heading: '内容任务', api: '/api/v1/workbench/resources/training-maps', redirect: '/platform/workbench/content', button: '新建训练营导图' }, { path: '/platform/content-tasks', heading: '内容任务', api: '/api/v1/workbench/resources/content-tasks', redirect: '/platform/workbench/content', button: '创建内容任务' }, { path: '/platform/flows', heading: '画布编排', api: '/api/v1/workbench/resources/flows', redirect: '/platform/workbench/flows' }, { path: '/platform/agents', selector: '.agent-list-page, .agent-list-container', api: '/api/v1/agents' }, { path: '/platform/knowledge-bases', selector: '[data-guide="kb-list-create"]', api: '/api/v1/knowledge-bases' }, { path: '/platform/workbench/operations/system', heading: '系统错误日志', api: '/api/v1/workbench/operations/records/system' }, ] for (const spec of routes) { const page = await context.newPage(), errors = [], responses = [], started = Date.now() page.on('pageerror', error => errors.push({ type: 'javascript', name: error.name })) page.on('response', response => { const pathname = new URL(response.url()).pathname responses.push({ path: pathname, status: response.status() }) if (response.status() >= 400 && (pathname.startsWith('/api/') || pathname.endsWith('.js'))) errors.push({ type: 'http', path: pathname, status: response.status() }) }) page.on('requestfailed', request => { if (request.failure()?.errorText !== 'net::ERR_ABORTED') errors.push({ type: 'network', path: new URL(request.url()).pathname }) }) try { const listLoaded = page.waitForResponse(response => new URL(response.url()).pathname === spec.api && response.request().method() === 'GET', { timeout: 12000 }) await Promise.all([page.goto(`${origin}${spec.path}`, { waitUntil: 'domcontentloaded' }), listLoaded]) if (spec.heading) await page.getByRole('heading', { name: spec.heading, exact: true }).first().waitFor() else await page.locator(spec.selector).first().waitFor() if (spec.button) await page.getByRole('button', { name: spec.button, exact: true }).waitFor() const url = new URL(page.url()) check(`navigation:${spec.path}`, url.pathname === (spec.redirect || spec.path) && errors.length === 0) if (spec.path.endsWith('/balance')) { await page.waitForFunction(expected => document.querySelector('.account-summary strong')?.textContent?.trim() === String(expected), accountIDs.length) check('balance_page_displays_api_account_count', true) await page.screenshot({ path: path.join(directory, `${reportName}-balance-desktop.png`), animations: 'disabled' }) } if (spec.path === '/platform/digital-humans') { const available = humansResult.data.find(row => !row.processing_job_id) assert.ok(available, 'No existing idle digital human available for read-only drawer verification') const drawerLoaded = page.waitForResponse(response => new URL(response.url()).pathname === `/api/v1/digital-humans/${available.id}`) await Promise.all([page.locator('.human-row .name-link').filter({ hasText: available.name }).first().click(), drawerLoaded]) const drawer = page.locator('.t-drawer:visible').filter({ has: page.locator('input.file-input') }) await drawer.locator('input.file-input').waitFor({ state: 'attached' }) const limit = drawer.locator('.upload-limit') const limitText = await limit.count() === 1 ? await limit.innerText() : '' check('digital_human_full_material_limits_visible', /99/.test(limitText) && /24\s*万/.test(limitText) && /20\s*MiB/.test(limitText)) check('digital_human_source_accepts_240000_characters', await drawer.locator('textarea[maxlength="240000"]').count() === 1) check('digital_human_drawer_fits_desktop', await drawer.evaluate(element => { const r = element.getBoundingClientRect(); return r.left >= -1 && r.right <= innerWidth + 1 && r.width >= 600 })) await page.screenshot({ path: path.join(directory, `${reportName}-digital-human-desktop.png`), animations: 'disabled' }) const files = Array.from({ length: 100 }, (_, index) => ({ name: `ui-only-material-${index + 1}.txt`, mimeType: 'text/plain', buffer: Buffer.from('Local UI verification only.') })) await drawer.locator('input.file-input').setInputFiles(files) const selectedFiles = await drawer.locator('.file-list > li').count() check('digital_human_selects_at_most_99_files', selectedFiles === 99, { selected_files: selectedFiles, attempted_files: files.length }) check('digital_human_explains_file_count_limit', (await drawer.innerText()).includes('每次最多选择 99 份材料')) } } catch (error) { check(`page_completed:${spec.path}`, false, { error: error.name, message: error.message.split('\n')[0].slice(0, 300) }) await page.screenshot({ path: path.join(directory, `${reportName}-failure-${navigation.length + 1}.png`), animations: 'disabled' }).catch(() => {}) } finally { navigation.push({ path: spec.path, duration_ms: Date.now() - started, errors, api_response_count: responses.filter(row => row.path.startsWith('/api/')).length }) await page.close() writeReport() } } check('no_business_writes_or_model_calls_attempted', blockedWrites.length === 0) check('no_browser_runtime_or_http_errors', navigation.every(row => row.errors.length === 0)) complete = true } main().catch(error => failures.push({ name: error.name, message: error.message.slice(0, 300) })).finally(async () => { if (browser) await browser.close() const report = writeReport() console.log(JSON.stringify({ report: path.join(directory, `${reportName}.json`), passed: report.passed, checks: checks.length, failed: checks.filter(row => !row.passed) })) if (!report.passed) process.exitCode = 1 })