From 2d1efa0def7be56b7c05c8feb9aa5f0ff616db49 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Fri, 23 Oct 2026 14:03:11 -0500 Subject: [PATCH] row 227 (C2-14, #1728): scripts/test-case2.sh and its driver scripts/case2/e2e.mjs, end-to-end on a scratch root. Runs the steps on refactor (layout, sync over the fake Gitea, her thread and a fake-harness reply, diverged hold and accept); each step that waits on an unlanded row prints a skip naming it (216, 217, 220, 225, 283, 285, 286, 288). Unrun: the slot is Darkwing's (sage-3581) --- agents/gizmoduck/work/queue-227/BUILD.md | 93 ++++++++++ scripts/case2/e2e.mjs | 219 +++++++++++++++++++++++ scripts/test-case2.sh | 94 ++++++++++ 3 files changed, 406 insertions(+) create mode 100644 agents/gizmoduck/work/queue-227/BUILD.md create mode 100644 scripts/case2/e2e.mjs create mode 100755 scripts/test-case2.sh diff --git a/agents/gizmoduck/work/queue-227/BUILD.md b/agents/gizmoduck/work/queue-227/BUILD.md new file mode 100644 index 000000000..074308afd --- /dev/null +++ b/agents/gizmoduck/work/queue-227/BUILD.md @@ -0,0 +1,93 @@ +# Row 227, Case 2 C2-14: end-to-end acceptance on a scratch root (#1728) + +Owner Gizmoduck (sage-3581, 10-23), reviewer Dewey, who also runs the script. +Brief: `docs/plans/2026-10-20_case2-rows.md`, section "C2-14 end-to-end acceptance on a scratch +root", read at 45dca69f6. Branch `gizmoduck/row227` on 45dca69f6. + +## Files + +- `scripts/test-case2.sh` (new). The entry point: syntax check, one driver run, one counted line per + step, an order check on the step ids, and a check that the made-up token is in no driver output. +- `scripts/case2/e2e.mjs` (new), its fixture. Every step runs in one node process, because her remote + is row 199's fake Gitea over TLS with a scratch CA, and it lives only as long as the process that + started it. `checkMapping` takes `https://` remotes only (`packages/library/src/mapping.mjs:46-48`), + so a plain file remote isn't an option. + +The driver reuses the test fixtures already on refactor and adds none of its own: +`packages/seat/tests/fake-gitea.mjs` (`scratchCa`, `fakeGitea`) and `packages/library/tests/helpers.mjs` +(`BASE`, her layout's tree, `MARKER`, `git`, `mappingEntry`). + +## Two stand-ins + +1. **The human.** `human-cli.mjs` proves a human shell and refuses any caller with an agent among its + ancestors (`packages/bus/src/human.mjs`; `packages/cli/tests/access.test.mjs:3-4,285-286` pins the + refusal). Dewey and I both run under an agent, so a shell driver of human-cli would refuse every + human verb. The driver binds the human in-process with `broker.bindHuman`, as the bus tests do + (`packages/bus/tests/library-sync.test.mjs:27`). The bus checks after the bind are the real ones. +2. **The fake harness.** No launcher and no model. The driver binds the creative instance's run on her + thread (`bindLaunch` with `thread`), claims it, receives her message and answers in reply to it. + That covers the thread routing and the companion's messaging lists (`roles/companion.json`), and + leaves the launcher out. If Dewey wants the launcher in the path, `fakeHarnesses` + (`packages/cli/tests/helpers.mjs:133`) is the way in. That is a bigger fixture, and I'd rather take + it with C2-06's own fake harness (brief C2-06) than build a second one here. + +## Steps + +The ids are the driver's. "Waits on" names every row whose code the step needs and that isn't on +refactor at 45dca69f6. A waiting step prints `skip waits on row : `, counted apart from +pass and fail. When its rows land, the step gets written and its skip line goes. + +| Step | Brief text | State | +| --- | --- | --- | +| S1a | bare repo in her layout | runs: classify on the fixture mapping (canon, manuscript, playground, unmapped, second universe) | +| S1b | with a supporting folder | waits on 283 (C2-01d, supporting folders and the editor field) | +| S2a | it syncs | runs: `library.sync` over https from the fake Gitea, served is her head | +| S2b | | runs: `library.file` reads canon and playground at served, refuses an unmapped note `project-read-path` | +| S2c | | runs: her fast-forward push is served by the next sync | +| S2d | | runs: the token is under no file of the data root, the mirror included | +| S3a | asks a creative agent in its thread | runs: project, thread on instance `bean` (companion), `thread.send` | +| S3b | through a fake harness | runs: the fake harness receives it and its reply is stored in her thread, `in_reply_to` her message | +| S3c | stores a reply whose cites verify | waits on 216 (C2-05a lookups) and 217 (C2-06 guard and page store) | +| S4 | refuses a playground cite with `"as": "canon"` | waits on 217 (`playground-cited-as-canon`) | +| S5a | holds a force-push as `library-diverged` | runs: refused, served unchanged, `diverged.commit` is the forced tip, the refusal recorded | +| S5b | | runs: `accept` of a stale commit refuses `library-accept-stale`, `accept` of the tip serves it | +| S6 | fixture-grant write, one commit with the trailer, notice in her thread | waits on 283, 285, 286, 288 | +| S7 | story-path write refuses `library-write-story-path`, ref unchanged | waits on 283, 288 | +| S8 | her revert lands as one commit | waits on 288 | +| S9 | her `library.file.save` lands as one commit, her `editor` as author | waits on 283, 288 | +| S10 | the Console renders the reply and the notice | waits on 220, 225 | + +Row notes: + +- Sage's in-flight list was 216, 217, 220, 225, 226, 283, 285 and 286. Rows 284, 287, 288 and 289 also + touch these steps, and the brief's "After" names the Q226 rows (told Sage in gizmoduck-0419). I mark + a step with the rows its code needs, not every row that touches it: + - 288 (C2-02) is the write verb, the notice, the revert and `library.file.save`, so S6-S9 all name it. + - 287 (C2-21, the grant executor) isn't named on S6, because the brief asks for a fixture grant. + - 284 (C2-05c) adds supporting docs to the lookups. S3c cites canon, so it waits on 216 and 217 only. + - 289 (C2-11b) is the Console library page. S9 is the verb, which 288 owns. If Dewey reads S9 as the + page, 289 joins its list. + - 226 (C2-13, install script and precheck) gates no step. A scratch root isn't an install. +- PRD 1.17 (row 308) made story files DOCX. The brief predates it and the fixture tree is Markdown. + S1-S5 read that tree, which is what the landed reader takes. S6-S9 take the file shape 288 lands with. + +## Choices + +- One driver process, not one per step: the fake Gitea's port and CA would change with each process, + and the library entry's remote is fixed at bus start. +- The bus timer stays on with a one-hour period, since `startBroker` needs `librarySync` to carry the + CA's `command`. Its pass at start can hold the mirror, so a step's sync retries + `library-sync-busy` for up to about 10 s. +- The refusal check opens the bus store read-only (`node:sqlite`, `readOnly: true`). The runtime + hands back no store, and no bus verb lists the events. +- Git in the driver runs with `GIT_CONFIG_GLOBAL=/dev/null` and `GIT_CONFIG_NOSYSTEM=1`, so a caller's + hooks, signing or credential helper can't reach it. `helpers.mjs` `git` already passes the + identity and `core.hooksPath=/dev/null`. + +## Not yet run + +Darkwing's re-pin window holds the slot (sage-3581), so as of this draft no node ran on it: no +`node --check`, no driver run, no suite. `bash -n scripts/test-case2.sh` passes. The first run waits for +Sage's CLEAR, and the runs go below. + +## Runs diff --git a/scripts/case2/e2e.mjs b/scripts/case2/e2e.mjs new file mode 100644 index 000000000..07fe76c7a --- /dev/null +++ b/scripts/case2/e2e.mjs @@ -0,0 +1,219 @@ +// C2-14 (#1728, row 227): the Case 2 end-to-end steps on a scratch root, driven by +// scripts/test-case2.sh. Usage: node scripts/case2/e2e.mjs +// +// One process holds her remote (row 199's fake Gitea on 127.0.0.1 over TLS with a scratch CA), a bus +// on a scratch data root and the steps, because the remote's port and CA live only as long as it does. +// It prints one line per step: +// ok +// not ok : +// skip waits on row : +// and exits 0 when it got to the end, 1 when a step threw outside its own check. +// +// Two stand-ins, both honest about what they are: +// - The human. human-cli.mjs proves its caller is a human shell and refuses any process with an agent +// among its ancestors (packages/bus/src/human.mjs), so a seat or a reviewer running this script can't +// use it. The driver binds the human in-process with bindHuman, as the bus tests do. +// - The fake harness. No launcher and no model: the driver binds the creative instance's run on her +// thread, claims it, receives her message and answers it. +// +// Nothing here reads a real key or reaches a real host. The token is made up and lives only in the slot. +import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { DatabaseSync } from 'node:sqlite'; +import { scratchCa, fakeGitea } from '../../packages/seat/tests/fake-gitea.mjs'; +import { BASE, MARKER, git, mappingEntry } from '../../packages/library/tests/helpers.mjs'; +import { startBroker } from '../../packages/bus/src/runtime.mjs'; +import * as library from '../../packages/library/src/index.mjs'; + +const TOKEN = 'fixture-token-c2-14-made-up-0227'; +const USER = 'books-bot'; +const BUSINESS = 'acme'; +const companion = JSON.parse(readFileSync(fileURLToPath(new URL('../../roles/companion.json', import.meta.url)), 'utf8')); + +const lines = []; +const out = (line) => { lines.push(line); process.stdout.write(line + '\n'); }; +// step(id, text, fn): fn returns nothing on a pass and throws on a fail. +async function step(id, text, fn) { + try { await fn(); out(`ok ${id} ${text}`); } + catch (e) { out(`not ok ${id} ${text}: ${String(e?.code ?? '')} ${String(e?.message ?? e).split('\n')[0].slice(0, 300)}`.trimEnd()); } +} +const skip = (id, rows, text) => out(`skip ${id} waits on row ${rows.join(',')}: ${text}`); +const expect = (cond, message) => { if (!cond) throw new Error(message); }; +const refuses = async (promise, code) => { + try { await promise; } catch (e) { if (e?.code === code) return; throw new Error(`refused ${e?.code ?? e?.message}, expected ${code}`); } + throw new Error(`answered, expected ${code}`); +}; + +// Every file under `dir` that holds the token, as bytes. +function holdsToken(dir) { + for (const name of readdirSync(dir, { recursive: true })) { + const file = join(dir, name); + let st; + try { st = statSync(file); } catch { continue; } + if (st.isFile() && readFileSync(file).includes(TOKEN)) return file; + } + return null; +} + +async function main(root) { + // Her side: a work repo in her layout's shape, cloned bare into the fake Gitea. + const work = join(root, 'her', 'books'); + mkdirSync(work, { recursive: true }); + git(work, 'init', '-q', '-b', 'main'); + for (const [path, text] of Object.entries(BASE)) { + mkdirSync(dirname(join(work, path)), { recursive: true }); + writeFileSync(join(work, path), text); + } + git(work, 'add', '-A'); + git(work, 'commit', '-qm', 'her first commit'); + const first = git(work, 'rev-parse', 'HEAD'); + const tls = scratchCa(join(root, 'ca')); + const gitea = await fakeGitea({ dir: join(root, 'gitea'), tls, user: USER, token: TOKEN }); + const remote = gitea.repo('writer', 'books'); + mkdirSync(dirname(remote), { recursive: true }); + git(root, 'clone', '-q', '--bare', '--no-local', work, remote); + // Her next commit, pushed to the bare repo; `from` rewinds first, which makes a force-push. + const push = (files, { from = null } = {}) => { + if (from) git(work, 'reset', '-q', '--hard', from); + for (const [path, text] of Object.entries(files)) { + mkdirSync(dirname(join(work, path)), { recursive: true }); + writeFileSync(join(work, path), text); + } + git(work, 'add', '-A'); + git(work, 'commit', '-qm', 'her next commit'); + git(work, 'push', '-q', '--force', remote, 'HEAD:refs/heads/main'); + return git(work, 'rev-parse', 'HEAD'); + }; + const remoteHead = () => git(remote, 'rev-parse', 'refs/heads/main'); + + // Our side: a scratch data root and config dir with the slot, and a bus with one creative instance. + const dataRoot = join(root, 'data'), configDir = join(root, 'config'); + mkdirSync(dataRoot, { mode: 0o700 }); + mkdirSync(join(configDir, 'secrets', BUSINESS), { recursive: true, mode: 0o700 }); + writeFileSync(join(configDir, 'secrets', BUSINESS, library.SLOT), TOKEN + '\n', { mode: 0o600 }); + const entry = { ...mappingEntry(), remote: `${gitea.origin}/writer/books.git`, user: USER }; + const business = { + id: BUSINESS, human: 'jason', arbiters: { technical: 'bean', delivery: 'bean' }, + roles: { bean: { definition: 'companion', authority: companion.authority } }, + definitions: { companion: { messaging: companion.messaging } }, + projects: { books: entry }, + }; + // syncCommand's argv with the scratch CA added, as packages/library/tests/sync.test.mjs does. The + // timer's period is long, so only its pass at start and the steps' own syncs fetch. + const command = (kind, url, opts) => { + const c = library.syncCommand(kind, url, opts); + return { argv: c.argv, env: { ...c.env, GIT_SSL_CAINFO: tls.ca } }; + }; + const bus = await startBroker({ dataRoot, businesses: { [BUSINESS]: business }, librarySync: { command, intervalMs: 3_600_000, configDir } }); + try { + const broker = bus.broker; + const human = broker.bindHuman({ business: BUSINESS, human: 'jason', via: 'cli', outsideAgent: true }); + const call = (cap, verb, args = {}) => Promise.resolve().then(() => broker.request(cap, { verb, args })); + // The timer's pass at start may still hold the mirror; a step's sync waits it out. + const sync = async (args = {}) => { + for (let i = 0; ; i++) { + try { return await call(human, 'library.sync', { project: 'books', ...args }); } + catch (e) { if (e?.code !== 'library-sync-busy' || i > 100) throw e; await new Promise(r => setTimeout(r, 100)); } + } + }; + const refusals = () => readRefusals(dataRoot); + + // S1: her layout and the mapping that reads it. + const mapping = library.checkMapping(entry); + await step('S1a', 'her layout classifies: a character is canon, a Playground note is playground, a top-level note is unmapped', () => { + const of = p => library.classify(mapping, p); + expect(of('Book One/Characters/Mara Vell.md').scope === 'canon' && of('Book One/Characters/Mara Vell.md').type === 'character', 'character'); + expect(of('Book One/Chapters/Chapter 01.md').type === 'manuscript', 'chapter'); + expect(of('Book One/Playground/What If.md').scope === 'playground', 'playground'); + expect(of('Inbox Note.md').scope === 'unmapped', 'inbox note'); + expect(of('Second World/Chapters/01.md').universe === 'second', 'second universe'); + }); + skip('S1b', [283], 'a supporting folder in the mapping and the editor field'); + + // S2: sync from her bare repo over https, and read at the served commit. + await step('S2a', 'library.sync fetches her bare repo and serves its head', async () => { + const r = await sync(); + expect(r.served === remoteHead() && r.served === first, `served ${r.served}, remote ${remoteHead()}`); + expect(r.lastError === null && r.diverged === null, 'state has an error or a divergence'); + }); + await step('S2b', 'library.file reads her canon and playground notes at the served commit, and refuses an unmapped one', async () => { + const canon = await call(human, 'library.file', { project: 'books', path: 'Book One/Characters/Mara Vell.md' }); + expect(canon.commit === first && canon.content.includes(MARKER), 'canon note'); + const play = await call(human, 'library.file', { project: 'books', path: 'Book One/Playground/What If.md' }); + expect(play.content.includes(MARKER), 'playground note'); + await refuses(call(human, 'library.file', { project: 'books', path: 'Inbox Note.md' }), 'project-read-path'); + }); + await step('S2c', 'her next commit is a fast-forward and the next sync serves it', async () => { + const next = push({ 'Book One/Places/Harbor Town.md': `---\ntype: place\n---\nSalt and tar. ${MARKER}\n` }); + const r = await sync(); + expect(r.served === next, `served ${r.served}, expected ${next}`); + }); + await step('S2d', 'the token is in the slot only: nowhere under the data root, the mirror included', () => { + const file = holdsToken(dataRoot); + expect(file === null, `token found under the data root (${file === null ? '' : file.slice(dataRoot.length)})`); + }); + + // S3: she asks the creative instance in its thread; the fake harness answers. + let thread, asked; + await step('S3a', 'she makes a thread with the creative instance and asks in it', async () => { + const project = (await call(human, 'project.create', { name: 'books' })).id; + const made = await call(human, 'thread.create', { project, name: 'Book One', instance: 'bean' }); + expect(made.instance === 'bean', `instance ${made.instance}`); + thread = made.id; + asked = (await call(human, 'thread.send', { thread, body: 'What colour are Mara Vell\'s eyes?' })).id; + }); + await step('S3b', 'the fake harness receives her message on its thread and its reply is stored in her thread', async () => { + const run = broker.bindLaunch({ business: BUSINESS, role: 'bean', run: 'bean-c2-14', harness: 'pi', thread }); + await call(run, 'role.claim'); + const inbox = await call(run, 'message.receive'); + expect(inbox.length === 1 && inbox[0].id === asked, `received ${inbox.length}`); + await call(run, 'message.send', { to: 'human', body: 'Grey. (Book One/Characters/Mara Vell.md)', in_reply_to: asked }); + const history = await call(human, 'thread.messages', { thread }); + expect(history.length === 2 && history[1].from_role === 'bean' && history[1].in_reply_to === asked, + `history ${history.map(m => m.from_role).join(',')}`); + }); + skip('S3c', [216, 217], 'the reply carries cites from the lookups, the guard verifies each one at the served commit, and the store keeps them'); + skip('S4', [217], 'a reply citing a playground note with "as": "canon" refuses playground-cited-as-canon'); + + // S5: a force-push holds as library-diverged until she accepts the new tip. + await step('S5a', 'a force-push of her branch holds as library-diverged and served stays put', async () => { + const before = (await call(human, 'library.status', { project: 'books' })).served; + const forced = push({ 'Book One/Places/Harbor Town.md': `---\ntype: place\n---\nRewritten. ${MARKER}\n` }, { from: first }); + await refuses(sync(), 'library-diverged'); + const status = await call(human, 'library.status', { project: 'books' }); + expect(status.served === before, 'served moved'); + expect(status.diverged?.commit === forced, `diverged ${JSON.stringify(status.diverged)}`); + expect(refusals().includes('library-diverged'), 'no recorded refusal'); + }); + await step('S5b', 'accept refuses a stale commit, then serves the new tip and clears the hold', async () => { + await refuses(sync({ accept: first }), 'library-accept-stale'); + const tip = remoteHead(); + const r = await sync({ accept: tip }); + expect(r.served === tip && r.diverged === null, `served ${r.served}`); + }); + + // S6-S9: agent writes, the story-path refusal, her revert and her Console save. + skip('S6', [283, 285, 286, 288], 'a seat with a fixture grant writes a supporting doc: one commit with the trailer in the bare repo, and the notice in her thread'); + skip('S7', [283, 288], 'a write to a story path refuses library-write-story-path, and the bare repo\'s ref is unchanged'); + skip('S8', [288], 'her revert of the write lands as one commit'); + skip('S9', [283, 288], 'her library.file.save lands as one commit with her editor as author'); + // S10: the Console. + skip('S10', [220, 225], 'the Console renders the reply and the notice'); + } finally { + await bus.close(); + await gitea.close(); + } +} + +// The codes of the bus's recorded refusals, oldest first, read from its store on a read-only handle. +function readRefusals(dataRoot) { + const db = new DatabaseSync(join(dataRoot, 'bus', 'bus.sqlite'), { readOnly: true }); + try { return db.prepare("SELECT body FROM events WHERE kind='action.refused' ORDER BY seq").all().map(row => JSON.parse(row.body).code); } + finally { db.close(); } +} + +const root = process.argv[2]; +if (!root) { process.stderr.write('usage: node scripts/case2/e2e.mjs \n'); process.exit(2); } +try { await main(resolve(root)); } +catch (e) { process.stderr.write(`e2e: stopped: ${e?.code ?? ''} ${String(e?.message ?? e).split('\n')[0]}\n`); process.exitCode = 1; } diff --git a/scripts/test-case2.sh b/scripts/test-case2.sh new file mode 100755 index 000000000..8588ef205 --- /dev/null +++ b/scripts/test-case2.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Case 2 end-to-end acceptance on a scratch root (C2-14, #1728, row 227). +# +# Her layout in a bare repo behind a fake Gitea on 127.0.0.1 (TLS, scratch CA), +# a bus on a scratch data root with one creative instance, and the steps of the +# C2-14 brief in order. scripts/case2/e2e.mjs runs the steps in one process, +# since the remote lives only as long as that process does, and prints one line +# per step. This script checks its syntax, runs it, and counts the lines. +# +# No live host, no real model, no network past 127.0.0.1, no real key. The +# human is an in-process stand-in: human-cli.mjs refuses a caller with an agent +# among its ancestors, so a seat can't drive it. The creative instance's run is +# a fake harness bound by the driver, with no launcher. +# +# A step that waits on a row not yet on refactor prints a skip line naming the +# row, and counts as neither pass nor fail. When the row lands, its step gets +# written in the driver and its skip line goes. +# +# scripts/test-case2.sh full run +# NO_COLOR=1 scripts/test-case2.sh plain output +set -uo pipefail +cd "$(dirname "$0")/.." + +SANDBOX="$(mktemp -d)" +trap 'rm -rf "$SANDBOX"' EXIT + +PASS=0 +FAIL=0 +SKIP=0 +if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then + C_OK=$'\033[0;32m'; C_FAIL=$'\033[0;31m'; C_RESET=$'\033[0m' +else + C_OK=""; C_FAIL=""; C_RESET="" +fi + +check() { + if [ "$2" = "0" ]; then PASS=$((PASS+1)); echo "${C_OK}OK${C_RESET} $1"; else FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} $1"; fi +} + +DRIVER="scripts/case2/e2e.mjs" +# The steps the driver must print, in order. A missing or extra line fails. +STEPS="S1a S1b S2a S2b S2c S2d S3a S3b S3c S4 S5a S5b S6 S7 S8 S9 S10" + +echo "toolchain: node $(node --version), git $(git --version | cut -d' ' -f3), openssl $(openssl version | cut -d' ' -f2)" +echo + +# --- syntax --- +node --check "$DRIVER" >/dev/null 2>&1 +check "syntax: $DRIVER" $? + +# --- the steps --- +# The driver's own state stays in the sandbox. Git reads no global or system +# config, so a caller's hooks, signing or credential helpers can't reach it. +OUT="$SANDBOX/steps.txt" +ERR="$SANDBOX/stderr.txt" +GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 \ + node "$DRIVER" "$SANDBOX/root" >"$OUT" 2>"$ERR" +RC=$? +check "driver ran to the end (exit $RC)" "$RC" +if [ "$RC" -ne 0 ]; then + # The driver prints codes and first lines only; never git's stderr with a token in it. + grep '^e2e: ' "$ERR" | head -5 +fi + +seen="" +while IFS= read -r line; do + case "$line" in + "ok "*) + id="${line#ok }"; id="${id%% *}" + check "${line#ok }" 0 ;; + "not ok "*) + id="${line#not ok }"; id="${id%% *}" + check "${line#not ok }" 1 ;; + "skip "*) + id="${line#skip }"; id="${id%% *}" + SKIP=$((SKIP+1)); echo "skip ${line#skip }" ;; + *) + id="" + check "driver line not understood: ${line:0:200}" 1 ;; + esac + [ -n "$id" ] && seen="$seen $id" +done <"$OUT" +[ "${seen# }" = "$STEPS" ] +check "the driver printed each step once, in order" $? + +# --- the token --- +# The made-up token is in the slot and nowhere else the bus writes. The driver +# checks the data root; this checks its output too. +! grep -rqF 'fixture-token-c2-14-made-up-0227' "$OUT" "$ERR" +check "the fixture token is in no driver output" $? + +echo +echo "selftest: $PASS passed, $FAIL failed, $SKIP skipped (each skip names the row it waits on)" +[ "$FAIL" -eq 0 ]