import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtemp, mkdir, writeFile, chmod, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fixture } from './fixture.mjs'; import { browser } from './browser.mjs'; import { P, T, stub } from './helpers/projects-review-fixture.mjs'; // Use Chromium tab zoom, not CSS zoom or device-scale emulation. The profile's // default zoom and extension both set the same 200% level for fixture pages. async function zoomLauncher() { const dir = await mkdtemp(join(tmpdir(), 's9-zoom-')); const ext = join(dir, 'extension'); await mkdir(ext); await writeFile(join(ext, 'manifest.json'), JSON.stringify({manifest_version:3,name:'S9 fixture zoom',version:'1.0',permissions:['tabs'],background:{service_worker:'zoom.js'}})); await writeFile(join(ext, 'zoom.js'), "chrome.tabs.onUpdated.addListener((id,change,tab)=>{if(tab.url?.startsWith('http://127.0.0.1:')) chrome.tabs.setZoom(id,2)});\n"); const launcher = join(dir, 'chromium'); await writeFile(launcher, `#!/usr/bin/env python3\nimport json, math, os, pathlib, sys\np = pathlib.Path(next(a.split('=',1)[1] for a in sys.argv[1:] if a.startswith('--user-data-dir='))) / 'Default'\np.mkdir(exist_ok=True)\n(p / 'Preferences').write_text(json.dumps({'partition': {'default_zoom_level': math.log(2)/math.log(1.2)}}))\nos.execv('/usr/bin/chromium', ['/usr/bin/chromium', '--disable-extensions-except=${ext}', '--load-extension=${ext}', *sys.argv[1:]])\n`); await chmod(launcher, 0o755); return {dir,launcher}; } async function setup(t, width, zoom = false, source = stub, height = width===390?900:1000) { const f = await fixture(); const previous = process.env.CHROMIUM; const z = zoom ? await zoomLauncher() : null; if (z) process.env.CHROMIUM = z.launcher; let b; try { b = await browser({machine:false}); } finally { if (previous === undefined) delete process.env.CHROMIUM; else process.env.CHROMIUM = previous; } t.after(async () => { await b.close(); await f.close(); if(z) await rm(z.dir,{recursive:true,force:true}); }); await b.call('Fetch.enable',{patterns:[{urlPattern:'*'}]}); b.on('Fetch.requestPaused',p=>void b.call(p.request.url.startsWith(f.base+'/')?'Fetch.continueRequest':'Fetch.failRequest',p.request.url.startsWith(f.base+'/')?{requestId:p.requestId}:{requestId:p.requestId,errorReason:'BlockedByClient'}).catch(()=>{})); await b.call('Page.addScriptToEvaluateOnNewDocument',{source}); await b.viewport(width,height); await b.navigate(f.base+'/#/projects/'+P); const wait=expr=>b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expr})return;await new Promise(r=>setTimeout(r,30))}throw Error('Timeout '+${JSON.stringify(expr)})})()`); if(zoom) await wait(`devicePixelRatio===2 && innerWidth===${width / 2}`); await wait("document.querySelector('[data-project-content] [data-project-room-status]')"); return {b,wait}; } // Enough history to expose page-flow behavior at both zoomed sizes. const history = stub + `;for(let i=0;i<42;i++){const n=String(i+10).padStart(12,'0'),body='History '+i+' '+('message text '.repeat(8)),at='2026-11-06T10:26:00Z';fixtureMessages.push({id:'40000000-0000-4000-8000-'+n,from_role:'human',body,at});const id='50000000-0000-4000-8000-'+n;fixtureRoom.push({id,ids:[id],from_role:'human',to:['assistant'],body,at})}`; const settle = b => b.evaluate('(async()=>{for(let i=0;i<12;i++)await new Promise(requestAnimationFrame)})()'); const metrics = b => b.evaluate(`(()=>{const log=document.querySelector('.project-chat .chat-log'),form=document.querySelector('.project-chat form[id$="send-form"]');const rect=e=>{const r=e.getBoundingClientRect();return {top:r.top,bottom:r.bottom}};return {y:scrollY,height:innerHeight,documentHeight:document.documentElement.scrollHeight,last:rect(log.lastElementChild),form:rect(form),field:rect(form.querySelector('textarea')),send:rect(form.querySelector('button[type=submit]'))}})()`); const inView = (r,h) => r.top>=-1 && r.bottom<=h+1; const check = (m,stage) => { assert.ok(inView(m.form,m.height) && inView(m.field,m.height) && inView(m.send,m.height),`${stage}: composer outside viewport: ${JSON.stringify(m)}`); assert.ok(m.last.bottom>=-1 && m.last.bottom<=m.form.top+1,`${stage}: newest message end not visible above composer: ${JSON.stringify(m)}`); // Only a message that cannot share the screen with the composer may start above it. if(m.form.bottom-m.last.top<=m.height+1) assert.ok(m.last.top>=-1,`${stage}: newest message cropped although it fits: ${JSON.stringify(m)}`); }; for (const width of [1440,390]) for(const [name,route] of [['room','/projects/rooms/'+P],['conversation','/projects/threads/'+T]]) { test(`S12 ${name} opens and follows with composer visible at ${width} with 200% tab zoom`,async t=>{ const {b,wait}=await setup(t,width,true,history); await b.evaluate(`location.hash=${JSON.stringify('#'+route)}`); await wait("document.querySelectorAll('.project-chat .chat-log > li').length >= 42"); await settle(b); const initial=await metrics(b); console.log('S12 initial',width,name,JSON.stringify(initial)); if(process.env.S12_CAPTURE) await b.screenshot(`${process.env.S12_CAPTURE}/${process.env.S12_PHASE}-${name}-${width}-200.png`); check(initial,'open'); const add=async suffix=>{ await b.evaluate(`(()=>{const id='${name==='room'?'50000000':'40000000'}-0000-4000-8000-'+${JSON.stringify(suffix)},m={id,from_role:'human',body:'New message '+${JSON.stringify(suffix)},at:'2026-11-06T10:29:00Z'};${name==='room'?"m.ids=[id];m.to=['assistant'];fixtureRoom.push(m)":'fixtureMessages.push(m)'};document.querySelector('#refresh').click()})()`); await wait(`document.querySelector('[data-message$="${suffix}"]')`); await settle(b); }; await add('000000000201'); const pinned=await metrics(b); check(pinned,'new while following'); // A short scroll up that leaves the newest message's end at the viewport bottom // (the S11 pin position) is still the reader leaving the tail. CDP wheel deltas // are device pixels: twice CSS at 200% tab zoom. await b.call('Input.dispatchMouseEvent',{type:'mouseWheel',x:80,y:100,deltaX:0,deltaY:-2*(pinned.height-pinned.last.bottom)}); await settle(b); const partial=await metrics(b); assert.ok(partial.y{const a=document.createElement('a');a.href='#example';a.textContent='Message link';document.querySelector('.chat-log').lastElementChild.append(a)})()"); await settle(b); await b.evaluate("document.querySelector('.chat-log li:last-child a').focus({preventScroll:true})"); for(let i=0;i<8;i++){ const before=await metrics(b); await b.key('Tab'); await settle(b); if(await b.evaluate("document.activeElement.matches('.project-chat textarea')")){ const focused=await metrics(b); check(focused,'focused composer'); assert.ok(Math.abs(focused.y-before.y)<=2,`Tab moved page: ${JSON.stringify({before,focused})}`); await b.key('k'); await settle(b); const typed=await metrics(b); check(typed,'typing'); assert.ok(Math.abs(typed.y-focused.y)<=2 && await b.evaluate("document.activeElement.value.endsWith('k')"),`typing moved page: ${JSON.stringify({focused,typed})}`); return; } } assert.fail('Tab did not reach composer textarea'); }); } for(const height of [1000,700]) test(`S12 screenshot ordinary conversation at 1440x${height}`,async t=>{ const {b,wait}=await setup(t,1440,false,history,height); await b.evaluate(`location.hash=${JSON.stringify('#/projects/threads/'+T)}`); await wait("document.querySelectorAll('.project-chat .chat-log > li').length >= 42"); await settle(b); if(process.env.S12_CAPTURE && process.env.S12_PHASE==='after') await b.screenshot(`${process.env.S12_CAPTURE}/after-conversation-1440x${height}-100.png`); }); test('S12 archived Room without composer keeps the newest message at the viewport edge',async t=>{ const {b,wait}=await setup(t,390,true,history+';fixtureProjects[0].archived=true'); await b.evaluate(`location.hash='#/projects/rooms/${P}'`); await wait("document.querySelectorAll('.project-chat .chat-log > li').length >= 42"); await settle(b); const m=await b.evaluate(`(()=>{const log=document.querySelector('.chat-log');return {form:!!document.querySelector('#chat-room-send-form'),bottom:log.lastElementChild.getBoundingClientRect().bottom,height:innerHeight}})()`); assert.equal(m.form,false,JSON.stringify(m)); assert.ok(Math.abs(m.bottom-m.height)<=1,JSON.stringify(m)); });