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 = `classification-check-${Date.now()}` const checks = [], failures = [], users = [], roles = [], kbs = [] let admin 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 rows = statement => JSON.parse(sql(`SELECT COALESCE(json_agg(t),'[]') FROM (${statement}) t`)) 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(45000)}) const result=await response.json() assert.ok((Array.isArray(expected)?expected:[expected]).includes(response.status),`${method} ${route}: ${response.status} ${result.message||result.error?.message||''}`) return {status:response.status,...result} } async function until(name,fn,timeout=240000) { const start=Date.now() while(Date.now()-startsetTimeout(resolve,1000)) } throw new Error(`${name} timed out`) } async function visual(kb,alice,job,tagName) { const browser=await chromium.launch({headless:true,channel:'chrome'}) try { const context=await browser.newContext({viewport:{width:1440,height:1000}}) await context.addInitScript(auth=>{ 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:'10059'}),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) },alice) const page=await context.newPage(),errors=[] page.on('pageerror',e=>errors.push(e.message)) await page.goto(`http://127.0.0.1:15173/platform/knowledge-bases/${kb}`) await page.getByRole('button',{name:'AI 推荐分类',exact:true}).click() await page.locator('.classification-panel').getByText(tagName,{exact:true}).waitFor() await page.screenshot({path:`${directory}/classification-desktop.png`,animations:'disabled'}) await page.setViewportSize({width:390,height:844}) await page.screenshot({path:`${directory}/classification-mobile.png`,animations:'disabled'}) check('mobile_full_category_name_and_no_page_overflow',await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+2)) const category=page.locator('.category-path').first() check('category_name_wraps_in_panel',await category.evaluate(element=>element.scrollWidth<=element.clientWidth+2)) await page.getByRole('button',{name:'采用推荐分类',exact:true}).click() await page.getByText('已采用',{exact:true}).waitFor() check('real_ui_manual_apply_completed',true) let mock={...job,status:'running',processed:0,failed:0,items:job.items.map(item=>({...item,status:'running',suggestions:[]}))} await page.route(`**/api/v1/knowledge-bases/${kb}/classification-jobs`,route=>route.fulfill({status:200,json:{success:true,data:[mock]}})) await page.reload() await page.getByRole('button',{name:'AI 推荐分类',exact:true}).click() await page.getByRole('progressbar',{name:'AI 正在分析首个文档'}).waitFor() await page.screenshot({path:`${directory}/classification-progress-mobile.png`,animations:'disabled'}) check('first_document_has_visible_progress',true) mock={...job,status:'completed_with_errors',processed:1,failed:1,items:job.items.map(item=>({...item,status:'failed',error:'分类请求超时,请稍后重试',suggestions:[]}))} await page.getByRole('button',{name:'重试失败的 1 个文档',exact:true}).waitFor() check('failed_job_has_retry_control',true) mock={...job,status:'running',processed:1,total:2,items:[...job.items,{id:'visual-pending',knowledge_id:'visual-pending',title:'等待处理的文档',status:'pending',suggestions:[]}]} await page.reload() await page.getByRole('button',{name:'AI 推荐分类',exact:true}).click() await page.route(`**/api/v1/knowledge-bases/${kb}/classification-jobs/${job.id}/apply`,route=>route.fulfill({status:409,json:{success:false,error:{message:'建议的分类已改名或删除,请重新推荐'}}})) await page.getByRole('button',{name:'采用推荐分类',exact:true}).click() await page.getByText('建议的分类已改名或删除,请重新推荐',{exact:true}).waitFor() await page.waitForTimeout(2500) check('polling_preserves_specific_apply_error',await page.getByText('建议的分类已改名或删除,请重新推荐',{exact:true}).isVisible()) assert.deepEqual(errors,[],'browser runtime errors') } finally {await browser.close()} } 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:['knowledge.read','knowledge.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') await api('/workbench/operations/grants','POST',{user_id:alice.user.id,amount:1000000,reason:'Disposable classification acceptance',idempotency_key:prefix}) const model='404e005b-83ae-5e2e-a3b3-999f9d5cd47e' const kb=(await api('/knowledge-bases','POST',{name:prefix,type:'document',summary_model_id:model,embedding_model_id:'a2c3ce32-6192-46c8-8072-910abfe8afed',storage_backend_id:'edcd8dc2-4e93-4bd4-839c-d835619fc0a6',indexing_strategy:{vector_enabled:true,keyword_enabled:true,wiki_enabled:false,graph_enabled:false}},alice,[200,201])).data;kbs.push(kb.id) const tagName='知识库使用 / 文件上传与分类 / 新人操作步骤和人工确认分类的完整说明' const tag=(await api(`/knowledge-bases/${kb.id}/tags`,'POST',{name:tagName},alice)).data const old=(await api(`/knowledge-bases/${kb.id}/tags`,'POST',{name:'已有归档分类'},alice)).data await api(`/knowledge-bases/${kb.id}/tags`,'POST',{name:'烹饪 / 家常菜制作'},alice) const form=new FormData();form.set('file',new Blob(['知识库新人使用说明。先打开知识库,上传文件,等待解析完成。可以为文档设置分类。AI 推荐分类会分析资料内容并说明归类理由,用户确认后添加分类,已有分类不会被删除。本文只介绍文件上传和分类管理的操作步骤。'],{type:'text/plain'}),'knowledge-classification-guide.txt');form.set('tag_ids',old.id) await api(`/knowledge-bases/${kb.id}/knowledge/file`,'POST',form,alice,[200,201,202]) const doc=await until('native ingestion',async()=>{ const doc=rows(`SELECT id,parse_status FROM knowledges WHERE knowledge_base_id=${literal(kb.id)} AND deleted_at IS NULL`)[0] if(doc?.parse_status==='failed')throw new Error('fixture ingestion failed') return doc?.parse_status==='completed'?doc:null }) const endpoint=`/knowledge-bases/${kb.id}/classification-jobs` const starts=await Promise.all([api(endpoint,'POST',{model_id:model,knowledge_ids:[doc.id]},alice,[202,409]),api(endpoint,'POST',{model_id:model,knowledge_ids:[doc.id]},alice,[202,409])]) check('concurrent_submission_has_one_job',starts.filter(result=>result.status===202).length===1&&starts.filter(result=>result.status===409).length===1) const id=starts.find(result=>result.status===202).data.id,start=Date.now() const job=await until('real model classification',async()=>{ const current=(await api(endpoint,'GET',undefined,alice)).data.find(job=>job.id===id) if(current?.status==='completed_with_errors')throw new Error(`real model classification failed: ${current.items[0].error}`) return current?.status==='completed'?current:null }) check('real_model_returns_existing_category_with_chinese_reason',job.items[0].status==='suggested'&&job.items[0].suggestions.some(s=>s.tag_id===tag.id&&s.name===tagName&&/[\u4e00-\u9fff]/.test(s.reason))) checks.push({name:'real_model_duration_ms',value:Date.now()-start}) const tagIDs=()=>rows(`SELECT tag_id FROM knowledge_tag_relations WHERE knowledge_id=${literal(doc.id)}`).map(row=>row.tag_id) check('recommendation_does_not_apply_automatically',tagIDs().length===1&&tagIDs()[0]===old.id) await api(endpoint,'GET',undefined,bob,[403,404]) await api(`${endpoint}/${id}/apply`,'POST',{item_ids:[job.items[0].id]},bob,[403,404]) await api(`${endpoint}/${id}/apply`,'POST',{item_ids:[job.items[0].id]},admin,404) check('other_user_and_admin_cannot_apply_owner_job',true) await api(`/knowledge-bases/${kb.id}/tags/${tag.id}`,'PUT',{name:tagName+'(修改)'},alice) await api(`${endpoint}/${id}/apply`,'POST',{item_ids:[job.items[0].id]},alice,409) check('stale_category_is_rejected',!tagIDs().includes(tag.id)) await api(`/knowledge-bases/${kb.id}/tags/${tag.id}`,'PUT',{name:tagName},alice) await visual(kb.id,alice,job,tagName) check('manual_apply_preserves_existing_tags',tagIDs().includes(old.id)&&tagIDs().includes(tag.id)) check('classification_has_operation_audit',rows(`SELECT action FROM audit_logs WHERE actor_user_id=${literal(alice.user.id)} AND target_id=${literal(id)} AND action IN ('knowledge.classification_requested','knowledge.classification_applied')`).length>=2) } main().catch(error=>{failures.push(error.message);process.exitCode=1}).finally(async()=>{ if(admin) { for(const id of kbs)try{await api(`/knowledge-bases/${id}`,'DELETE',undefined,admin,[200,202,404])}catch(error){failures.push('cleanup KB: '+error.message)} if(users.length) { const ids=users.map(literal).join(',') sql(`DELETE FROM knowledge_classification_jobs WHERE tenant_id=10059 AND owner_id IN (${ids}); DELETE FROM workbench_usage WHERE tenant_id=10059 AND owner_id IN (${ids}); DELETE FROM workbench_balance_holds WHERE tenant_id=10059 AND user_id IN (${ids}); DELETE FROM workbench_transactions WHERE tenant_id=10059 AND user_id IN (${ids}); DELETE FROM workbench_balances WHERE tenant_id=10059 AND user_id IN (${ids})`) } 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)} } fs.writeFileSync(`${directory}/knowledge-classification-live.json`,JSON.stringify({time:new Date().toISOString(),prefix,fixtures:{kbs,users,roles},checks,failures,passed:!failures.length},null,2)) console.log(JSON.stringify({checks,failures,passed:!failures.length})) if(failures.length)process.exitCode=1 })