row 198 r4 round 2: UNTESTED, mos-1513 B1 via named listener, N1 refusal stub, canSend gate
Untested: no node or browser run. Pushed early at Sage's and Webby's request for a fixed sha. - six old browser tests forward writes through tests/board-route.mjs (routeWrites), with /api/config answered send:true - discord 409 reached through the named send listener - board-refused.test.mjs (N1 stub), board-route-map.test.mjs (node mapping), send-off-browser.test.mjs (canSend gate, send off) - app.js: reply form and Seen button gated on /api/config send - BUILD.md round 2 section: numstat, line maps, N1 mapping, open items
This commit is contained in:
1 parent
4d8123b0a5
commit
ab64174753
12 files changed
+389
-13
No files matched your search
@@ -367,3 +367,102 @@ paths. Expected: `R6 G4b` fails at the granted 403 assertion, because the grante
|
||||
|
||||
UNVERIFIED. These four files and the granted-user test are edited but not run. The rule "commit only after suites
|
||||
are green" is not yet met, and this commit says so. The coverage loss above needs Sage's ruling before the gate.
|
||||
|
||||
## r4 round 2: mos-1513 (B1 fix), N1 test, route helper (19:23Z 10-23)
|
||||
|
||||
Not run. Node and browser suites wait for Sage's CLEAR.
|
||||
|
||||
### B1: CDP interception instead of a named listener on the old files
|
||||
|
||||
Per mos-1513, the browser tests keep the real in-process board. Each one calls `routeWrites(b, base, board)` from
|
||||
`packages/webui/tests/board-route.mjs` before it navigates. Fetch intercepts the page's `/api/config` (answered
|
||||
with the real body and `send: true`), `/api/reply` and `/api/seen`. Each write is forwarded over node http to a
|
||||
named send listener on the same board, with the preview Host, the `https://` Origin and the signed user header.
|
||||
So the server's own gate, parse and status mapping answer, and the page gets that answer back. Nothing re-implements
|
||||
`boardSend`.
|
||||
|
||||
Shared helper, named for webby-0243: `board-route.mjs` exports `ROUTE`, `namedListener(board)`, `forward(port, path,
|
||||
body)` and `routeWrites(b, base, board)`. `namedListener` and `forward` are the same code `routeWrites` uses.
|
||||
|
||||
### Numstat (git diff --numstat, packages/webui/tests, against 4d8123b0a)
|
||||
|
||||
3 1 board-held.test.mjs
|
||||
7 2 browser-edge.test.mjs
|
||||
6 2 browser.test.mjs
|
||||
6 3 discord.test.mjs
|
||||
4 2 history-return-flow.test.mjs
|
||||
4 2 return-flow.test.mjs
|
||||
|
||||
New files: `board-route.mjs` (helper), `board-route-map.test.mjs` (node, see below), `board-refused.test.mjs` (N1).
|
||||
|
||||
### Line map: every removed line in the six old tests
|
||||
|
||||
No assertion line is changed. Every removed line is one of these, and each is listed by old line:
|
||||
|
||||
- board-held.test.mjs: `done` teardown (adds `stopRoute()` before `b.close()`); the `routeWrites` line is added in
|
||||
`setup`. No assertion.
|
||||
- browser-edge.test.mjs: the `await b.viewport(320); await b.navigate(...)` line is rewritten as the same two
|
||||
statements with the `routeWrites` line added before it; the `finally` adds `stopRoute?.()`. The `board` object
|
||||
is unchanged. `held` and `releaseBoard` are the same. No assertion.
|
||||
- browser.test.mjs: the `finally` adds `stopRoute?.()`; `routeWrites` is added before the `viewport`/`navigate`
|
||||
line, which is byte-identical. No assertion.
|
||||
- discord.test.mjs: the `fetch(base + '/api/reply', ...)` request line becomes `namedListener` + `forward`, the
|
||||
request setup, not the assertion; `assert.equal(reply.status, 409)` is byte-identical. The `finally` closes the
|
||||
named listener.
|
||||
- history-return-flow.test.mjs: `let b` gains `stopRoute`; `finally` adds `stopRoute()`. No assertion.
|
||||
- return-flow.test.mjs: `let web, b` gains `stopRoute`; `routeWrites` added after `browser()`; `finally` adds
|
||||
`stopRoute()`. No assertion.
|
||||
|
||||
### N1 (sage-3618, mos-1506 b): the refusal block, old to new
|
||||
|
||||
Old line numbers are in `shell.test.mjs` at 14fe7513c, the block 178-245. New line numbers are in
|
||||
`board-refused.test.mjs`. Every old assertion is restored with its text unchanged:
|
||||
|
||||
181 -> 74 183 -> 76 184 -> 77 185 -> 78 187 -> 80
|
||||
188 -> 81 189 -> 82 190 -> 83 191 -> 84 192 -> 85
|
||||
193 -> 86 194 -> 87 195 -> 88 196 -> 89 197 -> 90
|
||||
203 -> 97 204 -> 98 205 -> 99 206 -> 100 207 -> 101
|
||||
208 -> 102 209 -> 103 210 -> 104 211 -> 105 212 -> 106
|
||||
215 -> 109 216 -> 110 217 -> 111 218 -> 112 220 -> 114
|
||||
221 -> 115 227 -> 122 229 -> 124 232 -> 127 236 -> 131
|
||||
237 -> 132 238 -> 133 239 -> 134 240 -> 135 241 -> 136
|
||||
243 -> 138
|
||||
|
||||
Changes in the port: the page-level 403 stub (`Fetch` on `*/api/board*` and `*/api/conversation*`) is the one
|
||||
place a page-level stub is used, per N1. The board is the in-process one with `refuse()` set. The fake-board
|
||||
data in `board()` is the old fake board's data, moved.
|
||||
|
||||
Pinned N1 mutant (not run): in `packages/webui/public/app.js` (the `error(err)` branch that sets
|
||||
`refused = err.status === 403`), drop the `#board-refused` show and the `refused` banner. Expected:
|
||||
`board-refused.test.mjs` fails at line 80 (`banner ref`) and line 83 (`#board-refused` hidden). Run after CLEAR.
|
||||
|
||||
### Node coverage for the real route
|
||||
|
||||
`board-route-map.test.mjs` (new) starts the named listener on a fixture board and pins the mapping the browser
|
||||
tests now rely on: seen is 200; a delivered reply is 200 with `delivered: true`; an exit-4 reply is 200 with
|
||||
`delivered: false` and `exitCode: 4` (this is `replyWith` in control-board/src/serve.mjs, which maps a nonzero exit
|
||||
to 200, not a status code). Existing coverage cited, not duplicated: `board-inprocess.test.mjs:84-97` (seen 200 and
|
||||
reply delivered through a named listener); `send.test.mjs:114-119` (loopback Host writes 403 read-only, nothing
|
||||
reaches the board); `preview.test.mjs:185-199` (every write 403 read-only from the public name and loopback; 401
|
||||
unsigned on `/api/reply`).
|
||||
|
||||
The discord 409 is now reached through the named listener, so `discord.test.mjs` pins the connector refusal on the
|
||||
real route.
|
||||
|
||||
### Not done in this round
|
||||
|
||||
- The plain-listener browser check for the reply form and Seen line with send off (the `canSend` gate in app.js).
|
||||
The node and browser coverage for that gate is not written yet.
|
||||
- `send-browser.test.mjs:67,243` is unchanged. Webby to confirm next round.
|
||||
- Mutant M18 and the N1 mutant are not run.
|
||||
|
||||
### Gate status
|
||||
|
||||
UNVERIFIED. Nothing in this round is run. Waiting for Sage's CLEAR before any node or browser run.
|
||||
|
||||
### Correction to the r4 round 2 entry above (19:24Z 10-23)
|
||||
|
||||
- The N1 mutant's file is `packages/webui/src/public/app.js`, not `packages/webui/public/app.js`.
|
||||
- `board-refused.test.mjs` does not use the in-process board. Its `board()` is an inline stub object with a `refuse()`
|
||||
flag; the 403 comes from a page-level Fetch stub on `*/api/board*` and `*/api/conversation*`, as the N1 ruling
|
||||
allows. The "in-process board with refuse() set" sentence above is wrong.
|
||||
@@ -20,6 +20,8 @@
|
||||
// `sign-in required`; that signs the page out too, and the line links to the Console's sign-in.
|
||||
// Any other 401 stays an error. `sso` comes from /api/config.
|
||||
let sso = false;
|
||||
// Row 198 (mos-1512): the reply form and the Seen button draw only when /api/config says send; otherwise a line says so.
|
||||
let canSend = false;
|
||||
const signInRequired = (res, body) => res.status === 401 && body?.error === 'sign-in required';
|
||||
const signedOut = () => Object.assign(new Error('the sign-in proxy redirected it, so it never reached the Console'), { signedOut: true });
|
||||
const labels = { registration: 'registered', 'first-user-message': 'first message', 'tmux-pane': 'tmux pane', 'session-cwd': 'session cwd', 'workspace-git-root': 'git root' };
|
||||
@@ -46,7 +48,7 @@
|
||||
const s = Date.parse(sent), a = Date.parse(r.lastActivity);
|
||||
return Number.isFinite(s) && Number.isFinite(a) && a < s;
|
||||
}
|
||||
const canReply = r => !r.connector && r.registered && r.registered.alive !== false && r.registered.tmux?.session;
|
||||
const canReply = r => canSend && !r.connector && r.registered && r.registered.alive !== false && r.registered.tmux?.session;
|
||||
const row = id => data?.sessions.find(r => key(r) === id);
|
||||
const announce = text => { $('announce').textContent = text; };
|
||||
// Colours come from tokens.css by data-palette and data-mode. System removes data-mode, so the OS setting picks light or dark.
|
||||
@@ -66,6 +68,7 @@
|
||||
$('mode').onchange = e => { mode = e.target.value; theme(); };
|
||||
function seenButton(r) {
|
||||
if (!['waiting', 'error'].includes(r.state) || !r.lastActivity) return '';
|
||||
if (!canSend) return '<span class="small muted">Seen is off: this Console is read-only.</span>';
|
||||
return `<button type="button" class="btn" data-seen="${esc(key(r))}" aria-label="${r.seen ? 'Unsee' : 'Mark seen'} ${esc(r.agent)}">${r.seen ? 'Unsee' : 'Seen'}</button>`;
|
||||
}
|
||||
const historyButton = r => `<button type="button" class="btn conv-open" data-history="${esc(key(r))}" aria-label="History for ${esc(r.agent)}">History</button>`;
|
||||
@@ -94,7 +97,7 @@
|
||||
if (r.relaunchedAt) fields.unshift(['Current activity', relaunchNotice(r)]);
|
||||
if (r.connector) fields.push(['Connector', `${r.connector.braked === true ? 'braked (STOP)' : r.connector.braked === false ? 'not braked' : 'brake unknown'}; owner ${r.connector.ownerState}`]);
|
||||
const receipt = receipts.get(selected);
|
||||
$('inspection').innerHTML = `<dl class="kv">${fields.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join('')}${pending(r) ? `<dt>Reply</dt><dd>Waiting for a reply to your message sent ${esc(awaiting.get(selected))}. The page checks every 10 seconds${paused ? ' once you resume' : ''}.</dd><dt>${r.relaunchedAt ? 'Historical assistant text' : 'Previous assistant text'}, before your message</dt>` : `<dt>${r.relaunchedAt ? 'Historical last assistant text' : 'Last assistant text'}</dt>`}<dd><pre class="last-text">${esc(r.lastAssistantText || 'No assistant text yet.')}</pre></dd>${r.lastError ? `<dt>${r.relaunchedAt ? 'Historical last error' : 'Last error'}</dt><dd>${esc(r.lastError)}</dd>` : ''}</dl>${historyButton(r)}${seenButton(r)}${canReply(r) ? `<form class="reply-form"><label for="reply">Reply to ${esc(r.agent)}</label><textarea id="reply" maxlength="2000" rows="3" placeholder="Message this seat">${esc(drafts.get(selected) || '')}</textarea><button class="btn primary" type="submit" ${sending.has(selected) ? 'disabled' : ''}>${sending.has(selected) ? 'Sending…' : 'Send'}</button></form>` : `<p class="muted">${r.connector ? 'Board replies disabled for Discord connectors' : 'reply needs a registered seat'}</p>`}${receipt ? `<p class="receipt ${receipt.delivered ? '' : 'failed'}" role="status">${esc(receipt.delivered ? `delivered ${receipt.sentAt} to tmux ${receipt.session}` : `failed${receipt.exitCode == null ? '' : ' (exit ' + receipt.exitCode + ')'}: ${receipt.stderr || receipt.error || 'no output'}`)}</p>` : ''}`;
|
||||
$('inspection').innerHTML = `<dl class="kv">${fields.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join('')}${pending(r) ? `<dt>Reply</dt><dd>Waiting for a reply to your message sent ${esc(awaiting.get(selected))}. The page checks every 10 seconds${paused ? ' once you resume' : ''}.</dd><dt>${r.relaunchedAt ? 'Historical assistant text' : 'Previous assistant text'}, before your message</dt>` : `<dt>${r.relaunchedAt ? 'Historical last assistant text' : 'Last assistant text'}</dt>`}<dd><pre class="last-text">${esc(r.lastAssistantText || 'No assistant text yet.')}</pre></dd>${r.lastError ? `<dt>${r.relaunchedAt ? 'Historical last error' : 'Last error'}</dt><dd>${esc(r.lastError)}</dd>` : ''}</dl>${historyButton(r)}${seenButton(r)}${canReply(r) ? `<form class="reply-form"><label for="reply">Reply to ${esc(r.agent)}</label><textarea id="reply" maxlength="2000" rows="3" placeholder="Message this seat">${esc(drafts.get(selected) || '')}</textarea><button class="btn primary" type="submit" ${sending.has(selected) ? 'disabled' : ''}>${sending.has(selected) ? 'Sending…' : 'Send'}</button></form>` : `<p class="muted">${!canSend ? 'Replies are off: this Console is read-only.' : r.connector ? 'Board replies disabled for Discord connectors' : 'reply needs a registered seat'}</p>`}${receipt ? `<p class="receipt ${receipt.delivered ? '' : 'failed'}" role="status">${esc(receipt.delivered ? `delivered ${receipt.sentAt} to tmux ${receipt.session}` : `failed${receipt.exitCode == null ? '' : ' (exit ' + receipt.exitCode + ')'}: ${receipt.stderr || receipt.error || 'no output'}`)}</p>` : ''}`;
|
||||
}
|
||||
// Conversation view (#1507, CHAT-02): the full read-only history from the board's
|
||||
// /api/conversations and /api/conversation routes. Session content is set with
|
||||
@@ -476,5 +479,5 @@
|
||||
setTimeout(() => { const a = document.activeElement; if (!a || a === document.body || a.disabled || !a.getClientRects().length) line.focus(); });
|
||||
});
|
||||
// Sign out (sage-1347, F7): a plain same-origin form POST to /auth/logout, shown only under --sso.
|
||||
api('/api/config').then(config => { board = config.board || 'the Console board'; $('board-url').textContent = `Board: ${board}`; window.MosaicFederation?.render(config.federation, $('federation')); if (config.sso === true) { sso = true; $('sign-out').hidden = out(); } }).catch(err => { if (machine) error(err); }).finally(refresh);
|
||||
api('/api/config').then(config => { canSend = config.send === true; board = config.board || 'the Console board'; $('board-url').textContent = `Board: ${board}`; window.MosaicFederation?.render(config.federation, $('federation')); if (config.sso === true) { sso = true; $('sign-out').hidden = out(); } }).catch(err => { if (machine) error(err); }).finally(refresh);
|
||||
})();
|
||||
@@ -22,6 +22,7 @@ import { views } from '../../bus/src/views.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { fixture, close } from './fixture.mjs';
|
||||
import { routeWrites } from './board-route.mjs';
|
||||
|
||||
const businesses = {
|
||||
demo: {
|
||||
@@ -73,6 +74,7 @@ async function setup(machine, fast = false) {
|
||||
const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) });
|
||||
const f = await fixture(), b = await browser({ machine });
|
||||
const web = await startServer({ port: 0, board: f.board, bus }), base = `http://127.0.0.1:${web.address().port}/`;
|
||||
const stopRoute = await routeWrites(b, base, f.board);
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub(fast) });
|
||||
await b.viewport(1280);
|
||||
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)})})()`);
|
||||
@@ -81,7 +83,7 @@ async function setup(machine, fast = false) {
|
||||
const reqs = () => b.evaluate('window.__reqs');
|
||||
const polls = () => b.evaluate('window.__polls');
|
||||
const settle = () => new Promise(r => setTimeout(r, 600));
|
||||
const done = async () => { await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); };
|
||||
const done = async () => { await stopRoute(); await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); };
|
||||
return { b, base, wait, text, shown, reqs, polls, settle, done };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// Row 198 r4 (sage-3618, mos-1506): the board refusal. The in-process board never answers 403 (consoleRead
|
||||
// has no 403 code), so the browser stubs the answer: while the board is refused, Fetch fulfils /api/board
|
||||
// and /api/conversation(s) with 403 board-refused, as the old fake board server did. The assertions are the
|
||||
// refusal block that 4d8123b0a deleted from shell.test.mjs, ported one for one (BUILD.md, r4 mapping).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync } 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 { ReadError } from '../src/reads.mjs';
|
||||
|
||||
// The old fake board's data: agent1 waiting on you in proj, plus agent2 and agent3, one conversation.
|
||||
function board() {
|
||||
const state = { refused: false, down: false, mode: 'ok' };
|
||||
let index = { 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 };
|
||||
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 };
|
||||
const set = (sessions, counts) => { index = { sessions, counts, generatedAt: new Date().toISOString() }; state.mode = 'ok'; };
|
||||
return {
|
||||
state,
|
||||
board: {
|
||||
index: () => {
|
||||
if (state.down) throw new ReadError('read-failed', 'board unavailable');
|
||||
if (state.mode === 'fail') throw new Error('board scan failed');
|
||||
return index;
|
||||
},
|
||||
conversation: url => {
|
||||
if (state.mode === 'fail') throw new Error('catalogue read failed');
|
||||
return { status: 200, body: url.pathname === '/api/conversations' ? catalogue : page };
|
||||
},
|
||||
markSeen: () => ({ status: 200, body: { ok: true } }),
|
||||
reply: () => ({ status: 200, body: { delivered: true } }),
|
||||
},
|
||||
one: () => { state.refused = false; state.down = false; set([one, { ...one, agent: 'agent2', state: 'working', waitingOnYou: false }, { ...one, agent: 'agent3', state: 'idle', waitingOnYou: false, seen: true }], { waiting: 1, working: 1 }); },
|
||||
registered: () => { state.refused = false; state.down = false; set([{ ...one, registered: { alive: true, tmux: { session: 'fixture' } } }], { waiting: 1 }); },
|
||||
fail: () => { state.refused = false; state.mode = 'fail'; },
|
||||
unavailable: () => { state.refused = false; state.down = true; },
|
||||
refuse: () => { state.refused = true; },
|
||||
};
|
||||
}
|
||||
|
||||
test('a board refusal fails closed: the page hides, the banner says refused, the conversation closes and clears', { timeout: 180000 }, async () => {
|
||||
const brd = board(); brd.one();
|
||||
const web = await startServer({ port: 0, board: brd.board });
|
||||
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'); };
|
||||
// The stub: while refused, the board's reads get the 403 the old fake server gave; otherwise they go on to the Console.
|
||||
await b.call('Fetch.enable', { patterns: [{ urlPattern: '*/api/board*', requestStage: 'Request' }, { urlPattern: '*/api/conversation*', requestStage: 'Request' }] });
|
||||
b.on('Fetch.requestPaused', ({ requestId }) => {
|
||||
const answer = brd.state.refused
|
||||
? b.call('Fetch.fulfillRequest', { requestId, responseCode: 403, responseHeaders: [{ name: 'content-type', value: 'application/json' }], body: Buffer.from(JSON.stringify({ error: 'board-refused' })).toString('base64') })
|
||||
: b.call('Fetch.continueRequest', { requestId });
|
||||
answer.catch(() => {});
|
||||
});
|
||||
try {
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'window.errors=[];addEventListener("error",e=>errors.push(e.message));addEventListener("unhandledrejection",e=>errors.push(String(e.reason)));' });
|
||||
await b.viewport(400, 900);
|
||||
await b.navigate(base); await wait('!document.querySelector("#sessions [aria-busy]")');
|
||||
await refresh(); await wait('document.querySelectorAll("table.sessions tbody tr").length===2');
|
||||
|
||||
// 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. With nothing focused, focus stays put.
|
||||
await b.evaluate('document.activeElement.blur()');
|
||||
brd.unavailable(); await refresh();
|
||||
assert.equal(await b.evaluate('document.activeElement === document.body'), true, 'a failure with nothing focused leaves focus alone');
|
||||
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);
|
||||
// Fix to HEAD behaviour: a failed catalogue read leaves no session from the last one in the picker.
|
||||
assert.equal(await count('#conv-pick option'), 1);
|
||||
brd.fail(); await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
|
||||
await wait('document.querySelector("#conv-status").textContent.startsWith("History unavailable")');
|
||||
assert.equal(await count('#conv-pick option'), 0, 'no stale session in the picker');
|
||||
brd.registered(); await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
|
||||
await wait('document.querySelector("#conv-log").textContent.includes("Fixture history line")');
|
||||
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);
|
||||
// Fix to HEAD behaviour: focus went back to History, which the refusal removed; it lands on the page, not <body>.
|
||||
assert.equal(await b.evaluate('document.activeElement.id'), 'main', 'focus after a refusal closes the conversation');
|
||||
brd.one(); await refresh(); await wait('document.querySelectorAll("table.sessions tbody tr").length===2');
|
||||
assert.deepEqual(await b.evaluate('window.errors'), []);
|
||||
} finally { await b.close(); await close(web); }
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
// Row 198 (mos-1513): the write mapping the browser tests reach through board-route.mjs, pinned in node
|
||||
// against the named send listener itself. The Console's own gate, parse and status mapping answer here;
|
||||
// the browser tests only get back what this listener returns.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { close, fixture } from './fixture.mjs';
|
||||
import { forward, namedListener } from './board-route.mjs';
|
||||
|
||||
test('named send listener maps the board writes: seen is 200, a delivered reply is 200 delivered, an exit-4 reply is 200 not delivered', { timeout: 60000 }, async t => {
|
||||
const f = await fixture();
|
||||
const named = await namedListener(f.board);
|
||||
t.after(async () => { await close(named); await f.close(); });
|
||||
const port = named.address().port;
|
||||
const rec = (await f.board.index()).sessions.find(r => r.agent === 'agent1');
|
||||
const row = `${rec.project}/${rec.agent}`;
|
||||
|
||||
const seen = await forward(port, '/api/seen', JSON.stringify({ project: rec.project, agent: rec.agent, lastActivity: rec.lastActivity, seen: true }));
|
||||
assert.equal(seen.status, 200, seen.text);
|
||||
|
||||
const delivered = await forward(port, '/api/reply', JSON.stringify({ agent: row, text: 'hi' }));
|
||||
assert.equal(delivered.status, 200, delivered.text);
|
||||
assert.equal(JSON.parse(delivered.text).delivered, true);
|
||||
assert.equal(f.captures.length, 1);
|
||||
|
||||
f.failReply();
|
||||
const refused = await forward(port, '/api/reply', JSON.stringify({ agent: row, text: 'hi' }));
|
||||
assert.equal(refused.status, 200, refused.text);
|
||||
const body = JSON.parse(refused.text);
|
||||
assert.equal(body.delivered, false);
|
||||
assert.equal(body.exitCode, 4);
|
||||
assert.equal(f.captures.length, 2);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
// Row 198 (mos-1513): the browser tests reach the real board routes. Fetch intercepts the page's
|
||||
// /api/config (send on) and its /api/reply and /api/seen writes. Node forwards each write to a named
|
||||
// send listener on the same board, with the preview Host, the https Origin and the signed user header,
|
||||
// so the server's own gate, parse and status mapping answer and the page gets that answer back.
|
||||
// Nothing here re-implements boardSend; the node route tests pin what the listener returns.
|
||||
import { request } from 'node:http';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
|
||||
export const ROUTE = { host: 'board-route.example', header: 'X-authentik-uid', user: 'board-route-user' };
|
||||
const JSON_HEADER = [{ name: 'content-type', value: 'application/json' }];
|
||||
|
||||
// The named send listener on the board: the preview Host, so the server's own gate and status mapping answer.
|
||||
export function namedListener(board) {
|
||||
return startServer({ port: 0, board, preview: ROUTE, send: async () => ({ id: 'board-route' }) });
|
||||
}
|
||||
|
||||
// One POST to the named listener, as the browser's forwarded write would arrive. Resolves { status, text }.
|
||||
export function forward(port, path, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const headers = { host: ROUTE.host, origin: `https://${ROUTE.host}`, [ROUTE.header]: ROUTE.user, 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) };
|
||||
const req = request({ host: '127.0.0.1', port, method: 'POST', path, headers }, res => {
|
||||
let text = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', chunk => { text += chunk; });
|
||||
res.on('end', () => resolve({ status: res.statusCode, text }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end(body);
|
||||
});
|
||||
}
|
||||
|
||||
// Returns a function that closes the named listener. Call it in the test's finally, before the browser closes.
|
||||
export async function routeWrites(b, base, board) {
|
||||
const listener = await namedListener(board);
|
||||
const port = listener.address().port;
|
||||
await b.call('Fetch.enable', { patterns: [
|
||||
{ urlPattern: '*/api/config', requestStage: 'Request' },
|
||||
{ urlPattern: '*/api/reply', requestStage: 'Request' },
|
||||
{ urlPattern: '*/api/seen', requestStage: 'Request' },
|
||||
] });
|
||||
b.on('Fetch.requestPaused', async ({ requestId, request: req }) => {
|
||||
try {
|
||||
const path = new URL(req.url).pathname;
|
||||
let answer;
|
||||
if (path === '/api/config') {
|
||||
const config = await (await fetch(base + '/api/config')).json();
|
||||
answer = { status: 200, text: JSON.stringify({ ...config, send: true }) };
|
||||
} else {
|
||||
answer = await forward(port, path, req.postData ?? '');
|
||||
}
|
||||
await b.call('Fetch.fulfillRequest', { requestId, responseCode: answer.status, responseHeaders: JSON_HEADER, body: Buffer.from(answer.text).toString('base64') });
|
||||
} catch {
|
||||
await b.call('Fetch.failRequest', { requestId, errorReason: 'Failed' }).catch(() => {});
|
||||
}
|
||||
});
|
||||
return () => close(listener);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { browser } from './browser.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
import { routeWrites } from './board-route.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
|
||||
test('browser edge states: loading, empty, malformed, stale, hostile/long values, in-flight reply and appearance fallback', { timeout: 120000 }, async () => {
|
||||
@@ -19,9 +20,12 @@ test('browser edge states: loading, empty, malformed, stale, hostile/long values
|
||||
};
|
||||
const web = await startServer({ port: 0, board });
|
||||
const b = await browser();
|
||||
const base = `http://127.0.0.1:${web.address().port}`;
|
||||
let stopRoute;
|
||||
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')})()`);
|
||||
try {
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'Object.defineProperty(window,"localStorage",{get(){throw new Error("storage denied")}});window.errors=[];addEventListener("error",e=>errors.push(e.message));' });
|
||||
stopRoute = await routeWrites(b, base, board);
|
||||
await b.viewport(320); await b.navigate(`http://127.0.0.1:${web.address().port}`);
|
||||
// Row 95: with storage denied the toggle starts off and still turns on; the page throws nothing.
|
||||
assert.match(await b.evaluate('document.querySelector("#status").textContent'), /Machine sessions hidden/);
|
||||
@@ -93,5 +97,5 @@ test('browser edge states: loading, empty, malformed, stale, hostile/long values
|
||||
assert.match(await b.evaluate('document.querySelector("#error").textContent'), /Invalid board session data/);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions [data-open]").length'), 1);
|
||||
assert.deepEqual(await b.evaluate('window.errors'), []);
|
||||
} finally { releaseBoard?.(index); held?.({ status: 200, body: {} }); await b.close(); await close(web); }
|
||||
} finally { releaseBoard?.(index); held?.({ status: 200, body: {} }); await stopRoute?.(); await b.close(); await close(web); }
|
||||
});
|
||||
@@ -4,14 +4,17 @@ import { mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { browser } from './browser.mjs';
|
||||
import { fixture } from './fixture.mjs';
|
||||
import { routeWrites } from './board-route.mjs';
|
||||
|
||||
test('served Console browser: real board fixtures, keyboard, drafts, receipts, themes, 320px and failures', { timeout: 120000 }, async () => {
|
||||
const f = await fixture(), b = await browser();
|
||||
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')})()`);
|
||||
let stopRoute;
|
||||
try {
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'window.errors=[];addEventListener("error",e=>errors.push(e.message));addEventListener("unhandledrejection",e=>errors.push(String(e.reason)));' });
|
||||
stopRoute = await routeWrites(b, f.base, f.board);
|
||||
await b.viewport(1440); await b.navigate(f.base); await wait('document.querySelectorAll("table.sessions tbody tr").length===3');
|
||||
assert.equal(await b.evaluate('document.querySelector("#waiting-count").textContent'), '2');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#projects [data-project]").length'), 3);
|
||||
@@ -119,5 +122,5 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
|
||||
assert.deepEqual(await b.evaluate('window.errors'), []);
|
||||
await b.navigate(f.base); await wait('!document.querySelector("#error").hidden');
|
||||
assert.match(await b.evaluate('document.querySelector("#error").textContent'), /No board data loaded/);
|
||||
} finally { await b.close(); await f.close(); }
|
||||
} finally { await stopRoute?.(); await b.close(); await f.close(); }
|
||||
});
|
||||
@@ -8,10 +8,11 @@ import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { close, inProcessBoard } from './fixture.mjs';
|
||||
import { forward, namedListener } from './board-route.mjs';
|
||||
|
||||
test('Discord row through real board/WebUI: independent brake/liveness, no Reply, literal content', { timeout: 60000 }, async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'webui-discord-'));
|
||||
let web, b;
|
||||
let web, b, named;
|
||||
try {
|
||||
const dir = join(root, 'discord', 'pilot'); mkdirSync(join(dir, 'run.lock'), { recursive: true });
|
||||
writeFileSync(join(root, 'discord', 'pilot.json'), JSON.stringify({ name: 'pilot', seat: 'sage', tokenFile: '/PRIVATE-NOT-READ', guildId: 'PRIVATE-ID' }), { mode: 0o600 });
|
||||
@@ -30,7 +31,9 @@ test('Discord row through real board/WebUI: independent brake/liveness, no Reply
|
||||
assert.equal(JSON.stringify(payload).includes('PRIVATE'), false);
|
||||
assert.equal(payload.sessions[0].task, 'Discord connector'); assert.equal(payload.sessions[0].taskSource, 'connector'); assert.equal(payload.sessions[0].taskSetBy, null);
|
||||
assert.equal(JSON.stringify(payload).includes('forged'), false);
|
||||
const reply = await fetch(base + '/api/reply', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ agent: 'fleet/sage (discord: pilot)', text: 'refuse me' }) });
|
||||
// The 409 is the board's refusal, reached through the named send listener; the loopback listener has no send and answers 403 first.
|
||||
named = await namedListener(board);
|
||||
const reply = await forward(named.address().port, '/api/reply', JSON.stringify({ agent: 'fleet/sage (discord: pilot)', text: 'refuse me' }));
|
||||
assert.equal(reply.status, 409);
|
||||
b = await browser(); await b.viewport(320, 1000);
|
||||
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,50))}throw Error('timeout')})()`);
|
||||
@@ -57,5 +60,5 @@ test('Discord row through real board/WebUI: independent brake/liveness, no Reply
|
||||
await wait('document.querySelector("#inspection").textContent.includes("owner absent")');
|
||||
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /not braked; owner absent/);
|
||||
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /offline/);
|
||||
} finally { if (b) await b.close(); if (web) await close(web); rmSync(root, { recursive: true, force: true }); }
|
||||
} finally { if (b) await b.close(); if (named) await close(named); if (web) await close(web); rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { join } from 'node:path';
|
||||
import { browser } from './browser.mjs';
|
||||
import { routeWrites } from './board-route.mjs';
|
||||
import { historyFixture, session, user, assistant, toolResult, at, waiter, turns } from './history-fixture.mjs';
|
||||
|
||||
const PEER = '[from: darkwing (7525c8e4) -> to: fixture (00000000)] peer note that lands mid-turn';
|
||||
@@ -16,7 +17,7 @@ const BIG = 'BIG_START ' + Array.from({ length: 900000 }, (_, i) => (i % 1679616
|
||||
|
||||
test('return flow through the conversation view: send, tool call, delayed result, peer message, exact long answers, relaunch', { timeout: 240000 }, async () => {
|
||||
const f = await historyFixture();
|
||||
let b;
|
||||
let b, stopRoute;
|
||||
try {
|
||||
const file = join(f.sessionsDir, '2026-09-26T10-00-00_s1.jsonl');
|
||||
const log = session(file, f.projectRoot);
|
||||
@@ -24,6 +25,7 @@ test('return flow through the conversation view: send, tool call, delayed result
|
||||
log.add(assistant('Input needed: OLD_ANSWER'), at(-90));
|
||||
|
||||
b = await browser(); await b.viewport(1440, 1000);
|
||||
stopRoute = await routeWrites(b, f.base, f.board);
|
||||
const wait = waiter(b);
|
||||
const draft = () => b.evaluate('(() => { const t = document.querySelector("#conv-reply"); return [t.value, t.selectionStart, document.activeElement === t]; })()');
|
||||
// The seat is waiting on Jason, so the view opens from its card.
|
||||
@@ -95,5 +97,5 @@ test('return flow through the conversation view: send, tool call, delayed result
|
||||
assert.notEqual(await b.evaluate('document.querySelector("#conv-pick").value'), opened);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-marker=newer]")'), null);
|
||||
assert.equal(await b.evaluate('document.querySelector("#conv-reply").value'), 'NEXT_DRAFT');
|
||||
} finally { if (b) await b.close(); await f.close(); }
|
||||
} finally { if (stopRoute) await stopRoute(); if (b) await b.close(); await f.close(); }
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { close, inProcessBoard } from './fixture.mjs';
|
||||
import { routeWrites } from './board-route.mjs';
|
||||
|
||||
const at = s => new Date(Date.now() + s * 1000).toISOString();
|
||||
const message = (timestamp, m) => JSON.stringify({ type: 'message', timestamp, message: m }) + '\n';
|
||||
@@ -21,7 +22,7 @@ const assistant = (timestamp, text) => message(timestamp, { role: 'assistant', s
|
||||
|
||||
test('reported return flow and relative Age: reply sent from the inspector, then the new answer appears there without manual refresh', { timeout: 90000 }, async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'webui-return-'));
|
||||
let web, b;
|
||||
let web, b, stopRoute;
|
||||
try {
|
||||
const sessionsDir = join(root, 'sessions'); mkdirSync(sessionsDir);
|
||||
const file = join(sessionsDir, 's.jsonl');
|
||||
@@ -32,6 +33,7 @@ test('reported return flow and relative Age: reply sent from the inspector, then
|
||||
exec: (f, args) => { captures.push(args); return { status: 0, stdout: '', stderr: '' }; } });
|
||||
web = await startServer({ port: 0, board });
|
||||
b = await browser(); await b.viewport(1440, 1000);
|
||||
stopRoute = await routeWrites(b, `http://127.0.0.1:${web.address().port}`, board);
|
||||
const wait = (expression, ms = 20000) => b.evaluate(`(async()=>{for(const end=Date.now()+${ms};Date.now()<end;){if(${expression})return true;await new Promise(r=>setTimeout(r,100))}throw Error('timeout: '+${JSON.stringify(expression)})})()`);
|
||||
const inspection = () => b.evaluate('document.querySelector("#inspection").textContent');
|
||||
await b.navigate(`http://127.0.0.1:${web.address().port}`); await wait('document.querySelector("table.sessions [data-open]")');
|
||||
@@ -71,5 +73,5 @@ test('reported return flow and relative Age: reply sent from the inspector, then
|
||||
const later = await inspection();
|
||||
assert.doesNotMatch(later, /Waiting for a reply|before your message/);
|
||||
assert.match(later, /Last assistant text/);
|
||||
} finally { if (b) await b.close(); if (web) await close(web); rmSync(root, { recursive: true, force: true }); }
|
||||
} finally { if (stopRoute) await stopRoute(); if (b) await b.close(); if (web) await close(web); rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
// Row 198 (mos-1512): with send off, the Console is read-only in the page. The reply form and the Seen
|
||||
// button are not drawn, and the inspector says so in words. The Console here is a plain listener with no
|
||||
// send, so /api/config answers send: false and the page's gate is what is under test.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { browser } from './browser.mjs';
|
||||
import { fixture } from './fixture.mjs';
|
||||
|
||||
test('send off: the reply form and the Seen button are not drawn, and the inspector says the Console is read-only', { timeout: 60000 }, async () => {
|
||||
const f = await fixture(), b = await browser();
|
||||
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)})})()`);
|
||||
try {
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'window.errors=[];addEventListener("error",e=>errors.push(e.message));addEventListener("unhandledrejection",e=>errors.push(String(e.reason)));' });
|
||||
await b.viewport(1440); await b.navigate(f.base);
|
||||
await wait('document.querySelector("table.sessions [data-open=\'proj/agent1\']")');
|
||||
assert.equal(await b.evaluate('(await fetch("/api/config").then(r => r.json())).send'), false);
|
||||
await b.evaluate('document.querySelector("table.sessions [data-open=\'proj/agent1\']").click()');
|
||||
await wait('!document.querySelector("#inspector").hidden');
|
||||
await wait('document.querySelector("#inspection").textContent.includes("Replies are off: this Console is read-only.")');
|
||||
assert.equal(await b.evaluate('!!document.querySelector(".reply-form")'), false);
|
||||
assert.equal(await b.evaluate('!!document.querySelector("#reply")'), false);
|
||||
assert.equal(await b.evaluate('!!document.querySelector("#inspection [data-seen]")'), false);
|
||||
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Seen is off: this Console is read-only\./);
|
||||
assert.deepEqual(await b.evaluate('window.errors'), []);
|
||||
} finally { await b.close(); await f.close(); }
|
||||
});
|
||||
Reference in new issue
Block a user