const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const {chromium} = require('playwright'); const root = path.resolve(__dirname, '../html'); (async () => { const browser = await chromium.launch({headless:true,channel:'msedge'}); try { const page = await browser.newPage({viewport:{width:1280,height:900}}); const requests = []; let failNext = false; await page.route('**/*', async route => { const url = new URL(route.request().url()); if (url.pathname.endsWith('/answer-cache')) { requests.push({method:route.request().method(), path:url.pathname, body:route.request().postDataJSON()}); if (failNext) { failNext=false; return route.fulfill({status:409,json:{detail:'数据已更新,请重新提问后确认'}}); } return route.fulfill({json:{status:'ok',entry_id:'entry1'}}); } if (url.pathname === '/api/data-quality') return route.fulfill({json:{data_version:'v',total:0,datasets:{}}}); if (url.pathname === '/api/runtime') return route.fulfill({json:{data_version:'v',schema_version:'s'}}); if (url.pathname.startsWith('/output/')) return route.fulfill({json:{nodes:[],links:[],relations:[],meta:{}}}); const file = path.join(root, url.pathname === '/' ? 'knowledge_graph_3d.html' : url.pathname); if (!file.startsWith(root) || !fs.existsSync(file)) return route.fulfill({status:404,body:''}); return route.fulfill({body:fs.readFileSync(file),contentType:file.endsWith('.js')?'application/javascript':file.endsWith('.css')?'text/css':'text/html'}); }); await page.goto('http://qa.test/'); assert.ok((await page.locator('#chatFrame').getAttribute('src')).includes('20260907-cache')); const frame = await (await page.locator('#chatFrame').elementHandle()).contentFrame(); await frame.waitForFunction(() => typeof askStream === 'function' && Boolean(window.AnswerCacheUI)); await frame.evaluate(async () => { const original=window.fetch; const payload={status:'ok',answer:'## 员工人数\n\n共有2名员工。',elapsed_sec:.01, cache_review:{eligible:true,answer_id:'answer1',checkpoint_id:'checkpoint1',cached:false,hit:false}}; const sse='event: answer_chunk\ndata: '+JSON.stringify({text:'处理中'})+'\n\n' + 'event: done\ndata: '+JSON.stringify(payload)+'\n\n'; window.fetch=async () => new Response(sse); try {await askStream('员工有多少人',true,true);} finally {window.fetch=original;} }); assert.equal(requests.length,0,'rendering an answer must not cache it'); const button=frame.locator('.answer-cache-button').last(); await button.click(); await frame.waitForFunction(() => document.querySelector('.answer-cache-button')?.textContent==='撤销缓存'); assert.equal(requests.length,1); assert.equal(requests[0].method,'POST'); assert.deepEqual(requests[0].body,{answer_id:'answer1',checkpoint_id:'checkpoint1',entry_id:null}); const originalThreadPath=requests[0].path; await frame.evaluate(() => { sessionStorage.setItem('kg_thread_id','new-thread'); }); await button.click(); await frame.waitForFunction(() => document.querySelector('.answer-cache-button')?.textContent==='答案正确,加入缓存'); assert.equal(requests[1].method,'DELETE'); assert.equal(requests[1].path,originalThreadPath,'old answer keeps its own thread scope'); assert.equal(requests[1].body.entry_id,'entry1'); failNext=true; await button.click(); await frame.waitForFunction(() => document.querySelector('.answer-cache-status').textContent.includes('数据已更新')); assert.equal(await button.innerText(),'答案正确,加入缓存'); assert.equal(await button.isEnabled(),true); await button.click(); await frame.waitForFunction(() => document.querySelector('.answer-cache-button').textContent==='撤销缓存'); // Resume rendering gets the same explicit approval control. await frame.evaluate(async () => { const original=window.fetch; window.fetch=async () => new Response(JSON.stringify({status:'ok',answer:'确认后完成', cache_review:{eligible:true,answer_id:'answer2',checkpoint_id:'checkpoint2',cached:false}})); try {await resumeThread('确认');} finally {window.fetch=original;} }); assert.equal(await frame.locator('.answer-cache-button').count(),2); await frame.evaluate(() => { const bubble=appendMessage('assistant','历史确认答案'); AnswerCacheUI.attach(bubble,{cache_review:{eligible:true,answer_id:'hit',checkpoint_id:'cphit', cached:true,hit:true,entry_id:'entry1'}},'t1',apiBase()); const incomplete=appendMessage('assistant','不完整结果'); AnswerCacheUI.attach(incomplete,{cache_review:{eligible:false,reason:'结果截断'}},'t1',apiBase()); }); assert.equal(await frame.getByText('已复用你确认的历史答案',{exact:true}).count(),1); assert.equal(await frame.getByText('暂不缓存:结果截断',{exact:true}).count(),1); assert.equal(await frame.locator('.answer-cache-button').count(),3); await page.setViewportSize({width:390,height:844}); await frame.locator('.answer-cache-button').last().scrollIntoViewIfNeeded(); assert.ok(await frame.locator('.answer-cache-button').last().isVisible()); console.log('PASS root iframe: explicit approval only, bound answer/checkpoint/thread, revoke, stale-error retry, resume, hit badge, excluded answer, mobile'); } finally {await browser.close();} })().catch(e=>{console.error(e);process.exitCode=1;});