const assert = require('node:assert/strict') const fs = require('node:fs') const path = require('node:path') const http = require('node:http') const { isDeepStrictEqual } = require('node:util') const { execFileSync } = require('node:child_process') const { chromium } = require('/Users/macclaw/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright') const directory = path.resolve(__dirname, '../../acceptance') const base = 'http://127.0.0.1:18080/api/v1' const db = 'yawan_native_acceptance_20260915' const sql = statement => execFileSync('docker', ['--context', 'colima-yawan', 'exec', 'WeKnora-postgres', 'psql', '-U', 'postgres', '-d', db, '-v', 'ON_ERROR_STOP=1', '-Atc', statement], {encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe']}).trim() const literal = value => "'" + String(value).replaceAll("'", "''") + "'" const checks = [], fixtureIDs = [], errors = [] let auth, author, model, visionModel, browser, page, stage = 'login', imageCalls = 0 const check = (name, pass) => { checks.push({name, passed: !!pass}); assert.ok(pass, name) } const resource = r => `/workbench/resources/${r.kind}/${r.id}` const days = [ {day: 1, title: '建立判断依据', prerequisite: '', hook: '如何应用判断依据', nodes: [{id: 'n1', label: '概念框架', children: [{id: 'n1a', label: '基本判断'}, {id: 'n1b', label: '应用边界'}]}]}, {day: 2, title: '实践与复盘', prerequisite: '掌握第一天的基本判断', hook: '如何独立迁移', nodes: [{id: 'n2', label: '实践框架', children: [{id: 'n2a', label: '案例练习'}, {id: 'n2b', label: '复盘问题'}]}]}, ] const server = http.createServer(async (req, res) => { try { let text = ''; for await (const part of req) text += part const body = JSON.parse(text), system = typeof body.messages[0].content === 'string' ? body.messages[0].content : '', prompt = body.messages.at(-1).content let content if (system.includes('Markdown 节点框架')) content = JSON.stringify({framework: '- 判断依据\n - 识别条件\n - 练习验证'}) else if (system.includes('课程架构师')) { const output = structuredClone(days) if (String(prompt).includes('本次批注只修改节点 n1a')) output[0].nodes[0].children[0].label = '更新后的基本判断' content = JSON.stringify({days: String(prompt).includes('只修改第 1 天') ? [output[0]] : output}) } else if (system.includes('内容编排师')) content = JSON.stringify({segments: [ {id: 's1', title: '建立判断', agent_id: author.id}, {id: 's2', title: '可选案例', agent_id: author.id}, {id: 's3', title: '迁移练习', agent_id: author.id}, ]}) else if (system.includes('跨稿质检员')) content = JSON.stringify({passed: true, summary: '前后判断依据一致,承接已采用段落。', issues: []}) else if (system.includes('成稿编辑')) content = '先建立判断依据,再完成迁移练习。' else if (Array.isArray(prompt)) { imageCalls++; content = '第一部分:建立判断。第二部分:迁移练习。' } else content = '先建立判断依据,再通过案例验证并进入迁移练习。' await new Promise(resolve => setTimeout(resolve, 900)) res.writeHead(200, {'Content-Type': 'application/json'}) res.end(JSON.stringify({id: 'parity', model: 'parity-model', choices: [{index: 0, message: {role: 'assistant', content}, finish_reason: 'stop'}], usage: {prompt_tokens: 30, completion_tokens: 30, total_tokens: 60}})) } catch { res.writeHead(500); res.end('{}') } }) async function request(route, method = 'GET', body, expected = 200) { const response = await fetch(base + route, {method, headers: {'Content-Type': 'application/json', ...(auth ? {Authorization: `Bearer ${auth.token}`, 'X-Tenant-ID': '10059'} : {})}, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(30000)}) assert.equal(response.status, expected, `${stage}: ${method} ${route} HTTP ${response.status}`) return response.json() } async function create(kind, name, data) { const item = (await request(`/workbench/resources/${kind}`, 'POST', {name, data}, 201)).data fixtureIDs.push(item.id); return item } async function action(item, name, args = {}) { stage = `action_${name}` const job = (await request(`${resource(item)}/actions/${name}`, 'POST', {version: item.version, ...args}, 202)).data const start = Date.now() let current do { await new Promise(resolve => setTimeout(resolve, 200)) current = (await request(`/workbench/jobs/${job.id}`)).data } while (['queued', 'running'].includes(current.status) && Date.now() - start < 45000) check(`${name}_job_succeeded`, current.status === 'succeeded') return (await request(resource(item))).data } async function visit(route) { await page.goto('http://127.0.0.1:15173' + route) await page.locator('.workbench-resource').first().waitFor({timeout: 30000}) } async function main() { const credentials = JSON.parse(fs.readFileSync(path.join(directory, 'super-admin-login.json'), 'utf8')) auth = await request('/auth/login', 'POST', {email: credentials.email, password: credentials.password}) const snapshot = () => sql("SELECT md5(coalesce(jsonb_agg(to_jsonb(r) ORDER BY id),'[]')::text) FROM workbench_resources r") const originals = snapshot() await new Promise(resolve => server.listen(18081, '0.0.0.0', resolve)) try { stage = 'create_fixtures' model = (await request('/models', 'POST', {name: `parity-${Date.now()}`, type: 'KnowledgeQA', source: 'remote', parameters: {base_url: 'http://host.docker.internal:18081/v1', api_key: 'acceptance-fixture', provider: 'generic'}}, 201)).data visionModel = (await request('/models', 'POST', {name: `parity-vision-${Date.now()}`, type: 'VLLM', source: 'remote', parameters: {base_url: 'http://host.docker.internal:18081/v1', api_key: 'acceptance-fixture', provider: 'generic'}}, 201)).data author = (await request('/agents', 'POST', {name: '迁移回归测试作者', config: {model_id: model.id, vlm_model_id: visionModel.id, agent_mode: 'quick-answer', kb_selection_mode: 'none'}}, 201)).data const flow = await create('flows', '迁移回归测试画布', {nodes: [{id: 'writer', agent_id: author.id, label: '内容作者'}], edges: []}) let map = await create('training-maps', '迁移回归测试导图', {topic: '学习方法', days_count: 0, days_auto: true, agent_id: author.id}) check('days_optional_on_create', map.data.days_count === 0) map = await action(map, 'generate') check('ai_recommended_days_saved', map.data.days_count === 2 && map.data.days.length === 2) const otherDay = JSON.stringify(map.data.days[1]) map = await action(map, 'revise', {day: 1, node_id: 'n1a', quote: '基本判断', advice: '补充判断条件'}) check('node_revision_is_scoped', map.data.days[0].nodes[0].children[0].label === '更新后的基本判断' && JSON.stringify(map.data.days[1]) === otherDay && map.data.days[0].nodes[0].children[1].label === '应用边界') map = await action(map, 'confirm') map = await action(map, 'generate-day', {day: 2}) map = await action(map, 'confirm-day', {day: 2}) let task = await create('content-tasks', '迁移回归测试任务', {map_id: map.id, day: 2, flow_id: flow.id}) check('task_inherits_canvas_agent', task.data.agent_id === author.id) task = await action(task, 'decompose') task = await action(task, 'generate', {segment_id: 's1'}) task = await action(task, 'accept', {segment_id: 's1'}) task = await action(task, 'skip', {segment_id: 's2'}) task = await action(task, 'generate', {segment_id: 's3'}) task = await action(task, 'accept', {segment_id: 's3'}) task = await action(task, 'review') task = await action(task, 'compose') check('complete_pipeline_output_visible', task.status === 'completed' && !!task.data.review && !!task.data.composed) const textTask = await create('content-tasks', '手动导入回归测试', {source_text: '基础概念、练习、复盘', flow_id: flow.id}) await action(textTask, 'decompose') let imageTask = await create('content-tasks', '导图图片回归测试', {source_image: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jv1sAAAAASUVORK5CYII=', flow_id: flow.id}) imageTask = await action(imageTask, 'import-image') check('image_recognition_then_decomposition', imageCalls === 1 && imageTask.data.source_text.includes('第一部分') && imageTask.data.segments.length === 3) imageTask = await action(imageTask, 'import-image') check('image_retry_refreshes_previous_ocr_text', imageCalls === 2) stage = 'legacy_fixture' const legacy = await create('training-maps', '迁移配置回归测试', {topic: '历史主题', days_count: 2, agent_id: author.id}) sql(`UPDATE workbench_resources SET status='needs_configuration', data=${literal(JSON.stringify({...legacy.data, agent_id: 'missing-agent', days, legacy_import: {read_only: true, needs_configuration: true}, legacy_history: {original: 'preserved'}}))}::jsonb WHERE id=${literal(legacy.id)}`) let imported = (await request(resource(legacy))).data imported = (await request(resource(imported), 'PUT', {name: '历史导图重命名测试', data: imported.data, version: imported.version})).data check('imported_rename_without_execution_config', imported.name === '历史导图重命名测试' && !imported.data.legacy_activation) const beforeDays = JSON.stringify(imported.data.days) imported = (await request(resource(imported), 'PUT', {name: imported.name, version: imported.version, configure: true, data: {...imported.data, agent_id: author.id}})).data const preservedDays = rows => rows.map(({confirmed, ...day}) => ({framework: '', ...day})) check('imported_configured_without_losing_nodes', imported.data.legacy_activation?.configured && isDeepStrictEqual(preservedDays(imported.data.days), preservedDays(JSON.parse(beforeDays))) && imported.data.legacy_history.original === 'preserved') stage = 'browser' browser = await chromium.launch({headless: true, channel: 'chrome'}) const context = await browser.newContext({viewport: {width: 1440, height: 1000}}) 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: '10059'}), weknora_token: a.token, weknora_refresh_token: a.refresh_token, weknora_memberships: JSON.stringify(a.memberships)} for (const [key, value] of Object.entries(values)) localStorage.setItem(key, value) localStorage.setItem('weknora:new-user-guide-done:v1', '1') }, auth) page = await context.newPage(); page.setDefaultTimeout(15000) page.on('pageerror', () => errors.push('page_error')) await visit(`/platform/workbench/content?tab=maps&resource=${map.id}`) await page.getByRole('button', {name: '全屏查看', exact: true}).click() await page.locator('.map-overview__canvas').waitFor() check('full_map_all_days_visible', await page.locator('.map-overview__day').count() === 2) await page.screenshot({path: path.join(directory, 'parity-map-desktop.png'), animations: 'disabled'}) await page.locator('.map-overview').getByRole('button', {name: '更新后的基本判断', exact: true}).click({button: 'right'}) const correction = page.locator('.t-dialog:visible').filter({hasText: '提交给 AI 修改'}) check('node_comment_quotes_selected_node', await correction.locator('blockquote').innerText() === '更新后的基本判断') await correction.locator('textarea').fill('保持先修关系,完善表述') await correction.getByRole('button', {name: '提交给 AI 修改', exact: true}).click() await page.locator('.t-dialog:visible .generation-status .t-progress').first().waitFor() check('node_revision_progress_visible', true) await page.locator('.t-dialog:visible .generation-status').getByText('已完成', {exact: true}).first().waitFor({timeout: 30000}) await page.setViewportSize({width: 390, height: 844}) await page.waitForFunction(() => { const r = document.querySelector('.t-dialog')?.getBoundingClientRect(); return r && r.left >= 0 && r.right <= innerWidth + 2 }, undefined, {polling: 100}) await page.screenshot({path: path.join(directory, 'parity-map-mobile.png'), animations: 'disabled'}) check('mobile_page_no_overflow', await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 2)) await page.locator('.t-dialog:visible .t-dialog__close').click() await page.setViewportSize({width: 1440, height: 1000}) await page.getByRole('button', {name: '新建训练营导图', exact: true}).click() const drawer = page.locator('.t-drawer--open') await drawer.getByText('AI 推荐', {exact: true}).waitFor() check('days_default_to_ai_recommendation', await drawer.locator('.t-input-number').count() === 0) await drawer.locator('.knowledge-scope > .t-select__wrap').click() await page.locator('.t-select-option').filter({hasText: '销售逻辑'}).click() await drawer.getByText('知识库与分类', {exact: true}).click() await drawer.locator('.knowledge-scope__row .t-tree-select').click() await page.locator('.knowledge-scope-popup .t-tree__item').first().waitFor() check('knowledge_categories_render_tree', await page.locator('.knowledge-scope-popup .t-tree__item').count() > 1) await page.screenshot({path: path.join(directory, 'parity-map-categories.png'), animations: 'disabled'}) stage = 'close_map_drawer' await drawer.getByRole('button', {name: '取消', exact: true}).click() stage = 'task_creation_browser' await visit(`/platform/workbench/content?tab=tasks&resource=${task.id}`) await page.getByRole('button', {name: '创建内容任务', exact: true}).click() check('task_form_has_no_redundant_agent_or_kb', await drawer.locator('.knowledge-scope').count() === 0 && !(await drawer.innerText()).includes('内容编排 Agent')) await drawer.getByText('导入文字或图片', {exact: true}).click() check('manual_image_upload_available', await drawer.locator('input[type=file]').getAttribute('accept') === 'image/png,image/jpeg,image/webp') await drawer.getByRole('button', {name: '取消', exact: true}).click() await visit(`/platform/workbench/content?tab=maps&resource=${imported.id}`) await page.getByRole('button', {name: '编辑配置', exact: true}).click() check('imported_edit_dialog_works', await drawer.locator('textarea').first().inputValue() === '历史主题') await drawer.getByRole('button', {name: '取消', exact: true}).click() check('browser_no_javascript_errors', errors.length === 0) stage = 'delete' await request(`${resource(imported)}?detach=true`, 'DELETE') await request(resource(imported), 'GET', undefined, 404) check('imported_delete_persists', true) } finally { if (page && !page.isClosed()) await page.screenshot({path: path.join(directory, 'parity-debug.png'), animations: 'disabled'}) await browser?.close() if (fixtureIDs.length) { const ids = fixtureIDs.map(literal).join(',') sql(`BEGIN; DELETE FROM workbench_jobs WHERE resource_id IN (${ids}); DELETE FROM workbench_events WHERE resource_id IN (${ids}); DELETE FROM workbench_revisions WHERE resource_id IN (${ids}); DELETE FROM workbench_resources WHERE id IN (${ids}); COMMIT;`) } if (author) await request(`/agents/${author.id}`, 'DELETE') if (visionModel) await request(`/models/${visionModel.id}`, 'DELETE') if (model) await request(`/models/${model.id}`, 'DELETE') await new Promise(resolve => server.close(resolve)) check('existing_resources_unchanged', snapshot() === originals) fs.writeFileSync(path.join(directory, 'migration-parity-checks.json'), JSON.stringify({stage, checks}, null, 2)) } console.log(JSON.stringify({checks, passed: checks.every(c => c.passed)})) } main().catch(error => { console.error(`${stage}: ${error instanceof assert.AssertionError ? error.message : String(error.message).split('\n')[0]}`); server.close(); process.exitCode = 1 })