Files
stack/packages/webui/tests/e2e6-layout-browser.test.mjs
jason.woltje 266f3a45ff fix(webui): E2E6 review round, apply-phase refusals, held sections, tab fade, header pins (#1873)
Review finding 1: a hire start refusal the apply step can also raise after the decision is
consumed (decision-stale, organization-instance-*, organization-refused, definition-missing,
invalid-request and the rest) no longer says "Nothing was applied". It says that part may be
applied when the inbox shows the proposal as answered, and to check Agents first. Only codes
that prepare() or the route checks alone raise keep "Nothing was applied".
Review P3: a failed workflows or autonomy read keeps the last good read, so an open editor or
a dirty form stays through it. Focus holds a section only in a field being typed in; a focused
button is rebuilt and gets focus back by data-focus-key. Autonomy has Discard changes. The
project tab fade is 0px when every tab fits.
s8 and s9 header pins at 1440 now measure from the content edge: 1418 was 100vw-based, ran
under the page scrollbar and is the P2-11 clipping itself.

Correction to the previous commit's message: P1-2 is the unknown project, agent or harness
wording; P2-4 names the refused field; P2-6 is the Queue refusal reason; P2-8 is "bus stopped".
2026-11-13 19:52:26 -06:00

82 lines
6.1 KiB
JavaScript

// E2E6 P2-11, P2-12, P2-13 (#1873): layout pins measured in a real browser.
// P2-11: a project's header stays inside its column at desktop widths, so its actions are not cut off.
// P2-12: at phone width the project tabs fade at the right edge while more tabs lie past it, and the
// fade is gone once the row is scrolled to its end or when every tab fits.
// P2-13: Restart bus and Stop bus share one top and height, on the organization page and in a pane.
import test from 'node:test';
import assert from 'node:assert/strict';
import { fixture, close } from './fixture.mjs';
import { browser } from './browser.mjs';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { P, stub } from './helpers/projects-review-fixture.mjs';
const until = (b, expression, what) => b.evaluate(`(async()=>{for(let i=0;i<300;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,25))}throw Error(${JSON.stringify(what)})})()`);
const frames = b => b.evaluate('new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(()=>r(true))))');
async function project(t, width) {
const f = await fixture(), b = await browser({ machine: false, args: ['--disable-extensions'] });
t.after(async () => { await b.close(); await f.close(); });
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub });
await b.viewport(width, 900);
await b.navigate(`${f.base}/#/projects/${P}`);
await until(b, `document.querySelector('.project-header .project-actions')`, 'project header');
await b.evaluate('new Promise(r=>setTimeout(r,400))');
return b;
}
for (const width of [1440, 1280]) {
test(`E2E6 P2-11: the project header and its actions stay inside the column at ${width}`, { timeout: 60000 }, async t => {
const b = await project(t, width);
const m = JSON.parse(await b.evaluate(`JSON.stringify((()=>{const h=document.querySelector('.project-header'),p=h.parentElement.getBoundingClientRect(),r=h.getBoundingClientRect();return {right:r.right,parent:p.right,actions:[...h.querySelectorAll('.project-actions > *')].map(a=>a.getBoundingClientRect().right),page:[document.documentElement.scrollWidth,innerWidth]}})())`));
assert.ok(m.right <= m.parent + 0.5, `header right ${m.right} inside its column ${m.parent}`);
for (const a of m.actions) assert.ok(a <= m.parent + 0.5, `action right ${a} inside ${m.parent}`);
assert.ok(m.page[0] <= m.page[1], 'no sideways page scroll');
});
}
test('E2E6 P2-12: at 390 the project tabs fade at the edge until scrolled to the end', { timeout: 60000 }, async t => {
const b = await project(t, 390);
const read = () => b.evaluate(`JSON.stringify((()=>{const e=document.querySelector('.project-tabs'),s=getComputedStyle(e);return {mask:s.maskImage||s.webkitMaskImage,fade:s.getPropertyValue('--tabs-fade').trim(),sw:e.scrollWidth,cw:e.clientWidth}})())`).then(JSON.parse);
const start = await read();
assert.ok(start.sw > start.cw, `the tabs overflow (${start.sw} > ${start.cw})`);
assert.match(start.mask, /linear-gradient/, 'a fade mask is set');
assert.equal(start.fade, '48px', 'a 48px fade while more tabs lie past the edge');
await b.evaluate(`{const e=document.querySelector('.project-tabs');e.scrollLeft=e.scrollWidth}true`);
await frames(b);
assert.equal((await read()).fade, '0px', 'no fade once the row is scrolled to its end');
// Review P3: a strip whose tabs all fit has nothing past the edge, so it has no fade.
await b.evaluate(`{const e=document.querySelector('.project-tabs');[...e.children].slice(1).forEach(c=>c.style.display='none');e.scrollLeft=0}true`);
await frames(b);
const fits = await read();
assert.ok(fits.sw <= fits.cw, `the remaining tab fits (${fits.sw} <= ${fits.cw})`);
assert.equal(fits.fade, '0px', 'no fade when nothing is cut off');
});
const org = {
busHealthy: true, decisionWrite: async () => ({}), send: async () => ({}),
requestRead: async () => { throw Object.assign(new Error('not-configured'), { code: 'not-configured' }); },
reads: { business: async () => ({ business: { id: 'acme', projects: [], instances: [] } }), settings: async () => ({}), queue: async () => ({ rows: [] }) },
bus: { instances: async () => ({ instances: {} }), agents: async () => [], 'thread.claims': async () => [], tasks: async () => [], inbox: async () => [], messages: async () => ({ messages: [] }) },
chat: { projects: async () => [], threads: async () => [] },
};
for (const [width, route, where] of [[1440, 'orgs/acme/organization', 'the organization page'], [390, 'orgs/acme/organization', 'the organization page'], [1440, 'orgs/acme/side-by-side?a=acme/organization&b=acme/agents', 'a pane'], [390, 'orgs/acme/side-by-side?a=acme/agents&b=acme/organization&show=b', 'a pane']]) {
test(`E2E6 P2-13: Restart bus and Stop bus line up on ${where} at ${width}`, { timeout: 60000 }, async t => {
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), log: () => {}, organizations: { list: async () => [{ id: 'acme', label: 'Acme', busHealthy: true }], resolve: async () => org } });
t.after(() => close(server));
const b = await browser({ machine: false, args: ['--disable-extensions'] });
t.after(() => b.close());
await b.viewport(width, 900);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/${route}`);
const pair = `[...document.querySelectorAll('[data-bus-restart],[data-bus-restart-off],[data-bus-stop],[data-bus-stop-off]')].filter(e=>e.getClientRects().length)`;
await until(b, `${pair}.length===2`, 'the two bus buttons');
await b.evaluate('new Promise(r=>setTimeout(r,300))');
const [restart, stop] = JSON.parse(await b.evaluate(`JSON.stringify(${pair}.map(e=>{const r=e.getBoundingClientRect();return {text:e.textContent.trim(),top:r.top,height:r.height,left:r.left,right:r.right}}))`));
assert.equal(restart.text, 'Restart bus');
assert.equal(stop.text, 'Stop bus');
assert.ok(Math.abs(restart.top - stop.top) < 0.5, `same top (${restart.top} vs ${stop.top})`);
assert.ok(Math.abs(restart.height - stop.height) < 0.5, `same height (${restart.height} vs ${stop.height})`);
assert.ok(stop.left - restart.right >= 4, 'a gap between them');
});
}