mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
test(scenarios): cover queue handoff, block, resolve and unclaim transitions (#2389)
A new scenario, packages/test-system/scenarios/queue-transitions-reach-exact-states.yaml, drives the queue transitions the test-layers Help wanted row lists as still owed: handoff, block, resolve and unclaim. It runs through the existing env.queue fixture and the shipped rig queue commands, and asserts each row's observable state through expect queue, which now reads closed rows too (rig queue list --json --full --all): the handoff's source reads handed-off to its new owner, and its successor is pending there with handedOffFrom set. Invalid fixtures are refused when the scenario loads, including a whitespace-only handoff destination. Test harness and docs only; no product code. The scenario itself hasn't been run end to end yet. Contributed by @DeryFerd.
This commit is contained in:
1 parent
3fe80b97f6
commit
9819da9d5e
7 files changed
+303
-7
No files matched your search
@@ -540,7 +540,7 @@ Before you start, two honest constraints:
|
||||
| registry | `host` | add/list/select/rename isolated entries that resolve to fixture endpoints. Pairing needs two container daemons. |
|
||||
| coordination | `parked` | An owed item with a stopped worker is reported. A held worker with a live wake is distinguished. |
|
||||
| coordination | `stream` | emit/list/show/archive an exact item. A restart and a bounded watch keep the order with no duplicates. |
|
||||
| coordination | `queue` | (partial) Next: handoff, block, resolve and unclaim, each with exact items and destinations. |
|
||||
| coordination | `queue` | (partial) handoff → successor owned by `--to` and source asserted closed (`handed-off`), block → parked blocked, resolve → unpark to in-progress (owner nudge suppressed), unclaim → pending with destination kept — each with exact items and destinations asserted via `expect queue` (`rig queue list --json --full --all`) in `queue-transitions-reach-exact-states.yaml`; baton restart coverage stays in `queue-baton-survives-restart.yaml`. |
|
||||
| coordination | `project` | Acquire a lease, classify one item twice, read one projection. The wrong holder cannot act. |
|
||||
| coordination | `view` | Register a view over known items. list/show return the exact filtered result after a mutation. |
|
||||
| coordination | `watchdog` | Register one short wake, see one response, stop it, prove no further deliveries. |
|
||||
|
||||
@@ -85,10 +85,15 @@ export function extractStubScripts(env: Record<string, unknown> | undefined): Re
|
||||
|
||||
/**
|
||||
* A queue-baton precondition (fixture level). The scenario asserts observables;
|
||||
* `env.queue` establishes a claimed baton via the SHIPPED `rig queue create|claim`
|
||||
* writes BEFORE the steps run — there is no queue action verb, so the baton lives
|
||||
* `env.queue` establishes queue state via the SHIPPED `rig queue ...` writes
|
||||
* BEFORE the steps run — there is no queue action verb, so the fixture lives
|
||||
* outside the scenario grammar (per the locked verb set), visibly documented in
|
||||
* the scenario file. Queue state is daemon-side, independent of any seat process.
|
||||
*
|
||||
* One entry = one qitem. Applied in this fixed order after `up`:
|
||||
* create → block (`state: "blocked"`) → resolve → claim → unclaim → handoff.
|
||||
* Companions are enforced at extract: unclaim needs claim, resolve needs
|
||||
* state: "blocked", and handoff is exclusive with every other transition.
|
||||
*/
|
||||
export interface QueuePrecondition {
|
||||
id: string;
|
||||
@@ -101,6 +106,14 @@ export interface QueuePrecondition {
|
||||
state?: "blocked";
|
||||
blockedOn?: string;
|
||||
tags?: string[];
|
||||
/** Release the claim after it (`rig queue unclaim <id>` as `destination`: in-progress → pending). Requires `claim`. */
|
||||
unclaim?: boolean;
|
||||
/** Unpark decision for `rig queue resolve <id> --decision <text>` as `destination` (blocked → in-progress). Requires `state: "blocked"`. */
|
||||
resolve?: string;
|
||||
/** Destination seat for `rig queue handoff <id> --to <session>` as `destination` (source → handed-off + successor row). Exclusive with claim/unclaim/resolve/blocked. */
|
||||
handoff?: string;
|
||||
/** `--summary` for the handoff's successor row (a handoff never inherits the source's). */
|
||||
handoffSummary?: string;
|
||||
}
|
||||
|
||||
/** Extract + shape-check the `env.queue` preconditions (loud on a malformed entry). */
|
||||
@@ -120,6 +133,12 @@ export function extractQueuePreconditions(env: Record<string, unknown> | undefin
|
||||
if (e.state !== undefined && e.state !== "blocked") throw new ScenarioPreconditionError("queue state fixture supports blocked only (use claim for in-progress)");
|
||||
if (e.state === "blocked" && (e.claim === true || typeof e.blocked_on !== "string" || !e.blocked_on)) throw new ScenarioPreconditionError("blocked fixture needs blocked_on and cannot also claim");
|
||||
if (e.tags !== undefined && (!Array.isArray(e.tags) || !e.tags.every(t => typeof t === "string" && t))) throw new ScenarioPreconditionError("queue tags fixture must be nonempty strings");
|
||||
if (e.unclaim !== undefined && e.unclaim !== true) throw new ScenarioPreconditionError(`env.queue[${i}].unclaim supports true only`);
|
||||
if (e.unclaim === true && e.claim !== true) throw new ScenarioPreconditionError(`env.queue[${i}].unclaim needs claim: true — only a claimed in-progress row can be unclaimed`);
|
||||
if (e.resolve !== undefined && (typeof e.resolve !== "string" || !e.resolve.trim())) throw new ScenarioPreconditionError(`env.queue[${i}].resolve must be the non-empty decision text passed to \`rig queue resolve --decision\``);
|
||||
if (e.resolve !== undefined && e.state !== "blocked") throw new ScenarioPreconditionError(`env.queue[${i}].resolve needs state: "blocked" — resolve unparks a blocked row to in-progress`);
|
||||
if (e.handoff !== undefined && (typeof e.handoff !== "string" || !e.handoff.trim())) throw new ScenarioPreconditionError(`env.queue[${i}].handoff must be the non-empty destination session passed to \`rig queue handoff --to\``);
|
||||
if (e.handoff !== undefined && (e.claim === true || e.unclaim === true || e.resolve !== undefined || e.state !== undefined)) throw new ScenarioPreconditionError(`env.queue[${i}].handoff is exclusive with claim/unclaim/resolve/blocked — it runs on the plain created row as its owner`);
|
||||
return {
|
||||
id: e.id as string,
|
||||
source: e.source as string,
|
||||
@@ -129,6 +148,10 @@ export function extractQueuePreconditions(env: Record<string, unknown> | undefin
|
||||
claim: e.claim === true,
|
||||
...(e.state === "blocked" ? { state: "blocked" as const, blockedOn: typeof e.blocked_on === "string" ? e.blocked_on : undefined } : {}),
|
||||
...(Array.isArray(e.tags) && e.tags.every(t => typeof t === "string") ? { tags: e.tags as string[] } : {}),
|
||||
...(e.unclaim === true ? { unclaim: true } : {}),
|
||||
...(e.resolve !== undefined ? { resolve: e.resolve } : {}),
|
||||
...(e.handoff !== undefined ? { handoff: e.handoff } : {}),
|
||||
...(typeof e.handoff_summary === "string" && e.handoff_summary ? { handoffSummary: e.handoff_summary } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -176,6 +199,17 @@ export async function applyQueuePreconditions(
|
||||
"--note", "Scenario parked typed baton", "--json"], { ...ctx.readEnv, OPENRIG_SESSION_NAME: p.destination }, ctx.rigBin);
|
||||
if (block.code !== 0) throw new ScenarioPreconditionError(`queue block ${p.id}: ${block.stderr || block.stdout}`);
|
||||
}
|
||||
if (p.resolve !== undefined) {
|
||||
// Fixture setup, not a delivery assertion: --no-notify keeps the precondition
|
||||
// to the unpark write (blocked → in-progress); the owner nudge is not this row's check.
|
||||
const resolve = await runRig(
|
||||
["queue", "resolve", p.id, "--decision", p.resolve, "--no-notify", "--json"],
|
||||
{ ...ctx.readEnv, OPENRIG_SESSION_NAME: p.destination }, ctx.rigBin,
|
||||
);
|
||||
if (resolve.code !== 0) {
|
||||
throw new ScenarioPreconditionError(`queue resolve ${p.id} (exit ${resolve.code}): ${resolve.stderr || resolve.stdout}`);
|
||||
}
|
||||
}
|
||||
if (p.claim) {
|
||||
const claim = await runRig(
|
||||
["queue", "claim", p.id, "--destination", p.destination, "--json"],
|
||||
@@ -185,6 +219,28 @@ export async function applyQueuePreconditions(
|
||||
throw new ScenarioPreconditionError(`queue claim ${p.id} (exit ${claim.code}): ${claim.stderr || claim.stdout}`);
|
||||
}
|
||||
}
|
||||
if (p.unclaim) {
|
||||
const unclaim = await runRig(
|
||||
["queue", "unclaim", p.id, "--json"],
|
||||
{ ...ctx.readEnv, OPENRIG_SESSION_NAME: p.destination }, ctx.rigBin,
|
||||
);
|
||||
if (unclaim.code !== 0) {
|
||||
throw new ScenarioPreconditionError(`queue unclaim ${p.id} (exit ${unclaim.code}): ${unclaim.stderr || unclaim.stdout}`);
|
||||
}
|
||||
}
|
||||
if (p.handoff !== undefined) {
|
||||
// The row's own owner hands off: the route derives the handing-off seat from
|
||||
// the transport header, and `destination` is the created row's destination.
|
||||
const handoff = await runRig(
|
||||
["queue", "handoff", p.id, "--to", p.handoff,
|
||||
...(p.handoffSummary ? ["--summary", p.handoffSummary] : []),
|
||||
"--json"],
|
||||
{ ...ctx.readEnv, OPENRIG_SESSION_NAME: p.destination }, ctx.rigBin,
|
||||
);
|
||||
if (handoff.code !== 0) {
|
||||
throw new ScenarioPreconditionError(`queue handoff ${p.id} (exit ${handoff.code}): ${handoff.stderr || handoff.stdout}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,6 +276,19 @@ export function loadScenarioFile(path: string, opts: LoadScenarioOptions = {}):
|
||||
const result = validateScenario(doc, { topologyKind: opts.topologyKind });
|
||||
if (!result.ok) return { ok: false, errors: result.errors };
|
||||
|
||||
// Queue preconditions are fixture content too: refuse an invalid env.queue at
|
||||
// the SAME load boundary as any other content problem, so the pure loader
|
||||
// never reports ok:true for a fixture the runner would reject. runScenarioFile
|
||||
// re-extracts below, fail-closed, before any spawn.
|
||||
try {
|
||||
extractQueuePreconditions(result.scenario.env);
|
||||
} catch (err) {
|
||||
if (err instanceof ScenarioPreconditionError) {
|
||||
return { ok: false, errors: [{ code: "QUEUE_PRECONDITION_INVALID", message: err.message, path: "env.queue" }] };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const topologyPath = resolve(dirname(path), result.scenario.topology);
|
||||
return { ok: true, loaded: { scenario: result.scenario, topologyPath } };
|
||||
}
|
||||
|
||||
@@ -107,7 +107,8 @@ export type ValidationErrorCode =
|
||||
| "UNKNOWN_EMIT_BEHAVIOR"
|
||||
| "USAGE_LIMIT_IN_STUB_TOPOLOGY"
|
||||
| "UNBOUND_STEP_VERB"
|
||||
| "UNKNOWN_DAEMON_OP";
|
||||
| "UNKNOWN_DAEMON_OP"
|
||||
| "QUEUE_PRECONDITION_INVALID";
|
||||
|
||||
export interface ValidationError {
|
||||
code: ValidationErrorCode;
|
||||
|
||||
@@ -75,8 +75,10 @@ export async function readSurface(
|
||||
case "ps":
|
||||
return jsonRig(["ps", "--json"], ctx);
|
||||
case "queue":
|
||||
// --full so compact-list does not blank body/summary/evidenceRef.
|
||||
return jsonRig(["queue", "list", "--json", "--full"], ctx);
|
||||
// --full so compact-list does not blank body/summary/evidenceRef; --all so
|
||||
// a closed row (a handoff's handed-off source) is visible to expect — the
|
||||
// matcher is contains-only, so extra open rows stay harmless.
|
||||
return jsonRig(["queue", "list", "--json", "--full", "--all"], ctx);
|
||||
case "stream":
|
||||
return jsonRig(["stream", "list", "--json"], ctx);
|
||||
case "scope": {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
extractQueuePreconditions,
|
||||
applyQueuePreconditions,
|
||||
} from "./helpers/scenario-pipeline.js";
|
||||
import { structuralSubsetMatch } from "./helpers/scenario-expect.js";
|
||||
import type { RigResult } from "./helpers/scenario-daemon.js";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -134,6 +135,31 @@ describe("authored evidence scenarios (expressibility)", () => {
|
||||
expect(tuiStep.match).toEqual({ ok: true });
|
||||
expect(equalsStep.equals).not.toHaveProperty("tui_socket");
|
||||
});
|
||||
|
||||
it("expresses queue-transitions: handoff / resolve / unclaim ride the fixture seam and validate at load", () => {
|
||||
const p = join(HERE, "..", "..", "test-system", "scenarios", "queue-transitions-reach-exact-states.yaml");
|
||||
const res = loadScenarioFile(p);
|
||||
expect(res.ok).toBe(true);
|
||||
if (!res.ok) return;
|
||||
expect(res.loaded.scenario.scenario).toBe("queue-transitions-reach-exact-states");
|
||||
expect(basename(res.loaded.topologyPath)).toBe("dev-pair-stub.yaml");
|
||||
const pre = extractQueuePreconditions(res.loaded.scenario.env);
|
||||
expect(pre).toHaveLength(5);
|
||||
expect(pre.map((q) => q.id)).toEqual(["t-pending", "t-blocked", "t-resolved", "t-unclaimed", "t-handoff"]);
|
||||
expect(pre[2]).toMatchObject({ state: "blocked", resolve: expect.any(String) });
|
||||
expect(pre[3]).toMatchObject({ claim: true, unclaim: true });
|
||||
expect(pre[4]).toMatchObject({ handoff: "dev-impl@dev-pair-stub", handoffSummary: expect.any(String) });
|
||||
const verbs = res.loaded.scenario.steps.map((s) => Object.keys(s)[0]);
|
||||
expect(verbs).toEqual(["up", "expect", "down"]);
|
||||
// The handoff gap: the SOURCE must read handed-off (closed) and the successor must carry the link back.
|
||||
const match = (res.loaded.scenario.steps.find((s) => "expect" in s) as { expect: { match?: unknown[] } }).expect.match as unknown[];
|
||||
// structuralSubsetMatch(actual, expected): observed items first, scenario entries second.
|
||||
const observedHandoff = [
|
||||
{ qitemId: "t-handoff", state: "handed-off", handedOffTo: "dev-impl@dev-pair-stub" },
|
||||
{ qitemId: "qitem-successor-1", destinationSession: "dev-impl@dev-pair-stub", summary: "transition — successor owned by dev-impl", state: "pending", handedOffFrom: "t-handoff" },
|
||||
];
|
||||
expect(structuralSubsetMatch(observedHandoff, match.slice(-2))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("queue-baton preconditions (post-P21 identity: env-carried, never a body flag)", () => {
|
||||
@@ -174,6 +200,107 @@ describe("queue-baton preconditions (post-P21 identity: env-carried, never a bod
|
||||
expect(() => extractQueuePreconditions({ queue: "nope" })).toThrow(ScenarioPreconditionError);
|
||||
expect(extractQueuePreconditions(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses an invalid env.queue at the load boundary, not only in runScenarioFile", () => {
|
||||
const p = write("bad-queue.yaml", [
|
||||
"scenario: bad-queue",
|
||||
"topology: ./topo.yaml",
|
||||
"env:",
|
||||
" queue:",
|
||||
" - { id: x, unclaim: true }",
|
||||
"steps:",
|
||||
" - up: {}",
|
||||
].join("\n"));
|
||||
const res = loadScenarioFile(p);
|
||||
expect(res.ok).toBe(false);
|
||||
if (res.ok) return;
|
||||
expect(res.errors.some((e) => e.code === "QUEUE_PRECONDITION_INVALID" && e.path === "env.queue")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// Help-wanted queue family — handoff, resolve and unclaim ride the SAME
|
||||
// fixture-level seam as create/claim/block (the verb set stays locked; the
|
||||
// scenario still asserts observables through `expect queue`).
|
||||
describe("queue transition preconditions (handoff / unclaim / resolve)", () => {
|
||||
it("extracts each transition with its required companion fields", () => {
|
||||
const pre = extractQueuePreconditions({ queue: [
|
||||
{ id: "q-unclaim", source: "s@r", destination: "d@r", claim: true, unclaim: true },
|
||||
{ id: "q-resolve", source: "s@r", destination: "d@r", state: "blocked", blocked_on: "fold:gate", resolve: "scenario decision" },
|
||||
{ id: "q-handoff", source: "s@r", destination: "d@r", handoff: "dev-dev@r", handoff_summary: "successor summary" },
|
||||
] });
|
||||
expect(pre).toHaveLength(3);
|
||||
expect(pre[0]).toMatchObject({ id: "q-unclaim", claim: true, unclaim: true });
|
||||
expect(pre[1]).toMatchObject({ id: "q-resolve", state: "blocked", resolve: "scenario decision" });
|
||||
expect(pre[2]).toMatchObject({ id: "q-handoff", handoff: "dev-dev@r", handoffSummary: "successor summary" });
|
||||
});
|
||||
|
||||
it("rejects an unclaim without a claim, naming both fields", () => {
|
||||
expect(() => extractQueuePreconditions({ queue: [{ id: "x", source: "s@r", destination: "d@r", unclaim: true }] }))
|
||||
.toThrow(/unclaim[\s\S]*claim/);
|
||||
});
|
||||
|
||||
it("rejects a resolve that is not parked blocked, naming both fields", () => {
|
||||
expect(() => extractQueuePreconditions({ queue: [{ id: "x", source: "s@r", destination: "d@r", resolve: "decision" }] }))
|
||||
.toThrow(/resolve[\s\S]*blocked/);
|
||||
});
|
||||
|
||||
it("rejects a handoff combined with any other transition field", () => {
|
||||
const base = { id: "x", source: "s@r", destination: "d@r", handoff: "d2@r" };
|
||||
expect(() => extractQueuePreconditions({ queue: [{ ...base, claim: true }] })).toThrow(/handoff/);
|
||||
expect(() => extractQueuePreconditions({ queue: [{ ...base, unclaim: true, claim: true }] })).toThrow(/handoff/);
|
||||
expect(() => extractQueuePreconditions({ queue: [{ ...base, state: "blocked" as const, blocked_on: "fold:gate", resolve: "d" }] })).toThrow(/handoff/);
|
||||
});
|
||||
|
||||
it("rejects a blank transition value loudly", () => {
|
||||
expect(() => extractQueuePreconditions({ queue: [{ id: "x", source: "s@r", destination: "d@r", resolve: " " }] })).toThrow(ScenarioPreconditionError);
|
||||
expect(() => extractQueuePreconditions({ queue: [{ id: "x", source: "s@r", destination: "d@r", handoff: "" }] })).toThrow(ScenarioPreconditionError);
|
||||
expect(() => extractQueuePreconditions({ queue: [{ id: "x", source: "s@r", destination: "d@r", handoff: " " }] })).toThrow(ScenarioPreconditionError);
|
||||
});
|
||||
|
||||
it("runs unclaim as the claimant right after claim — `rig queue unclaim <id> --json`", async () => {
|
||||
const calls: { args: string[]; env: Record<string, string | undefined> }[] = [];
|
||||
const runRig = vi.fn(async (args: string[], env: Record<string, string | undefined>) => {
|
||||
calls.push({ args, env });
|
||||
return okRig();
|
||||
});
|
||||
await applyQueuePreconditions(
|
||||
[{ id: "q-u", source: "s@r", destination: "d@r", claim: true, unclaim: true }],
|
||||
{ rigBin: "/bin/rig", readEnv: {}, runRig },
|
||||
);
|
||||
expect(calls.map((c) => c.args.slice(0, 2))).toEqual([["queue", "create"], ["queue", "claim"], ["queue", "unclaim"]]);
|
||||
expect(calls[2].args).toEqual(["queue", "unclaim", "q-u", "--json"]);
|
||||
expect(calls[2].env.OPENRIG_SESSION_NAME).toBe("d@r");
|
||||
});
|
||||
|
||||
it("runs resolve --decision --no-notify as the parked owner right after the block write", async () => {
|
||||
const calls: { args: string[]; env: Record<string, string | undefined> }[] = [];
|
||||
const runRig = vi.fn(async (args: string[], env: Record<string, string | undefined>) => {
|
||||
calls.push({ args, env });
|
||||
return okRig();
|
||||
});
|
||||
await applyQueuePreconditions(
|
||||
[{ id: "q-r", source: "s@r", destination: "d@r", state: "blocked" as const, blockedOn: "fold:gate", resolve: "scenario decision" }],
|
||||
{ rigBin: "/bin/rig", readEnv: {}, runRig },
|
||||
);
|
||||
expect(calls.map((c) => c.args.slice(0, 2))).toEqual([["queue", "create"], ["queue", "update"], ["queue", "resolve"]]);
|
||||
expect(calls[2].args).toEqual(["queue", "resolve", "q-r", "--decision", "scenario decision", "--no-notify", "--json"]);
|
||||
expect(calls[2].env.OPENRIG_SESSION_NAME).toBe("d@r");
|
||||
});
|
||||
|
||||
it("runs handoff --to --summary as the row owner right after create", async () => {
|
||||
const calls: { args: string[]; env: Record<string, string | undefined> }[] = [];
|
||||
const runRig = vi.fn(async (args: string[], env: Record<string, string | undefined>) => {
|
||||
calls.push({ args, env });
|
||||
return okRig();
|
||||
});
|
||||
await applyQueuePreconditions(
|
||||
[{ id: "q-h", source: "s@r", destination: "d@r", handoff: "dev-dev@r", handoffSummary: "successor summary" }],
|
||||
{ rigBin: "/bin/rig", readEnv: {}, runRig },
|
||||
);
|
||||
expect(calls.map((c) => c.args.slice(0, 2))).toEqual([["queue", "create"], ["queue", "handoff"]]);
|
||||
expect(calls[1].args).toEqual(["queue", "handoff", "q-h", "--to", "dev-dev@r", "--summary", "successor summary", "--json"]);
|
||||
expect(calls[1].env.OPENRIG_SESSION_NAME).toBe("d@r");
|
||||
});
|
||||
});
|
||||
|
||||
describe("D3 — env.scope_mission survives the WHOLE path into the shipped argv", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import net from "node:net";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve, join } from "node:path";
|
||||
@@ -25,6 +25,8 @@ const realBaseEnv = () => ({ HOME: process.env.HOME, PATH: process.env.PATH, TER
|
||||
|
||||
describe("surface readers — reserved-proof defense-in-depth + dispatch (pure)", () => {
|
||||
const ctx: SurfaceContext = { rigBin: RIG_BIN, readEnv: {}, baseUrl: "http://127.0.0.1:1" };
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); });
|
||||
|
||||
it("a runtime-smuggled 'proof' read still FAILS LOUD with a named UnboundSurfaceError (defense-in-depth)", async () => {
|
||||
// "proof" left the ExpectSurface type (RESERVED); only a cast can reach here.
|
||||
@@ -40,6 +42,26 @@ describe("surface readers — reserved-proof defense-in-depth + dispatch (pure)"
|
||||
it("readSurface(unknown surface) throws (never silently skips)", async () => {
|
||||
await expect(readSurface("database" as never, ctx)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("queue reader invokes `rig queue list --json --full --all` so a closed source row is visible", async () => {
|
||||
// A stand-in rig bin: records the REAL argv the reader spawns, so the flag
|
||||
// plumbing is pinned through the subprocess boundary (a comment-only change
|
||||
// cannot silently drop --all and hide handed-off rows from expect).
|
||||
const d = mkdtempSync(join(tmpdir(), "surf-"));
|
||||
dirs.push(d);
|
||||
const argvLog = join(d, "argv.log");
|
||||
const fakeRig = join(d, "fake-rig.mjs");
|
||||
writeFileSync(fakeRig, [
|
||||
'import { appendFileSync } from "node:fs";',
|
||||
`appendFileSync(${JSON.stringify(argvLog)}, JSON.stringify(process.argv.slice(2)) + "\\n");`,
|
||||
'process.stdout.write("[]");',
|
||||
"",
|
||||
].join("\n"));
|
||||
const observed = await readSurface("queue", { rigBin: fakeRig, readEnv: { PATH: process.env.PATH }, baseUrl: "http://127.0.0.1:1" });
|
||||
expect(observed).toEqual([]);
|
||||
const invocations = readFileSync(argvLog, "utf-8").trim().split("\n").map((l) => JSON.parse(l));
|
||||
expect(invocations).toContainEqual(["queue", "list", "--json", "--full", "--all"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("surface readers — tui_socket `state` query (fake unix socket)", () => {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# Queue transition family (Help wanted: handoff, block, resolve and unclaim,
|
||||
# each with exact items and destinations).
|
||||
# FORM: the four transitions ride the fixture-level `env.queue` seam — SHIPPED
|
||||
# `rig queue ...` writes applied right after `up` (there is NO queue action
|
||||
# verb; the verb set stays locked, so setup lives outside the scenario grammar
|
||||
# and is visibly declared here). The scenario only ASSERTS the observable rows
|
||||
# through `expect queue` (`rig queue list --json --full`).
|
||||
# Rows: t-pending untouched; t-blocked parked on a typed gate; t-resolved parked
|
||||
# then unparked by `rig queue resolve --decision` (blocked → in-progress, the
|
||||
# owner nudge suppressed — fixture setup, not this row's check); t-unclaimed
|
||||
# claimed then released (in-progress → pending, destination kept); t-handoff
|
||||
# handed off to dev-impl — the source row reads handed-off (closed) and the
|
||||
# successor is pending at dev-impl, carrying handedOffFrom back to t-handoff;
|
||||
# the queue read includes closed rows via --all so both are visible.
|
||||
# Deps: R-10 runner. No emit, no seed — healthy form only.
|
||||
scenario: queue-transitions-reach-exact-states
|
||||
topology: ./dev-pair-stub.yaml
|
||||
env:
|
||||
queue:
|
||||
- id: t-pending
|
||||
source: harness@dev-pair-stub
|
||||
destination: dev-qa@dev-pair-stub
|
||||
summary: "transition — stays pending"
|
||||
- id: t-blocked
|
||||
source: harness@dev-pair-stub
|
||||
destination: dev-qa@dev-pair-stub
|
||||
state: blocked
|
||||
blocked_on: "fold:scenario-transition-wait"
|
||||
summary: "transition — parked blocked"
|
||||
- id: t-resolved
|
||||
source: harness@dev-pair-stub
|
||||
destination: dev-qa@dev-pair-stub
|
||||
state: blocked
|
||||
blocked_on: "fold:scenario-transition-resolve"
|
||||
resolve: "scenario decision recorded while parked"
|
||||
summary: "transition — unparked by resolve"
|
||||
- id: t-unclaimed
|
||||
source: harness@dev-pair-stub
|
||||
destination: dev-qa@dev-pair-stub
|
||||
claim: true
|
||||
unclaim: true
|
||||
summary: "transition — claimed then released"
|
||||
- id: t-handoff
|
||||
source: harness@dev-pair-stub
|
||||
destination: dev-qa@dev-pair-stub
|
||||
handoff: dev-impl@dev-pair-stub
|
||||
handoff_summary: "transition — successor owned by dev-impl"
|
||||
summary: "transition — handed off to dev-impl"
|
||||
steps:
|
||||
- up: {}
|
||||
- expect:
|
||||
surface: queue
|
||||
within: 20s
|
||||
match:
|
||||
- qitemId: t-pending
|
||||
state: pending
|
||||
destinationSession: dev-qa@dev-pair-stub
|
||||
- qitemId: t-blocked
|
||||
state: blocked
|
||||
blockedOn: "fold:scenario-transition-wait"
|
||||
destinationSession: dev-qa@dev-pair-stub
|
||||
- qitemId: t-resolved
|
||||
state: in-progress
|
||||
destinationSession: dev-qa@dev-pair-stub
|
||||
- qitemId: t-unclaimed
|
||||
state: pending
|
||||
destinationSession: dev-qa@dev-pair-stub
|
||||
- qitemId: t-handoff
|
||||
state: handed-off
|
||||
handedOffTo: dev-impl@dev-pair-stub
|
||||
- state: pending
|
||||
destinationSession: dev-impl@dev-pair-stub
|
||||
summary: "transition — successor owned by dev-impl"
|
||||
handedOffFrom: t-handoff
|
||||
- down: {}
|
||||
Reference in new issue
Block a user