90 lines
10 KiB
JavaScript
90 lines
10 KiB
JavaScript
// Browser regression for the actual CanvasChatPanel history effects and SDK.
|
|
// Run: node scripts/verify-claw-history-ui.mjs [--source-ref=<git revision>]
|
|
import assert from 'node:assert/strict';
|
|
import {execFileSync} from 'node:child_process';
|
|
import fs from 'node:fs/promises';
|
|
import http from 'node:http';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import {createRequire} from 'node:module';
|
|
import {chromium} from '@playwright/test';
|
|
import ts from 'typescript';
|
|
const require=createRequire(import.meta.url);
|
|
const esbuild=require(require.resolve('esbuild',{paths:[require.resolve('tsx')]}));
|
|
const root=process.cwd(), sourcePath='components/canvas/CanvasChatPanel.tsx';
|
|
const ref=process.argv.find(x=>x.startsWith('--source-ref='))?.split('=')[1];
|
|
const source=ref?execFileSync('git',['show',`${ref}:${sourcePath}`],{encoding:'utf8'}):await fs.readFile(sourcePath,'utf8');
|
|
const ast=ts.createSourceFile(sourcePath,source,ts.ScriptTarget.Latest,true,ts.ScriptKind.TSX);
|
|
const effects=[];let swrDeclaration='',fetchDeclaration='',isClawdDeclaration='';
|
|
function visit(node){
|
|
if(ts.isExpressionStatement(node)&&ts.isCallExpression(node.expression)&&node.expression.expression.getText(ast)==='useEffect'){
|
|
const text=node.getText(ast);
|
|
if(text.includes('hasInitializedChatStateRef.current')||text.includes('const shouldForceHydrate = forceHistoryHydrationRef.current'))effects.push({pos:node.pos,text});
|
|
}
|
|
if(ts.isVariableStatement(node)&&node.getText(ast).includes('useSWR<CanvasHistoryMessagesResponse>'))swrDeclaration=node.getText(ast);
|
|
if(ts.isFunctionDeclaration(node)&&node.name?.text==='fetchCanvasHistoryMessages')fetchDeclaration=node.getText(ast);
|
|
if(ts.isFunctionDeclaration(node)&&node.name?.text==='isClawdChatMessage')isClawdDeclaration=node.getText(ast);
|
|
ts.forEachChild(node,visit);
|
|
}visit(ast);
|
|
assert.equal(effects.length,2,'Expected the session reset and history hydration effects');
|
|
assert(swrDeclaration&&fetchDeclaration&&isClawdDeclaration);
|
|
const noticeStart=source.indexOf('{isClawdMode && renderableVisibleMessages.length === 0 &&');
|
|
const noticeEnd=source.indexOf('{!isDetailGenerationMode && renderableVisibleMessages.length === 0 &&',noticeStart);
|
|
const notice=noticeStart>=0&¬iceEnd>noticeStart?source.slice(noticeStart,noticeEnd):'';
|
|
const temp=await fs.mkdtemp(path.join(os.tmpdir(),'claw-history-ui-'));
|
|
const message=(id,text,role='assistant')=>({id,role,parts:[{type:'text',text}],createdAt:'2026-09-15T00:00:00Z',videoMetadata:{composerMode:'clawd'}});
|
|
const histories={a:[message('a-user','旧问题','user'),message('a-answer','历史回复')],new:[],pending:[message('p-old','之前的消息')],regular:[message('r-old','持久化旧消息')]};
|
|
const pending=message('pending-user','刚提交的新问题','user');pending.createdAt='2026-09-15T01:00:00Z';
|
|
let pendingDelay=350,requests=0,failHistory=true;
|
|
const server=http.createServer(async(req,res)=>{
|
|
const url=new URL(req.url,'http://localhost');
|
|
if(url.pathname==='/history'){
|
|
requests++;const id=url.searchParams.get('chatId');const payload=JSON.stringify({messages:histories[id]||[]});
|
|
if(id==='error'){await new Promise(r=>setTimeout(r,500));if(failHistory){res.writeHead(500);res.end('{}');return;}}
|
|
if(id==='pending')await new Promise(r=>setTimeout(r,pendingDelay));
|
|
res.setHeader('Content-Type','application/json');res.end(payload);return;
|
|
}
|
|
if(url.pathname==='/app.js'){res.setHeader('Content-Type','text/javascript');res.end(await fs.readFile(path.join(temp,'app.js')));return;}
|
|
res.setHeader('Content-Type','text/html');res.end('<div id="root"></div><script src="/app.js"></script>');
|
|
});
|
|
const entry=`import React,{useState,useRef,useEffect}from'react';import{createRoot}from'react-dom/client';import{useChat}from'@ai-sdk/react';import useSWR from'swr';import{sortCanvasMessagesByCreatedAt,buildCanvasHistoryMessagesSignature}from'./components/canvas/chat-panel/canvas-chat-media';
|
|
const fetcher=async url=>{const response=await fetch(url);if(!response.ok)throw new Error('history unavailable');return response.json()};${fetchDeclaration}\n${isClawdDeclaration}
|
|
function Button({children,onClick}){return <button onClick={onClick}>{children}</button>}
|
|
function App(){
|
|
const scenario=new URLSearchParams(location.search).get('case')||'cache';
|
|
const [activeChatId,setActiveChatId]=useState(scenario==='pending'?'pending':scenario==='regular'?'regular':scenario==='error'?'error':'a');
|
|
const rawChatId=activeChatId;const historyMessagesSWRKey='/history?chatId='+activeChatId;
|
|
${swrDeclaration}
|
|
const historyData=fetchedHistoryData?.chatId===rawChatId?fetchedHistoryData:undefined;
|
|
const [isClawdSubmitting,setSubmitting]=useState(scenario==='pending');
|
|
const [status,setStatus]=useState(scenario==='regular'?'streaming':'ready');
|
|
const isClawdMode=scenario!=='regular';const activeClawdRequestUi=isClawdSubmitting?{userMessageId:'pending-user'}:null;
|
|
const {messages,setMessages}=useChat({id:activeChatId,messages:scenario==='pending'?[${JSON.stringify(pending)}]:scenario==='regular'?[{id:'stream',role:'assistant',parts:[{type:'text',text:'仍在输出'}]}]:historyData?.messages||[]});
|
|
const hasInitializedChatStateRef=useRef(false),loadedHistorySignatureRef=useRef(''),processedMessageIds=useRef(new Set()),hasHydratedHistoryRef=useRef(false),hiddenPromptRef=useRef(null),autoContextRef=useRef(null),pendingLaunchRef=useRef(null),textareaRef=useRef(null),historyScrollTimerRef=useRef(null),messagesEndRef=useRef(null),forceHistoryHydrationRef=useRef(null),inFlightChatIdRef=useRef(null),inFlightRawChatIdRef=useRef(null);
|
|
const [,setMessageViewportKey]=useState(0),[,setInput]=useState(''),[,setAttachments]=useState([]),[,setClawdTableFiles]=useState([]),[,setIsSessionMenuOpen]=useState(false),[,setDetailSlotPickerIndex]=useState(null);
|
|
${effects.sort((a,b)=>a.pos-b.pos).map(x=>x.text).join('\n')}
|
|
window.historyTest={messages,loaded:!!historyData,pending:isClawdSubmitting,switchTo(id){forceHistoryHydrationRef.current=id;setActiveChatId(id)},refresh(){return mutateHistoryData()},finish(){setSubmitting(false);setStatus('ready')}};
|
|
const renderableVisibleMessages=messages;return <div>${notice}<div id="messages">{messages.map(m=><p key={m.id} data-message-id={m.id}>{m.parts[0]?.text}</p>)}</div><span id="pending">{String(isClawdSubmitting)}</span></div>
|
|
}createRoot(document.getElementById('root')).render(<App/>);`;
|
|
let browser;
|
|
try{
|
|
await esbuild.build({stdin:{contents:entry,loader:'tsx',resolveDir:root},outfile:path.join(temp,'app.js'),bundle:true,jsx:'automatic',define:{'process.env.NODE_ENV':'"production"'}});
|
|
await new Promise(r=>server.listen(0,'127.0.0.1',r));const base='http://127.0.0.1:'+server.address().port;
|
|
browser=await chromium.launch({channel:'chrome',headless:true});
|
|
const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));
|
|
const ids=()=>page.locator('[data-message-id]').evaluateAll(nodes=>nodes.map(n=>n.getAttribute('data-message-id')));
|
|
await page.goto(base);await page.waitForFunction(()=>window.historyTest?.loaded);assert.deepEqual(await ids(),['a-user','a-answer']);
|
|
await page.evaluate(()=>window.historyTest.switchTo('new'));await page.waitForFunction(()=>window.historyTest.loaded);assert.deepEqual(await ids(),[]);
|
|
const priorRequests=requests;await page.evaluate(()=>window.historyTest.switchTo('a'));await page.waitForTimeout(150);
|
|
assert.deepEqual(await ids(),['a-user','a-answer'],'Opening cached history after a new chat must not clear its messages');assert.equal(requests,priorRequests,'This case must exercise the cached response, not a fresh network response');console.log('PASS cached history survives new-chat round trip');
|
|
await page.goto(base+'/?case=pending');await page.waitForFunction(()=>window.historyTest?.loaded);await page.waitForTimeout(100);
|
|
assert.equal(await page.locator('#pending').innerText(),'true');assert.deepEqual(await ids(),['p-old','pending-user'],'History must appear before the task finishes and preserve the optimistic user message');console.log('PASS history loads during an active Claw task');
|
|
histories.pending.push({...pending,parts:[{type:'text',text:'服务器已保存的新问题'}]});await page.evaluate(()=>window.historyTest.refresh());await page.waitForTimeout(100);assert.deepEqual(await ids(),['p-old','pending-user']);assert.equal(await page.locator('[data-message-id="pending-user"]').innerText(),'服务器已保存的新问题');console.log('PASS persisted message replaces its optimistic copy without duplicates');
|
|
pendingDelay=700;const read=page.evaluate(()=>window.historyTest.refresh());await page.waitForTimeout(100);await page.evaluate(()=>window.historyTest.switchTo('new'));await read;await page.waitForTimeout(100);assert.deepEqual(await ids(),[],'A late response from another chat must not be displayed');console.log('PASS late history cannot leak into another chat');
|
|
await page.goto(base+'/?case=regular');await page.waitForFunction(()=>window.historyTest?.loaded);assert.deepEqual(await ids(),['stream'],'Regular SDK output must retain its streaming protection');console.log('PASS ordinary streaming messages remain protected');
|
|
await page.goto(base);await page.waitForFunction(()=>window.historyTest?.loaded);histories.a.push(message('a-new','其他窗口保存的新回复'));await page.waitForTimeout(11000);await page.evaluate(()=>window.dispatchEvent(new Event('focus')));await page.waitForFunction(()=>window.historyTest.messages.some(x=>x.id==='a-new'));console.log('PASS returning to the browser refreshes history');
|
|
histories.a.push(message('a-reconnect','重新连接后的消息'));await page.waitForTimeout(11000);await page.evaluate(()=>window.dispatchEvent(new Event('online')));await page.waitForFunction(()=>window.historyTest.messages.some(x=>x.id==='a-reconnect'));console.log('PASS reconnect refreshes history');
|
|
if(notice){await page.goto(base+'/?case=error');await page.getByText('正在加载聊天记录…',{exact:true}).waitFor();await page.getByText('聊天记录加载失败,请重试',{exact:true}).waitFor();failHistory=false;histories.error=[message('retry-restored','重试恢复的记录')];await page.getByRole('button',{name:'重新加载',exact:true}).click();await page.locator('[data-message-id="retry-restored"]').waitFor();console.log('PASS loading, failure and retry states recover history');}
|
|
assert.deepEqual(errors,[]);
|
|
}finally{await browser?.close();server.close();await fs.rm(temp,{recursive:true,force:true});}
|