160 lines
11 KiB
JavaScript
160 lines
11 KiB
JavaScript
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
|
||
|
|
})
|