Files
xinmi/scripts/verify_migration_parity.cjs
T
yawan f7fc992f57
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
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

198 lines
16 KiB
JavaScript

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