Files

186 lines
18 KiB
JavaScript
Raw Permalink Normal View History

const assert = require('node:assert/strict')
const fs = require('node:fs')
const crypto = require('node:crypto')
const { execFileSync } = require('node:child_process')
const { chromium } = require('/Users/macclaw/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright')
const directory = '/Volumes/Extreme SSD/yawan-storage/weknora/acceptance'
const base = 'http://127.0.0.1:18080/api/v1'
const prefix = `skill-version-check-${Date.now()}`
const checks = [], failures = [], users = [], roles = [], agents = [], skillIDs = new Set()
let admin, browser, browserPage
const literal = value => `'${String(value).replaceAll("'", "''")}'`
const sql = statement => execFileSync('docker', ['--context', 'colima-yawan', 'exec', 'WeKnora-postgres', 'psql', '-U', 'postgres', '-d', 'yawan_native_acceptance_20260915', '-v', 'ON_ERROR_STOP=1', '-Atc', statement], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim()
const check = (name, value) => { checks.push({ name, passed: !!value }); assert.ok(value, name); console.log(name) }
async function api(route, method = 'GET', body, identity = admin, expected = 200) {
const form = body instanceof FormData
const response = await fetch(base + route, { method, headers: { ...(form ? {} : { 'Content-Type': 'application/json' }), ...(identity ? { Authorization: `Bearer ${identity.token}`, 'X-Tenant-ID': '10059' } : {}) }, body: body === undefined ? undefined : form ? body : JSON.stringify(body), signal: AbortSignal.timeout(30000) })
const raw = await response.text()
let result
try { result = JSON.parse(raw) } catch { result = { message: raw.slice(0, 160) } }
assert.ok((Array.isArray(expected) ? expected : [expected]).includes(response.status), `${method} ${route}: ${response.status} ${result.message || result.error?.message || ''}`)
return result
}
async function main() {
const credentials = JSON.parse(fs.readFileSync(`${directory}/super-admin-login.json`, 'utf8'))
admin = await api('/auth/login', 'POST', { email: credentials.email, password: credentials.password }, null)
const role = (await api('/access/roles', 'POST', { name: prefix, permissions: ['agents.read', 'agents.write'] })).data
roles.push(role.id)
async function member(label) {
const password = crypto.randomBytes(18).toString('base64url'), email = `${prefix}-${label}@example.test`
const user = (await api('/access/users', 'POST', { username: `${prefix}-${label}`, email, password, role: 'contributor', status: 'active', custom_role_id: role.id })).data
users.push(user.id)
return api('/auth/login', 'POST', { email, password }, null)
}
const alice = await member('alice'), bob = await member('bob')
const name = `${prefix}-writing`
async function importMD(instructions) {
const form = new FormData()
form.set('file', new Blob([instructions], { type: 'text/markdown' }), `${prefix}.md`)
form.set('name', name)
form.set('description', 'Disposable skill lifecycle acceptance')
const skill = (await api('/skills/instructions/import', 'POST', form, alice, 201)).data
skillIDs.add(skill.id)
return skill
}
const original = await importMD('# Writing method\n\nInitial rule: use concrete examples.')
check('markdown_import_is_private_and_owned', original.instruction_only && original.visibility === 'private' && original.created_by === alice.user.id && !original.version_source_id)
await api('/access/grants', 'POST', { resources: [{ kind: 'skills', id: original.id }], user_ids: [], action: 'publish' })
const getSkill = async (id, identity = alice) => (await api(`/skills/instructions/${id}`, 'GET', undefined, identity)).data
check('published_original_readable_by_other_user', (await getSkill(original.id, bob)).id === original.id)
check('published_skill_displays_global_visibility', (await getSkill(original.id, bob)).visibility === 'global')
const fork = (await api(`/resource-versions/skills/${original.id}/fork`, 'POST', {}, alice)).data
skillIDs.add(fork.personal_id)
const persistedFork = (await api(`/resource-versions/forks/${fork.id}`, 'GET', undefined, alice)).data.fork
const personal = await getSkill(fork.personal_id)
check('same_owner_same_name_personal_copy_has_distinct_id', personal.id !== original.id && personal.name === original.name && personal.created_by === original.created_by && personal.version_source_id === original.id && personal.visibility === 'private' && fork.no_export)
await api(`/skills/instructions/${personal.id}`, 'GET', undefined, bob, 404)
await api(`/resource-versions/forks/${fork.id}`, 'GET', undefined, bob, 404)
check('personal_skill_and_version_are_user_isolated', true)
const reimported = await importMD('# Writing method\n\nReimported original rule: explain cause before result.')
check('same_name_reimport_updates_original_only', reimported.id === original.id && (await getSkill(personal.id)).instructions === personal.instructions && (await getSkill(original.id)).instructions === reimported.instructions)
const sameName = (await api('/skills/instructions', 'GET', undefined, alice)).data.filter(skill => skill.name === name)
check('same_name_original_and_personal_both_listed', sameName.length === 2 && sameName.some(skill => skill.id === original.id) && sameName.some(skill => skill.id === personal.id))
await api(`/resource-versions/forks/${fork.id}/rebase`, 'POST', {}, alice)
let version = (await api(`/resource-versions/forks/${fork.id}`, 'GET', undefined, alice)).data
check('rebase_updates_baseline_without_overwriting_personal', version.fork.base_snapshot.instructions === reimported.instructions && version.snapshot.instructions === personal.instructions)
const submittedText = '# Writing method\n\nSubmitted rule: connect every conclusion to an explicit premise.'
const laterText = '# Writing method\n\nLater personal rule: this must never appear in the approved global snapshot.'
async function edit(instructions) {
version = (await api(`/resource-versions/forks/${fork.id}`, 'GET', undefined, alice)).data
await api(`/resource-versions/forks/${fork.id}`, 'PUT', { fingerprint: version.fingerprint, snapshot: { ...version.snapshot, instructions } }, alice)
}
await edit(submittedText)
await api(`/resource-versions/forks/${fork.id}`, 'PUT', { fingerprint: version.fingerprint, snapshot: { ...version.snapshot, instructions: 'stale overwrite' } }, alice, 409)
check('stale_personal_edit_is_rejected', (await getSkill(personal.id)).instructions === submittedText)
const submission = (await api(`/resource-versions/forks/${fork.id}/submit`, 'POST', { note: 'Disposable immutable snapshot acceptance' }, alice)).data
await edit(laterText)
const beforeReview = (await api('/resource-versions', 'GET', undefined, alice)).data.submissions.find(row => row.id === submission.id)
check('submission_freezes_content_before_later_edit', beforeReview.proposed_snapshot.instructions === submittedText && (await getSkill(personal.id)).instructions === laterText && (await getSkill(original.id)).instructions === reimported.instructions)
await api(`/resource-versions/submissions/${submission.id}/review`, 'POST', { approve: true, note: 'Must require administrator' }, bob, 403)
check('ordinary_user_cannot_approve', true)
await api(`/resource-versions/submissions/${submission.id}/review`, 'POST', { approve: true, note: 'Disposable skill snapshot approved' })
check('approval_publishes_frozen_snapshot_and_preserves_later_personal_edit', (await getSkill(original.id)).instructions === submittedText && (await getSkill(personal.id)).instructions === laterText)
const approved = (await api('/resource-versions', 'GET', undefined, alice)).data.submissions.find(row => row.id === submission.id)
check('approved_submission_keeps_original_personal_id_and_snapshot', approved.status === 'approved' && approved.personal_id === personal.id && approved.proposed_snapshot.instructions === submittedText)
// A disposable migrated-history row verifies native deletion cannot erase provenance.
const historyID = crypto.randomUUID()
const historyBody = JSON.stringify({ name, instructions: 'Archived migrated fixture rule' })
sql(`INSERT INTO workbench_resource_version_history(id,tenant_id,owner_id,kind,source_id,name,status,proposed_snapshot,review_note,created_at,reviewed_at,source_system,source_table,legacy_id,source_snapshot,source_sha256) VALUES (${literal(historyID)},10059,${literal(alice.user.id)},'skills',${literal(original.id)},${literal(name)},'approved',${literal(historyBody)}::jsonb,'Disposable imported history',now()-interval '1 day',now()-interval '1 day','acceptance-fixture',${literal(prefix)},1,${literal(historyBody)}::jsonb,${literal(crypto.createHash('sha256').update(historyBody).digest('hex'))})`)
const history = (await api('/resource-versions', 'GET', undefined, alice)).data.history.find(row => row.id === historyID)
check('historical_submission_is_visible_before_delete', history?.proposed_snapshot.instructions === 'Archived migrated fixture rule')
const agent = (await api('/agents', 'POST', { name: `${prefix}-agent`, config: { role: 'production', agent_mode: 'quick-answer', kb_selection_mode: 'none', knowledge_bases: [], fusion_agent_ids: [], instruction_skill_ids: [], system_prompt: 'Use the selected writing methods.' } }, alice, 201)).data
agents.push(agent.id)
await api('/skills/instructions/mount', 'POST', { agent_ids: [agent.id], skill_ids: [original.id], mode: 'add' }, alice)
browser = await chromium.launch({ headless: true, channel: 'chrome' })
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
await context.addInitScript(auth => {
const entries = { weknora_user: JSON.stringify({ ...auth.user, tenant_id: String(auth.user.tenant_id) }), weknora_tenant: JSON.stringify({ ...auth.active_tenant, id: '10059' }), weknora_token: auth.token, weknora_refresh_token: auth.refresh_token, weknora_memberships: JSON.stringify(auth.memberships), 'weknora:new-user-guide-done:v1': '1' }
for (const [key, value] of Object.entries(entries)) localStorage.setItem(key, value)
}, alice)
const page = await context.newPage(), errors = []
browserPage = page
page.on('pageerror', error => errors.push(error.message))
await page.goto('http://127.0.0.1:15173/platform/agents?tab=skills')
await page.getByPlaceholder('搜索技能名称或说明', { exact: true }).fill(name)
const personalRow = page.locator('.instruction-row').filter({ hasText: '我的个人版本' }).filter({ has: page.getByRole('button', { name, exact: true }) })
await personalRow.waitFor()
check('browser_distinguishes_global_original_and_private_copy', await page.locator('.instruction-row').filter({ hasText: '全局技能' }).count() === 1 && await personalRow.getByText('私有技能', { exact: true }).isVisible())
check('personal_row_has_accessible_delete_control', await personalRow.getByRole('button', { name: `删除 ${name}`, exact: true }).isVisible())
await personalRow.getByRole('button', { name: '挂载', exact: true }).click()
const dialog = page.locator('.t-dialog:visible')
await dialog.getByPlaceholder('搜索可管理的 Agent', { exact: true }).fill(agent.name)
await dialog.locator('.mount-agent').filter({ hasText: agent.name }).click()
await dialog.getByRole('button', { name: '确认挂载', exact: true }).click()
await dialog.waitFor({ state: 'hidden' })
const getAgent = async () => (await api(`/agents/${agent.id}`, 'GET', undefined, alice)).data
check('browser_mount_keeps_original_and_adds_personal', (await getAgent()).config.instruction_skill_ids.includes(original.id) && (await getAgent()).config.instruction_skill_ids.includes(personal.id))
await api(`/skills/instructions/${personal.id}`, 'DELETE', undefined, alice, 409)
check('delete_without_detach_rejects_without_changing_bindings', (await getAgent()).config.instruction_skill_ids.includes(personal.id))
await page.screenshot({ path: `${directory}/skill-versions-desktop.png`, animations: 'disabled' })
await personalRow.getByRole('button', { name: `删除 ${name}`, exact: true }).click()
await dialog.getByText('这是你的个人版本,原全局技能保持不变。', { exact: true }).waitFor()
await page.screenshot({ path: `${directory}/skill-versions-delete-desktop.png`, animations: 'disabled' })
await page.setViewportSize({ width: 390, height: 844 })
await page.waitForFunction(() => {
const element = [...document.querySelectorAll('.t-dialog')].find(item => item.getBoundingClientRect().width > 0)
if (!element) return false
const rect = element.getBoundingClientRect()
return rect.left >= 0 && rect.right <= innerWidth + 2 && rect.top >= 0 && rect.bottom <= innerHeight + 2
})
check('mobile_delete_dialog_fits_viewport', await dialog.evaluate(element => { const rect = element.getBoundingClientRect(); return rect.left >= 0 && rect.right <= innerWidth + 2 && rect.top >= 0 && rect.bottom <= innerHeight + 2 }))
await page.screenshot({ path: `${directory}/skill-versions-delete-mobile.png`, animations: 'disabled' })
await dialog.getByRole('button', { name: '取消', exact: true }).click()
await dialog.waitFor({ state: 'hidden' })
check('mobile_skills_page_has_no_document_overflow', await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 2))
await page.screenshot({ path: `${directory}/skill-versions-mobile.png`, animations: 'disabled' })
await personalRow.getByRole('button', { name: `删除 ${name}`, exact: true }).click()
const deletion = page.waitForResponse(response => response.url().endsWith(`/skills/instructions/${personal.id}?detach=true`) && response.request().method() === 'DELETE')
await dialog.getByRole('button', { name: '解绑并删除', exact: true }).click()
check('browser_delete_calls_atomic_detach_endpoint', (await deletion).status() === 200)
await personalRow.waitFor({ state: 'detached' })
await api(`/skills/instructions/${personal.id}`, 'GET', undefined, alice, 404)
check('deleted_personal_disappears_while_global_and_binding_survive', (await getSkill(original.id)).instructions === submittedText && JSON.stringify((await getAgent()).config.instruction_skill_ids) === JSON.stringify([original.id]))
check('browser_has_no_runtime_errors', errors.length === 0)
const recreated = (await api(`/resource-versions/skills/${original.id}/fork`, 'POST', {}, alice)).data
skillIDs.add(recreated.personal_id)
check('recreate_gets_fresh_personal_id_same_ledger_and_creation_time', recreated.personal_id !== personal.id && recreated.id === persistedFork.id && recreated.created_at === persistedFork.created_at && recreated.no_export === true)
const recreatedSkill = await getSkill(recreated.personal_id)
check('recreated_personal_copies_current_global_version', recreatedSkill.instructions === submittedText && recreatedSkill.version_source_id === original.id && recreatedSkill.name === name)
const repeated = (await api(`/resource-versions/skills/${original.id}/fork`, 'POST', {}, alice)).data
check('repeated_fork_is_idempotent', repeated.id === recreated.id && repeated.personal_id === recreated.personal_id)
const finalVersions = (await api('/resource-versions', 'GET', undefined, alice)).data
const savedApproval = finalVersions.submissions.find(row => row.id === approved.id)
const savedHistory = finalVersions.history.find(row => row.id === historyID)
check('deletion_and_recreation_preserve_approved_submission_history', JSON.stringify(savedApproval) === JSON.stringify(approved) && JSON.stringify(savedHistory) === JSON.stringify(history))
}
main().catch(error => { failures.push(error.message); process.exitCode = 1 }).finally(async () => {
if (failures.length && browserPage) {
try {
await browserPage.screenshot({ path: `${directory}/skill-versions-failure.png`, animations: 'disabled' })
console.log(JSON.stringify({ failure_url: browserPage.url(), skills_panels: await browserPage.locator('.instruction-skills').count() }))
} catch (error) { failures.push(`failure screenshot: ${error.message}`) }
}
await browser?.close()
if (admin) {
for (const id of agents) try { await api(`/agents/${id}?detach=true`, 'DELETE', undefined, admin, [200, 404]) } catch (error) { failures.push(`cleanup agent: ${error.message}`) }
for (const id of [...skillIDs].reverse()) try { await api(`/skills/instructions/${id}?detach=true`, 'DELETE', undefined, admin, [200, 404]) } catch (error) { failures.push(`cleanup skill: ${error.message}`) }
const ids = [...skillIDs].map(literal).join(',') || "''", owners = users.map(literal).join(',') || "''", agentIDs = agents.map(literal).join(',') || "''"
try {
sql(`DELETE FROM workbench_resource_submissions WHERE tenant_id=10059 AND owner_id IN (${owners}) AND kind='skills'; DELETE FROM workbench_resource_forks WHERE tenant_id=10059 AND owner_id IN (${owners}) AND kind='skills'; DELETE FROM workbench_resource_version_history WHERE tenant_id=10059 AND owner_id IN (${owners}) AND kind='skills'; DELETE FROM workbench_resource_hidden WHERE tenant_id=10059 AND owner_id IN (${owners}); DELETE FROM workbench_access_grants WHERE tenant_id=10059 AND resource_id IN (${ids},${agentIDs}); DELETE FROM workbench_access_publications WHERE tenant_id=10059 AND resource_id IN (${ids},${agentIDs}); DELETE FROM tenant_skill_catalog WHERE tenant_id=10059 AND id IN (${ids}); DELETE FROM custom_agents WHERE tenant_id=10059 AND id IN (${agentIDs})`)
} catch (error) { failures.push(`cleanup fixture rows: ${error.message}`) }
for (const id of users) try { await api(`/access/users/${id}`, 'DELETE') } catch (error) { failures.push(`cleanup user: ${error.message}`) }
for (const id of roles) try { await api(`/access/roles/${id}`, 'DELETE') } catch (error) { failures.push(`cleanup role: ${error.message}`) }
try { check('fixture_skills_and_agents_cleaned', sql(`SELECT (SELECT count(*) FROM tenant_skill_catalog WHERE tenant_id=10059 AND name LIKE ${literal(prefix + '%')}) + (SELECT count(*) FROM custom_agents WHERE tenant_id=10059 AND name LIKE ${literal(prefix + '%')})`) === '0') } catch (error) { failures.push(error.message) }
}
const report = { time: new Date().toISOString(), prefix, fixtures: { users, roles, agents, skill_ids: [...skillIDs] }, checks, failures, passed: failures.length === 0 }
fs.writeFileSync(`${directory}/skill-versions-live.json`, JSON.stringify(report, null, 2))
console.log(JSON.stringify(report, null, 2))
if (failures.length) process.exitCode = 1
})