@openwop/openwop-conformance 2.0.0-rc.9 → 2.0.1
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 +57 -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 +3 -3
- package/requirement-aliases.json +4 -1
- package/requirements.json +1030 -57
- 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/v2-webhook-durable-delivery.test.ts +58 -7
- package/src/scenarios/version-negotiation.test.ts +25 -3
- package/src/setup.ts +104 -50
|
@@ -2,17 +2,31 @@
|
|
|
2
2
|
* Track 13: operator-driven pause/resume (rest-endpoints.md v1.1).
|
|
3
3
|
*
|
|
4
4
|
* Exercises the `POST /v1/runs/{runId}:pause` and `:resume` endpoints
|
|
5
|
-
* against a long-running fixture (`conformance-
|
|
5
|
+
* against a long-running fixture (`conformance-cancellable` or `conformance-delay`;
|
|
6
|
+
* both take `inputs.delayMs` — conformance/fixtures.md).
|
|
6
7
|
*
|
|
7
8
|
* Verifies:
|
|
8
|
-
* 1. :pause on a running run transitions to `paused
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* 1. :pause (immediate) on a running run transitions to `paused`; :resume
|
|
10
|
+
* returns it to `running` and it reaches a terminal.
|
|
11
|
+
* 2. :resume on a non-paused run returns 409 with details.runStatus.
|
|
12
|
+
* 3. :pause on an already-paused run returns 409 (details.runStatus paused)
|
|
13
|
+
* without a matching Idempotency-Key, and 202 with the cached response
|
|
14
|
+
* when the same key is sent — rest-endpoints.md §:pause Idempotency.
|
|
15
|
+
* 4. :pause on a terminal run returns 409 with details.runStatus.
|
|
16
|
+
* 5. drain-current-node lets the executing node reach a terminal FIRST:
|
|
17
|
+
* node.completed precedes run.paused in the log.
|
|
12
18
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
19
|
+
* rc.53 (2026-09-05): a tier-1 host un-skipping this file found it
|
|
20
|
+
* contradicting rest-endpoints.md in three places — it expected 200/202 on a
|
|
21
|
+
* second :pause (the prose says 409 unless the Idempotency-Key matches), it
|
|
22
|
+
* asserted an error code `conflict` that v1 never named (v1 mandates 409 +
|
|
23
|
+
* details.runStatus and names only run_terminal for a terminal run), and it
|
|
24
|
+
* paused a 30 s node under drain-current-node and polled 10 s for `paused`
|
|
25
|
+
* (drain lets the node finish; a host that yields mid-node under drain is the
|
|
26
|
+
* non-conforming one). It also sent `delaySeconds`, a field the fixtures do
|
|
27
|
+
* not define. v1 is frozen; the scenario was the defect in every case.
|
|
28
|
+
*
|
|
29
|
+
* Capability gating: skips when no long-running fixture is advertised.
|
|
16
30
|
*
|
|
17
31
|
* @see spec/v1/rest-endpoints.md §pause/resume
|
|
18
32
|
*/
|
|
@@ -31,35 +45,49 @@ const FIXTURE =
|
|
|
31
45
|
null;
|
|
32
46
|
|
|
33
47
|
const SKIP = !FIXTURE;
|
|
48
|
+
const DOC_PAUSE = 'rest-endpoints.md POST /v1/runs/{runId}:pause';
|
|
49
|
+
const DOC_RESUME = 'rest-endpoints.md POST /v1/runs/{runId}:resume';
|
|
50
|
+
|
|
51
|
+
async function cancel(runId: string): Promise<void> {
|
|
52
|
+
await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, { reason: 'conformance-cleanup' });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function eventTypes(runId: string): Promise<string[]> {
|
|
56
|
+
const res = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events/poll?timeout=1`);
|
|
57
|
+
const events = (res.json as { events?: unknown } | null)?.events;
|
|
58
|
+
return Array.isArray(events) ? events.map((e) => String((e as { type?: unknown }).type)) : [];
|
|
59
|
+
}
|
|
34
60
|
|
|
35
61
|
describe.skipIf(SKIP)('pause/resume: running → paused → running → terminal', () => {
|
|
36
62
|
it('pause transitions to paused; resume returns the run to running', async () => {
|
|
37
63
|
const create = await driver.post('/v1/runs', {
|
|
38
64
|
workflowId: FIXTURE!,
|
|
39
|
-
|
|
65
|
+
// 3 s, not 30 s: the resumed node.started is a FRESH attempt (runs.md
|
|
66
|
+
// §Pause and resume; rc.52), so a 30 s node resumed at ~60 ms completes
|
|
67
|
+
// at ~30.2 s — past the suite's 30 s test timeout by construction on
|
|
68
|
+
// any conforming host. rc.53 shipped 30 s and a tier-1 host measured
|
|
69
|
+
// exactly that. The pause is `immediate`, issued within ~60 ms of
|
|
70
|
+
// `running`, so 3 s is ample for the pause to land first.
|
|
71
|
+
inputs: { delayMs: 3_000 },
|
|
40
72
|
});
|
|
41
73
|
expect(create.status).toBe(201);
|
|
42
74
|
const runId = (create.json as { runId: string }).runId;
|
|
43
75
|
|
|
44
76
|
await pollUntilStatus(runId, 'running', { timeoutMs: 10_000 });
|
|
45
77
|
|
|
78
|
+
// `immediate` snapshots between events, so `paused` is observable within
|
|
79
|
+
// milliseconds. Under drain-current-node the node would finish first
|
|
80
|
+
// (rest-endpoints.md); that semantic is witnessed by the drain leg below.
|
|
46
81
|
const pause = await driver.post(`/v1/runs/${encodeURIComponent(runId)}:pause`, {
|
|
47
82
|
reason: 'conformance-test',
|
|
48
|
-
drainPolicy: '
|
|
83
|
+
drainPolicy: 'immediate',
|
|
49
84
|
});
|
|
50
85
|
if (pause.status === 404) {
|
|
51
|
-
|
|
52
|
-
// eslint-disable-next-line no-console
|
|
53
|
-
console.warn(
|
|
54
|
-
'[pause-resume] host returned 404 for :pause — endpoint not implemented; skipping rest',
|
|
55
|
-
);
|
|
56
|
-
await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
|
|
57
|
-
reason: 'conformance-cleanup',
|
|
58
|
-
});
|
|
86
|
+
await cancel(runId);
|
|
59
87
|
return softSkip('blocked', 'precondition not met — `pause.status === 404` returned early ([pause-resume] host returned 404 for :pause — endpoint not implemented; skipping rest) (seam, prior step, or fixture unavailable)');
|
|
60
88
|
}
|
|
61
|
-
expect(pause.status, req('openwop.it.pause-resume.pause-transitions-to-paused-resume-returns-the-run-to-running',
|
|
62
|
-
|
|
89
|
+
expect(pause.status, req('openwop.it.pause-resume.pause-transitions-to-paused-resume-returns-the-run-to-running',
|
|
90
|
+
DOC_PAUSE,
|
|
63
91
|
':pause MUST return 202 on a pausable run',
|
|
64
92
|
)).toBe(202);
|
|
65
93
|
|
|
@@ -68,8 +96,8 @@ describe.skipIf(SKIP)('pause/resume: running → paused → running → terminal
|
|
|
68
96
|
const resume = await driver.post(`/v1/runs/${encodeURIComponent(runId)}:resume`, {
|
|
69
97
|
reason: 'conformance-test',
|
|
70
98
|
});
|
|
71
|
-
expect(resume.status, req('openwop.it.pause-resume.pause-transitions-to-paused-resume-returns-the-run-to-running',
|
|
72
|
-
|
|
99
|
+
expect(resume.status, req('openwop.it.pause-resume.pause-transitions-to-paused-resume-returns-the-run-to-running',
|
|
100
|
+
DOC_RESUME,
|
|
73
101
|
':resume MUST return 202 on a paused run',
|
|
74
102
|
)).toBe(202);
|
|
75
103
|
|
|
@@ -82,7 +110,7 @@ describe.skipIf(SKIP)('pause/resume: :resume on a non-paused run returns 409', (
|
|
|
82
110
|
it('resuming a running (not paused) run returns 409 with details.runStatus', async () => {
|
|
83
111
|
const create = await driver.post('/v1/runs', {
|
|
84
112
|
workflowId: FIXTURE!,
|
|
85
|
-
inputs: {
|
|
113
|
+
inputs: { delayMs: 30_000 },
|
|
86
114
|
});
|
|
87
115
|
expect(create.status).toBe(201);
|
|
88
116
|
const runId = (create.json as { runId: string }).runId;
|
|
@@ -91,61 +119,85 @@ describe.skipIf(SKIP)('pause/resume: :resume on a non-paused run returns 409', (
|
|
|
91
119
|
|
|
92
120
|
const resume = await driver.post(`/v1/runs/${encodeURIComponent(runId)}:resume`, {});
|
|
93
121
|
if (resume.status === 404) {
|
|
94
|
-
await
|
|
95
|
-
reason: 'conformance-cleanup',
|
|
96
|
-
});
|
|
122
|
+
await cancel(runId);
|
|
97
123
|
return softSkip('blocked', 'precondition not met — `resume.status === 404` returned early (seam, prior step, or fixture unavailable)');
|
|
98
124
|
}
|
|
99
|
-
expect(resume.status, req('openwop.it.pause-resume.resuming-a-running-not-paused-run-returns-409-with-details-runstatus',
|
|
100
|
-
|
|
125
|
+
expect(resume.status, req('openwop.it.pause-resume.resuming-a-running-not-paused-run-returns-409-with-details-runstatus',
|
|
126
|
+
DOC_RESUME,
|
|
101
127
|
':resume on a non-paused run MUST return 409',
|
|
102
128
|
)).toBe(409);
|
|
103
129
|
|
|
130
|
+
// v1 mandates the status and details.runStatus; it names no code for
|
|
131
|
+
// this case (the registry is open — error-envelope.schema.json), so
|
|
132
|
+
// none is asserted. `run_state_conflict` is the v2 answer (runs.md).
|
|
104
133
|
const body = resume.json as { error?: string; details?: { runStatus?: string } };
|
|
105
|
-
expect(body.
|
|
106
|
-
|
|
134
|
+
expect(typeof body.details?.runStatus, req('openwop.it.pause-resume.resuming-a-running-not-paused-run-returns-409-with-details-runstatus',
|
|
135
|
+
DOC_RESUME,
|
|
136
|
+
'the 409 MUST carry details.runStatus with the actual state',
|
|
137
|
+
)).toBe('string');
|
|
107
138
|
|
|
108
|
-
await
|
|
109
|
-
reason: 'conformance-cleanup',
|
|
110
|
-
});
|
|
139
|
+
await cancel(runId);
|
|
111
140
|
});
|
|
112
141
|
});
|
|
113
142
|
|
|
114
|
-
describe.skipIf(SKIP)('pause/resume: pause is
|
|
115
|
-
it(':pause on an already-paused run
|
|
143
|
+
describe.skipIf(SKIP)('pause/resume: a second :pause is 409 without a matching Idempotency-Key and 202 with one', () => {
|
|
144
|
+
it(':pause on an already-paused run returns 409 with details.runStatus paused, unless the request carries the original Idempotency-Key', async () => {
|
|
116
145
|
const create = await driver.post('/v1/runs', {
|
|
117
146
|
workflowId: FIXTURE!,
|
|
118
|
-
inputs: {
|
|
147
|
+
inputs: { delayMs: 30_000 },
|
|
119
148
|
});
|
|
120
149
|
expect(create.status).toBe(201);
|
|
121
150
|
const runId = (create.json as { runId: string }).runId;
|
|
122
151
|
await pollUntilStatus(runId, 'running', { timeoutMs: 10_000 });
|
|
123
152
|
|
|
124
|
-
const first = await driver.post(`/v1/runs/${encodeURIComponent(runId)}:pause`, {});
|
|
153
|
+
const first = await driver.post(`/v1/runs/${encodeURIComponent(runId)}:pause`, { drainPolicy: 'immediate' });
|
|
125
154
|
if (first.status === 404) {
|
|
126
|
-
await
|
|
127
|
-
reason: 'conformance-cleanup',
|
|
128
|
-
});
|
|
155
|
+
await cancel(runId);
|
|
129
156
|
return softSkip('blocked', 'precondition not met — `first.status === 404` returned early (seam, prior step, or fixture unavailable)');
|
|
130
157
|
}
|
|
131
|
-
expect(
|
|
158
|
+
expect(first.status, req('openwop.it.pause-resume.pause-on-an-already-paused-run-returns-409-with-details-runstatus-paused-unless',
|
|
159
|
+
DOC_PAUSE,
|
|
160
|
+
':pause MUST return 202 on a pausable run',
|
|
161
|
+
)).toBe(202);
|
|
132
162
|
await pollUntilStatus(runId, 'paused', { timeoutMs: 10_000 });
|
|
133
163
|
|
|
134
|
-
//
|
|
135
|
-
// already paused
|
|
136
|
-
//
|
|
164
|
+
// rest-endpoints.md §:pause Idempotency: "a :pause against a run that is
|
|
165
|
+
// already paused returns 409 (with the existing pause's pausedAt in
|
|
166
|
+
// details) unless the request carries Idempotency-Key matching the
|
|
167
|
+
// original pause, in which case the host returns 202 with the cached
|
|
168
|
+
// response." Until rc.53 this leg asserted 200/202 here, citing an
|
|
169
|
+
// "additive contract" the prose does not contain.
|
|
137
170
|
const second = await driver.post(`/v1/runs/${encodeURIComponent(runId)}:pause`, {});
|
|
138
|
-
expect(
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
171
|
+
expect(second.status, req('openwop.it.pause-resume.pause-on-an-already-paused-run-returns-409-with-details-runstatus-paused-unless',
|
|
172
|
+
DOC_PAUSE,
|
|
173
|
+
':pause on an already-paused run without a matching Idempotency-Key MUST return 409',
|
|
174
|
+
)).toBe(409);
|
|
175
|
+
const body = second.json as { details?: { runStatus?: string } };
|
|
176
|
+
expect(body.details?.runStatus, req('openwop.it.pause-resume.pause-on-an-already-paused-run-returns-409-with-details-runstatus-paused-unless',
|
|
177
|
+
DOC_PAUSE,
|
|
178
|
+
'the 409 MUST carry details.runStatus: paused',
|
|
179
|
+
)).toBe('paused');
|
|
180
|
+
await cancel(runId);
|
|
181
|
+
|
|
182
|
+
// The keyed form: the same Idempotency-Key on both pauses collapses the
|
|
183
|
+
// second into the cached 202.
|
|
184
|
+
const keyed = await driver.post('/v1/runs', { workflowId: FIXTURE!, inputs: { delayMs: 30_000 } });
|
|
185
|
+
expect(keyed.status).toBe(201);
|
|
186
|
+
const keyedId = (keyed.json as { runId: string }).runId;
|
|
187
|
+
await pollUntilStatus(keyedId, 'running', { timeoutMs: 10_000 });
|
|
188
|
+
const key = `openwop-conformance-pause-${keyedId.replace(/[^A-Za-z0-9._~-]/g, '-')}`.slice(0, 128);
|
|
189
|
+
const k1 = await driver.post(`/v1/runs/${encodeURIComponent(keyedId)}:pause`, { drainPolicy: 'immediate' }, { headers: { 'Idempotency-Key': key } });
|
|
190
|
+
expect(k1.status, req('openwop.it.pause-resume.pause-on-an-already-paused-run-returns-409-with-details-runstatus-paused-unless',
|
|
191
|
+
DOC_PAUSE,
|
|
192
|
+
':pause with an Idempotency-Key MUST return 202 on a pausable run',
|
|
193
|
+
)).toBe(202);
|
|
194
|
+
await pollUntilStatus(keyedId, 'paused', { timeoutMs: 10_000 });
|
|
195
|
+
const k2 = await driver.post(`/v1/runs/${encodeURIComponent(keyedId)}:pause`, { drainPolicy: 'immediate' }, { headers: { 'Idempotency-Key': key } });
|
|
196
|
+
expect(k2.status, req('openwop.it.pause-resume.pause-on-an-already-paused-run-returns-409-with-details-runstatus-paused-unless',
|
|
197
|
+
DOC_PAUSE,
|
|
198
|
+
'a :pause carrying the original Idempotency-Key MUST return 202 with the cached response, not 409',
|
|
199
|
+
)).toBe(202);
|
|
200
|
+
await cancel(keyedId);
|
|
149
201
|
});
|
|
150
202
|
});
|
|
151
203
|
|
|
@@ -160,19 +212,58 @@ describe.skipIf(SKIP)('pause/resume: :pause on a terminal run returns 409', () =
|
|
|
160
212
|
|
|
161
213
|
const pause = await driver.post(`/v1/runs/${encodeURIComponent(runId)}:pause`, {});
|
|
162
214
|
if (pause.status === 404) return softSkip('blocked', 'precondition not met — `pause.status === 404` returned early (seam, prior step, or fixture unavailable)');
|
|
163
|
-
expect(pause.status, req('openwop.it.pause-resume.pause-on-a-completed-cancelled-failed-run-must-return-409',
|
|
164
|
-
|
|
215
|
+
expect(pause.status, req('openwop.it.pause-resume.pause-on-a-completed-cancelled-failed-run-must-return-409',
|
|
216
|
+
DOC_PAUSE,
|
|
165
217
|
':pause on a terminal run MUST return 409',
|
|
166
218
|
)).toBe(409);
|
|
167
219
|
|
|
220
|
+
// v1 mandates details.runStatus; `run_terminal` is the one code v1 names
|
|
221
|
+
// for a mutation on a terminal run (rest-endpoints.md, bulk-cancel), and
|
|
222
|
+
// the registry is open, so the code is not asserted here.
|
|
168
223
|
const body = pause.json as { error?: string; details?: { runStatus?: string } };
|
|
169
|
-
expect(body.error).toBe('conflict');
|
|
170
|
-
// Spec requires `details.runStatus` to disclose the terminal state so
|
|
171
|
-
// the caller can decide whether to retry or surface the conflict.
|
|
172
224
|
expect(['completed', 'failed', 'cancelled']).toContain(body.details?.runStatus);
|
|
173
225
|
});
|
|
174
226
|
});
|
|
175
227
|
|
|
228
|
+
describe.skipIf(SKIP)('pause/resume: drain-current-node lets the executing node reach a terminal first', () => {
|
|
229
|
+
it('under drain-current-node node.completed precedes run.paused in the log', async () => {
|
|
230
|
+
// A short node: drain has something to wait for that finishes inside the
|
|
231
|
+
// poll window. A 30 s node under drain pauses after 30 s — that is the
|
|
232
|
+
// semantic, not a bug (rest-endpoints.md: "lets the executing node reach
|
|
233
|
+
// a terminal before the run transitions to paused").
|
|
234
|
+
const create = await driver.post('/v1/runs', {
|
|
235
|
+
workflowId: FIXTURE!,
|
|
236
|
+
inputs: { delayMs: 3_000 },
|
|
237
|
+
});
|
|
238
|
+
expect(create.status).toBe(201);
|
|
239
|
+
const runId = (create.json as { runId: string }).runId;
|
|
240
|
+
await pollUntilStatus(runId, 'running', { timeoutMs: 10_000 });
|
|
241
|
+
|
|
242
|
+
const pause = await driver.post(`/v1/runs/${encodeURIComponent(runId)}:pause`, {
|
|
243
|
+
reason: 'conformance-drain',
|
|
244
|
+
drainPolicy: 'drain-current-node',
|
|
245
|
+
});
|
|
246
|
+
if (pause.status === 404) {
|
|
247
|
+
await cancel(runId);
|
|
248
|
+
return softSkip('blocked', 'precondition not met — `pause.status === 404` returned early (seam, prior step, or fixture unavailable)');
|
|
249
|
+
}
|
|
250
|
+
expect(pause.status, req('openwop.it.pause-resume.under-drain-current-node-node-completed-precedes-run-paused-in-the-log',
|
|
251
|
+
DOC_PAUSE,
|
|
252
|
+
':pause with drainPolicy drain-current-node MUST return 202 (pause requested)',
|
|
253
|
+
)).toBe(202);
|
|
254
|
+
|
|
255
|
+
await pollUntilStatus(runId, 'paused', { timeoutMs: 20_000 });
|
|
256
|
+
const types = await eventTypes(runId);
|
|
257
|
+
const done = types.indexOf('node.completed');
|
|
258
|
+
const paused = types.indexOf('run.paused');
|
|
259
|
+
expect(done >= 0 && paused > done, req('openwop.it.pause-resume.under-drain-current-node-node-completed-precedes-run-paused-in-the-log',
|
|
260
|
+
DOC_PAUSE,
|
|
261
|
+
`drain-current-node MUST let the executing node reach a terminal before the run transitions to paused — node.completed MUST precede run.paused (types: ${types.join(', ')})`,
|
|
262
|
+
)).toBe(true);
|
|
263
|
+
await cancel(runId);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
176
267
|
describe.skipIf(SKIP)('pause/resume: :pause-during-suspend race', () => {
|
|
177
268
|
it(':pause MUST NOT silently override an active interrupt suspend', async () => {
|
|
178
269
|
// If the host seeds an approval fixture, drive a suspend then attempt
|
|
@@ -198,18 +289,15 @@ describe.skipIf(SKIP)('pause/resume: :pause-during-suspend race', () => {
|
|
|
198
289
|
reason: 'race-test',
|
|
199
290
|
});
|
|
200
291
|
if (pause.status === 404) {
|
|
201
|
-
|
|
202
|
-
await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
|
|
203
|
-
reason: 'conformance-cleanup',
|
|
204
|
-
});
|
|
292
|
+
await cancel(runId);
|
|
205
293
|
return softSkip('blocked', 'precondition not met — `pause.status === 404` returned early (seam, prior step, or fixture unavailable)');
|
|
206
294
|
}
|
|
207
295
|
|
|
208
296
|
// Either rejection (preferred) or stacked-pause is OK; silent override is not.
|
|
209
297
|
if (pause.status === 409) {
|
|
210
298
|
const body = pause.json as { details?: { runStatus?: string } };
|
|
211
|
-
expect(body.details?.runStatus, req('openwop.it.pause-resume.pause-must-not-silently-override-an-active-interrupt-suspend',
|
|
212
|
-
|
|
299
|
+
expect(body.details?.runStatus, req('openwop.it.pause-resume.pause-must-not-silently-override-an-active-interrupt-suspend',
|
|
300
|
+
DOC_PAUSE,
|
|
213
301
|
':pause-during-suspend MUST surface the active waiting-* status in the conflict envelope',
|
|
214
302
|
)).toMatch(/^waiting-/);
|
|
215
303
|
} else {
|
|
@@ -220,13 +308,11 @@ describe.skipIf(SKIP)('pause/resume: :pause-during-suspend race', () => {
|
|
|
220
308
|
const status = (snap.json as { status: string }).status;
|
|
221
309
|
expect(
|
|
222
310
|
status === 'paused' || status.startsWith('waiting-'),
|
|
223
|
-
req('openwop.it.pause-resume.pause-must-not-silently-override-an-active-interrupt-suspend',
|
|
311
|
+
req('openwop.it.pause-resume.pause-must-not-silently-override-an-active-interrupt-suspend', DOC_PAUSE, ':pause-during-suspend MUST NOT silently discard the active interrupt'),
|
|
224
312
|
).toBe(true);
|
|
225
313
|
}
|
|
226
314
|
|
|
227
|
-
await
|
|
228
|
-
reason: 'conformance-cleanup',
|
|
229
|
-
});
|
|
315
|
+
await cancel(runId);
|
|
230
316
|
});
|
|
231
317
|
});
|
|
232
318
|
|
|
@@ -249,7 +335,7 @@ describe.skipIf(SKIP)('pause/resume: drainPolicy discrimination per capabilities
|
|
|
249
335
|
for (const policy of drainPolicies) {
|
|
250
336
|
const create = await driver.post('/v1/runs', {
|
|
251
337
|
workflowId: FIXTURE!,
|
|
252
|
-
inputs: {
|
|
338
|
+
inputs: { delayMs: 30_000 },
|
|
253
339
|
});
|
|
254
340
|
expect(create.status).toBe(201);
|
|
255
341
|
const runId = (create.json as { runId: string }).runId;
|
|
@@ -260,14 +346,12 @@ describe.skipIf(SKIP)('pause/resume: drainPolicy discrimination per capabilities
|
|
|
260
346
|
reason: `conformance-drainpolicy-${policy}`,
|
|
261
347
|
drainPolicy: policy,
|
|
262
348
|
});
|
|
263
|
-
expect(pause.status, req('openwop.it.pause-resume.every-drainpolicy-advertised-by-the-host-is-accepted-on-pause',
|
|
349
|
+
expect(pause.status, req('openwop.it.pause-resume.every-drainpolicy-advertised-by-the-host-is-accepted-on-pause',
|
|
264
350
|
'capabilities.md §`runs.pauseResume.drainPolicies` + rest-endpoints.md POST /v1/runs/{runId}:pause',
|
|
265
351
|
`host-advertised drainPolicy='${policy}' MUST be accepted on :pause`,
|
|
266
352
|
)).toBe(202);
|
|
267
353
|
|
|
268
|
-
await
|
|
269
|
-
reason: 'conformance-cleanup',
|
|
270
|
-
});
|
|
354
|
+
await cancel(runId);
|
|
271
355
|
}
|
|
272
356
|
});
|
|
273
357
|
});
|
|
@@ -3,40 +3,42 @@
|
|
|
3
3
|
* the host must be able to honour (suite 2.0.0, target major 2; unaided).
|
|
4
4
|
*
|
|
5
5
|
* `fixtures[]` in discovery gates scenarios: `isFixtureAdvertised(id)` decides
|
|
6
|
-
* whether a scenario runs at all.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* scenario's requirement rather than to the advertisement that was wrong.
|
|
6
|
+
* whether a scenario runs at all. So a host whose advertised list and seeded set
|
|
7
|
+
* drift apart fails somewhere else entirely — the scenario gated on the missing
|
|
8
|
+
* fixture attempts, fails on a run that cannot be created, and the failure is
|
|
9
|
+
* attributed to that scenario's requirement rather than to the advertisement
|
|
10
|
+
* that was wrong. That misattribution is what this scenario exists to catch.
|
|
12
11
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
12
|
+
* **This scenario shipped with a second leg that was wrong, and the correction
|
|
13
|
+
* matters more than the check.** That leg asserted the advertised ids are a
|
|
14
|
+
* SUBSET of `conformance/fixtures/` — "the vocabulary is closed, so an id the
|
|
15
|
+
* corpus does not define is a typo or an invention". The vocabulary is not
|
|
16
|
+
* closed. Host-supplied fixtures are the normal case: dozens of ids the
|
|
17
|
+
* scenarios gate on are deliberately not shipped, and `v2-approver-enforced`
|
|
18
|
+
* says so in its own docstring — it needs an approval fixture whose
|
|
19
|
+
* `approversList` names a principal the suite is not, and records `blocked`
|
|
20
|
+
* naming it precisely because "no such fixture ships in `conformance/fixtures/`".
|
|
21
21
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
22
|
+
* So the leg failed a host for doing exactly what the corpus asks. It was found
|
|
23
|
+
* by running the suite against the reference host, which advertised two
|
|
24
|
+
* host-supplied fixtures and was marked non-conformant for it. Set membership
|
|
25
|
+
* cannot distinguish a typo from a legitimate host fixture, and a check that
|
|
26
|
+
* cannot tell those apart is not a check — it is a coin flip that happens to
|
|
27
|
+
* land on "fail" for correct hosts.
|
|
28
|
+
*
|
|
29
|
+
* What survives is the leg that was always sound: an advertised fixture MUST be
|
|
30
|
+
* creatable. That holds whoever defines it, and it is the one that catches the
|
|
31
|
+
* drift the misattribution comes from.
|
|
28
32
|
*
|
|
29
33
|
* @see spec/v2/core/conformance.md
|
|
30
|
-
* @see conformance/
|
|
34
|
+
* @see conformance/src/scenarios/v2-approver-enforced.test.ts (a host-supplied fixture, by design)
|
|
31
35
|
*/
|
|
32
36
|
|
|
33
37
|
import { describe, it, expect } from 'vitest';
|
|
34
|
-
import { existsSync, readdirSync } from 'node:fs';
|
|
35
38
|
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
36
39
|
import { v2Discovery } from '../lib/v2.js';
|
|
37
40
|
import { softSkip } from '../lib/soft-skip.js';
|
|
38
41
|
import { req } from '../lib/requirement-ids.js';
|
|
39
|
-
import { FIXTURES_DIR } from '../lib/paths.js';
|
|
40
42
|
|
|
41
43
|
const ID = 'openwop.requirement.0168.advertised-fixtures-exist';
|
|
42
44
|
const DOC = 'spec/v2/core/conformance.md §Fixtures';
|
|
@@ -57,26 +59,6 @@ function advertisedIds(doc: Record<string, unknown>): string[] {
|
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
describe('v2-advertised-fixtures-exist (conformance.md §Fixtures)', () => {
|
|
60
|
-
it('every advertised fixture id is one the corpus defines', async () => {
|
|
61
|
-
const doc = await v2Discovery().catch(() => null);
|
|
62
|
-
if (!doc) return softSkip('blocked', 'v2 discovery unreachable');
|
|
63
|
-
const ids = advertisedIds(doc);
|
|
64
|
-
if (ids.length === 0) return softSkip('inapplicable', 'the host advertises no fixtures[] — there is no claim to falsify');
|
|
65
|
-
if (FIXTURES_DIR === null || !existsSync(FIXTURES_DIR)) {
|
|
66
|
-
return softSkip('blocked', 'the fixture catalog is absent from this layout, so an advertised id cannot be checked against it');
|
|
67
|
-
}
|
|
68
|
-
const catalog = new Set(
|
|
69
|
-
readdirSync(FIXTURES_DIR)
|
|
70
|
-
.filter((f) => f.endsWith('.json'))
|
|
71
|
-
.map((f) => f.replace(/\.json$/, '')),
|
|
72
|
-
);
|
|
73
|
-
const unknown = ids.filter((id) => !catalog.has(id));
|
|
74
|
-
expect(
|
|
75
|
-
unknown,
|
|
76
|
-
req(ID, DOC, `every id in fixtures[] MUST name a fixture the corpus defines — the vocabulary is closed, so an id the catalog does not carry is a typo or an invention rather than a capability (${unknown.length} unknown of ${ids.length}: ${unknown.slice(0, 5).join(', ')})`),
|
|
77
|
-
).toEqual([]);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
62
|
it('a sampled advertised fixture is actually creatable, not just listed', async () => {
|
|
81
63
|
const doc = await v2Discovery().catch(() => null);
|
|
82
64
|
if (!doc) return softSkip('blocked', 'v2 discovery unreachable');
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0172 §A.1 / `spec/v2/core/versioning.md` §1.2 — a host that advertises
|
|
3
|
+
* major 2 serves the major-2 PATH SPACE, not just the major-2 discovery
|
|
4
|
+
* document (suite 2.0.0, target major 2; unaided).
|
|
5
|
+
*
|
|
6
|
+
* `v2-version-header-honored` checks that `OpenWOP-Version` is honored or
|
|
7
|
+
* refused rather than ignored. It probes `/.well-known/openwop`, because that
|
|
8
|
+
* is the one resource whose representation the header selects. **It therefore
|
|
9
|
+
* cannot see a host that negotiates correctly on the well-known resource and
|
|
10
|
+
* has mounted almost none of the rest of the v2 surface.**
|
|
11
|
+
*
|
|
12
|
+
* That is not hypothetical. A tier-1 host advertising
|
|
13
|
+
* `protocolVersions: ["1.1","2.0"]` was found serving **two of fifteen**
|
|
14
|
+
* top-level segments of the v2 path space: its unversioned mount was a
|
|
15
|
+
* deliberate allowlist (`['/runs','/interrupts']`, chosen over a blanket
|
|
16
|
+
* `/v1`-strip because the host serves a large non-`/v1` surface a blanket
|
|
17
|
+
* rewrite would shadow) and the list was simply incomplete. Every probe used to
|
|
18
|
+
* call the dual stack live — `protocolVersions`, `preferredVersion`, the
|
|
19
|
+
* response header, the two differing representations — hits `/.well-known`, so
|
|
20
|
+
* every one of them passed. `POST /webhooks` under major 2 returned `404` while
|
|
21
|
+
* `POST /v1/webhooks` returned `201`. The host found this itself by applying
|
|
22
|
+
* the artifact rule to a scenario it had first classified as a harness defect.
|
|
23
|
+
*
|
|
24
|
+
* The discriminator is a PAIR, not a single probe, because "this host does not
|
|
25
|
+
* implement webhooks at all" and "this host implements webhooks but did not
|
|
26
|
+
* mount them under major 2" are different facts that a lone `404` cannot
|
|
27
|
+
* separate:
|
|
28
|
+
*
|
|
29
|
+
* /v1<path> exists AND <path> is 404 under major 2 ⇒ the advertisement
|
|
30
|
+
* overstates: the surface exists and major 2 does not reach it.
|
|
31
|
+
*
|
|
32
|
+
* both 404 ⇒ the host does not serve that surface in either major. Not this
|
|
33
|
+
* scenario's business, and recorded as neither pass nor failure.
|
|
34
|
+
*
|
|
35
|
+
* Only parameterless GETs from `spec/v2/path-manifest.json` are probed: they
|
|
36
|
+
* need no fixture, mutate nothing, and a route that is not mounted answers 404
|
|
37
|
+
* regardless of auth, so the check is unaided and safe against a live host.
|
|
38
|
+
*
|
|
39
|
+
* @see spec/v2/core/versioning.md §1.2
|
|
40
|
+
* @see RFCS/0172-v2-versioning-and-release.md §A.1
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { describe, it, expect } from 'vitest';
|
|
44
|
+
import { loadEnv } from '../lib/env.js';
|
|
45
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
46
|
+
import { req } from '../lib/requirement-ids.js';
|
|
47
|
+
import { readFileSync } from 'node:fs';
|
|
48
|
+
import { join } from 'node:path';
|
|
49
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
50
|
+
|
|
51
|
+
const ID = 'openwop.requirement.0172.advertised-path-space-served';
|
|
52
|
+
const DOC = 'spec/v2/core/versioning.md §1.2';
|
|
53
|
+
|
|
54
|
+
/** The well-known resource is the one the HEADER selects; it has no /v1 twin to pair against. */
|
|
55
|
+
const NOT_PAIRABLE = new Set(['/.well-known/openwop']);
|
|
56
|
+
|
|
57
|
+
interface Probe { readonly status: number | null; readonly version: string | null; readonly html: boolean }
|
|
58
|
+
|
|
59
|
+
async function get(path: string, major2: boolean): Promise<Probe> {
|
|
60
|
+
const { baseUrl, apiKey } = loadEnv();
|
|
61
|
+
const headers: Record<string, string> = { Accept: 'application/json' };
|
|
62
|
+
if (major2) headers['OpenWOP-Version'] = '2.0';
|
|
63
|
+
if (apiKey) headers['authorization'] = `Bearer ${apiKey}`;
|
|
64
|
+
try {
|
|
65
|
+
const res = await fetch(`${baseUrl.replace(/\/$/, '')}${path}`, { headers });
|
|
66
|
+
const ct = res.headers.get('content-type') ?? '';
|
|
67
|
+
return { status: res.status, version: res.headers.get('openwop-version'), html: /text\/html/i.test(ct) };
|
|
68
|
+
} catch {
|
|
69
|
+
return { status: null, version: null, html: false };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* "Reached under major 2" is NOT "any status but 404". A hosting layer in
|
|
75
|
+
* front of the host answers every unrouted path with `200 text/html` — the
|
|
76
|
+
* SPA shell — and no `OpenWOP-Version` header, and that satisfied the first
|
|
77
|
+
* version of this check. Measured 2026-09-05 on a tier-1 host's public origin:
|
|
78
|
+
* `/.well-known/openwop` and `/v1/**` were rewritten to the backend and every
|
|
79
|
+
* unversioned major-2 path fell through to `index.html` with a 200, while the
|
|
80
|
+
* Cloud Run URL one hop behind answered every one correctly. The scenario was
|
|
81
|
+
* green on production for ten hours because a shell and a mount produced the
|
|
82
|
+
* same status code. `versioning.md` §1.4 requires `OpenWOP-Version` on every
|
|
83
|
+
* response; a response without it did not come from the host.
|
|
84
|
+
*/
|
|
85
|
+
function reachedUnderMajor2(p: Probe): boolean {
|
|
86
|
+
if (p.status === null || p.status === 404) return false;
|
|
87
|
+
if (p.version === null) return false;
|
|
88
|
+
if (p.html) return false;
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parameterlessGets(): string[] {
|
|
93
|
+
try {
|
|
94
|
+
const manifest = JSON.parse(
|
|
95
|
+
readFileSync(join(SCHEMAS_DIR, '..', 'spec', 'v2', 'path-manifest.json'), 'utf8'),
|
|
96
|
+
) as { operations?: ReadonlyArray<{ method: string; path: string }> };
|
|
97
|
+
return (manifest.operations ?? [])
|
|
98
|
+
.filter((o) => o.method === 'GET' && !o.path.includes('{') && !NOT_PAIRABLE.has(o.path))
|
|
99
|
+
.map((o) => o.path)
|
|
100
|
+
.sort();
|
|
101
|
+
} catch {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
describe('v2-advertised-path-space-served (RFC 0172 §A.1)', () => {
|
|
107
|
+
it('a host advertising major 2 reaches the surfaces it already serves under /v1', async () => {
|
|
108
|
+
const { baseUrl } = loadEnv();
|
|
109
|
+
const bare = await (async () => {
|
|
110
|
+
try {
|
|
111
|
+
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/.well-known/openwop`, { headers: { Accept: 'application/json' } });
|
|
112
|
+
return res.status === 200 ? ((await res.json()) as Record<string, unknown>) : null;
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
})();
|
|
117
|
+
if (!bare) return softSkip('blocked', 'the discovery document is unreadable, so the advertised majors are unknown');
|
|
118
|
+
|
|
119
|
+
const versions = Array.isArray(bare['protocolVersions']) ? (bare['protocolVersions'] as unknown[]).map(String) : [];
|
|
120
|
+
if (!new Set(versions.map((v) => v.split('.')[0])).has('2')) {
|
|
121
|
+
return softSkip('inapplicable', `the host advertises [${versions.join(', ') || 'no protocolVersions'}] — it does not claim major 2, so there is no path space to hold it to`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const paths = parameterlessGets();
|
|
125
|
+
if (paths.length === 0) return softSkip('blocked', 'spec/v2/path-manifest.json is unreadable from this layout');
|
|
126
|
+
|
|
127
|
+
const overstated: string[] = [];
|
|
128
|
+
const served: string[] = [];
|
|
129
|
+
let pairable = 0;
|
|
130
|
+
for (const path of paths) {
|
|
131
|
+
const v1 = await get(`/v1${path}`, false);
|
|
132
|
+
if (v1.status === null) return softSkip('blocked', `the host became unreachable while probing /v1${path}`);
|
|
133
|
+
// The host does not serve this surface in EITHER major. Legitimate, and a
|
|
134
|
+
// different question from the one asked here.
|
|
135
|
+
if (v1.status === 404) continue;
|
|
136
|
+
pairable += 1;
|
|
137
|
+
|
|
138
|
+
const v2 = await get(path, true);
|
|
139
|
+
if (v2.status === null) return softSkip('blocked', `the host became unreachable while probing ${path}`);
|
|
140
|
+
// 401/403/422 all answer "this path exists" and count as reached. A 404
|
|
141
|
+
// does not — and neither does a 200 that did not come from the host: no
|
|
142
|
+
// OpenWOP-Version header, or an HTML body, is a hosting fallback wearing
|
|
143
|
+
// a success code (see reachedUnderMajor2).
|
|
144
|
+
if (!reachedUnderMajor2(v2)) {
|
|
145
|
+
overstated.push(`${path} (/v1 → ${v1.status}, major 2 → ${v2.status}${v2.version === null ? ', no OpenWOP-Version header' : ''}${v2.html ? ', text/html body' : ''})`);
|
|
146
|
+
} else {
|
|
147
|
+
served.push(path);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (pairable === 0) {
|
|
152
|
+
return softSkip('inapplicable', 'no parameterless GET in the v2 manifest is served under /v1 either, so there is no pair to compare and the advertisement cannot be checked this way');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
expect(
|
|
156
|
+
overstated,
|
|
157
|
+
req(ID, DOC, `a host advertising major 2 MUST reach, under major 2, the surfaces it already serves under /v1 — advertising the major is a claim about the PATH SPACE and not only about the well-known resource, whose representation the header selects and which therefore passes even when almost nothing else is mounted (${overstated.length} of ${pairable} pairable surface(s) unreachable: ${overstated.slice(0, 6).join('; ')})`),
|
|
158
|
+
).toEqual([]);
|
|
159
|
+
|
|
160
|
+
expect(
|
|
161
|
+
served.length,
|
|
162
|
+
req(ID, DOC, 'at least one non-well-known surface MUST be reachable under major 2, or the advertisement rests entirely on the one resource the header selects'),
|
|
163
|
+
).toBeGreaterThan(0);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -39,7 +39,7 @@ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse
|
|
|
39
39
|
async function gate(): Promise<{ floor: string } | { kind: 'blocked' | 'inapplicable'; reason: string }> {
|
|
40
40
|
const doc = await discovery();
|
|
41
41
|
if (!doc) return { kind: 'blocked', reason: 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0' };
|
|
42
|
-
if (!seamsProfileAdvertised(doc)) return { kind: '
|
|
42
|
+
if (!seamsProfileAdvertised(doc)) return { kind: 'inapplicable', reason: 'seams profile not advertised (conformance.seamsProfile !== openwop-conformance-seams-v2) — the assurance floor is seam-gated' };
|
|
43
43
|
const auth = await familyAdvertised('auth');
|
|
44
44
|
const lanes = Array.isArray(auth?.['lanes']) ? (auth['lanes'] as Array<Record<string, unknown>>) : [];
|
|
45
45
|
const workload = lanes.find((l) => l['lane'] === 'workload');
|