S10 review P1: the max-height:700px page-flow rule moved the composer off screen on 1440x700 and 1366x650 windows at 100%. Page flow now applies only at max-height:540px (200% tab zoom on desktop screens) or below 320px wide. S10 review P2: the moved Room details control keeps its 44px target.
126 lines
12 KiB
JavaScript
126 lines
12 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};
|
|
}
|
|
const oneWord = stub.replace("name:'Build offline timer'", "name:'assistant'");
|
|
for (const width of [1440,390]) for (const [name,route] of [['room','/projects/rooms/'+P],['conversation','/projects/threads/'+T]]) {
|
|
test(`S10 ${name} transcript stays readable at ${width} with actual 200% tab zoom`,async t=>{
|
|
const {b,wait}=await setup(t,width,true,oneWord);
|
|
await b.evaluate(`location.hash=${JSON.stringify('#'+route)}`);
|
|
await wait("document.querySelector('.project-chat .chat-log > li:last-child')");
|
|
if(name==='conversation') await wait("document.querySelector('.project-request-item .request-panel')");
|
|
await b.evaluate('document.fonts.ready.then(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))))');
|
|
const data=await b.evaluate(`(()=>{const log=document.querySelector('.project-chat .chat-log'),last=log.lastElementChild;last.scrollIntoView({block:'end'});const l=log.getBoundingClientRect(),r=last.getBoundingClientRect(),d=document.documentElement;return {width:innerWidth,height:innerHeight,dpr:devicePixelRatio,client:log.clientHeight,content:log.scrollHeight,documentClient:d.clientHeight,documentScroll:d.scrollHeight,logTop:l.top,logBottom:l.bottom,lastTop:r.top,lastBottom:r.bottom}})()`);
|
|
console.log('S10 transcript',width,name,JSON.stringify(data));
|
|
if(process.env.S10_CAPTURE && (width===1440 || name==='conversation')) await b.screenshot(`${process.env.S10_CAPTURE}/${process.env.S10_PHASE}-${name}-${width}-200.png`);
|
|
assert.ok(data.client>=160 || (data.documentScroll>data.documentClient && data.client>=data.content),`collapsed transcript: ${JSON.stringify(data)}`);
|
|
assert.ok(data.lastTop>=Math.max(0,data.logTop)-1 && data.lastBottom<=Math.min(data.height,data.logBottom)+1,`newest message not fully visible: ${JSON.stringify(data)}`);
|
|
});
|
|
}
|
|
test('S10 one-word conversation title does not break at phone 200% zoom',async t=>{
|
|
const {b,wait}=await setup(t,390,true,oneWord);
|
|
await b.evaluate(`location.hash=${JSON.stringify('#/projects/threads/'+T)}`);
|
|
await wait("document.querySelector('.project-header[data-project-conversation=true] h1')?.textContent==='assistant'");
|
|
await b.evaluate('document.fonts.ready.then(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))))');
|
|
// A Range per character observes actual line placement, including anywhere wraps.
|
|
const data=await b.evaluate(`(()=>{const h=document.querySelector('.project-heading h1'),n=h.firstChild,r=document.createRange(),tops=[];for(let i=0;i<n.length;i++){r.setStart(n,i);r.setEnd(n,i+1);tops.push(r.getBoundingClientRect().top)}return {text:h.textContent,client:h.clientWidth,font:getComputedStyle(h).fontSize,tops}})()`);
|
|
console.log('S10 title',JSON.stringify(data));
|
|
assert.equal(new Set(data.tops).size,1,`title broke inside its word: ${JSON.stringify(data)}`);
|
|
});
|
|
for(const width of [1440,390]) for(const [name,route] of [['room','/projects/rooms/'+P],['conversation','/projects/threads/'+T]]) test(`S10 ${name} retains its single transcript scroller at ${width} and 100%`,async t=>{
|
|
const {b,wait}=await setup(t,width,false,oneWord);
|
|
await b.evaluate(`location.hash=${JSON.stringify('#'+route)}`);
|
|
await wait("document.querySelector('.project-chat .chat-log > li:last-child')");
|
|
if(name==='conversation') await wait("document.querySelector('.project-request-item .request-panel')");
|
|
await b.evaluate('document.fonts.ready.then(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))))');
|
|
await b.evaluate('(async()=>{for(let i=0;i<12;i++)await new Promise(requestAnimationFrame)})()');
|
|
const data=await b.evaluate(`(()=>{const d=document.documentElement,l=document.querySelector('.project-chat .chat-log');return {documentClient:d.clientHeight,documentScroll:d.scrollHeight,client:l.clientHeight,content:l.scrollHeight,bottom:l.scrollHeight-l.clientHeight-l.scrollTop}})()`);
|
|
console.log('S10 ordinary layout',width,name,JSON.stringify(data));
|
|
if(process.env.S10_CAPTURE && process.env.S10_PHASE==='after' && name==='conversation') await b.screenshot(`${process.env.S10_CAPTURE}/after-conversation-${width}-100.png`);
|
|
assert.ok(data.documentScroll<=data.documentClient,`page scrolls: ${JSON.stringify(data)}`);
|
|
assert.ok(data.bottom<=1,`newest message is not visible on load: ${JSON.stringify(data)}`);
|
|
});
|
|
for(const [name,route] of [['conversation','/projects/threads/'+T],['project','/projects/'+P],['room','/projects/rooms/'+P]]) test(`S10 12-character ${name} title fits at phone 200% zoom`,async t=>{
|
|
const source=stub.replace("name:'Build offline timer'", "name:'conversation'").replace("name:'Pomodoro Timer'", "name:'conversation'");
|
|
const {b,wait}=await setup(t,390,true,source);
|
|
await b.evaluate(`location.hash=${JSON.stringify('#'+route)}`);
|
|
await wait("document.querySelector('.project-heading h1')?.textContent==='conversation'");
|
|
await b.evaluate('document.fonts.ready.then(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))))');
|
|
const data=await b.evaluate(`(()=>{const h=document.querySelector('.project-heading h1'),n=h.firstChild,r=document.createRange(),tops=[];for(let i=0;i<n.length;i++){r.setStart(n,i);r.setEnd(n,i+1);tops.push(r.getBoundingClientRect().top)}return {client:h.clientWidth,font:getComputedStyle(h).fontSize,tops}})()`);
|
|
console.log('S10 twelve-character title',name,JSON.stringify(data));
|
|
assert.equal(new Set(data.tops).size,1,`title broke inside its word: ${JSON.stringify(data)}`);
|
|
});
|
|
|
|
test('S10 tall wrapped heading cannot collapse the transcript at an intermediate viewport height',async t=>{
|
|
const source=stub.replace("name:'Build offline timer'", "name:'A very long conversation title that wraps across many lines for readable content'");
|
|
const {b,wait}=await setup(t,390,false,source,720);
|
|
await b.evaluate(`location.hash=${JSON.stringify('#/projects/threads/'+T)}`);
|
|
await wait("document.querySelector('.project-chat .chat-log > li:last-child') && document.querySelector('.project-request-item .request-panel')");
|
|
const data=await b.evaluate(`(()=>{const log=document.querySelector('.project-chat .chat-log'),d=document.documentElement;return {client:log.clientHeight,content:log.scrollHeight,documentClient:d.clientHeight,documentScroll:d.scrollHeight}})()`);
|
|
console.log('S10 intermediate height',JSON.stringify(data));
|
|
assert.ok(data.client>=160 || (data.documentScroll>data.documentClient && data.client>=data.content),`collapsed transcript: ${JSON.stringify(data)}`);
|
|
});
|
|
|
|
// Short desktop windows at 100% keep the pinned composer and an independent transcript.
|
|
for(const [w,h] of [[1440,700],[1366,650]]) for(const [name,route] of [['room','/projects/rooms/'+P],['conversation','/projects/threads/'+T]]) test(`S10 ${name} keeps its own transcript scroller in a short ${w}x${h} window at 100%`,async t=>{
|
|
const {b,wait}=await setup(t,w,false,oneWord,h);
|
|
await b.evaluate(`location.hash=${JSON.stringify('#'+route)}`);
|
|
await wait("document.querySelector('.project-chat .chat-log > li:last-child')");
|
|
if(name==='conversation') await wait("document.querySelector('.project-request-item .request-panel')");
|
|
await b.evaluate('document.fonts.ready.then(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))))');
|
|
await b.evaluate('(async()=>{for(let i=0;i<12;i++)await new Promise(requestAnimationFrame)})()');
|
|
const data=await b.evaluate(`(()=>{const d=document.documentElement,l=document.querySelector('.project-chat .chat-log');return {block:getComputedStyle(document.querySelector('.project-chat')).display,documentClient:d.clientHeight,documentScroll:d.scrollHeight,client:l.clientHeight,content:l.scrollHeight,bottom:l.scrollHeight-l.clientHeight-l.scrollTop}})()`);
|
|
console.log('S10 short window',w,h,name,JSON.stringify(data));
|
|
assert.notEqual(data.block,'block',`short window switched to the narrow normal-flow layout: ${JSON.stringify(data)}`);
|
|
assert.ok(data.client>=160,`transcript below its 160px minimum: ${JSON.stringify(data)}`);
|
|
if(name==='conversation') assert.ok(data.documentScroll<=data.documentClient,`page scrolls: ${JSON.stringify(data)}`);
|
|
assert.ok(data.documentScroll<=data.documentClient+120,`page grew with the history: ${JSON.stringify(data)}`);
|
|
assert.ok(data.bottom<=1,`newest message is not visible on load: ${JSON.stringify(data)}`);
|
|
});
|
|
|
|
test('S10 Room details keeps a 44px target at phone 200% zoom',async t=>{
|
|
const {b,wait}=await setup(t,390,true,oneWord);
|
|
await b.evaluate(`location.hash=${JSON.stringify('#/projects/rooms/'+P)}`);
|
|
await wait("document.querySelector('.project-chat .project-section-title [data-project-dialog=\"room\"]')");
|
|
await b.evaluate('document.fonts.ready.then(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))))');
|
|
const r=await b.evaluate(`(()=>{const r=document.querySelector('.project-chat .project-section-title [data-project-dialog="room"]').getBoundingClientRect();return {width:r.width,height:r.height}})()`);
|
|
console.log('S10 room details',JSON.stringify(r));
|
|
assert.ok(r.width>=44-0.5 && r.height>=44-0.5,`Room details target below 44px: ${JSON.stringify(r)}`);
|
|
});
|