@openwop/openwop-conformance 2.44.8 → 2.45.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 +15 -0
- package/README.md +3 -2
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +47 -33
- package/scenario-majors.json +5 -2
- package/schemas/CORPUS-STAMP.json +12 -12
- package/src/scenarios/eventOrdering.test.ts +8 -2
- package/src/scenarios/failure-path.test.ts +4 -1
- package/src/scenarios/idempotency.test.ts +6 -2
- package/src/scenarios/interrupt-clarification.test.ts +4 -1
- package/src/scenarios/replay-fork.test.ts +10 -3
- package/src/scenarios/runs-lifecycle.test.ts +6 -1
- package/src/scenarios/stream-modes-buffer.test.ts +7 -1
- package/src/scenarios/stream-modes-mixed.test.ts +7 -1
- package/src/scenarios/stream-modes.test.ts +10 -4
- package/src/scenarios/v2-cors-preflight.test.ts +141 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# `@openwop/openwop-conformance` Changelog
|
|
2
2
|
|
|
3
|
+
## [2.45.0] — 2026-09-29 — a host that grants an origin admits the contract's request headers in preflight
|
|
4
|
+
|
|
5
|
+
- **New scenario `v2-cors-preflight`: `openwop.requirement.headers.cors-preflight-admits`** (#1763; minor, since a scenario is added). It witnesses the new `headers.md` §Cross-origin preflight (Class 3).
|
|
6
|
+
- **Probe.** For every operation in `spec/v2/path-manifest.json` it sends `OPTIONS <path>` with `Origin`, `Access-Control-Request-Method`, and `Access-Control-Request-Headers`. The headers requested are that operation's declared request headers, plus `authorization` when authenticated and `content-type` with a body.
|
|
7
|
+
- **Reading.** It reads the answer as the Fetch standard's CORS-preflight check does. A CORS-safelisted method needs no listing. `*` admits nothing once credentials are allowed, and never admits `authorization`.
|
|
8
|
+
- **Gate.** It is self-gated on the host's grant: an operation whose preflight does not grant the origin is not judged, and the row is `inapplicable` when none is. The origin is `OPENWOP_CORS_ORIGIN` (default `https://conformance.invalid`), so an allowlisting host's operator can name an allowed one.
|
|
9
|
+
- **Verdicts.** Fails naming each granted operation and the missing names. Passes with an `observed:` detail (granted count).
|
|
10
|
+
- **Measured on stub hosts.** A copy of openwop-app's current middleware (reflect any origin, explicit list) records `executed-fail` on four operations: `getCapabilities` and `getRun` (`If-None-Match`), `createRun` (`OpenWOP-Force-Engine-Version`), `getContentPage` (`Accept-Language`). A host that reflects the request headers passes. A host that answers no CORS is `inapplicable`.
|
|
11
|
+
- **The per-operation sets come from the manifest.** `spec/v2/path-manifest.json` now carries `requestHeaders`, `authenticated` and `requestBody` per operation, written by `derive-v2-api.py` from `api/v2/openapi.yaml`, so the suite reads them without a YAML parser.
|
|
12
|
+
|
|
13
|
+
## [2.44.9] — 2026-09-29 — a floor gated on a withheld fixture records `blocked`, not nothing
|
|
14
|
+
|
|
15
|
+
- **Nine v1 floor files no longer skip at describe level** (#1686). Each of the following gated every test with `describe.skipIf(!isFixtureAdvertised(…))`: `eventOrdering`, `failure-path`, `idempotency`, `interrupt-clarification`, `replay-fork`, `runs-lifecycle`, `stream-modes`, `stream-modes-buffer` and `stream-modes-mixed`. When every test in a file is describe-skipped, vitest runs none of `setup.ts`'s hooks for it, so the file's floor requirement got no ledger row. `--certify` then counted the floor unclassified and rejected the whole certification, not just the profile. `byok-roundtrip` hit this on openwop-app's 2026-09-28 major-1 cut (`certified: none`) and was fixed in #1708. Each test in the nine files now opens with `if (SKIP) return softSkip('blocked', NO_FIXTURE_REASON)`, so a withheld fixture records `blocked` with the fixture named. That denies only the affected profile. It is `blocked`, not `inapplicable`: the host advertises the capability the floor witnesses, so the requirement applies to the captured discovery, and a withheld fixture is exactly RFC 0148 §A's `blocked` ("a required seam, fixture … was unavailable"). `byok-roundtrip`'s `blocked` (#1708) stands for the same reason. Measured against a stub host that advertises no fixtures, with the ledger on: before, `runs-lifecycle` wrote no ledger row; after, `openwop.floor.runs-lifecycle` is `blocked` with the reason. A test that passed before still passes: the gates are unchanged, only where they sit.
|
|
16
|
+
- **New self-test, `describe-level-skip.test.ts`** (server-free, `test:self`). It fails when a v1 floor file (`PROFILE_FLOOR_SCENARIOS`, `required` plus every `conditional` branch) can skip every test at describe level. A gate on having no target (`!process.env.OPENWOP_BASE_URL`) is exempt, since with no host there is no bundle. A ratchet also holds the 56 non-floor files with the same shape at a ceiling of 56. Those rows resolve to a report-derived `blocked` that names no reason; that is the right disposition, and they certify nothing either way. Sabotage: reverting `runs-lifecycle` to its describe-level gate fails the floor rule, and adding a new all-gated file fails the ratchet.
|
|
17
|
+
|
|
3
18
|
## [2.44.8] — 2026-09-29 — every subscribed event of a run is delivered
|
|
4
19
|
|
|
5
20
|
- **`0173.webhook-delivery-complete`: every matching event reaches a healthy receiver at least once** (#1781). A new leg in `v2-webhook-durable-delivery` (target major 2, gated on `webhooks` and `conformance-noop`) reads `webhooks.md` §Durability, "deliver each matching event at least once", across a whole run; no row did before. It registers one subscription to `run.started`, `node.started`, `node.completed` and `run.completed` with a receiver that answers `204` to everything, and runs the noop fixture. The expected set is every logged event of a subscribed type (`GET /runs/{runId}/events/poll`), keyed by `sequence`. Each must arrive at least once, deduplicated on `(webhookId, runId, sequence)`; duplicates are counted, never held against the host. The wait is `retryWaitFor`: the advertised `maxElapsedMs` plus the grace (RFC 0225), else the cap or the floor. The row convicts when some of the run's subscribed events arrived and others did not, or when nothing arrived and nothing reached the listener; nothing of this run arriving while other traffic did is `blocked`. A pass carries an `observed:` detail (counts, duplicates, slowest first arrival, wait path). The leg unregisters its subscription. Measured on the v2 reference host (openwop-examples `d0255b4b`): `executed-pass` in 255 ms; with its `run.started` fan-out removed, `executed-fail` naming `run.started (sequence 0)` after the 90 s window, and the file's other five legs stay green, which is the gap: myndhyve#560's lost `run.started` fan-out was convicted only incidentally, under the wrong MUST (#1780). Surveyed against the three hosts' code: each delivers every subscribed type and passes on its certified deployment (MyndHyve's window is its advertised 600000 ms plus the grace).
|
package/README.md
CHANGED
|
@@ -135,7 +135,8 @@ Exit code is non-zero on any failed assertion. `--certify` distinguishes: `0`
|
|
|
135
135
|
|
|
136
136
|
## What's Covered
|
|
137
137
|
|
|
138
|
-
The current suite has
|
|
138
|
+
The current suite has 575 scenario files under `src/scenarios/`.
|
|
139
|
+
- 2026-09-29 (suite 2.45.0, openwop#1763): NEW `v2-cors-preflight.test.ts` — `headers.md` §Cross-origin preflight. For every operation it preflights with `Origin`, the method, and the operation's declared request headers (plus `authorization` / `content-type` where they apply, from `spec/v2/path-manifest.json`), and reads the answer as the Fetch standard's CORS-preflight check does. Self-gated: `inapplicable` when no operation's preflight grants `OPENWOP_CORS_ORIGIN` (default `https://conformance.invalid`).
|
|
139
140
|
- 2026-09-23 (suite 2.37.0 cycle, RFC 0213): NEW `v2-sse-last-event-id-cursor.test.ts` (a `Last-Event-ID` past the log is an exclusive cursor; a malformed id, when refused, is `400 validation_error`; the cursor never changes the answer for an unknown or foreign-tenant run — public test of `event-cursor-after-authorization`), `v2-idempotency-in-flight.test.ts` (five concurrent same-key creates yield one run; each loser is a marked replay or `409 idempotency_in_flight` with no retry timing in `details`; `partial-witness` when no loser was refused in flight) and `v2-interrupt-resolve-terminal.test.ts` (a run-scoped resolve after cancel or completion is `409 interrupt_already_resolved`, never `interrupt_cancelled`). All three sit off the core-standard floor until measured on the three bundle hosts.
|
|
140
141
|
- 2026-09-03 (suite `1.157.0 -> 1.158.0`, gap G17): NEW `idempotency-concurrent-claim.test.ts` — drives the new `host-sample-test-seams.md` §25 concurrent duplicate-delivery seam for the RFC 0150 §B / `idempotency.md` §"Concurrent duplicates (Layer 2)" atomic-claim MUST, which is unconditional and had no witness of any kind. Asserts every executor mints the SAME `logicalInvocationId` **before** asserting `delivered === 1` — without the identity check a host passes by minting different ids and never colliding, one effect because nothing raced. Not profile-gated and so not opt-out-able (the obligation is unconditional); an unmounted seam records `blocked`, which is not certifiable. Graduates `layer2-invocation-claim-atomic` reference-impl -> protocol.
|
|
141
142
|
- 2026-08-19 (suite `1.137.0 → 1.138.0`): NEW `durability-poison-exhaustion.test.ts` — RFC 0158 §C.8, the FIRST row of that RFC's conformance table to land. Asserts what `failure-path.test.ts` cannot: not just that deterministically failing work reaches terminal, but that attempts STOP — counted on the log, re-counted after a scaled quiet window, asserted unchanged. A host still redelivering records more. Seam-gated on the existing event-log seam (`blocked` = unobservable, not unmet) and outside every profile floor.
|
|
@@ -481,7 +482,7 @@ Server-required (added in 1.7.0):
|
|
|
481
482
|
| ------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
482
483
|
| **Redaction** | [`capabilities.md`](../spec/v1/capabilities.md) §"Secrets" + NFR-7 + §"aiProviders" | Vendor-neutral assertions that the server doesn't leak secret material. Three scenario groups: (a) discovery shape contract — `secrets` + `aiProviders` advertisements are well-formed regardless of `secrets.supported`; when `supported === true`, scopes MUST be non-empty + `resolution === 'host-managed'`; `byok ⊆ supported`. (b) bearer-token redaction — invalid Bearer canary in `Authorization` header is not echoed in the 401 response body. (c) credentialRef echo control — gated on `secrets.supported === true`; canary planted in `configurable.ai.credentialRef` MUST NOT appear in any RunEvent payload (poll-based capture; transport-agnostic). Uses runtime-built canary fixtures (`lib/canaries.ts`) that defeat static secret scanners. 6 scenarios. |
|
|
483
484
|
|
|
484
|
-
Current source tree:
|
|
485
|
+
Current source tree: 575 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
|
|
485
486
|
|
|
486
487
|
## Remaining Gaps
|
|
487
488
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openwop/openwop-conformance",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.45.0",
|
|
4
4
|
"description": "Production-ready black-box conformance suite for OpenWOP v1.0 compliant servers.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -56,6 +56,6 @@
|
|
|
56
56
|
"@openwop/spec-artifacts": "file:../spec-artifacts"
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
|
-
"@openwop/spec-artifacts": "2.
|
|
59
|
+
"@openwop/spec-artifacts": "2.45.0"
|
|
60
60
|
}
|
|
61
61
|
}
|
package/requirements.json
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
"$comment": "GENERATED by conformance/scripts/generate-requirement-registry.mjs — do not edit. One record per it()/test() in src/scenarios. Ids: openwop.it.<file-stem>.<title-slug>[~n] (src/lib/requirement-ids.ts). A record with id null has an interpolated title; its run-time row is keyed by the rendered title and maps here by file+line only. Renamed ids need a row in requirement-aliases.json.",
|
|
3
3
|
"generatedFrom": "src/scenarios/*.test.ts",
|
|
4
4
|
"counts": {
|
|
5
|
-
"files":
|
|
6
|
-
"tests":
|
|
7
|
-
"withStableId":
|
|
5
|
+
"files": 650,
|
|
6
|
+
"tests": 2496,
|
|
7
|
+
"withStableId": 2496,
|
|
8
8
|
"interpolatedTitles": 0,
|
|
9
|
-
"explicitIds":
|
|
9
|
+
"explicitIds": 2375
|
|
10
10
|
},
|
|
11
11
|
"records": [
|
|
12
12
|
{
|
|
@@ -12791,7 +12791,7 @@
|
|
|
12791
12791
|
{
|
|
12792
12792
|
"id": "openwop.it.eventOrdering.events-from-a-single-poll-have-non-decreasing-sequence-numbers",
|
|
12793
12793
|
"file": "eventOrdering.test.ts",
|
|
12794
|
-
"line":
|
|
12794
|
+
"line": 55,
|
|
12795
12795
|
"title": "events from a single poll have non-decreasing sequence numbers",
|
|
12796
12796
|
"explicitId": "openwop.it.eventOrdering.events-from-a-single-poll-have-non-decreasing-sequence-numbers",
|
|
12797
12797
|
"citations": [
|
|
@@ -12805,7 +12805,7 @@
|
|
|
12805
12805
|
{
|
|
12806
12806
|
"id": "openwop.it.eventOrdering.repeated-polls-of-a-terminal-run-yield-identical-event-sequences",
|
|
12807
12807
|
"file": "eventOrdering.test.ts",
|
|
12808
|
-
"line":
|
|
12808
|
+
"line": 81,
|
|
12809
12809
|
"title": "repeated polls of a terminal run yield identical event sequences",
|
|
12810
12810
|
"explicitId": "openwop.it.eventOrdering.repeated-polls-of-a-terminal-run-yield-identical-event-sequences",
|
|
12811
12811
|
"citations": [
|
|
@@ -12823,7 +12823,7 @@
|
|
|
12823
12823
|
{
|
|
12824
12824
|
"id": "openwop.it.eventOrdering.event-stream-contains-exactly-one-of-run-completed-run-failed-run-cancelled",
|
|
12825
12825
|
"file": "eventOrdering.test.ts",
|
|
12826
|
-
"line":
|
|
12826
|
+
"line": 114,
|
|
12827
12827
|
"title": "event stream contains exactly one of run.completed / run.failed / run.cancelled",
|
|
12828
12828
|
"explicitId": "openwop.it.eventOrdering.event-stream-contains-exactly-one-of-run-completed-run-failed-run-cancelled",
|
|
12829
12829
|
"citations": [
|
|
@@ -12836,7 +12836,7 @@
|
|
|
12836
12836
|
{
|
|
12837
12837
|
"id": "openwop.it.eventOrdering.the-terminal-event-is-the-last-event-in-the-stream",
|
|
12838
12838
|
"file": "eventOrdering.test.ts",
|
|
12839
|
-
"line":
|
|
12839
|
+
"line": 136,
|
|
12840
12840
|
"title": "the terminal event is the LAST event in the stream",
|
|
12841
12841
|
"explicitId": "openwop.it.eventOrdering.the-terminal-event-is-the-last-event-in-the-stream",
|
|
12842
12842
|
"citations": [
|
|
@@ -13103,7 +13103,7 @@
|
|
|
13103
13103
|
{
|
|
13104
13104
|
"id": "openwop.it.failure-path.post-v1-runs-accepts-the-run-and-run-terminates-as-failed-with-structured-error",
|
|
13105
13105
|
"file": "failure-path.test.ts",
|
|
13106
|
-
"line":
|
|
13106
|
+
"line": 19,
|
|
13107
13107
|
"title": "POST /v1/runs accepts the run and run terminates as failed with structured error",
|
|
13108
13108
|
"explicitId": "openwop.it.failure-path.post-v1-runs-accepts-the-run-and-run-terminates-as-failed-with-structured-error",
|
|
13109
13109
|
"citations": [
|
|
@@ -14869,7 +14869,7 @@
|
|
|
14869
14869
|
{
|
|
14870
14870
|
"id": "openwop.it.idempotency.returns-same-runid-twice-and-sets-openwop-idempotent-replay-on-the-replay",
|
|
14871
14871
|
"file": "idempotency.test.ts",
|
|
14872
|
-
"line":
|
|
14872
|
+
"line": 33,
|
|
14873
14873
|
"title": "returns same runId twice and sets openwop-Idempotent-Replay on the replay",
|
|
14874
14874
|
"explicitId": "openwop.it.idempotency.returns-same-runid-twice-and-sets-openwop-idempotent-replay-on-the-replay",
|
|
14875
14875
|
"citations": [
|
|
@@ -14894,7 +14894,7 @@
|
|
|
14894
14894
|
{
|
|
14895
14895
|
"id": "openwop.it.idempotency.returns-409-when-the-body-changes-under-the-same-key",
|
|
14896
14896
|
"file": "idempotency.test.ts",
|
|
14897
|
-
"line":
|
|
14897
|
+
"line": 73,
|
|
14898
14898
|
"title": "returns 409 when the body changes under the same key",
|
|
14899
14899
|
"explicitId": "openwop.it.idempotency.returns-409-when-the-body-changes-under-the-same-key",
|
|
14900
14900
|
"citations": [
|
|
@@ -15354,7 +15354,7 @@
|
|
|
15354
15354
|
{
|
|
15355
15355
|
"id": "openwop.it.interrupt-clarification.run-suspends-at-ask-answers-payload-drives-terminal-completed",
|
|
15356
15356
|
"file": "interrupt-clarification.test.ts",
|
|
15357
|
-
"line":
|
|
15357
|
+
"line": 22,
|
|
15358
15358
|
"title": "run suspends at ask, answers payload drives terminal completed",
|
|
15359
15359
|
"explicitId": "openwop.it.interrupt-clarification.run-suspends-at-ask-answers-payload-drives-terminal-completed",
|
|
15360
15360
|
"citations": [
|
|
@@ -22575,7 +22575,7 @@
|
|
|
22575
22575
|
{
|
|
22576
22576
|
"id": "openwop.it.replay-fork.produces-a-new-run-that-reaches-terminal-completed",
|
|
22577
22577
|
"file": "replay-fork.test.ts",
|
|
22578
|
-
"line":
|
|
22578
|
+
"line": 61,
|
|
22579
22579
|
"title": "produces a new run that reaches terminal `completed`",
|
|
22580
22580
|
"explicitId": "openwop.it.replay-fork.produces-a-new-run-that-reaches-terminal-completed",
|
|
22581
22581
|
"citations": [
|
|
@@ -22608,7 +22608,7 @@
|
|
|
22608
22608
|
{
|
|
22609
22609
|
"id": "openwop.it.replay-fork.produces-a-new-run-that-reaches-terminal-completed~2",
|
|
22610
22610
|
"file": "replay-fork.test.ts",
|
|
22611
|
-
"line":
|
|
22611
|
+
"line": 107,
|
|
22612
22612
|
"title": "produces a new run that reaches terminal `completed`",
|
|
22613
22613
|
"explicitId": "openwop.it.replay-fork.produces-a-new-run-that-reaches-terminal-completed~2",
|
|
22614
22614
|
"citations": [
|
|
@@ -22621,7 +22621,7 @@
|
|
|
22621
22621
|
{
|
|
22622
22622
|
"id": "openwop.it.replay-fork.rejects-negative-fromseq-with-400",
|
|
22623
22623
|
"file": "replay-fork.test.ts",
|
|
22624
|
-
"line":
|
|
22624
|
+
"line": 145,
|
|
22625
22625
|
"title": "rejects negative fromSeq with 400",
|
|
22626
22626
|
"explicitId": "openwop.it.replay-fork.rejects-negative-fromseq-with-400",
|
|
22627
22627
|
"citations": [
|
|
@@ -22634,7 +22634,7 @@
|
|
|
22634
22634
|
{
|
|
22635
22635
|
"id": "openwop.it.replay-fork.rejects-fromseq-beyond-source-event-log-length-with-422",
|
|
22636
22636
|
"file": "replay-fork.test.ts",
|
|
22637
|
-
"line":
|
|
22637
|
+
"line": 165,
|
|
22638
22638
|
"title": "rejects fromSeq beyond source event log length with 422",
|
|
22639
22639
|
"explicitId": "openwop.it.replay-fork.rejects-fromseq-beyond-source-event-log-length-with-422",
|
|
22640
22640
|
"citations": [
|
|
@@ -22647,7 +22647,7 @@
|
|
|
22647
22647
|
{
|
|
22648
22648
|
"id": "openwop.it.replay-fork.rejects-replay-mode-with-non-empty-runoptionsoverlay-overlay-is-branch-only",
|
|
22649
22649
|
"file": "replay-fork.test.ts",
|
|
22650
|
-
"line":
|
|
22650
|
+
"line": 187,
|
|
22651
22651
|
"title": "rejects replay mode with non-empty runOptionsOverlay (overlay is branch-only)",
|
|
22652
22652
|
"explicitId": "openwop.it.replay-fork.rejects-replay-mode-with-non-empty-runoptionsoverlay-overlay-is-branch-only",
|
|
22653
22653
|
"citations": [
|
|
@@ -22660,7 +22660,7 @@
|
|
|
22660
22660
|
{
|
|
22661
22661
|
"id": "openwop.it.replay-fork.rejects-fork-on-a-non-existent-run-with-404",
|
|
22662
22662
|
"file": "replay-fork.test.ts",
|
|
22663
|
-
"line":
|
|
22663
|
+
"line": 214,
|
|
22664
22664
|
"title": "rejects fork on a non-existent run with 404",
|
|
22665
22665
|
"explicitId": "openwop.it.replay-fork.rejects-fork-on-a-non-existent-run-with-404",
|
|
22666
22666
|
"citations": [
|
|
@@ -23735,7 +23735,7 @@
|
|
|
23735
23735
|
{
|
|
23736
23736
|
"id": "openwop.it.runs-lifecycle.post-v1-runs-returns-201-with-runid-per-rest-endpoints-md",
|
|
23737
23737
|
"file": "runs-lifecycle.test.ts",
|
|
23738
|
-
"line":
|
|
23738
|
+
"line": 24,
|
|
23739
23739
|
"title": "POST /v1/runs returns 201 with runId per rest-endpoints.md",
|
|
23740
23740
|
"explicitId": "openwop.it.runs-lifecycle.post-v1-runs-returns-201-with-runid-per-rest-endpoints-md",
|
|
23741
23741
|
"citations": [
|
|
@@ -23756,7 +23756,7 @@
|
|
|
23756
23756
|
{
|
|
23757
23757
|
"id": "openwop.it.runs-lifecycle.reaches-terminal-completed-within-bounded-time-per-fixtures-md-noop-spec",
|
|
23758
23758
|
"file": "runs-lifecycle.test.ts",
|
|
23759
|
-
"line":
|
|
23759
|
+
"line": 44,
|
|
23760
23760
|
"title": "reaches terminal `completed` within bounded time per fixtures.md noop spec",
|
|
23761
23761
|
"explicitId": "openwop.it.runs-lifecycle.reaches-terminal-completed-within-bounded-time-per-fixtures-md-noop-spec",
|
|
23762
23762
|
"citations": [
|
|
@@ -23773,7 +23773,7 @@
|
|
|
23773
23773
|
{
|
|
23774
23774
|
"id": "openwop.it.runs-lifecycle.get-v1-runs-nonexistentid-returns-404-or-403-per-rest-endpoints-md",
|
|
23775
23775
|
"file": "runs-lifecycle.test.ts",
|
|
23776
|
-
"line":
|
|
23776
|
+
"line": 63,
|
|
23777
23777
|
"title": "GET /v1/runs/{nonexistentId} returns 404 (or 403) per rest-endpoints.md",
|
|
23778
23778
|
"explicitId": "openwop.it.runs-lifecycle.get-v1-runs-nonexistentid-returns-404-or-403-per-rest-endpoints-md",
|
|
23779
23779
|
"citations": [
|
|
@@ -25058,7 +25058,7 @@
|
|
|
25058
25058
|
{
|
|
25059
25059
|
"id": "openwop.it.stream-modes-buffer.accepts-bufferms-in-range-and-emits-at-least-one-event-batch-frame",
|
|
25060
25060
|
"file": "stream-modes-buffer.test.ts",
|
|
25061
|
-
"line":
|
|
25061
|
+
"line": 38,
|
|
25062
25062
|
"title": "accepts bufferMs in range and emits at least one event: batch frame",
|
|
25063
25063
|
"explicitId": "openwop.it.stream-modes-buffer.accepts-bufferms-in-range-and-emits-at-least-one-event-batch-frame",
|
|
25064
25064
|
"citations": [
|
|
@@ -25079,7 +25079,7 @@
|
|
|
25079
25079
|
{
|
|
25080
25080
|
"id": "openwop.it.stream-modes-buffer.rejects-out-of-range-bufferms-with-400-validation-error",
|
|
25081
25081
|
"file": "stream-modes-buffer.test.ts",
|
|
25082
|
-
"line":
|
|
25082
|
+
"line": 75,
|
|
25083
25083
|
"title": "rejects out-of-range bufferMs with 400 validation_error",
|
|
25084
25084
|
"explicitId": "openwop.it.stream-modes-buffer.rejects-out-of-range-bufferms-with-400-validation-error",
|
|
25085
25085
|
"citations": [
|
|
@@ -25092,7 +25092,7 @@
|
|
|
25092
25092
|
{
|
|
25093
25093
|
"id": "openwop.it.stream-modes-buffer.forces-flush-on-terminal-run-completed-arrives-bundled-in-a-batch-before-the-tim",
|
|
25094
25094
|
"file": "stream-modes-buffer.test.ts",
|
|
25095
|
-
"line":
|
|
25095
|
+
"line": 94,
|
|
25096
25096
|
"title": "forces flush on terminal — run.completed arrives bundled in a batch BEFORE the timer fires",
|
|
25097
25097
|
"explicitId": "openwop.it.stream-modes-buffer.forces-flush-on-terminal-run-completed-arrives-bundled-in-a-batch-before-the-tim",
|
|
25098
25098
|
"citations": [
|
|
@@ -25110,7 +25110,7 @@
|
|
|
25110
25110
|
{
|
|
25111
25111
|
"id": "openwop.it.stream-modes-buffer.bufferms-0-behaves-identically-to-omitting-per-event-mode",
|
|
25112
25112
|
"file": "stream-modes-buffer.test.ts",
|
|
25113
|
-
"line":
|
|
25113
|
+
"line": 139,
|
|
25114
25114
|
"title": "bufferMs=0 behaves identically to omitting (per-event mode)",
|
|
25115
25115
|
"explicitId": "openwop.it.stream-modes-buffer.bufferms-0-behaves-identically-to-omitting-per-event-mode",
|
|
25116
25116
|
"citations": [
|
|
@@ -25123,7 +25123,7 @@
|
|
|
25123
25123
|
{
|
|
25124
25124
|
"id": "openwop.it.stream-modes-mixed.accepts-streammode-updates-messages-and-emits-a-server-closed-stream",
|
|
25125
25125
|
"file": "stream-modes-mixed.test.ts",
|
|
25126
|
-
"line":
|
|
25126
|
+
"line": 36,
|
|
25127
25127
|
"title": "accepts streamMode=updates,messages and emits a server-closed stream",
|
|
25128
25128
|
"explicitId": "openwop.it.stream-modes-mixed.accepts-streammode-updates-messages-and-emits-a-server-closed-stream",
|
|
25129
25129
|
"citations": [
|
|
@@ -25144,7 +25144,7 @@
|
|
|
25144
25144
|
{
|
|
25145
25145
|
"id": "openwop.it.stream-modes-mixed.rejects-streammode-values-updates-with-400-unsupported-stream-mode",
|
|
25146
25146
|
"file": "stream-modes-mixed.test.ts",
|
|
25147
|
-
"line":
|
|
25147
|
+
"line": 67,
|
|
25148
25148
|
"title": "rejects streamMode=values,updates with 400 + unsupported_stream_mode",
|
|
25149
25149
|
"explicitId": "openwop.it.stream-modes-mixed.rejects-streammode-values-updates-with-400-unsupported-stream-mode",
|
|
25150
25150
|
"citations": [
|
|
@@ -25169,7 +25169,7 @@
|
|
|
25169
25169
|
{
|
|
25170
25170
|
"id": "openwop.it.stream-modes-mixed.rejects-streammode-updates-bogus-one-bad-mode-fails-the-whole-list",
|
|
25171
25171
|
"file": "stream-modes-mixed.test.ts",
|
|
25172
|
-
"line":
|
|
25172
|
+
"line": 102,
|
|
25173
25173
|
"title": "rejects streamMode=updates,bogus (one bad mode fails the whole list)",
|
|
25174
25174
|
"explicitId": "openwop.it.stream-modes-mixed.rejects-streammode-updates-bogus-one-bad-mode-fails-the-whole-list",
|
|
25175
25175
|
"citations": [
|
|
@@ -25182,7 +25182,7 @@
|
|
|
25182
25182
|
{
|
|
25183
25183
|
"id": "openwop.it.stream-modes-mixed.mixed-mode-union-updates-debug-sees-every-event-updates-sees",
|
|
25184
25184
|
"file": "stream-modes-mixed.test.ts",
|
|
25185
|
-
"line":
|
|
25185
|
+
"line": 121,
|
|
25186
25186
|
"title": "mixed mode union: updates,debug sees every event updates sees",
|
|
25187
25187
|
"explicitId": "openwop.it.stream-modes-mixed.mixed-mode-union-updates-debug-sees-every-event-updates-sees",
|
|
25188
25188
|
"citations": [
|
|
@@ -25196,7 +25196,7 @@
|
|
|
25196
25196
|
{
|
|
25197
25197
|
"id": "openwop.it.stream-modes.emits-at-least-run-started-run-completed-and-server-closes-the-stream",
|
|
25198
25198
|
"file": "stream-modes.test.ts",
|
|
25199
|
-
"line":
|
|
25199
|
+
"line": 37,
|
|
25200
25200
|
"title": "emits at least run.started + run.completed and server closes the stream",
|
|
25201
25201
|
"explicitId": "openwop.it.stream-modes.emits-at-least-run-started-run-completed-and-server-closes-the-stream",
|
|
25202
25202
|
"citations": [
|
|
@@ -25217,7 +25217,7 @@
|
|
|
25217
25217
|
{
|
|
25218
25218
|
"id": "openwop.it.stream-modes.returns-400-and-a-structured-error-body",
|
|
25219
25219
|
"file": "stream-modes.test.ts",
|
|
25220
|
-
"line":
|
|
25220
|
+
"line": 63,
|
|
25221
25221
|
"title": "returns 400 and a structured error body",
|
|
25222
25222
|
"explicitId": "openwop.it.stream-modes.returns-400-and-a-structured-error-body",
|
|
25223
25223
|
"citations": [
|
|
@@ -25242,7 +25242,7 @@
|
|
|
25242
25242
|
{
|
|
25243
25243
|
"id": "openwop.it.stream-modes.returns-200-emits-at-least-one-event-server-closes-per-stream-modes-md-values",
|
|
25244
25244
|
"file": "stream-modes.test.ts",
|
|
25245
|
-
"line":
|
|
25245
|
+
"line": 94,
|
|
25246
25246
|
"title": "returns 200 + emits at least one event + server-closes per stream-modes.md §values",
|
|
25247
25247
|
"explicitId": "openwop.it.stream-modes.returns-200-emits-at-least-one-event-server-closes-per-stream-modes-md-values",
|
|
25248
25248
|
"citations": [
|
|
@@ -25259,7 +25259,7 @@
|
|
|
25259
25259
|
{
|
|
25260
25260
|
"id": "openwop.it.stream-modes.debug-stream-is-a-superset-of-updates-per-stream-modes-md-mode-mapping",
|
|
25261
25261
|
"file": "stream-modes.test.ts",
|
|
25262
|
-
"line":
|
|
25262
|
+
"line": 119,
|
|
25263
25263
|
"title": "debug stream is a superset of updates per stream-modes.md mode-mapping",
|
|
25264
25264
|
"explicitId": "openwop.it.stream-modes.debug-stream-is-a-superset-of-updates-per-stream-modes-md-mode-mapping",
|
|
25265
25265
|
"citations": [
|
|
@@ -28995,6 +28995,20 @@
|
|
|
28995
28995
|
}
|
|
28996
28996
|
]
|
|
28997
28997
|
},
|
|
28998
|
+
{
|
|
28999
|
+
"id": "openwop.it.v2-cors-preflight.every-operation-a-granted-origin-preflights-admits-its-method-and-every-request",
|
|
29000
|
+
"file": "v2-cors-preflight.test.ts",
|
|
29001
|
+
"line": 110,
|
|
29002
|
+
"title": "every operation a granted origin preflights admits its method and every request header the contract declares for it",
|
|
29003
|
+
"explicitId": "openwop.requirement.headers.cors-preflight-admits",
|
|
29004
|
+
"citations": [
|
|
29005
|
+
{
|
|
29006
|
+
"section": "spec/v2/core/headers.md §Cross-origin preflight",
|
|
29007
|
+
"requirement": null,
|
|
29008
|
+
"interpolated": true
|
|
29009
|
+
}
|
|
29010
|
+
]
|
|
29011
|
+
},
|
|
28998
29012
|
{
|
|
28999
29013
|
"id": "openwop.it.v2-created-run-readable.a-run-created-at-this-base-is-readable-and-pollable-at-this-base-by-the-id-it-re",
|
|
29000
29014
|
"file": "v2-created-run-readable.test.ts",
|
package/scenario-majors.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$comment": "GENERATED by conformance/scripts/generate-scenario-majors.mjs (RFC 0168 §D.3). Do not edit; add a file to BOTH_MAJORS in the generator to target both majors.",
|
|
3
3
|
"counts": {
|
|
4
|
-
"files":
|
|
4
|
+
"files": 575,
|
|
5
5
|
"v1": 452,
|
|
6
|
-
"v2":
|
|
6
|
+
"v2": 138
|
|
7
7
|
},
|
|
8
8
|
"majors": {
|
|
9
9
|
"a2a-1-0-agent-card.test.ts": [
|
|
@@ -1347,6 +1347,9 @@
|
|
|
1347
1347
|
"v2-conversation-turn-parts.test.ts": [
|
|
1348
1348
|
2
|
|
1349
1349
|
],
|
|
1350
|
+
"v2-cors-preflight.test.ts": [
|
|
1351
|
+
2
|
|
1352
|
+
],
|
|
1350
1353
|
"v2-created-run-readable.test.ts": [
|
|
1351
1354
|
2
|
|
1352
1355
|
],
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_comment": "Provenance of @openwop/spec-artifacts (RFC 0168 §D.2). files: SHA-256 per file; the conformance suite compares the installed peer against dist/spec-artifacts.lock.json at start.",
|
|
3
3
|
"package": "@openwop/spec-artifacts",
|
|
4
|
-
"version": "2.
|
|
5
|
-
"corpusTag": "v2.
|
|
4
|
+
"version": "2.45.0",
|
|
5
|
+
"corpusTag": "v2.45.0",
|
|
6
6
|
"files": {
|
|
7
7
|
"api/.redocly.lint-ignore.yaml": "bf5a8350b88a72fa43f59605ed8d903ed24b6cfccda5e45509c9f6ed9ee4e712",
|
|
8
8
|
"api/asyncapi.yaml": "d5ecb9ee6114582be3b1f662c84bfac9ae96dae7bacb853e461168f70a8e1c7d",
|
|
9
9
|
"api/grpc/openwop.proto": "c3e72bb17cba514ee98feb6434e6c9b6ea6795bfd086489ec69fd882dd1ad977",
|
|
10
10
|
"api/openapi.yaml": "69464bc0e1e71ef3b543a73d4fa4f67202343d2603c80af8033f6c3619ef1b30",
|
|
11
11
|
"api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
|
|
12
|
-
"api/seams-v2.yaml": "
|
|
13
|
-
"api/v2/asyncapi.yaml": "
|
|
14
|
-
"api/v2/openapi.yaml": "
|
|
12
|
+
"api/seams-v2.yaml": "92744ab58f83798c63b96700475bd55c201e7af14c3fd230c31ab0bbd86f6a60",
|
|
13
|
+
"api/v2/asyncapi.yaml": "4f00d2847c3ffb526af9c294f9e318b762f197e86cfd607934e677f12a1fc8bc",
|
|
14
|
+
"api/v2/openapi.yaml": "396fe10fc350cbae59190c4bd0e4cd24e18b16b4aac9db2f6c78e54344db4709",
|
|
15
15
|
"api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
|
|
16
16
|
"schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
|
|
17
17
|
"schemas/a2a-task-state.schema.json": "75d5049dea8bd873ff0e7546f1c60c8d36c219bec7084264360be54a8be30ae1",
|
|
@@ -204,13 +204,13 @@
|
|
|
204
204
|
"schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
|
|
205
205
|
"spec/v1/alias-detectors.json": "0b2f28808ffe0b9d98a187775038c3406bb70919747a788a6e4f866e293ae6d3",
|
|
206
206
|
"spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
|
|
207
|
-
"spec/v1/core-standard-manifest.json": "
|
|
207
|
+
"spec/v1/core-standard-manifest.json": "684dff535d3536673129ef594b97717cbc30fd066a6ea4d2c52d9fbbde69836d",
|
|
208
208
|
"spec/v1/deprecations.json": "a3293072ea951857395d05f0437264e88965f1e9019ff61c55bacfa9c1aaa17c",
|
|
209
209
|
"spec/v1/deprecations.schema.json": "3e393c405d2a41b467d8c5e3c468549078df1ce6a6d2588fc488097b95d9b55b",
|
|
210
210
|
"spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
|
|
211
211
|
"spec/v1/event-codemap.schema.json": "d05933b2e88103aff51a2f774df97b9bda065e0fc88dbd9b114cc47a32189174",
|
|
212
212
|
"spec/v1/extensions.json": "79a60754aa16cbdbdb604f8e5c00038af535a1690a4d07bd5ff7c38f0256a7bc",
|
|
213
|
-
"spec/v1/gaps.json": "
|
|
213
|
+
"spec/v1/gaps.json": "5be0fd0a8055f5cac76346fc2a98c44553f73d3f28a39ce12dcf1979d55d1409",
|
|
214
214
|
"spec/v1/gaps.schema.json": "8fd83259f556553c9df0f53e7a82ca8c2a4771d471197f69b4e2ae0ceeacfd66",
|
|
215
215
|
"spec/v1/migrations.json": "4c50760be70cb644d02427bcaf5749606700edf4513f423903cf3b1d12f5c300",
|
|
216
216
|
"spec/v1/migrations.schema.json": "886779aa6c22e646db097f5df210adb018a4dd14a7b815465a18c8a7056c8f72",
|
|
@@ -226,7 +226,7 @@
|
|
|
226
226
|
"spec/v2/core/events.md": "c1a20e3aa6736119ea07aa2cf66faa808a1874d2cde4933033b65506d1742883",
|
|
227
227
|
"spec/v2/core/execution.md": "8a0c9db1bbfb97c008ab9328b7333c1cc3318196e78bfbefb49e781bdf4f9d44",
|
|
228
228
|
"spec/v2/core/form-content-packs.md": "02931227899676fd4581a27017be2a6c4953904717be9b81c29d12f87ab3917d",
|
|
229
|
-
"spec/v2/core/headers.md": "
|
|
229
|
+
"spec/v2/core/headers.md": "21d6c8220c3ac75b10e31c4cf6179abb539e2558af5006e40228d8c383ed5c92",
|
|
230
230
|
"spec/v2/core/host-services.md": "18389b55286875922c26e13fbcd6c787fca2568f68b7e6f65176f1938404a218",
|
|
231
231
|
"spec/v2/core/i18n.md": "882904b7d59f992ab1e5f7346167161a54cf665b8fee47e64a3862cf9133a580",
|
|
232
232
|
"spec/v2/core/idempotency.md": "adbd317a6ebb57c2759c076297b31f67def9e391576bccd0bbea787b13e85872",
|
|
@@ -300,12 +300,12 @@
|
|
|
300
300
|
"spec/v2/interop-map.schema.json": "0017c882df34ee215e2e0729de051490e09d43aac3d2c949285698ed51832f12",
|
|
301
301
|
"spec/v2/migrations.json": "651954938a1e154c0b6f1e3dd6e105889d30de3bb65789f21de7f77a12869e9e",
|
|
302
302
|
"spec/v2/migrations.schema.json": "b91ade6320ad1b1185c9495b9424f3aa63438ed8e412c45171c227d82c0afd86",
|
|
303
|
-
"spec/v2/path-manifest.json": "
|
|
303
|
+
"spec/v2/path-manifest.json": "27c2307b84376b2f22bea4e2213fc7e33ef3bf8fd1d18039f7955edb00dc9c9a",
|
|
304
304
|
"spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
|
|
305
305
|
"spec/v2/profiles.json": "180beb9ef2d161766fa23b2987e7e829851e3d316273147cf6a4a3f324befd6b",
|
|
306
|
-
"spec/v2/release.json": "
|
|
306
|
+
"spec/v2/release.json": "89c093ee04d4aaceef641782e53fbc368e21123e244246f1727fa8ac3b151101",
|
|
307
307
|
"spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
|
|
308
|
-
"spec/v2/surface-baseline.json": "
|
|
308
|
+
"spec/v2/surface-baseline.json": "85c44cc57bfc9f8242e77dc27d7b28a1b239c694ef761cce4748ab15d6a3290f"
|
|
309
309
|
},
|
|
310
|
-
"corpusCommit": "
|
|
310
|
+
"corpusCommit": "1ac68fd60069d0b9729312976de4946171281f87"
|
|
311
311
|
}
|
|
@@ -21,9 +21,11 @@ import { pollUntilTerminal } from '../lib/polling.js';
|
|
|
21
21
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
22
22
|
import { req } from '../lib/requirement-ids.js';
|
|
23
23
|
import { blockedDespiteAssertions } from '../lib/soft-skip.js';
|
|
24
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
24
25
|
|
|
25
26
|
const NOOP_WORKFLOW_ID = 'conformance-noop';
|
|
26
27
|
const SKIP_NO_NOOP = !isFixtureAdvertised(NOOP_WORKFLOW_ID);
|
|
28
|
+
const NO_FIXTURE_REASON = `precondition not met — \`${NOOP_WORKFLOW_ID}\` is not advertised, so this host cannot witness the eventOrdering floor. RFC 0148 §A: \`blocked\`, naming the fixture (#1686: a describe-level skip recorded no disposition, and an unrecorded floor rejected the whole certification)`;
|
|
27
29
|
|
|
28
30
|
interface RawEvent {
|
|
29
31
|
// Event shape may use `seq` or `sequence` depending on host's event-
|
|
@@ -49,8 +51,9 @@ function getSeq(event: RawEvent): number | null {
|
|
|
49
51
|
// always yields a readable events[] of at least two. A host that does not was
|
|
50
52
|
// never observed ordering anything — until 2026-09-27 these recorded a
|
|
51
53
|
// partial-witness PASS.
|
|
52
|
-
describe
|
|
54
|
+
describe('event-ordering: polling returns events in monotonic order', () => {
|
|
53
55
|
it('events from a single poll have non-decreasing sequence numbers', async () => {
|
|
56
|
+
if (SKIP_NO_NOOP) return softSkip('blocked', NO_FIXTURE_REASON);
|
|
54
57
|
const create = await driver.post('/v1/runs', { workflowId: NOOP_WORKFLOW_ID });
|
|
55
58
|
expect(create.status).toBe(201);
|
|
56
59
|
const runId = (create.json as { runId: string }).runId;
|
|
@@ -76,6 +79,7 @@ describe.skipIf(SKIP_NO_NOOP)('event-ordering: polling returns events in monoton
|
|
|
76
79
|
});
|
|
77
80
|
|
|
78
81
|
it('repeated polls of a terminal run yield identical event sequences', async () => {
|
|
82
|
+
if (SKIP_NO_NOOP) return softSkip('blocked', NO_FIXTURE_REASON);
|
|
79
83
|
const create = await driver.post('/v1/runs', { workflowId: NOOP_WORKFLOW_ID });
|
|
80
84
|
expect(create.status).toBe(201);
|
|
81
85
|
const runId = (create.json as { runId: string }).runId;
|
|
@@ -106,8 +110,9 @@ describe.skipIf(SKIP_NO_NOOP)('event-ordering: polling returns events in monoton
|
|
|
106
110
|
});
|
|
107
111
|
});
|
|
108
112
|
|
|
109
|
-
describe
|
|
113
|
+
describe('event-ordering: terminal run has at most one terminal event', () => {
|
|
110
114
|
it('event stream contains exactly one of run.completed / run.failed / run.cancelled', async () => {
|
|
115
|
+
if (SKIP_NO_NOOP) return softSkip('blocked', NO_FIXTURE_REASON);
|
|
111
116
|
const create = await driver.post('/v1/runs', { workflowId: NOOP_WORKFLOW_ID });
|
|
112
117
|
expect(create.status).toBe(201);
|
|
113
118
|
const runId = (create.json as { runId: string }).runId;
|
|
@@ -129,6 +134,7 @@ describe.skipIf(SKIP_NO_NOOP)('event-ordering: terminal run has at most one term
|
|
|
129
134
|
});
|
|
130
135
|
|
|
131
136
|
it('the terminal event is the LAST event in the stream', async () => {
|
|
137
|
+
if (SKIP_NO_NOOP) return softSkip('blocked', NO_FIXTURE_REASON);
|
|
132
138
|
const create = await driver.post('/v1/runs', { workflowId: NOOP_WORKFLOW_ID });
|
|
133
139
|
expect(create.status).toBe(201);
|
|
134
140
|
const runId = (create.json as { runId: string }).runId;
|
|
@@ -9,12 +9,15 @@ import { driver } from '../lib/driver.js';
|
|
|
9
9
|
import { pollUntilTerminal } from '../lib/polling.js';
|
|
10
10
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
11
11
|
import { req } from '../lib/requirement-ids.js';
|
|
12
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
12
13
|
|
|
13
14
|
const WORKFLOW_ID = 'conformance-failure';
|
|
14
15
|
const SKIP_NO_FIXTURE = !isFixtureAdvertised(WORKFLOW_ID);
|
|
16
|
+
const NO_FIXTURE_REASON = `precondition not met — \`${WORKFLOW_ID}\` is not advertised, so this host cannot witness the failure-path floor. RFC 0148 §A: \`blocked\`, naming the fixture (#1686: a describe-level skip recorded no disposition, and an unrecorded floor rejected the whole certification)`;
|
|
15
17
|
|
|
16
|
-
describe
|
|
18
|
+
describe('failure: conformance-failure fixture reaches terminal `failed`', () => {
|
|
17
19
|
it('POST /v1/runs accepts the run and run terminates as failed with structured error', async () => {
|
|
20
|
+
if (SKIP_NO_FIXTURE) return softSkip('blocked', NO_FIXTURE_REASON);
|
|
18
21
|
const create = await driver.post('/v1/runs', { workflowId: WORKFLOW_ID });
|
|
19
22
|
expect(create.status, req('openwop.it.failure-path.post-v1-runs-accepts-the-run-and-run-terminates-as-failed-with-structured-error',
|
|
20
23
|
'rest-endpoints.md',
|
|
@@ -19,16 +19,19 @@ import { driver } from '../lib/driver.js';
|
|
|
19
19
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
20
20
|
import { readErrorCode } from '../lib/error-envelope.js';
|
|
21
21
|
import { req } from '../lib/requirement-ids.js';
|
|
22
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
22
23
|
|
|
23
24
|
const WORKFLOW_ID = 'conformance-idempotent';
|
|
24
25
|
const SKIP_NO_FIXTURE = !isFixtureAdvertised(WORKFLOW_ID);
|
|
26
|
+
const NO_FIXTURE_REASON = `precondition not met — \`${WORKFLOW_ID}\` is not advertised, so this host cannot witness the idempotency floor. RFC 0148 §A: \`blocked\`, naming the fixture (#1686: a describe-level skip recorded no disposition, and an unrecorded floor rejected the whole certification)`;
|
|
25
27
|
|
|
26
28
|
function freshKey(suffix: string): string {
|
|
27
29
|
return `openwop-conformance-${Date.now()}-${Math.random().toString(36).slice(2, 8)}-${suffix}`;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
describe
|
|
32
|
+
describe('idempotency: same key + same body replays per idempotency.md §Layer 1', () => {
|
|
31
33
|
it('returns same runId twice and sets openwop-Idempotent-Replay on the replay', async () => {
|
|
34
|
+
if (SKIP_NO_FIXTURE) return softSkip('blocked', NO_FIXTURE_REASON);
|
|
32
35
|
const key = freshKey('replay');
|
|
33
36
|
const body = { workflowId: WORKFLOW_ID, inputs: { nonce: 'abc-123' } };
|
|
34
37
|
|
|
@@ -66,8 +69,9 @@ describe.skipIf(SKIP_NO_FIXTURE)('idempotency: same key + same body replays per
|
|
|
66
69
|
});
|
|
67
70
|
});
|
|
68
71
|
|
|
69
|
-
describe
|
|
72
|
+
describe('idempotency: same key + different body conflicts per idempotency.md §Layer 1', () => {
|
|
70
73
|
it('returns 409 when the body changes under the same key', async () => {
|
|
74
|
+
if (SKIP_NO_FIXTURE) return softSkip('blocked', NO_FIXTURE_REASON);
|
|
71
75
|
const key = freshKey('conflict');
|
|
72
76
|
|
|
73
77
|
const first = await driver.post(
|
|
@@ -11,13 +11,16 @@ import { driver } from '../lib/driver.js';
|
|
|
11
11
|
import { pollUntilStatus, pollUntilTerminal } from '../lib/polling.js';
|
|
12
12
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
13
13
|
import { req } from '../lib/requirement-ids.js';
|
|
14
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
14
15
|
|
|
15
16
|
const WORKFLOW_ID = 'conformance-clarification';
|
|
16
17
|
const NODE_ID = 'ask';
|
|
17
18
|
const SKIP_NO_FIXTURE = !isFixtureAdvertised(WORKFLOW_ID);
|
|
19
|
+
const NO_FIXTURE_REASON = `precondition not met — \`${WORKFLOW_ID}\` is not advertised, so this host cannot witness the interrupt-clarification floor. RFC 0148 §A: \`blocked\`, naming the fixture (#1686: a describe-level skip recorded no disposition, and an unrecorded floor rejected the whole certification)`;
|
|
18
20
|
|
|
19
|
-
describe
|
|
21
|
+
describe('interrupt: clarification answers resume to `completed`', () => {
|
|
20
22
|
it('run suspends at ask, answers payload drives terminal completed', async () => {
|
|
23
|
+
if (SKIP_NO_FIXTURE) return softSkip('blocked', NO_FIXTURE_REASON);
|
|
21
24
|
const create = await driver.post('/v1/runs', { workflowId: WORKFLOW_ID });
|
|
22
25
|
expect(create.status).toBe(201);
|
|
23
26
|
const runId = (create.json as { runId: string }).runId;
|
|
@@ -33,6 +33,7 @@ import { req } from '../lib/requirement-ids.js';
|
|
|
33
33
|
|
|
34
34
|
const SOURCE_WORKFLOW_ID = 'conformance-noop';
|
|
35
35
|
const SKIP_NO_NOOP = !isFixtureAdvertised(SOURCE_WORKFLOW_ID);
|
|
36
|
+
const NO_FIXTURE_REASON = `precondition not met — \`${SOURCE_WORKFLOW_ID}\` is not advertised, so this host cannot witness the replay-fork floor. RFC 0148 §A: \`blocked\`, naming the fixture (#1686: a describe-level skip recorded no disposition, and an unrecorded floor rejected the whole certification)`;
|
|
36
37
|
|
|
37
38
|
async function fetchReplayModes(): Promise<readonly string[]> {
|
|
38
39
|
const res = await driver.get('/.well-known/openwop', { authenticated: false });
|
|
@@ -56,8 +57,9 @@ async function startAndFinishNoop(): Promise<string> {
|
|
|
56
57
|
return runId;
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
describe
|
|
60
|
+
describe('replay: fork from fromSeq=0 in replay mode', () => {
|
|
60
61
|
it('produces a new run that reaches terminal `completed`', async (ctx) => {
|
|
62
|
+
if (SKIP_NO_NOOP) return softSkip('blocked', NO_FIXTURE_REASON);
|
|
61
63
|
const modes = await fetchReplayModes();
|
|
62
64
|
if (!modes.includes('replay')) {
|
|
63
65
|
// Visible skip — earlier this was a silent `return` that
|
|
@@ -101,8 +103,9 @@ describe.skipIf(SKIP_NO_NOOP)('replay: fork from fromSeq=0 in replay mode', () =
|
|
|
101
103
|
});
|
|
102
104
|
});
|
|
103
105
|
|
|
104
|
-
describe
|
|
106
|
+
describe('replay: fork from fromSeq=0 in branch mode with empty overlay', () => {
|
|
105
107
|
it('produces a new run that reaches terminal `completed`', async (ctx) => {
|
|
108
|
+
if (SKIP_NO_NOOP) return softSkip('blocked', NO_FIXTURE_REASON);
|
|
106
109
|
const modes = await fetchReplayModes();
|
|
107
110
|
if (!modes.includes('branch')) {
|
|
108
111
|
softSkip('inapplicable', "host does not advertise the `branch` fork mode — this leg's rule has no path to apply");
|
|
@@ -130,7 +133,7 @@ describe.skipIf(SKIP_NO_NOOP)('replay: fork from fromSeq=0 in branch mode with e
|
|
|
130
133
|
});
|
|
131
134
|
});
|
|
132
135
|
|
|
133
|
-
describe
|
|
136
|
+
describe('replay: validation errors', () => {
|
|
134
137
|
// Earlier each of these tests had a silent `return;` early-exit
|
|
135
138
|
// when the host advertised no replay modes (or only the wrong mode for
|
|
136
139
|
// the assertion). That collapsed unexercised paths into vacuous green
|
|
@@ -140,6 +143,7 @@ describe.skipIf(SKIP_NO_NOOP)('replay: validation errors', () => {
|
|
|
140
143
|
// "exercised the validation path and got the expected error code."
|
|
141
144
|
|
|
142
145
|
it('rejects negative fromSeq with 400', async (ctx) => {
|
|
146
|
+
if (SKIP_NO_NOOP) return softSkip('blocked', NO_FIXTURE_REASON);
|
|
143
147
|
const modes = await fetchReplayModes();
|
|
144
148
|
if (modes.length === 0) {
|
|
145
149
|
softSkip('inapplicable', "host advertises no usable fork mode for this leg");
|
|
@@ -159,6 +163,7 @@ describe.skipIf(SKIP_NO_NOOP)('replay: validation errors', () => {
|
|
|
159
163
|
});
|
|
160
164
|
|
|
161
165
|
it('rejects fromSeq beyond source event log length with 422', async (ctx) => {
|
|
166
|
+
if (SKIP_NO_NOOP) return softSkip('blocked', NO_FIXTURE_REASON);
|
|
162
167
|
const modes = await fetchReplayModes();
|
|
163
168
|
if (modes.length === 0) {
|
|
164
169
|
softSkip('inapplicable', "host advertises no usable fork mode for this leg");
|
|
@@ -180,6 +185,7 @@ describe.skipIf(SKIP_NO_NOOP)('replay: validation errors', () => {
|
|
|
180
185
|
});
|
|
181
186
|
|
|
182
187
|
it('rejects replay mode with non-empty runOptionsOverlay (overlay is branch-only)', async (ctx) => {
|
|
188
|
+
if (SKIP_NO_NOOP) return softSkip('blocked', NO_FIXTURE_REASON);
|
|
183
189
|
const modes = await fetchReplayModes();
|
|
184
190
|
if (!modes.includes('replay')) {
|
|
185
191
|
// The rule under test (`replay` + non-empty `runOptionsOverlay`
|
|
@@ -206,6 +212,7 @@ describe.skipIf(SKIP_NO_NOOP)('replay: validation errors', () => {
|
|
|
206
212
|
});
|
|
207
213
|
|
|
208
214
|
it('rejects fork on a non-existent run with 404', async (ctx) => {
|
|
215
|
+
if (SKIP_NO_NOOP) return softSkip('blocked', NO_FIXTURE_REASON);
|
|
209
216
|
const modes = await fetchReplayModes();
|
|
210
217
|
if (modes.length === 0) {
|
|
211
218
|
softSkip('inapplicable', "host advertises no usable fork mode for this leg");
|
|
@@ -14,12 +14,15 @@ import { driver } from '../lib/driver.js';
|
|
|
14
14
|
import { pollUntilTerminal } from '../lib/polling.js';
|
|
15
15
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
16
16
|
import { req } from '../lib/requirement-ids.js';
|
|
17
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
17
18
|
|
|
18
19
|
const NOOP_WORKFLOW_ID = 'conformance-noop';
|
|
19
20
|
const SKIP_NO_NOOP = !isFixtureAdvertised(NOOP_WORKFLOW_ID);
|
|
21
|
+
const NO_FIXTURE_REASON = `precondition not met — \`${NOOP_WORKFLOW_ID}\` is not advertised, so this host cannot witness the runs-lifecycle floor. RFC 0148 §A: \`blocked\`, naming the fixture (#1686: a describe-level skip recorded no disposition, and an unrecorded floor rejected the whole certification)`;
|
|
20
22
|
|
|
21
|
-
describe
|
|
23
|
+
describe('run lifecycle: conformance-noop fixture', () => {
|
|
22
24
|
it('POST /v1/runs returns 201 with runId per rest-endpoints.md', async () => {
|
|
25
|
+
if (SKIP_NO_NOOP) return softSkip('blocked', NO_FIXTURE_REASON);
|
|
23
26
|
const res = await driver.post('/v1/runs', { workflowId: NOOP_WORKFLOW_ID });
|
|
24
27
|
|
|
25
28
|
expect(res.status, req('openwop.it.runs-lifecycle.post-v1-runs-returns-201-with-runid-per-rest-endpoints-md',
|
|
@@ -39,6 +42,7 @@ describe.skipIf(SKIP_NO_NOOP)('run lifecycle: conformance-noop fixture', () => {
|
|
|
39
42
|
});
|
|
40
43
|
|
|
41
44
|
it('reaches terminal `completed` within bounded time per fixtures.md noop spec', async () => {
|
|
45
|
+
if (SKIP_NO_NOOP) return softSkip('blocked', NO_FIXTURE_REASON);
|
|
42
46
|
const create = await driver.post('/v1/runs', { workflowId: NOOP_WORKFLOW_ID });
|
|
43
47
|
expect(create.status).toBe(201);
|
|
44
48
|
const runId = (create.json as { runId: string }).runId;
|
|
@@ -57,6 +61,7 @@ describe.skipIf(SKIP_NO_NOOP)('run lifecycle: conformance-noop fixture', () => {
|
|
|
57
61
|
});
|
|
58
62
|
|
|
59
63
|
it('GET /v1/runs/{nonexistentId} returns 404 (or 403) per rest-endpoints.md', async () => {
|
|
64
|
+
if (SKIP_NO_NOOP) return softSkip('blocked', NO_FIXTURE_REASON);
|
|
60
65
|
const res = await driver.get('/v1/runs/openwop-conformance-this-run-id-does-not-exist');
|
|
61
66
|
expect(
|
|
62
67
|
[403, 404].includes(res.status),
|
|
@@ -23,17 +23,20 @@ import { subscribe } from '../lib/sse.js';
|
|
|
23
23
|
import { pollUntilTerminal } from '../lib/polling.js';
|
|
24
24
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
25
25
|
import { req } from '../lib/requirement-ids.js';
|
|
26
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
26
27
|
|
|
27
28
|
const WORKFLOW_ID = 'conformance-delay';
|
|
28
29
|
const SKIP_NO_FIXTURE = !isFixtureAdvertised(WORKFLOW_ID);
|
|
30
|
+
const NO_FIXTURE_REASON = `precondition not met — \`${WORKFLOW_ID}\` is not advertised, so this host cannot witness the stream-modes-buffer floor. RFC 0148 §A: \`blocked\`, naming the fixture (#1686: a describe-level skip recorded no disposition, and an unrecorded floor rejected the whole certification)`;
|
|
29
31
|
|
|
30
32
|
interface RunEventDoc {
|
|
31
33
|
readonly type: string;
|
|
32
34
|
readonly sequence: number;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
describe
|
|
37
|
+
describe('stream-modes-buffer: ?bufferMs= aggregation hint', () => {
|
|
36
38
|
it('accepts bufferMs in range and emits at least one event: batch frame', async () => {
|
|
39
|
+
if (SKIP_NO_FIXTURE) return softSkip('blocked', NO_FIXTURE_REASON);
|
|
37
40
|
const create = await driver.post('/v1/runs', { workflowId: WORKFLOW_ID });
|
|
38
41
|
expect(create.status).toBe(201);
|
|
39
42
|
const runId = (create.json as { runId: string }).runId;
|
|
@@ -70,6 +73,7 @@ describe.skipIf(SKIP_NO_FIXTURE)('stream-modes-buffer: ?bufferMs= aggregation hi
|
|
|
70
73
|
});
|
|
71
74
|
|
|
72
75
|
it('rejects out-of-range bufferMs with 400 validation_error', async () => {
|
|
76
|
+
if (SKIP_NO_FIXTURE) return softSkip('blocked', NO_FIXTURE_REASON);
|
|
73
77
|
const create = await driver.post('/v1/runs', { workflowId: WORKFLOW_ID });
|
|
74
78
|
const runId = (create.json as { runId: string }).runId;
|
|
75
79
|
|
|
@@ -88,6 +92,7 @@ describe.skipIf(SKIP_NO_FIXTURE)('stream-modes-buffer: ?bufferMs= aggregation hi
|
|
|
88
92
|
});
|
|
89
93
|
|
|
90
94
|
it('forces flush on terminal — run.completed arrives bundled in a batch BEFORE the timer fires', async () => {
|
|
95
|
+
if (SKIP_NO_FIXTURE) return softSkip('blocked', NO_FIXTURE_REASON);
|
|
91
96
|
const create = await driver.post('/v1/runs', { workflowId: WORKFLOW_ID });
|
|
92
97
|
const runId = (create.json as { runId: string }).runId;
|
|
93
98
|
|
|
@@ -132,6 +137,7 @@ describe.skipIf(SKIP_NO_FIXTURE)('stream-modes-buffer: ?bufferMs= aggregation hi
|
|
|
132
137
|
});
|
|
133
138
|
|
|
134
139
|
it('bufferMs=0 behaves identically to omitting (per-event mode)', async () => {
|
|
140
|
+
if (SKIP_NO_FIXTURE) return softSkip('blocked', NO_FIXTURE_REASON);
|
|
135
141
|
const create = await driver.post('/v1/runs', { workflowId: WORKFLOW_ID });
|
|
136
142
|
const runId = (create.json as { runId: string }).runId;
|
|
137
143
|
|
|
@@ -22,16 +22,19 @@ import { subscribe, type SseEvent } from '../lib/sse.js';
|
|
|
22
22
|
import { pollUntilTerminal } from '../lib/polling.js';
|
|
23
23
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
24
24
|
import { req } from '../lib/requirement-ids.js';
|
|
25
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
25
26
|
|
|
26
27
|
const WORKFLOW_ID = 'conformance-delay';
|
|
27
28
|
const SKIP_NO_FIXTURE = !isFixtureAdvertised(WORKFLOW_ID);
|
|
29
|
+
const NO_FIXTURE_REASON = `precondition not met — \`${WORKFLOW_ID}\` is not advertised, so this host cannot witness the stream-modes-mixed floor. RFC 0148 §A: \`blocked\`, naming the fixture (#1686: a describe-level skip recorded no disposition, and an unrecorded floor rejected the whole certification)`;
|
|
28
30
|
|
|
29
31
|
function eventTypes(events: readonly SseEvent[]): string[] {
|
|
30
32
|
return events.map((e) => e.event);
|
|
31
33
|
}
|
|
32
34
|
|
|
33
|
-
describe
|
|
35
|
+
describe('stream-modes-mixed: comma-separated subsets', () => {
|
|
34
36
|
it('accepts streamMode=updates,messages and emits a server-closed stream', async () => {
|
|
37
|
+
if (SKIP_NO_FIXTURE) return softSkip('blocked', NO_FIXTURE_REASON);
|
|
35
38
|
const create = await driver.post('/v1/runs', {
|
|
36
39
|
workflowId: WORKFLOW_ID,
|
|
37
40
|
inputs: { delayMs: 500 },
|
|
@@ -62,6 +65,7 @@ describe.skipIf(SKIP_NO_FIXTURE)('stream-modes-mixed: comma-separated subsets',
|
|
|
62
65
|
});
|
|
63
66
|
|
|
64
67
|
it('rejects streamMode=values,updates with 400 + unsupported_stream_mode', async () => {
|
|
68
|
+
if (SKIP_NO_FIXTURE) return softSkip('blocked', NO_FIXTURE_REASON);
|
|
65
69
|
const create = await driver.post('/v1/runs', {
|
|
66
70
|
workflowId: WORKFLOW_ID,
|
|
67
71
|
inputs: { delayMs: 100 },
|
|
@@ -96,6 +100,7 @@ describe.skipIf(SKIP_NO_FIXTURE)('stream-modes-mixed: comma-separated subsets',
|
|
|
96
100
|
});
|
|
97
101
|
|
|
98
102
|
it('rejects streamMode=updates,bogus (one bad mode fails the whole list)', async () => {
|
|
103
|
+
if (SKIP_NO_FIXTURE) return softSkip('blocked', NO_FIXTURE_REASON);
|
|
99
104
|
const create = await driver.post('/v1/runs', {
|
|
100
105
|
workflowId: WORKFLOW_ID,
|
|
101
106
|
inputs: { delayMs: 100 },
|
|
@@ -114,6 +119,7 @@ describe.skipIf(SKIP_NO_FIXTURE)('stream-modes-mixed: comma-separated subsets',
|
|
|
114
119
|
});
|
|
115
120
|
|
|
116
121
|
it('mixed mode union: updates,debug sees every event updates sees', async () => {
|
|
122
|
+
if (SKIP_NO_FIXTURE) return softSkip('blocked', NO_FIXTURE_REASON);
|
|
117
123
|
// Run twice — once with updates only, once with updates,debug.
|
|
118
124
|
// The mixed-mode response MUST be a superset of the updates-only
|
|
119
125
|
// response (union semantics).
|
|
@@ -12,9 +12,11 @@ import { driver } from '../lib/driver.js';
|
|
|
12
12
|
import { subscribe, type SseEvent } from '../lib/sse.js';
|
|
13
13
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
14
14
|
import { req } from '../lib/requirement-ids.js';
|
|
15
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
15
16
|
|
|
16
17
|
const WORKFLOW_ID = 'conformance-delay';
|
|
17
18
|
const SKIP_NO_FIXTURE = !isFixtureAdvertised(WORKFLOW_ID);
|
|
19
|
+
const NO_FIXTURE_REASON = `precondition not met — \`${WORKFLOW_ID}\` is not advertised, so this host cannot witness the stream-modes floor. RFC 0148 §A: \`blocked\`, naming the fixture (#1686: a describe-level skip recorded no disposition, and an unrecorded floor rejected the whole certification)`;
|
|
18
20
|
|
|
19
21
|
async function startDelayRun(delayMs: number): Promise<string> {
|
|
20
22
|
const create = await driver.post('/v1/runs', {
|
|
@@ -31,8 +33,9 @@ function eventTypes(events: readonly SseEvent[]): string[] {
|
|
|
31
33
|
return events.map((e) => e.event);
|
|
32
34
|
}
|
|
33
35
|
|
|
34
|
-
describe
|
|
36
|
+
describe('stream-modes: updates (default) closes on terminal event', () => {
|
|
35
37
|
it('emits at least run.started + run.completed and server closes the stream', async () => {
|
|
38
|
+
if (SKIP_NO_FIXTURE) return softSkip('blocked', NO_FIXTURE_REASON);
|
|
36
39
|
const runId = await startDelayRun(1_000);
|
|
37
40
|
const { events, closedBy } = await subscribe(
|
|
38
41
|
`/v1/runs/${encodeURIComponent(runId)}/events?streamMode=updates`,
|
|
@@ -56,8 +59,9 @@ describe.skipIf(SKIP_NO_FIXTURE)('stream-modes: updates (default) closes on term
|
|
|
56
59
|
});
|
|
57
60
|
});
|
|
58
61
|
|
|
59
|
-
describe
|
|
62
|
+
describe('stream-modes: invalid streamMode is rejected', () => {
|
|
60
63
|
it('returns 400 and a structured error body', async () => {
|
|
64
|
+
if (SKIP_NO_FIXTURE) return softSkip('blocked', NO_FIXTURE_REASON);
|
|
61
65
|
const runId = await startDelayRun(1_000);
|
|
62
66
|
const res = await driver.get(
|
|
63
67
|
`/v1/runs/${encodeURIComponent(runId)}/events?streamMode=does-not-exist`,
|
|
@@ -86,8 +90,9 @@ describe.skipIf(SKIP_NO_FIXTURE)('stream-modes: invalid streamMode is rejected',
|
|
|
86
90
|
});
|
|
87
91
|
});
|
|
88
92
|
|
|
89
|
-
describe
|
|
93
|
+
describe('stream-modes: values mode is reachable + closes on terminal', () => {
|
|
90
94
|
it('returns 200 + emits at least one event + server-closes per stream-modes.md §values', async () => {
|
|
95
|
+
if (SKIP_NO_FIXTURE) return softSkip('blocked', NO_FIXTURE_REASON);
|
|
91
96
|
const runId = await startDelayRun(1_000);
|
|
92
97
|
const result = await subscribe(
|
|
93
98
|
`/v1/runs/${encodeURIComponent(runId)}/events?streamMode=values`,
|
|
@@ -110,8 +115,9 @@ describe.skipIf(SKIP_NO_FIXTURE)('stream-modes: values mode is reachable + close
|
|
|
110
115
|
});
|
|
111
116
|
});
|
|
112
117
|
|
|
113
|
-
describe
|
|
118
|
+
describe('stream-modes: debug emits at least as many events as updates', () => {
|
|
114
119
|
it('debug stream is a superset of updates per stream-modes.md mode-mapping', async () => {
|
|
120
|
+
if (SKIP_NO_FIXTURE) return softSkip('blocked', NO_FIXTURE_REASON);
|
|
115
121
|
const runIdUpdates = await startDelayRun(1_000);
|
|
116
122
|
const updatesResult = await subscribe(
|
|
117
123
|
`/v1/runs/${encodeURIComponent(runIdUpdates)}/events?streamMode=updates`,
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `headers.md` §Cross-origin preflight — a host that grants an origin admits
|
|
3
|
+
* what a conforming browser client sends (suite 2.45.0, target major 2;
|
|
4
|
+
* openwop#1763; self-gated on the host's own grant).
|
|
5
|
+
*
|
|
6
|
+
* A browser client MUST send `OpenWOP-Version` and `OpenWOP-Client-Version` on
|
|
7
|
+
* every request (versioning.md §1.3, §1.5), `Authorization` on an
|
|
8
|
+
* authenticated operation and `Content-Type: application/json` with a body.
|
|
9
|
+
* None of them is CORS-safelisted, so the browser preflights. When the host
|
|
10
|
+
* grants the origin but leaves one out of `Access-Control-Allow-Headers`, the
|
|
11
|
+
* browser never sends the request: the operation is unreachable from that
|
|
12
|
+
* origin and the host's logs show nothing. Both production hosts shipped that
|
|
13
|
+
* outage with explicit lists: openwop-app on `OpenWOP-Version` (2026-09-18) and
|
|
14
|
+
* both on `OpenWOP-Client-Version` at the SDK 2.5.0 release (RFC 0219 G8).
|
|
15
|
+
*
|
|
16
|
+
* The admitted set per operation comes from `spec/v2/path-manifest.json`, which
|
|
17
|
+
* `derive-v2-api.py` writes from `api/v2/openapi.yaml`: the declared header
|
|
18
|
+
* parameters, plus `Authorization` when authenticated and `Content-Type` when
|
|
19
|
+
* the operation takes a body. For each operation the leg sends
|
|
20
|
+
* `OPTIONS <path>` (path parameters filled with a placeholder) with `Origin`,
|
|
21
|
+
* `Access-Control-Request-Method` and `Access-Control-Request-Headers`, and
|
|
22
|
+
* reads the answer the way the Fetch standard's CORS-preflight check does:
|
|
23
|
+
*
|
|
24
|
+
* - granted: `Access-Control-Allow-Origin` is the origin or `*`. Anything
|
|
25
|
+
* else and the host does not serve that origin: that operation is not
|
|
26
|
+
* judged (a host MAY grant no origin at all; the rule is conditional).
|
|
27
|
+
* - method: a CORS-safelisted method (GET, HEAD, POST) is admitted without
|
|
28
|
+
* listing; any other must be listed, or `*` when credentials are not
|
|
29
|
+
* allowed. Compared case-sensitively, as Fetch does.
|
|
30
|
+
* - headers: every requested name listed (case-insensitive), or `*` when
|
|
31
|
+
* credentials are not allowed, and `*` never admits `authorization`.
|
|
32
|
+
*
|
|
33
|
+
* The row passes when at least one operation was granted and every granted one
|
|
34
|
+
* admits its set, fails when any granted one does not (naming the operation and
|
|
35
|
+
* the missing names), and is `inapplicable` when no operation was granted. The
|
|
36
|
+
* origin is `OPENWOP_CORS_ORIGIN` (default `https://conformance.invalid`): a
|
|
37
|
+
* host with an allowlist is inapplicable at the default, and its operator sets
|
|
38
|
+
* an allowlisted origin to be witnessed. A pass carries an `observed:` detail.
|
|
39
|
+
*
|
|
40
|
+
* @see spec/v2/core/headers.md §Cross-origin preflight
|
|
41
|
+
*/
|
|
42
|
+
import { describe, it, expect } from 'vitest';
|
|
43
|
+
import { readFileSync } from 'node:fs';
|
|
44
|
+
import { join } from 'node:path';
|
|
45
|
+
import { loadEnv } from '../lib/env.js';
|
|
46
|
+
import { v2Discovery } from '../lib/v2.js';
|
|
47
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
48
|
+
import { req } from '../lib/requirement-ids.js';
|
|
49
|
+
import { noteObservation } from '../lib/row-observation.js';
|
|
50
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
51
|
+
|
|
52
|
+
const ID = 'openwop.requirement.headers.cors-preflight-admits';
|
|
53
|
+
const DOC = 'spec/v2/core/headers.md §Cross-origin preflight';
|
|
54
|
+
const ORIGIN_ENV = 'OPENWOP_CORS_ORIGIN';
|
|
55
|
+
const DEFAULT_ORIGIN = 'https://conformance.invalid';
|
|
56
|
+
/** Fetch standard: a CORS-safelisted method passes the preflight method check without being listed. */
|
|
57
|
+
const SAFELISTED_METHODS = new Set(['GET', 'HEAD', 'POST']);
|
|
58
|
+
const PLACEHOLDER = 'conformance-cors-probe';
|
|
59
|
+
|
|
60
|
+
interface ManifestOp { readonly method: string; readonly path: string; readonly operationId: string; readonly requestHeaders?: readonly string[]; readonly authenticated?: boolean; readonly requestBody?: boolean }
|
|
61
|
+
interface Preflight { readonly status: number | null; readonly allowOrigin: string | null; readonly allowMethods: string | null; readonly allowHeaders: string | null; readonly allowCredentials: string | null }
|
|
62
|
+
|
|
63
|
+
function operations(): ManifestOp[] | null {
|
|
64
|
+
try {
|
|
65
|
+
const manifest = JSON.parse(readFileSync(join(SCHEMAS_DIR, '..', 'spec', 'v2', 'path-manifest.json'), 'utf8')) as { operations?: ManifestOp[] };
|
|
66
|
+
const ops = manifest.operations ?? [];
|
|
67
|
+
return ops.length > 0 && ops.every((o) => Array.isArray(o.requestHeaders) && typeof o.authenticated === 'boolean' && typeof o.requestBody === 'boolean') ? ops : null;
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The names a conforming browser client puts in Access-Control-Request-Headers for this operation, lowercased. */
|
|
74
|
+
export function requestedHeaders(op: ManifestOp): string[] {
|
|
75
|
+
const names = new Set((op.requestHeaders ?? []).map((h) => h.toLowerCase()));
|
|
76
|
+
if (op.authenticated === true) names.add('authorization');
|
|
77
|
+
if (op.requestBody === true) names.add('content-type');
|
|
78
|
+
return [...names].sort();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const tokens = (v: string | null): string[] => (v ?? '').split(',').map((t) => t.trim()).filter((t) => t.length > 0);
|
|
82
|
+
|
|
83
|
+
/** What the preflight answer fails to admit, per the Fetch standard's CORS-preflight check; empty when it admits everything. */
|
|
84
|
+
export function unadmitted(op: ManifestOp, p: Preflight): { method: string | null; headers: string[] } {
|
|
85
|
+
const credentials = p.allowCredentials === 'true';
|
|
86
|
+
const methods = tokens(p.allowMethods);
|
|
87
|
+
const methodOk = SAFELISTED_METHODS.has(op.method) || methods.includes(op.method) || (!credentials && methods.includes('*'));
|
|
88
|
+
const listed = new Set(tokens(p.allowHeaders).map((h) => h.toLowerCase()));
|
|
89
|
+
const star = !credentials && listed.has('*');
|
|
90
|
+
const headers = requestedHeaders(op).filter((h) => !listed.has(h) && !(star && h !== 'authorization'));
|
|
91
|
+
return { method: methodOk ? null : op.method, headers };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function preflight(baseUrl: string, op: ManifestOp, origin: string): Promise<Preflight> {
|
|
95
|
+
const path = op.path.replace(/\{[^}]+\}/g, PLACEHOLDER);
|
|
96
|
+
try {
|
|
97
|
+
const res = await fetch(`${baseUrl.replace(/\/$/, '')}${path}`, {
|
|
98
|
+
method: 'OPTIONS',
|
|
99
|
+
headers: { Origin: origin, 'Access-Control-Request-Method': op.method, 'Access-Control-Request-Headers': requestedHeaders(op).join(',') },
|
|
100
|
+
});
|
|
101
|
+
await res.arrayBuffer().catch(() => undefined);
|
|
102
|
+
const h = res.headers;
|
|
103
|
+
return { status: res.status, allowOrigin: h.get('access-control-allow-origin'), allowMethods: h.get('access-control-allow-methods'), allowHeaders: h.get('access-control-allow-headers'), allowCredentials: h.get('access-control-allow-credentials') };
|
|
104
|
+
} catch {
|
|
105
|
+
return { status: null, allowOrigin: null, allowMethods: null, allowHeaders: null, allowCredentials: null };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
describe('headers.md §Cross-origin preflight (self-gated on the host granting the origin)', () => {
|
|
110
|
+
it('every operation a granted origin preflights admits its method and every request header the contract declares for it', async () => {
|
|
111
|
+
if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
|
|
112
|
+
const ops = operations();
|
|
113
|
+
if (ops === null) return softSkip('blocked', 'spec/v2/path-manifest.json carries no per-operation requestHeaders / authenticated / requestBody (a corpus older than openwop#1763), so the admitted sets cannot be derived');
|
|
114
|
+
const origin = process.env[ORIGIN_ENV]?.trim() || DEFAULT_ORIGIN;
|
|
115
|
+
const { baseUrl } = loadEnv();
|
|
116
|
+
|
|
117
|
+
const granted: string[] = [];
|
|
118
|
+
const failures: string[] = [];
|
|
119
|
+
for (const op of ops) {
|
|
120
|
+
const p = await preflight(baseUrl, op, origin);
|
|
121
|
+
if (p.allowOrigin !== origin && p.allowOrigin !== '*') continue;
|
|
122
|
+
granted.push(op.operationId);
|
|
123
|
+
const miss = unadmitted(op, p);
|
|
124
|
+
if (miss.method !== null || miss.headers.length > 0) {
|
|
125
|
+
const parts = [
|
|
126
|
+
...(miss.method === null ? [] : [`method ${miss.method} not in Access-Control-Allow-Methods (${JSON.stringify(p.allowMethods)})`]),
|
|
127
|
+
...(miss.headers.length === 0 ? [] : [`${miss.headers.join(', ')} not in Access-Control-Allow-Headers (${JSON.stringify(p.allowHeaders)}${p.allowCredentials === 'true' ? ', credentials allowed so * admits nothing' : ''})`]),
|
|
128
|
+
];
|
|
129
|
+
failures.push(`${op.operationId} (${op.method} ${op.path}): ${parts.join('; ')}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
noteObservation(`origin ${origin}: ${granted.length}/${ops.length} operation(s) granted, ${failures.length} not admitting their set`);
|
|
133
|
+
if (granted.length === 0) {
|
|
134
|
+
return softSkip('inapplicable', `no operation's preflight granted origin ${origin} (Access-Control-Allow-Origin neither it nor *): the host does not serve that origin cross-origin, which is host policy. An operator whose host allowlists origins sets ${ORIGIN_ENV} to one of them to be witnessed`);
|
|
135
|
+
}
|
|
136
|
+
expect(
|
|
137
|
+
failures,
|
|
138
|
+
req(ID, DOC, `a host that grants an origin MUST admit, per operation, its method and every request header api/v2/openapi.yaml declares for it, Authorization when authenticated and Content-Type with a body; a browser that is refused never sends the request. ${failures.length} of ${granted.length} granted operation(s) do not: ${failures.join(' | ')}`),
|
|
139
|
+
).toEqual([]);
|
|
140
|
+
});
|
|
141
|
+
});
|