Files
stack/packages/webui/tests/s12-composer-browser.test.mjs
jason.woltje 0e34b30770 fix(webui): keep a short scroll up off the tail in zoomed transcripts
An upward reader scroll that left the newest message's end at the
viewport bottom still read as at tail, so new content pulled the reader
down past the composer pin. The message-end tail position now counts
only on a downward scroll. A channel-disabled Conversation pins the
newest message instead of a composer that cannot send. S11 and S12
visibility checks require the whole newest message whenever it fits
above the composer.
2026-11-07 04:22:46 -06:00

113 lines
9.4 KiB
JavaScript

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<pinned.y-20 && Math.abs(partial.last.bottom-partial.height)<40,`partial scroll missed the band: ${JSON.stringify({pinned,partial})}`);
await add('000000000202'); const partialHeld=await metrics(b);
assert.ok(Math.abs(partialHeld.y-partial.y)<=2,`short scroll up pulled back: ${JSON.stringify({partial,partialHeld})}`);
await b.call('Input.dispatchMouseEvent',{type:'mouseWheel',x:80,y:100,deltaX:0,deltaY:initial.height*4}); await settle(b);
await add('000000000203'); const tail=await metrics(b); check(tail,'resumed after short scroll');
await b.call('Input.dispatchMouseEvent',{type:'mouseWheel',x:80,y:100,deltaX:0,deltaY:-initial.height}); await settle(b);
const up=await metrics(b); assert.ok(up.y<tail.y-100,JSON.stringify(up));
await add('000000000204'); const held=await metrics(b);
assert.ok(Math.abs(held.y-up.y)<=2,`reader moved: ${JSON.stringify({up,held})}`);
await b.call('Input.dispatchMouseEvent',{type:'mouseWheel',x:80,y:100,deltaX:0,deltaY:initial.height*4}); await settle(b);
await add('000000000205'); check(await metrics(b),'resumed follow');
// A link in the newest message is the last transcript tab stop.
await b.evaluate("(()=>{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));
});