const assert = require('node:assert/strict') const http = require('node:http') const { execFileSync } = require('node:child_process') const { readFileSync, writeFileSync } = require('node:fs') const path = require('node:path') const crypto = require('node:crypto') const base = 'http://127.0.0.1:18080/api/v1' const directory = path.resolve(__dirname, '../../acceptance') const env = Object.fromEntries(readFileSync(path.join(directory, 'runtime.env'), 'utf8').split('\n').filter(Boolean).map(line => { const i = line.indexOf('='); return [line.slice(0,i),line.slice(i+1)] })) assert.equal(env.DB_NAME, 'yawan_native_acceptance_20260915', 'Tests require the dedicated disposable database') const sql = text => execFileSync('docker', ['--context','colima-yawan','exec','WeKnora-postgres','psql','-U',env.DB_USER,'-d',env.DB_NAME,'-v','ON_ERROR_STOP=1','-Atc',text], { encoding:'utf8' }).trim() const checks = [], requests = [] let authorID = '' async function request(route, token, body, method, status) { const response = await fetch(base + route,{method: method || (body ? 'POST' : 'GET'),headers:{'Content-Type':'application/json',...(token ? {Authorization:`Bearer ${token}`} : {})},body:body ? JSON.stringify(body):undefined,signal:AbortSignal.timeout(15000)}) const value = await response.json() assert.equal(response.status, status || (body ? 200 : 200), `${route}: ${JSON.stringify(value)}`) return value } async function account(name) { const email = `acceptance-${name}-${Date.now()}@example.test`, password = `Test!${crypto.randomBytes(12).toString('hex')}` await request('/auth/register', null, {username:`验收${name}${crypto.randomBytes(4).toString('hex')}`,email,password},'POST',201) return request('/auth/login',null,{email,password}) } async function act(resource, action, args = {}) { const job = (await request(`/workbench/resources/${resource.kind}/${resource.id}/actions/${action}`,owner.token,{version:resource.version,...args},'POST',202)).data const started=Date.now() let result do { await new Promise(resolve=>setTimeout(resolve,300)) result=(await request(`/workbench/jobs/${job.id}`,owner.token)).data } while (['queued','running'].includes(result.status) && Date.now()-started<30000) assert.equal(result.status,'succeeded',`${action}: ${result.error}`) return (await request(`/workbench/resources/${resource.kind}/${resource.id}`,owner.token)).data } let owner const server = http.createServer(async (req,res)=>{ let input=''; for await (const chunk of req) input+=chunk const body=JSON.parse(input), system=body.messages[0].content, prompt=body.messages.at(-1).content requests.push({system,prompt}) let output if(system.includes('数字人表达档案蒸馏引擎')) { const fields=JSON.parse(system.slice(system.indexOf('{'),system.indexOf('}')+1)) const data=JSON.parse(prompt) output=JSON.stringify(Object.fromEntries(Object.keys(fields).map(key=>[key, `${data.previous_draft?.[key] || ''}材料依据:${data.sources.map(source=>source.Text || source.text).join(';')}`]))) await new Promise(resolve=>setTimeout(resolve,2500)) } else if(system.includes('课程架构师')) { output=JSON.stringify({days:[{day:1,title:'基础判断',hook:'如何进入实践',prerequisite:'',nodes:[{id:'n1',label:'建立概念'}]},{day:2,title:'实际应用',prerequisite:'掌握第一天的概念',hook:'总结复盘',nodes:[{id:'n2',label:'案例迁移'}]}]}) if(prompt.includes('只修改第 2 天')) output=JSON.stringify({days:[JSON.parse(output).days[1]]}) if(system.includes('Markdown 节点框架')) output=JSON.stringify({framework:'- 核心概念\n - 判断依据\n - 应用场景'}) } else if(system.includes('内容编排师')) { output=JSON.stringify({segments:[{id:'s1',title:'建立概念',agent_id:authorID},{id:'s2',title:'可选案例',agent_id:authorID},{id:'s3',title:'迁移练习',agent_id:authorID}]}) } else if(system.includes('跨稿质检员')) output=JSON.stringify({passed:true,summary:'衔接一致,无重复讲授。',issues:[]}) else if(system.includes('成稿编辑')) output='确认的基础内容。承接基础后的迁移练习。' else output=prompt.includes('可选案例') && prompt.includes('当前内容段:可选案例') ? 'SKIPPED_SENTINEL 不应进入后续上下文。' : '采用正文:先建立判断依据,再进入实际应用。' res.writeHead(200,{'Content-Type':'application/json'}) res.end(JSON.stringify({id:'acceptance',object:'chat.completion',created:Math.floor(Date.now()/1000),model:'acceptance-model',choices:[{index:0,message:{role:'assistant',content:output},finish_reason:'stop'}],usage:{prompt_tokens:20,completion_tokens:20,total_tokens:40}})) }) async function main() { await new Promise(resolve=>server.listen(18081,'0.0.0.0',resolve)) let ready=false for(let attempt=0;attempt<40;attempt++) { try { ready=(await fetch('http://127.0.0.1:18080/health',{signal:AbortSignal.timeout(1000)})).ok } catch {} if(ready) break await new Promise(resolve=>setTimeout(resolve,500)) } assert.ok(ready,'Acceptance backend did not become healthy') owner=await account('主用户') const outsider=await account('隔离用户') const contributor=await account('协作者') const viewer=await account('只读用户') const tenant=owner.active_tenant.id for(const [login,role] of [[contributor,'contributor'],[viewer,'viewer']]) { assert.match(login.user.id,/^[a-f\d-]+$/i) sql(`INSERT INTO tenant_members(user_id,tenant_id,role,status,created_at,updated_at) VALUES ('${login.user.id}',${Number(tenant)},'${role}','active',now(),now())`) Object.assign(login,await request('/auth/switch-tenant',login.token,{tenant_id:tenant})) } checks.push('真实注册、登录及切换空间') const model=(await request('/models',owner.token,{name:'acceptance-model',type:'KnowledgeQA',source:'remote',parameters:{base_url:'http://host.docker.internal:18081/v1',api_key:'acceptance-only',provider:'generic'}},'POST',201)).data const author=(await request('/agents',owner.token,{name:'验收课程作者',config:{model_id:model.id,system_prompt:'中文课程作者',agent_mode:'quick-answer'}},'POST',201)).data authorID=author.id async function importSkill(login,text='SKILL_ACCEPTANCE_RULE:先给判断依据,再给实践练习。',status=201) { const form=new FormData() form.set('file',new Blob([text],{type:'text/markdown'}),'课程结构.md') form.set('description','验收用文字技能') const response=await fetch(`${base}/skills/instructions/import`,{method:'POST',headers:{Authorization:`Bearer ${login.token}`},body:form,signal:AbortSignal.timeout(15000)}) const value=await response.json() assert.equal(response.status,status,`MD import: ${JSON.stringify(value)}`) return value.data } const skill=await importSkill(owner) assert.match((await request(`/skills/instructions/${skill.id}`,owner.token)).data.instructions,/SKILL_ACCEPTANCE_RULE/) await request(`/skills/instructions/${skill.id}`,outsider.token,null,'GET',404) await request(`/skills/instructions/${skill.id}`,contributor.token,null,'GET',404) assert.ok(!(await request('/skills/instructions',contributor.token)).data.some(row=>row.id===skill.id)) await importSkill(viewer,'not allowed',403) const privateSkill=await importSkill(contributor,'协作者的同名私人规则。') assert.notEqual(privateSkill.id,skill.id) const secondAuthor=(await request('/agents',owner.token,{name:'验收第二作者',config:{model_id:model.id,system_prompt:'第二作者',agent_mode:'quick-answer'}},'POST',201)).data const mounted=(await request('/skills/instructions/mount',owner.token,{agent_ids:[author.id,secondAuthor.id],skill_ids:[skill.id]})).data assert.equal(mounted.length,2) for(const agent of [author,secondAuthor]) assert.deepEqual((await request(`/agents/${agent.id}`,owner.token)).data.config.instruction_skill_ids,[skill.id]) await request('/skills/instructions/mount',contributor.token,{agent_ids:[author.id],skill_ids:[privateSkill.id]},'POST',403) await request('/skills/instructions/mount',owner.token,{agent_ids:[author.id],skill_ids:[privateSkill.id]},'POST',403) await request('/skills/instructions/mount',owner.token,{agent_ids:[secondAuthor.id],skill_ids:[skill.id],mode:'remove'}) assert.deepEqual((await request(`/agents/${secondAuthor.id}`,owner.token)).data.config.instruction_skill_ids||[],[]) checks.push('MD技能导入与同名隔离;单个/多个Agent挂载及移除;只读、跨空间和跨用户权限拦截') const create=async(kind,name,data,login=owner)=>(await request(`/workbench/resources/${kind}`,login.token,{name,data},'POST',201)).data const flow=await create('flows','验收画布',{nodes:[{id:'writer',agent_id:authorID,label:'内容作者'}],edges:[]}) let map=await create('training-maps','验收训练营',{topic:'学习方法',days_count:2,agent_id:authorID,knowledge_base_ids:[]}) await request(`/workbench/resources/training-maps/${map.id}`,outsider.token,null,'GET',404) await request(`/workbench/resources/training-maps/${map.id}`,contributor.token,null,'GET',404) await request('/workbench/resources/flows',viewer.token,{name:'不能创建',data:flow.data},'POST',403) const owned=await create('flows','协作者私人画布',flow.data,contributor) await request(`/workbench/resources/flows/${owned.id}`,owner.token) await request(`/workbench/resources/flows/${owned.id}`,owner.token,{name:'不允许代改',data:owned.data,version:owned.version},'PUT',404) await request(`/workbench/resources/flows/${owned.id}?detach=true`,owner.token,null,'DELETE',404) const privateMap=await create('training-maps','协作者私人导图',{topic:'私有主题',days_count:2,agent_id:authorID,knowledge_base_ids:[]},contributor) await request(`/workbench/resources/training-maps/${privateMap.id}/actions/generate`,owner.token,{version:privateMap.version},'POST',404) checks.push('跨空间及同空间其他用户不可读取私人内容;只读账号不可写;管理员可查看') map=await act(map,'generate') assert.equal(map.data.days.length,2) assert.ok(requests.at(-1).system.includes('SKILL_ACCEPTANCE_RULE'),'Mounted skill must reach actual model HTTP request') checks.push('挂载的文字技能实际参与导图模型请求') map=await act(map,'generate-day',{day:2}) map=await act(map,'confirm-day',{day:2}) map=await act(map,'confirm') let task=await create('content-tasks','验收内容任务',{map_id:map.id,day:2,flow_id:flow.id,agent_id:authorID,knowledge_base_ids:[]}) task=await act(task,'decompose') task=await act(task,'generate',{segment_id:'s1'}) task=await act(task,'accept',{segment_id:'s1'}) task=await act(task,'generate',{segment_id:'s2'}) task=await act(task,'skip',{segment_id:'s2'}) task=await act(task,'generate',{segment_id:'s3'}) assert.ok(!requests.at(-1).prompt.includes('SKIPPED_SENTINEL')) task=await act(task,'accept',{segment_id:'s3'}) task=await act(task,'review') assert.match(task.data.review,/跨稿质检/) task=await act(task,'compose') assert.equal(task.status,'completed') checks.push('导图生成、第二天框架、确认、拆解、按段生成、跳过衔接、AI质检及成稿') await request(`/workbench/resources/content-tasks/${task.id}`,owner.token,{name:task.name,data:task.data,version:1},'PUT',409) await request(`/workbench/resources/flows/${flow.id}`,owner.token,null,'DELETE',409) await request(`/workbench/resources/flows/${flow.id}?detach=true`,owner.token,null,'DELETE',200) assert.equal((await request(`/workbench/resources/content-tasks/${task.id}`,owner.token)).data.status,'needs_configuration') await request(`/workbench/resources/flows/${flow.id}`,owner.token,null,'GET',404) checks.push('版本冲突拦截;绑定提示及一键解绑删除,删除后不复现') await request('/workbench/notifications/read-all',owner.token,{},'POST',200) assert.ok((await request('/workbench/notifications',owner.token)).data.every(row=>row.read)) checks.push('通知一键已读') let human=(await request('/digital-humans',owner.token,{name:'验收数字人',description:'持续蒸馏验收',model_id:model.id},'POST',201)).data assert.equal(human.bindings.length,1) const primaryID=human.primary_agent_id assert.equal((await request(`/agents/${primaryID}`,owner.token)).data.name,human.name) assert.equal(human.creator_name,owner.user.username) await request(`/agents/${primaryID}`,contributor.token,null,'GET',404) await request(`/agents/${primaryID}`,owner.token,null,'DELETE',409) await request(`/digital-humans/${human.id}`,outsider.token,null,'GET',404) await request(`/digital-humans/${human.id}`,contributor.token,null,'GET',404) await request('/digital-humans',viewer.token,{name:'只读账号不能创建'},'POST',403) const privateHuman=(await request('/digital-humans',contributor.token,{name:'协作者数字人',model_id:model.id},'POST',201)).data await request(`/digital-humans/${privateHuman.id}`,owner.token) await request(`/digital-humans/${privateHuman.id}`,owner.token,null,'DELETE',404) const readHuman=async()=> (await request(`/digital-humans/${human.id}`,owner.token)).data async function finishDistill(job) { const started=Date.now() let result do { await new Promise(resolve=>setTimeout(resolve,200)) result=(await request(`/digital-humans/${human.id}/jobs/${job.id}`,owner.token)).data } while(result.status==='running' && Date.now()-started<30000) assert.equal(result.status,'completed',result.error) human=await readHuman() assert.equal(human.processing_job_id,'') return result } const materials=new FormData() materials.set('revision',String(human.revision)) materials.set('model_id',model.id) materials.set('advice','保留真实表达习惯') materials.append('files',new Blob(['先建立判断依据,再引导实践。'],{type:'text/markdown'}),'原稿.md') const uploaded=await fetch(`${base}/digital-humans/${human.id}/distill`,{method:'POST',headers:{Authorization:`Bearer ${owner.token}`},body:materials,signal:AbortSignal.timeout(15000)}) assert.equal(uploaded.status,202) const initialJob=(await uploaded.json()).data await request(`/digital-humans/${human.id}/distill`,owner.token,{revision:human.revision,sources:[{name:'冲突',text:'不能并发'}]},'POST',409) await request(`/digital-humans/${human.id}`,owner.token,null,'DELETE',409) await finishDistill(initialJob) assert.equal(human.active_version,0) human=(await request(`/digital-humans/${human.id}/review`,owner.token,{version:human.latest_version,approve:true})).data assert.equal(human.active_version,human.latest_version) assert.match((await request(`/agents/${primaryID}`,owner.token)).data.config.system_prompt,/先建立判断依据/) const extraAgent=(await request('/agents',owner.token,{name:'验收第二个绑定',config:{model_id:model.id,system_prompt:'保留原职责',agent_mode:'quick-answer'}},'POST',201)).data await request(`/digital-humans/${human.id}/bindings`,owner.token,{agent_id:extraAgent.id}) human=await readHuman() assert.equal(human.bindings.length,2) const nextJob=(await request(`/digital-humans/${human.id}/distill`,owner.token,{revision:human.revision,sources:[{name:'重复原稿',text:'先建立判断依据,再引导实践。'},{name:'增量原稿',text:'用案例回顾并承接上一章节。'}],advice:'补充叙事衔接'},'POST',202)).data await finishDistill(nextJob) const savedCount=sql(`SELECT jsonb_array_length(sources) FROM digital_human_versions WHERE digital_human_id='${human.id}' AND number=${Number(human.latest_version)}`) assert.equal(Number(savedCount),2) human=(await request(`/digital-humans/${human.id}/review`,owner.token,{version:human.latest_version,approve:true})).data for(const id of [primaryID,extraAgent.id]) { const prompt=(await request(`/agents/${id}`,owner.token)).data.config.system_prompt assert.match(prompt,/先建立判断依据/) assert.match(prompt,/用案例回顾/) } checks.push('数字人创建自动同名Agent;MD上传蒸馏;增量材料去重;审核后同步多个Agent;并发与身份隔离') await request(`/digital-humans/${human.id}`,owner.token,null,'DELETE') await request(`/digital-humans/${human.id}`,owner.token,null,'GET',404) await request(`/agents/${primaryID}`,owner.token,null,'GET',404) assert.equal((await request(`/agents/${extraAgent.id}`,owner.token)).data.config.system_prompt,'保留原职责') checks.push('数字人删除联动默认Agent,其他Agent解除绑定并保留职责') writeFileSync(path.join(directory,'browser-auth.json'),JSON.stringify(owner),{mode:0o600}) if(process.env.WB_BROWSER === '1') { checks.push(await require('./workbench_digital_browser.cjs')(owner,directory)) checks.push(await require('./workbench_content_browser.cjs')(owner,directory,map)) } writeFileSync(path.join(directory,'result.json'),JSON.stringify({time:new Date().toISOString(),checks,model:'deterministic HTTP stub, not a live AI quality benchmark',calls:requests.length},null,2)) console.log(JSON.stringify({checks,calls:requests.length},null,2)) } main().catch(error=>{console.error(error.message);process.exitCode=1}).finally(()=>server.close())