@openwop/openwop-conformance 1.72.2 → 1.98.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 +36 -0
- package/README.md +2 -2
- package/api/asyncapi.yaml +58 -0
- package/api/openapi.yaml +4 -1
- package/dist/cli.js +107 -1
- package/dist/lib/profiles.js +70 -4
- package/package.json +2 -1
- package/schemas/CORPUS-STAMP.json +2 -2
- package/schemas/README.md +2 -0
- package/schemas/capabilities.schema.json +2054 -572
- package/schemas/certification-bundle-v2.schema.json +108 -0
- package/schemas/run-event-payloads.schema.json +3411 -857
- package/schemas/run-event.schema.json +31 -9
- package/schemas/workflow-definition.schema.json +492 -130
- package/schemas/workload-identity.schema.json +73 -0
- package/src/cli.ts +119 -1
- package/src/lib/a2a-fake-peer.ts +20 -0
- package/src/lib/behavior-gate.ts +42 -7
- package/src/lib/llm-cache-key-recipe.ts +51 -0
- package/src/lib/mcp-fake-server.ts +20 -0
- package/src/lib/profiles.ts +95 -4
- package/src/lib/requirement-ledger.ts +138 -0
- package/src/lib/requirement-registry.ts +62 -0
- package/src/scenarios/a2a-version-negotiation.test.ts +159 -0
- package/src/scenarios/capability-example-root-layout.test.ts +113 -0
- package/src/scenarios/certification-bundle-v2.test.ts +157 -0
- package/src/scenarios/certification-floor-enforcement.test.ts +115 -0
- package/src/scenarios/compensation-behavior.test.ts +164 -0
- package/src/scenarios/compensation-profile.test.ts +175 -0
- package/src/scenarios/contract-provenance.test.ts +209 -0
- package/src/scenarios/core-manifest-and-extension-registry.test.ts +198 -0
- package/src/scenarios/discovery-canonical-family-no-shadow.test.ts +219 -0
- package/src/scenarios/effect-identity-composition.test.ts +129 -0
- package/src/scenarios/effect-identity-cross-scope.test.ts +82 -0
- package/src/scenarios/mcp-version-negotiation.test.ts +159 -0
- package/src/scenarios/multi-region-effect-vocabulary.test.ts +175 -0
- package/src/scenarios/multi-region-idempotency.test.ts +17 -7
- package/src/scenarios/openapi-resolved-paths.test.ts +127 -0
- package/src/scenarios/protocol-version-grammar.test.ts +119 -0
- package/src/scenarios/requirement-ledger.test.ts +162 -0
- package/src/scenarios/rfc-0147-self-audit.test.ts +104 -0
- package/src/scenarios/rfc-lifecycle-coherence.test.ts +137 -0
- package/src/scenarios/semantic-digest-v2.test.ts +128 -0
- package/src/scenarios/semantic-digest-vectors.test.ts +140 -0
- package/src/scenarios/spec-corpus-validity.test.ts +22 -8
- package/src/scenarios/strict-behavior-gate.test.ts +120 -0
- package/src/scenarios/versioned-composition-profiles.test.ts +183 -0
- package/src/scenarios/workload-identity-behavior.test.ts +188 -0
- package/src/scenarios/workload-identity-profile.test.ts +175 -0
- package/vectors/semantic-request-digest-v2.json +236 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0148 §C / gap G6 — an undefined floor set is UNPROVABLE, never proven.
|
|
3
|
+
*
|
|
4
|
+
* `conformance-certification.md` §B(2) requires every floor scenario of a
|
|
5
|
+
* claimed profile to appear in `results.passed`, and `profiles.md` §"Claiming
|
|
6
|
+
* vs passing" says a host claims a profile "by satisfying its predicate AND
|
|
7
|
+
* passing the conformance scenarios labelled with the profile tag."
|
|
8
|
+
*
|
|
9
|
+
* `PROFILE_FLOOR_SCENARIOS` transcribed that prose for `openwop-core-standard`
|
|
10
|
+
* alone. For every other profile the floor was `undefined`, and the verifier
|
|
11
|
+
* computed `floorProven` from `missingFloor.length === 0 && prefixOk` — both
|
|
12
|
+
* vacuously true over an absent floor. So a claim with nothing behind it
|
|
13
|
+
* verified as PROVEN. That is a third vacuity mode alongside the two RFC 0148
|
|
14
|
+
* §"Motivation" names: those let an unexecuted assertion count as a pass, this
|
|
15
|
+
* lets an entire profile claim verify against nothing.
|
|
16
|
+
*
|
|
17
|
+
* These legs are server-free and always-on. A floor rule that only ran when a
|
|
18
|
+
* host advertised something would be gated on the very claim it exists to
|
|
19
|
+
* check.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, it, expect } from 'vitest';
|
|
23
|
+
import { verifyBundle, verifyBundleProfile, PROFILE_FLOOR_SCENARIOS } from '../lib/profiles.js';
|
|
24
|
+
|
|
25
|
+
/** Derives `openwop-core` and `openwop-stream-sse` (transports omitted ⇒ all). */
|
|
26
|
+
const streamingDiscovery = {
|
|
27
|
+
protocolVersion: '1.0',
|
|
28
|
+
supportedEnvelopes: ['final', 'clarification.request'],
|
|
29
|
+
schemaVersions: { 'workflow-definition': '1.0' },
|
|
30
|
+
limits: { clarificationRounds: 3, schemaRounds: 2, envelopesPerTurn: 8 },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const bundleClaiming = (profiles: readonly string[], passed: readonly string[], failed: readonly string[] = []) => ({
|
|
34
|
+
discovery: { document: streamingDiscovery },
|
|
35
|
+
claimedProfiles: profiles,
|
|
36
|
+
results: { passed, failed },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('RFC 0148 §C — floor enforcement is not vacuous', () => {
|
|
40
|
+
it('a profile with NO floor definition is unprovable, not proven', () => {
|
|
41
|
+
// `openwop-interrupts` is deliberately untranscribed: its prose section does
|
|
42
|
+
// not yet name a settled floor set. Claiming it must therefore fail — the
|
|
43
|
+
// corpus cannot substantiate it, which is a different thing from the host
|
|
44
|
+
// having failed something.
|
|
45
|
+
const v = verifyBundleProfile(bundleClaiming(['openwop-interrupts'], []), 'openwop-interrupts');
|
|
46
|
+
expect(v.floorUnspecified, 'no floor set is defined for openwop-interrupts').toBe(true);
|
|
47
|
+
expect(v.floorProven, 'RFC 0148 §C: an undefined floor MUST NOT satisfy the floor condition').toBe(false);
|
|
48
|
+
expect(v.valid).toBe(false);
|
|
49
|
+
expect(v.missingFloor, 'nothing is "missing" — the floor was never evaluable').toEqual([]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('a discovery-only profile with an EXPLICIT empty floor is proven by its predicate alone', () => {
|
|
53
|
+
// The distinction the fix turns on: `openwop-fixtures` is discovery-payload
|
|
54
|
+
// -only by `profiles.md`, so an empty floor is a decision on record. Without
|
|
55
|
+
// `discoveryOnly`, "legitimately empty" and "not yet written" are the same
|
|
56
|
+
// value, which is what produced the defect.
|
|
57
|
+
const v = verifyBundleProfile(bundleClaiming(['openwop-fixtures'], []), 'openwop-fixtures');
|
|
58
|
+
expect(PROFILE_FLOOR_SCENARIOS['openwop-fixtures']?.discoveryOnly).toBe(true);
|
|
59
|
+
expect(v.floorUnspecified).toBe(false);
|
|
60
|
+
expect(v.floorProven).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('a claimed streaming profile whose floor scenarios FAILED is rejected', () => {
|
|
64
|
+
// The shape of the real defect: the one published v1 bundle claimed
|
|
65
|
+
// `openwop-stream-sse` while all three `stream-modes*` scenarios sat in its
|
|
66
|
+
// own `results.failed`, and the verifier accepted it.
|
|
67
|
+
const v = verifyBundleProfile(
|
|
68
|
+
bundleClaiming(
|
|
69
|
+
['openwop-stream-sse'],
|
|
70
|
+
[],
|
|
71
|
+
['stream-modes.test.ts', 'stream-modes-buffer.test.ts', 'stream-modes-mixed.test.ts'],
|
|
72
|
+
),
|
|
73
|
+
'openwop-stream-sse',
|
|
74
|
+
);
|
|
75
|
+
expect(v.derivable, 'the discovery document does derive the profile').toBe(true);
|
|
76
|
+
expect(v.floorProven, 'profiles.md §openwop-stream-sse: predicate AND those scenarios pass').toBe(false);
|
|
77
|
+
expect(v.missingFloor).toContain('stream-modes.test.ts');
|
|
78
|
+
expect(v.valid).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('a claimed streaming profile whose floor scenarios all PASSED is accepted', () => {
|
|
82
|
+
const v = verifyBundleProfile(
|
|
83
|
+
bundleClaiming(['openwop-stream-sse'], [
|
|
84
|
+
'stream-modes.test.ts',
|
|
85
|
+
'stream-modes-buffer.test.ts',
|
|
86
|
+
'stream-modes-mixed.test.ts',
|
|
87
|
+
]),
|
|
88
|
+
'openwop-stream-sse',
|
|
89
|
+
);
|
|
90
|
+
expect(v.floorProven).toBe(true);
|
|
91
|
+
expect(v.valid).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('one unprovable claim invalidates the whole bundle', () => {
|
|
95
|
+
// A bundle is valid iff EVERY claim is. Mixing a provable claim with an
|
|
96
|
+
// unprovable one must not average out to valid.
|
|
97
|
+
const r = verifyBundle(bundleClaiming(['openwop-core', 'openwop-interrupts'], []));
|
|
98
|
+
expect(r.verdicts.find((v) => v.profile === 'openwop-core')?.valid).toBe(true);
|
|
99
|
+
expect(r.verdicts.find((v) => v.profile === 'openwop-interrupts')?.valid).toBe(false);
|
|
100
|
+
expect(r.valid).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('every transcribed floor names scenario files that exist in this suite', () => {
|
|
104
|
+
// Guards the transcription itself: a floor citing a renamed or deleted
|
|
105
|
+
// scenario can never be satisfied, which would fail honest hosts for a
|
|
106
|
+
// reason unrelated to their behavior.
|
|
107
|
+
const all = Object.entries(PROFILE_FLOOR_SCENARIOS).flatMap(([profile, floor]) =>
|
|
108
|
+
floor.required.map((scenario) => ({ profile, scenario })),
|
|
109
|
+
);
|
|
110
|
+
expect(all.length, 'the floor map MUST NOT be empty — an empty map re-opens the vacuity').toBeGreaterThan(0);
|
|
111
|
+
for (const { profile, scenario } of all) {
|
|
112
|
+
expect(scenario, `${profile} floor cites a non-scenario filename`).toMatch(/\.test\.ts$/);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0151 §C–§G — the compensation BEHAVIORAL witness.
|
|
3
|
+
*
|
|
4
|
+
* This file exists because RFC 0147 §A.5 requires "at least one host MUST
|
|
5
|
+
* execute every normative behavioral path in strict mode", and until now RFC
|
|
6
|
+
* 0151 had no behavioral scenario at all — only a server-free schema check.
|
|
7
|
+
* A willing host had nothing to run.
|
|
8
|
+
*
|
|
9
|
+
* That is a gap in the *suite*, not in any host, and it is worth naming plainly:
|
|
10
|
+
* for several turns the blocker was described as "no host implements this", when
|
|
11
|
+
* part of it was that the evidence-collecting apparatus did not exist either.
|
|
12
|
+
*
|
|
13
|
+
* Every leg here is capability-gated on `compensation.supported` via
|
|
14
|
+
* `behaviorGate`, so it soft-skips against a host that does not advertise and
|
|
15
|
+
* HARD-FAILS under `OPENWOP_REQUIRE_BEHAVIOR=true`. The gate records an RFC 0148
|
|
16
|
+
* §A ledger disposition either way, so an unrun requirement resolves to
|
|
17
|
+
* `blocked` rather than to silence.
|
|
18
|
+
*
|
|
19
|
+
* What each leg pins, and why it is the observable projection of a §C rule that
|
|
20
|
+
* would otherwise be unverifiable:
|
|
21
|
+
*
|
|
22
|
+
* - **Plan before first effect.** §C: "the host MUST persist a compensation
|
|
23
|
+
* plan before executing its first inverse action." Black-box, that is
|
|
24
|
+
* `compensation.requested` strictly preceding `compensation.started`. A host
|
|
25
|
+
* that starts unwinding before persisting cannot resume after a crash — and
|
|
26
|
+
* the crash is exactly when it matters.
|
|
27
|
+
* - **Reverse-completion order.** §C orders by *descending durable
|
|
28
|
+
* forward-completion sequence*. Compensating in forward order can release a
|
|
29
|
+
* resource another inverse action still depends on.
|
|
30
|
+
* - **Replay does not re-fire.** §F. A replay that re-executes inverse effects
|
|
31
|
+
* turns a recovery into a second outage.
|
|
32
|
+
* - **No provider bodies or credentials in events.** §D and §G. These events
|
|
33
|
+
* land in the durable log, which is the least revocable place a credential
|
|
34
|
+
* can go.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { describe, it, expect } from 'vitest';
|
|
38
|
+
import { driver } from '../lib/driver.js';
|
|
39
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
40
|
+
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
41
|
+
|
|
42
|
+
const PROFILE = 'openwop-compensation';
|
|
43
|
+
|
|
44
|
+
interface CompensationCaps {
|
|
45
|
+
readonly supported?: boolean;
|
|
46
|
+
readonly orderingModels?: readonly string[];
|
|
47
|
+
readonly manualIntervention?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** §D's closed event set. Content-free by construction. */
|
|
51
|
+
const COMPENSATION_EVENTS = [
|
|
52
|
+
'compensation.requested',
|
|
53
|
+
'compensation.started',
|
|
54
|
+
'compensation.completed',
|
|
55
|
+
'compensation.failed',
|
|
56
|
+
'compensation.paused',
|
|
57
|
+
'compensation.manual_intervention_required',
|
|
58
|
+
] as const;
|
|
59
|
+
|
|
60
|
+
async function advertised(): Promise<boolean> {
|
|
61
|
+
const disco = await driver.get('/.well-known/openwop');
|
|
62
|
+
const caps = capabilityFamily<CompensationCaps>(disco.json, 'compensation');
|
|
63
|
+
return caps?.supported === true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe('RFC 0151 §C — compensation lifecycle (capability-gated behavior)', () => {
|
|
67
|
+
it('a host advertising compensation implements reverse-completion', async () => {
|
|
68
|
+
if (!behaviorGate(PROFILE, await advertised())) return;
|
|
69
|
+
const disco = await driver.get('/.well-known/openwop');
|
|
70
|
+
const caps = capabilityFamily<CompensationCaps>(disco.json, 'compensation');
|
|
71
|
+
expect(
|
|
72
|
+
caps?.orderingModels ?? [],
|
|
73
|
+
driver.describe(
|
|
74
|
+
'RFCS/0151-compensation-and-partial-failure-profile.md §A',
|
|
75
|
+
'an advertising host MUST implement `reverse-completion`; `dependency-graph` is optional',
|
|
76
|
+
),
|
|
77
|
+
).toContain('reverse-completion');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('the plan is persisted before the first inverse action', async () => {
|
|
81
|
+
if (!behaviorGate(PROFILE, await advertised())) return;
|
|
82
|
+
// §C's crash-safety rule, in its only black-box form: `requested` (plan
|
|
83
|
+
// persisted) strictly precedes `started` (first inverse action). A host that
|
|
84
|
+
// unwinds before persisting cannot resume — and the crash is precisely when
|
|
85
|
+
// resumption is the thing that matters.
|
|
86
|
+
const seam = await driver.post('/v1/host/sample/test/compensation/unwind', {});
|
|
87
|
+
if (seam.status === 404) {
|
|
88
|
+
expect(
|
|
89
|
+
seam.status,
|
|
90
|
+
driver.describe(
|
|
91
|
+
'RFCS/0151 §C',
|
|
92
|
+
'a host advertising `compensation` MUST expose the unwind sample seam so plan-before-effect ' +
|
|
93
|
+
'ordering can be witnessed; without it the requirement is unobservable and the profile ' +
|
|
94
|
+
'cannot be certified (RFC 0148 §A: unobservable resolves to `blocked`, not to a pass)',
|
|
95
|
+
),
|
|
96
|
+
).not.toBe(404);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const events = (seam.json as { events?: { type: string }[] }).events ?? [];
|
|
100
|
+
const requestedAt = events.findIndex((e) => e.type === 'compensation.requested');
|
|
101
|
+
const startedAt = events.findIndex((e) => e.type === 'compensation.started');
|
|
102
|
+
expect(requestedAt, driver.describe('RFCS/0151 §D', '`compensation.requested` MUST be emitted')).toBeGreaterThanOrEqual(0);
|
|
103
|
+
expect(startedAt, driver.describe('RFCS/0151 §D', '`compensation.started` MUST be emitted')).toBeGreaterThanOrEqual(0);
|
|
104
|
+
expect(
|
|
105
|
+
requestedAt,
|
|
106
|
+
driver.describe(
|
|
107
|
+
'RFCS/0151 §C',
|
|
108
|
+
'the plan MUST be persisted BEFORE the first inverse action executes',
|
|
109
|
+
),
|
|
110
|
+
).toBeLessThan(startedAt);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('inverse actions run in descending forward-completion order', async () => {
|
|
114
|
+
if (!behaviorGate(PROFILE, await advertised())) return;
|
|
115
|
+
const seam = await driver.post('/v1/host/sample/test/compensation/unwind', { nodes: 3 });
|
|
116
|
+
if (seam.status === 404) return; // covered by the seam assertion above
|
|
117
|
+
const order = (seam.json as { compensatedOrder?: number[] }).compensatedOrder ?? [];
|
|
118
|
+
const descending = [...order].sort((a, b) => b - a);
|
|
119
|
+
expect(
|
|
120
|
+
order,
|
|
121
|
+
driver.describe(
|
|
122
|
+
'RFCS/0151 §C',
|
|
123
|
+
'`reverse-completion` orders compensations by DESCENDING durable forward-completion ' +
|
|
124
|
+
'sequence. Compensating forward can release a resource a later inverse action still needs.',
|
|
125
|
+
),
|
|
126
|
+
).toEqual(descending);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('replay does not re-fire inverse effects', async () => {
|
|
130
|
+
if (!behaviorGate(PROFILE, await advertised())) return;
|
|
131
|
+
const seam = await driver.post('/v1/host/sample/test/compensation/replay', {});
|
|
132
|
+
if (seam.status === 404) return;
|
|
133
|
+
expect(
|
|
134
|
+
(seam.json as { refiredEffects?: number }).refiredEffects ?? 0,
|
|
135
|
+
driver.describe(
|
|
136
|
+
'RFCS/0151 §F',
|
|
137
|
+
'replay defaults MUST use recorded compensation outcomes and MUST NOT re-fire inverse ' +
|
|
138
|
+
'effects — a replay that re-executes them turns a recovery into a second outage',
|
|
139
|
+
),
|
|
140
|
+
).toBe(0);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('compensation events carry no provider bodies or credentials', async () => {
|
|
144
|
+
if (!behaviorGate(PROFILE, await advertised())) return;
|
|
145
|
+
const seam = await driver.post('/v1/host/sample/test/compensation/unwind', {});
|
|
146
|
+
if (seam.status === 404) return;
|
|
147
|
+
const events = (seam.json as { events?: Record<string, unknown>[] }).events ?? [];
|
|
148
|
+
for (const e of events) {
|
|
149
|
+
if (!COMPENSATION_EVENTS.includes(e['type'] as (typeof COMPENSATION_EVENTS)[number])) continue;
|
|
150
|
+
const serialized = JSON.stringify(e);
|
|
151
|
+
for (const forbidden of ['-----BEGIN', 'Bearer ', 'sk-', 'authorization', 'providerResponse']) {
|
|
152
|
+
expect(
|
|
153
|
+
serialized.toLowerCase().includes(forbidden.toLowerCase()),
|
|
154
|
+
driver.describe(
|
|
155
|
+
'RFCS/0151 §D + §G',
|
|
156
|
+
'compensation event payloads carry opaque IDs and closed reason codes — never provider ' +
|
|
157
|
+
'bodies or credentials. These land in the DURABLE log, which is the least revocable ' +
|
|
158
|
+
`place a credential can reach. Found: ${forbidden}`,
|
|
159
|
+
),
|
|
160
|
+
).toBe(false);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0151 — the compensation profile's shape contract.
|
|
3
|
+
*
|
|
4
|
+
* **What this proves, stated first because RFC 0147 §A.5 turns on it:** the
|
|
5
|
+
* schemas admit exactly the shapes §A and §B describe and reject the ones they
|
|
6
|
+
* forbid. That is *shape-only* evidence. It is **not** evidence that any host
|
|
7
|
+
* orders an unwind correctly, persists a plan before the first inverse action,
|
|
8
|
+
* or resumes one after a crash — and RFC 0151 cannot reach a defensible
|
|
9
|
+
* `Accepted` on this alone. §A.5 requires a host executing every normative
|
|
10
|
+
* behavioral path in strict mode, and none does.
|
|
11
|
+
*
|
|
12
|
+
* Saying that here rather than in a register keeps it next to the thing it
|
|
13
|
+
* qualifies. A conformance file that verifies structure while its RFC claims
|
|
14
|
+
* behavior is how "green suite" and "working protocol" come apart.
|
|
15
|
+
*
|
|
16
|
+
* The design constraints worth holding onto, each of which the schema encodes:
|
|
17
|
+
*
|
|
18
|
+
* - **Compensation is a second effect, not an undo.** It can fail, can be
|
|
19
|
+
* partially applied, and can itself be harmful (RFC 0147 R9) — hence
|
|
20
|
+
* `requiresApproval` and the security-high tier.
|
|
21
|
+
* - **Inputs come from recorded facts.** §B forbids prompt/model regeneration
|
|
22
|
+
* from constructing a compensation input during replay, because an inverse
|
|
23
|
+
* built from a re-inferred value is not the inverse of what was done.
|
|
24
|
+
* - **`nodeTypeId` resolves at registration**, so an unwind cannot fail on a
|
|
25
|
+
* typo first discovered during a failure — the worst possible moment.
|
|
26
|
+
*
|
|
27
|
+
* Server-free.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { describe, it, expect } from 'vitest';
|
|
31
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
32
|
+
import { join, resolve as pathResolve } from 'node:path';
|
|
33
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
34
|
+
import { SCHEMAS_DIR, V1_DIR } from '../lib/paths.js';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Schemas ship inside the package; RFC prose does not. The old form derived
|
|
38
|
+
* BOTH from `V1_DIR` — null in the published tarball — and cast the null away,
|
|
39
|
+
* so this file threw at import for every consumer installing from npm.
|
|
40
|
+
*/
|
|
41
|
+
const RFCS_DIR = V1_DIR === null ? null : pathResolve(V1_DIR, '..', '..', 'RFCS');
|
|
42
|
+
|
|
43
|
+
function schema(name: string): Record<string, unknown> {
|
|
44
|
+
return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Validate a candidate node against `WorkflowNode` alone.
|
|
49
|
+
*
|
|
50
|
+
* Every schema in the directory is registered first: `WorkflowNode` `$ref`s
|
|
51
|
+
* siblings by filename, so compiling it in isolation resolves nothing and Ajv
|
|
52
|
+
* throws rather than silently accepting — which is the good failure, but only
|
|
53
|
+
* if the harness registers what the schema actually depends on.
|
|
54
|
+
*/
|
|
55
|
+
function nodeValidator() {
|
|
56
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
57
|
+
for (const file of readdirSync(SCHEMAS_DIR).filter((f) => f.endsWith('.schema.json'))) {
|
|
58
|
+
const s = schema(file);
|
|
59
|
+
ajv.addSchema(s, file);
|
|
60
|
+
}
|
|
61
|
+
const wf = schema('workflow-definition.schema.json') as { $defs: Record<string, unknown> };
|
|
62
|
+
return ajv.compile({ ...(wf.$defs['WorkflowNode'] as object), $defs: wf.$defs });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('RFC 0151 §A — compensation capability shape', () => {
|
|
66
|
+
const caps = schema('capabilities.schema.json') as {
|
|
67
|
+
properties: Record<string, { properties?: Record<string, unknown>; required?: string[] }>;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
it('the capability family exists and is closed', () => {
|
|
71
|
+
const c = caps.properties['compensation'];
|
|
72
|
+
expect(c, 'RFC 0151 §A: `compensation` MUST be a declared capability family').toBeDefined();
|
|
73
|
+
expect(c.required).toEqual(['supported']);
|
|
74
|
+
expect(Object.keys(c.properties ?? {}).sort()).toEqual(
|
|
75
|
+
['manualIntervention', 'orderingModels', 'profileVersion', 'supported'].sort(),
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('orderingModels is a closed enum of the two named models', () => {
|
|
80
|
+
const models = (caps.properties['compensation'].properties?.['orderingModels'] as {
|
|
81
|
+
items: { enum: string[] };
|
|
82
|
+
}).items.enum;
|
|
83
|
+
expect(
|
|
84
|
+
[...models].sort(),
|
|
85
|
+
'RFC 0151 §A: an advertising host MUST implement `reverse-completion` and MAY add ' +
|
|
86
|
+
'`dependency-graph`. A third model would be an unadvertised ordering guarantee.',
|
|
87
|
+
).toEqual(['dependency-graph', 'reverse-completion']);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('profileVersion participates in identity, so it is constrained', () => {
|
|
91
|
+
// §C derives the inverse-action id partly from `profileVersion`. An
|
|
92
|
+
// unconstrained string there would let two hosts mint colliding identities
|
|
93
|
+
// under different ordering rules.
|
|
94
|
+
const pv = caps.properties['compensation'].properties?.['profileVersion'] as { pattern?: string };
|
|
95
|
+
expect(pv.pattern, 'RFC 0151 §C: profileVersion is part of the inverse-action id').toBe('^[1-9][0-9]*$');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('RFC 0151 §B — node compensation declaration', () => {
|
|
100
|
+
const validate = nodeValidator();
|
|
101
|
+
// A minimally VALID node — `WorkflowNode` requires `name`, `position`,
|
|
102
|
+
// `config`, and `inputs` independently of this RFC. Building the fixture from
|
|
103
|
+
// the real required set keeps the legs below testing `compensation` rather
|
|
104
|
+
// than accidentally testing whether the base node is well-formed.
|
|
105
|
+
const base = {
|
|
106
|
+
id: 'reserve-inventory',
|
|
107
|
+
typeId: 'vendor.shop.reserve',
|
|
108
|
+
name: 'Reserve inventory',
|
|
109
|
+
position: { x: 0, y: 0 },
|
|
110
|
+
config: {},
|
|
111
|
+
inputs: {},
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
it('a well-formed declaration validates', () => {
|
|
115
|
+
const ok = validate({
|
|
116
|
+
...base,
|
|
117
|
+
compensation: {
|
|
118
|
+
nodeTypeId: 'vendor.shop.release',
|
|
119
|
+
inputMapping: { reservationId: '${nodes.reserve-inventory.output.id}' },
|
|
120
|
+
retry: { maxAttempts: 5, backoffMs: 1000 },
|
|
121
|
+
requiresApproval: false,
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
expect(ok, JSON.stringify(validate.errors)).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('a node without compensation stays valid', () => {
|
|
128
|
+
// The profile is optional. Absent MUST NOT become a validation error, or
|
|
129
|
+
// every existing workflow in the corpus breaks.
|
|
130
|
+
expect(validate({ ...base }), JSON.stringify(validate.errors)).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('the declaration is closed — an unknown key is rejected', () => {
|
|
134
|
+
expect(
|
|
135
|
+
validate({ ...base, compensation: { nodeTypeId: 'x', onFailure: 'ignore' } }),
|
|
136
|
+
'RFC 0151 §B: `compensation` is closed. An unrecognized key here is a silent behavioral ' +
|
|
137
|
+
'assumption on the unwind path, which is the least observable place to have one.',
|
|
138
|
+
).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('nodeTypeId is required and non-empty', () => {
|
|
142
|
+
expect(validate({ ...base, compensation: {} }), 'nodeTypeId is required').toBe(false);
|
|
143
|
+
expect(
|
|
144
|
+
validate({ ...base, compensation: { nodeTypeId: '' } }),
|
|
145
|
+
'RFC 0151 §B: `nodeTypeId` MUST resolve at registration — an empty id resolves to nothing, ' +
|
|
146
|
+
'and the failure would surface only during an unwind.',
|
|
147
|
+
).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('retry bounds are integers within sane floors', () => {
|
|
151
|
+
expect(validate({ ...base, compensation: { nodeTypeId: 'x', retry: { maxAttempts: 0 } } })).toBe(false);
|
|
152
|
+
expect(validate({ ...base, compensation: { nodeTypeId: 'x', retry: { backoffMs: -1 } } })).toBe(false);
|
|
153
|
+
expect(validate({ ...base, compensation: { nodeTypeId: 'x', retry: { maxAttempts: 1, backoffMs: 0 } } })).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe.skipIf(RFCS_DIR === null)('RFC 0151 — what this file does NOT establish', () => {
|
|
158
|
+
it('records that behavioral conformance is absent, per RFC 0147 §A.5', () => {
|
|
159
|
+
// Not decoration. RFC 0147 §A.5 forbids `Accepted` on shape-only evidence
|
|
160
|
+
// for a behavioral requirement, and this scenario is shape-only by
|
|
161
|
+
// construction: it compiles schemas and never contacts a host. The RFC's
|
|
162
|
+
// acceptance criteria and `docs/RFC-0147-SELF-AUDIT.md` both record 0151 as
|
|
163
|
+
// violating §A.5, and this leg exists so that reading the conformance suite
|
|
164
|
+
// alone cannot leave a different impression.
|
|
165
|
+
const rfc = readFileSync(
|
|
166
|
+
join(RFCS_DIR as string, '0151-compensation-and-partial-failure-profile.md'),
|
|
167
|
+
'utf8',
|
|
168
|
+
);
|
|
169
|
+
expect(
|
|
170
|
+
/Reverse-unwind, retry, crash, partial\/manual, approval, replay, and isolation scenarios pass\.\s*\(/.test(rfc),
|
|
171
|
+
'RFC 0151\'s behavioral acceptance item MUST remain unticked and annotated: no host executes ' +
|
|
172
|
+
'an unwind, so ordering, persistence-before-first-action, and crash resumption are unproven.',
|
|
173
|
+
).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `contractProvenance` — which corpus revision a host implements against (RFC 0146).
|
|
3
|
+
*
|
|
4
|
+
* THE FAILURE THIS EXISTS FOR, and it is not hypothetical. A reference host's hand-copied
|
|
5
|
+
* `capabilities.schema.json` carried 81 properties where the corpus had 88, so it validated
|
|
6
|
+
* its own discovery document against a contract that predated the very declaration it was
|
|
7
|
+
* checking. Green, and wrong. Nothing on the wire distinguished it from a current host.
|
|
8
|
+
*
|
|
9
|
+
* WHY VALIDATION CANNOT SEE THIS — the point that decides the whole design. v1.x changes are
|
|
10
|
+
* additive (`COMPATIBILITY.md` §2.1), so a document written against an older contract still
|
|
11
|
+
* validates against the newer schema. Validation is precisely the instrument that is blind
|
|
12
|
+
* here, which is why the stale host was green. Only a claim on the wire can surface it.
|
|
13
|
+
*
|
|
14
|
+
* ADVISORY BY CONSTRUCTION. A host on an older corpus revision is CONFORMANT — additive means
|
|
15
|
+
* older is legal. So requirement 3 forbids rejecting on a mismatch, and these legs assert the
|
|
16
|
+
* SHAPE of the claim, never that a host is current. A leg that failed a host for being behind
|
|
17
|
+
* would convert an optional disclosure into a de-facto upgrade mandate inside a version line
|
|
18
|
+
* where being behind is permitted.
|
|
19
|
+
*
|
|
20
|
+
* NOT BUILT (RFC 0146 G2): a leg asserting the advertised revision matches what the host
|
|
21
|
+
* ACTUALLY validates with (requirement 2). Nothing observable from outside distinguishes a
|
|
22
|
+
* host implementing corpus X from one merely claiming X — the same self-report limit as
|
|
23
|
+
* RFC 0145 requirement 3. Asserting it would be theatre.
|
|
24
|
+
*
|
|
25
|
+
* THE CONSUMER HALF (RFC 0146 G3). A provenance nobody reads is a field, not a mechanism, so
|
|
26
|
+
* this suite reads it: leg C compares a host's advertised revision against the suite's OWN
|
|
27
|
+
* `schemas/CORPUS-STAMP.json` and REPORTS the drift. It is the second half of G3 — the first
|
|
28
|
+
* being a host that advertises.
|
|
29
|
+
*
|
|
30
|
+
* IT REPORTS AND NEVER FAILS, and that is requirement 3, not timidity. v1.x revisions are
|
|
31
|
+
* additive, so a host on an older corpus is CONFORMANT; a leg that reddened it would convert
|
|
32
|
+
* an advisory disclosure into an upgrade mandate inside a version line where being behind is
|
|
33
|
+
* legal. The same call the RFC 0144 G1 arm-witness leg makes for dotted-at-root.
|
|
34
|
+
*
|
|
35
|
+
* THE STAMP ONLY EXISTS IN THE PUBLISHED LAYOUT. It is written at prepack into the vendored
|
|
36
|
+
* `schemas/`, never into the repo tree — so a repo-layout run has nothing to compare against
|
|
37
|
+
* and the leg is INAPPLICABLE there. That asymmetry already bit once: when the stamp landed,
|
|
38
|
+
* "written only into the tarball" and "visible to the gate" turned out to be different claims,
|
|
39
|
+
* and only forcing the published layout revealed it. Verified the same way here.
|
|
40
|
+
*
|
|
41
|
+
* @see schemas/capabilities.schema.json §contractProvenance
|
|
42
|
+
* @see RFCS/0146-contract-provenance-advertisement.md
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { describe, it, expect } from 'vitest';
|
|
46
|
+
import { readFileSync } from 'node:fs';
|
|
47
|
+
import { join } from 'node:path';
|
|
48
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
49
|
+
import addFormats from 'ajv-formats';
|
|
50
|
+
import { driver } from '../lib/driver.js';
|
|
51
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
52
|
+
|
|
53
|
+
const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
|
|
54
|
+
|
|
55
|
+
function provenanceSubschema(): Record<string, unknown> {
|
|
56
|
+
const caps = JSON.parse(
|
|
57
|
+
readFileSync(join(SCHEMAS_DIR, 'capabilities.schema.json'), 'utf8'),
|
|
58
|
+
) as { properties: Record<string, Record<string, unknown>>; required?: string[] };
|
|
59
|
+
return caps.properties.contractProvenance;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function compiled(): ReturnType<Ajv2020['compile']> {
|
|
63
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
64
|
+
addFormats(ajv);
|
|
65
|
+
return ajv.compile(provenanceSubschema());
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('contract-provenance (RFC 0146, always-on)', () => {
|
|
69
|
+
it('A1 — declared at the document ROOT, and OPTIONAL', () => {
|
|
70
|
+
const caps = JSON.parse(
|
|
71
|
+
readFileSync(join(SCHEMAS_DIR, 'capabilities.schema.json'), 'utf8'),
|
|
72
|
+
) as { properties: Record<string, unknown>; required?: string[] };
|
|
73
|
+
|
|
74
|
+
expect(
|
|
75
|
+
caps.properties['contractProvenance'],
|
|
76
|
+
why('capabilities.schema.json', 'declared as a root property — families live at the document root per capabilities.md §"Document-root layout", never under the deprecated wrapper'),
|
|
77
|
+
).toBeDefined();
|
|
78
|
+
expect(
|
|
79
|
+
(caps.required ?? []).includes('contractProvenance'),
|
|
80
|
+
why('RFC 0146 req 1', 'OPTIONAL — absent means UNSPECIFIED provenance, not "current" and not "stale"; a host must not be non-conformant for staying silent'),
|
|
81
|
+
).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('A2 — a full 40-hex commit validates; a short SHA or a vendor build string does NOT', () => {
|
|
85
|
+
const validate = compiled();
|
|
86
|
+
|
|
87
|
+
expect(
|
|
88
|
+
validate({ suiteVersion: '1.72.0', corpusCommit: '93d4692eb1e28244b860da6ddcb6521b57a712b3' }),
|
|
89
|
+
why('RFC 0146 req 4', `a real stamp validates: ${JSON.stringify(validate.errors)}`),
|
|
90
|
+
).toBe(true);
|
|
91
|
+
// This is what keeps the field from decaying into a free-text version box. A short SHA is
|
|
92
|
+
// ambiguous across a growing history; a vendor build id belongs in `implementation`, which
|
|
93
|
+
// already exists for exactly that.
|
|
94
|
+
expect(
|
|
95
|
+
validate({ corpusCommit: '93d4692' }),
|
|
96
|
+
why('RFC 0146 req 4', 'a SHORT sha is REJECTED — abbreviated commits are ambiguous and this field is an identity, not a hint'),
|
|
97
|
+
).toBe(false);
|
|
98
|
+
expect(
|
|
99
|
+
validate({ corpusCommit: 'build-4711' }),
|
|
100
|
+
why('RFC 0146 req 4', 'a vendor build identifier is REJECTED — `implementation` is the field for that'),
|
|
101
|
+
).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('A3 — both members optional, and unknown members rejected', () => {
|
|
105
|
+
const validate = compiled();
|
|
106
|
+
|
|
107
|
+
expect(validate({}), why('RFC 0146 req 1', 'an empty object validates — a host may know neither')).toBe(true);
|
|
108
|
+
expect(
|
|
109
|
+
validate({ suiteVersion: '1.72.0' }),
|
|
110
|
+
why('RFC 0146 req 1', 'a host that knows only its suite version advertises only that'),
|
|
111
|
+
).toBe(true);
|
|
112
|
+
expect(
|
|
113
|
+
validate({ suiteVersion: '1.72.0', schemaDigest: 'abc' }),
|
|
114
|
+
why('RFC 0146 §Proposal', 'the object is closed — a digest is a DIFFERENT artifact answering a different question (tamper vs identity) and is deliberately not part of this shape'),
|
|
115
|
+
).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// `RFCS/` is NOT shipped in the published tarball, so this leg is repo-layout only. Reading
|
|
119
|
+
// it unconditionally made the scenario ENOENT for every adopter running from the package —
|
|
120
|
+
// passing locally and reddening for a reason that has nothing to do with the host under
|
|
121
|
+
// test, which is worse than a no-op. Third instance of this asymmetry in this corpus; the
|
|
122
|
+
// first two were the CORPUS-STAMP gate and the link-checker's filesystem walk.
|
|
123
|
+
const rfcText = ((): string | null => {
|
|
124
|
+
try {
|
|
125
|
+
return readFileSync(
|
|
126
|
+
join(SCHEMAS_DIR, '..', 'RFCS', '0146-contract-provenance-advertisement.md'),
|
|
127
|
+
'utf8',
|
|
128
|
+
);
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
})();
|
|
133
|
+
|
|
134
|
+
it.skipIf(rfcText === null)('A4 — the RFC states the advisory rule, which is what stops this becoming an upgrade mandate', () => {
|
|
135
|
+
const rfc = rfcText ?? '';
|
|
136
|
+
expect(
|
|
137
|
+
/MUST NOT reject a request, refuse interop, or fail a run solely because/.test(rfc),
|
|
138
|
+
why('RFC 0146 req 3', 'a consumer MUST NOT reject on a mismatch — v1.x revisions are additive, so a host on an older revision is CONFORMANT and the field detects drift rather than creating an error'),
|
|
139
|
+
).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
/** The suite's OWN provenance, from the vendored stamp — present only in the published layout. */
|
|
144
|
+
function suiteStamp(): { suiteVersion?: string; corpusCommit?: string } | null {
|
|
145
|
+
try {
|
|
146
|
+
return JSON.parse(readFileSync(join(SCHEMAS_DIR, 'CORPUS-STAMP.json'), 'utf8')) as {
|
|
147
|
+
suiteVersion?: string;
|
|
148
|
+
corpusCommit?: string;
|
|
149
|
+
};
|
|
150
|
+
} catch {
|
|
151
|
+
// Repo layout: no stamp is written into the tree, so there is nothing to compare against.
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
describe('contract-provenance: the suite READS the advert — the consumer half (RFC 0146 G3)', () => {
|
|
157
|
+
it('compares the advertised revision against the suite\'s own, and reports drift', async () => {
|
|
158
|
+
const mine = suiteStamp();
|
|
159
|
+
if (mine === null) return; // repo layout — inapplicable, see the docblock
|
|
160
|
+
|
|
161
|
+
const res = await driver.get('/.well-known/openwop');
|
|
162
|
+
if (res.status !== 200 || res.json === null || res.json === undefined) return;
|
|
163
|
+
const adv = (res.json as Record<string, unknown>)['contractProvenance'] as
|
|
164
|
+
| { suiteVersion?: string; corpusCommit?: string }
|
|
165
|
+
| undefined;
|
|
166
|
+
if (adv === undefined) return; // silent host — absent means UNSPECIFIED (req 1), not stale
|
|
167
|
+
|
|
168
|
+
const same = adv.corpusCommit !== undefined && adv.corpusCommit === mine.corpusCommit;
|
|
169
|
+
console.log(
|
|
170
|
+
` [contract-provenance] host: suite=${adv.suiteVersion ?? '?'} commit=${(adv.corpusCommit ?? '?').slice(0, 12)} | ` +
|
|
171
|
+
`this suite: suite=${mine.suiteVersion ?? '?'} commit=${(mine.corpusCommit ?? '?').slice(0, 12)} | ` +
|
|
172
|
+
`${same ? 'SAME corpus revision' : 'DIFFERENT corpus revision — the host implements a revision this suite was not cut from'}`,
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// NO ASSERTION ON EQUALITY, deliberately. Requirement 3: a consumer MUST NOT reject or
|
|
176
|
+
// fail solely because a host's provenance differs from its own — additive means older is
|
|
177
|
+
// CONFORMANT. What IS asserted is that a host making the claim makes a well-formed one,
|
|
178
|
+
// because an unparseable provenance is useless to every consumer, not just this one.
|
|
179
|
+
expect(
|
|
180
|
+
typeof adv.corpusCommit === 'string' || typeof adv.suiteVersion === 'string',
|
|
181
|
+
driver.describe(
|
|
182
|
+
'RFC 0146 req 1 + req 4',
|
|
183
|
+
'an advertised contractProvenance carries at least one of suiteVersion / corpusCommit — an object conveying neither is indistinguishable from silence while looking like an answer',
|
|
184
|
+
),
|
|
185
|
+
).toBe(true);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe('contract-provenance: a host advertising it makes a well-formed claim (RFC 0146)', () => {
|
|
190
|
+
it('the advertised provenance validates against the declared shape', async () => {
|
|
191
|
+
const res = await driver.get('/.well-known/openwop');
|
|
192
|
+
if (res.status !== 200 || res.json === null || res.json === undefined) return;
|
|
193
|
+
|
|
194
|
+
const doc = res.json as Record<string, unknown>;
|
|
195
|
+
const adv = doc['contractProvenance'];
|
|
196
|
+
// INAPPLICABLE, not gated. The field is OPTIONAL (req 1) and strict mode must not coerce a
|
|
197
|
+
// host into advertising — the same call RFC 0142 makes for `store` and RFC 0145 for
|
|
198
|
+
// `registrationSource`. Silence is an honest answer here.
|
|
199
|
+
if (adv === undefined) return;
|
|
200
|
+
|
|
201
|
+
expect(
|
|
202
|
+
compiled()(adv),
|
|
203
|
+
driver.describe(
|
|
204
|
+
'capabilities.schema.json §contractProvenance',
|
|
205
|
+
`an advertised contractProvenance MUST match the declared shape — a full 40-hex corpusCommit and a published suiteVersion, nothing else: ${JSON.stringify(compiled().errors)}`,
|
|
206
|
+
),
|
|
207
|
+
).toBe(true);
|
|
208
|
+
});
|
|
209
|
+
});
|