dewey: row 83 round 1 packet, unit script takes --send (#1576)
build.patch againstd13b9300(brief blobf8d14d70, rev 630), candidate manifest, identity and mutant harnesses with results, gate summary. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
# Row 83 (#1576): Console service, the unit script takes --send, round 1
|
||||
|
||||
Owner: Dewey. Reviewers: Darkwing and Filbert, then Sage's gate. Brief:
|
||||
`docs/plans/2026-10-12_console-service-send.md` § "The unit script takes
|
||||
--send", blob `f8d14d70` (re-pinned at rev 630 for lead decision 102; first
|
||||
pinned as `739bea66` at rev 627, briefed at rev 628). Lead decision 101
|
||||
gives the reason. Base `d13b9300`. None of the candidate's paths, nor the
|
||||
template, changed between `d13b9300` and `refactor` HEAD (`a1c5c8fc`) when
|
||||
the packet was built.
|
||||
|
||||
`build.patch` is `git diff --cached --binary d13b9300 --` the four paths
|
||||
below. Apply it with `git apply --index` to a detached worktree of the base;
|
||||
`candidate-manifest.sha256` then checks 4/4 OK (checked on a fresh
|
||||
worktree).
|
||||
|
||||
## What ships
|
||||
|
||||
- `render` and `install` take `--send`, a flag with no value. It is
|
||||
accepted only with all three preview flags. Otherwise `serve_flags`
|
||||
refuses before anything is written: exit 1, empty stdout, and on stderr
|
||||
`webui-service: --send needs the preview mode (--public-host,
|
||||
--auth-header and --allow-user)`, serve's own wording (cli.mjs:37).
|
||||
`install` renders before `mkdir`, so a refusal creates no directory and
|
||||
leaves an installed unit alone (tested).
|
||||
- With `--send`, ExecStart ends `... --allow-user ID --send`. Nothing else
|
||||
in the unit changes (tested line by line against the same render without
|
||||
`--send`).
|
||||
- Without `--send`, every render is the template with its four
|
||||
placeholders filled, byte for byte (new test), and the base and
|
||||
candidate scripts give the same stdout, stderr, status and installed
|
||||
files on every argument list the base tests use (`identity.txt`).
|
||||
- `uninstall` and `status` take no `--send`: it sets SERVE_SET like the
|
||||
other serve flags, so both exit 4 (tested with a stand-in systemctl).
|
||||
- `status` prints one new line when a unit is installed: `send: on
|
||||
(--send; human messages to roles)` or `send: off (read-only)`. The brief
|
||||
allowed this "if small"; it is five lines plus a comment. With no unit
|
||||
installed there is no send line.
|
||||
- `USAGE` and the header comment name `[--send]` inside the preview set.
|
||||
README's service paragraph and TOOLS.md's "Console service" section say
|
||||
the script takes `--send` only with the preview set (exit 1, nothing
|
||||
written), that ExecStart then ends with `--send`, and that the Console
|
||||
needs the system config at start (`--send needs the bus configured`,
|
||||
exit 2, which the unit doesn't restart: `RestartPreventExitStatus=2` in
|
||||
the template). README's "Message a role" `--send` paragraph gets one
|
||||
sentence pointing at `scripts/webui-service.sh install` with `--send`.
|
||||
- The start wording (lead decision 102). The script's header comment, its
|
||||
install message, TOOLS.md's "Console service" and README's service
|
||||
paragraph now say Jason starts and restarts the Console, and so may Mos
|
||||
under decision 102, which accepts that sends from a Console Mos started
|
||||
enter the trail as Jason (`human-cli`). Any other agent starting it,
|
||||
even through systemd, still works around the human proof (decisions 62
|
||||
and 94). The script still has no start, stop or restart verb. A test
|
||||
checks the install message's wording.
|
||||
|
||||
The template (`packages/webui/systemd/mosaic-webui.service.in`) is
|
||||
unchanged. `install` prints no new line; its `serve:` line already shows
|
||||
the ExecStart, `--send` included. The install message's opening sentence
|
||||
changed wording only (decision 102).
|
||||
|
||||
## Decisions and tradeoffs
|
||||
|
||||
- **Refusal order.** The `--send` check runs before the all-three check.
|
||||
`--send` with one or two preview flags therefore gets the `--send`
|
||||
message, not "all three or none". The brief asks for a message naming
|
||||
the preview flags the way serve refuses, and serve checks the preview
|
||||
set first. Both messages name the three flags. A partial preview set
|
||||
without `--send` still gets "all three or none" (base test unchanged).
|
||||
- **Status detection is anchored on the allowed user.** My first version
|
||||
matched `^ExecStart=.* --send$`. The mutant `status-send-anywhere`
|
||||
survived it, and that showed a real gap: `--allow-user` accepts the value
|
||||
`--send` (its character set allows `-`, and serve's `USER` pattern
|
||||
accepts it), so a read-only unit's ExecStart can end
|
||||
`--allow-user --send`. The check is now
|
||||
`^ExecStart=.* --allow-user [^ ][^ ]* --send$`. render always puts
|
||||
`--send` right after the allowed user, so a sending unit ends with both
|
||||
the allowed user and `--send`. A test installs both cases, read-only
|
||||
`--allow-user --send` and sending `--allow-user --send --send`, and
|
||||
checks status for each.
|
||||
- **No config or bus check in the script** (out of scope per the brief).
|
||||
A unit installed with `--send` on a host without the system config
|
||||
installs fine and exits 2 at start; the docs say so.
|
||||
|
||||
## Files
|
||||
|
||||
- `scripts/webui-service.sh`: `SEND=0`, the `--send` case, the refusal
|
||||
and the append in `serve_flags`, the status line, USAGE and header.
|
||||
- `packages/webui/tests/service.test.mjs`: 5 new tests, 2 widened.
|
||||
- `packages/webui/README.md`: the service paragraph, one sentence in
|
||||
"Message a role".
|
||||
- `docs/TOOLS.md`: the "Console service" FLAGS comment and prose.
|
||||
|
||||
## Tests
|
||||
|
||||
`packages/webui/tests/service.test.mjs`, 15 tests (10 base, 5 new). The
|
||||
helper `filled(flags)` is the template with `@REPO@`, `@PATH@`, `@NODE@`
|
||||
and `@FLAGS@` replaced, computed in the test the way the base script does
|
||||
it.
|
||||
|
||||
- *without --send, render is the template with its placeholders filled,
|
||||
byte for byte*: five flag combinations, from none to every flag.
|
||||
- *render --send with the preview set ends ExecStart with --send and
|
||||
changes nothing else*: four argument orders (`--send` first, last, in the
|
||||
middle of the preview set, with `--port` and `--business`). Each equals
|
||||
`filled(flags + ' --send')`, and against the same render without
|
||||
`--send` exactly one line differs, the ExecStart, by ` --send`.
|
||||
- *--send without the whole preview set is refused and writes nothing*:
|
||||
`--send` alone, with `--port`, with `--business`, with each one of the
|
||||
three preview flags, and with each two of the three. Each `render` gives
|
||||
exit 1, empty stdout and serve's message. Each `install` into a fresh
|
||||
`--dir` gives the same, and the directory is never created. An install
|
||||
of `--send` with two preview flags over an installed unit leaves it byte
|
||||
for byte, with no temp file and no systemctl call.
|
||||
- *install --send into --dir with --no-reload writes the sending unit, and
|
||||
status says whether it sends*: `written:`, the `serve:` line ending
|
||||
`--send`, the file equal to `filled(...)`, mode 0644, no temp file;
|
||||
status `send: on`; reinstall `unchanged:`; install without `--send`
|
||||
writes the read-only unit and status says `send: off (read-only)`; the
|
||||
odd allowed user `--send` case above; no `send:` line without a unit.
|
||||
The only systemctl calls are status's reads. Status's `/healthz` goes to
|
||||
a loopback port that was just closed, so it reaches no real Console.
|
||||
- *uninstall and status take no --send*: exit 4 for `uninstall --send`
|
||||
(with and without `--no-reload`) and `status --send`, against a stand-in
|
||||
systemctl that records no call; the unit stays installed.
|
||||
- Widened: the agent-marker test also renders `[...PREVIEW, '--send']`;
|
||||
the usage test adds `render --send yes ...PREVIEW` (`--send` takes no
|
||||
value); the install test checks the decision 102 start wording in the
|
||||
install message.
|
||||
|
||||
Why the uninstall and status cases sit in their own test: in the usage
|
||||
loop they run with the real `/usr/bin/systemctl` on PATH. A mutant where
|
||||
`--send` doesn't set SERVE_SET would let `uninstall --send` reach a real
|
||||
`systemctl --user daemon-reload`. With the stand-in, no mutant can.
|
||||
|
||||
## Byte identity (`identity.mjs`, `identity.txt`)
|
||||
|
||||
One scratch worktree of `d13b9300` holds the base script as
|
||||
`scripts/webui-service-base.sh` and the candidate as
|
||||
`scripts/webui-service.sh`, so REPO and the template are the same for
|
||||
both. Each script runs the same 86 argument lists from a clean scratch:
|
||||
every render the base tests make (the value and port refusals included),
|
||||
the usage list, `--help`, and an install, reinstall, refused install,
|
||||
install over, status, uninstall, uninstall and status sequence, all with
|
||||
`--no-reload` and a stand-in systemctl.
|
||||
|
||||
Result: 69 identical, 17 expected differences, 0 unexpected. The 17 are
|
||||
the usage text on 12 exit-4 cases plus `--help` (the FLAGS line names
|
||||
`[--send]`), three installs whose message differs only in the decision 102
|
||||
start wording (the base message's opening lines replaced by the
|
||||
candidate's, then everything else identical, installed unit included),
|
||||
and one status with a unit installed (the added `send: off (read-only)`
|
||||
line, nothing else). A self-check: with the append made
|
||||
unconditional, the same run reports 13 unexpected differences.
|
||||
|
||||
## Mutants (`mutants.py`, `mutants.txt`)
|
||||
|
||||
15 single-site mutants of `scripts/webui-service.sh`, each run against the
|
||||
base tests (`d13b9300`'s `service.test.mjs`), the candidate tests and
|
||||
`identity.mjs`. Baselines: all three pass on the unmutated candidate.
|
||||
Rerun on the decision 102 candidate, 2026-10-12T03:53Z; same results as
|
||||
the first run.
|
||||
|
||||
| mutant | base tests | candidate tests |
|
||||
|---|---|---|
|
||||
| send-no-preview-check | survived | killed |
|
||||
| send-needs-any-preview | survived | killed |
|
||||
| send-message-reworded | survived | killed |
|
||||
| send-no-append | survived | killed |
|
||||
| send-append-always | killed | killed |
|
||||
| send-append-before-preview | survived | killed |
|
||||
| send-default-on | killed | killed |
|
||||
| send-no-serve-set | survived | killed |
|
||||
| send-takes-value | survived | killed |
|
||||
| send-flag-unknown | survived | killed |
|
||||
| status-send-always-off | survived | killed |
|
||||
| status-send-always-on | survived | killed |
|
||||
| status-no-send-line | survived | killed |
|
||||
| status-send-anywhere | survived | killed |
|
||||
| status-send-suffix-only | survived | killed |
|
||||
|
||||
The candidate tests kill 15 of 15. The two the brief names,
|
||||
`send-no-preview-check` and `send-no-append`, survive the base tests.
|
||||
`send-append-always` and `send-default-on` are killed by the base tests
|
||||
too, as a change to every render should be.
|
||||
|
||||
## Gate (my run on the candidate)
|
||||
|
||||
On the staged candidate in a worktree of `d13b9300`, run sequentially,
|
||||
2026-10-12T03:54:20Z to 03:58:42Z (`gate-summary.txt`):
|
||||
|
||||
- `packages/webui`, `node --test 'tests/*.test.mjs'`: 81 tests, 81 pass,
|
||||
rc=0.
|
||||
- `scripts/test-auth.sh`, `test-conductor.sh`, `test-config.sh`,
|
||||
`test-discord.sh`, `test-extension-package.sh`, `test-foundation.sh`,
|
||||
`test-queue.sh`, `test-release.sh`, `test-task.sh`: all rc=0.
|
||||
|
||||
## For reviewers
|
||||
|
||||
- **Allowed user starting with `-`.** The base script and serve both accept
|
||||
an allowed user like `--send` or `-x`. That isn't new here, and changing
|
||||
it would change no-`--send` renders, which the brief rules out. The only
|
||||
effect on this row was status detection, now anchored. If it should be
|
||||
refused, that's a follow-up for the script and serve together.
|
||||
- **The template's comment still says Jason starts it.**
|
||||
`mosaic-webui.service.in` lines 6-9 say Jason starts it from a personal
|
||||
shell and that an agent starting it works around the human proof. The
|
||||
template isn't in this row's files and is rendered into every unit, so
|
||||
changing it would break the byte identity the brief asks for. Bringing
|
||||
it in line with decision 102 is a follow-up (a template change plus the
|
||||
render tests that pin it).
|
||||
- Nothing here starts, enables or restarts a unit, and no test reaches the
|
||||
real systemd: every install and uninstall uses `--no-reload` or a
|
||||
stand-in systemctl.
|
||||
@@ -0,0 +1,366 @@
|
||||
diff --git a/docs/TOOLS.md b/docs/TOOLS.md
|
||||
index 631e1814..89c85715 100644
|
||||
--- a/docs/TOOLS.md
|
||||
+++ b/docs/TOOLS.md
|
||||
@@ -246,17 +246,22 @@ scripts/webui-service.sh install [--dir DIR] [--no-reload] [FLAGS]
|
||||
scripts/webui-service.sh uninstall [--dir DIR] [--no-reload]
|
||||
scripts/webui-service.sh status [--dir DIR]
|
||||
# FLAGS: [--port N] [--business ID] [--board URL]
|
||||
-# [--public-host NAME --auth-header HEADER --allow-user ID]
|
||||
+# [--public-host NAME --auth-header HEADER --allow-user ID [--send]]
|
||||
```
|
||||
|
||||
The systemd user unit `mosaic-webui.service` runs the Console,
|
||||
`node packages/webui/src/cli.mjs serve`, with serve's flags as given:
|
||||
`--port` defaults to 7332 (7330 is the operator's own Console), and the
|
||||
-preview set goes all three or none. The template is
|
||||
+preview set goes all three or none. `--send` (row 83) goes only with the
|
||||
+preview set and is refused otherwise (exit 1, nothing written); ExecStart
|
||||
+then ends with `--send`, and the Console needs the system config at start
|
||||
+(`--send needs the bus configured`, exit 2). Without `--send` the unit is
|
||||
+read-only. The template is
|
||||
`packages/webui/systemd/mosaic-webui.service.in`. `install` writes a temp
|
||||
file, renames it and runs `systemctl --user daemon-reload`; `uninstall`
|
||||
-refuses while the unit is active; `status` prints the unit state and
|
||||
-`GET /healthz` on the installed port. Each value is held to a plain
|
||||
+refuses while the unit is active; `status` prints the unit state, whether
|
||||
+the installed unit sends, and `GET /healthz` on the installed port. Each
|
||||
+value is held to a plain
|
||||
character set the unit can carry (no space, `%`, `$`, quote, backslash,
|
||||
`&` or `|`) and refused otherwise. The unit sets no `MOSAIC_*`, `CLAUDE*`,
|
||||
`CODEX*` or `PI_*` variable. Serve's exit 2 (usage, config, or a port
|
||||
@@ -264,9 +269,11 @@ already in use) is not restarted.
|
||||
|
||||
There is no start, stop, enable or restart verb. The Console reads the bus
|
||||
through the human transport, so whoever starts it holds the human's
|
||||
-capability; an agent starting it, even through systemd, works around the
|
||||
-human proof (lead decisions 62 and 94). Jason starts it from his own shell:
|
||||
-`systemctl --user enable --now mosaic-webui`.
|
||||
+capability. Jason starts and restarts it
|
||||
+(`systemctl --user enable --now mosaic-webui`), and so may Mos under lead
|
||||
+decision 102, which accepts that sends from a Console Mos started enter the
|
||||
+trail as Jason (`human-cli`). Any other agent starting it, even through
|
||||
+systemd, works around the human proof (lead decisions 62 and 94).
|
||||
Exit codes: `0` ok · `1` failed or refused · `4` usage.
|
||||
|
||||
## Discord connector (`scripts/discord.sh`)
|
||||
diff --git a/packages/webui/README.md b/packages/webui/README.md
|
||||
index 5ac12d95..4a7cf535 100644
|
||||
--- a/packages/webui/README.md
|
||||
+++ b/packages/webui/README.md
|
||||
@@ -100,8 +100,15 @@ Under `--send` the first of those can also send (next section).
|
||||
|
||||
To run the Console as a systemd user unit, `scripts/webui-service.sh`
|
||||
renders and installs one with these flags (row 75; `docs/TOOLS.md`
|
||||
-"Console service"). Jason starts it from his own shell (lead decision 94).
|
||||
-The script takes no `--send`, so a unit it installs is read-only.
|
||||
+"Console service"). Jason starts and restarts it, and so may Mos under lead
|
||||
+decision 102, which accepts that sends from a Console Mos started enter the
|
||||
+trail as Jason. Any other agent starting it works around the human proof
|
||||
+(lead decisions 62 and 94). Without `--send` the unit is read-only. The script takes `--send` (row 83)
|
||||
+only with the three preview flags; otherwise it exits 1 and writes nothing.
|
||||
+With it, the unit's ExecStart ends with `--send`, and the Console needs the
|
||||
+system config at start: without it serve refuses with `--send needs the bus
|
||||
+configured` and exits 2, which the unit doesn't restart. `status` says
|
||||
+whether the installed unit sends.
|
||||
|
||||
## Message a role (row 76, #1570)
|
||||
|
||||
@@ -113,9 +120,11 @@ node packages/webui/src/cli.mjs serve --port 7332 \
|
||||
`--send` is off by default. It goes only with the three preview flags and
|
||||
refuses at start without them. It also needs the system config: without it
|
||||
the start refuses with `--send needs the bus configured`, where a read-only
|
||||
-Console would still serve the board. With `--send` the Inbox has a "Message
|
||||
-a role" box: a role picker filled from the agents read, a text box and
|
||||
-Send. The message reaches the role as a message from the human, class INFO,
|
||||
+Console would still serve the board. A unit that sends comes from
|
||||
+`scripts/webui-service.sh install` with `--send` (above). With `--send` the
|
||||
+Inbox has a "Message a role" box: a role picker filled from the agents read,
|
||||
+a text box and Send. The message reaches the role as a message from the
|
||||
+human, class INFO,
|
||||
through `message.send` on the human transport. It goes the same way as one
|
||||
sent from the CLI, with class INFO where `mosaic talk` uses REQUEST (lead
|
||||
decision 95). Decisions are still answered in a
|
||||
diff --git a/packages/webui/tests/service.test.mjs b/packages/webui/tests/service.test.mjs
|
||||
index d50b6652..da440d6b 100644
|
||||
--- a/packages/webui/tests/service.test.mjs
|
||||
+++ b/packages/webui/tests/service.test.mjs
|
||||
@@ -11,10 +11,35 @@ import { fileURLToPath } from 'node:url';
|
||||
// scripts/webui-service.sh (row 75, #1569). Nothing here starts a unit or
|
||||
// reaches the real systemd: install and uninstall run with --no-reload or a
|
||||
// stand-in systemctl on PATH, and XDG_CONFIG_HOME points into a temp dir.
|
||||
+// Row 83 (#1576) adds --send, taken only with the preview set.
|
||||
const REPO = fileURLToPath(new URL('../../..', import.meta.url)).replace(/\/$/, '');
|
||||
const SCRIPT = join(REPO, 'scripts', 'webui-service.sh');
|
||||
const NODE_DIR = dirname(process.execPath);
|
||||
const PREVIEW = ['--public-host', 'console.example.com', '--auth-header', 'X-authentik-uid', '--allow-user', 'jason'];
|
||||
+const PREVIEW_FLAGS = '--public-host console.example.com --auth-header X-authentik-uid --allow-user jason';
|
||||
+const SEND_REFUSAL = /^webui-service: --send needs the preview mode \(--public-host, --auth-header and --allow-user\)$/m;
|
||||
+const TEMPLATE = readFileSync(join(REPO, 'packages', 'webui', 'systemd', 'mosaic-webui.service.in'), 'utf8');
|
||||
+
|
||||
+// The unit as the base script renders it (row 75): the template with its four
|
||||
+// placeholders filled in, nothing else. Row 83 adds --send and must leave
|
||||
+// every unit rendered without it byte for byte as this.
|
||||
+function filled(flags) {
|
||||
+ const base = '/usr/local/bin:/usr/bin:/bin';
|
||||
+ const path = `:${base}:`.includes(`:${NODE_DIR}:`) ? base : `${NODE_DIR}:${base}`;
|
||||
+ return TEMPLATE.replaceAll('@REPO@', REPO).replaceAll('@PATH@', path).replaceAll('@NODE@', process.execPath).replaceAll('@FLAGS@', flags);
|
||||
+}
|
||||
+
|
||||
+// A loopback port with nothing listening, so status's /healthz request
|
||||
+// reaches no real Console.
|
||||
+async function closedPort() {
|
||||
+ const server = createServer();
|
||||
+ server.listen(0, '127.0.0.1');
|
||||
+ await once(server, 'listening');
|
||||
+ const { port } = server.address();
|
||||
+ server.close();
|
||||
+ await once(server, 'close');
|
||||
+ return port;
|
||||
+}
|
||||
|
||||
function tmp(t) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'webui-service-'));
|
||||
@@ -114,7 +139,7 @@ test('the rendered unit names none of the human proof agent markers and sets no
|
||||
const source = readFileSync(join(REPO, 'packages', 'bus', 'src', 'human.mjs'), 'utf8');
|
||||
const markers = [...source.match(/const MARKERS = \[([^\]]*)\]/)[1].matchAll(/'([A-Z_]+)'/g)].map((m) => m[1]);
|
||||
assert.ok(markers.length >= 7 && markers.includes('MOSAIC_BUS_CAP') && markers.includes('CLAUDECODE'), markers.join(' '));
|
||||
- for (const args of [[], [...PREVIEW, '--business', 'mosaic']]) {
|
||||
+ for (const args of [[], [...PREVIEW, '--business', 'mosaic'], [...PREVIEW, '--send']]) {
|
||||
const r = await run(dir, ['render', ...args]);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
for (const marker of markers) assert.ok(!r.stdout.includes(marker), marker);
|
||||
@@ -212,9 +237,129 @@ test('status prints the unit state and GET /healthz on the installed port', asyn
|
||||
|
||||
test('usage: an unknown verb or flag, and start or enable, exit 4', async (t) => {
|
||||
const dir = tmp(t);
|
||||
- for (const args of [[], ['start'], ['stop'], ['enable'], ['restart'], ['render', '--bogus'], ['render', '--port'], ['render', '--dir', dir], ['render', '--no-reload'], ['uninstall', '--port', '7332'], ['status', '--business', 'mosaic'], ['status', '--no-reload']]) {
|
||||
+ for (const args of [[], ['start'], ['stop'], ['enable'], ['restart'], ['render', '--bogus'], ['render', '--port'], ['render', '--dir', dir], ['render', '--no-reload'], ['uninstall', '--port', '7332'], ['status', '--business', 'mosaic'], ['status', '--no-reload'], ['render', '--send', 'yes', ...PREVIEW]]) {
|
||||
const r = await run(dir, args);
|
||||
assert.equal(r.status, 4, args.join(' '));
|
||||
assert.match(r.stderr, /^usage: scripts\/webui-service\.sh/);
|
||||
}
|
||||
});
|
||||
+
|
||||
+test('without --send, render is the template with its placeholders filled, byte for byte', async (t) => {
|
||||
+ const dir = tmp(t);
|
||||
+ for (const [args, flags] of [
|
||||
+ [[], '--port 7332'],
|
||||
+ [['--port', '7340'], '--port 7340'],
|
||||
+ [['--business', 'mosaic'], '--port 7332 --business mosaic'],
|
||||
+ [PREVIEW, `--port 7332 ${PREVIEW_FLAGS}`],
|
||||
+ [['--port', '7340', '--business', 'mosaic.dev_1', '--board', 'http://127.0.0.1:7331', ...PREVIEW], `--port 7340 --business mosaic.dev_1 --board http://127.0.0.1:7331 ${PREVIEW_FLAGS}`],
|
||||
+ ]) {
|
||||
+ const r = await run(dir, ['render', ...args]);
|
||||
+ assert.equal(r.status, 0, r.stderr);
|
||||
+ assert.equal(r.stdout, filled(flags), args.join(' '));
|
||||
+ }
|
||||
+});
|
||||
+
|
||||
+test('render --send with the preview set ends ExecStart with --send and changes nothing else', async (t) => {
|
||||
+ const dir = tmp(t);
|
||||
+ const extra = ['--port', '7340', '--business', 'mosaic'];
|
||||
+ for (const args of [[...PREVIEW, '--send'], ['--send', ...PREVIEW], [...PREVIEW.slice(0, 2), '--send', ...PREVIEW.slice(2)], ['--send', ...extra, ...PREVIEW]]) {
|
||||
+ const plain = args.filter((a) => a !== '--send');
|
||||
+ const flags = `${plain.includes('--port') ? '--port 7340 --business mosaic' : '--port 7332'} ${PREVIEW_FLAGS}`;
|
||||
+ const r = await run(dir, ['render', ...args]);
|
||||
+ assert.equal(r.status, 0, r.stderr);
|
||||
+ assert.equal(r.stdout, filled(`${flags} --send`), args.join(' '));
|
||||
+ assert.ok(execStart(r.stdout).endsWith(' --allow-user jason --send'), execStart(r.stdout));
|
||||
+ const without = await run(dir, ['render', ...plain]);
|
||||
+ assert.equal(without.status, 0, without.stderr);
|
||||
+ const a = r.stdout.split('\n'), b = without.stdout.split('\n');
|
||||
+ assert.equal(a.length, b.length);
|
||||
+ assert.deepEqual(a.map((l, i) => (l === b[i] ? null : [l, b[i]])).filter(Boolean), [[`${execStart(without.stdout)} --send`, execStart(without.stdout)]]);
|
||||
+ }
|
||||
+});
|
||||
+
|
||||
+test('--send without the whole preview set is refused and writes nothing', async (t) => {
|
||||
+ const dir = tmp(t);
|
||||
+ const pairs = [PREVIEW.slice(0, 2), PREVIEW.slice(2, 4), PREVIEW.slice(4, 6)];
|
||||
+ const partial = [[], ['--port', '7340'], ['--business', 'mosaic'], ...pairs, [...pairs[0], ...pairs[1]], [...pairs[0], ...pairs[2]], [...pairs[1], ...pairs[2]]];
|
||||
+ for (const args of partial) {
|
||||
+ const r = await run(dir, ['render', '--send', ...args]);
|
||||
+ assert.equal(r.status, 1, args.join(' '));
|
||||
+ assert.equal(r.stdout, '', args.join(' '));
|
||||
+ assert.match(r.stderr, SEND_REFUSAL, args.join(' '));
|
||||
+ }
|
||||
+ const bin = standIn(dir);
|
||||
+ const fresh = join(dir, 'never');
|
||||
+ for (const args of partial) {
|
||||
+ const r = await run(dir, ['install', '--dir', fresh, '--no-reload', ...args, '--send'], bin);
|
||||
+ assert.equal(r.status, 1, args.join(' '));
|
||||
+ assert.match(r.stderr, SEND_REFUSAL, args.join(' '));
|
||||
+ }
|
||||
+ assert.ok(!existsSync(fresh), 'a refusal creates no directory');
|
||||
+ const unitDir = join(dir, 'units');
|
||||
+ const target = join(unitDir, 'mosaic-webui.service');
|
||||
+ assert.equal((await run(dir, ['install', '--dir', unitDir, '--no-reload', ...PREVIEW], bin)).status, 0);
|
||||
+ const before = readFileSync(target, 'utf8');
|
||||
+ const r = await run(dir, ['install', '--dir', unitDir, '--no-reload', '--send', ...PREVIEW.slice(0, 4)], bin);
|
||||
+ assert.equal(r.status, 1);
|
||||
+ assert.equal(readFileSync(target, 'utf8'), before, 'the installed unit is left alone');
|
||||
+ assert.deepEqual(readdirSync(unitDir), ['mosaic-webui.service']);
|
||||
+ assert.ok(!existsSync(join(dir, 'calls.txt')), 'no systemctl call');
|
||||
+});
|
||||
+
|
||||
+test('install --send into --dir with --no-reload writes the sending unit, and status says whether it sends', async (t) => {
|
||||
+ const dir = tmp(t);
|
||||
+ const unitDir = join(dir, 'units');
|
||||
+ const target = join(unitDir, 'mosaic-webui.service');
|
||||
+ const port = await closedPort();
|
||||
+ const flags = `--port ${port} ${PREVIEW_FLAGS}`;
|
||||
+ const r = await run(dir, ['install', '--dir', unitDir, '--no-reload', '--port', String(port), ...PREVIEW, '--send']);
|
||||
+ assert.equal(r.status, 0, r.stderr);
|
||||
+ assert.match(r.stdout, new RegExp(`^written: ${target}$`, 'm'));
|
||||
+ // Lead decision 102: Jason or Mos starts it, no other agent.
|
||||
+ assert.match(r.stdout, /^this script starts nothing: .* Jason\nstarts and restarts it, or Mos under lead decision 102 .*\ntrail as Jason\); no other agent does:$/m);
|
||||
+ assert.match(r.stdout, new RegExp(`^serve: \\S+ ${REPO.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/packages/webui/src/cli\\.mjs serve ${flags} --send$`, 'm'));
|
||||
+ assert.equal(readFileSync(target, 'utf8'), filled(`${flags} --send`));
|
||||
+ assert.equal(statSync(target).mode & 0o777, 0o644);
|
||||
+ assert.deepEqual(readdirSync(unitDir), ['mosaic-webui.service']);
|
||||
+ const bin = standIn(dir);
|
||||
+ const sending = await run(dir, ['status', '--dir', unitDir], bin);
|
||||
+ assert.equal(sending.status, 0, sending.stderr);
|
||||
+ assert.match(sending.stdout, /^send: on \(--send; human messages to roles\)$/m);
|
||||
+ assert.match(sending.stdout, /^healthz: \S+ unreachable \(ECONNREFUSED\)$/m);
|
||||
+ const again = await run(dir, ['install', '--dir', unitDir, '--no-reload', '--port', String(port), '--send', ...PREVIEW], bin);
|
||||
+ assert.match(again.stdout, new RegExp(`^unchanged: ${target}$`, 'm'));
|
||||
+ const readOnly = await run(dir, ['install', '--dir', unitDir, '--no-reload', '--port', String(port), ...PREVIEW], bin);
|
||||
+ assert.match(readOnly.stdout, new RegExp(`^written: ${target}$`, 'm'));
|
||||
+ assert.equal(readFileSync(target, 'utf8'), filled(flags));
|
||||
+ const off = await run(dir, ['status', '--dir', unitDir], bin);
|
||||
+ assert.match(off.stdout, /^send: off \(read-only\)$/m);
|
||||
+ assert.doesNotMatch(off.stdout, /^send: on/m);
|
||||
+ // The only systemctl calls are status's reads; install ran none.
|
||||
+ assert.deepEqual(readFileSync(join(dir, 'calls.txt'), 'utf8').split('\n').filter(Boolean).filter((c) => !/^--user (is-enabled|is-active|show) mosaic-webui\.service/.test(c)), []);
|
||||
+ const none = await run(dir, ['status', '--dir', join(dir, 'empty')], bin);
|
||||
+ assert.doesNotMatch(none.stdout, /^send:/m, 'no unit, no send line');
|
||||
+ // serve takes --send as an allowed user, so ExecStart can end with
|
||||
+ // `--allow-user --send` on a unit that doesn't send.
|
||||
+ const oddUser = [...PREVIEW.slice(0, 4), '--allow-user', '--send'];
|
||||
+ for (const [args, ending, state] of [[oddUser, '--allow-user --send', 'off'], [[...oddUser, '--send'], '--allow-user --send --send', 'on']]) {
|
||||
+ const r = await run(dir, ['install', '--dir', unitDir, '--no-reload', '--port', String(port), ...args]);
|
||||
+ assert.equal(r.status, 0, r.stderr);
|
||||
+ assert.ok(execStart(readFileSync(target, 'utf8')).endsWith(` ${ending}`), args.join(' '));
|
||||
+ assert.match((await run(dir, ['status', '--dir', unitDir], bin)).stdout, new RegExp(`^send: ${state} `, 'm'), args.join(' '));
|
||||
+ }
|
||||
+});
|
||||
+
|
||||
+test('uninstall and status take no --send', async (t) => {
|
||||
+ const dir = tmp(t);
|
||||
+ const bin = standIn(dir);
|
||||
+ const unitDir = join(dir, 'units');
|
||||
+ assert.equal((await run(dir, ['install', '--dir', unitDir, '--no-reload', ...PREVIEW, '--send'])).status, 0);
|
||||
+ for (const args of [['uninstall', '--dir', unitDir, '--send'], ['uninstall', '--dir', unitDir, '--no-reload', '--send'], ['status', '--dir', unitDir, '--send']]) {
|
||||
+ const r = await run(dir, args, bin);
|
||||
+ assert.equal(r.status, 4, args.join(' '));
|
||||
+ assert.match(r.stderr, /^usage: scripts\/webui-service\.sh/);
|
||||
+ assert.equal(r.stdout, '', args.join(' '));
|
||||
+ }
|
||||
+ assert.ok(existsSync(join(unitDir, 'mosaic-webui.service')), 'still installed');
|
||||
+ assert.ok(!existsSync(join(dir, 'calls.txt')), 'no systemctl call');
|
||||
+});
|
||||
diff --git a/scripts/webui-service.sh b/scripts/webui-service.sh
|
||||
index 4ff06833..944bb97a 100755
|
||||
--- a/scripts/webui-service.sh
|
||||
+++ b/scripts/webui-service.sh
|
||||
@@ -11,19 +11,24 @@
|
||||
# rename) and run `systemctl --user daemon-reload`
|
||||
# uninstall [--dir DIR] [--no-reload]
|
||||
# remove the unit; refuses while it is active
|
||||
-# status [--dir DIR] unit state, then GET /healthz on its port
|
||||
+# status [--dir DIR] unit state, whether it sends, then GET
|
||||
+# /healthz on its port
|
||||
#
|
||||
# FLAGS are serve's: --port N (default 7332; 7330 is the operator's own
|
||||
# Console), --business ID, --board URL, and the preview set --public-host
|
||||
-# NAME --auth-header HEADER --allow-user ID, all three or none. systemd
|
||||
+# NAME --auth-header HEADER --allow-user ID, all three or none, and --send
|
||||
+# (row 83), taken only with the preview set: the unit's Console then sends
|
||||
+# human messages to roles and needs the system config at start. systemd
|
||||
# expands % and $ and splits on spaces, so each value is held to a plain
|
||||
# character set and refused otherwise. None is a secret.
|
||||
#
|
||||
# There is no start, stop, enable or restart verb. The Console reads the
|
||||
# bus through the human transport, so whoever starts it holds the human's
|
||||
-# capability. An agent starting it, even detached through systemd, works
|
||||
-# around the human proof (lead decisions 62 and 94). Jason starts it from
|
||||
-# his own shell: `systemctl --user enable --now mosaic-webui`.
|
||||
+# capability. Jason starts and restarts it
|
||||
+# (`systemctl --user enable --now mosaic-webui`), and so may Mos under lead
|
||||
+# decision 102, which accepts that sends from a Console Mos started enter
|
||||
+# the trail as Jason (human-cli). Any other agent starting it, even detached
|
||||
+# through systemd, works around the human proof (lead decisions 62 and 94).
|
||||
#
|
||||
# Exit codes: 0 ok, 1 operation failed, 4 usage.
|
||||
set -euo pipefail
|
||||
@@ -31,7 +36,7 @@ REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
TEMPLATE="$REPO/packages/webui/systemd/mosaic-webui.service.in"
|
||||
UNIT_NAME="mosaic-webui.service"
|
||||
USAGE="usage: scripts/webui-service.sh render [FLAGS] | install [--dir DIR] [--no-reload] [FLAGS] | uninstall [--dir DIR] [--no-reload] | status [--dir DIR]
|
||||
- FLAGS: [--port N] [--business ID] [--board URL] [--public-host NAME --auth-header HEADER --allow-user ID]"
|
||||
+ FLAGS: [--port N] [--business ID] [--board URL] [--public-host NAME --auth-header HEADER --allow-user ID [--send]]"
|
||||
|
||||
die() { echo "webui-service: $*" >&2; exit 1; }
|
||||
usage() { echo "$USAGE" >&2; exit 4; }
|
||||
@@ -45,6 +50,7 @@ BOARD=""
|
||||
PUBLIC_HOST=""
|
||||
AUTH_HEADER=""
|
||||
ALLOW_USER=""
|
||||
+SEND=0
|
||||
SERVE_SET=0
|
||||
|
||||
parse_flags() {
|
||||
@@ -58,6 +64,7 @@ parse_flags() {
|
||||
--public-host) [ $# -ge 2 ] || usage; PUBLIC_HOST="$2"; SERVE_SET=1; shift 2 ;;
|
||||
--auth-header) [ $# -ge 2 ] || usage; AUTH_HEADER="$2"; SERVE_SET=1; shift 2 ;;
|
||||
--allow-user) [ $# -ge 2 ] || usage; ALLOW_USER="$2"; SERVE_SET=1; shift 2 ;;
|
||||
+ --send) SEND=1; SERVE_SET=1; shift ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
@@ -86,6 +93,9 @@ serve_flags() {
|
||||
[ -z "$PUBLIC_HOST" ] || preview=$((preview + 1))
|
||||
[ -z "$AUTH_HEADER" ] || preview=$((preview + 1))
|
||||
[ -z "$ALLOW_USER" ] || preview=$((preview + 1))
|
||||
+ # serve refuses --send without the preview set; refuse it here first,
|
||||
+ # so no unit is written that would only exit 2 at start.
|
||||
+ [ "$SEND" = 0 ] || [ "$preview" -eq 3 ] || die "--send needs the preview mode (--public-host, --auth-header and --allow-user)"
|
||||
if [ "$preview" -ne 0 ]; then
|
||||
[ "$preview" -eq 3 ] || die "--public-host, --auth-header and --allow-user go together: all three or none"
|
||||
case "$PUBLIC_HOST" in [A-Za-z0-9]*) ;; *) die "public host must be a hostname, no port or scheme: $PUBLIC_HOST" ;; esac
|
||||
@@ -95,6 +105,7 @@ serve_flags() {
|
||||
case "$ALLOW_USER" in *[!A-Za-z0-9._@+-]*) die "allowed user contains a character the unit cannot carry: $ALLOW_USER" ;; esac
|
||||
flags="$flags --public-host $PUBLIC_HOST --auth-header $AUTH_HEADER --allow-user $ALLOW_USER"
|
||||
fi
|
||||
+ [ "$SEND" = 0 ] || flags="$flags --send"
|
||||
echo "$flags"
|
||||
}
|
||||
|
||||
@@ -138,8 +149,9 @@ install_unit() {
|
||||
echo "daemon-reload done"
|
||||
fi
|
||||
cat <<MSG
|
||||
-this script starts nothing: the Console holds the human's capability, so the
|
||||
-human starts it from their own shell:
|
||||
+this script starts nothing: the Console holds the human's capability. Jason
|
||||
+starts and restarts it, or Mos under lead decision 102 (sends then enter the
|
||||
+trail as Jason); no other agent does:
|
||||
systemctl --user enable --now mosaic-webui start now and at login
|
||||
systemctl --user status mosaic-webui
|
||||
journalctl --user -u mosaic-webui -f the Console's log
|
||||
@@ -182,6 +194,13 @@ status_unit() {
|
||||
echo "healthz: not checked; no unit at $target"
|
||||
return 0
|
||||
fi
|
||||
+ # render puts --send right after the allowed user, which may itself be
|
||||
+ # the value --send; a unit that sends ends with both.
|
||||
+ if grep -q '^ExecStart=.* --allow-user [^ ][^ ]* --send$' "$target"; then
|
||||
+ echo "send: on (--send; human messages to roles)"
|
||||
+ else
|
||||
+ echo "send: off (read-only)"
|
||||
+ fi
|
||||
local port
|
||||
port="$(sed -n 's/^ExecStart=.* --port \([0-9][0-9]*\).*$/\1/p' "$target")"
|
||||
[ -n "$port" ] || { echo "healthz: not checked; no --port in $target"; return 0; }
|
||||
@@ -0,0 +1,4 @@
|
||||
fa02b37dd32589c6f85083bcda286af1d9b3890216807d9b6eb96bfac621be44 scripts/webui-service.sh
|
||||
4f199141046bd15dae24b2c33b0bbf0226f7ae6da820acb0b185eafe7458ebe3 packages/webui/tests/service.test.mjs
|
||||
09c4a1ddf8bdaa3948939b0667d05ec1d7a981ff3e9ae30371bdbf24459025d6 packages/webui/README.md
|
||||
39e2752b8c0d7c622f515ae8724bcd5c90914c5aa99c48126b6a0c29985b3358 docs/TOOLS.md
|
||||
@@ -0,0 +1,16 @@
|
||||
2026-10-12T03:54:20Z
|
||||
node-webui rc=0
|
||||
test-auth rc=0
|
||||
test-conductor rc=0
|
||||
test-config rc=0
|
||||
test-discord rc=0
|
||||
test-extension-package rc=0
|
||||
test-foundation rc=0
|
||||
test-queue rc=0
|
||||
test-release rc=0
|
||||
test-task rc=0
|
||||
GATE-DONE
|
||||
2026-10-12T03:58:42Z
|
||||
ℹ tests 81
|
||||
ℹ pass 81
|
||||
ℹ fail 0
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env node
|
||||
// Dewey row 83: the base script (d13b9300) and the candidate, run side by side
|
||||
// in one scratch worktree (same REPO, same template) on every argument list
|
||||
// the base service tests use, without --send. Expected: stdout, stderr and
|
||||
// exit status identical, and every installed file identical, except the usage
|
||||
// text on exit 4 (it names [--send]), status's new send: line, and the
|
||||
// install message's start wording (lead decision 102).
|
||||
// Usage: identity.mjs <worktree>, where scripts/webui-service-base.sh is the
|
||||
// base script and scripts/webui-service.sh the candidate. Nothing here reaches
|
||||
// the real systemd: install and uninstall run with --no-reload, status with a
|
||||
// stand-in systemctl.
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const WT = process.argv[2];
|
||||
const BASE = join(WT, 'scripts', 'webui-service-base.sh');
|
||||
const CAND = join(WT, 'scripts', 'webui-service.sh');
|
||||
const NODE_DIR = dirname(process.execPath);
|
||||
const PREVIEW = ['--public-host', 'console.example.com', '--auth-header', 'X-authentik-uid', '--allow-user', 'jason'];
|
||||
const scratch = mkdtempSync(join(tmpdir(), 'r83-identity-'));
|
||||
const bin = join(scratch, 'bin');
|
||||
mkdirSync(bin);
|
||||
writeFileSync(join(bin, 'systemctl'), '#!/bin/sh\ncase "$*" in *is-enabled*) echo enabled ;; *is-active*) echo inactive ;; esac\nexit 0\n');
|
||||
chmodSync(join(bin, 'systemctl'), 0o755);
|
||||
|
||||
const set = (flag, value) => {
|
||||
const preview = PREVIEW.slice();
|
||||
const i = preview.indexOf(flag);
|
||||
if (i >= 0) { preview[i + 1] = value; return preview; }
|
||||
return [flag, value];
|
||||
};
|
||||
const U = join(scratch, 'units');
|
||||
const cases = [
|
||||
[], ['--port', '7340', '--business', 'mosaic.dev_1', '--board', 'http://127.0.0.1:7331', ...PREVIEW],
|
||||
PREVIEW, ['--business', 'mosaic'], [...PREVIEW, '--business', 'mosaic'],
|
||||
PREVIEW.slice(0, 2), PREVIEW.slice(2, 6), [...PREVIEW.slice(0, 2), ...PREVIEW.slice(4, 6)],
|
||||
].map((a) => ['render', ...a]);
|
||||
for (const flag of ['--business', '--board', '--public-host', '--auth-header', '--allow-user']) {
|
||||
for (const bad of [' x', '%n', '\n', '$HOME', '"', '\\', '|', '&']) {
|
||||
cases.push(['render', ...set(flag, flag === '--board' ? `http://127.0.0.1:7331${bad}` : `a${bad}b`)]);
|
||||
}
|
||||
}
|
||||
for (const port of ['0', '65536', '7a', '', '-1', '999999', '7332', '65535']) cases.push(['render', '--port', port]);
|
||||
for (const args of [['--business', 'Mosaic'], ['--business', '-x'], ['--business', 'a'.repeat(65)], ['--board', 'https://127.0.0.1:7331'], ['--board', '127.0.0.1:7331'], set('--public-host', 'console.example.com:443'), set('--public-host', 'https://console.example.com'), set('--auth-header', 'X_uid')]) cases.push(['render', ...args]);
|
||||
for (const args of [[], ['start'], ['stop'], ['enable'], ['restart'], ['render', '--bogus'], ['render', '--port'], ['render', '--dir', scratch], ['render', '--no-reload'], ['uninstall', '--port', '7332'], ['status', '--business', 'mosaic'], ['status', '--no-reload'], ['--help']]) cases.push(args);
|
||||
// Install, then the same again (unchanged), a refusal over it, then uninstall.
|
||||
cases.push(['install', '--dir', U, '--no-reload', '--business', 'mosaic'], ['install', '--dir', U, '--no-reload', '--business', 'mosaic'], ['install', '--dir', U, '--no-reload', '--business', 'a b'], ['install', '--dir', join(scratch, 'never'), '--no-reload', '--port', '0'], ['install', '--dir', U, '--no-reload', ...PREVIEW, '--port', '7340'], ['status', '--dir', U], ['uninstall', '--dir', U, '--no-reload'], ['uninstall', '--dir', U, '--no-reload'], ['status', '--dir', U]);
|
||||
|
||||
const env = { PATH: `${bin}:${NODE_DIR}:/usr/bin:/bin`, HOME: scratch, XDG_CONFIG_HOME: join(scratch, 'config') };
|
||||
const files = (dir) => (existsSync(dir) ? readdirSync(dir).sort().map((f) => `${f}\n${readFileSync(join(dir, f), 'utf8')}`).join('\n') : '<absent>');
|
||||
// Each script runs the whole sequence from a clean scratch, so install state carries the same way.
|
||||
function sequence(script) {
|
||||
rmSync(U, { recursive: true, force: true });
|
||||
rmSync(join(scratch, 'never'), { recursive: true, force: true });
|
||||
return cases.map((args) => {
|
||||
const r = spawnSync(script, args, { env, encoding: 'utf8' });
|
||||
return { status: r.status, stdout: r.stdout, stderr: r.stderr, units: files(U), never: existsSync(join(scratch, 'never')) };
|
||||
});
|
||||
}
|
||||
const base = sequence(BASE);
|
||||
const cand = sequence(CAND);
|
||||
const usageBase = readFileSync(BASE, 'utf8').match(/^ FLAGS: .*"$/m)[0].slice(0, -1);
|
||||
const usageCand = readFileSync(CAND, 'utf8').match(/^ FLAGS: .*"$/m)[0].slice(0, -1);
|
||||
// The install message's lines before its command list.
|
||||
const message = (script) => { const s = readFileSync(script, 'utf8'); return s.slice(s.indexOf('cat <<MSG\n') + 10, s.indexOf(' systemctl --user enable --now mosaic-webui start')); };
|
||||
const msgBase = message(BASE), msgCand = message(CAND);
|
||||
let same = 0, expected = 0, unexpected = 0;
|
||||
cases.forEach((args, i) => {
|
||||
const b = base[i], c = cand[i];
|
||||
const label = JSON.stringify(args).replaceAll(scratch, '$S');
|
||||
if (JSON.stringify(b) === JSON.stringify(c)) { same++; return; }
|
||||
const usageOnly = b.status === 4 && c.status === 4 && JSON.stringify({ ...b, stderr: b.stderr.replace(usageBase, usageCand) }) === JSON.stringify(c);
|
||||
const helpOnly = args[0] === '--help' && JSON.stringify({ ...b, stdout: b.stdout.replace(usageBase, usageCand) }) === JSON.stringify(c);
|
||||
const bl = b.stdout.split('\n'), cl = c.stdout.split('\n');
|
||||
const sendLineOnly = args[0] === 'status' && b.status === c.status && b.stderr === c.stderr && b.units === c.units
|
||||
&& JSON.stringify(cl.filter((l) => !/^send: off \(read-only\)$/.test(l))) === JSON.stringify(bl) && cl.length === bl.length + 1;
|
||||
const messageOnly = args[0] === 'install' && b.status === 0 && msgBase !== msgCand && b.stdout.includes(msgBase) && JSON.stringify({ ...b, stdout: b.stdout.replace(msgBase, msgCand) }) === JSON.stringify(c);
|
||||
if (messageOnly) { expected++; console.log(`start wording only ${label}`); return; }
|
||||
if (usageOnly || helpOnly) { expected++; console.log(`usage text only ${label}`); return; }
|
||||
if (sendLineOnly) { expected++; console.log(`send: line only ${label}: + ${cl.find((l) => l.startsWith('send:'))}`); return; }
|
||||
unexpected++;
|
||||
console.log(`DIFFERS ${label}\n base ${JSON.stringify(b)}\n cand ${JSON.stringify(c)}`);
|
||||
});
|
||||
console.log(`${cases.length} cases: ${same} identical, ${expected} expected differences, ${unexpected} unexpected`);
|
||||
rmSync(scratch, { recursive: true, force: true });
|
||||
process.exitCode = unexpected ? 1 : 0;
|
||||
@@ -0,0 +1,18 @@
|
||||
usage text only []
|
||||
usage text only ["start"]
|
||||
usage text only ["stop"]
|
||||
usage text only ["enable"]
|
||||
usage text only ["restart"]
|
||||
usage text only ["render","--bogus"]
|
||||
usage text only ["render","--port"]
|
||||
usage text only ["render","--dir","$S"]
|
||||
usage text only ["render","--no-reload"]
|
||||
usage text only ["uninstall","--port","7332"]
|
||||
usage text only ["status","--business","mosaic"]
|
||||
usage text only ["status","--no-reload"]
|
||||
usage text only ["--help"]
|
||||
start wording only ["install","--dir","$S/units","--no-reload","--business","mosaic"]
|
||||
start wording only ["install","--dir","$S/units","--no-reload","--business","mosaic"]
|
||||
start wording only ["install","--dir","$S/units","--no-reload","--public-host","console.example.com","--auth-header","X-authentik-uid","--allow-user","jason","--port","7340"]
|
||||
send: line only ["status","--dir","$S/units"]: + send: off (read-only)
|
||||
86 cases: 69 identical, 17 expected differences, 0 unexpected
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
# Dewey row 83 mutants: single-site edits to scripts/webui-service.sh in a mutant worktree (never
|
||||
# the build tree). Each mutant runs against the base tests (d13b9300's service.test.mjs, copied in
|
||||
# as tests/base-service.test.mjs) and the candidate tests (tests/service.test.mjs), then
|
||||
# identity.mjs. The candidate is uncommitted there, so the script is restored from the copy read
|
||||
# before the edit, not from git.
|
||||
# Usage: mutants.py [mutant ...]. A site that doesn't match exactly once is BAD-SITE.
|
||||
import os, subprocess, sys
|
||||
|
||||
R = os.path.expanduser('~/dewey-scratch/r83')
|
||||
T = R + '/mut'
|
||||
OUT = R + '/out/mut'
|
||||
S = 'scripts/webui-service.sh'
|
||||
BASE_TESTS = ['node', '--test', 'packages/webui/tests/base-service.test.mjs']
|
||||
CAND_TESTS = ['node', '--test', 'packages/webui/tests/service.test.mjs']
|
||||
IDENTITY = ['node', os.path.join(os.path.dirname(os.path.abspath(__file__)), 'identity.mjs'), T]
|
||||
|
||||
CHECK = ' [ "$SEND" = 0 ] || [ "$preview" -eq 3 ] || die "--send needs the preview mode (--public-host, --auth-header and --allow-user)"\n'
|
||||
APPEND = ' [ "$SEND" = 0 ] || flags="$flags --send"\n'
|
||||
CASE = ' --send) SEND=1; SERVE_SET=1; shift ;;\n'
|
||||
STATUS_IF = """ if grep -q '^ExecStart=.* --allow-user [^ ][^ ]* --send$' "$target"; then\n"""
|
||||
STATUS_BLOCK = STATUS_IF + """ echo "send: on (--send; human messages to roles)"
|
||||
else
|
||||
echo "send: off (read-only)"
|
||||
fi
|
||||
"""
|
||||
MUTANTS = [
|
||||
# name, edits [(old, new)]
|
||||
('send-no-preview-check', [(CHECK, '')]),
|
||||
('send-needs-any-preview', [(CHECK, CHECK.replace('-eq 3', '-ne 0'))]),
|
||||
('send-message-reworded', [(CHECK, CHECK.replace('needs the preview mode', 'needs the preview set'))]),
|
||||
('send-no-append', [(APPEND, '')]),
|
||||
('send-append-always', [(APPEND, ' flags="$flags --send"\n')]),
|
||||
('send-append-before-preview', [(APPEND, ''), (' local preview=0\n', APPEND + ' local preview=0\n')]),
|
||||
('send-default-on', [('SEND=0\n', 'SEND=1\n')]),
|
||||
('send-no-serve-set', [(CASE, CASE.replace(' SERVE_SET=1;', ''))]),
|
||||
('send-takes-value', [(CASE, ' --send) [ $# -ge 2 ] || usage; SEND=1; SERVE_SET=1; shift 2 ;;\n')]),
|
||||
('send-flag-unknown', [(CASE, '')]),
|
||||
('status-send-always-off', [(STATUS_IF, ' if false; then\n')]),
|
||||
('status-send-always-on', [(STATUS_IF, ' if true; then\n')]),
|
||||
('status-no-send-line', [(STATUS_BLOCK, '')]),
|
||||
('status-send-anywhere', [(STATUS_IF, """ if grep -q -- '--send' "$target"; then\n""")]),
|
||||
('status-send-suffix-only', [(STATUS_IF, """ if grep -q '^ExecStart=.* --send$' "$target"; then\n""")]),
|
||||
]
|
||||
|
||||
def run(cmd):
|
||||
try:
|
||||
p = subprocess.run(cmd, cwd=T, capture_output=True, text=True, env={**os.environ, 'TMPDIR': R + '/tmp'}, timeout=600)
|
||||
except subprocess.TimeoutExpired:
|
||||
return 'TIMEOUT', 'timed out after 600 s'
|
||||
return p.returncode, p.stdout + p.stderr
|
||||
|
||||
def verdict(rc):
|
||||
return 'survived' if rc == 0 else ('TIMEOUT' if rc == 'TIMEOUT' else 'killed')
|
||||
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
os.makedirs(R + '/tmp', exist_ok=True)
|
||||
path = os.path.join(T, S)
|
||||
pristine = open(path).read()
|
||||
for label, cmd in (('base tests', BASE_TESTS), ('candidate tests', CAND_TESTS), ('identity', IDENTITY)):
|
||||
rc, out = run(cmd)
|
||||
open(f'{OUT}/baseline-{label.replace(" ", "-")}.txt', 'w').write(out)
|
||||
print(f'baseline {label}:', 'pass' if rc == 0 else f'FAIL rc={rc}', flush=True)
|
||||
want = sys.argv[1:]
|
||||
results = []
|
||||
for name, edits in MUTANTS:
|
||||
if want and name not in want:
|
||||
continue
|
||||
bad = [old for old, _ in edits if pristine.count(old) != 1]
|
||||
if bad:
|
||||
results.append(f'{name} BAD-SITE ({[pristine.count(o) for o in bad]} matches)')
|
||||
print(results[-1], flush=True)
|
||||
continue
|
||||
mutated = pristine
|
||||
for old, new in edits:
|
||||
mutated = mutated.replace(old, new)
|
||||
try:
|
||||
open(path, 'w').write(mutated)
|
||||
b_rc, b_out = run(BASE_TESTS)
|
||||
c_rc, c_out = run(CAND_TESTS)
|
||||
i_rc, i_out = run(IDENTITY)
|
||||
finally:
|
||||
open(path, 'w').write(pristine)
|
||||
open(f'{OUT}/{name}.txt', 'w').write(f'== base tests rc={b_rc}\n{b_out}\n== candidate tests rc={c_rc}\n{c_out}\n== identity rc={i_rc}\n{i_out}')
|
||||
results.append(f'{name:28} base tests {verdict(b_rc):8} candidate tests {verdict(c_rc):8} identity {"same" if i_rc == 0 else "differs"}')
|
||||
print(results[-1], flush=True)
|
||||
assert open(path).read() == pristine, 'script not restored'
|
||||
killed = sum(1 for r in results if 'candidate tests killed' in r)
|
||||
print(f'{len(results)} mutants: candidate tests kill {killed}', flush=True)
|
||||
@@ -0,0 +1,19 @@
|
||||
baseline base tests: pass
|
||||
baseline candidate tests: pass
|
||||
baseline identity: pass
|
||||
send-no-preview-check base tests survived candidate tests killed identity same
|
||||
send-needs-any-preview base tests survived candidate tests killed identity same
|
||||
send-message-reworded base tests survived candidate tests killed identity same
|
||||
send-no-append base tests survived candidate tests killed identity same
|
||||
send-append-always base tests killed candidate tests killed identity differs
|
||||
send-append-before-preview base tests survived candidate tests killed identity same
|
||||
send-default-on base tests killed candidate tests killed identity differs
|
||||
send-no-serve-set base tests survived candidate tests killed identity same
|
||||
send-takes-value base tests survived candidate tests killed identity same
|
||||
send-flag-unknown base tests survived candidate tests killed identity same
|
||||
status-send-always-off base tests survived candidate tests killed identity same
|
||||
status-send-always-on base tests survived candidate tests killed identity differs
|
||||
status-no-send-line base tests survived candidate tests killed identity same
|
||||
status-send-anywhere base tests survived candidate tests killed identity same
|
||||
status-send-suffix-only base tests survived candidate tests killed identity same
|
||||
15 mutants: candidate tests kill 15
|
||||
Reference in New Issue
Block a user