import { test, expect } from '@playwright/test' import { readFileSync, readlinkSync } from 'node:fs' import { createHash } from 'node:crypto' const manifestPath=process.env.WORK_CHECKPOINT_HTTP_MANIFEST test.skip(!manifestPath,'Run explicitly with a task-owned mechanism instance manifest') const manifest=manifestPath?JSON.parse(readFileSync(manifestPath,'utf8')):null if(manifest&&(manifest.mode!=='mechanism'||!manifest.dataDir.includes('fast-slow-u2-u3-')||readlinkSync(`/proc/${manifest.pid}/exe`)!==manifest.binary||createHash('sha256').update(readFileSync(manifest.binary)).digest('hex')!==manifest.binarySHA256))throw new Error('Owned mechanism instance verification failed') type Scenario='normal'|'fast-ack'|'queue-hold'|'slow-stages'|'zero-failure'|'partial-failure'|'choice' async function setup(request:any,scenario:Scenario='normal',awaitTask=true,options:{text?:string,small?:boolean,startFailure?:boolean}={}){ const project=await(await request.post(manifest.base+'/api/projects',{data:{name:'browser-'+Date.now()}})).json() const conv=await(await request.post(manifest.base+`/api/projects/${project.id}/conversations`,{data:{title:'材料整理',profileId:'checkpoint-eval'}})).json() const ids:string[]=[] for(let i=0;i<(options.small?1:3);i++){ const marker=i===0&&scenario!=='normal'?`[fixture:${scenario}]\n`:'' const res=await request.post(manifest.base+`/api/projects/${project.id}/uploads`,{multipart:{file:{name:`来源${i+1}.txt`,mimeType:'text/plain',buffer:Buffer.from(marker+'上线六月。验收条件需要补齐,预算50万元尚未审批。交付包含12项需求,其中3项依赖外部接口。第一期先完成8项核心需求。\n\n'+(options.small?'':('整理记录说明:保留日期、金额、限制和待确认事项。\n\n').repeat(options.text?250:100)))}}}) expect(res.ok()).toBeTruthy();ids.push((await res.json()).artifactId) } if(options.startFailure)expect((await request.post(manifest.base+'/__fixture/material-start-failure',{data:{enabled:true}})).status()).toBe(204) let sendBody:any try{ const sent=await request.post(manifest.base+`/api/conversations/${conv.id}/messages`,{data:{text:options.text??'',attachmentIds:ids,requestId:'browser-'+Date.now()}}) expect(sent.ok()).toBeTruthy() sendBody=await sent.json() await expect.poll(async()=> (await(await request.get(manifest.base+`/api/runs/${sendBody.run.id}`)).json()).run.status).toBe('completed') }finally{ if(options.startFailure)expect((await request.post(manifest.base+'/__fixture/material-start-failure',{data:{enabled:false}})).status()).toBe(204) } if(awaitTask)await expect.poll(async()=>{ const out=await(await request.get(manifest.base+`/api/conversations/${conv.id}/cards`)).json() return out.cards.find((c:any)=>c.task)?.task.executionState }).toBe(scenario.endsWith('failure')?'failed':'completed') return {project,conv,ids,sendRunId:sendBody.run.id} } async function waitForFailedWorkCard(request:any,seed:any){ await expect.poll(async()=>{ const out=await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json() const card=out.cards.find((c:any)=>c.task) return card?.task.executionState==='failed'&&card.status==='failed'&&Boolean(card.work) }).toBe(true) } async function expectOnlySendRun(request:any,seed:any){ const current=await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}`)).json() expect(current.runs.filter((r:any)=>r.kind==='conversation').map((r:any)=>r.id)).toEqual([seed.sendRunId]) } async function open(page:any,seed:any){ await page.addInitScript(({id}:{id:string})=>{localStorage.setItem('as.conv',id);localStorage.setItem('as.ui',JSON.stringify({theme:'light',rightCollapsed:true,fontSize:14}))},{id:seed.conv.id}) await page.goto('/') await expect(page.locator('[data-conversation-work]')).toContainText('上线六月') } test('attachment-only intake gives a safe first reply before sourced findings',async({page,request})=>{ const seed=await setup(request,'fast-ack',false) const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards expect(cards.filter((c:any)=>c.task)).toHaveLength(1) expect(cards.find((c:any)=>c.task).task.checkpointSeq??0).toBe(0) await open(page,seed) const reply=page.getByText(/已收到 3 份材料.*待确认/) await expect(reply).toBeVisible() await expect(page.getByText(/剩余\s*4\s*项.*含\s*3\s*项依赖接口/)).toHaveCount(0) await page.setViewportSize({width:390,height:844}) await expect(reply).toBeVisible() if(process.env.WORK_CHECKPOINT_SCREENSHOT)await page.screenshot({path:process.env.WORK_CHECKPOINT_SCREENSHOT.replace(/\.png$/, '-mobile.png'),fullPage:true}) await page.reload() await expect(reply).toBeVisible() }) test('text with long material keeps the user focus in the host reply',async({page,request})=>{ const seed=await setup(request,'fast-ack',false,{text:'重点核对预算审批'}) await open(page,seed) await expect(page.getByText(/你提出的工作重点已带入整理任务.*待确认/)).toBeVisible() const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards expect(cards.filter((c:any)=>c.task)).toHaveLength(1) }) test('running and completed resends reuse one material card',async({page,request})=>{ const seed=await setup(request,'slow-stages',false) const resend=async(key:string)=>{ const response=await request.post(manifest.base+`/api/conversations/${seed.conv.id}/messages`,{data:{text:'',attachmentIds:seed.ids,requestId:key}}) expect(response.ok()).toBeTruthy() const body=await response.json() await expect.poll(async()=> (await(await request.get(manifest.base+`/api/runs/${body.run.id}`)).json()).run.status).toBe('completed') } await resend('active-resend-'+Date.now()) await open(page,seed) await expect(page.getByText(/这批材料仍在整理.*待确认/)).toBeVisible() await expect.poll(async()=>{ const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards return cards.find((c:any)=>c.task)?.task.executionState }).toBe('completed') await resend('completed-resend-'+Date.now()) await expect(page.getByText(/这批材料已整理完成.*待确认/)).toBeVisible() const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards expect(cards.filter((c:any)=>c.task)).toHaveLength(1) }) test('text with a small attachment retains the ordinary reply path',async({page,request})=>{ const seed=await setup(request,'normal',true,{text:'请先解释材料要点',small:true}) await open(page,seed) await expect(page.getByText('已收到材料,正在后台整理;请结合阶段发现继续工作。').first()).toBeVisible() const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards expect(cards.filter((c:any)=>c.task)).toHaveLength(1) }) test('real HTTP task recovery, source navigation and changed-version limitation',async({page,request})=>{ const seed=await setup(request);const errors:string[]=[];page.on('pageerror',e=>errors.push(e.message));await open(page,seed) await expect(page.locator('[data-conversation-work]')).toContainText('验收条件需要补齐') await expect(page.locator('[data-conversation-work]')).toContainText('材料未说明具体归属') await page.reload();await expect(page.locator('[data-conversation-work]')).toContainText('上线六月') await page.getByRole('button',{name:'查看依据',exact:true}).first().click() await page.getByRole('button',{name:/查看来源/}).first().click() await expect(page.locator('.preview-pane')).toBeVisible();await expect(page.getByText('任务处理时引用的来源版本;下方是本项发现引用的片段')).toBeVisible() const card=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards.find((c:any)=>c.task) const source=card.task.findings[0].sources[0] const update=await request.put(manifest.base+`/api/artifacts/${source.artifactId}/content`,{data:'上线七月。文件内容已经更新。',headers:{'Content-Type':'text/plain'}});expect(update.ok()).toBeTruthy() const old=await request.get(manifest.base+`/api/artifacts/${source.artifactId}/content?expectedHash=${source.contentHash}`);expect(old.status()).toBe(409) await page.reload();await expect(page.locator('[data-conversation-work]')).toContainText('依据文件已更新') await expect(page.locator('[data-conversation-work] .acard-findings').first()).not.toContainText('上线六月') expect(errors).toEqual([]) if(process.env.WORK_CHECKPOINT_SCREENSHOT)await page.screenshot({path:process.env.WORK_CHECKPOINT_SCREENSHOT,fullPage:true}) }) test('background replay does not steal the current file, focus or viewport',async({page,request})=>{ const seed=await setup(request);await open(page,seed) await page.getByRole('button',{name:'查看依据',exact:true}).first().click();await page.getByRole('button',{name:/查看来源/}).first().click();await expect(page.locator('.preview-pane')).toBeVisible() const tabs=await page.locator('.tab-strip').textContent() const composer=page.locator('textarea').last();await composer.fill('正在记录自己的想法');await composer.focus() const focus=await composer.evaluate((el:any)=>document.activeElement===el);expect(focus).toBe(true) const sent=await request.post(manifest.base+`/api/conversations/${seed.conv.id}/messages`,{data:{text:'重点核对预算',attachmentIds:seed.ids,requestId:'another-'+Date.now()}});expect(sent.ok()).toBeTruthy() await expect.poll(async()=>{ const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards return cards.filter((c:any)=>c.task?.executionState==='completed').length }).toBe(2) await expect(composer).toBeFocused();await expect(composer).toHaveValue('正在记录自己的想法') expect(await page.locator('.tab-strip').textContent()).toBe(tabs) }) test('two sourced stages appear while the material task is still running',async({page,request})=>{ const seed=await setup(request,'slow-stages',false) await open(page,seed) const cards=async()=>(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards await expect.poll(async()=>{const card=(await cards()).find((c:any)=>c.task);return card?.task.executionState}).toBe('running') await expect(page.locator('[data-conversation-work]')).toContainText('上线六月') await expect.poll(async()=>{const card=(await cards()).find((c:any)=>c.task);return card?.task.executionState==='running'&&card.task.checkpointSeq>=2&&card.task.findings?.length>1}).toBe(true) await expect(page.locator('[data-conversation-work]')).toContainText('验收条件需要补齐') await expect.poll(async()=>{const card=(await cards()).find((c:any)=>c.task);return card?.task.executionState}).toBe('completed') }) for(const scenario of ['zero-failure','partial-failure'] as const){ test(`${scenario} shows the saved scope without a deliverable choice`,async({page,request})=>{ const seed=await setup(request,scenario) await waitForFailedWorkCard(request,seed) await page.addInitScript(({id}:{id:string})=>{localStorage.setItem('as.conv',id);localStorage.setItem('as.ui',JSON.stringify({theme:'light',rightCollapsed:true,fontSize:14}))},{id:seed.conv.id}) await page.goto('/') const work=page.locator('[data-conversation-work]') await expect(work).toContainText('执行失败') if(scenario==='zero-failure')await expect(work).toContainText('暂无阶段结论记录') else await expect(work).toContainText('上线六月') await expect(page.locator('.work-choice')).toHaveCount(0) await expect(page.locator('.hitl-choice')).toHaveCount(0) await expectOnlySendRun(request,seed) }) } test('completed sourced work offers only its grounded suggestions',async({page,request})=>{ const seed=await setup(request,'choice') await open(page,seed) const work=page.locator('[data-conversation-work]') await expect(work.locator('.work-choice')).toHaveCount(3) for(const label of ['会议摘要','验收清单','联调计划'])await expect(work.locator('.work-choice').filter({hasText:label})).toHaveCount(1) await expect(work.locator('.work-decision legend')).toContainText('接下来需要做什么?') await page.reload() await expect(work.locator('.work-choice')).toHaveCount(3) }) test('failed material registration reports no started task or unsupported finding',async({page,request})=>{ const seed=await setup(request,'normal',false,{startFailure:true}) await page.addInitScript(({id}:{id:string})=>{localStorage.setItem('as.conv',id);localStorage.setItem('as.ui',JSON.stringify({theme:'light',rightCollapsed:true,fontSize:14}))},{id:seed.conv.id}) await page.goto('/') await expect(page.getByText(/后台整理未能启动/).first()).toBeVisible() const conversation=await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}`)).json() expect(conversation.messages.some((m:any)=>m.role==='assistant'&&(m.parts??[]).some((p:any)=>p.type==='text'&&p.text?.includes('未能启动')))).toBe(true) const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards expect(cards.filter((c:any)=>c.task)).toHaveLength(0) await expect(page.getByText(/剩余\s*4\s*项.*含\s*3\s*项依赖接口/)).toHaveCount(0) }) test('saturated material slots show queued status without claiming execution',async({page,request})=>{ const seed=await setup(request,'queue-hold',false) const sendAnother=async(label:string,hold:boolean)=>{ const uploaded=await request.post(manifest.base+`/api/projects/${seed.project.id}/uploads`,{ multipart:{file:{name:`${label}.txt`,mimeType:'text/plain',buffer:Buffer.from((hold?'[fixture:queue-hold]\n':'')+'上线六月。验收条件需要补齐。'+label)}} }) expect(uploaded.ok()).toBeTruthy() const artifactId=(await uploaded.json()).artifactId const sent=await request.post(manifest.base+`/api/conversations/${seed.conv.id}/messages`,{data:{text:'',attachmentIds:[artifactId],requestId:'queue-'+label}}) expect(sent.ok()).toBeTruthy() const runId=(await sent.json()).run.id await expect.poll(async()=> (await(await request.get(manifest.base+`/api/runs/${runId}`)).json()).run.status).toBe('completed') } await sendAnother('占用二',true) await sendAnother('占用三',true) await sendAnother('等待四',false) await page.addInitScript(({id}:{id:string})=>{localStorage.setItem('as.conv',id);localStorage.setItem('as.ui',JSON.stringify({theme:'light',rightCollapsed:true,fontSize:14}))},{id:seed.conv.id}) await page.goto('/') await expect(page.locator('[data-conversation-work]')).toContainText('排队中') await expect(page.getByText(/整理任务正在排队.*待确认/).first()).toBeVisible() const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards.filter((c:any)=>c.task) expect(cards).toHaveLength(4) expect(cards.filter((c:any)=>c.task.executionState==='queued')).toHaveLength(1) await expect(page.getByText(/剩余\s*4\s*项.*含\s*3\s*项依赖接口/)).toHaveCount(0) })