@openwop/openwop-conformance 2.0.0-rc.9 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/README.md +8 -3
- package/dist/cli.js +24 -34
- package/dist/lib/requirement-registry.js +69 -0
- package/dist/lib/scenario-disposition.js +84 -11
- package/dist/lib/seams.js +72 -0
- package/dist/lib/soft-skip.js +39 -8
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirement-aliases.json +4 -1
- package/requirements.json +1028 -55
- package/scenario-majors.json +57 -3
- package/schemas/CORPUS-STAMP.json +40 -37
- package/src/cli.ts +23 -30
- package/src/global-setup.ts +11 -0
- package/src/lib/corpus-stamp.ts +24 -2
- package/src/lib/era2-seed.ts +12 -1
- package/src/lib/fixtures.ts +31 -0
- package/src/lib/requirement-registry.ts +68 -0
- package/src/lib/scenario-disposition.ts +88 -9
- package/src/lib/seams.ts +31 -1
- package/src/lib/soft-skip.ts +42 -7
- package/src/lib/sse.ts +8 -0
- package/src/scenarios/era-key-stamped-v1.test.ts +156 -0
- package/src/scenarios/pause-resume.test.ts +159 -75
- package/src/scenarios/v2-advertised-fixtures-exist.test.ts +25 -43
- package/src/scenarios/v2-advertised-path-space-served.test.ts +165 -0
- package/src/scenarios/v2-assurance-downgrade-audited.test.ts +1 -1
- package/src/scenarios/v2-chain-pin-exact.test.ts +1 -1
- package/src/scenarios/v2-coherence-not-in-bundle.test.ts +11 -2
- package/src/scenarios/v2-created-run-readable.test.ts +98 -0
- package/src/scenarios/v2-dual-stack-negotiation.test.ts +29 -1
- package/src/scenarios/v2-effect-identity-business-key.test.ts +1 -1
- package/src/scenarios/v2-effect-seam-manifest.test.ts +21 -55
- package/src/scenarios/v2-effect-seam-no-refire.test.ts +104 -0
- package/src/scenarios/v2-era-2-append-vocabulary.test.ts +16 -2
- package/src/scenarios/v2-interrupt-token-scheme.test.ts +1 -1
- package/src/scenarios/v2-malformed-body-envelope.test.ts +79 -0
- package/src/scenarios/v2-manifest-ceiling-refused.test.ts +1 -1
- package/src/scenarios/v2-manifest-hatch-carried.test.ts +1 -1
- package/src/scenarios/v2-minimum-version-refused.test.ts +1 -1
- package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +1 -1
- package/src/scenarios/v2-negotiation-authenticated.test.ts +1 -1
- package/src/scenarios/v2-negotiation-decided-emitted.test.ts +1 -1
- package/src/scenarios/v2-pack-isolation.test.ts +1 -1
- package/src/scenarios/v2-peer-dependency-declared.test.ts +1 -1
- package/src/scenarios/v2-poll-cursor-v2.test.ts +18 -0
- package/src/scenarios/v2-revocation-honored.test.ts +1 -1
- package/src/scenarios/v2-run-annotation-not-event.test.ts +71 -0
- package/src/scenarios/v2-run-bulk-cancel.test.ts +91 -0
- package/src/scenarios/v2-run-cancel.test.ts +97 -0
- package/src/scenarios/v2-run-completed-outputs.test.ts +93 -0
- package/src/scenarios/v2-run-diff-identical.test.ts +75 -0
- package/src/scenarios/v2-run-fork-prefix.test.ts +160 -0
- package/src/scenarios/v2-run-fork-refusals.test.ts +70 -0
- package/src/scenarios/v2-run-options-limits.test.ts +69 -0
- package/src/scenarios/v2-run-pause-resume.test.ts +117 -0
- package/src/scenarios/v2-run-snapshot-etag.test.ts +57 -0
- package/src/scenarios/v2-sse-last-event-id.test.ts +93 -0
- package/src/scenarios/v2-stream-mode-refusal.test.ts +118 -0
- package/src/scenarios/v2-stream-sse-projection.test.ts +79 -0
- package/src/scenarios/v2-subject-link-record.test.ts +1 -1
- package/src/scenarios/v2-v1-signed-webhook-accepted.test.ts +1 -1
- package/src/scenarios/version-negotiation.test.ts +25 -3
- package/src/setup.ts +104 -50
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `spec/v2/core/replay.md` §Endpoint — the fork prefix boundary is EXCLUSIVE
|
|
3
|
+
* (suite 2.0.0, target major 2; unaided; gated on `replay`; two runs created).
|
|
4
|
+
*
|
|
5
|
+
* "Events with `sequence < fromSeq` are fixed history; events `>= fromSeq` are
|
|
6
|
+
* re-executed." A host that copies `sequence <= fromSeq` inherits one event too
|
|
7
|
+
* many and re-executes from one event too late.
|
|
8
|
+
*
|
|
9
|
+
* Why this file exists. Until rc.60 the only major-2 witness of that boundary
|
|
10
|
+
* was `v2-fork-a-v1-run`, which is driven through the conformance seam and sits
|
|
11
|
+
* on the seams floor — so a host that does not advertise the seams profile was
|
|
12
|
+
* never measured on it, and `v2-run-fork-refusals` (the unaided fork scenario)
|
|
13
|
+
* asserts only the refusals and never reads the forked log. A tier-1 host
|
|
14
|
+
* shipped `<= fromSeq` and passed every unaided major-2 scenario; it found the
|
|
15
|
+
* defect only when fixing 1-based sequence numbering made the off-by-one
|
|
16
|
+
* visible — two errors cancelling. The v1 scenarios that do check the prefix
|
|
17
|
+
* (`replay-fork`, `replay-fork-arbitrary`, `feedback-fork-not-copied`) are
|
|
18
|
+
* `majors: [1]` and never run here.
|
|
19
|
+
*
|
|
20
|
+
* The falsifiable shape, chosen to be race-free and implementation-agnostic.
|
|
21
|
+
* `fromSeq` is placed on a `node.completed` that is not the run's last, so the
|
|
22
|
+
* source's event AT `fromSeq` is one re-execution cannot honestly reproduce at
|
|
23
|
+
* that same sequence: an exclusive host restarts that node, and its event at
|
|
24
|
+
* `fromSeq` is the fresh `node.started`; an inclusive host inherited the
|
|
25
|
+
* `node.completed` and re-executes from `fromSeq + 1`, so its event at
|
|
26
|
+
* `fromSeq` is the source's, verbatim. Both logs are read after they settle.
|
|
27
|
+
*
|
|
28
|
+
* Measured on the reference host (exclusive) at `fromSeq: 4` of an 8-event
|
|
29
|
+
* `conformance-multi-node` log: source `4 node.completed b`, fork
|
|
30
|
+
* `4 node.started b`, fork 9 events to the source's 8 — the fork is LONGER,
|
|
31
|
+
* because node `b` had started inside the prefix and its completion is
|
|
32
|
+
* re-executed. Length is therefore not the discriminator and this file does not
|
|
33
|
+
* assert on it; the event at the boundary is.
|
|
34
|
+
*
|
|
35
|
+
* Floor membership: RULED OUT (rc.65), and the reasoning generalises. Rule 10
|
|
36
|
+
* admits this file mechanically — it gates only on `replay`, which IS in the
|
|
37
|
+
* core-standard predicate, and carries no seam token. But rule 10's stated
|
|
38
|
+
* intent is that a floor file be "witnessable by an honest holder of the
|
|
39
|
+
* predicate", and this file is not: it is a SINGLE `it` gated entirely on
|
|
40
|
+
* `isFixtureAdvertised(conformance-multi-node)`, and no spec text makes that
|
|
41
|
+
* fixture mandatory. An honest `replay` holder without it records the whole
|
|
42
|
+
* file `inapplicable` and contributes zero witness. Contrast the eleven of
|
|
43
|
+
* thirteen current core-standard members with no fixture gate at all, and the
|
|
44
|
+
* two that have one (`v2-run-cancel`, `v2-run-pause-resume`) gating only SOME
|
|
45
|
+
* legs of a multi-`it` file, so the file still witnesses — the rc.59
|
|
46
|
+
* `partial-witness:` pattern.
|
|
47
|
+
*
|
|
48
|
+
* The test for any future floor candidate, then: CAN THE FILE STILL WITNESS
|
|
49
|
+
* FOR A PREDICATE HOLDER THAT ADVERTISES NO OPTIONAL FIXTURE? If the answer is
|
|
50
|
+
* no because it is single-leg and fixture-gated, it is a valid scenario and not
|
|
51
|
+
* a floor member. That is a statement about the floor's promise, not about this
|
|
52
|
+
* file's worth: the boundary it checks is real and a tier-1 host shipped the
|
|
53
|
+
* bug it catches.
|
|
54
|
+
*
|
|
55
|
+
* What this file deliberately does NOT assert: the CONTENT of the re-executed
|
|
56
|
+
* tail, determinism across two forks, timing, and effect re-fire suppression.
|
|
57
|
+
* Those are `replay.md` §Replay determinism and the seams floor's, witnessed in
|
|
58
|
+
* `replay-fork-arbitrary` (major 1) and `v2-effect-seam-no-refire` (seams).
|
|
59
|
+
*
|
|
60
|
+
* @see spec/v2/core/replay.md §Endpoint
|
|
61
|
+
* @see spec/v2/core/runs.md §Fork
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
import { describe, it, expect } from 'vitest';
|
|
65
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
66
|
+
import { v2Discovery, gateFamily } from '../lib/v2.js';
|
|
67
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
68
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
69
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
70
|
+
import { req } from '../lib/requirement-ids.js';
|
|
71
|
+
|
|
72
|
+
const ID = 'openwop.requirement.0170.fork-prefix-boundary';
|
|
73
|
+
const DOC = 'spec/v2/core/replay.md §Endpoint';
|
|
74
|
+
const MULTI = 'conformance-multi-node';
|
|
75
|
+
const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
|
|
76
|
+
|
|
77
|
+
interface Ev { readonly sequence?: unknown; readonly type?: unknown }
|
|
78
|
+
|
|
79
|
+
async function discovery(): Promise<Record<string, unknown> | null> { try { return await v2Discovery(); } catch { return null; } }
|
|
80
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
|
|
81
|
+
const enc = (id: string): string => encodeURIComponent(id);
|
|
82
|
+
|
|
83
|
+
/** Every event of a run, in sequence order, read through the poll surface. */
|
|
84
|
+
async function logOf(runId: string): Promise<Ev[] | null> {
|
|
85
|
+
const res = await http(() => driver.get(`/runs/${enc(runId)}/events/poll?timeout=1&streamMode=debug`));
|
|
86
|
+
if (res === null || res.status !== 200) return null;
|
|
87
|
+
const events = (res.json as { events?: unknown } | undefined)?.events;
|
|
88
|
+
if (!Array.isArray(events)) return null;
|
|
89
|
+
return (events as Ev[]).filter((e) => typeof e.sequence === 'number').sort((a, b) => (a.sequence as number) - (b.sequence as number));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Poll until the run is terminal or the bound elapses; returns the last status seen. */
|
|
93
|
+
async function settle(runId: string, ms = 20_000): Promise<string | null> {
|
|
94
|
+
const deadline = Date.now() + ms;
|
|
95
|
+
let status: string | null = null;
|
|
96
|
+
while (Date.now() < deadline) {
|
|
97
|
+
const res = await http(() => driver.get(`/runs/${enc(runId)}`));
|
|
98
|
+
status = res?.status === 200 ? String((res.json as { status?: unknown } | undefined)?.status ?? '') : status;
|
|
99
|
+
if (status !== null && TERMINAL.has(status)) return status;
|
|
100
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
101
|
+
}
|
|
102
|
+
return status;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
describe('v2 run-fork-prefix (replay.md §Endpoint — the boundary is exclusive)', () => {
|
|
106
|
+
it('a replay fork inherits exactly [0, fromSeq): the event at fromSeq is re-executed, never carried over', async () => {
|
|
107
|
+
if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
|
|
108
|
+
if (!(await gateFamily('replay'))) return softSkip('inapplicable', 'replay family not advertised (gate recorded under openwop.family.replay) — forkRun is gated on replay (runs.md §Surface)');
|
|
109
|
+
if (!isFixtureAdvertised(MULTI)) return softSkip('inapplicable', `${MULTI} fixture not advertised — no multi-event deterministic run to fork at a mid-log point`);
|
|
110
|
+
|
|
111
|
+
const created = await http(() => driver.post('/runs', { workflowId: MULTI }));
|
|
112
|
+
if (created === null || created.status !== 201) return softSkip('blocked', `POST /runs {workflowId: ${MULTI}} answered ${created?.status ?? 'no response'} ${readErrorCode(created?.json) ?? ''}`.trim());
|
|
113
|
+
const runId = (created.json as { runId?: unknown }).runId;
|
|
114
|
+
if (typeof runId !== 'string') return softSkip('blocked', 'the 201 carried no runId');
|
|
115
|
+
|
|
116
|
+
const sourceStatus = await settle(runId);
|
|
117
|
+
if (sourceStatus === null || !TERMINAL.has(sourceStatus)) return softSkip('blocked', `the source run did not settle within 20 s (last status: ${sourceStatus ?? 'unreadable'}) — a mid-log fork point cannot be chosen from an unfinished log`);
|
|
118
|
+
const source = await logOf(runId);
|
|
119
|
+
if (source === null) return softSkip('blocked', `GET /runs/{runId}/events/poll did not answer 200 with events[] for the source run`);
|
|
120
|
+
|
|
121
|
+
// The fork point: a `node.completed` that is not the run's last event, so
|
|
122
|
+
// the source's event AT fromSeq is one an honest re-execution cannot
|
|
123
|
+
// reproduce at that sequence (it restarts the node instead).
|
|
124
|
+
const seqs = source.map((e) => e.sequence as number);
|
|
125
|
+
const last = Math.max(...seqs);
|
|
126
|
+
const point = source.find((e) => e.type === 'node.completed' && (e.sequence as number) > 0 && (e.sequence as number) < last);
|
|
127
|
+
if (point === undefined) return softSkip('inapplicable', `no node.completed sits strictly inside the ${source.length}-event source log (types: ${[...new Set(source.map((e) => String(e.type)))].join(', ')}) — this host's log shape offers no fork point where an inherited row and a re-executed row are distinguishable`);
|
|
128
|
+
const fromSeq = point.sequence as number;
|
|
129
|
+
|
|
130
|
+
const fork = await http(() => driver.post(`/runs/${enc(runId)}:fork`, { mode: 'replay', fromSeq }));
|
|
131
|
+
if (fork === null) return softSkip('blocked', 'POST /runs/{runId}:fork unreachable (fetch failed)');
|
|
132
|
+
if (fork.status === 404) return softSkip('blocked', 'POST /runs/{runId}:fork answered 404 — forkRun is served by a host advertising replay (runs.md §Surface)');
|
|
133
|
+
expect(fork.status, req(ID, 'spec/v2/core/runs.md §Fork', `a replay fork at a sequence in the source log MUST answer 201 — got ${fork.status} ${readErrorCode(fork.json) ?? ''}`.trim())).toBe(201);
|
|
134
|
+
const forkId = (fork.json as { runId?: unknown }).runId;
|
|
135
|
+
if (typeof forkId !== 'string') return softSkip('blocked', 'the fork 201 carried no runId');
|
|
136
|
+
|
|
137
|
+
const forkStatus = await settle(forkId);
|
|
138
|
+
if (forkStatus === null || !TERMINAL.has(forkStatus)) return softSkip('blocked', `the fork did not settle within 20 s (last status: ${forkStatus ?? 'unreadable'}) — the inherited-prefix length is only comparable on a finished log`);
|
|
139
|
+
const forked = await logOf(forkId);
|
|
140
|
+
if (forked === null) return softSkip('blocked', 'GET /runs/{runId}/events/poll did not answer 200 with events[] for the fork');
|
|
141
|
+
|
|
142
|
+
// The prefix itself: every inherited row matches the source by sequence and
|
|
143
|
+
// type. This is the fixed-history claim, stated positively.
|
|
144
|
+
const inherited = forked.filter((e) => (e.sequence as number) < fromSeq);
|
|
145
|
+
expect(
|
|
146
|
+
inherited.map((e) => `${String(e.sequence)}:${String(e.type)}`),
|
|
147
|
+
req(ID, DOC, `the fork MUST inherit [0, ${fromSeq}) from the source verbatim by sequence and type`),
|
|
148
|
+
).toEqual(source.filter((e) => (e.sequence as number) < fromSeq).map((e) => `${String(e.sequence)}:${String(e.type)}`));
|
|
149
|
+
|
|
150
|
+
// The boundary. The source's event at `fromSeq` is re-executed, not fixed
|
|
151
|
+
// history, so it MUST NOT appear at its own sequence in the fork. An
|
|
152
|
+
// inclusive host copies [0, fromSeq] and that is exactly what it shows.
|
|
153
|
+
const at = forked.find((e) => (e.sequence as number) === fromSeq);
|
|
154
|
+
if (at === undefined) return softSkip('blocked', `the fork's log has no event at sequence ${fromSeq} (last ${Math.max(...forked.map((e) => e.sequence as number))}) — the boundary is unobservable on this log`);
|
|
155
|
+
expect(
|
|
156
|
+
String(at.type),
|
|
157
|
+
req(ID, DOC, `the event at sequence ${fromSeq} is re-executed, not fixed history: the fork MUST NOT carry the source's ${String(point.type)} at that sequence — seeing it means the prefix was copied inclusively ([0, ${fromSeq}] instead of [0, ${fromSeq}))`),
|
|
158
|
+
).not.toBe(String(point.type));
|
|
159
|
+
}, 60_000);
|
|
160
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `spec/v2/core/runs.md` §Fork — the refusals and the copied owner (suite
|
|
3
|
+
* 2.0.0, target major 2; gated on `replay`; one run created plus one fork).
|
|
4
|
+
*
|
|
5
|
+
* A `replay` fork with a non-empty `runOptionsOverlay` MUST be rejected 400
|
|
6
|
+
* (the overlay is `branch`-only); a `fromSeq` not in the source log MUST be
|
|
7
|
+
* rejected `422 fork_point_invalid`; a valid `replay` fork answers `201 {
|
|
8
|
+
* runId, sourceRunId, mode, status, eventsUrl }` and the child's `owner` is
|
|
9
|
+
* copied verbatim from the parent (RFC 0170 §A.4). The 201 is the control for
|
|
10
|
+
* the two refusals. Determinism and side-effect suppression are replay.md's
|
|
11
|
+
* and witnessed there.
|
|
12
|
+
*
|
|
13
|
+
* @see spec/v2/core/runs.md §Fork
|
|
14
|
+
* @see RFCS/0170 §A.4
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect } from 'vitest';
|
|
18
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
19
|
+
import { v2Discovery, gateFamily } from '../lib/v2.js';
|
|
20
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
21
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
22
|
+
import { req } from '../lib/requirement-ids.js';
|
|
23
|
+
|
|
24
|
+
const ID = 'openwop.requirement.0170.run-fork-refusals';
|
|
25
|
+
const DOC = 'spec/v2/core/runs.md §Fork';
|
|
26
|
+
const NOOP = 'conformance-noop';
|
|
27
|
+
const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
|
|
28
|
+
|
|
29
|
+
async function discovery(): Promise<Record<string, unknown> | null> { try { return await v2Discovery(); } catch { return null; } }
|
|
30
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
|
|
31
|
+
const enc = (id: string): string => encodeURIComponent(id);
|
|
32
|
+
async function snapshot(runId: string): Promise<Record<string, unknown> | null> {
|
|
33
|
+
const res = await http(() => driver.get(`/runs/${enc(runId)}`));
|
|
34
|
+
return res?.status === 200 && res.json && typeof res.json === 'object' ? (res.json as Record<string, unknown>) : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('v2 run-fork-refusals (runs.md §Fork)', () => {
|
|
38
|
+
it('replay+overlay → 400; a fromSeq outside the log → 422 fork_point_invalid; a valid replay fork → 201 with the parent\'s owner verbatim', async () => {
|
|
39
|
+
if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
|
|
40
|
+
if (!(await gateFamily('replay'))) return softSkip('inapplicable', 'replay family not advertised (gate recorded under openwop.family.replay) — forkRun is gated on replay (runs.md §Surface)');
|
|
41
|
+
const created = await http(() => driver.post('/runs', { workflowId: NOOP }));
|
|
42
|
+
if (created === null) return softSkip('blocked', 'POST /runs unreachable (fetch failed)');
|
|
43
|
+
const runId = (created.json as { runId?: unknown } | null)?.runId;
|
|
44
|
+
if (created.status !== 201 || typeof runId !== 'string') return softSkip('blocked', `POST /runs answered ${created.status} ${readErrorCode(created.json) ?? ''}`.trim());
|
|
45
|
+
const t0 = Date.now(); let parent: Record<string, unknown> | null = null;
|
|
46
|
+
while (Date.now() - t0 < 10_000) { parent = await snapshot(runId); if (parent && TERMINAL.has(String(parent['status']))) break; await new Promise((r) => setTimeout(r, 250)); }
|
|
47
|
+
if (!parent || !TERMINAL.has(String(parent['status']))) return softSkip('blocked', 'the noop run did not settle within 10 s');
|
|
48
|
+
|
|
49
|
+
const overlay = await http(() => driver.post(`/runs/${enc(runId)}:fork`, { mode: 'replay', runOptionsOverlay: { tags: ['conformance'] } }));
|
|
50
|
+
if (overlay === null) return softSkip('blocked', 'POST /runs/{runId}:fork unreachable (fetch failed)');
|
|
51
|
+
if (overlay.status === 404) return softSkip('blocked', 'POST /runs/{runId}:fork answered 404 with replay advertised — forkRun is not mounted');
|
|
52
|
+
expect(overlay.status, req(ID, DOC, `a replay fork with a non-empty runOptionsOverlay MUST be rejected 400 (the overlay is branch-only) — got ${overlay.status} ${readErrorCode(overlay.json) ?? ''}`.trim())).toBe(400);
|
|
53
|
+
|
|
54
|
+
const bad = await http(() => driver.post(`/runs/${enc(runId)}:fork`, { mode: 'branch', fromSeq: 999_999 }));
|
|
55
|
+
if (bad === null) return softSkip('blocked', 'POST /runs/{runId}:fork unreachable (fetch failed)');
|
|
56
|
+
expect(bad.status, req(ID, DOC, `a fromSeq not in the source log MUST be rejected 422 — got ${bad.status} ${readErrorCode(bad.json) ?? ''}`.trim())).toBe(422);
|
|
57
|
+
expect(readErrorCode(bad.json), req(ID, DOC, 'the refusal MUST be fork_point_invalid')).toBe('fork_point_invalid');
|
|
58
|
+
|
|
59
|
+
const ok = await http(() => driver.post(`/runs/${enc(runId)}:fork`, { mode: 'replay' }));
|
|
60
|
+
if (ok === null) return softSkip('blocked', 'POST /runs/{runId}:fork unreachable (fetch failed)');
|
|
61
|
+
expect(ok.status, req(ID, DOC, `a valid replay fork MUST answer 201 — got ${ok.status} ${readErrorCode(ok.json) ?? ''} (the control for the two refusals above)`.trim())).toBe(201);
|
|
62
|
+
const body = ok.json as { runId?: unknown; sourceRunId?: unknown; mode?: unknown; eventsUrl?: unknown } | null;
|
|
63
|
+
expect(body?.sourceRunId, req(ID, DOC, 'the 201 MUST name the parent as sourceRunId')).toBe(runId);
|
|
64
|
+
expect(body?.mode, req(ID, DOC, 'the 201 MUST echo the mode')).toBe('replay');
|
|
65
|
+
expect(typeof body?.runId === 'string' && typeof body?.eventsUrl === 'string', req(ID, DOC, 'the 201 MUST carry runId and eventsUrl')).toBe(true);
|
|
66
|
+
const child = await snapshot(String(body?.runId));
|
|
67
|
+
if (!child) return softSkip('blocked', 'the child snapshot could not be read');
|
|
68
|
+
expect(child['owner'], req(ID, 'RFCS/0170 §A.4', 'the child\'s owner MUST be copied verbatim from the parent')).toEqual(parent['owner']);
|
|
69
|
+
}, 30_000);
|
|
70
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `spec/v2/core/runs.md` §Run options — `tags`, `metadata`, and the closed
|
|
3
|
+
* create body (suite 2.0.0, target major 2; unaided; one run created).
|
|
4
|
+
*
|
|
5
|
+
* `tags` is opaque: a host MUST NOT reject a tag on format and MUST answer
|
|
6
|
+
* `400 validation_error` over the limits (100 entries, 256 characters).
|
|
7
|
+
* `metadata` MUST be persisted and surface unchanged on the snapshot, as MUST
|
|
8
|
+
* `tags`. The create body is closed at the composition, so an unknown ROOT key
|
|
9
|
+
* MUST be refused `400 validation_error` (`v2-configurable-closed` covers keys
|
|
10
|
+
* inside `configurable` only).
|
|
11
|
+
*
|
|
12
|
+
* The refusals are the control for the acceptance and vice versa: a host that
|
|
13
|
+
* accepts everything fails the 400 legs; a host that refuses odd tags fails the
|
|
14
|
+
* 201 leg. `""` is deliberately not among the odd tags — the schema's
|
|
15
|
+
* `minLength: 1` is a length rule, not a format rule (finding 4).
|
|
16
|
+
*
|
|
17
|
+
* @see spec/v2/core/runs.md §Run options
|
|
18
|
+
* @see schemas/v2/run-options.schema.json
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, it, expect } from 'vitest';
|
|
22
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
23
|
+
import { v2Discovery } from '../lib/v2.js';
|
|
24
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
25
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
26
|
+
import { req } from '../lib/requirement-ids.js';
|
|
27
|
+
|
|
28
|
+
const ID = 'openwop.requirement.0171.run-options-limits';
|
|
29
|
+
const DOC = 'spec/v2/core/runs.md §Run options';
|
|
30
|
+
const NOOP = 'conformance-noop';
|
|
31
|
+
const ODD_TAGS = ['has space', 'emoji 🚀', 'slash/colon:pipe|', 'UPPER-and_under', 'x'.repeat(256)];
|
|
32
|
+
const METADATA = { nested: { a: 1, b: [1, '2', null, true] }, s: 'unicode ∞ ok', n: 0 };
|
|
33
|
+
|
|
34
|
+
async function discovery(): Promise<Record<string, unknown> | null> { try { return await v2Discovery(); } catch { return null; } }
|
|
35
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
|
|
36
|
+
|
|
37
|
+
async function refused(body: Record<string, unknown>, what: string): Promise<string | null> {
|
|
38
|
+
const res = await http(() => driver.post('/runs', body));
|
|
39
|
+
if (res === null) return `POST /runs unreachable (fetch failed) for ${what}`;
|
|
40
|
+
expect(res.status, req(ID, DOC, `${what} MUST be refused 400 — got ${res.status} ${readErrorCode(res.json) ?? ''}`.trim())).toBe(400);
|
|
41
|
+
expect(readErrorCode(res.json), req(ID, DOC, `the refusal for ${what} MUST be validation_error`)).toBe('validation_error');
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('v2 run-options-limits (runs.md §Run options)', () => {
|
|
46
|
+
it('more than 100 tags, a tag over 256 characters, and an unknown root key are each refused 400 validation_error', async () => {
|
|
47
|
+
if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
|
|
48
|
+
const probes: Array<[Record<string, unknown>, string]> = [
|
|
49
|
+
[{ workflowId: NOOP, tags: Array.from({ length: 101 }, (_, i) => `t${i}`) }, '101 tags'],
|
|
50
|
+
[{ workflowId: NOOP, tags: ['y'.repeat(257)] }, 'a 257-character tag'],
|
|
51
|
+
[{ workflowId: NOOP, conformanceUnknownRootKey: 1 }, 'an unknown root key on the create body (closed at the composition)'],
|
|
52
|
+
];
|
|
53
|
+
for (const [body, what] of probes) { const r = await refused(body, what); if (r) return softSkip('blocked', r); }
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('odd-format tags and free-form metadata are accepted and surface unchanged on the snapshot', async () => {
|
|
57
|
+
if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
|
|
58
|
+
const res = await http(() => driver.post('/runs', { workflowId: NOOP, tags: ODD_TAGS, metadata: METADATA }));
|
|
59
|
+
if (res === null) return softSkip('blocked', 'POST /runs unreachable (fetch failed)');
|
|
60
|
+
expect(res.status, req(ID, DOC, `a host MUST NOT reject a tag on format and MUST accept free-form metadata — got ${res.status} ${readErrorCode(res.json) ?? ''}`.trim())).toBe(201);
|
|
61
|
+
const runId = (res.json as { runId?: unknown } | null)?.runId;
|
|
62
|
+
if (typeof runId !== 'string') return softSkip('blocked', 'the 201 carried no runId');
|
|
63
|
+
const snap = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}`));
|
|
64
|
+
if (snap === null || snap.status !== 200) return softSkip('blocked', `GET /runs/{runId} answered ${snap?.status ?? 'no response'} for the run just created`);
|
|
65
|
+
const body = snap.json as { tags?: unknown; metadata?: unknown } | null;
|
|
66
|
+
expect(body?.tags, req(ID, DOC, 'tags MUST surface unchanged on RunSnapshot')).toEqual(ODD_TAGS);
|
|
67
|
+
expect(body?.metadata, req(ID, DOC, 'metadata MUST be persisted and surface unchanged on RunSnapshot')).toEqual(METADATA);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `spec/v2/core/runs.md` §Pause and resume (suite 2.0.0, target major 2).
|
|
3
|
+
*
|
|
4
|
+
* Legs:
|
|
5
|
+
* 1. unaided: `:pause` and `:resume` on a terminal run MUST answer 409 with an
|
|
6
|
+
* error envelope. The prose names no code; only `run_terminal` is
|
|
7
|
+
* registered (finding 2). The leg asserts 409 + a code and names none. On
|
|
8
|
+
* its own this leg is one-sided — a host that answers 409 to everything
|
|
9
|
+
* passes it — so its recorded reason says so when leg 2 cannot run.
|
|
10
|
+
* 2. on `conformance-delay`: pause → `202 { runId, status: paused }`; pause
|
|
11
|
+
* again → 409; resume → `202 { runId, status: running }`; the log carries
|
|
12
|
+
* `run.paused` then `run.resumed`. The run is cancelled at the end so
|
|
13
|
+
* nothing is left running.
|
|
14
|
+
*
|
|
15
|
+
* @see spec/v2/core/runs.md §Pause and resume
|
|
16
|
+
* @see conformance/fixtures.md §conformance-delay
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, it, expect } from 'vitest';
|
|
20
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
21
|
+
import { v2Discovery } from '../lib/v2.js';
|
|
22
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
23
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
24
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
25
|
+
import { req } from '../lib/requirement-ids.js';
|
|
26
|
+
|
|
27
|
+
const ID = 'openwop.requirement.0170.run-pause-resume';
|
|
28
|
+
const DOC = 'spec/v2/core/runs.md §Pause and resume';
|
|
29
|
+
const NOOP = 'conformance-noop';
|
|
30
|
+
const DELAY = 'conformance-delay';
|
|
31
|
+
const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
|
|
32
|
+
|
|
33
|
+
async function discovery(): Promise<Record<string, unknown> | null> { try { return await v2Discovery(); } catch { return null; } }
|
|
34
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
|
|
35
|
+
const enc = (id: string): string => encodeURIComponent(id);
|
|
36
|
+
async function create(body: Record<string, unknown>): Promise<{ runId: string } | { reason: string }> {
|
|
37
|
+
const res = await http(() => driver.post('/runs', body));
|
|
38
|
+
if (res === null) return { reason: 'POST /runs unreachable (fetch failed)' };
|
|
39
|
+
const runId = (res.json as { runId?: unknown } | null)?.runId;
|
|
40
|
+
if (res.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs answered ${res.status} ${readErrorCode(res.json) ?? ''} — create refused`.trim() };
|
|
41
|
+
return { runId };
|
|
42
|
+
}
|
|
43
|
+
async function statusOf(runId: string): Promise<string | null> {
|
|
44
|
+
const res = await http(() => driver.get(`/runs/${enc(runId)}`));
|
|
45
|
+
return res?.status === 200 ? String((res.json as { status?: unknown } | null)?.status ?? '') : null;
|
|
46
|
+
}
|
|
47
|
+
async function waitFor(runId: string, pred: (s: string) => boolean, ms: number): Promise<string | null> {
|
|
48
|
+
const t0 = Date.now(); let last: string | null = null;
|
|
49
|
+
while (Date.now() - t0 < ms) { last = await statusOf(runId); if (last !== null && pred(last)) return last; await new Promise((r) => setTimeout(r, 250)); }
|
|
50
|
+
return last;
|
|
51
|
+
}
|
|
52
|
+
async function eventTypes(runId: string): Promise<string[] | null> {
|
|
53
|
+
const res = await http(() => driver.get(`/runs/${enc(runId)}/events/poll?timeout=1`));
|
|
54
|
+
const events = (res?.json as { events?: unknown } | null)?.events;
|
|
55
|
+
return res?.status === 200 && Array.isArray(events) ? events.map((e) => String((e as { type?: unknown }).type)) : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('v2 run-pause-resume (runs.md §Pause and resume)', () => {
|
|
59
|
+
it('pause and resume on a terminal run answer 409 with an error envelope', async () => {
|
|
60
|
+
if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
|
|
61
|
+
const c = await create({ workflowId: NOOP });
|
|
62
|
+
if ('reason' in c) return softSkip('blocked', c.reason);
|
|
63
|
+
const s = await waitFor(c.runId, (x) => TERMINAL.has(x), 10_000);
|
|
64
|
+
if (s === null || !TERMINAL.has(s)) return softSkip('blocked', `the noop run did not reach a terminal status within 10 s (last: ${s ?? 'unreadable'})`);
|
|
65
|
+
const pause = await http(() => driver.post(`/runs/${enc(c.runId)}:pause`, {}));
|
|
66
|
+
if (pause === null) return softSkip('blocked', 'POST /runs/{runId}:pause unreachable (fetch failed)');
|
|
67
|
+
if (pause.status === 404) return softSkip('blocked', 'POST /runs/{runId}:pause answered 404 — pauseRun is a core operation (runs.md §Surface) and is not mounted');
|
|
68
|
+
expect(pause.status, req(ID, DOC, `pause on a terminal (${s}) run MUST answer 409 — got ${pause.status} ${readErrorCode(pause.json) ?? ''}`.trim())).toBe(409);
|
|
69
|
+
expect(readErrorCode(pause.json), req(ID, DOC, `a pause refused because the run is terminal MUST carry run_terminal (got ${String(readErrorCode(pause.json))})`)).toBe('run_terminal');
|
|
70
|
+
const resume = await http(() => driver.post(`/runs/${enc(c.runId)}:resume`, {}));
|
|
71
|
+
if (resume === null) return softSkip('blocked', 'POST /runs/{runId}:resume unreachable (fetch failed)');
|
|
72
|
+
expect(resume.status, req(ID, DOC, `resume on a run that is not paused MUST answer 409 — got ${resume.status} ${readErrorCode(resume.json) ?? ''}`.trim())).toBe(409);
|
|
73
|
+
expect(readErrorCode(resume.json), req(ID, DOC, `a resume refused because the run is terminal MUST carry run_terminal (got ${String(readErrorCode(resume.json))})`)).toBe('run_terminal');
|
|
74
|
+
if (!isFixtureAdvertised(DELAY)) return softSkip('inapplicable', `${DELAY} fixture not advertised — the positive pause/resume leg cannot run, so the two 409s above are witnessed without their control (a host answering 409 to everything would pass them)`);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('a running run pauses (202 paused), refuses a second pause (409), resumes (202 running), and the log carries run.paused then run.resumed', async () => {
|
|
78
|
+
if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
|
|
79
|
+
if (!isFixtureAdvertised(DELAY)) return softSkip('inapplicable', `${DELAY} fixture not advertised — no run stays running long enough to pause`);
|
|
80
|
+
const c = await create({ workflowId: DELAY, inputs: { delayMs: 15_000 } });
|
|
81
|
+
if ('reason' in c) return softSkip('blocked', c.reason);
|
|
82
|
+
const running = await waitFor(c.runId, (x) => x === 'running' || TERMINAL.has(x), 5_000);
|
|
83
|
+
if (running !== 'running') return softSkip('blocked', `${DELAY} did not reach running within 5 s (last: ${running ?? 'unreadable'})`);
|
|
84
|
+
try {
|
|
85
|
+
const pause = await http(() => driver.post(`/runs/${enc(c.runId)}:pause`, { reason: 'conformance', drainPolicy: 'immediate' }));
|
|
86
|
+
if (pause === null) return softSkip('blocked', 'POST /runs/{runId}:pause unreachable (fetch failed)');
|
|
87
|
+
if (pause.status === 404) return softSkip('blocked', 'POST /runs/{runId}:pause answered 404 — not mounted');
|
|
88
|
+
expect(pause.status, req(ID, DOC, `pauseRun on a running run MUST answer 202 — got ${pause.status} ${readErrorCode(pause.json) ?? ''}`.trim())).toBe(202);
|
|
89
|
+
const pb = pause.json as { runId?: unknown; status?: unknown } | null;
|
|
90
|
+
expect(pb?.runId, req(ID, DOC, 'the 202 MUST carry the runId')).toBe(c.runId);
|
|
91
|
+
expect(pb?.status, req(ID, DOC, `the 202 MUST carry status paused (got ${String(pb?.status)})`)).toBe('paused');
|
|
92
|
+
const again = await http(() => driver.post(`/runs/${enc(c.runId)}:pause`, {}));
|
|
93
|
+
expect(again?.status ?? null, req(ID, DOC, `a second pause on a paused run MUST answer 409 — got ${again?.status ?? 'no response'}`)).toBe(409);
|
|
94
|
+
expect(readErrorCode(again?.json), req(ID, DOC, `a pause refused on a non-terminal run MUST carry run_state_conflict (got ${String(readErrorCode(again?.json))})`)).toBe('run_state_conflict');
|
|
95
|
+
// `details` sits at the envelope ROOT (schemas/v2/error-envelope.schema.json), not under `error`.
|
|
96
|
+
const runStatus = (again?.json as { details?: { runStatus?: unknown } } | null)?.details?.runStatus;
|
|
97
|
+
expect(runStatus, req(ID, DOC, `run_state_conflict MUST carry details.runStatus naming the status that refused it (got ${String(runStatus)})`)).toBe('paused');
|
|
98
|
+
const resume = await http(() => driver.post(`/runs/${enc(c.runId)}:resume`, { reason: 'conformance' }));
|
|
99
|
+
expect(resume?.status ?? null, req(ID, DOC, `resumeRun on a paused run MUST answer 202 — got ${resume?.status ?? 'no response'} ${readErrorCode(resume?.json) ?? ''}`.trim())).toBe(202);
|
|
100
|
+
const rb = resume?.json as { runId?: unknown; status?: unknown } | null;
|
|
101
|
+
expect(rb?.status, req(ID, DOC, `the 202 MUST carry status running (got ${String(rb?.status)})`)).toBe('running');
|
|
102
|
+
const types = await eventTypes(c.runId);
|
|
103
|
+
const p = types?.indexOf('run.paused') ?? -1; const r = types?.indexOf('run.resumed') ?? -1;
|
|
104
|
+
expect(p >= 0 && r > p, req(ID, 'spec/v2/core/events.md §run.paused', `the log MUST carry run.paused then run.resumed (types: ${(types ?? []).join(', ')})`)).toBe(true);
|
|
105
|
+
// An immediate pause cuts the executing attempt BETWEEN events: no terminal
|
|
106
|
+
// node event for it, ever — a node.failed here makes a replay fold a
|
|
107
|
+
// failure the source never had (runs.md §Pause and resume; replay.md).
|
|
108
|
+
const between = (types ?? []).slice(p + 1, r);
|
|
109
|
+
expect(between.some((t) => t === 'node.failed' || t === 'node.completed' || t === 'node.cancelled'), req(ID, DOC, `with drainPolicy immediate the interrupted attempt MUST NOT record a terminal node event (between run.paused and run.resumed: ${between.join(', ') || 'nothing'})`)).toBe(false);
|
|
110
|
+
const poll = await http(() => driver.get(`/runs/${enc(c.runId)}/events/poll?timeout=1`));
|
|
111
|
+
const paused = ((poll?.json as { events?: Array<{ type?: unknown; payload?: { drainPolicy?: unknown } }> } | null)?.events ?? []).find((e) => e.type === 'run.paused');
|
|
112
|
+
expect(paused?.payload?.drainPolicy, req(ID, DOC, `run.paused's payload MUST echo the request's drainPolicy word (got ${String(paused?.payload?.drainPolicy)})`)).toBe('immediate');
|
|
113
|
+
} finally {
|
|
114
|
+
await http(() => driver.post(`/runs/${enc(c.runId)}/cancel`, { reason: 'conformance cleanup' }));
|
|
115
|
+
}
|
|
116
|
+
}, 30_000);
|
|
117
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `spec/v2/core/runs.md` §Snapshot — the conditional GET (suite 2.0.0, target
|
|
3
|
+
* major 2; unaided; one run created).
|
|
4
|
+
*
|
|
5
|
+
* The 200 SHOULD carry a strong `ETag`; WHEN PRESENT, a matching
|
|
6
|
+
* `If-None-Match` MUST receive `304` with no body. The SHOULD gates the MUST:
|
|
7
|
+
* a host that sends no ETag records `inapplicable`. Every response carries
|
|
8
|
+
* `OpenWOP-Version` (versioning.md §1.4), the 304 included.
|
|
9
|
+
*
|
|
10
|
+
* Control: a non-matching `If-None-Match` MUST receive 200 with the body — a
|
|
11
|
+
* host that answers 304 to any conditional request fails here. `headers.md`
|
|
12
|
+
* scopes `If-None-Match` to the discovery document; runs.md applies it to the
|
|
13
|
+
* snapshot with a MUST (finding 5, filed).
|
|
14
|
+
*
|
|
15
|
+
* @see spec/v2/core/runs.md §Snapshot
|
|
16
|
+
* @see spec/v2/core/versioning.md §1.4
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, it, expect } from 'vitest';
|
|
20
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
21
|
+
import { v2Discovery } from '../lib/v2.js';
|
|
22
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
23
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
24
|
+
import { req } from '../lib/requirement-ids.js';
|
|
25
|
+
|
|
26
|
+
const ID = 'openwop.requirement.0170.run-snapshot-etag';
|
|
27
|
+
const DOC = 'spec/v2/core/runs.md §Snapshot';
|
|
28
|
+
const NOOP = 'conformance-noop';
|
|
29
|
+
|
|
30
|
+
async function discovery(): Promise<Record<string, unknown> | null> { try { return await v2Discovery(); } catch { return null; } }
|
|
31
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
|
|
32
|
+
|
|
33
|
+
describe('v2 run-snapshot-etag (runs.md §Snapshot)', () => {
|
|
34
|
+
it('a matching If-None-Match receives 304 with no body and the version header; a non-matching one receives 200', async () => {
|
|
35
|
+
if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
|
|
36
|
+
const created = await http(() => driver.post('/runs', { workflowId: NOOP }));
|
|
37
|
+
if (created === null) return softSkip('blocked', 'POST /runs unreachable (fetch failed)');
|
|
38
|
+
const runId = (created.json as { runId?: unknown } | null)?.runId;
|
|
39
|
+
if (created.status !== 201 || typeof runId !== 'string') return softSkip('blocked', `POST /runs answered ${created.status} ${readErrorCode(created.json) ?? ''} — create refused`.trim());
|
|
40
|
+
const path = `/runs/${encodeURIComponent(runId)}`;
|
|
41
|
+
// Let the noop settle so the tag is stable across the two conditional reads.
|
|
42
|
+
await new Promise((r) => setTimeout(r, 1_000));
|
|
43
|
+
const first = await http(() => driver.get(path));
|
|
44
|
+
if (first === null || first.status !== 200) return softSkip('blocked', `GET /runs/{runId} answered ${first?.status ?? 'no response'}`);
|
|
45
|
+
const etag = first.headers.get('etag');
|
|
46
|
+
if (!etag) return softSkip('inapplicable', 'the snapshot carries no ETag — runs.md §Snapshot makes the ETag a SHOULD; the 304 rule applies only when it is present');
|
|
47
|
+
const hit = await http(() => driver.get(path, { headers: { 'If-None-Match': etag } }));
|
|
48
|
+
if (hit === null) return softSkip('blocked', 'conditional GET unreachable (fetch failed)');
|
|
49
|
+
expect(hit.status, req(ID, DOC, `a request whose If-None-Match matches the ETag MUST receive 304 — got ${hit.status}`)).toBe(304);
|
|
50
|
+
expect(hit.text.length, req(ID, DOC, `the 304 MUST carry no body (got ${hit.text.length} byte(s))`)).toBe(0);
|
|
51
|
+
expect(hit.headers.get('openwop-version'), req(ID, 'spec/v2/core/versioning.md §1.4', 'every response carries OpenWOP-Version, the 304 included')).not.toBeNull();
|
|
52
|
+
const miss = await http(() => driver.get(path, { headers: { 'If-None-Match': '"openwop-conformance-no-such-tag"' } }));
|
|
53
|
+
if (miss === null) return softSkip('blocked', 'conditional GET unreachable (fetch failed)');
|
|
54
|
+
expect(miss.status, req(ID, DOC, `a non-matching If-None-Match MUST receive 200 with the body — got ${miss.status} (a host answering 304 to any conditional request is not honouring the tag)`)).toBe(200);
|
|
55
|
+
expect((miss.json as { runId?: unknown } | null)?.runId, req(ID, DOC, 'the 200 body is the snapshot')).toBe(runId);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `spec/v2/core/events.md` §SSE frames — `Last-Event-ID` resumes every mode
|
|
3
|
+
* (suite 2.0.0, target major 2; unaided; one run created).
|
|
4
|
+
*
|
|
5
|
+
* The host MUST look up the event with that sequence, MUST begin at the next
|
|
6
|
+
* sequence, and MUST NOT re-emit the resumption point; in `values` mode the
|
|
7
|
+
* resumption MUST emit a `state.snapshot` first. Frames carry `id:` = the
|
|
8
|
+
* sequence (0-based: the first frame's id is `0`, so ids are tested for
|
|
9
|
+
* presence and parsed, never for truthiness).
|
|
10
|
+
*
|
|
11
|
+
* Legs and controls, all on one completed noop run:
|
|
12
|
+
* 1. the full stream: ≥1 frame, numeric ids, closed by the server;
|
|
13
|
+
* 2. resume at the first id: the first resumed id is greater, the resumption
|
|
14
|
+
* id is absent, and the resumed set equals the full set minus every id at
|
|
15
|
+
* or below it — one assertion catches loss and duplication;
|
|
16
|
+
* 3. resume at the last id: zero frames, closed by server (a host that ignores
|
|
17
|
+
* the header re-sends all N against an expected 0; a host that closes
|
|
18
|
+
* every stream empty fails leg 1);
|
|
19
|
+
* 4. if `values` is served: a `values` resume's first frame is
|
|
20
|
+
* `event: state.snapshot` (a frame name, not a type — §SSE frames).
|
|
21
|
+
*
|
|
22
|
+
* @see spec/v2/core/events.md §SSE frames
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { describe, it, expect } from 'vitest';
|
|
26
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
27
|
+
import { subscribe, type SseEvent } from '../lib/sse.js';
|
|
28
|
+
import { v2Discovery } from '../lib/v2.js';
|
|
29
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
30
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
31
|
+
import { req } from '../lib/requirement-ids.js';
|
|
32
|
+
|
|
33
|
+
const ID = 'openwop.requirement.0171.sse-last-event-id';
|
|
34
|
+
const DOC = 'spec/v2/core/events.md §SSE frames';
|
|
35
|
+
const NOOP = 'conformance-noop';
|
|
36
|
+
const V2 = { 'OpenWOP-Version': '2.0' };
|
|
37
|
+
|
|
38
|
+
async function discovery(): Promise<Record<string, unknown> | null> { try { return await v2Discovery(); } catch { return null; } }
|
|
39
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
|
|
40
|
+
const enc = (id: string): string => encodeURIComponent(id);
|
|
41
|
+
/** The frame's sequence, or null when the frame carries no id — `0` is a valid id. */
|
|
42
|
+
function seqOf(f: SseEvent): number | null { if (f.id === null || f.id === '') return null; const n = Number.parseInt(f.id, 10); return Number.isFinite(n) ? n : null; }
|
|
43
|
+
|
|
44
|
+
async function createSettled(): Promise<{ runId: string } | { reason: string }> {
|
|
45
|
+
const res = await http(() => driver.post('/runs', { workflowId: NOOP }));
|
|
46
|
+
if (res === null) return { reason: 'POST /runs unreachable (fetch failed)' };
|
|
47
|
+
const runId = (res.json as { runId?: unknown } | null)?.runId;
|
|
48
|
+
if (res.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs answered ${res.status} ${readErrorCode(res.json) ?? ''}`.trim() };
|
|
49
|
+
const t0 = Date.now();
|
|
50
|
+
while (Date.now() - t0 < 10_000) {
|
|
51
|
+
const s = await http(() => driver.get(`/runs/${enc(runId)}`));
|
|
52
|
+
if (s?.status === 200 && ['completed', 'failed', 'cancelled'].includes(String((s.json as { status?: unknown }).status))) return { runId };
|
|
53
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
54
|
+
}
|
|
55
|
+
return { reason: 'the noop run did not settle within 10 s' };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('v2 sse-last-event-id (events.md §SSE frames)', () => {
|
|
59
|
+
it('Last-Event-ID resumes at the next sequence, never re-emits the resumption point, loses nothing, and at the last id yields nothing', async () => {
|
|
60
|
+
if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
|
|
61
|
+
const c = await createSettled(); if ('reason' in c) return softSkip('blocked', c.reason);
|
|
62
|
+
const path = `/runs/${enc(c.runId)}/events?streamMode=debug`;
|
|
63
|
+
const full = await subscribe(path, { timeoutMs: 8_000, extraHeaders: V2 });
|
|
64
|
+
if (full.status === 404) return softSkip('blocked', 'GET /runs/{runId}/events answered 404 — streamRunEvents is a core operation and is not mounted');
|
|
65
|
+
expect(full.status, req(ID, DOC, `the stream MUST answer 200 — got ${full.status}`)).toBe(200);
|
|
66
|
+
expect(full.events.length, req(ID, DOC, 'the completed log MUST stream as at least one frame')).toBeGreaterThan(0);
|
|
67
|
+
expect(full.closedBy, req(ID, DOC, `the host MUST close after the terminal event (closed by ${full.closedBy})`)).toBe('server');
|
|
68
|
+
const ids = full.events.map(seqOf);
|
|
69
|
+
expect(ids.every((n) => n !== null), req(ID, DOC, `every frame MUST carry id: = its sequence (frames without a parseable id: ${ids.filter((n) => n === null).length} of ${ids.length})`)).toBe(true);
|
|
70
|
+
const seqs = ids as number[];
|
|
71
|
+
expect([...seqs].sort((a, b) => a - b), req(ID, DOC, 'frames MUST arrive in log order')).toEqual(seqs);
|
|
72
|
+
const first = seqs[0]!; const last = seqs[seqs.length - 1]!;
|
|
73
|
+
|
|
74
|
+
const resumed = await subscribe(path, { timeoutMs: 8_000, extraHeaders: V2, lastEventId: String(first) });
|
|
75
|
+
expect(resumed.status, req(ID, DOC, `a resume with Last-Event-ID MUST answer 200 — got ${resumed.status}`)).toBe(200);
|
|
76
|
+
const rseqs = resumed.events.map(seqOf).filter((n): n is number => n !== null);
|
|
77
|
+
expect(rseqs.includes(first), req(ID, DOC, `the host MUST NOT re-emit the resumption point (Last-Event-ID ${first} re-emitted)`)).toBe(false);
|
|
78
|
+
expect(rseqs, req(ID, DOC, `the host MUST begin at the next sequence and lose nothing: resumed ids (${rseqs.join(',')}) MUST equal the full set minus every id ≤ ${first} (${seqs.filter((n) => n > first).join(',')})`)).toEqual(seqs.filter((n) => n > first));
|
|
79
|
+
|
|
80
|
+
const atEnd = await subscribe(path, { timeoutMs: 8_000, extraHeaders: V2, lastEventId: String(last) });
|
|
81
|
+
expect(atEnd.status, req(ID, DOC, `a resume at the terminal sequence MUST answer 200 — got ${atEnd.status}`)).toBe(200);
|
|
82
|
+
expect(atEnd.events.length, req(ID, DOC, `a resume at the last sequence (${last}) MUST emit nothing and close — a host ignoring the header re-sends all ${seqs.length} (got ${atEnd.events.length})`)).toBe(0);
|
|
83
|
+
expect(atEnd.closedBy, req(ID, DOC, 'the host MUST close the empty resume itself')).toBe('server');
|
|
84
|
+
|
|
85
|
+
// values: resumption MUST emit a state.snapshot first — only where the host serves values.
|
|
86
|
+
const probe = await http(() => driver.get(`/runs/${enc(c.runId)}/events?streamMode=bogus`, { headers: { Accept: 'text/event-stream' } }));
|
|
87
|
+
const supported = (probe?.json as { details?: { supported?: unknown } } | null)?.details?.supported;
|
|
88
|
+
if (!Array.isArray(supported) || !supported.includes('values')) return softSkip('inapplicable', `values mode not served (details.supported: ${JSON.stringify(supported)}) — the state.snapshot-first resumption leg does not apply`);
|
|
89
|
+
const values = await subscribe(`/runs/${enc(c.runId)}/events?streamMode=values`, { timeoutMs: 8_000, extraHeaders: V2, lastEventId: String(first) });
|
|
90
|
+
expect(values.status, req(ID, DOC, `a values resume MUST answer 200 — got ${values.status}`)).toBe(200);
|
|
91
|
+
expect(values.events[0]?.event ?? null, req(ID, DOC, `in values mode a resumption MUST emit a state.snapshot first (first frame: ${values.events[0]?.event ?? 'none'})`)).toBe('state.snapshot');
|
|
92
|
+
}, 45_000);
|
|
93
|
+
});
|