feat(webui): console tokens and shell restyle (#1542, row 53)
The console takes the design package's tokens.css and icons.svg as byte
copies, guarded by a test, and a new shell.css holds the components:
command bar with freshness line, section list (a strip that scrolls
inside itself below 760px), dense table, inspector, status mark, chips,
copy command, toast, Ctrl+K palette, not found, banners and skeleton
rows. JetBrains Mono 2.304 ships from the official archive with sha256
and OFL. Harbor following the system setting is the default palette.
A refusal (403, not-configured, no-bus-host, or the board's 403) fails
closed everywhere: no kept rows, counts, inspector, palette entries or
conversation history, and a later 500 or 503 doesn't bring them back.
Dewey built it in three rounds. Round 1 (75569953) and round 2
(78c3aef9) got changes from Darkwing (27148, 27165) and Filbert (27143,
27163) for refusal leaks outside the main view; round 3 (2562c05d) was
approved by Darkwing (27171) and Filbert (27174). Sage's gate on
ea080fd8 plus the candidate: runs 41/0, queue 148/0, webui 27/0,
conversation 182/0, control-board 124/0, build-tokens --check current,
every scripts/test-*.sh 0 failed (task 98/0 and release 14/0 with
Docker). The round 2 gate had one discord engine timing failure in the
full run, 66/0 alone, filed as #1553.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -65,8 +65,8 @@ export async function browser() {
|
||||
await writeFile(path, Buffer.from(data, 'base64'));
|
||||
},
|
||||
async key(key, code = key, modifiers = 0) {
|
||||
const virtual = { Tab: 9, Enter: 13, Escape: 27, ArrowDown: 40, ArrowRight: 39, ' ': 32 }[key];
|
||||
await call('Input.dispatchKeyEvent', { type: 'keyDown', key, code, modifiers, windowsVirtualKeyCode: virtual, text: key === 'Enter' ? '\r' : key.length === 1 ? key : '' });
|
||||
const virtual = { Tab: 9, Enter: 13, Escape: 27, ArrowDown: 40, ArrowUp: 38, ArrowRight: 39, ' ': 32, k: 75 }[key];
|
||||
await call('Input.dispatchKeyEvent', { type: 'keyDown', key, code, modifiers, windowsVirtualKeyCode: virtual, text: modifiers ? '' : key === 'Enter' ? '\r' : key.length === 1 ? key : '' });
|
||||
await call('Input.dispatchKeyEvent', { type: 'keyUp', key, code, modifiers, windowsVirtualKeyCode: virtual });
|
||||
},
|
||||
async close() {
|
||||
|
||||
@@ -36,6 +36,10 @@ test('real board fixture passes through WebUI; assets and isolated seen/reply wo
|
||||
for (const path of ['/', '/app.js', '/brand.js', '/console.css', '/live.css', '/shared/app.css', '/assets/fonts/manrope-400.woff2']) {
|
||||
const response = await fetch(f.base + path); assert.equal(response.status, 200, path); assert.equal(response.headers.get('cache-control'), 'no-store');
|
||||
}
|
||||
// #1542: the shell's tokens, sprite, stylesheet and mono fonts, each with its type.
|
||||
for (const [path, type] of [['/tokens.css', 'text/css'], ['/shell.css', 'text/css'], ['/icons.svg', 'image/svg+xml'], ...[400, 500, 700].map(w => [`/assets/fonts/jetbrains-mono-${w}.woff2`, 'font/woff2'])]) {
|
||||
const response = await fetch(f.base + path); assert.equal(response.status, 200, path); assert.ok(response.headers.get('content-type').startsWith(type), path);
|
||||
}
|
||||
assert.equal((await fetch(f.base + '/../../AGENTS.md')).status, 404);
|
||||
assert.equal((await fetch(f.base + '/api/reply')).status, 405);
|
||||
assert.equal((await fetch(f.base + '/api/board', { method: 'OPTIONS' })).status, 405);
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
// #1542 (row 53): the Console shell restyle. Static checks: the design copies match
|
||||
// docs/design byte for byte, the mono fonts match their recorded digests, and the shipped
|
||||
// strings are unchanged. Browser checks: System is the default appearance, the board and
|
||||
// bus states (empty, a failed read over kept data, a refusal), no sideways scroll at 400px,
|
||||
// visible keyboard focus, the Ctrl+K palette, and each view in its five states. Nothing here
|
||||
// writes; the bus is a stub.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createServer } from 'node:http';
|
||||
import { mkdirSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { browser } from './browser.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { BusReadError } from '../src/bus.mjs';
|
||||
|
||||
const pub = new URL('../src/public/', import.meta.url), design = new URL('../../../docs/design/', import.meta.url);
|
||||
const read = (name, base = pub) => readFileSync(new URL(name, base));
|
||||
|
||||
test('tokens.css and icons.svg are byte copies of docs/design', () => {
|
||||
for (const name of ['tokens.css', 'icons.svg']) assert.ok(read(name).equals(read(name, design)), `${name} differs from docs/design/${name}; copy it again`);
|
||||
});
|
||||
|
||||
test('JetBrains Mono files match the digests in their sources file, with the OFL text', () => {
|
||||
const fonts = new URL('assets/fonts/', pub);
|
||||
const listed = new Map(read('jetbrains-mono-sources.txt', fonts).toString().split('\n').map(l => l.match(/^([0-9a-f]{64}) {2}(\S+)$/)).filter(Boolean).map(([, sum, file]) => [file, sum]));
|
||||
const shipped = readdirSync(fonts).filter(f => f.startsWith('jetbrains-mono-') && f !== 'jetbrains-mono-sources.txt').sort();
|
||||
assert.deepEqual([...listed.keys()].sort(), shipped);
|
||||
assert.deepEqual(shipped, ['jetbrains-mono-400.woff2', 'jetbrains-mono-500.woff2', 'jetbrains-mono-700.woff2', 'jetbrains-mono-OFL.txt']);
|
||||
for (const [file, sum] of listed) assert.equal(createHash('sha256').update(read(file, fonts)).digest('hex'), sum, file);
|
||||
assert.match(read('jetbrains-mono-OFL.txt', fonts).toString(), /SIL OPEN FONT LICENSE Version 1\.1/);
|
||||
const css = read('shell.css').toString();
|
||||
for (const w of [400, 500, 700]) assert.ok(css.includes(`url(/assets/fonts/jetbrains-mono-${w}.woff2)`), `shell.css @font-face ${w}`);
|
||||
});
|
||||
|
||||
test('shipped strings are unchanged, and the web keeps its write boundary', () => {
|
||||
// Markup in the source (<code>, <b>) is not part of the string. Each string is counted in the
|
||||
// file that ships it, so dropping one of two copies fails too.
|
||||
const src = ['app.js', 'bus.js', 'index.html'].map(f => read(f).toString()).join('\n');
|
||||
const plain = Object.fromEntries(['app.js', 'bus.js'].map(f => [f, read(f).toString().replace(/<\/?(code|b)>/g, '')]));
|
||||
for (const [file, s, n = 1] of [
|
||||
['bus.js', 'Copied: ${text}'],
|
||||
['bus.js', 'The clipboard is unavailable. The command is selected; press Ctrl+C to copy it.'],
|
||||
['bus.js', 'Decisions are answered in a terminal with mosaic decide; Console only shows them.'],
|
||||
['bus.js', 'No task ${txt(ref)} in this business.', 2], ['bus.js', 'No decision with id ${txt(id)}.'],
|
||||
['bus.js', '${txt(b)} is not a decision id.'], ['bus.js', '${txt(b)} is not a task reference.'],
|
||||
['bus.js', 'No page at this address.'], ['app.js', 'Nothing is waiting on you.'], ['bus.js', 'Nothing is waiting on you.'],
|
||||
['bus.js', 'No tasks in this business yet.'], ['bus.js', 'Tasks appear after the PM creates them or the poller reads them from Vikunja.'],
|
||||
['bus.js', 'No open tasks.'], ['bus.js', 'No role is held right now.'], ['bus.js', 'Nothing of this kind in the trail.'],
|
||||
['bus.js', 'Console shows nothing rather than a guess. Nothing was written.'],
|
||||
]) assert.equal(plain[file].split(s).length - 1, n, `${file}: ${s}`);
|
||||
// No Resolve control and no bus write: the only POSTs are the board's Seen and reply.
|
||||
assert.doesNotMatch(src, /<button[^>]*>[^<]*(Resolve|Decide|Approve)/i);
|
||||
assert.doesNotMatch(read('bus.js').toString(), /method:\s*['"](POST|PUT|PATCH|DELETE)/);
|
||||
assert.equal(read('icons.svg').toString().includes('<script'), false);
|
||||
});
|
||||
|
||||
function board() {
|
||||
let reply = { status: 200, body: { sessions: [], counts: {}, generatedAt: new Date().toISOString() } };
|
||||
const one = { agent: 'agent1', project: 'proj', state: 'waiting', waitingOnYou: true, task: 'Fixture task', lastActivity: '2026-10-10T00:00:00Z', lastAssistantText: 'Input needed: fixture', registered: null };
|
||||
// While the board answers, agent1 has one conversation with one message. A failure or refusal answers every path.
|
||||
const catalogue = { conversations: [{ project: 'proj', seat: 'agent1', conversation: 'c1', availability: 'available', title: 'Fixture', conversationCreatedAt: '2026-10-10T00:00:00Z', lastActivityAt: '2026-10-10T00:00:00Z' }] };
|
||||
const page = { page: { branch: 'b1', entries: [{ message: 'm1', role: 'assistant', createdAt: '2026-10-10T00:00:00Z', content: [{ block: 0, type: 'text', text: 'Fixture history line' }] }] }, view: { incomplete: false, defaultBranch: 'b1', branch: 'b1' }, cursor: null, follow: null };
|
||||
let gate = null;
|
||||
const server = createServer(async (req, res) => {
|
||||
for await (const chunk of req) { void chunk; }
|
||||
await gate;
|
||||
const path = new URL(req.url, 'http://localhost').pathname;
|
||||
const body = reply.status !== 200 ? reply.body : path === '/api/conversations' ? catalogue : path === '/api/conversation' ? page : reply.body;
|
||||
res.writeHead(reply.status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body));
|
||||
});
|
||||
return {
|
||||
server,
|
||||
// Holds every reply until the returned function is called.
|
||||
hold: () => { let go; gate = new Promise(r => { go = r; }); return () => { gate = null; go(); }; },
|
||||
empty: () => { reply = { status: 200, body: { sessions: [], counts: {}, generatedAt: new Date().toISOString() } }; },
|
||||
one: () => { reply = { status: 200, body: { sessions: [one, { ...one, agent: 'agent2', state: 'working', waitingOnYou: false }, { ...one, agent: 'agent3', state: 'idle', waitingOnYou: false, seen: true }], counts: { waiting: 1, working: 1 }, generatedAt: new Date().toISOString() } }; },
|
||||
fail: () => { reply = { status: 500, body: { error: 'board scan failed' } }; },
|
||||
refuse: () => { reply = { status: 403, body: { error: 'board-refused' } }; },
|
||||
// One seat a reply can reach: registered, alive, with a tmux session.
|
||||
registered: () => { reply = { status: 200, body: { sessions: [{ ...one, registered: { alive: true, tmux: { session: 'fixture' } } }], counts: { waiting: 1 }, generatedAt: new Date().toISOString() } }; },
|
||||
unavailable: () => { reply = { status: 503, body: { error: 'board unavailable' } }; },
|
||||
};
|
||||
}
|
||||
|
||||
// The four Q1 verbs over fixed rows, or a BusReadError with a chosen code.
|
||||
function stubBus() {
|
||||
const rows = { inbox: [], tasks: [], agents: [], trail: [] };
|
||||
let fail = null, gate = null;
|
||||
const verb = name => async () => { await gate; if (fail) throw new BusReadError(fail, `fixture ${fail}`); return structuredClone(rows[name]); };
|
||||
const hold = () => { let go; gate = new Promise(r => { go = r; }); return () => { gate = null; go(); }; };
|
||||
return { rows, hold, fail: code => { fail = code; }, bus: { inbox: verb('inbox'), tasks: verb('tasks'), agents: verb('agents'), trail: verb('trail') } };
|
||||
}
|
||||
|
||||
test('shell in a browser: System default, seeded states, 400px, focus and the Ctrl+K palette', { timeout: 180000 }, async () => {
|
||||
const brd = board(); await new Promise(r => brd.server.listen(0, '127.0.0.1', r));
|
||||
const s = stubBus();
|
||||
const web = await startServer({ port: 0, board: `http://127.0.0.1:${brd.server.address().port}`, bus: s.bus });
|
||||
const base = `http://127.0.0.1:${web.address().port}/`, b = await browser();
|
||||
const out = process.env.WEBUI_EVIDENCE;
|
||||
if (out) mkdirSync(out, { recursive: true });
|
||||
const shot = async name => { if (out) await b.screenshot(join(out, `shell-${name}.png`)); };
|
||||
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
|
||||
const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`);
|
||||
const count = sel => b.evaluate(`document.querySelectorAll(${JSON.stringify(sel)}).length`);
|
||||
const flat = async () => assert.equal(await b.evaluate('document.documentElement.scrollWidth<=document.documentElement.clientWidth'), true, 'sideways scroll at 400px');
|
||||
const refresh = async () => { await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled'); };
|
||||
const go = async (hash, h1) => { await b.evaluate(`location.hash=${JSON.stringify(hash)}`); await wait(`document.querySelector("#bus-view h1")?.textContent.startsWith(${JSON.stringify(h1)}) && !document.querySelector("#bus-view [aria-busy]")`); };
|
||||
try {
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'window.errors=[];addEventListener("error",e=>errors.push(e.message));addEventListener("unhandledrejection",e=>errors.push(String(e.reason)));window.requests=[];const f=window.fetch;window.fetch=(u,o={})=>{requests.push([(o.method||"GET").toUpperCase(),String(u)]);return f(u,o)};' });
|
||||
await b.viewport(400, 900);
|
||||
|
||||
// System is the default: no data-mode, Harbor, the OS picks light or dark. Colours come from tokens.css, not inline.
|
||||
await b.call('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: 'dark' }] });
|
||||
await b.navigate(base); await wait('!document.querySelector("#sessions [aria-busy]")');
|
||||
assert.equal(await b.evaluate('document.documentElement.hasAttribute("data-mode")'), false);
|
||||
assert.equal(await b.evaluate('document.documentElement.dataset.palette'), 'harbor');
|
||||
assert.equal(await b.evaluate('document.querySelector("#mode").value'), 'system');
|
||||
assert.equal(await b.evaluate('document.documentElement.getAttribute("style")'), null);
|
||||
const lum = await b.evaluate('(()=>{const [r,g,bb]=getComputedStyle(document.body).backgroundColor.match(/[\\d.]+/g).map(Number);return (r+g+bb)/3})()');
|
||||
assert.ok(lum < 80, `dark OS gives a dark page under System (${lum})`);
|
||||
await b.call('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: 'light' }] });
|
||||
assert.ok(await b.evaluate('(()=>{const [r,g,bb]=getComputedStyle(document.body).backgroundColor.match(/[\\d.]+/g).map(Number);return (r+g+bb)/3})()') > 180, 'light OS gives a light page');
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.documentElement).getPropertyValue("--r").trim()'), '6px');
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.documentElement).getPropertyValue("--r-lg").trim()'), '10px');
|
||||
assert.equal(await b.evaluate('document.fonts.check("12px \\"JetBrains Mono\\"")'), true);
|
||||
await b.evaluate('document.querySelector("#mode").value="dark";document.querySelector("#mode").dispatchEvent(new Event("change"))');
|
||||
assert.equal(await b.evaluate('document.documentElement.dataset.mode'), 'dark');
|
||||
assert.equal(await b.evaluate('JSON.parse(localStorage.getItem("mosaic-console-appearance")).mode'), 'dark');
|
||||
await b.evaluate('document.querySelector("#mode").value="system";document.querySelector("#mode").dispatchEvent(new Event("change"))');
|
||||
assert.equal(await b.evaluate('document.documentElement.hasAttribute("data-mode")'), false);
|
||||
|
||||
// Board, empty: what would appear and where it comes from.
|
||||
assert.match(await text('#sessions .empty'), /No sessions match these filters\.Sessions appear here when the board scan finds/);
|
||||
assert.match(await text('#waiting .empty'), /Nothing is waiting on you\./);
|
||||
assert.match(await text('#fresh-board'), /^Board scanned \d\d:\d\d:\d\d UTC/);
|
||||
await flat(); await shot('board-empty-400');
|
||||
|
||||
// Board, a failed read over kept data: the data stays, labelled with its scan time, and the bar says stale.
|
||||
brd.one(); await refresh(); await wait('document.querySelectorAll("table.sessions tbody tr").length===2');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions .st-waiting").length'), 1);
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("table.sessions .st-waiting"),"::before").content'), '"◐" / ""');
|
||||
brd.fail(); await refresh();
|
||||
assert.equal(await b.evaluate('document.querySelector("#error").className'), 'banner err');
|
||||
assert.match(await text('#error'), /board scan failed.*Showing last known data\. Scanned /);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 2);
|
||||
assert.match(await text('#fresh-board'), /stale: the last refresh failed/);
|
||||
await flat(); await shot('board-stale-400');
|
||||
|
||||
// Board, a refusal: fails closed. The kept rows are hidden, and so is everything drawn from
|
||||
// them: the open inspector, the project filter, the project tree's counts and the footer.
|
||||
await b.evaluate('document.querySelector("#projects [data-project=proj]").click()');
|
||||
assert.equal(await b.evaluate('document.querySelector("#projects [data-project=proj]").getAttribute("aria-pressed")'), 'true');
|
||||
await b.evaluate('document.querySelector("table.sessions [data-open]").click()'); await wait('!document.querySelector("#inspector").hidden');
|
||||
assert.equal(await text('#footer'), 'waiting 1 · working 1');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#projects .count").length'), 2);
|
||||
assert.equal(await count('#seen .wait-item'), 1);
|
||||
brd.refuse(); await refresh();
|
||||
assert.equal(await b.evaluate('document.querySelector("#error").className'), 'banner ref');
|
||||
assert.match(await text('#error'), /The board refused the read\. refused: board-refused/);
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
|
||||
assert.equal(await b.evaluate('document.querySelector("#board-refused").hidden'), false);
|
||||
assert.equal(await b.evaluate('document.querySelector("#inspector").hidden'), true);
|
||||
assert.equal(await b.evaluate('document.body.classList.contains("has-inspector")'), false);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#projects .count, #projects [data-project]").length'), 0);
|
||||
assert.equal(await text('#footer'), 'Board not read: refused');
|
||||
assert.equal(await text('#fresh-board'), 'Board not read: refused');
|
||||
assert.doesNotMatch(await text('#status'), /Scanned/);
|
||||
for (const id of ['#waiting-count', '#seen-count', '#session-count']) assert.equal(await text(id), '–', id);
|
||||
await flat(); await shot('board-refused-400');
|
||||
// A failed read after the refusal shows the board again, empty: no row, card or count from
|
||||
// the refused scan, and the footer says what the banner says.
|
||||
brd.unavailable(); await refresh();
|
||||
assert.equal(await b.evaluate('document.querySelector("#error").className'), 'banner err');
|
||||
assert.match(await text('#error'), /board unavailable.*No board data loaded\./);
|
||||
assert.notEqual(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
|
||||
assert.equal(await count('#sessions tr, #sessions [data-open], #waiting .wait-item, #seen .wait-item, #board-view [data-history]'), 0);
|
||||
for (const id of ['#waiting-count', '#seen-count', '#session-count']) assert.equal(await text(id), '–', id);
|
||||
assert.equal(await text('#footer'), 'Board not read: the read failed');
|
||||
assert.equal(await text('#fresh-board'), 'Board not read: the read failed');
|
||||
assert.equal(await text('#status'), 'Not read: the read failed');
|
||||
assert.equal(await text('#projects'), 'Not read: the read failed.');
|
||||
await flat(); await shot('board-refused-then-failed-400');
|
||||
brd.one(); await refresh();
|
||||
assert.equal(await b.evaluate('document.querySelector("#error").hidden'), true);
|
||||
assert.equal(await b.evaluate('document.querySelector("#projects [data-project=\'\']").getAttribute("aria-pressed")'), 'true');
|
||||
assert.notEqual(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
|
||||
assert.equal(await text('#footer'), 'waiting 1 · working 1');
|
||||
// A good read after a failed one drops the stale words.
|
||||
brd.fail(); await refresh(); assert.match(await text('#fresh-board'), /stale/);
|
||||
brd.one(); await refresh(); assert.match(await text('#fresh-board'), /^Board scanned \d\d:\d\d:\d\d UTC/); assert.doesNotMatch(await text('#fresh-board'), /stale/);
|
||||
// A refusal with a conversation open closes it and clears its history and reply form: the
|
||||
// session is in no scan the page holds.
|
||||
brd.registered(); await refresh(); await wait('document.querySelectorAll("table.sessions tbody tr").length===1');
|
||||
await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
|
||||
await wait('document.querySelector("#conv-log").textContent.includes("Fixture history line")');
|
||||
assert.equal(await b.evaluate('document.querySelector("#conv-form").hidden'), false);
|
||||
brd.refuse(); await refresh();
|
||||
assert.equal(await b.evaluate('document.querySelector("#conversation").hidden'), true);
|
||||
assert.equal(await b.evaluate('document.body.classList.contains("has-conversation")'), false);
|
||||
assert.equal(await count('#conv-log > *'), 0);
|
||||
assert.equal(await b.evaluate('document.querySelector("#conv-form").checkVisibility()'), false);
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
|
||||
assert.equal(await b.evaluate('document.querySelector("#board-refused").hidden'), false);
|
||||
brd.one(); await refresh(); await wait('document.querySelectorAll("table.sessions tbody tr").length===2');
|
||||
|
||||
// Keyboard on the board: down moves between rows, Enter opens, Esc closes and returns focus.
|
||||
await b.evaluate('document.querySelector("table.sessions [data-open]").focus()');
|
||||
await b.key('ArrowDown'); assert.equal(await b.evaluate('document.activeElement.textContent'), 'agent2');
|
||||
await b.key('ArrowUp'); assert.equal(await b.evaluate('document.activeElement.textContent'), 'agent1');
|
||||
await b.key('Enter'); await wait('document.querySelector("#inspector-title")?.textContent==="agent1"');
|
||||
await flat();
|
||||
await b.evaluate('document.querySelector("#close").focus()'); await b.key('Escape');
|
||||
await wait('document.activeElement?.matches("[data-open]") && document.activeElement.textContent==="agent1"');
|
||||
|
||||
// Visible focus: a keyboard-focused control has an outline of at least 2px.
|
||||
await b.evaluate('document.querySelector(".skip").focus();document.querySelector(".skip").blur()');
|
||||
for (const sel of ['#cmdk', '#sections a', '#refresh']) {
|
||||
await b.evaluate(`document.querySelector(${JSON.stringify(sel)}).focus()`);
|
||||
const ring = await b.evaluate(`(()=>{const c=getComputedStyle(document.activeElement);return [c.outlineStyle, parseFloat(c.outlineWidth), parseFloat(c.outlineOffset)]})()`);
|
||||
assert.notEqual(ring[0], 'none', `${sel} focus ring`); assert.ok(ring[1] >= 2, `${sel} outline ${ring[1]}px`); assert.equal(ring[2], 2, `${sel} outline offset`);
|
||||
}
|
||||
|
||||
// Bus pages, empty: each says what would appear and where it comes from.
|
||||
for (const [hash, h1, words] of [
|
||||
['#/inbox', 'Inbox', /^Nothing is waiting on you\.A decision shows here when an agent raises one routed to you through the bus\.$/],
|
||||
['#/tasks', 'Tasks', /^No tasks in this business yet\.Tasks appear after the PM creates them or the poller reads them from Vikunja\.$/],
|
||||
['#/agents', 'Agents', /^No role is held right now\.A role shows here while a run holds its claim on the bus\.$/],
|
||||
]) {
|
||||
await go(hash, h1);
|
||||
assert.match(await text('#bus-view .empty'), words, hash);
|
||||
assert.match(await text('#fresh-bus'), /^Bus read \d\d:\d\d:\d\d UTC/, hash);
|
||||
await flat(); await shot(`bus-empty-${h1.toLowerCase()}-400`);
|
||||
}
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#fresh-board")).display'), 'none');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#sections svg use").length'), 4);
|
||||
for (const sel of ['#fresh', '#session-count']) assert.equal(await b.evaluate(`getComputedStyle(document.querySelector(${JSON.stringify(sel)})).fontVariantNumeric`), 'tabular-nums', sel);
|
||||
// Before any copy the toast is an empty live region still in the accessibility tree, out of sight.
|
||||
assert.deepEqual(await b.evaluate('(()=>{const t=document.querySelector("#toast"),c=getComputedStyle(t),r=t.getBoundingClientRect();return [c.display!=="none",c.visibility,t.textContent,r.width<=1&&r.height<=1]})()'), [true, 'visible', '', true]);
|
||||
// Below 760px the section list is one strip that scrolls inside itself: every link on one
|
||||
// line, no label wrapped, and still no sideways scroll on the page. 360px, then back to 400px.
|
||||
for (const w of [360, 400]) {
|
||||
await b.viewport(w, 900);
|
||||
assert.equal(await b.evaluate('new Set([...document.querySelectorAll("#sections a")].map(a=>a.offsetTop)).size'), 1, `one row at ${w}px`);
|
||||
assert.match(await b.evaluate('getComputedStyle(document.querySelector("#sections")).overflowX'), /^(auto|scroll)$/, `strip scrolls at ${w}px`);
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#sections a")].filter(a=>a.getClientRects().length>1||a.scrollWidth>a.clientWidth+1).map(a=>a.textContent)'), [], `no wrapped label at ${w}px`);
|
||||
await flat();
|
||||
}
|
||||
|
||||
// Bus, a failed read over kept data: the kept rows stay under a stale banner; the bar says stale.
|
||||
s.rows.tasks = [{ task_ref: 'vikunja:32/7', fields: { title: 'Kept task', bucket: 2, done: false, assignees: [] }, source: 'self' }, { task_ref: 'vikunja:32/8', fields: { title: 'Second task', bucket: 2, done: false, assignees: [] }, source: 'self' }];
|
||||
await go('#/trail/task/vikunja%3A32%2F7', 'Trail for task #7');
|
||||
assert.match(await text('#bus-view .empty'), /^Nothing of this kind in the trail\.Every step the stack takes on this subject writes a trail row\.$/);
|
||||
await flat(); await shot('bus-empty-trail-400');
|
||||
await go('#/tasks', 'Tasks');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view td.s1-title a").length'), 2);
|
||||
s.fail('outcome-unknown'); await b.evaluate('document.querySelector("#refresh").click()');
|
||||
await wait('!!document.querySelector("#bus-view .s1-stale")');
|
||||
assert.equal(await b.evaluate('document.querySelector("#bus-view .s1-stale").className'), 'banner err s1-stale');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view td.s1-title a").length'), 2);
|
||||
assert.match(await text('#fresh-bus'), /stale: the last read failed/);
|
||||
// Up and down move between task links.
|
||||
await b.evaluate('document.querySelector("#bus-view td.s1-title a").focus()');
|
||||
await b.key('ArrowDown'); assert.match(await b.evaluate('document.activeElement.textContent'), /Second task/);
|
||||
await b.key('ArrowUp'); assert.match(await b.evaluate('document.activeElement.textContent'), /Kept task/);
|
||||
await flat(); await shot('bus-stale-400');
|
||||
|
||||
// Bus, a refusal: fails closed even with kept data, the code in mono, nothing shown as current.
|
||||
s.fail('human-required'); await b.evaluate('document.querySelector("#refresh").click()');
|
||||
await wait('!!document.querySelector("#bus-view .banner.ref")');
|
||||
assert.equal(await b.evaluate('document.querySelector("#bus-view .banner.ref code").textContent'), 'human-required');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view td.s1-title a").length'), 0);
|
||||
assert.match(await text('#bus-view .empty'), /Nothing to show\./);
|
||||
assert.equal(await text('#fresh-bus'), 'Bus not read: refused');
|
||||
await flat(); await shot('bus-refused-400');
|
||||
s.fail(null);
|
||||
|
||||
// Not found names the address and points to the Board and to Ctrl K.
|
||||
await go('#/nowhere', 'Not found');
|
||||
assert.match(await text('#bus-view .empty'), /No page at this address\.Nothing is at #\/nowhere\. Go to the Board or press Ctrl K to search\./);
|
||||
assert.equal(await b.evaluate('document.querySelector("#bus-view .empty a").getAttribute("href")'), '#/');
|
||||
|
||||
// Ctrl+K: opens on the views and the kept tasks, filters, Enter goes, Esc restores focus.
|
||||
await go('#/inbox', 'Inbox');
|
||||
await b.evaluate('document.querySelector("#refresh").focus()');
|
||||
await b.key('k', 'KeyK', 3); await new Promise(r => setTimeout(r, 200));
|
||||
assert.equal(await b.evaluate('document.querySelector("#cmdk-dialog").open'), false, 'Ctrl+Alt+K opens nothing');
|
||||
await b.key('k', 'KeyK', 2); await wait('document.querySelector("#cmdk-dialog").open');
|
||||
assert.equal(await b.evaluate('document.activeElement.id'), 'cmdk-input');
|
||||
await wait('[...document.querySelectorAll("#cmdk-list [data-href]")].some(e=>e.dataset.href==="#/tasks/vikunja%3A32%2F7")');
|
||||
await b.call('Input.insertText', { text: 'agen' });
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#cmdk-list [data-href]")].map(e=>e.dataset.href)'), ['#/agents']);
|
||||
// The view render focuses its h1; wait for it so a late render can't take focus from the next step.
|
||||
await b.key('Enter'); await wait('location.hash==="#/agents" && !document.querySelector("#cmdk-dialog").open && document.activeElement===document.querySelector("#bus-view h1") && document.activeElement.textContent==="Agents"');
|
||||
await b.evaluate('document.querySelector("#refresh").focus()');
|
||||
await b.key('k', 'KeyK', 2); await wait('document.querySelector("#cmdk-dialog").open');
|
||||
await b.call('Input.insertText', { text: 'kept' });
|
||||
assert.equal(await b.evaluate('document.querySelector("#cmdk-list [aria-selected=true]").dataset.href'), '#/tasks/vikunja%3A32%2F7');
|
||||
await b.key('ArrowDown'); await b.key('ArrowUp');
|
||||
assert.equal(await b.evaluate('document.querySelector("#cmdk-input").getAttribute("aria-activedescendant")'), 'cmdk-opt-0');
|
||||
await flat(); await shot('palette-400');
|
||||
await b.key('Escape'); await wait('!document.querySelector("#cmdk-dialog").open && document.activeElement?.id==="refresh"');
|
||||
await b.evaluate('{const c=document.querySelector("#cmdk");c.focus();c.click()}'); await wait('document.querySelector("#cmdk-dialog").open');
|
||||
await b.call('Input.insertText', { text: 'zzzz' });
|
||||
assert.match(await text('#cmdk-list'), /No view, decision or task matches\./);
|
||||
await b.key('Escape'); await wait('document.activeElement?.id==="cmdk"');
|
||||
|
||||
// Copy shows a toast and keeps the status line; the toast never steals focus.
|
||||
s.rows.inbox = [{ id: 'd'.repeat(32), question: 'Push it?', class: 'gated', action: 'git.push.protected', target: 'refactor', blocking: true, options: [{ key: 'yes', text: 'Yes' }], recommendation: 'yes', at: new Date().toISOString(), raised_by: 'coder' }];
|
||||
// The decision page reads the decision from its trail, in the broker's row shape.
|
||||
s.rows.trail = [{ table: 'decisions', seq: 1, at: s.rows.inbox[0].at, id: 'd'.repeat(32), class: 'gated', action: 'git.push.protected', route_to: 'human', question: 'Push it?', options: [{ key: 'yes', text: 'Yes' }], recommendation: 'yes', blocking: 1, raised_by_role: 'coder', raised_by_run: 'coder-run' }];
|
||||
await go('#/inbox', 'Inbox');
|
||||
assert.equal(await b.evaluate('document.querySelector("#bus-view .tag.tag-gated code").textContent'), 'git.push.protected');
|
||||
assert.equal(await b.evaluate('document.querySelector("#bus-view .tag.tag-block").textContent'), 'blocking');
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#bus-view .tag.tag-block")).textTransform'), 'uppercase');
|
||||
await flat(); await shot('bus-inbox-400');
|
||||
await b.evaluate('document.querySelector("#bus-view .s1-dec-open").click()'); await wait('document.querySelectorAll("#bus-view [data-copy]").length===1');
|
||||
await b.evaluate('window.copied=[];navigator.clipboard.writeText=async t=>{copied.push(t)}');
|
||||
await b.evaluate('{const c=document.querySelector("#bus-view [data-copy]");c.focus();c.click()}');
|
||||
await wait('document.querySelector("#toast").classList.contains("on")');
|
||||
assert.equal(await text('#toast'), `Copied: mosaic decide ${'d'.repeat(32)} yes`);
|
||||
assert.equal(await text('#copy-status'), `Copied: mosaic decide ${'d'.repeat(32)} yes`);
|
||||
assert.equal(await b.evaluate('document.activeElement.matches("[data-copy]")'), true);
|
||||
await flat(); await shot('toast-400');
|
||||
await b.evaluate('navigator.clipboard.writeText=async()=>{throw new Error("denied")}');
|
||||
await b.evaluate('document.querySelector("#bus-view [data-copy]").click()');
|
||||
await wait('document.querySelector("#toast").classList.contains("err")');
|
||||
assert.equal(await text('#toast span'), 'The clipboard is unavailable. The command is selected; press Ctrl+C to copy it.');
|
||||
assert.equal(await b.evaluate('getSelection().toString()'), `mosaic decide ${'d'.repeat(32)} yes`);
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
assert.equal(await b.evaluate('document.querySelector("#toast").className'), 'toast on err', 'the error toast stays until dismissed');
|
||||
await b.evaluate('document.querySelector("#toast [data-dismiss]").click()');
|
||||
assert.equal(await b.evaluate('document.querySelector("#toast").classList.contains("on")'), false);
|
||||
|
||||
assert.deepEqual(await b.evaluate('window.errors'), []);
|
||||
assert.deepEqual(await b.evaluate('window.requests.filter(([m])=>m!=="GET")'), []);
|
||||
await b.viewport(1440, 900); await go('#/tasks', 'Tasks'); await shot('bus-tasks-1440');
|
||||
await b.evaluate('location.hash="#/"'); await wait('document.querySelectorAll("table.sessions tbody tr").length===2'); await shot('board-1440');
|
||||
// The side column sits between the command bar and the status footer: a short page has no scroll,
|
||||
// and scrolled to the end of a long one the column is still below the bar.
|
||||
const edges = 'JSON.stringify(["cmdbar","tree","foot"].map(c=>document.querySelector("."+c).getBoundingClientRect()).map(r=>[Math.round(r.top),Math.round(r.bottom)]).concat([[document.documentElement.scrollHeight,innerHeight]]))';
|
||||
let [bar, tree, foot, [scrollH, innerH]] = JSON.parse(await b.evaluate(edges));
|
||||
assert.equal(scrollH, innerH);
|
||||
assert.deepEqual([tree[0], tree[1]], [bar[1], foot[0]]);
|
||||
await b.viewport(1440, 420); await b.evaluate('scrollTo(0, 1e5)'); await flat();
|
||||
[bar, tree, foot, [scrollH, innerH]] = JSON.parse(await b.evaluate(edges));
|
||||
assert.ok(scrollH > innerH, `the board page is taller than ${innerH}px`);
|
||||
assert.deepEqual([tree[0], tree[1]], [bar[1], foot[0]]);
|
||||
assert.deepEqual(await b.evaluate('window.errors'), []);
|
||||
} finally { await b.close(); await close(web); await close(brd.server); }
|
||||
});
|
||||
|
||||
// Every restyled view in its five states: loading (a held read), normal, empty, a failed read
|
||||
// over kept data, and a refusal. Empty is seeded per view above; here each view is read once
|
||||
// with rows, then made to fail, then refused, at 400px.
|
||||
test('five states on Board, Inbox, Tasks, Agents and Trail', { timeout: 180000 }, async () => {
|
||||
const brd = board(); await new Promise(r => brd.server.listen(0, '127.0.0.1', r));
|
||||
const s = stubBus();
|
||||
const at = new Date().toISOString(), id = 'd'.repeat(32);
|
||||
s.rows.inbox = [{ id, question: 'Push it?', class: 'gated', action: 'git.push.protected', target: 'refactor', blocking: true, options: [{ key: 'yes', text: 'Yes' }], recommendation: 'yes', at, raised_by: 'coder' }];
|
||||
s.rows.trail = [{ table: 'decisions', seq: 1, at, id, class: 'gated', action: 'git.push.protected', route_to: 'human', question: 'Push it?', options: [{ key: 'yes', text: 'Yes' }], recommendation: 'yes', blocking: 1, raised_by_role: 'coder', raised_by_run: 'coder-run' }];
|
||||
s.rows.tasks = [{ task_ref: 'vikunja:32/7', fields: { title: 'Kept task', bucket: 2, done: false, assignees: [] }, source: 'self' }, { task_ref: 'vikunja:32/8', fields: { title: 'Second task', bucket: 2, done: false, assignees: [] }, source: 'self' }];
|
||||
s.rows.agents = [{ role: 'coder', holder_run: 'coder-run', harness: 'pi', address: 'coder@bus', at }];
|
||||
const web = await startServer({ port: 0, board: `http://127.0.0.1:${brd.server.address().port}`, bus: s.bus });
|
||||
const base = `http://127.0.0.1:${web.address().port}/`, b = await browser();
|
||||
const out = process.env.WEBUI_EVIDENCE;
|
||||
if (out) mkdirSync(out, { recursive: true });
|
||||
const shot = async name => { if (out) await b.screenshot(join(out, `states-${name}.png`)); };
|
||||
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
|
||||
const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`);
|
||||
const count = sel => b.evaluate(`document.querySelectorAll(${JSON.stringify(sel)}).length`);
|
||||
const flat = async () => assert.equal(await b.evaluate('document.documentElement.scrollWidth<=document.documentElement.clientWidth'), true, 'sideways scroll at 400px');
|
||||
const refresh = async () => { await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled'); };
|
||||
const go = async (hash, h1) => { await b.evaluate(`location.hash=${JSON.stringify(hash)}`); await wait(`document.querySelector("#bus-view h1")?.textContent.startsWith(${JSON.stringify(h1)}) && !document.querySelector("#bus-view [aria-busy]")`); };
|
||||
try {
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'window.errors=[];addEventListener("error",e=>errors.push(e.message));addEventListener("unhandledrejection",e=>errors.push(String(e.reason)));window.requests=[];const f=window.fetch;window.fetch=(u,o={})=>{requests.push([(o.method||"GET").toUpperCase(),String(u)]);return f(u,o)};' });
|
||||
await b.viewport(400, 900);
|
||||
|
||||
// Board, loading: the first scan is held, so the sessions list shows its skeleton.
|
||||
brd.one(); let release = brd.hold();
|
||||
await b.navigate(base); await wait('!!document.querySelector("#sessions .skel-rows[aria-busy=true]")');
|
||||
await flat(); await shot('board-loading-400');
|
||||
release(); await wait('document.querySelectorAll("table.sessions tbody tr").length===2');
|
||||
assert.equal(await b.evaluate('document.querySelector("#error").hidden'), true);
|
||||
await flat(); await shot('board-normal-400');
|
||||
|
||||
for (const [hash, h1, rows, name] of [
|
||||
['#/inbox', 'Inbox', '#bus-view .s1-dec-open', 'inbox'],
|
||||
['#/tasks', 'Tasks', '#bus-view td.s1-title a', 'tasks'],
|
||||
['#/agents', 'Agents', '#bus-view table.s1-agents tbody tr', 'agents'],
|
||||
[`#/trail/decision/${id}`, 'Trail for decision', '#bus-view ol.s1-trail li', 'trail'],
|
||||
]) {
|
||||
// Loading: the read is held, the view shows the skeleton under "Reading…".
|
||||
release = s.hold();
|
||||
await b.evaluate(`location.hash=${JSON.stringify(hash)}`);
|
||||
await wait('!!document.querySelector("#bus-view .skel-rows[aria-busy=true]")');
|
||||
assert.equal(await text('#bus-view h1'), 'Reading…', hash);
|
||||
await flat(); await shot(`${name}-loading-400`);
|
||||
// Normal.
|
||||
release();
|
||||
await wait(`document.querySelector("#bus-view h1")?.textContent.startsWith(${JSON.stringify(h1)}) && !document.querySelector("#bus-view [aria-busy]")`);
|
||||
const n = await count(rows);
|
||||
assert.ok(n >= 1, `${hash} shows its rows`);
|
||||
assert.equal(await count('#bus-view .banner'), 0, hash);
|
||||
assert.match(await text('#fresh-bus'), /^Bus read \d\d:\d\d:\d\d UTC/, hash);
|
||||
await flat(); await shot(`${name}-normal-400`);
|
||||
// A failed read over kept data: the same rows under a stale banner.
|
||||
s.fail('outcome-unknown'); await refresh();
|
||||
await wait('!!document.querySelector("#bus-view .s1-stale")');
|
||||
assert.equal(await b.evaluate('document.querySelector("#bus-view .s1-stale").className'), 'banner err s1-stale', hash);
|
||||
assert.equal(await count(rows), n, `${hash} keeps its rows`);
|
||||
assert.match(await text('#fresh-bus'), /stale: the last read failed/, hash);
|
||||
await flat(); await shot(`${name}-stale-400`);
|
||||
// A refusal, by the bus (403) or for want of one (no-bus-host): no kept rows, the code
|
||||
// shown, nothing current, and no count in the section list.
|
||||
for (const code of ['human-required', 'no-bus-host']) {
|
||||
s.fail(code); await refresh();
|
||||
await wait('!!document.querySelector("#bus-view .banner.ref")');
|
||||
assert.equal(await text('#bus-view .banner.ref code'), code, hash);
|
||||
assert.equal(await count(rows), 0, `${hash} hides kept rows on a ${code} refusal`);
|
||||
assert.match(await text('#bus-view .empty'), /Nothing to show\./, hash);
|
||||
assert.equal(await text('#fresh-bus'), 'Bus not read: refused', hash);
|
||||
assert.equal(await count('#sections .count, #sections .tag-block'), 0, `${hash} drops the Inbox count on a ${code} refusal`);
|
||||
if (code === 'human-required') { await flat(); await shot(`${name}-refused-400`); }
|
||||
s.fail(null); await refresh();
|
||||
await wait(`!document.querySelector("#bus-view .banner") && document.querySelectorAll(${JSON.stringify(rows)}).length===${n}`);
|
||||
}
|
||||
}
|
||||
|
||||
// After a refusal the palette lists only the views: no kept decision or task.
|
||||
const views = ['#/', '#/inbox', '#/tasks', '#/agents'];
|
||||
const palette = '[...document.querySelectorAll("#cmdk-list [data-href]")].map(e=>e.dataset.href)';
|
||||
await b.evaluate('location.hash="#/inbox"'); await wait('document.querySelectorAll("#sections .count").length===1');
|
||||
assert.match(await text('#sections'), /Inbox 1 1 blocking/);
|
||||
s.fail('human-required'); await refresh(); await wait('!!document.querySelector("#bus-view .banner.ref")');
|
||||
await b.key('k', 'KeyK', 2); await wait('document.querySelector("#cmdk-dialog").open');
|
||||
assert.deepEqual(await b.evaluate(palette), views);
|
||||
await b.key('Escape'); await wait('!document.querySelector("#cmdk-dialog").open');
|
||||
s.fail(null);
|
||||
// The palette's own reads: kept rows show until a refused refetch, then go, with the count.
|
||||
await go('#/tasks', 'Tasks'); await b.evaluate('location.hash="#/inbox"'); await wait('document.querySelectorAll("#sections .count").length===1');
|
||||
await b.key('k', 'KeyK', 2); await wait(`${palette}.some(h=>h.startsWith("#/tasks/"))`);
|
||||
await b.key('Escape'); await wait('!document.querySelector("#cmdk-dialog").open');
|
||||
s.fail('human-required');
|
||||
await b.key('k', 'KeyK', 2); await wait(`document.querySelector("#cmdk-dialog").open && ${palette}.length===${views.length} && !document.querySelector("#sections .count")`);
|
||||
assert.deepEqual(await b.evaluate(palette), views);
|
||||
await b.key('Escape'); await wait('!document.querySelector("#cmdk-dialog").open');
|
||||
// Palette labels are bus text: markup in a question or a task title shows as text.
|
||||
s.fail(null);
|
||||
s.rows.inbox = [{ ...s.rows.inbox[0], question: '<img src=x onerror="errors.push(1)" id="pk-q">Push?' }];
|
||||
s.rows.tasks = [{ ...s.rows.tasks[0], fields: { ...s.rows.tasks[0].fields, title: '<b id="pk-t">Bold</b> task' } }, s.rows.tasks[1]];
|
||||
await b.key('k', 'KeyK', 2); await wait(`${palette}.length===${views.length + 3}`);
|
||||
assert.equal(await count('#cmdk-list #pk-q, #cmdk-list #pk-t, #cmdk-list img, #cmdk-list b'), 0);
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#cmdk-list [data-href] span:first-child")].slice(4).map(e=>e.textContent)'), ['<img src=x onerror="errors.push(1)" id="pk-q">Push?', '#7 <b id="pk-t">Bold</b> task', '#8 Second task']);
|
||||
await b.key('Escape'); await wait('!document.querySelector("#cmdk-dialog").open');
|
||||
// The board page's Inbox read: a refusal there drops the count too.
|
||||
s.fail(null); await refresh(); await wait('document.querySelectorAll("#sections .count").length===1');
|
||||
s.fail('no-bus-host'); await b.evaluate('location.hash="#/"'); await wait('!document.querySelector("#sections .count")');
|
||||
await b.key('k', 'KeyK', 2); await wait('document.querySelector("#cmdk-dialog").open');
|
||||
assert.deepEqual(await b.evaluate(palette), views);
|
||||
await b.key('Escape'); s.fail(null);
|
||||
|
||||
assert.deepEqual(await b.evaluate('window.errors'), []);
|
||||
assert.deepEqual(await b.evaluate('window.requests.filter(([m])=>m!=="GET")'), []);
|
||||
} finally { await b.close(); await close(web); await close(brd.server); }
|
||||
});
|
||||
Reference in New Issue
Block a user