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.
131 lines
11 KiB
JavaScript
131 lines
11 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'),r=log.lastElementChild.getBoundingClientRect(),d=document.documentElement,f=document.querySelector('.project-chat form[id$="send-form"]')?.getBoundingClientRect();return {top:scrollY,height:innerHeight,documentHeight:d.scrollHeight,lastTop:r.top,lastBottom:r.bottom,formTop:f?.top,formBottom:f?.bottom,logTop:log.scrollTop,logBottom:log.scrollHeight-log.clientHeight-log.scrollTop}})()`);
|
|
// Whole newest message in view. When it and the composer below it cannot both fit (S12), the
|
|
// end of the message above the composer is enough.
|
|
const visible = m => {
|
|
const whole = m.lastTop>=-1 && m.lastBottom<=m.height+1;
|
|
const fits = m.formBottom===undefined || m.formBottom-m.lastTop <= m.height+1;
|
|
return whole || !fits && m.lastBottom>=1 && m.lastBottom<=Math.min(m.height,m.formTop)+1;
|
|
};
|
|
for (const width of [1440,390]) for(const [name,route] of [['room','/projects/rooms/'+P],['conversation','/projects/threads/'+T]]) {
|
|
test(`S11 ${name} follows page-flow transcript 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('S11 initial',width,name,JSON.stringify(initial));
|
|
if(process.env.S11_CAPTURE) await b.screenshot(`${process.env.S11_CAPTURE}/${process.env.S11_PHASE}-${name}-${width}-200.png`);
|
|
assert.ok(initial.documentHeight >= initial.height*3,JSON.stringify(initial));
|
|
assert.ok(visible(initial),`newest message not visible on opening: ${JSON.stringify(initial)}`);
|
|
await b.call('Input.dispatchMouseEvent',{type:'mouseWheel',x:80,y:100,deltaX:0,deltaY:-initial.height*2}); await settle(b);
|
|
const up=await metrics(b);
|
|
assert.ok(up.top < initial.top-100,JSON.stringify({initial,up}));
|
|
await b.evaluate(`(()=>{const id='${name==='room'?'50000000':'40000000'}-0000-4000-8000-000000000099',m={id,from_role:'human',body:'New message while reading',at:'2026-11-06T10:27: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$=\"000000000099\"]')"); await settle(b);
|
|
const held=await metrics(b); console.log('S11 held',width,name,JSON.stringify({up,held}));
|
|
assert.ok(Math.abs(held.top-up.top)<=2,`reader moved: ${JSON.stringify({up,held})}`);
|
|
assert.ok(!visible(held),JSON.stringify(held));
|
|
await b.evaluate("document.querySelector('.chat-log').lastElementChild.scrollIntoView({block:'end'})"); await settle(b);
|
|
await b.evaluate(`(()=>{const id='${name==='room'?'50000000':'40000000'}-0000-4000-8000-000000000100',m={id,from_role:'human',body:'New message at tail',at:'2026-11-06T10:28: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$=\"000000000100\"]')"); await settle(b);
|
|
const followed=await metrics(b); console.log('S11 followed',width,name,JSON.stringify(followed));
|
|
assert.ok(visible(followed),`new message not followed: ${JSON.stringify(followed)}`);
|
|
});
|
|
}
|
|
|
|
test('S11 reader ending at an old pin after growth keeps position on the log scroller',async t=>{
|
|
const {b,wait}=await setup(t,1440,false,history);
|
|
await b.evaluate(`location.hash=${JSON.stringify('#/projects/threads/'+T)}`);
|
|
await wait("document.querySelectorAll('.project-chat .chat-log > li').length >= 42"); await settle(b);
|
|
const before=await b.evaluate(`(async()=>{const l=document.querySelector('.chat-log'),last=l.lastElementChild.querySelector('.chat-body');last.append(' pin '.repeat(150));await Promise.resolve();const pin=l.scrollTop;last.style.minHeight=(last.getBoundingClientRect().height+250)+'px';l.scrollTop=pin+100;l.scrollTop=pin;return {pin,top:l.scrollTop,gap:l.scrollHeight-l.clientHeight-l.scrollTop}})()`);
|
|
await settle(b);
|
|
await b.evaluate("document.querySelector('.chat-log li:last-child .chat-body').append(' next '.repeat(150))"); await settle(b);
|
|
const after=await metrics(b); console.log('S11 collision',JSON.stringify({before,after}));
|
|
assert.ok(before.gap>=249,JSON.stringify(before));
|
|
assert.ok(Math.abs(after.logTop-before.pin)<=2,`reader was pulled to tail: ${JSON.stringify({before,after})}`);
|
|
});
|
|
|
|
for(const height of [1000,700]) test(`S11 conversation retains ordinary layout 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);
|
|
const m=await metrics(b); console.log('S11 ordinary',height,JSON.stringify(m));
|
|
if(process.env.S11_CAPTURE && process.env.S11_PHASE==='after') await b.screenshot(`${process.env.S11_CAPTURE}/after-conversation-1440x${height}-100.png`);
|
|
assert.ok(m.documentHeight<=m.height && m.logBottom<=1,JSON.stringify(m));
|
|
});
|
|
|
|
// S11 review P1: in page flow, focus moves, find-style reveals and dialog focus
|
|
// return scroll the document too. They must not be pulled back to the tail.
|
|
const focusSeen = b => b.evaluate(`(()=>{const a=document.activeElement,r=a.getBoundingClientRect();return {tag:a.tagName,top:r.top,bottom:r.bottom,height:innerHeight,y:scrollY,visible:a!==document.body&&r.bottom>0&&r.top<innerHeight}})()`);
|
|
for (const width of [1440,390]) for(const [name,route] of [['room','/projects/rooms/'+P],['conversation','/projects/threads/'+T]]) {
|
|
test(`S11 ${name} keeps keyboard focus and reveals on screen at ${width} with 200% tab zoom`,async t=>{
|
|
const {b,wait}=await setup(t,width,true,history);
|
|
const open=async()=>{await b.evaluate(`location.hash='#/projects/${P}'`);await wait("!document.querySelector('.project-chat .chat-log')");await b.evaluate(`location.hash=${JSON.stringify('#'+route)}`);await wait("document.querySelectorAll('.project-chat .chat-log > li').length >= 42");await settle(b);assert.ok(visible(await metrics(b)),'opens at the newest message');};
|
|
await open();
|
|
for(let i=0;i<3;i++){await b.key('Tab');await settle(b);const f=await focusSeen(b);assert.ok(f.visible,`Tab ${i+1} focus off screen: ${JSON.stringify(f)}`);}
|
|
await open();
|
|
await b.key('Tab','Tab',8); await settle(b);
|
|
let f=await focusSeen(b); assert.ok(f.visible,`Shift+Tab focus off screen: ${JSON.stringify(f)}`);
|
|
await open();
|
|
await b.evaluate("document.querySelector('.project-tabs a').focus()"); await settle(b);
|
|
f=await focusSeen(b); assert.ok(f.visible,`programmatic focus off screen: ${JSON.stringify(f)}`);
|
|
await open();
|
|
await b.evaluate("document.querySelectorAll('.project-chat .chat-log > li')[3].scrollIntoView({block:'center'})"); await settle(b);
|
|
const reveal=()=>b.evaluate("(()=>{const r=document.querySelectorAll('.project-chat .chat-log > li')[3].getBoundingClientRect();return {top:r.top,bottom:r.bottom,height:innerHeight,y:scrollY}})()");
|
|
const shown=await reveal(); assert.ok(shown.bottom>0&&shown.top<shown.height,`reveal undone: ${JSON.stringify(shown)}`);
|
|
await b.evaluate(`(()=>{const id='${name==='room'?'50000000':'40000000'}-0000-4000-8000-000000000101',m={id,from_role:'human',body:'New message after reveal',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$=\"000000000101\"]')"); await settle(b);
|
|
const held=await reveal(); assert.ok(Math.abs(held.y-shown.y)<=2,`reader moved after reveal: ${JSON.stringify({shown,held})}`);
|
|
await open();
|
|
await b.evaluate("document.querySelector('[data-project-dialog]').focus()"); await settle(b);
|
|
await b.key('Enter'); await settle(b);
|
|
assert.ok(await b.evaluate("!!document.querySelector('dialog[open]')"),'dialog opened');
|
|
await b.key('Escape'); await settle(b);
|
|
f=await focusSeen(b); assert.ok(f.visible,`dialog return focus off screen: ${JSON.stringify(f)}`);
|
|
});
|
|
}
|