// Creates disposable fixtures only. No generation actions or paid model calls. // VERIFY_API_ORIGIN / VERIFY_FRONTEND_ORIGIN default to the public deployment. // CANVAS_BROWSER=0 runs API checks only. PostgreSQL must back the selected API. const assert = require('node:assert/strict') const crypto = require('node:crypto') const fs = require('node:fs') const path = require('node:path') const { execFileSync } = require('node:child_process') const root = path.resolve(__dirname, '../..') const directory = path.join(root, 'acceptance') const origin = (process.env.VERIFY_API_ORIGIN || 'https://agent.pducv.cn').replace(/\/$/, '') const frontend = (process.env.VERIFY_FRONTEND_ORIGIN || origin).replace(/\/$/, '') const reportName = process.env.VERIFY_REPORT_NAME || 'canvas-configuration-live' const tenant = 10059 const database = 'yawan_native_acceptance_20260915' const prefix = `canvas-check-${Date.now()}-${crypto.randomBytes(3).toString('hex')}` const checks = [], failures = [], resources = [] const knowledgeKeys = ['knowledge_base_ids', 'knowledge_ids', 'knowledge_folders', 'knowledge_tags'] const browserEnabled = process.env.CANVAS_BROWSER !== '0' let admin, member, role, user, agent, kb, browser, foreignTenant, databaseVerified = false assert.match(reportName, /^[a-zA-Z0-9_-]+$/) const literal = value => `'${String(value).replaceAll("'", "''")}'` const sql = query => execFileSync('docker', ['--context', 'colima-yawan', 'exec', '-i', 'WeKnora-postgres', 'psql', '-X', '-q', '-U', 'postgres', '-d', database, '-At', '-v', 'ON_ERROR_STOP=1'], { input: query, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim() function check(name, value) { checks.push({ name, passed: !!value }) assert.ok(value, name) console.log(`PASS ${name}`) } async function api(route, method = 'GET', body, identity = admin, expected = 200) { const response = await fetch(origin + '/api/v1' + route, { method, headers: { 'Content-Type': 'application/json', ...(identity ? { Authorization: `Bearer ${identity.token}`, 'X-Tenant-ID': String(tenant) } : {}), }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(30000) }) const data = await response.json() assert.ok((Array.isArray(expected) ? expected : [expected]).includes(response.status), `${method} ${route}: HTTP ${response.status}; ${data.message || data.error?.message || ''}`) return data } function resourcePath(resource) { assert.ok(resources.some(row => row.id === resource.id && row.kind === resource.kind), 'Only recorded fixtures may be changed') return `/workbench/resources/${resource.kind}/${resource.id}` } async function create(kind, label, data, identity = member) { const resource = (await api(`/workbench/resources/${kind}`, 'POST', { name: `${prefix}-${label}`, data }, identity, 201)).data resources.push({ ...resource, identity }) assert.equal(resource.owner_id, identity.user.id) return resource } function fixtureWhere(resource) { resourcePath(resource) assert.ok(resource.name.startsWith(prefix + '-')) return `id=${literal(resource.id)} AND tenant_id=${Number(resource.tenant_id)} AND kind=${literal(resource.kind)} AND name LIKE ${literal(prefix + '-%')}` } function seed(resource, data, ownerID = resource.owner_id, status = resource.status) { assert.ok(databaseVerified, 'API and database identity must match before seeding') const id = sql(`UPDATE workbench_resources SET data=${literal(JSON.stringify(data))}::jsonb, owner_id=${literal(ownerID)},status=${literal(status)} WHERE ${fixtureWhere(resource)} RETURNING id;`) assert.equal(id, resource.id, 'Exactly the recorded fixture must be seeded') } async function read(resource, identity = admin) { return (await api(resourcePath(resource), 'GET', undefined, identity)).data } async function absent(resource, identity = admin) { await api(resourcePath(resource), 'GET', undefined, identity, 404) const listed = await api(`/workbench/resources/${resource.kind}?q=${encodeURIComponent(prefix)}&page_size=100`, 'GET', undefined, identity) assert.ok(!listed.data.some(row => row.id === resource.id), 'Deleted fixture must not reappear in a fresh list request') } function scopeIsEmpty(data, keys = knowledgeKeys) { return keys.every(key => data[key] == null || Object.keys(data[key]).length === 0) } function manuscript(task) { return { ...task.data, segments: [ { id: 'accepted', title: 'Accepted fixture', agent_id: agent.id, status: 'accepted', content: 'Keep this accepted fixture prose.' }, { id: 'skipped', title: 'Skipped fixture', agent_id: agent.id, status: 'skipped', content: 'Keep this skipped fixture prose.' }, ], review: 'Keep the fixture review.', composed: 'Keep the fixture final manuscript.', legacy_history: { fixture: prefix, opaque: { retained: true } }, custom_fixture_field: ['preserve', 123] } } async function checkDetached(before, actor) { const after = await read(before) assert.deepEqual(after.data, { ...before.data, flow_id: '' }, 'Detachment must preserve all other fields, including prose and accepted states') assert.equal(after.owner_id, before.owner_id) assert.equal(after.version, before.version + 1) assert.equal(after.status, 'needs_configuration') const revisions = JSON.parse(sql(`SELECT coalesce(json_agg(r ORDER BY version),'[]') FROM workbench_revisions r WHERE tenant_id=${tenant} AND resource_id=${literal(before.id)} AND version IN (${before.version},${after.version});`)) const next = revisions.find(row => row.version === after.version) assert.ok(next && next.actor_id === actor.user.id, 'Detachment revision must record the actual actor') assert.deepEqual(next.data, after.data) return after } async function browserPage(identity, resource) { const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } }) await context.addInitScript(({ auth, tenantID }) => { for (const [key, value] of Object.entries({ weknora_user: JSON.stringify({ ...auth.user, tenant_id: String(auth.user.tenant_id) }), weknora_tenant: JSON.stringify({ ...auth.active_tenant, id: String(tenantID) }), weknora_token: auth.token, weknora_refresh_token: auth.refresh_token, weknora_memberships: JSON.stringify(auth.memberships), 'weknora:new-user-guide-done:v1': '1', })) localStorage.setItem(key, value) }, { auth: identity, tenantID: tenant }) const page = await context.newPage() page.setDefaultTimeout(20000) page.on('pageerror', error => failures.push(`browser: ${error.message}`)) await page.route('**/api/v1/workbench/resources/**', async route => { const request = route.request() if (!['GET', 'HEAD'].includes(request.method())) { const pathname = new URL(request.url()).pathname const allowed = resources.some(row => pathname === '/api/v1' + resourcePath(row)) if (!allowed) { failures.push(`blocked browser mutation outside recorded fixtures: ${pathname}`) return route.abort() } } return route.continue() }) await page.goto(frontend + '/platform/workbench/flows') const row = page.locator('.workbench-resource').filter({ hasText: resource.name }) await row.waitFor() await row.click() await page.getByRole('button', { name: '\u5220\u9664\u753b\u5e03', exact: true }).waitFor() return { page, context } } async function deleteInBrowser(page, resource) { const [response] = await Promise.all([ page.waitForResponse(value => value.request().method() === 'DELETE' && new URL(value.url()).pathname === '/api/v1' + resourcePath(resource)), (async () => { await page.getByRole('button', { name: '\u5220\u9664\u753b\u5e03', exact: true }).click() await page.locator('.t-popconfirm:visible').getByRole('button', { name: /^(\u786e\u5b9a|\u786e\u8ba4)$/ }).click() })(), ]) assert.equal(response.status(), 200) const row = page.locator('.workbench-resource').filter({ hasText: resource.name }) await row.waitFor({ state: 'hidden' }) const [reloaded] = await Promise.all([ page.waitForResponse(value => value.request().method() === 'GET' && new URL(value.url()).pathname === '/api/v1/workbench/resources/flows'), page.reload(), ]) assert.equal(reloaded.status(), 200) await page.getByRole('heading', { name: '\u753b\u5e03\u7f16\u6392', exact: true }).waitFor() await page.waitForLoadState('networkidle', { timeout: 30000 }) assert.equal(await row.count(), 0) } async function main() { const credentials = JSON.parse(fs.readFileSync(path.join(directory, 'super-admin-login.json'), 'utf8')) admin = await api('/auth/login', 'POST', { email: credentials.email, password: credentials.password }, null) check('same_tenant_administrator_available', (await api('/access/me')).data.administrator === true) role = (await api('/access/roles', 'POST', { name: prefix, permissions: [ 'flows.read', 'flows.write', 'content.read', 'content.write', 'agents.read', 'agents.write', 'knowledge.read', 'knowledge.write', ] })).data check('api_uses_expected_fixture_database', sql(`SELECT count(*) FROM workbench_access_roles WHERE tenant_id=${tenant} AND id=${literal(role.id)} AND name=${literal(prefix)}`) === '1') databaseVerified = true const password = crypto.randomBytes(18).toString('base64url') const email = `${prefix}@example.test` user = (await api('/access/users', 'POST', { username: prefix, email, password, role: 'contributor', status: 'active', custom_role_id: role.id })).data member = await api('/auth/login', 'POST', { email, password }, null) check('fixture_member_is_not_an_administrator', (await api('/access/me', 'GET', undefined, member)).data.administrator === false) const source = (await api('/agents')).data.find(row => row.config?.model_id) assert.ok(source, 'An existing configured model ID is needed for the Agent selector; it will not be invoked') kb = (await api('/knowledge-bases', 'POST', { name: prefix + '-knowledge', type: 'document', indexing_strategy: { vector_enabled: false, keyword_enabled: false, wiki_enabled: false, graph_enabled: false }, }, member, [200, 201])).data agent = (await api('/agents', 'POST', { name: prefix + '-agent', config: { role: 'production', agent_mode: 'quick-answer', model_id: source.config.model_id, kb_selection_mode: 'selected', knowledge_bases: [kb.id], system_prompt: 'Disposable canvas fixture. No generation requested.', fusion_agent_ids: [], instruction_skill_ids: [], } }, member, 201)).data const staleKB = crypto.randomUUID() const stale = { knowledge_base_ids: [staleKB], knowledge_ids: [crypto.randomUUID()], knowledge_folders: { [staleKB]: ['removed-folder'] }, knowledge_tags: { [staleKB]: [crypto.randomUUID()] } } const flowData = { nodes: [{ id: 'writer', label: 'Fixture writer', agent_id: agent.id }], edges: [], ...stale } const own = await create('flows', 'own', flowData) check('creating_canvas_removes_all_four_legacy_knowledge_fields', knowledgeKeys.every(key => !Object.hasOwn(own.data, key))) seed(own, { ...own.data, ...stale }) const historicalScope = (await read(own, member)).data assert.deepEqual(Object.fromEntries(knowledgeKeys.map(key => [key, historicalScope[key]])), stale) let ownTask = await create('content-tasks', 'own-task', { source_text: 'Disposable outline', flow_id: own.id, ...stale }) assert.deepEqual(ownTask.data.knowledge_base_ids, [kb.id]) assert.ok(scopeIsEmpty(ownTask.data, knowledgeKeys.slice(1))) assert.ok(ownTask.data.flow_snapshot && scopeIsEmpty(ownTask.data.flow_snapshot)) assert.equal(ownTask.data.agent_id, agent.id) check('stale_canvas_and_task_scopes_are_replaced_by_node_agent_scope', true) seed(ownTask, manuscript(ownTask)) ownTask = await read(ownTask, member) await api(resourcePath(own), 'DELETE', undefined, member, 409) if (browserEnabled) { const { chromium } = require('/Users/macclaw/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright') browser = await chromium.launch({ headless: true, channel: 'chrome' }) const { page, context } = await browserPage(member, own) check('canvas_has_no_knowledge_selector', await page.locator('.knowledge-scope').count() === 0 && !/\u753b\u5e03\u77e5\u8bc6\u8303\u56f4/.test(await page.locator('.workbench-detail').innerText())) const [saved] = await Promise.all([ page.waitForResponse(value => value.request().method() === 'PUT' && new URL(value.url()).pathname === '/api/v1' + resourcePath(own)), page.getByRole('button', { name: '\u4fdd\u5b58', exact: true }).click(), ]) assert.equal(saved.status(), 200) const savedData = (await read(own, member)).data check('saving_canvas_removes_all_four_legacy_knowledge_fields', knowledgeKeys.every(key => !Object.hasOwn(savedData, key))) await page.setViewportSize({ width: 390, height: 844 }) await page.screenshot({ path: path.join(directory, `${reportName}-mobile.png`), animations: 'disabled' }) check('canvas_mobile_has_no_horizontal_overflow', await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 2)) await page.setViewportSize({ width: 1440, height: 1000 }) await deleteInBrowser(page, own) check('owner_delete_survives_browser_reload', true) await context.close() } else { await api(resourcePath(own) + '?detach=true', 'DELETE', undefined, member) } await absent(own, member) await checkDetached(ownTask, member) check('owner_delete_detaches_safely_and_does_not_reappear', true) const old = await create('flows', 'historical', flowData) let oldTask = await create('content-tasks', 'historical-task', { source_text: 'Disposable historical outline', flow_id: old.id }) const blocker = await create('content-tasks', 'other-owner-reference', { source_text: 'Disposable blocker', flow_id: old.id }) seed(blocker, blocker.data, admin.user.id) const blockerBefore = await read(blocker) assert.equal(blockerBefore.owner_id, admin.user.id) const orphanOwner = crypto.randomUUID() assert.equal(sql(`SELECT count(*) FROM users WHERE id=${literal(orphanOwner)}`), '0') seed(old, { ...old.data, legacy_import: { read_only: true, fixture: prefix } }, orphanOwner, 'needs_configuration') seed(oldTask, manuscript(oldTask), orphanOwner, 'completed') const historical = await read(old) oldTask = await read(oldTask) await api(resourcePath(old), 'PUT', { name: old.name + '-forbidden', data: historical.data, version: historical.version }, admin, [403, 404]) await api(resourcePath(old), 'DELETE', undefined, member, [403, 404]) await api(resourcePath(oldTask), 'DELETE', undefined, admin, [403, 404]) assert.deepEqual(await read(old), historical) check('administrator_delete_permission_does_not_allow_editing_or_task_deletion', true) await api(resourcePath(old) + '?detach=true', 'DELETE', undefined, admin, 409) assert.deepEqual(await read(oldTask), oldTask) assert.deepEqual(await read(blocker), blockerBefore) check('another_owners_reference_blocks_deletion_without_changing_tasks', true) await api(resourcePath(blocker), 'DELETE') await api(resourcePath(old), 'DELETE', undefined, admin, 409) if (browserEnabled) { const { page, context } = await browserPage(admin, old) check('administrator_sees_delete_but_not_save_for_historical_canvas', await page.getByRole('button', { name: '\u5220\u9664\u753b\u5e03', exact: true }).isEnabled() && await page.getByRole('button', { name: '\u4fdd\u5b58', exact: true }).count() === 0) await page.screenshot({ path: path.join(directory, `${reportName}-admin.png`), animations: 'disabled' }) await deleteInBrowser(page, old) await context.close() } else { await api(resourcePath(old) + '?detach=true', 'DELETE') } await absent(old) await checkDetached(oldTask, admin) assert.equal(sql(`SELECT count(*) FROM workbench_events WHERE tenant_id=${tenant} AND resource_id=${literal(old.id)} AND action='delete' AND owner_id=${literal(admin.user.id)}`), '1') check('administrator_deletes_orphaned_canvas_preserving_manuscript_and_audit_actor', true) foreignTenant = Number(sql(`INSERT INTO tenants(name,business,status) VALUES (${literal(prefix)},'canvas-fixture','active') RETURNING id;`)) assert.ok(Number.isSafeInteger(foreignTenant) && foreignTenant > 0 && foreignTenant !== tenant) const foreign = { id: crypto.randomUUID(), tenant_id: foreignTenant, owner_id: admin.user.id, kind: 'flows', name: prefix + '-foreign' } resources.push(foreign) sql(`INSERT INTO workbench_resources(id,tenant_id,owner_id,owner_name,kind,name,status,version,data,created_at,updated_at) VALUES(${literal(foreign.id)},${foreignTenant},${literal(admin.user.id)},${literal(prefix)},'flows',${literal(foreign.name)},'draft',1,'{}',now(),now());`) await api(resourcePath(foreign), 'GET', undefined, admin, 404) await api(resourcePath(foreign) + '?detach=true', 'DELETE', undefined, admin, 404) check('administrator_cannot_delete_same_owner_canvas_in_another_tenant', sql(`SELECT count(*) FROM workbench_resources WHERE ${fixtureWhere(foreign)}`) === '1') check('no_generation_jobs_or_model_usage', sql(`SELECT (SELECT count(*) FROM workbench_jobs WHERE resource_id IN (${resources.map(row => literal(row.id)).join(',')})) + (SELECT count(*) FROM workbench_usage WHERE tenant_id=${tenant} AND agent_id=${literal(agent.id)})`) === '0') } async function cleanup() { async function attempt(label, action) { try { await action() } catch (error) { failures.push(`cleanup ${label}: ${error.message}`) } } await attempt('browser', async () => { await browser?.close() }) if (databaseVerified) { for (const resource of [...resources].reverse()) await attempt(resource.id, async () => { assert.equal(sql(`SELECT count(*) FROM workbench_jobs WHERE resource_id=${literal(resource.id)} AND status IN ('queued','running')`), '0') sql(`BEGIN; DELETE FROM workbench_events WHERE tenant_id=${Number(resource.tenant_id)} AND resource_id=${literal(resource.id)}; DELETE FROM workbench_revisions WHERE tenant_id=${Number(resource.tenant_id)} AND resource_id=${literal(resource.id)}; DELETE FROM workbench_jobs WHERE tenant_id=${Number(resource.tenant_id)} AND resource_id=${literal(resource.id)}; DELETE FROM workbench_resources WHERE ${fixtureWhere(resource)}; COMMIT;`) }) if (foreignTenant) await attempt('foreign tenant', async () => { assert.equal(sql(`SELECT count(*) FROM workbench_resources WHERE tenant_id=${foreignTenant}`), '0') sql(`DELETE FROM tenants WHERE id=${foreignTenant} AND name=${literal(prefix)} AND business='canvas-fixture';`) }) } if (agent) await attempt('agent', () => api(`/agents/${agent.id}?detach=true`, 'DELETE', undefined, member, [200, 404])) if (kb) await attempt('knowledge', () => api(`/knowledge-bases/${kb.id}`, 'DELETE', undefined, member, [200, 202, 404])) if (databaseVerified) await attempt('fixture publications', async () => { const ids = [...resources.map(row => row.id), agent?.id, kb?.id].filter(Boolean) if (ids.length) for (const table of ['workbench_access_grants', 'workbench_access_publications']) { sql(`DELETE FROM ${table} WHERE tenant_id=${tenant} AND resource_id IN (${ids.map(literal).join(',')});`) } }) if (user) await attempt('user', () => api(`/access/users/${user.id}`, 'DELETE', undefined, admin, [200, 404])) if (role) await attempt('role', () => api(`/access/roles/${role.id}`, 'DELETE', undefined, admin, [200, 404])) if (databaseVerified && resources.length) await attempt('verify resources removed', async () => { check('all_recorded_canvas_and_task_fixtures_removed', sql(`SELECT count(*) FROM workbench_resources WHERE id IN (${resources.map(row => literal(row.id)).join(',')})`) === '0') }) } main().catch(error => { failures.push(error.message) }).finally(async () => { await cleanup() const report = { checked_at: new Date().toISOString(), origin, frontend, prefix, browser_enabled: browserEnabled, checks, failures, passed: failures.length === 0, fixtures: { role_id: role?.id, user_id: user?.id, agent_id: agent?.id, knowledge_base_id: kb?.id, foreign_tenant: foreignTenant, resources: resources.map(({ id, kind, tenant_id }) => ({ id, kind, tenant_id })) } } fs.writeFileSync(path.join(directory, `${reportName}.json`), JSON.stringify(report, null, 2), { mode: 0o600 }) console.log(JSON.stringify(report, null, 2)) if (failures.length) process.exitCode = 1 })