@openwop/openwop-conformance 2.0.0-rc.37 → 2.0.0-rc.6
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/README.md +2 -2
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +19 -234
- package/scenario-majors.json +3 -15
- package/schemas/CORPUS-STAMP.json +32 -34
- package/src/global-setup.ts +0 -11
- package/src/lib/corpus-stamp.ts +2 -24
- package/src/scenarios/v2-advertised-fixtures-exist.test.ts +43 -25
- package/src/scenarios/v2-dual-stack-negotiation.test.ts +1 -29
- package/src/scenarios/v2-era-2-append-vocabulary.test.ts +0 -9
- package/src/scenarios/v2-era-stamp-universal.test.ts +1 -1
- package/src/scenarios/v2-interrupt-token-scheme.test.ts +1 -1
- package/src/scenarios/version-negotiation.test.ts +3 -25
- package/src/setup.ts +0 -58
- package/src/scenarios/era-key-stamped-v1.test.ts +0 -156
- package/src/scenarios/v2-advertised-path-space-served.test.ts +0 -140
- package/src/scenarios/v2-run-completed-outputs.test.ts +0 -93
- package/src/scenarios/v2-version-header-honored.test.ts +0 -124
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `spec/v1/version-negotiation.md` §Stamping / §Legacy detection — the two
|
|
3
|
-
* run-document stamping MUSTs, and the legacy rule that makes their absence
|
|
4
|
-
* actively harmful (suite 2.0.0, target major 1; unaided).
|
|
5
|
-
*
|
|
6
|
-
* The rule is a v1 `MUST` and has been since the contract was written:
|
|
7
|
-
*
|
|
8
|
-
* §Stamping "Every persisted run document MUST carry an
|
|
9
|
-
* `eventLogSchemaVersion: number` field. The current v1
|
|
10
|
-
* value is `2`."
|
|
11
|
-
* §Legacy detection "Hosts identify an older run document as legacy when
|
|
12
|
-
* `eventLogSchemaVersion` is undefined or `< 2`" … legacy
|
|
13
|
-
* runs have "no event subcollection … Readers MUST fall
|
|
14
|
-
* back to the snapshot for state."
|
|
15
|
-
*
|
|
16
|
-
* **Nothing in the suite has ever asserted it.** `version-negotiation.test.ts`
|
|
17
|
-
* opens by claiming it checks "the four version axes (`engineVersion`,
|
|
18
|
-
* `eventLogSchemaVersion`, per-event `schemaVersion`, `pinnedVersions`) appear
|
|
19
|
-
* where the spec says they should" — and `protocolVersion` is the only axis it
|
|
20
|
-
* asserts. Across all 444 v1 scenario files the sole occurrence of the
|
|
21
|
-
* identifier `eventLogSchemaVersion` was that sentence: a docstring describing
|
|
22
|
-
* a check that does not exist. **A comment claiming coverage is worse than no
|
|
23
|
-
* comment**, because it answers "is this tested?" for anyone who greps, and
|
|
24
|
-
* answers it wrongly.
|
|
25
|
-
*
|
|
26
|
-
* Both production hosts were measured on 2026-09-04 and neither stamps the
|
|
27
|
-
* field on any run it has ever served. Each found it independently, after the
|
|
28
|
-
* other published its own greps.
|
|
29
|
-
*
|
|
30
|
-
* The consequence fails in the direction that punishes correctness. A client
|
|
31
|
-
* following §Legacy detection exactly classifies every such run as legacy and
|
|
32
|
-
* reads the snapshot — **ignoring the event log the host is in fact serving**.
|
|
33
|
-
* The host under-serves the conforming reader and over-serves the careless one.
|
|
34
|
-
*
|
|
35
|
-
* Why the schema could not catch it: `run-snapshot.schema.json` requires only
|
|
36
|
-
* `runId`, `workflowId` and `status`, so a snapshot missing the field validates
|
|
37
|
-
* cleanly. The obligation is prose-only, which is exactly the shape that needs
|
|
38
|
-
* a scenario rather than a keyword.
|
|
39
|
-
*
|
|
40
|
-
* @see spec/v1/version-negotiation.md §Stamping
|
|
41
|
-
* @see spec/v1/version-negotiation.md §Legacy detection
|
|
42
|
-
*/
|
|
43
|
-
|
|
44
|
-
import { describe, it, expect } from 'vitest';
|
|
45
|
-
import { driver } from '../lib/driver.js';
|
|
46
|
-
import { softSkip } from '../lib/soft-skip.js';
|
|
47
|
-
import { req } from '../lib/requirement-ids.js';
|
|
48
|
-
|
|
49
|
-
const ID_STAMPED = 'openwop.requirement.version-negotiation.era-key-stamped';
|
|
50
|
-
const ID_NOT_LEGACY = 'openwop.requirement.version-negotiation.era-key-not-legacy';
|
|
51
|
-
const ID_ENGINE = 'openwop.requirement.version-negotiation.engine-version-stamped';
|
|
52
|
-
const DOC = 'spec/v1/version-negotiation.md §Stamping';
|
|
53
|
-
|
|
54
|
-
interface Snapshot { readonly eventLogSchemaVersion?: unknown; readonly engineVersion?: unknown }
|
|
55
|
-
|
|
56
|
-
/** A run this host created moments ago — the one case where "legacy" cannot apply. */
|
|
57
|
-
async function freshRun(): Promise<{ runId: string } | { skip: string }> {
|
|
58
|
-
try {
|
|
59
|
-
// v1 path keys are explicit: the driver's unversioned rewrite is a major-2
|
|
60
|
-
// behaviour, and `/runs` answers 404 on a v1 host. The first version of this
|
|
61
|
-
// file used `/runs` and therefore SOFT-SKIPPED against a host that violates
|
|
62
|
-
// the rule — passing vacuously, which is the failure this scenario exists to
|
|
63
|
-
// catch, committed by the scenario itself.
|
|
64
|
-
const created = await driver.post('/v1/runs', { workflowId: 'conformance-noop', inputs: {} });
|
|
65
|
-
if (created.status !== 201) return { skip: `POST /v1/runs answered ${created.status} — no run to inspect` };
|
|
66
|
-
const runId = (created.json as { runId?: unknown } | null)?.runId;
|
|
67
|
-
if (typeof runId !== 'string') return { skip: 'POST /v1/runs returned no runId' };
|
|
68
|
-
return { runId };
|
|
69
|
-
} catch {
|
|
70
|
-
return { skip: 'POST /v1/runs unreachable' };
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
describe('era-key-stamped-v1 (version-negotiation.md §Stamping)', () => {
|
|
75
|
-
it('a run the host just created carries eventLogSchemaVersion', async () => {
|
|
76
|
-
const r = await freshRun();
|
|
77
|
-
if ('skip' in r) return softSkip('blocked', r.skip);
|
|
78
|
-
|
|
79
|
-
let snap;
|
|
80
|
-
try {
|
|
81
|
-
snap = await driver.get(`/v1/runs/${encodeURIComponent(r.runId)}`);
|
|
82
|
-
} catch {
|
|
83
|
-
return softSkip('blocked', 'GET /v1/runs/{runId} unreachable');
|
|
84
|
-
}
|
|
85
|
-
if (snap.status !== 200) return softSkip('blocked', `GET /v1/runs/{runId} answered ${snap.status}`);
|
|
86
|
-
|
|
87
|
-
const value = (snap.json as Snapshot | null)?.eventLogSchemaVersion;
|
|
88
|
-
expect(
|
|
89
|
-
value,
|
|
90
|
-
req(ID_STAMPED, DOC, 'every persisted run document MUST carry an eventLogSchemaVersion — the field is prose-only (run-snapshot.schema.json requires just runId, workflowId and status), so a snapshot without it validates cleanly and only this check can see its absence'),
|
|
91
|
-
).not.toBeUndefined();
|
|
92
|
-
expect(
|
|
93
|
-
typeof value === 'number',
|
|
94
|
-
req(ID_STAMPED, DOC, `eventLogSchemaVersion MUST be a number (got ${JSON.stringify(value)})`),
|
|
95
|
-
).toBe(true);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it('a freshly created run is not classified legacy by the host\'s own rule', async () => {
|
|
99
|
-
const r = await freshRun();
|
|
100
|
-
if ('skip' in r) return softSkip('blocked', r.skip);
|
|
101
|
-
|
|
102
|
-
let snap;
|
|
103
|
-
try {
|
|
104
|
-
snap = await driver.get(`/v1/runs/${encodeURIComponent(r.runId)}`);
|
|
105
|
-
} catch {
|
|
106
|
-
return softSkip('blocked', 'GET /v1/runs/{runId} unreachable');
|
|
107
|
-
}
|
|
108
|
-
if (snap.status !== 200) return softSkip('blocked', `GET /v1/runs/{runId} answered ${snap.status}`);
|
|
109
|
-
const value = (snap.json as Snapshot | null)?.eventLogSchemaVersion;
|
|
110
|
-
if (value === undefined) {
|
|
111
|
-
return softSkip('blocked', 'the field is absent — the stamping leg above records that; legacy classification cannot be judged separately from it');
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// §Legacy detection: "undefined or < 2" is legacy, and a legacy run means
|
|
115
|
-
// "no event subcollection … Readers MUST fall back to the snapshot". A host
|
|
116
|
-
// that serves an event log while stamping a legacy value is telling a
|
|
117
|
-
// conforming client to ignore the log it is serving.
|
|
118
|
-
expect(
|
|
119
|
-
typeof value === 'number' && value >= 2,
|
|
120
|
-
req(ID_NOT_LEGACY, 'spec/v1/version-negotiation.md §Legacy detection', `a run created moments ago MUST NOT be legacy: legacy is "undefined or < 2", and a legacy run is specified to have no event subcollection so readers MUST fall back to the snapshot. Stamping ${JSON.stringify(value)} on a new run instructs a CONFORMING client to ignore the event log this host is serving it — the failure lands on the correct reader and spares the careless one`),
|
|
121
|
-
).toBe(true);
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
it('a run the host just created carries engineVersion — the legacy escape cannot reach it', async () => {
|
|
125
|
-
const r = await freshRun();
|
|
126
|
-
if ('skip' in r) return softSkip('blocked', r.skip);
|
|
127
|
-
|
|
128
|
-
let snap;
|
|
129
|
-
try {
|
|
130
|
-
snap = await driver.get(`/v1/runs/${encodeURIComponent(r.runId)}`);
|
|
131
|
-
} catch {
|
|
132
|
-
return softSkip('blocked', 'GET /v1/runs/{runId} unreachable');
|
|
133
|
-
}
|
|
134
|
-
if (snap.status !== 200) return softSkip('blocked', `GET /v1/runs/{runId} answered ${snap.status}`);
|
|
135
|
-
|
|
136
|
-
// §Stamping: "Every persisted run document MUST carry an `engineVersion:
|
|
137
|
-
// number` field … Servers MAY omit this field on legacy runs that predate
|
|
138
|
-
// the contract." The escape is scoped to runs that PREDATE the contract, so
|
|
139
|
-
// it cannot cover a run created seconds ago — which is why this leg creates
|
|
140
|
-
// one rather than inspecting whatever happens to be in the store.
|
|
141
|
-
//
|
|
142
|
-
// Asserted here because nothing else asserts it ON A RUN: version-fold.test.ts
|
|
143
|
-
// reads engineVersion from the DISCOVERY document, and wasm-pack-load.test.ts
|
|
144
|
-
// carries it only as a type field. Both mention the identifier, so a grep
|
|
145
|
-
// suggests coverage that does not exist for this requirement.
|
|
146
|
-
const value = (snap.json as Snapshot | null)?.engineVersion;
|
|
147
|
-
expect(
|
|
148
|
-
value,
|
|
149
|
-
req(ID_ENGINE, DOC, 'every persisted run document MUST carry engineVersion; the "MAY omit" escape applies only to legacy runs that predate the contract, and this run was created moments ago'),
|
|
150
|
-
).not.toBeUndefined();
|
|
151
|
-
expect(
|
|
152
|
-
typeof value === 'number',
|
|
153
|
-
req(ID_ENGINE, DOC, `engineVersion MUST be a number set to the writer engine's CURRENT_ENGINE_VERSION at write time (got ${JSON.stringify(value)})`),
|
|
154
|
-
).toBe(true);
|
|
155
|
-
});
|
|
156
|
-
});
|
|
@@ -1,140 +0,0 @@
|
|
|
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 }
|
|
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
|
-
return { status: res.status };
|
|
67
|
-
} catch {
|
|
68
|
-
return { status: null };
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function parameterlessGets(): string[] {
|
|
73
|
-
try {
|
|
74
|
-
const manifest = JSON.parse(
|
|
75
|
-
readFileSync(join(SCHEMAS_DIR, '..', 'spec', 'v2', 'path-manifest.json'), 'utf8'),
|
|
76
|
-
) as { operations?: ReadonlyArray<{ method: string; path: string }> };
|
|
77
|
-
return (manifest.operations ?? [])
|
|
78
|
-
.filter((o) => o.method === 'GET' && !o.path.includes('{') && !NOT_PAIRABLE.has(o.path))
|
|
79
|
-
.map((o) => o.path)
|
|
80
|
-
.sort();
|
|
81
|
-
} catch {
|
|
82
|
-
return [];
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
describe('v2-advertised-path-space-served (RFC 0172 §A.1)', () => {
|
|
87
|
-
it('a host advertising major 2 reaches the surfaces it already serves under /v1', async () => {
|
|
88
|
-
const { baseUrl } = loadEnv();
|
|
89
|
-
const bare = await (async () => {
|
|
90
|
-
try {
|
|
91
|
-
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/.well-known/openwop`, { headers: { Accept: 'application/json' } });
|
|
92
|
-
return res.status === 200 ? ((await res.json()) as Record<string, unknown>) : null;
|
|
93
|
-
} catch {
|
|
94
|
-
return null;
|
|
95
|
-
}
|
|
96
|
-
})();
|
|
97
|
-
if (!bare) return softSkip('blocked', 'the discovery document is unreadable, so the advertised majors are unknown');
|
|
98
|
-
|
|
99
|
-
const versions = Array.isArray(bare['protocolVersions']) ? (bare['protocolVersions'] as unknown[]).map(String) : [];
|
|
100
|
-
if (!new Set(versions.map((v) => v.split('.')[0])).has('2')) {
|
|
101
|
-
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`);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const paths = parameterlessGets();
|
|
105
|
-
if (paths.length === 0) return softSkip('blocked', 'spec/v2/path-manifest.json is unreadable from this layout');
|
|
106
|
-
|
|
107
|
-
const overstated: string[] = [];
|
|
108
|
-
const served: string[] = [];
|
|
109
|
-
let pairable = 0;
|
|
110
|
-
for (const path of paths) {
|
|
111
|
-
const v1 = await get(`/v1${path}`, false);
|
|
112
|
-
if (v1.status === null) return softSkip('blocked', `the host became unreachable while probing /v1${path}`);
|
|
113
|
-
// The host does not serve this surface in EITHER major. Legitimate, and a
|
|
114
|
-
// different question from the one asked here.
|
|
115
|
-
if (v1.status === 404) continue;
|
|
116
|
-
pairable += 1;
|
|
117
|
-
|
|
118
|
-
const v2 = await get(path, true);
|
|
119
|
-
if (v2.status === null) return softSkip('blocked', `the host became unreachable while probing ${path}`);
|
|
120
|
-
// Any status but 404 means the route is MOUNTED — 401/403/422 all answer
|
|
121
|
-
// "this path exists". Only 404 says major 2 cannot reach it.
|
|
122
|
-
if (v2.status === 404) overstated.push(`${path} (/v1 → ${v1.status}, major 2 → 404)`);
|
|
123
|
-
else served.push(path);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
if (pairable === 0) {
|
|
127
|
-
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');
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
expect(
|
|
131
|
-
overstated,
|
|
132
|
-
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('; ')})`),
|
|
133
|
-
).toEqual([]);
|
|
134
|
-
|
|
135
|
-
expect(
|
|
136
|
-
served.length,
|
|
137
|
-
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'),
|
|
138
|
-
).toBeGreaterThan(0);
|
|
139
|
-
});
|
|
140
|
-
});
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `spec/v2/core/events.md` §Payloads — `run.completed` MUST carry `outputs`
|
|
3
|
-
* (suite 2.0.0, target major 2; unaided).
|
|
4
|
-
*
|
|
5
|
-
* This requirement is one day old, and the reason it needed writing is the
|
|
6
|
-
* reason this file exists. Both majors' payload schemas NAMED `outputs` on
|
|
7
|
-
* `runCompleted` and REQUIRED nothing. v1 also left the object open. So a
|
|
8
|
-
* tier-1 host emitted the singular `output` for its entire life and validated
|
|
9
|
-
* every time; v2 closing the object caught the EXTRA key — and that is the
|
|
10
|
-
* only reason anyone looked. Nothing in either major has ever caught an ABSENT
|
|
11
|
-
* one: a host emitting `run.completed {}` validates in v1 and, until rc.35, in
|
|
12
|
-
* v2. Seventeen scenario files mention `run.completed`; none read its payload.
|
|
13
|
-
*
|
|
14
|
-
* A key that no schema requires and no scenario reads is a requirement that
|
|
15
|
-
* exists only as a property name — the same shape as `eventLogSchemaVersion`,
|
|
16
|
-
* which lived in a schema `description` for the life of v1 while two hosts
|
|
17
|
-
* shipped without it. Asserting the EFFECT (the terminal event of a run that
|
|
18
|
-
* completed carries an outputs object) is the only form of this check a
|
|
19
|
-
* permissive emitter cannot satisfy by accident.
|
|
20
|
-
*
|
|
21
|
-
* @see spec/v2/core/events.md §Payloads
|
|
22
|
-
* @see schemas/v2/run-event-payloads.schema.json $defs/runCompleted
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import { describe, it, expect } from 'vitest';
|
|
26
|
-
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
27
|
-
import { v2Discovery, v2Validator } from '../lib/v2.js';
|
|
28
|
-
import { readErrorCode } from '../lib/error-envelope.js';
|
|
29
|
-
import { softSkip } from '../lib/soft-skip.js';
|
|
30
|
-
import { req } from '../lib/requirement-ids.js';
|
|
31
|
-
|
|
32
|
-
const ID = 'openwop.requirement.0171.run-completed-outputs';
|
|
33
|
-
const DOC = 'spec/v2/core/events.md §Payloads';
|
|
34
|
-
const NOOP_WORKFLOW_ID = 'conformance-noop';
|
|
35
|
-
|
|
36
|
-
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
|
|
37
|
-
try { return await fn(); } catch { return null; }
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
async function createRun(): Promise<{ runId: string } | { reason: string }> {
|
|
41
|
-
try { if (!(await v2Discovery())) return { reason: 'v2 discovery unreachable' }; } catch { return { reason: 'v2 discovery unreachable' }; }
|
|
42
|
-
const res = await http(() => driver.post('/runs', { workflowId: NOOP_WORKFLOW_ID }));
|
|
43
|
-
if (res === null) return { reason: 'POST /runs unreachable (fetch failed)' };
|
|
44
|
-
const runId = (res.json as { runId?: unknown } | undefined)?.runId;
|
|
45
|
-
if (res.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs {workflowId: ${NOOP_WORKFLOW_ID}} answered ${res.status} ${readErrorCode(res.json) ?? ''} — the smallest valid create was refused (fixture not seeded?)`.trim() };
|
|
46
|
-
return { runId };
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
interface Ev { readonly type?: unknown; readonly payload?: unknown }
|
|
50
|
-
|
|
51
|
-
/** Poll until the terminal event lands. The noop workflow completes in well under a second; the bound is for a slow host, not a slow workflow. */
|
|
52
|
-
async function terminalEvent(runId: string): Promise<{ ev: Ev } | { reason: string }> {
|
|
53
|
-
let last: string | null = null;
|
|
54
|
-
for (let i = 0; i < 20; i += 1) {
|
|
55
|
-
const res = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}/events/poll?timeout=1`));
|
|
56
|
-
if (res === null) return { reason: 'GET /runs/{runId}/events/poll unreachable (fetch failed)' };
|
|
57
|
-
if (res.status !== 200) return { reason: `GET /runs/{runId}/events/poll answered ${res.status}` };
|
|
58
|
-
const events = (res.json as { events?: unknown } | undefined)?.events;
|
|
59
|
-
if (Array.isArray(events)) {
|
|
60
|
-
const hit = events.find((e): e is Ev => typeof (e as Ev)?.type === 'string' && (e as Ev).type === 'run.completed');
|
|
61
|
-
if (hit) return { ev: hit };
|
|
62
|
-
const failed = events.find((e): e is Ev => typeof (e as Ev)?.type === 'string' && /^run\.(failed|cancelled)$/.test(String((e as Ev).type)));
|
|
63
|
-
if (failed) return { reason: `the noop run reached ${String(failed.type)} rather than run.completed — nothing to assert outputs on` };
|
|
64
|
-
last = `${events.length} event(s), none terminal`;
|
|
65
|
-
}
|
|
66
|
-
await new Promise((r) => setTimeout(r, 250));
|
|
67
|
-
}
|
|
68
|
-
return { reason: `run.completed never appeared (${last ?? 'no events'}) — the host may not run the noop workflow to completion` };
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
describe('v2 run-completed-outputs (events.md §Payloads)', () => {
|
|
72
|
-
it('the run.completed event of a run that completed carries an outputs object', async () => {
|
|
73
|
-
const c = await createRun();
|
|
74
|
-
if ('reason' in c) return softSkip('blocked', c.reason);
|
|
75
|
-
const t = await terminalEvent(c.runId);
|
|
76
|
-
if ('reason' in t) return softSkip('blocked', t.reason);
|
|
77
|
-
|
|
78
|
-
const payload = (t.ev.payload ?? null) as { outputs?: unknown; output?: unknown } | null;
|
|
79
|
-
expect(
|
|
80
|
-
payload !== null && typeof payload === 'object',
|
|
81
|
-
req(ID, DOC, 'run.completed MUST carry a payload object'),
|
|
82
|
-
).toBe(true);
|
|
83
|
-
expect(
|
|
84
|
-
payload !== null && typeof payload.outputs === 'object' && payload.outputs !== null && !Array.isArray(payload.outputs),
|
|
85
|
-
req(ID, DOC, `run.completed MUST carry \`outputs\` as an object (an empty object is valid) — a client cannot tell "no outputs" from "outputs not rendered" when the key is absent, and no schema in either major required it until rc.35, which is how a host emitted the singular \`output\` for its whole life. Got keys ${JSON.stringify(payload ? Object.keys(payload) : null)}`),
|
|
86
|
-
).toBe(true);
|
|
87
|
-
|
|
88
|
-
// The schema now requires it too; assert through the validator so the two
|
|
89
|
-
// cannot drift apart again without this leg saying which one moved.
|
|
90
|
-
const r = v2Validator('run-event')(t.ev);
|
|
91
|
-
expect(r.ok, req(ID, DOC, `the terminal event MUST validate against run-event.schema.json, whose runCompleted payload now requires outputs (${r.errors})`)).toBe(true);
|
|
92
|
-
});
|
|
93
|
-
});
|
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* RFC 0172 §A.3 — the `OpenWOP-Version` header is HONORED or REFUSED, never
|
|
3
|
-
* ignored (suite 2.0.0, target major 2; unaided).
|
|
4
|
-
*
|
|
5
|
-
* A host that does not implement v2 has two correct answers to
|
|
6
|
-
* `OpenWOP-Version: 2`: serve the v2 representation, or refuse with `406
|
|
7
|
-
* protocol_version_unsupported`. There is a third thing it can do, and it is
|
|
8
|
-
* the dangerous one — return `200` with the v1 document, ignoring the header
|
|
9
|
-
* entirely.
|
|
10
|
-
*
|
|
11
|
-
* That third case is invisible to every presence-gated scenario in the suite.
|
|
12
|
-
* A scenario that skips when a v2 shape is absent records `inapplicable`
|
|
13
|
-
* whether the host REFUSED major 2 or silently handed back v1; both are
|
|
14
|
-
* non-failures, so a bundle looks clean while witnessing nothing. This is not
|
|
15
|
-
* hypothetical: it is the shape that let RFC 0165 sit `Accepted` on a host
|
|
16
|
-
* that served none of it — the acceptance cited merged PRs, the gated
|
|
17
|
-
* scenarios recorded `inapplicable`, and nothing in the chain ever compared
|
|
18
|
-
* the two representations. Measured on a live tier-1 host 2026-09-04:
|
|
19
|
-
* `OpenWOP-Version: 2` returned `200` with a body byte-identical to the
|
|
20
|
-
* header-less fetch. Credit to the openwop-app session for the probe.
|
|
21
|
-
*
|
|
22
|
-
* The check is cheap and needs no v2 support: fetch the resource twice, once
|
|
23
|
-
* with the header and once without. A host advertising two majors MUST NOT
|
|
24
|
-
* return the same bytes for both, because the two representations differ by
|
|
25
|
-
* construction (`capabilities.md` §1). A host advertising one major MUST
|
|
26
|
-
* refuse rather than ignore.
|
|
27
|
-
*
|
|
28
|
-
* @see spec/v2/core/versioning.md §1.3
|
|
29
|
-
* @see RFCS/0172-v2-versioning-and-release.md §A.3
|
|
30
|
-
*/
|
|
31
|
-
|
|
32
|
-
import { describe, it, expect } from 'vitest';
|
|
33
|
-
import { loadEnv } from '../lib/env.js';
|
|
34
|
-
import { softSkip } from '../lib/soft-skip.js';
|
|
35
|
-
import { req } from '../lib/requirement-ids.js';
|
|
36
|
-
|
|
37
|
-
const ID = 'openwop.requirement.0172.version-header-honored';
|
|
38
|
-
const DOC = 'spec/v2/core/versioning.md §1.3';
|
|
39
|
-
const PATH = '/.well-known/openwop';
|
|
40
|
-
|
|
41
|
-
interface Fetched {
|
|
42
|
-
readonly status: number;
|
|
43
|
-
readonly body: string;
|
|
44
|
-
readonly version: string | null;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
async function fetchRepresentation(header: string | null): Promise<Fetched | null> {
|
|
48
|
-
const { baseUrl, apiKey } = loadEnv();
|
|
49
|
-
const headers: Record<string, string> = { Accept: 'application/json' };
|
|
50
|
-
if (header !== null) headers['OpenWOP-Version'] = header;
|
|
51
|
-
if (apiKey) headers['authorization'] = `Bearer ${apiKey}`;
|
|
52
|
-
try {
|
|
53
|
-
const res = await fetch(`${baseUrl.replace(/\/$/, '')}${PATH}`, { headers });
|
|
54
|
-
return { status: res.status, body: await res.text(), version: res.headers.get('openwop-version') };
|
|
55
|
-
} catch {
|
|
56
|
-
return null;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
describe('v2-version-header-honored (RFC 0172 §A.3)', () => {
|
|
61
|
-
it('OpenWOP-Version is honored or refused — never ignored', async () => {
|
|
62
|
-
const bare = await fetchRepresentation(null);
|
|
63
|
-
const asked = await fetchRepresentation('2.0');
|
|
64
|
-
if (bare === null || asked === null) {
|
|
65
|
-
return softSkip('blocked', `${PATH} unreachable for one of the two probes — the comparison needs both`);
|
|
66
|
-
}
|
|
67
|
-
if (bare.status !== 200) {
|
|
68
|
-
return softSkip('blocked', `the header-less GET ${PATH} answered ${bare.status}; there is no baseline representation to compare against`);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// A refusal is a correct answer and ends the check: the host has told the
|
|
72
|
-
// truth about not serving major 2.
|
|
73
|
-
if (asked.status === 406) {
|
|
74
|
-
expect(
|
|
75
|
-
asked.status,
|
|
76
|
-
req(ID, DOC, 'a host that does not serve major 2 MUST refuse with 406 protocol_version_unsupported, which it did'),
|
|
77
|
-
).toBe(406);
|
|
78
|
-
return softSkip('inapplicable', 'the host refused major 2 with the specified 406 — a correct answer, and there is no second representation to compare bytes against');
|
|
79
|
-
}
|
|
80
|
-
if (asked.status !== 200) {
|
|
81
|
-
return softSkip('blocked', `GET ${PATH} with OpenWOP-Version: 2.0 answered ${asked.status} — neither the v2 representation (200) nor the specified refusal (406), so this scenario cannot rule on it`);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// 200 means the host claims to have served major 2. The bytes decide
|
|
85
|
-
// whether it actually did.
|
|
86
|
-
expect(
|
|
87
|
-
asked.body === bare.body,
|
|
88
|
-
req(ID, DOC, `answering 200 to OpenWOP-Version: 2.0 with a body byte-identical to the header-less fetch means the header was IGNORED, not honored — the host served v1 and called it v2. Refusing with 406 is the correct answer for a host that does not implement major 2; silently returning v1 is the one answer that no presence-gated scenario can detect, because an absent v2 shape records \`inapplicable\` whether the host refused or ignored`),
|
|
89
|
-
).toBe(false);
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it('a host advertising two majors serves two different representations', async () => {
|
|
93
|
-
const bare = await fetchRepresentation(null);
|
|
94
|
-
if (bare === null || bare.status !== 200) {
|
|
95
|
-
return softSkip('blocked', `the header-less GET ${PATH} answered ${bare?.status ?? 'no response'}`);
|
|
96
|
-
}
|
|
97
|
-
let doc: Record<string, unknown>;
|
|
98
|
-
try {
|
|
99
|
-
doc = JSON.parse(bare.body) as Record<string, unknown>;
|
|
100
|
-
} catch {
|
|
101
|
-
return softSkip('blocked', `${PATH} did not return a JSON object`);
|
|
102
|
-
}
|
|
103
|
-
const versions = Array.isArray(doc['protocolVersions']) ? (doc['protocolVersions'] as unknown[]).map(String) : [];
|
|
104
|
-
const majors = new Set(versions.map((v) => v.split('.')[0]));
|
|
105
|
-
if (!(majors.has('1') && majors.has('2'))) {
|
|
106
|
-
return softSkip('inapplicable', `the host advertises [${versions.join(', ') || 'no protocolVersions'}] — one major or none, so there is no second representation to differ from`);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const asked = await fetchRepresentation('2.0');
|
|
110
|
-
if (asked === null) return softSkip('blocked', `${PATH} unreachable under OpenWOP-Version: 2.0`);
|
|
111
|
-
expect(
|
|
112
|
-
asked.status,
|
|
113
|
-
req(ID, DOC, `a host advertising both majors MUST serve major 2 when asked for it, not refuse (got ${asked.status})`),
|
|
114
|
-
).toBe(200);
|
|
115
|
-
expect(
|
|
116
|
-
asked.body === bare.body,
|
|
117
|
-
req(ID, DOC, 'the v1 document and the closed v2 root differ by construction (capabilities.md §1), so a host advertising both majors returning identical bytes for both has advertised a major it does not actually serve'),
|
|
118
|
-
).toBe(false);
|
|
119
|
-
expect(
|
|
120
|
-
asked.version?.trim().split('.')[0] ?? null,
|
|
121
|
-
req(ID, DOC, `the response to OpenWOP-Version: 2.0 MUST carry OpenWOP-Version naming the major it served (got ${String(asked.version)})`),
|
|
122
|
-
).toBe('2');
|
|
123
|
-
});
|
|
124
|
-
});
|