WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
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
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
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
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()-start<timeout) { const value=await fn(); if(value)return value; await new Promise(resolve=>setTimeout(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
|
||||
})
|
||||
Reference in New Issue
Block a user