mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
fix(bootstrap): guard mutating subcommands against --help side effects (#4065)
`gbrain bootstrap <subcommand> --help` (a help token AFTER the subcommand name, e.g. `gbrain bootstrap uninstall --help`) fell through into the subcommand's own arg parsing instead of printing help, since none of the mutating handlers (repo/hooks/verify/attach/uninstall/render/interview) checked for --help/-h/help themselves. `uninstall --help` ran a real uninstall; `repo --help` created a real private GitHub repo; etc. Add a SUBCOMMAND_HELP usage map plus a pre-dispatch hasHelpToken() guard in runBootstrap so a help token anywhere in the subcommand's args short-circuits before any lock/runner/engine/handler call. Bare `help` (no dashes) is also recognized, except for `interview` (its --set KEY value free-text answers could legitimately be the literal word "help"). New test/bootstrap-subcommand-help.serial.test.ts arms fixtures so the real operation would reach its side effect if the guard were removed (an already-rendered workspace for render/hooks/attach, an operational verify config, an isolated uninstall home with a real receipt-tracked file, a fresh interview workspace) and asserts nothing mutates.
This commit is contained in:
@@ -116,6 +116,60 @@ Env: GBRAIN_BOOTSTRAP_ABORT_AFTER=<phase> (test seam — abort after that phase'
|
||||
const SUPPORT_HINT =
|
||||
'If you are stuck: run `gbrain bootstrap status --json` and relay the "support" block verbatim.';
|
||||
|
||||
/**
|
||||
* Per-subcommand `--help`/`-h`/`help` usage text for the subcommands that
|
||||
* MUTATE state (create a repo, register MCP/hooks, run the verify contract,
|
||||
* adopt a workspace, remove receipt-tracked paths, record an interview
|
||||
* answer). `runBootstrap`'s dispatch checks `args[0]` for top-level help
|
||||
* (`--help`/`-h`/`help`/no args), but a help token AFTER the subcommand name
|
||||
* (e.g. `gbrain bootstrap repo --help`, `gbrain bootstrap uninstall help`)
|
||||
* previously fell straight into the subcommand's own arg parsing, which had
|
||||
* no help handling of its own — so it ran the real mutation instead of
|
||||
* printing help. `status`/`cloud-setup-script` are pure reads, so they don't
|
||||
* need a guard.
|
||||
*/
|
||||
const SUBCOMMAND_HELP: Record<string, string> = {
|
||||
render:
|
||||
'gbrain bootstrap render [--force] [--only F] [--minimal]\n' +
|
||||
' Render identity files from the confirmed interview answers. Never clobbers; --force backs up first.',
|
||||
repo:
|
||||
'gbrain bootstrap repo\n' +
|
||||
' Create the dedicated PRIVATE GitHub repo (or adopt an EMPTY private repo you created\n' +
|
||||
' under your own account), verify the privacy bit via the API, push.',
|
||||
hooks:
|
||||
'gbrain bootstrap hooks [--harness claude-code|codex] [--repair] [--no-hooks] [--gbrain-bin <path>]\n' +
|
||||
' Register MCP (+ per-turn hooks on Claude Code, ON by default; --no-hooks opts out).',
|
||||
verify:
|
||||
'gbrain bootstrap verify [--json]\n' +
|
||||
' The whole install contract (round-trip, graph floor, magic moment, scans, hooks smoke). Exit 0 or not done.',
|
||||
attach:
|
||||
'gbrain bootstrap attach [--harness H]\n' +
|
||||
' Machine two: adopt a cloned agent workspace.',
|
||||
uninstall:
|
||||
'gbrain bootstrap uninstall [--delete-brain] [--home <dir>] [--yes]\n' +
|
||||
' Receipt-keyed removal. The repo stays yours.',
|
||||
interview:
|
||||
'gbrain bootstrap interview --init | --set KEY "value" | --skip KEY | --status | --show | --confirm <hash>\n' +
|
||||
' Create/record/read interview state. See `gbrain bootstrap --help` for the per-flag description.',
|
||||
};
|
||||
|
||||
/**
|
||||
* `--help`/`-h` are always recognized. The bare word `help` (no dashes) is
|
||||
* ALSO recognized for every subcommand above EXCEPT `interview` — mirroring
|
||||
* the top-level `sub === 'help'` handling for a user who tries the same
|
||||
* spelling after a subcommand name. `interview` is excluded from the
|
||||
* bare-word form because its `--set KEY "value"` free-text answers could
|
||||
* legitimately BE the literal word "help" (e.g. a one-word answer); none of
|
||||
* the other subcommands' flags take arbitrary prose, only booleans, enums,
|
||||
* or paths, so the bare-word collision risk there is negligible (matches the
|
||||
* already-accepted low-impact risk of `-h` colliding with a literal path
|
||||
* value like `--home -h`).
|
||||
*/
|
||||
function hasHelpToken(args: string[], allowBareWord: boolean): boolean {
|
||||
if (args.includes('--help') || args.includes('-h')) return true;
|
||||
return allowBareWord && args.includes('help');
|
||||
}
|
||||
|
||||
/** Thrown by the A7 abort seam; mapped to exit 130 (simulated kill). */
|
||||
export class BootstrapAbortInjected extends Error {
|
||||
constructor(phase: string) {
|
||||
@@ -1060,6 +1114,16 @@ export async function runBootstrap(args: string[], opts: RunBootstrapOpts = {}):
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Subcommand-level help: BEFORE any subcommand body runs, so a help token
|
||||
// after a mutating subcommand (repo/hooks/verify/attach/uninstall/render/
|
||||
// interview) never falls through into the real operation, regardless of
|
||||
// what other flags/values precede it in `rest`. No install-log entry
|
||||
// either — this isn't a phase run.
|
||||
if (SUBCOMMAND_HELP[sub] && hasHelpToken(rest, sub !== 'interview')) {
|
||||
console.log(SUBCOMMAND_HELP[sub]);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// The install log records the PHASE name, and the hooks subcommand is the
|
||||
// 'wire' phase (status.ts phase list) — one mapping, used at every log site.
|
||||
const logPhaseName = sub === 'hooks' ? 'wire' : sub;
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* bootstrap subcommand `--help`/`-h`/`help` guard (src/commands/bootstrap.ts).
|
||||
*
|
||||
* Regression test for a safety defect: `gbrain bootstrap <subcommand> --help`
|
||||
* (a help token AFTER the subcommand name, e.g. `gbrain bootstrap repo
|
||||
* --help`) fell straight through into the subcommand's own arg parsing, and
|
||||
* none of the mutating handlers (repo/hooks/verify/attach/uninstall/render/
|
||||
* interview) checked for a help token themselves. So `--help` did not print
|
||||
* help — it ran the real operation (private repo creation, MCP/hook
|
||||
* registration, the verify contract, workspace adoption, receipt-keyed file
|
||||
* removal, or recording an interview answer).
|
||||
*
|
||||
* Each fixture below is deliberately built so the real (non-`--help`)
|
||||
* operation WOULD reach its dangerous side effect if the guard were removed
|
||||
* — an operational verify config, an already-initialized attach workspace,
|
||||
* an isolated uninstall home with a real receipt + a bootstrap-created file
|
||||
* to survive, a fresh interview workspace to prove `--init` never runs. This
|
||||
* proves the guard intercepts BEFORE any write/exec call, not merely that
|
||||
* the operation would have failed anyway on an unprepared fixture.
|
||||
*
|
||||
* Serial: mutates GBRAIN_HOME.
|
||||
*/
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { runBootstrap } from '../src/commands/bootstrap.ts';
|
||||
import type { ExecRunner } from '../src/core/bootstrap/repo.ts';
|
||||
import { interviewStatePath } from '../src/core/bootstrap/interview.ts';
|
||||
import { receiptPath, writeReceipt, type InstallReceipt } from '../src/core/bootstrap/format.ts';
|
||||
import { readInstallLog } from '../src/core/bootstrap/status.ts';
|
||||
import { initState, setAnswer, skipAnswer, confirm, readBackHash } from '../src/core/bootstrap/interview.ts';
|
||||
|
||||
let tmpParent: string; // GBRAIN_HOME parent (configDir appends .gbrain)
|
||||
let home: string;
|
||||
let ws: string;
|
||||
let prevHome: string | undefined;
|
||||
|
||||
const REQUIRED_ANSWERS: Record<string, string> = {
|
||||
AGENT_NAME: 'HelpGuard',
|
||||
PRINCIPAL_NAME: 'Pat Example',
|
||||
AGENT_PURPOSE: 'Maintain the research corpus and draft the weekly memo without re-briefing.',
|
||||
AGENT_TOP_JOBS: '- corpus upkeep\n- weekly memo\n- meeting prep',
|
||||
PRINCIPAL_CONTEXT: 'Runs a small research group; values signal over noise.',
|
||||
VOICE_REGISTER: 'Direct: three options, the second one wins.',
|
||||
};
|
||||
|
||||
/** Recording fake runner — never spawns anything; asserts zero calls when
|
||||
* the --help guard is doing its job. */
|
||||
function makeRunner(): { runner: ExecRunner; calls: string[][] } {
|
||||
const calls: string[][] = [];
|
||||
const runner: ExecRunner = async (argv: string[]) => {
|
||||
calls.push(argv);
|
||||
return { code: 0, stdout: '', stderr: '' };
|
||||
};
|
||||
return { runner, calls };
|
||||
}
|
||||
|
||||
/** Capture console.log + console.error around an async call. */
|
||||
async function capture<T>(fn: () => Promise<T>): Promise<{ result: T; out: string; err: string }> {
|
||||
const origLog = console.log;
|
||||
const origErr = console.error;
|
||||
let out = '';
|
||||
let err = '';
|
||||
console.log = (...args: unknown[]) => {
|
||||
out += args.map(String).join(' ') + '\n';
|
||||
};
|
||||
console.error = (...args: unknown[]) => {
|
||||
err += args.map(String).join(' ') + '\n';
|
||||
};
|
||||
try {
|
||||
const result = await fn();
|
||||
return { result, out, err };
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
console.error = origErr;
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpParent = mkdtempSync(join(tmpdir(), 'gb-subhelp-'));
|
||||
home = join(tmpParent, '.gbrain');
|
||||
mkdirSync(home, { recursive: true });
|
||||
ws = mkdtempSync(join(tmpdir(), 'gb-subhelp-ws-'));
|
||||
prevHome = process.env.GBRAIN_HOME;
|
||||
process.env.GBRAIN_HOME = tmpParent;
|
||||
|
||||
// Interview: all required answers, confirmed — so a real (non---help)
|
||||
// render/hooks/repo/attach call on this workspace would succeed, giving
|
||||
// the --help guard something real to intercept.
|
||||
expect(initState(ws).ok).toBe(true);
|
||||
for (const [key, value] of Object.entries(REQUIRED_ANSWERS)) {
|
||||
const r = setAnswer(ws, key, value);
|
||||
if (!r.ok) throw new Error(r.message);
|
||||
}
|
||||
expect(setAnswer(ws, 'MCP_SCOPE', 'project').ok).toBe(true);
|
||||
expect(skipAnswer(ws, 'HOOKS_CONSENT').ok).toBe(true);
|
||||
const h = readBackHash(ws);
|
||||
if (!h.ok) throw new Error(h.message);
|
||||
expect(confirm(ws, h.hash).ok).toBe(true);
|
||||
|
||||
// Materialize a real render + receipt so render/hooks/attach --help have
|
||||
// real, pre-existing state they could (but must not) mutate. `ws` now also
|
||||
// satisfies attach's precondition (an `initialized: true` agent.json).
|
||||
const render = await capture(() => runBootstrap(['render', '--workspace', ws]));
|
||||
expect(render.result).toBe(0);
|
||||
|
||||
// An "operational" verify config: loadConfig() resolves, so a real
|
||||
// (non---help) verify would proceed past the "no brain configured" early
|
||||
// exit into createEngine()/connect() — which would create files under
|
||||
// `verifyDataDir`. It's never touched if the --help guard works.
|
||||
const verifyDataDir = join(home, 'verify-brain.pglite');
|
||||
writeFileSync(join(home, 'config.json'), JSON.stringify({ engine: 'pglite', database_path: verifyDataDir }), 'utf8');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (prevHome === undefined) delete process.env.GBRAIN_HOME;
|
||||
else process.env.GBRAIN_HOME = prevHome;
|
||||
rmSync(tmpParent, { recursive: true, force: true });
|
||||
rmSync(ws, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('bootstrap <subcommand> --help/-h/help never runs the real operation', () => {
|
||||
test('render --help: usage text, exit 0, receipt + install log untouched', async () => {
|
||||
const receiptBefore = readFileSync(receiptPath(home), 'utf8');
|
||||
const mtimeBefore = statSync(receiptPath(home)).mtimeMs;
|
||||
const logCountBefore = readInstallLog(home, 1000).length;
|
||||
|
||||
const r = await capture(() => runBootstrap(['render', '--workspace', ws, '--help']));
|
||||
|
||||
expect(r.result).toBe(0);
|
||||
expect(r.out).toContain('Render identity files');
|
||||
expect(readFileSync(receiptPath(home), 'utf8')).toBe(receiptBefore);
|
||||
expect(statSync(receiptPath(home)).mtimeMs).toBe(mtimeBefore);
|
||||
expect(readInstallLog(home, 1000).length).toBe(logCountBefore);
|
||||
});
|
||||
|
||||
test('repo --help: usage text, exit 0, zero exec calls', async () => {
|
||||
const { runner, calls } = makeRunner();
|
||||
|
||||
const r = await capture(() => runBootstrap(['repo', '--workspace', ws, '--help'], { runner }));
|
||||
|
||||
expect(r.result).toBe(0);
|
||||
expect(r.out).toContain('PRIVATE GitHub repo');
|
||||
expect(calls.length).toBe(0);
|
||||
});
|
||||
|
||||
test('repo help (bare word, no dashes): usage text, exit 0, zero exec calls', async () => {
|
||||
const { runner, calls } = makeRunner();
|
||||
|
||||
const r = await capture(() => runBootstrap(['repo', '--workspace', ws, 'help'], { runner }));
|
||||
|
||||
expect(r.result).toBe(0);
|
||||
expect(r.out).toContain('PRIVATE GitHub repo');
|
||||
expect(calls.length).toBe(0);
|
||||
});
|
||||
|
||||
test('hooks --help: usage text, exit 0, zero exec calls, receipt untouched', async () => {
|
||||
const receiptBefore = readFileSync(receiptPath(home), 'utf8');
|
||||
const mtimeBefore = statSync(receiptPath(home)).mtimeMs;
|
||||
const { runner, calls } = makeRunner();
|
||||
|
||||
const r = await capture(() =>
|
||||
runBootstrap(
|
||||
['hooks', '--workspace', ws, '--harness', 'claude-code', '--gbrain-bin', process.execPath, '--help'],
|
||||
{ runner },
|
||||
),
|
||||
);
|
||||
|
||||
expect(r.result).toBe(0);
|
||||
expect(r.out).toContain('Register MCP');
|
||||
expect(calls.length).toBe(0);
|
||||
expect(readFileSync(receiptPath(home), 'utf8')).toBe(receiptBefore);
|
||||
expect(statSync(receiptPath(home)).mtimeMs).toBe(mtimeBefore);
|
||||
});
|
||||
|
||||
test('verify --help: usage text, exit 0, engine never connects (data dir never created)', async () => {
|
||||
const verifyDataDir = join(home, 'verify-brain.pglite');
|
||||
expect(existsSync(verifyDataDir)).toBe(false); // sanity: config points at a path that doesn't exist yet
|
||||
|
||||
const r = await capture(() => runBootstrap(['verify', '--workspace', ws, '--help']));
|
||||
|
||||
expect(r.result).toBe(0);
|
||||
expect(r.out).toContain('install contract');
|
||||
// A real (unguarded) verify would have called engine.connect(), which
|
||||
// acquires a file lock under the data dir — creating it. It must still
|
||||
// be absent.
|
||||
expect(existsSync(verifyDataDir)).toBe(false);
|
||||
});
|
||||
|
||||
test('attach --help: usage text, exit 0, receipt untouched — a real attach here WOULD rewrite it', async () => {
|
||||
// `ws` already has an `initialized: true` agent.json (from the render
|
||||
// above) and an existing receipt — attach's real precondition, and the
|
||||
// exact case its own docstring says it does NOT refuse (it merges into
|
||||
// the existing receipt instead). So an unguarded attach --help here
|
||||
// would visibly touch the receipt.
|
||||
const receiptBefore = readFileSync(receiptPath(home), 'utf8');
|
||||
const mtimeBefore = statSync(receiptPath(home)).mtimeMs;
|
||||
|
||||
const r = await capture(() => runBootstrap(['attach', '--workspace', ws, '--help']));
|
||||
|
||||
expect(r.result).toBe(0);
|
||||
expect(r.out).toContain('adopt a cloned agent workspace');
|
||||
expect(readFileSync(receiptPath(home), 'utf8')).toBe(receiptBefore);
|
||||
expect(statSync(receiptPath(home)).mtimeMs).toBe(mtimeBefore);
|
||||
});
|
||||
|
||||
describe('uninstall --help (isolated home + a real receipt-tracked file that must survive)', () => {
|
||||
function seedUninstallFixture(): { uninstallWs: string; isolatedHome: string; dummyCreatedPath: string } {
|
||||
const uninstallWs = mkdtempSync(join(tmpdir(), 'gb-subhelp-uninstall-ws-'));
|
||||
const isolatedHome = join(uninstallWs, '.gbrain');
|
||||
mkdirSync(join(isolatedHome, 'brain.pglite'), { recursive: true });
|
||||
mkdirSync(join(isolatedHome, 'bootstrap'), { recursive: true });
|
||||
writeFileSync(join(isolatedHome, 'config.json'), '{"engine":"pglite"}', 'utf8');
|
||||
const dummyCreatedPath = join(uninstallWs, 'DUMMY_CREATED.md');
|
||||
writeFileSync(dummyCreatedPath, 'created-by-bootstrap — must survive --help', 'utf8');
|
||||
const receipt: InstallReceipt = {
|
||||
receipt_version: 1,
|
||||
workspace_dir: uninstallWs,
|
||||
source_id: 'workspace',
|
||||
agent_name: 'Uninstall Help Test',
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
created_by: '0.0.0-test',
|
||||
brain_created_by_bootstrap: false,
|
||||
created_paths: [dummyCreatedPath],
|
||||
registrations: [],
|
||||
};
|
||||
writeReceipt(isolatedHome, receipt);
|
||||
return { uninstallWs, isolatedHome, dummyCreatedPath };
|
||||
}
|
||||
|
||||
test('--help: usage text, exit 0, zero exec calls, receipt-tracked file + receipt survive', async () => {
|
||||
const { uninstallWs, isolatedHome, dummyCreatedPath } = seedUninstallFixture();
|
||||
try {
|
||||
const receiptBefore = readFileSync(receiptPath(isolatedHome), 'utf8');
|
||||
const { runner, calls } = makeRunner();
|
||||
|
||||
const r = await capture(() =>
|
||||
runBootstrap(['uninstall', '--workspace', uninstallWs, '--home', isolatedHome, '--help'], { runner }),
|
||||
);
|
||||
|
||||
expect(r.result).toBe(0);
|
||||
expect(r.out).toContain('Receipt-keyed removal');
|
||||
expect(calls.length).toBe(0);
|
||||
expect(existsSync(dummyCreatedPath)).toBe(true);
|
||||
expect(readFileSync(receiptPath(isolatedHome), 'utf8')).toBe(receiptBefore);
|
||||
} finally {
|
||||
rmSync(uninstallWs, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('-h: same interception (the highest-risk subcommand, short flag spelling)', async () => {
|
||||
const { uninstallWs, isolatedHome, dummyCreatedPath } = seedUninstallFixture();
|
||||
try {
|
||||
const { runner, calls } = makeRunner();
|
||||
|
||||
const r = await capture(() =>
|
||||
runBootstrap(['uninstall', '--workspace', uninstallWs, '--home', isolatedHome, '-h'], { runner }),
|
||||
);
|
||||
|
||||
expect(r.result).toBe(0);
|
||||
expect(r.out).toContain('Receipt-keyed removal');
|
||||
expect(calls.length).toBe(0);
|
||||
expect(existsSync(dummyCreatedPath)).toBe(true);
|
||||
} finally {
|
||||
rmSync(uninstallWs, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('help (bare word): same interception', async () => {
|
||||
const { uninstallWs, isolatedHome, dummyCreatedPath } = seedUninstallFixture();
|
||||
try {
|
||||
const { runner, calls } = makeRunner();
|
||||
|
||||
const r = await capture(() =>
|
||||
runBootstrap(['uninstall', '--workspace', uninstallWs, '--home', isolatedHome, 'help'], { runner }),
|
||||
);
|
||||
|
||||
expect(r.result).toBe(0);
|
||||
expect(r.out).toContain('Receipt-keyed removal');
|
||||
expect(calls.length).toBe(0);
|
||||
expect(existsSync(dummyCreatedPath)).toBe(true);
|
||||
} finally {
|
||||
rmSync(uninstallWs, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('interview + help (Warning: help combined with a real action flag)', () => {
|
||||
test('interview --init --help: usage text, exit 0, state/interview.json never created', async () => {
|
||||
const freshWs = mkdtempSync(join(tmpdir(), 'gb-subhelp-interview-'));
|
||||
try {
|
||||
expect(existsSync(interviewStatePath(freshWs))).toBe(false);
|
||||
|
||||
const r = await capture(() => runBootstrap(['interview', '--workspace', freshWs, '--init', '--help']));
|
||||
|
||||
expect(r.result).toBe(0);
|
||||
expect(r.out).toContain('Create/record/read interview state');
|
||||
expect(existsSync(interviewStatePath(freshWs))).toBe(false);
|
||||
} finally {
|
||||
rmSync(freshWs, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('interview --set KEY value --help: usage text, exit 0, no answer recorded', async () => {
|
||||
const freshWs = mkdtempSync(join(tmpdir(), 'gb-subhelp-interview-'));
|
||||
try {
|
||||
expect(initState(freshWs).ok).toBe(true);
|
||||
|
||||
const r = await capture(() =>
|
||||
runBootstrap(['interview', '--workspace', freshWs, '--set', 'AGENT_NAME', 'ShouldNotRecord', '--help']),
|
||||
);
|
||||
|
||||
expect(r.result).toBe(0);
|
||||
expect(r.out).toContain('Create/record/read interview state');
|
||||
const raw = JSON.parse(readFileSync(interviewStatePath(freshWs), 'utf8')) as { answers: Record<string, unknown> };
|
||||
expect(raw.answers['AGENT_NAME']).toBeUndefined();
|
||||
} finally {
|
||||
rmSync(freshWs, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('interview --set VOICE_REGISTER "help" (bare word as a legitimate answer VALUE, no --help flag): still records — proves the bare-word exclusion for interview', async () => {
|
||||
const freshWs = mkdtempSync(join(tmpdir(), 'gb-subhelp-interview-'));
|
||||
try {
|
||||
expect(initState(freshWs).ok).toBe(true);
|
||||
|
||||
const r = await capture(() => runBootstrap(['interview', '--workspace', freshWs, '--set', 'VOICE_REGISTER', 'help']));
|
||||
|
||||
expect(r.result).toBe(0);
|
||||
expect(r.out).toContain('recorded');
|
||||
const raw = JSON.parse(readFileSync(interviewStatePath(freshWs), 'utf8')) as { answers: Record<string, { value?: string }> };
|
||||
expect(raw.answers['VOICE_REGISTER']?.value).toBe('help');
|
||||
} finally {
|
||||
rmSync(freshWs, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user