Dewey's round 3 candidate, manifest
agents/dewey/work/queue-40/candidate-manifest-r3.sha256 (d0aa0ded,
27 files, checked OK in the canonical tree).
- WebUI inbox, tasks, agents and trail views, read-only over /api/bus.
The README says the bus proof ends at the Console process.
- CHAT-03 seal: the engine command is fixed, the engine environment is
explicit, SEAL_FLAGS has --no-approve, escalating is cleared on throw.
- Terminal input typed after Ctrl-T or Ctrl-O is held. Only the run whose
own parse set held drains it (T1), and #run catches errors per action.
- DEFERRED keeps N2 and moves F2 to done, citing T1.
Reviews: Filbert approve (comment 27011, rev 260), Darkwing approve
(27013, rev 264). Landing gate on 8cad7722 plus the candidate: webui 22,
conversation 161, control-board 124, every scripts/test-*.sh green,
test-task 98/0. Mutant Mr survives; its flows test is the first
follow-up row.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
267 lines
21 KiB
JavaScript
267 lines
21 KiB
JavaScript
// Slice 1 S5 (#1522): the inbox, tasks, agents and trail pages in a browser,
|
|
// against an in-process broker read through its reader session, so every row
|
|
// the page renders has the broker's own shape. Nothing here starts the human
|
|
// CLI or reaches a real broker; the seeding writes go straight to the broker.
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { Store } from '../../bus/src/store.mjs';
|
|
import { Broker } from '../../bus/src/broker.mjs';
|
|
import { views } from '../../bus/src/views.mjs';
|
|
import { startServer } from '../src/serve.mjs';
|
|
import { BusReadError } from '../src/bus.mjs';
|
|
import { browser } from './browser.mjs';
|
|
import { fixture, close } from './fixture.mjs';
|
|
|
|
const HOSTILE = '<script>window.injected=1</script><img src=x onerror="window.injected=2"> \u001b[31mred \u202eevil';
|
|
const businesses = {
|
|
demo: {
|
|
id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' },
|
|
roles: {
|
|
pm: { authority: { withinRole: ['message.send', 'task.create'], crossRole: [] } },
|
|
cto: { authority: { withinRole: ['message.send'], crossRole: [] } },
|
|
coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } },
|
|
},
|
|
},
|
|
};
|
|
const digest = c => c.repeat(64);
|
|
const iso = () => new Date().toISOString();
|
|
// A poll read counts only when it was sent after the stack's own write (task_current), and the
|
|
// broker's clock can run a few milliseconds ahead of Date.now() when writes come fast.
|
|
const later = () => new Date(Date.now() + 1000).toISOString();
|
|
|
|
function seed(root) {
|
|
const store = new Store(root), b = new Broker({ store, businesses });
|
|
const launch = (role, harness, address) => { const cap = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness, address }); b.request(cap, { verb: 'role.claim' }); return cap; };
|
|
const coder = launch('coder', 'pi', 'coder-run'), pm = launch('pm', 'pi', 'pm-run');
|
|
launch('cto', 'claude-code', 'cto-thread');
|
|
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
|
// The human's request, the task PM created for it, a later poll that moved it, and a refused write.
|
|
const asked = b.request(human, { verb: 'message.send', args: { to: 'pm', body: `Build the inbox ${HOSTILE}` } });
|
|
const fields = { project_id: 32, title: `Inbox ${HOSTILE}`, description: `Line one ${HOSTILE}\nline two`, done: false, due_date: '2026-10-20T00:00:00Z', priority: 3, percent_done: 40, bucket: 2, labels: [], assignees: [5] };
|
|
b.recordTask({ cap: pm, business: 'demo', snapshots: [{ task_ref: 'vikunja:32/7', updated: iso(), digest: digest('a'), fields }], events: [{ kind: 'task.created', subject: 'vikunja:32/7', body: { request: asked.request, requirement: 'REQ-SLICE-1' } }] });
|
|
b.recordTask({ business: 'demo', snapshots: [{ task_ref: 'vikunja:32/7', updated: iso(), digest: digest('b'), fields: { ...fields, bucket: 3 }, via: 'board', read_at: later() }], events: [{ kind: 'task.changed.external', subject: 'vikunja:32/7', body: { via: 'board', digest: digest('b'), previous: digest('a'), changed: ['bucket'], comments: 2 } }] });
|
|
b.recordTask({ cap: pm, business: 'demo', events: [{ kind: 'task.conflict', subject: 'vikunja:32/7', body: { verb: 'task.update', expected: digest('a'), actual: digest('b') } }] });
|
|
// A task the stack never wrote, a done task and one no longer readable.
|
|
b.recordTask({ business: 'demo', snapshots: [{ task_ref: 'vikunja:32/8', updated: iso(), digest: digest('c'), fields: { ...fields, title: 'Foreign task', bucket: 2, assignees: [] }, via: 'cursor', read_at: iso() }], events: [{ kind: 'task.changed.external', subject: 'vikunja:32/8', body: { via: 'cursor', digest: digest('c'), previous: null, changed: [] } }] });
|
|
b.recordTask({ cap: pm, business: 'demo', snapshots: [{ task_ref: 'vikunja:32/9', updated: iso(), digest: digest('d'), fields: { ...fields, title: 'Done task', done: true, percent_done: 100 } }] });
|
|
b.recordTask({ cap: pm, business: 'demo', snapshots: [{ task_ref: 'vikunja:32/10', updated: iso(), digest: digest('e'), fields: { ...fields, title: 'Moved task' } }] });
|
|
b.recordTask({ business: 'demo', snapshots: [{ task_ref: 'vikunja:32/10', updated: iso(), digest: digest('f'), fields: { gone: 'moved' }, via: 'task', read_at: later() }], events: [{ kind: 'task.missing', subject: 'vikunja:32/10', body: { reason: 'moved', project: 40 } }] });
|
|
// An open blocking decision that replaced an earlier one, seen by the human; one routed to PM; one resolved.
|
|
const raise = args => b.request(coder, { verb: 'decision.raise', args });
|
|
const push = { action: 'git.push.protected', target: 'refactor', task_ref: 'vikunja:32/7', options: [{ key: 'yes', text: `Allow ${HOSTILE}` }, { key: 'no', text: 'Decline' }], recommendation: 'no', blocking: true };
|
|
const old = raise({ ...push, question: 'Push the first candidate?' });
|
|
const open = raise({ ...push, question: `Push the release? ${HOSTILE}\nSecond line`, supersedes: old.id });
|
|
b.request(human, { verb: 'decision.seen', args: { id: open.id } });
|
|
const routed = raise({ action: 'task.scope.change', question: 'Widen the scope?', options: [{ key: 'widen', text: 'Widen' }, { key: 'keep', text: 'Keep' }], recommendation: 'keep', blocking: false, approvalChoice: 'widen' });
|
|
const resolved = raise({ ...push, question: 'Push the hotfix?', blocking: false, task_ref: undefined, target: 'hotfix' });
|
|
b.request(human, { verb: 'decision.resolve', args: { id: resolved.id, choice: 'yes' } });
|
|
const reader = b.bindReader({ business: 'demo' });
|
|
const calls = [];
|
|
let fail = null;
|
|
const bus = views({ call: async (verb, args = {}) => { calls.push(verb); if (fail) throw new BusReadError(fail); return structuredClone(b.request(reader, { verb, args })); } });
|
|
return { store, bus, calls, fail: code => { fail = code; }, ids: { old: old.id, open: open.id, routed: routed.id, resolved: resolved.id } };
|
|
}
|
|
|
|
test('S5 pages in a browser: real broker rows, inert text, Copy, routes, failures, layout and no writes', { timeout: 180000 }, async () => {
|
|
const root = mkdtempSync(join(tmpdir(), 'webui-bus-browser-'));
|
|
const s = seed(root), f = await fixture(), b = await browser();
|
|
const web = await startServer({ port: 0, board: f.boardURL, bus: s.bus }), base = `http://127.0.0.1:${web.address().port}/`;
|
|
const out = process.env.WEBUI_EVIDENCE;
|
|
if (out) mkdirSync(out, { recursive: true });
|
|
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 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]")`); };
|
|
const inertCheck = async where => {
|
|
assert.equal(await b.evaluate('!!window.injected'), false, where);
|
|
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view script, #bus-view img, #bus-view iframe, #bus-view object, #bus-view embed, #bus-view svg, #bus-view style, #bus-view link").length'), 0, where);
|
|
const shown = await text('#bus-view');
|
|
assert.doesNotMatch(shown, /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/, where);
|
|
};
|
|
const noOverflow = async where => {
|
|
for (const width of [320, 1440]) {
|
|
await b.viewport(width, 1000);
|
|
assert.equal(await b.evaluate('document.documentElement.scrollWidth<=document.documentElement.clientWidth'), true, `${where} ${width} overflow`);
|
|
if (out) await b.screenshot(join(out, `s5-${where}-${width}.png`));
|
|
}
|
|
await b.viewport(1440, 1000);
|
|
};
|
|
try {
|
|
// Every request the page makes is logged with its method; the bus pages must make only GETs.
|
|
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(1440, 1000);
|
|
|
|
// The board is untouched at the root, with the Console sections above its projects.
|
|
await b.navigate(base); await wait('document.querySelectorAll("table.sessions tbody tr").length===3');
|
|
assert.equal(await b.evaluate('document.querySelector("#bus-view").hidden'), true);
|
|
assert.equal(await b.evaluate('document.querySelectorAll("#projects [data-project]").length'), 3);
|
|
await wait('document.querySelector("#sections a[href=\\"#/inbox\\"] .count")?.textContent==="1"');
|
|
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]").getAttribute("href")'), '#/');
|
|
|
|
// Inbox: the one open human decision, its approval context, and the gap labels.
|
|
await go('#/inbox', 'Inbox');
|
|
assert.equal(await b.evaluate('document.activeElement.tagName'), 'H1');
|
|
assert.equal(await b.evaluate('document.body.classList.contains("on-bus")'), true);
|
|
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
|
|
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view .s1-dec").length'), 1);
|
|
const card = await text('#bus-view .s1-dec');
|
|
assert.match(card, /Push the release\? <script>window\.injected=1<\/script>/);
|
|
assert.match(card, /␛\[31mred \[U\+202E\]evil/);
|
|
assert.doesNotMatch(card, /Second line/);
|
|
assert.match(card, /Choosing “yes” approves git\.push\.protected on refactor\./);
|
|
assert.match(card, /blocking/);
|
|
for (const label of ['Decisions routed to roles: not in the Q1 module', 'Closed decisions: not in the Q1 module', 'Seen and DM state per row: not in the Q1 module'])
|
|
assert.ok((await text('#bus-view')).includes(label), label);
|
|
assert.equal(await b.evaluate('document.title'), 'Inbox · Mosaic Console');
|
|
await inertCheck('inbox'); await noOverflow('inbox');
|
|
|
|
// The open decision: options with Copy, history, the earlier version, no write.
|
|
await b.evaluate('document.querySelector("#bus-view .s1-dec-open").click()');
|
|
await wait(`location.hash===${JSON.stringify(`#/inbox/${s.ids.open}`)}`);
|
|
await wait('document.querySelectorAll("#bus-view [data-copy]").length===2');
|
|
assert.equal(await b.evaluate('document.activeElement.tagName'), 'H1');
|
|
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view [data-copy]")].map(e=>e.dataset.copy)'), [`mosaic decide ${s.ids.open} yes`, `mosaic decide ${s.ids.open} no`]);
|
|
const detail = await text('#bus-view');
|
|
assert.match(detail, /approves git\.push\.protected/); assert.match(detail, /recommended/);
|
|
assert.match(detail, /Seen by jason via cli/);
|
|
assert.match(detail, /Second line/);
|
|
assert.ok(detail.includes(`${s.ids.old.slice(0, 8)} withdrawn Push the first candidate?`), detail);
|
|
assert.ok(detail.includes('A later version that replaces this one: not in the Q1 module'));
|
|
assert.match(detail, /DMnot sent/);
|
|
await inertCheck('decision');
|
|
// Copy writes the command to the clipboard and nothing else; with no clipboard the command is selected.
|
|
await b.evaluate('window.copied=[];navigator.clipboard.writeText=async t=>{copied.push(t)}');
|
|
await b.evaluate('document.querySelector("#bus-view [data-copy]").click()');
|
|
await wait('document.querySelector("#copy-status").textContent.startsWith("Copied")');
|
|
assert.deepEqual(await b.evaluate('window.copied'), [`mosaic decide ${s.ids.open} yes`]);
|
|
await b.evaluate('navigator.clipboard.writeText=async()=>{throw new Error("denied")}');
|
|
await b.evaluate('document.querySelectorAll("#bus-view [data-copy]")[1].click()');
|
|
await wait('document.querySelector("#copy-status").textContent.startsWith("The clipboard is unavailable")');
|
|
assert.equal(await b.evaluate('getSelection().toString()'), `mosaic decide ${s.ids.open} no`);
|
|
// A refresh keeps the focused button and the copy status.
|
|
await b.evaluate('document.querySelectorAll("#bus-view [data-copy]")[1].focus()');
|
|
await b.evaluate('document.querySelector("#refresh").click()');
|
|
await new Promise(r => setTimeout(r, 500));
|
|
assert.equal(await b.evaluate('document.activeElement.dataset.copy'), `mosaic decide ${s.ids.open} no`);
|
|
assert.match(await text('#copy-status'), /clipboard is unavailable/);
|
|
await noOverflow('decision');
|
|
|
|
// A decision routed to a role and a resolved one show no command.
|
|
await go(`#/inbox/${s.ids.routed}`, 'Decision');
|
|
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view [data-copy]").length'), 0);
|
|
assert.match(await text('#bus-view'), /routed to pm, not to you/);
|
|
assert.match(await text('#bus-view'), /Choosing “widen” approves task\.scope\.change\./);
|
|
await go(`#/inbox/${s.ids.resolved}`, 'Decision');
|
|
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view [data-copy]").length'), 0);
|
|
assert.match(await text('#bus-status'), /Resolved: jason chose “yes” via cli/);
|
|
assert.match(await text('#bus-view'), /chosen/);
|
|
|
|
// Tasks: buckets, markers, done, missing, the freshness line.
|
|
await go('#/tasks', 'Tasks');
|
|
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]").getAttribute("href")'), '#/tasks');
|
|
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view h2[id^=b-bucket]")].map(h=>h.textContent)'), ['Bucket 2 1', 'Bucket 3 1']);
|
|
const tasks = await text('#bus-view');
|
|
assert.match(tasks, /Inbox <script>window\.injected=1<\/script>/);
|
|
// The list reads no trail: it says only that the current state came from a Vikunja read.
|
|
assert.equal(await b.evaluate('[...document.querySelectorAll("#bus-view .badge")].filter(e=>/^last read from Vikunja \\((board|cursor)\\)$/.test(e.textContent)).length'), 2);
|
|
assert.doesNotMatch(tasks, /refused write|not created by the stack/);
|
|
assert.match(tasks, /#10 \(no title in the current read\)/);
|
|
assert.match(tasks, /user 5/); assert.match(tasks, /2026-10-20/); assert.match(tasks, /high/);
|
|
assert.match(tasks, /Done 1/); assert.match(tasks, /No longer readable 1/); assert.match(tasks, /missing: moved/);
|
|
assert.match(tasks, /Bucket names, bot names and the Vikunja address: not in the Q1 module/);
|
|
assert.match(tasks, /Poller status: from the S3 status read, not built yet/);
|
|
await inertCheck('tasks'); await noOverflow('tasks');
|
|
|
|
// Task detail: request, requirement, external change, refused write, decisions, snapshots and trail.
|
|
await go('#/tasks/vikunja%3A32%2F7', '#7');
|
|
const t7 = await text('#bus-view');
|
|
assert.match(t7, /RequirementREQ-SLICE-1/);
|
|
assert.match(t7, /Changed in Vikunja after the stack wrote it: bucket, 2 new comments/);
|
|
assert.match(t7, /task\.update: Vikunja changed after the stack read it \(expected aaaaaaaa, found bbbbbbbb\)/);
|
|
assert.ok(t7.includes(s.ids.open.slice(0, 8)) && t7.includes(s.ids.old.slice(0, 8)));
|
|
assert.match(t7, /Request from the human, recorded from the CLI/);
|
|
assert.match(t7, /the request task\.created names/);
|
|
assert.match(t7, /The human to pm: Build the inbox/);
|
|
assert.match(t7, /Created for REQ-SLICE-1/);
|
|
assert.match(t7, /Changed in Vikunja: bucket, and 2 new comments/);
|
|
assert.match(t7, /Session started: pm as pm-run \(pi, pm-run\)/);
|
|
assert.match(t7, /Snapshots 2/);
|
|
await inertCheck('task'); await noOverflow('task');
|
|
await go('#/tasks/vikunja%3A32%2F8', '#8');
|
|
assert.match(await text('#bus-view'), /Not created by the stack; first read/);
|
|
assert.match(await text('#bus-view'), /Read from Vikunja for the first time/);
|
|
await go('#/tasks/vikunja%3A32%2F10', '#10');
|
|
assert.match(await text('#bus-status'), /No longer readable in Vikunja \(moved, now in project 40\)/);
|
|
|
|
// The trail with filters.
|
|
await go('#/trail/task/vikunja%3A32%2F7?kind=task', 'Trail for task #7');
|
|
assert.equal(await b.evaluate('document.querySelector(".chips [aria-current=true]").textContent.startsWith("Task changes")'), true);
|
|
assert.equal(await b.evaluate('[...document.querySelectorAll("#bus-view .s1-trow")].every(r=>r.classList.contains("s1-g-task"))'), true);
|
|
await go('#/trail/task/vikunja%3A32%2F7?kind=request', 'Trail for task #7');
|
|
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view .s1-trow").length'), 3); // the message, its delivery, the human.input
|
|
await go(`#/trail/decision/${s.ids.open}`, 'Trail for decision');
|
|
const dt = await text('#bus-view');
|
|
assert.match(dt, /Raised with this context: choosing “yes” approves git\.push\.protected on refactor/);
|
|
assert.match(dt, /Withdrawn by coder/); assert.match(dt, /jason saw it via cli/);
|
|
assert.match(dt, /Push the first candidate\?/);
|
|
await inertCheck('trail'); await noOverflow('trail');
|
|
|
|
// Agents: one row per held role, with its harness and address; the gaps are labelled.
|
|
await go('#/agents', 'Agents');
|
|
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view table.s1-agents tbody th")].map(e=>e.textContent)').then(r => r.sort()), ['coder', 'cto', 'pm']);
|
|
assert.match(await b.evaluate('[...document.querySelectorAll("#bus-view table.s1-agents tbody tr")].find(r=>r.querySelector("th").textContent==="cto").textContent'), /cto-runclaude-codecto-thread/);
|
|
for (const label of ['Role instances, launch limits and model families', 'Launch state', 'Live and ended sessions', 'Credential notices'])
|
|
assert.ok((await text('#bus-view')).includes(`${label}: not in the Q1 module`), label);
|
|
await noOverflow('agents');
|
|
|
|
// Addresses that are not pages.
|
|
await go('#/nope', 'Not found');
|
|
await go('#/inbox/bad%20id', 'Not found');
|
|
assert.match(await text('#bus-view'), /bad id is not a decision id/);
|
|
await go('#/tasks/vikunja%3A32%2F99', 'Not found');
|
|
|
|
// A failed read shows what was read before, labelled; a page never read shows the refusal.
|
|
await go('#/inbox', 'Inbox');
|
|
s.fail('outcome-unknown');
|
|
await b.evaluate('document.querySelector("#refresh").click()');
|
|
await wait('!!document.querySelector("#bus-view .s1-stale")');
|
|
assert.match(await text('#bus-view .s1-stale'), /The last read failed\. The bus did not answer in time\. \(outcome-unknown\)/);
|
|
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view .s1-dec").length'), 1);
|
|
await go('#/trail/task/vikunja%3A32%2F9', 'The read failed');
|
|
s.fail('human-required');
|
|
await b.evaluate('document.querySelector("#bus-view [data-retry]").click()');
|
|
await wait('document.querySelector("#bus-view h1").textContent==="Bus refused the read"');
|
|
assert.match(await text('#bus-view'), /started it from their own shell.*\(human-required\).*Nothing was written\./s);
|
|
s.fail(null);
|
|
await b.evaluate('document.querySelector("#bus-view [data-retry]").click()');
|
|
await wait('document.querySelector("#bus-view h1").textContent.startsWith("Trail for task #9")');
|
|
await noOverflow('refused');
|
|
|
|
// Back to the board: it still works.
|
|
await b.evaluate('document.querySelector("#sections a[href=\\"#/\\"]").click()');
|
|
await wait('document.querySelector("#bus-view").hidden && getComputedStyle(document.querySelector("#board-view")).display!=="none"');
|
|
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 3);
|
|
assert.equal(await b.evaluate('document.title'), 'Mosaic Console');
|
|
|
|
// The skip link focuses main and does not route.
|
|
await b.evaluate('location.hash="#/agents"'); await wait('document.querySelector("#bus-view h1")?.textContent==="Agents"');
|
|
await b.evaluate('document.querySelector(".skip").click()');
|
|
assert.equal(await b.evaluate('location.hash'), '#/agents');
|
|
assert.equal(await b.evaluate('document.activeElement.id'), 'main');
|
|
|
|
assert.deepEqual(await b.evaluate('window.errors'), []);
|
|
const requests = await b.evaluate('window.requests');
|
|
assert.deepEqual(requests.filter(([m]) => m !== 'GET'), []);
|
|
assert.ok(requests.some(([, u]) => u.startsWith('/api/bus/trail?subject=')));
|
|
assert.deepEqual([...new Set(s.calls)].sort(), ['agents', 'inbox', 'tasks', 'trail']);
|
|
|
|
// A Console with no bus configured says so, and its board still works.
|
|
await b.navigate(`${f.base}/#/inbox`); await wait('document.querySelector("#bus-view h1")?.textContent==="No bus to read"');
|
|
assert.match(await text('#bus-view'), /no bus configured.*The control board still works\. \(not-configured\)/s);
|
|
await b.evaluate('location.hash="#/"'); await wait('document.querySelectorAll("table.sessions tbody tr").length===3');
|
|
assert.deepEqual(await b.evaluate('window.errors'), []);
|
|
} finally { await b.close(); await close(web); await f.close(); s.store.close(); rmSync(root, { recursive: true, force: true }); }
|
|
});
|