Files

160 lines
11 KiB
JavaScript
Raw Permalink Normal View History

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
})