row 198 r4: port the four fake-board tests to the in-process board (sage-3597, sage-3603)
browser-edge, preview, tester-listener and shell read an in-process board now, not a fake HTTP server. Changed expectations and deleted refusal-only assertions are listed in BUILD.md for Mos (mos-1272). Adds granted-user R6 G4b: a granted session gets 403 owner-only on all five board paths before any read, and the owner reaches them. UNVERIFIED: these tests were not run (slot rule). Gate still waits for CLEAR.
This commit is contained in:
1 parent
14fe7513ce
commit
4d8123b0a5
6 files changed
+178
-98
No files matched your search
@@ -295,3 +295,75 @@ need a rerun, because the drift check did not cover them: they are upstream-only
|
|||||||
UNVERIFIED. No node, no test run, and no mutant has run on r4. The gate waits for Sage's CLEAR, after 226,
|
UNVERIFIED. No node, no test run, and no mutant has run on r4. The gate waits for Sage's CLEAR, after 226,
|
||||||
220/216 and 285 in gate order. The four fake-board tests above are known red. The rest are unknown until the
|
220/216 and 285 in gate order. The four fake-board tests above are known red. The rest are unknown until the
|
||||||
run.
|
run.
|
||||||
|
|
||||||
|
## r4: the four fake-board tests ported to the in-process board (sage-3597, sage-3603)
|
||||||
|
|
||||||
|
Scope: the four tests that read the retired fake HTTP board server, plus one new granted-user test. Numstat is
|
||||||
|
against 14fe7513c, the r4 tip before this entry. Nothing in this section has been run. The gate is still
|
||||||
|
UNVERIFIED, and the tests here were not executed (slot rule). Sage sends the list below to Mos (mos-1272).
|
||||||
|
|
||||||
|
| File | +/- | What changed |
|
||||||
|
|---|---|---|
|
||||||
|
| `packages/webui/tests/browser-edge.test.mjs` | 14/13 | The fake server is gone. `index()` holds while `holdBoard` is set and resolves to the page's board on release. `reply()` holds in place of the `held` response and returns `{status, body}`. The 5 s "made no /api/board request" check still runs: it now watches `index()` being called. Every assertion is kept. |
|
||||||
|
| `packages/webui/tests/preview.test.mjs` | 12/16 | `setup()` builds an in-process board that records `GET /api/board`, `POST /api/seen` and `POST /api/reply` in `boardHits`. The 401 and read-only tests keep their `boardHits` checks. The dead-board case is a board whose `index()` throws, so the answer is 502 `invalid-response`. The upstream origin is no longer in the body (see the deleted assertion below). |
|
||||||
|
| `packages/webui/tests/tester-listener.test.mjs` | 4/4 | The first test's `hits` counter moves into an in-process `index()`. Every bus and board read still counts, and both 403 checks are unchanged. The `createServer` import is removed. |
|
||||||
|
| `packages/webui/tests/shell.test.mjs` | 44/63 | `board()` is an in-process board. Modes: `empty`, `one`, `registered`, `fail` (throws, so 502 `invalid-response`), `unavailable` (throws `ReadError('read-failed')`, so 503). `hold()` gates `index()`. The conversation catalogue and page are sync. Changed expectations (listed below). Refusal-only steps removed (listed below). |
|
||||||
|
| `packages/webui/tests/granted-user.test.mjs` | 32/2 | `setup()` takes `board` and `send` options, defaulting to the old `null` and `false`. New test `R6 G4b`: a granted session gets 403 `owner-only` on all five board paths with no board read, and the owner session gets 200 on each, with the read order checked. |
|
||||||
|
|
||||||
|
### Changed expectations (not loosened; each is a product change, for Mos)
|
||||||
|
|
||||||
|
These expectations now name the in-process contract. The message text came from the fake server before. Each one is
|
||||||
|
an old assertion with a new value.
|
||||||
|
|
||||||
|
- `shell.test.mjs` stale-data check: `/board scan failed.*Showing last known data/` is now `/invalid-response.*Showing last known data/`. A scan throw is mapped by `consoleRead` to `invalid-response` (502). Before, the fake returned 500 with the text.
|
||||||
|
- `shell.test.mjs` unavailable check: `/board unavailable.*No board data loaded/` is now `/read-failed.*No board data loaded/`. `consoleRead` sends the code as `error`, and the page shows `err.message`, which is that code. The page shows `read-failed` now, where it showed `board unavailable` before.
|
||||||
|
- `preview.test.mjs` dead board: the status stays 502, and `body.error` is now `invalid-response`.
|
||||||
|
|
||||||
|
### Deleted assertions (proxy-only, no in-process counterpart)
|
||||||
|
|
||||||
|
- `preview.test.mjs` `assert.equal(board.body.board, dead)`. The in-process board has no origin, so the body
|
||||||
|
can't name one. The status and the bus checks around it are kept.
|
||||||
|
- `shell.test.mjs` the refusal block: the fake's HTTP 403 `board-refused`. `consoleRead` has no 403 code: `STATUS`
|
||||||
|
in `reads.mjs` maps only `not-configured`, `no-bus-host`, `queue-refused`, `read-failed`, `not-found` and
|
||||||
|
`invalid-request`, and any other code is 502. So the in-process board cannot refuse, and `app.js`'s
|
||||||
|
`err.status === 403` branch ("The board refused the read", `#board-refused`, the fail-closed drop of kept
|
||||||
|
rows, the inspector and the counts) has no trigger in these tests. The block that was deleted asserted every
|
||||||
|
one of these. The conversation refusal is deleted too: a 403 from `/api/conversation` closed the open
|
||||||
|
conversation, and the new step closes it with `#conv-back`.
|
||||||
|
This is a coverage loss, and it needs a decision. Either `reads.mjs` maps a board refusal to 403 (a product
|
||||||
|
change, which Mos rules on), or the refusal branch is retired. The granted-user 403 `owner-only` answer goes
|
||||||
|
through the same `err.status === 403` branch, so the page path is still reached by granted users. It is not
|
||||||
|
covered by a shell check.
|
||||||
|
|
||||||
|
### Status change, dead board (Webby's question, webby-0229)
|
||||||
|
|
||||||
|
A board failure used to answer by the origin's state: a dead origin was 502 with the origin named, a board 500
|
||||||
|
was 500, and a 503 passed through. Now:
|
||||||
|
|
||||||
|
- no board configured (`board` null): 503 `not-configured`;
|
||||||
|
- a `ReadError` with a code in `STATUS`: that code's status;
|
||||||
|
- any other throw (a scan failure): 502 `invalid-response` on `/api/board`. The 500 applies only on the
|
||||||
|
conversation routes (`conversation read failed`).
|
||||||
|
|
||||||
|
No test under `packages/webui/tests` asserts 502 on a board path other than the preview dead-board case above.
|
||||||
|
The grep: `usage`, `limits-route`, `routes`, `chat`, `library-status`, `thread-claims`, `send`, `bus`,
|
||||||
|
`project-file`, `tester-listener`, `send-browser`, `instances`, `tester-cli` and `agents`. Those 502s belong to bus,
|
||||||
|
chat, or project-file routes, which this change does not touch.
|
||||||
|
|
||||||
|
### Granted-user gate (sage-3603): the test and its mutant
|
||||||
|
|
||||||
|
The board is a closure that the granted rebind doesn't touch, so `userRoute` is the only gate in front of it
|
||||||
|
for a granted session. Upstream's null-origin backstop is gone. Test `R6 G4b` pins that: the granted session gets
|
||||||
|
403 `owner-only` on `GET /api/board`, `GET /api/conversations`, `GET /api/conversation`, `POST /api/seen` and
|
||||||
|
`POST /api/reply`, and `reads` is still empty after those five requests. The owner session gets 200 on each, and
|
||||||
|
`reads` shows the five calls in order.
|
||||||
|
|
||||||
|
Mutant M18 (not run): in `packages/webui/src/serve.mjs`, change the granted gate
|
||||||
|
`if (!userRoute(path, req.method)) return json(res, 403, ...)` so it passes when `path` is one of the five board
|
||||||
|
paths. Expected: `R6 G4b` fails at the granted 403 assertion, because the granted request reaches the board and
|
||||||
|
`reads` is not empty. Run it with the rest of the granted-user suite once Sage gives CLEAR. The kill is not yet shown.
|
||||||
|
|
||||||
|
### Gate status
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -1,22 +1,23 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createServer } from 'node:http';
|
|
||||||
import { browser } from './browser.mjs';
|
import { browser } from './browser.mjs';
|
||||||
import { close } from './fixture.mjs';
|
import { close } from './fixture.mjs';
|
||||||
import { startServer } from '../src/serve.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 () => {
|
test('browser edge states: loading, empty, malformed, stale, hostile/long values, in-flight reply and appearance fallback', { timeout: 120000 }, async () => {
|
||||||
let index = { sessions: [], counts: {}, generatedAt: 'fixture time' }, held = null, sends = 0, holdBoard = true, boardResponse;
|
let index = { sessions: [], counts: {}, generatedAt: 'fixture time' }, held = null, sends = 0, holdBoard = true, releaseBoard;
|
||||||
let resolveBoardRequest;
|
let resolveBoardRequest;
|
||||||
const boardRequested = new Promise(resolve => { resolveBoardRequest = resolve; });
|
const boardRequested = new Promise(resolve => { resolveBoardRequest = resolve; });
|
||||||
const server = createServer(async (req, res) => {
|
// The board runs in-process (row 198): index() is the page's /api/board read, reply() its reply.
|
||||||
for await (const chunk of req) { void chunk; }
|
const board = {
|
||||||
res.setHeader('content-type', 'application/json');
|
index: () => {
|
||||||
if (req.url === '/api/board') { if (holdBoard) { boardResponse = res; resolveBoardRequest(); return; } return res.end(JSON.stringify(index)); }
|
if (!holdBoard) return index;
|
||||||
sends++; held = res;
|
resolveBoardRequest();
|
||||||
});
|
return new Promise(resolve => { releaseBoard = resolve; });
|
||||||
await new Promise(r => server.listen(0, '127.0.0.1', r));
|
},
|
||||||
const web = await startServer({ port: 0, board: `http://127.0.0.1:${server.address().port}` });
|
reply: () => { sends++; return new Promise(resolve => { held = resolve; }); },
|
||||||
|
};
|
||||||
|
const web = await startServer({ port: 0, board });
|
||||||
const b = await browser();
|
const 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')})()`);
|
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 {
|
try {
|
||||||
@@ -30,7 +31,7 @@ test('browser edge states: loading, empty, malformed, stale, hostile/long values
|
|||||||
let timer;
|
let timer;
|
||||||
try { await Promise.race([boardRequested, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('Show machine sessions made no /api/board request in 5 s')), 5000); })]); }
|
try { await Promise.race([boardRequested, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('Show machine sessions made no /api/board request in 5 s')), 5000); })]); }
|
||||||
finally { clearTimeout(timer); }
|
finally { clearTimeout(timer); }
|
||||||
holdBoard = false; boardResponse.end(JSON.stringify(index));
|
holdBoard = false; releaseBoard(index);
|
||||||
await wait('document.querySelector("#sessions").textContent.includes("No sessions")');
|
await wait('document.querySelector("#sessions").textContent.includes("No sessions")');
|
||||||
assert.equal(await b.evaluate('document.querySelector("#waiting-count").textContent'), '0');
|
assert.equal(await b.evaluate('document.querySelector("#waiting-count").textContent'), '0');
|
||||||
assert.equal(await b.evaluate('document.documentElement.dataset.palette'), 'harbor');
|
assert.equal(await b.evaluate('document.documentElement.dataset.palette'), 'harbor');
|
||||||
@@ -52,7 +53,7 @@ test('browser edge states: loading, empty, malformed, stale, hostile/long values
|
|||||||
await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled');
|
await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled');
|
||||||
assert.equal(await b.evaluate('document.querySelector(".reply-form button").disabled'), true);
|
assert.equal(await b.evaluate('document.querySelector(".reply-form button").disabled'), true);
|
||||||
await b.evaluate('document.querySelector("#reply").focus()'); await b.call('Input.insertText', { text: 'newer ' });
|
await b.evaluate('document.querySelector("#reply").focus()'); await b.call('Input.insertText', { text: 'newer ' });
|
||||||
held.end(JSON.stringify({ delivered: true, sentAt: 'fixture', session: agent }));
|
held({ status: 200, body: { delivered: true, sentAt: 'fixture', session: agent } });
|
||||||
await wait('document.querySelector(".receipt")?.textContent.startsWith("delivered")');
|
await wait('document.querySelector(".receipt")?.textContent.startsWith("delivered")');
|
||||||
assert.equal(sends, 1); assert.match(await b.evaluate('document.querySelector("#reply").value'), /newer/);
|
assert.equal(sends, 1); assert.match(await b.evaluate('document.querySelector("#reply").value'), /newer/);
|
||||||
// A stale registration removes the form, but does not destroy its draft.
|
// A stale registration removes the form, but does not destroy its draft.
|
||||||
@@ -92,5 +93,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.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.equal(await b.evaluate('document.querySelectorAll("table.sessions [data-open]").length'), 1);
|
||||||
assert.deepEqual(await b.evaluate('window.errors'), []);
|
assert.deepEqual(await b.evaluate('window.errors'), []);
|
||||||
} finally { boardResponse?.end(); held?.end(); await b.close(); await close(web); await close(server); }
|
} finally { releaseBoard?.(index); held?.({ status: 200, body: {} }); await b.close(); await close(web); }
|
||||||
});
|
});
|
||||||
@@ -51,7 +51,7 @@ const ALICE = { id: 'alice', home: 'local', subjects: [{ provider: 'a', sub: 'al
|
|||||||
// The fake OP, an in-process broker for business `demo`, createSso with the access hook, and the
|
// The fake OP, an in-process broker for business `demo`, createSso with the access hook, and the
|
||||||
// human listener. The owner's options are counting stubs; each user session's options are
|
// human listener. The owner's options are counting stubs; each user session's options are
|
||||||
// consoleBus over that user's transport. `counts` records every owner stub and user transport call.
|
// consoleBus over that user's transport. `counts` records every owner stub and user transport call.
|
||||||
async function setup(t, { users = [ALICE], grants = [grant()] } = {}) {
|
async function setup(t, { users = [ALICE], grants = [grant()], board = null, send = false } = {}) {
|
||||||
const op = await fakeOidc();
|
const op = await fakeOidc();
|
||||||
t.after(() => op.close());
|
t.after(() => op.close());
|
||||||
const iss = op.issuer('a');
|
const iss = op.issuer('a');
|
||||||
@@ -103,7 +103,7 @@ async function setup(t, { users = [ALICE], grants = [grant()] } = {}) {
|
|||||||
reads: Object.fromEntries(['settings', 'business', 'busStatus', 'queue', 'row'].map(v => [v, stub(v)])),
|
reads: Object.fromEntries(['settings', 'business', 'busStatus', 'queue', 'row'].map(v => [v, stub(v)])),
|
||||||
chat: Object.fromEntries(['projects', 'threads', 'messages', 'send', 'open', 'createProject', 'createThread'].map(v => [v, stub(v)])),
|
chat: Object.fromEntries(['projects', 'threads', 'messages', 'send', 'open', 'createProject', 'createThread'].map(v => [v, stub(v)])),
|
||||||
};
|
};
|
||||||
const web = await startServer({ port: 0, board: null, sso, log, ...owner, userOptions, federation: FEDERATION });
|
const web = await startServer({ port: 0, board, send, sso, log, ...owner, userOptions, federation: FEDERATION });
|
||||||
t.after(() => close(web));
|
t.after(() => close(web));
|
||||||
const p1 = broker.request(human, { verb: 'project.create', args: { name: 'shared' } }).id;
|
const p1 = broker.request(human, { verb: 'project.create', args: { name: 'shared' } }).id;
|
||||||
const p2 = broker.request(human, { verb: 'project.create', args: { name: 'private' } }).id;
|
const p2 = broker.request(human, { verb: 'project.create', args: { name: 'private' } }).id;
|
||||||
@@ -246,6 +246,36 @@ test('R6 G4 every owner route answers 403 owner-only before any bus call', async
|
|||||||
assert.equal(events(env), before, 'something was written');
|
assert.equal(events(env), before, 'something was written');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Row 198 (sage-3603): the board runs in-process behind userRoute alone; upstream's null-origin backstop
|
||||||
|
// is gone. A granted session is refused on all five board paths before any board read, and the owner's
|
||||||
|
// session reaches each one. The mutant that drops the userRoute check for these paths must fail here.
|
||||||
|
test('R6 G4b the five board paths: a granted session is refused before any board read, the owner reaches all five', async t => {
|
||||||
|
const reads = [];
|
||||||
|
const board = {
|
||||||
|
index: async () => { reads.push('index'); return { sessions: [], counts: {}, generatedAt: 'fixture' }; },
|
||||||
|
conversation: url => { reads.push(url.pathname); return { status: 200, body: { conversations: [] } }; },
|
||||||
|
markSeen: async () => { reads.push('markSeen'); return { sessions: [] }; },
|
||||||
|
reply: async () => { reads.push('reply'); return { status: 200, body: { delivered: true } }; },
|
||||||
|
};
|
||||||
|
const env = await setup(t, { board, send: true });
|
||||||
|
const { session: granted } = await signIn(env, 'alice-sub');
|
||||||
|
const { session: owner } = await signIn(env, 'owner-sub');
|
||||||
|
const seen = JSON.stringify({ project: 'p', agent: 'a', lastActivity: 'x', seen: true }), reply = JSON.stringify({ agent: 'p/a', text: 'hi' });
|
||||||
|
const paths = [['GET', '/api/board'], ['GET', '/api/conversations'], ['GET', '/api/conversation?conversation=c1'], ['POST', '/api/seen', seen], ['POST', '/api/reply', reply]];
|
||||||
|
const headers = (method, cookie) => (method === 'GET' ? { cookie } : writeHeaders(cookie));
|
||||||
|
for (const [method, path, body] of paths) {
|
||||||
|
const r = await call(env.port, { method, path, headers: headers(method, granted), body });
|
||||||
|
assert.equal(r.status, 403, `granted ${method} ${path}`);
|
||||||
|
assert.equal(r.body.error, 'owner-only', `granted ${method} ${path}`);
|
||||||
|
}
|
||||||
|
assert.deepEqual(reads, [], 'a granted session reached the board');
|
||||||
|
for (const [method, path, body] of paths) {
|
||||||
|
const r = await call(env.port, { method, path, headers: headers(method, owner), body });
|
||||||
|
assert.equal(r.status, 200, `owner ${method} ${path}: ${JSON.stringify(r.body)}`);
|
||||||
|
}
|
||||||
|
assert.deepEqual(reads, ['index', '/api/conversations', '/api/conversation', 'markSeen', 'reply']);
|
||||||
|
});
|
||||||
|
|
||||||
test('R6 G5 the bus refuses every owner verb over the user transport on its own', async t => {
|
test('R6 G5 the bus refuses every owner verb over the user transport on its own', async t => {
|
||||||
const env = await setup(t);
|
const env = await setup(t);
|
||||||
const o = consoleBus(env.userTransport('alice'), { send: true, owner: env.ownerCall });
|
const o = consoleBus(env.userTransport('alice'), { send: true, owner: env.ownerCall });
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
|
|
||||||
import { after as laneAfter } from 'node:test';
|
|
||||||
// Row 74 (#1568, lead decision 93): the preview mode. A request for the public name needs the
|
// Row 74 (#1568, lead decision 93): the preview mode. A request for the public name needs the
|
||||||
// proxy's header equal to the one user id, else 401 with nothing read; in preview mode nothing
|
// proxy's header equal to the one user id, else 401 with nothing read; in preview mode nothing
|
||||||
// but GET and HEAD gets through, whatever the Host; loopback requests work as they did. Round 2:
|
// but GET and HEAD gets through, whatever the Host; loopback requests work as they did. Round 2:
|
||||||
@@ -7,7 +5,7 @@ import { after as laneAfter } from 'node:test';
|
|||||||
// fails closed instead of reaching the loopback path.
|
// fails closed instead of reaching the loopback path.
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createServer, request } from 'node:http';
|
import { request } from 'node:http';
|
||||||
import { connect } from 'node:net';
|
import { connect } from 'node:net';
|
||||||
import { spawn, spawnSync } from 'node:child_process';
|
import { spawn, spawnSync } from 'node:child_process';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
@@ -46,20 +44,17 @@ function raw(port, lines) {
|
|||||||
const signedIn = { [HEADER]: ID };
|
const signedIn = { [HEADER]: ID };
|
||||||
const json = { 'content-type': 'application/json' };
|
const json = { 'content-type': 'application/json' };
|
||||||
|
|
||||||
// A board that counts what reaches it, and a bus that counts its reads.
|
// A board that counts what reaches it (in-process, row 198), and a bus that counts its reads.
|
||||||
async function setup(t, options = {}) {
|
async function setup(t, options = {}) {
|
||||||
const boardHits = [];
|
const boardHits = [];
|
||||||
const upstream = createServer((req, res) => {
|
const board = {
|
||||||
boardHits.push(`${req.method} ${req.url}`);
|
index: async () => { boardHits.push('GET /api/board'); return { sessions: [] }; },
|
||||||
res.writeHead(200, json);
|
markSeen: async () => { boardHits.push('POST /api/seen'); return { sessions: [] }; },
|
||||||
res.end(req.url === '/api/board' ? '{"sessions":[]}' : '{"ok":true}');
|
reply: async () => { boardHits.push('POST /api/reply'); return { status: 200, body: { delivered: true } }; },
|
||||||
});
|
};
|
||||||
await new Promise(r => upstream.listen(0, '127.0.0.1', r));
|
|
||||||
// Closed first and on its own, so a refused startServer doesn't leave it holding the process open.
|
|
||||||
t.after(() => close(upstream));
|
|
||||||
const busCalls = [];
|
const busCalls = [];
|
||||||
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async arg => { busCalls.push([v, arg]); return [{ verb: v }]; }]));
|
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async arg => { busCalls.push([v, arg]); return [{ verb: v }]; }]));
|
||||||
const web = await startServer({ port: 0, board: `http://127.0.0.1:${upstream.address().port}`, bus, preview, ...options });
|
const web = await startServer({ port: 0, board, bus, preview, ...options });
|
||||||
t.after(() => close(web));
|
t.after(() => close(web));
|
||||||
return { port: web.address().port, boardHits, busCalls };
|
return { port: web.address().port, boardHits, busCalls };
|
||||||
}
|
}
|
||||||
@@ -207,8 +202,9 @@ test('in preview mode every write answers 403 read-only, from the public name an
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('loopback requests are unchanged in preview mode, and the bus views work with the board down', async t => {
|
test('loopback requests are unchanged in preview mode, and the bus views work with the board down', async t => {
|
||||||
const dead = `http://127.0.0.1:${await leasePort({after:laneAfter}, {closed:true})}`;
|
// The board is down: every read throws, which the Console answers as invalid-response (502).
|
||||||
const { port, busCalls } = await setup(t, { board: dead, timeout: 200 });
|
const down = { index: async () => { throw new Error('board down'); } };
|
||||||
|
const { port, busCalls } = await setup(t, { board: down });
|
||||||
const loopback = { host: `127.0.0.1:${port}` };
|
const loopback = { host: `127.0.0.1:${port}` };
|
||||||
const inbox = await send(port, { headers: loopback });
|
const inbox = await send(port, { headers: loopback });
|
||||||
assert.equal(inbox.status, 200); assert.deepEqual(inbox.body.rows, [{ verb: 'inbox' }]);
|
assert.equal(inbox.status, 200); assert.deepEqual(inbox.body.rows, [{ verb: 'inbox' }]);
|
||||||
@@ -217,7 +213,7 @@ test('loopback requests are unchanged in preview mode, and the bus views work wi
|
|||||||
assert.equal((await send(port, { headers: { host: `127.0.0.1:${port + 1}` } })).status, 403);
|
assert.equal((await send(port, { headers: { host: `127.0.0.1:${port + 1}` } })).status, 403);
|
||||||
// The board panel's existing error; the bus views still answer.
|
// The board panel's existing error; the bus views still answer.
|
||||||
const board = await send(port, { path: '/api/board', headers: signedIn });
|
const board = await send(port, { path: '/api/board', headers: signedIn });
|
||||||
assert.equal(board.status, 502); assert.equal(board.body.board, dead);
|
assert.equal(board.status, 502); assert.equal(board.body.error, 'invalid-response');
|
||||||
assert.equal((await send(port, { path: '/api/bus/agents', headers: signedIn })).status, 200);
|
assert.equal((await send(port, { path: '/api/bus/agents', headers: signedIn })).status, 200);
|
||||||
assert.deepEqual(busCalls.map(c => c[0]), ['inbox', 'agents']);
|
assert.deepEqual(busCalls.map(c => c[0]), ['inbox', 'agents']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,13 +7,13 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { createServer } from 'node:http';
|
|
||||||
import { mkdirSync, readFileSync, readdirSync } from 'node:fs';
|
import { mkdirSync, readFileSync, readdirSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { browser } from './browser.mjs';
|
import { browser } from './browser.mjs';
|
||||||
import { close } from './fixture.mjs';
|
import { close } from './fixture.mjs';
|
||||||
import { startServer } from '../src/serve.mjs';
|
import { startServer } from '../src/serve.mjs';
|
||||||
import { BusReadError } from '../src/bus.mjs';
|
import { BusReadError } from '../src/bus.mjs';
|
||||||
|
import { ReadError } from '../src/reads.mjs';
|
||||||
|
|
||||||
const pub = new URL('../src/public/', import.meta.url), design = new URL('../../../docs/design/', import.meta.url);
|
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));
|
const read = (name, base = pub) => readFileSync(new URL(name, base));
|
||||||
@@ -68,31 +68,41 @@ test('shipped strings are unchanged, and the web keeps its write boundary', () =
|
|||||||
assert.equal(read('icons.svg').toString().includes('<script'), false);
|
assert.equal(read('icons.svg').toString().includes('<script'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The Console's board, in-process (row 198). Each mode sets what the next read answers. A thrown
|
||||||
|
// ReadError answers its own code; any other throw answers invalid-response (consoleRead's mapping).
|
||||||
|
// A scan that fails answers the same as a board that is down: the page keeps or drops data as before.
|
||||||
function board() {
|
function board() {
|
||||||
let reply = { status: 200, body: { sessions: [], counts: {}, generatedAt: new Date().toISOString() } };
|
let mode = 'empty', gate = null;
|
||||||
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 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.
|
// While the board answers, agent1 has one conversation with one message. A failure 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 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 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 answer = () => {
|
||||||
const server = createServer(async (req, res) => {
|
if (mode === 'fail') throw new Error('board scan failed');
|
||||||
for await (const chunk of req) { void chunk; }
|
if (mode === 'unavailable') throw new ReadError('read-failed', 'board unavailable');
|
||||||
await gate;
|
if (mode === 'one') return { 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() };
|
||||||
const path = new URL(req.url, 'http://localhost').pathname;
|
if (mode === 'registered') return { sessions: [{ ...one, registered: { alive: true, tmux: { session: 'fixture' } } }], counts: { waiting: 1 }, generatedAt: new Date().toISOString() };
|
||||||
const body = reply.status !== 200 ? reply.body : path === '/api/conversations' ? catalogue : path === '/api/conversation' ? page : reply.body;
|
return { sessions: [], counts: {}, generatedAt: new Date().toISOString() };
|
||||||
res.writeHead(reply.status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body));
|
};
|
||||||
});
|
const set = next => () => { mode = next; };
|
||||||
return {
|
return {
|
||||||
server,
|
board: {
|
||||||
// Holds every reply until the returned function is called.
|
// Waits while held, so the page shows its loading state.
|
||||||
|
index: async () => { await gate; return answer(); },
|
||||||
|
// The catalogue and the page are sync, as the Console calls them; a failing mode throws, which the route answers 500.
|
||||||
|
conversation: url => {
|
||||||
|
if (mode === 'fail' || mode === 'unavailable') throw new Error('conversation read failed');
|
||||||
|
return { status: 200, body: url.pathname === '/api/conversations' ? catalogue : page };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Holds every board read until the returned function is called.
|
||||||
hold: () => { let go; gate = new Promise(r => { go = r; }); return () => { gate = null; go(); }; },
|
hold: () => { let go; gate = new Promise(r => { go = r; }); return () => { gate = null; go(); }; },
|
||||||
empty: () => { reply = { status: 200, body: { sessions: [], counts: {}, generatedAt: new Date().toISOString() } }; },
|
empty: set('empty'),
|
||||||
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() } }; },
|
one: set('one'),
|
||||||
fail: () => { reply = { status: 500, body: { error: 'board scan failed' } }; },
|
fail: set('fail'),
|
||||||
refuse: () => { reply = { status: 403, body: { error: 'board-refused' } }; },
|
|
||||||
// One seat a reply can reach: registered, alive, with a tmux session.
|
// 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() } }; },
|
registered: set('registered'),
|
||||||
unavailable: () => { reply = { status: 503, body: { error: 'board unavailable' } }; },
|
unavailable: set('unavailable'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,9 +116,9 @@ function stubBus() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test('shell in a browser: System default, seeded states, 400px, focus and the Ctrl+K palette', { timeout: 180000 }, async () => {
|
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 brd = board();
|
||||||
const s = stubBus();
|
const s = stubBus();
|
||||||
const web = await startServer({ port: 0, board: `http://127.0.0.1:${brd.server.address().port}`, bus: s.bus });
|
const web = await startServer({ port: 0, board: brd.board, bus: s.bus });
|
||||||
const base = `http://127.0.0.1:${web.address().port}/`, b = await browser();
|
const base = `http://127.0.0.1:${web.address().port}/`, b = await browser();
|
||||||
const out = process.env.WEBUI_EVIDENCE;
|
const out = process.env.WEBUI_EVIDENCE;
|
||||||
if (out) mkdirSync(out, { recursive: true });
|
if (out) mkdirSync(out, { recursive: true });
|
||||||
@@ -170,39 +180,18 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct
|
|||||||
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("table.sessions .st-waiting"),"::before").content'), '"◐" / ""');
|
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("table.sessions .st-waiting"),"::before").content'), '"◐" / ""');
|
||||||
brd.fail(); await refresh();
|
brd.fail(); await refresh();
|
||||||
assert.equal(await b.evaluate('document.querySelector("#error").className'), 'banner err');
|
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.match(await text('#error'), /invalid-response.*Showing last known data\. Scanned /);
|
||||||
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 2);
|
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 2);
|
||||||
assert.match(await text('#fresh-board'), /stale: the last refresh failed/);
|
assert.match(await text('#fresh-board'), /stale: the last refresh failed/);
|
||||||
await flat(); await shot('board-stale-400');
|
await flat(); await shot('board-stale-400');
|
||||||
|
|
||||||
// Board, a refusal: fails closed. The kept rows are hidden, and so is everything drawn from
|
// A failed first read on a fresh load: the board is drawn empty and the footer says what the banner says.
|
||||||
// them: the open inspector, the project filter, the project tree's counts and the footer.
|
// With nothing focused, focus stays put. (Before row 198 this followed a refusal; the refusal is gone.)
|
||||||
await b.evaluate('document.querySelector("#projects [data-project=proj]").click()');
|
brd.unavailable(); await b.navigate(base); await wait('!document.querySelector("#error").hidden');
|
||||||
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()');
|
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.activeElement === document.body'), true, 'a failure with nothing focused leaves focus alone');
|
||||||
assert.equal(await b.evaluate('document.querySelector("#error").className'), 'banner err');
|
assert.equal(await b.evaluate('document.querySelector("#error").className'), 'banner err');
|
||||||
assert.match(await text('#error'), /board unavailable.*No board data loaded\./);
|
assert.match(await text('#error'), /read-failed.*No board data loaded\./);
|
||||||
assert.notEqual(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
|
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);
|
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);
|
for (const id of ['#waiting-count', '#seen-count', '#session-count']) assert.equal(await text(id), '–', id);
|
||||||
@@ -210,7 +199,7 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct
|
|||||||
assert.equal(await text('#fresh-board'), '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('#status'), 'Not read: the read failed');
|
||||||
assert.equal(await text('#projects'), 'Not read: the read failed.');
|
assert.equal(await text('#projects'), 'Not read: the read failed.');
|
||||||
await flat(); await shot('board-refused-then-failed-400');
|
await flat(); await shot('board-unavailable-400');
|
||||||
brd.one(); await refresh();
|
brd.one(); await refresh();
|
||||||
assert.equal(await b.evaluate('document.querySelector("#error").hidden'), true);
|
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.equal(await b.evaluate('document.querySelector("#projects [data-project=\'\']").getAttribute("aria-pressed")'), 'true');
|
||||||
@@ -219,8 +208,7 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct
|
|||||||
// A good read after a failed one drops the stale words.
|
// A good read after a failed one drops the stale words.
|
||||||
brd.fail(); await refresh(); assert.match(await text('#fresh-board'), /stale/);
|
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/);
|
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
|
// A conversation is opened from a registered session, and a failed catalogue read clears the picker.
|
||||||
// session is in no scan the page holds.
|
|
||||||
brd.registered(); await refresh(); await wait('document.querySelectorAll("table.sessions tbody tr").length===1');
|
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 b.evaluate('document.querySelector("table.sessions [data-history]").click()');
|
||||||
await wait('document.querySelector("#conv-log").textContent.includes("Fixture history line")');
|
await wait('document.querySelector("#conv-log").textContent.includes("Fixture history line")');
|
||||||
@@ -232,15 +220,8 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct
|
|||||||
assert.equal(await count('#conv-pick option'), 0, 'no stale session in the picker');
|
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()');
|
brd.registered(); await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
|
||||||
await wait('document.querySelector("#conv-log").textContent.includes("Fixture history line")');
|
await wait('document.querySelector("#conv-log").textContent.includes("Fixture history line")');
|
||||||
brd.refuse(); await refresh();
|
// Back to the board, closed as the refusal used to close it.
|
||||||
assert.equal(await b.evaluate('document.querySelector("#conversation").hidden'), true);
|
await b.evaluate('document.getElementById("conv-back").click()'); await wait('document.querySelector("#conversation").hidden');
|
||||||
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');
|
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.
|
// Keyboard on the board: down moves between rows, Enter opens, Esc closes and returns focus.
|
||||||
@@ -410,21 +391,21 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct
|
|||||||
assert.ok(scrollH > innerH, `the board page is taller than ${innerH}px`);
|
assert.ok(scrollH > innerH, `the board page is taller than ${innerH}px`);
|
||||||
assert.deepEqual([tree[0], tree[1]], [bar[1], foot[0]]);
|
assert.deepEqual([tree[0], tree[1]], [bar[1], foot[0]]);
|
||||||
assert.deepEqual(await b.evaluate('window.errors'), []);
|
assert.deepEqual(await b.evaluate('window.errors'), []);
|
||||||
} finally { await b.close(); await close(web); await close(brd.server); }
|
} finally { await b.close(); await close(web); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Every restyled view in its five states: loading (a held read), normal, empty, a failed read
|
// 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
|
// 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.
|
// with rows, then made to fail, then refused, at 400px.
|
||||||
test('five states on Board, Inbox, Tasks, Agents and Trail', { timeout: 180000 }, async () => {
|
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 brd = board();
|
||||||
const s = stubBus();
|
const s = stubBus();
|
||||||
const at = new Date().toISOString(), id = 'd'.repeat(32);
|
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.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.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.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 }];
|
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 web = await startServer({ port: 0, board: brd.board, bus: s.bus });
|
||||||
const base = `http://127.0.0.1:${web.address().port}/`, b = await browser();
|
const base = `http://127.0.0.1:${web.address().port}/`, b = await browser();
|
||||||
const out = process.env.WEBUI_EVIDENCE;
|
const out = process.env.WEBUI_EVIDENCE;
|
||||||
if (out) mkdirSync(out, { recursive: true });
|
if (out) mkdirSync(out, { recursive: true });
|
||||||
@@ -525,5 +506,5 @@ test('five states on Board, Inbox, Tasks, Agents and Trail', { timeout: 180000 }
|
|||||||
|
|
||||||
assert.deepEqual(await b.evaluate('window.errors'), []);
|
assert.deepEqual(await b.evaluate('window.errors'), []);
|
||||||
assert.deepEqual(await b.evaluate('window.requests.filter(([m])=>m!=="GET")'), []);
|
assert.deepEqual(await b.evaluate('window.requests.filter(([m])=>m!=="GET")'), []);
|
||||||
} finally { await b.close(); await close(web); await close(brd.server); }
|
} finally { await b.close(); await close(web); }
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { request, createServer } from 'node:http';
|
import { request } from 'node:http';
|
||||||
import { startServer } from '../src/serve.mjs';
|
import { startServer } from '../src/serve.mjs';
|
||||||
import { startTesterSocket, TESTER_ORIGIN } from '../src/tester-socket.mjs';
|
import { startTesterSocket, TESTER_ORIGIN } from '../src/tester-socket.mjs';
|
||||||
import { tmp } from '../../cli/tests/helpers.mjs';
|
import { tmp } from '../../cli/tests/helpers.mjs';
|
||||||
@@ -24,9 +24,9 @@ export const post = (web, path, data, headers = {}) => http(web, path, { method:
|
|||||||
|
|
||||||
test('main listener refuses x-mosaic-tester before any board or bus read for every Host', async t => {
|
test('main listener refuses x-mosaic-tester before any board or bus read for every Host', async t => {
|
||||||
let hits = 0;
|
let hits = 0;
|
||||||
const upstream = createServer((q, r) => { hits++; r.end('{}'); });
|
// The board runs in-process (row 198): a read that reaches it counts as a hit.
|
||||||
await new Promise(r => upstream.listen(0, '127.0.0.1', r)); t.after(() => close(upstream));
|
const board = { index: async () => { hits++; return { sessions: [] }; } };
|
||||||
const web = await startServer({ port: 0, board: `http://127.0.0.1:${upstream.address().port}`, preview: { host: 'console.example', header: 'x-user', user: 'jason' }, bus: { agents: async () => { hits++; return []; } } });
|
const web = await startServer({ port: 0, board, preview: { host: 'console.example', header: 'x-user', user: 'jason' }, bus: { agents: async () => { hits++; return []; } } });
|
||||||
t.after(() => close(web));
|
t.after(() => close(web));
|
||||||
for (const host of [`127.0.0.1:${web.address().port}`, 'console.example', 'other.example'])
|
for (const host of [`127.0.0.1:${web.address().port}`, 'console.example', 'other.example'])
|
||||||
for (const path of ['/api/board', '/api/bus/agents']) {
|
for (const path of ['/api/board', '/api/bus/agents']) {
|
||||||
|
|||||||
Reference in new issue
Block a user