@ggui-ai/protocol-conformance 0.6.2 → 0.7.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/README.md +54 -16
- package/dist/fixtures/consume-buffer/index.d.ts.map +1 -1
- package/dist/fixtures/consume-buffer/index.js +4 -1
- package/dist/fixtures/index.d.ts +42 -4
- package/dist/fixtures/index.d.ts.map +1 -1
- package/dist/fixtures/index.js +42 -4
- package/dist/fixtures/reserved-channel-authority/index.d.ts.map +1 -1
- package/dist/fixtures/reserved-channel-authority/index.js +4 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/match-behavior.d.ts.map +1 -1
- package/dist/match-behavior.js +4 -2
- package/dist/resource-read-conformance/cases/all-ephemeral-substrate-fuses-like-none.json +29 -0
- package/dist/resource-read-conformance/cases/evicted-row-remints-from-the-durable-record.json +26 -0
- package/dist/resource-read-conformance/cases/half-wired-blueprints-only-substrate-fuses-like-none.json +29 -0
- package/dist/resource-read-conformance/cases/half-wired-identity-only-substrate-fuses-like-none.json +29 -0
- package/dist/resource-read-conformance/cases/live-row-read-returns-mount-material.json +18 -0
- package/dist/resource-read-conformance/cases/malformed-locator-stays-outside-the-typed-set.json +19 -0
- package/dist/resource-read-conformance/cases/read-miss-answers-not-found.json +23 -0
- package/dist/resource-read-conformance/cases/read-on-a-substrate-less-server-answers-not-supported.json +22 -0
- package/dist/resource-read-conformance/cases/read-record-with-purged-blueprint-answers-blueprint-unresolvable.json +29 -0
- package/dist/resource-read-conformance/cases/read-refusal-is-indistinguishable-from-a-miss.json +47 -0
- package/dist/resource-read-conformance/cases/read-without-a-delivery-channel-answers-not-mountable.json +22 -0
- package/dist/resource-read-conformance/cases/registry-match-fuses-on-not-mountable.json +40 -0
- package/dist/resource-read-conformance/cases/substrate-less-fusion-of-refusal-and-miss.json +29 -0
- package/dist/resource-read-conformance/index.d.ts +371 -0
- package/dist/resource-read-conformance/index.d.ts.map +1 -0
- package/dist/resource-read-conformance/index.js +782 -0
- package/package.json +8 -3
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resource-read conformance catalog — the kit's first MCP-binding
|
|
3
|
+
* driver.
|
|
4
|
+
*
|
|
5
|
+
* ## The obligation this catalog grades
|
|
6
|
+
*
|
|
7
|
+
* A read of a render locator (`ui://ggui/render/{sessionId}` or
|
|
8
|
+
* `ui://ggui/render/{sessionId}/{blueprintKey}`) has exactly TWO exits:
|
|
9
|
+
* a result whose contents declare a delivery channel — a live mount —
|
|
10
|
+
* or one typed JSON-RPC error. There is no third outcome. In
|
|
11
|
+
* particular there is no successful result carrying a shell that can
|
|
12
|
+
* never paint anything, which is what a loading placeholder returned on
|
|
13
|
+
* a failure branch amounts to.
|
|
14
|
+
*
|
|
15
|
+
* That invariant is what makes the read host-checkable: a host that
|
|
16
|
+
* gets `contents` back can mount them without inspecting anything, and
|
|
17
|
+
* a host that gets an error can route on `error.data.code` — "come back
|
|
18
|
+
* never" versus "come back with a fresh render" — without parsing prose.
|
|
19
|
+
*
|
|
20
|
+
* ## Why this is not a fixture in `../fixtures`
|
|
21
|
+
*
|
|
22
|
+
* `../fixtures` is the Path-A catalog: WebSocket-observable behaviors,
|
|
23
|
+
* driven by `runConformance()` over a live channel with a runner-owned
|
|
24
|
+
* subscribe frame and an observation window. A `resources/read` has no
|
|
25
|
+
* WebSocket envelope, no subscribe and no frames to observe — it is a
|
|
26
|
+
* request/response on the MCP binding. Registering these cases in the
|
|
27
|
+
* Path-A catalog would make every WebSocket run carry a permanent skip
|
|
28
|
+
* that could never become a pass on that transport, which is the false
|
|
29
|
+
* gate the kit's skip-set pinning exists to prevent.
|
|
30
|
+
*
|
|
31
|
+
* So this catalog gets the treatment the gadget obligations already
|
|
32
|
+
* get (`../schema-conformance`, `../registration-conformance`,
|
|
33
|
+
* `../resolution-conformance`): its own case shape, its own runner, and
|
|
34
|
+
* its cases shipped as raw JSON under `./cases/` so a non-TypeScript
|
|
35
|
+
* implementer can grade their own server against the same catalog. The
|
|
36
|
+
* difference is that those three grade a pure function while this one
|
|
37
|
+
* drives a live server — hence the scenario driver below, which is the
|
|
38
|
+
* seam an adopter implements.
|
|
39
|
+
*
|
|
40
|
+
* ## What an adopter implements
|
|
41
|
+
*
|
|
42
|
+
* {@link runResourceReadConformance} takes a
|
|
43
|
+
* {@link ResourceReadScenarioDriver}: given a declared server shape,
|
|
44
|
+
* caller identity and seed set, bring a server up that way and return
|
|
45
|
+
* something that can read a URI. The kit never imports a concrete
|
|
46
|
+
* server — adopters drive their OWN. The kit owns the locator grammar,
|
|
47
|
+
* the wire numbers, and every assertion; the driver owns nothing but
|
|
48
|
+
* "make my server look like this".
|
|
49
|
+
*
|
|
50
|
+
* Two rules make the grade honest:
|
|
51
|
+
*
|
|
52
|
+
* - A driver that CANNOT express a scenario throws from the driver
|
|
53
|
+
* function. The runner records a SKIP with that reason — never a
|
|
54
|
+
* pass. A server that cannot be brought up half-wired cannot be
|
|
55
|
+
* graded on what a half-wired server answers.
|
|
56
|
+
* - A driver's {@link PreparedResourceReadScenario.read} MUST NOT
|
|
57
|
+
* throw for a protocol-level failure — it resolves with
|
|
58
|
+
* `{kind: 'error'}` carrying the RAW JSON-RPC error frame the
|
|
59
|
+
* server put on the wire. A throw from `read` is a FAIL, because
|
|
60
|
+
* the read is the thing under test. The raw frame matters: an MCP
|
|
61
|
+
* client's reconstructed error object rewrites `message`, and the
|
|
62
|
+
* indistinguishability obligation below is graded on bytes.
|
|
63
|
+
*
|
|
64
|
+
* ## What this catalog deliberately does NOT grade
|
|
65
|
+
*
|
|
66
|
+
* - **The order in which a substrate-less server answers.**
|
|
67
|
+
* `NOT_SUPPORTED` describes the deployment, not the locator, so a
|
|
68
|
+
* server that keeps no durable record MAY answer it immediately —
|
|
69
|
+
* before any per-locator work, including the access check. Grading
|
|
70
|
+
* "the access check comes first" against such a server would fail a
|
|
71
|
+
* correct implementation. What IS graded is that its answer does
|
|
72
|
+
* not vary with the row (see `indistinguishable`), which is the
|
|
73
|
+
* property the ordering rule exists to produce.
|
|
74
|
+
* - **`detail` wording, on any code.** `detail` is optional operator
|
|
75
|
+
* context; two conformant servers word it differently. What is
|
|
76
|
+
* graded is its ABSENCE on `NOT_FOUND`, which is normative.
|
|
77
|
+
* - **The `NOT_FOUND` message literal.** What is normative is that
|
|
78
|
+
* the message is CONSTANT — identical for a refused read and a
|
|
79
|
+
* read of a locator that never existed. A server whose constant
|
|
80
|
+
* message reads differently is conformant; one whose message varies
|
|
81
|
+
* with the locator is not, and the fusion cases catch exactly that.
|
|
82
|
+
* - **Internal-error message text.** A `-32603` carries whatever the
|
|
83
|
+
* operator's own store or runtime produced. No case asserts its
|
|
84
|
+
* content, and none should — it is not protocol surface.
|
|
85
|
+
* - **The number a URI naming no locator receives — including
|
|
86
|
+
* negatively.** MCP assigns the resource-missing number to a
|
|
87
|
+
* `resources/read` of a URI the server does not serve, so a
|
|
88
|
+
* framework that maps every unrecognized URI onto it is behaving
|
|
89
|
+
* correctly; a catalog that banned that number would fail it
|
|
90
|
+
* forever. What IS graded is the classification: such a URI MUST
|
|
91
|
+
* NOT carry one of the four resource-read codes on
|
|
92
|
+
* `error.data.code`, because the four classify reads that name a
|
|
93
|
+
* locator and this names none. The two stay distinguishable on
|
|
94
|
+
* that alone.
|
|
95
|
+
* - **The shell's markup.** Success is graded on the projected render
|
|
96
|
+
* meta — the delivery channel it declares — never on DOM or HTML
|
|
97
|
+
* shape, which is a server's own presentation concern.
|
|
98
|
+
*/
|
|
99
|
+
import { isRecord } from '@ggui-ai/protocol';
|
|
100
|
+
import notFoundOnAMiss from './cases/read-miss-answers-not-found.json' with { type: 'json' };
|
|
101
|
+
import refusalIsAMiss from './cases/read-refusal-is-indistinguishable-from-a-miss.json' with { type: 'json' };
|
|
102
|
+
import purgedBlueprint from './cases/read-record-with-purged-blueprint-answers-blueprint-unresolvable.json' with { type: 'json' };
|
|
103
|
+
import substrateLess from './cases/read-on-a-substrate-less-server-answers-not-supported.json' with { type: 'json' };
|
|
104
|
+
import noDeliveryChannel from './cases/read-without-a-delivery-channel-answers-not-mountable.json' with { type: 'json' };
|
|
105
|
+
import substrateLessFusion from './cases/substrate-less-fusion-of-refusal-and-miss.json' with { type: 'json' };
|
|
106
|
+
import halfWiredIdentityOnly from './cases/half-wired-identity-only-substrate-fuses-like-none.json' with { type: 'json' };
|
|
107
|
+
import halfWiredBlueprintsOnly from './cases/half-wired-blueprints-only-substrate-fuses-like-none.json' with { type: 'json' };
|
|
108
|
+
import allEphemeral from './cases/all-ephemeral-substrate-fuses-like-none.json' with { type: 'json' };
|
|
109
|
+
import registryFusion from './cases/registry-match-fuses-on-not-mountable.json' with { type: 'json' };
|
|
110
|
+
import malformedLocator from './cases/malformed-locator-stays-outside-the-typed-set.json' with { type: 'json' };
|
|
111
|
+
import liveRowMount from './cases/live-row-read-returns-mount-material.json' with { type: 'json' };
|
|
112
|
+
import remintMount from './cases/evicted-row-remints-from-the-durable-record.json' with { type: 'json' };
|
|
113
|
+
// =============================================================================
|
|
114
|
+
// Catalog
|
|
115
|
+
// =============================================================================
|
|
116
|
+
/**
|
|
117
|
+
* Every resource-read case the kit ships, in deterministic order: the
|
|
118
|
+
* four typed classes, then the disclosure fusions, then the boundary of
|
|
119
|
+
* the classification, then the two mount cases.
|
|
120
|
+
*/
|
|
121
|
+
export const resourceReadCases = [
|
|
122
|
+
notFoundOnAMiss,
|
|
123
|
+
purgedBlueprint,
|
|
124
|
+
substrateLess,
|
|
125
|
+
noDeliveryChannel,
|
|
126
|
+
refusalIsAMiss,
|
|
127
|
+
substrateLessFusion,
|
|
128
|
+
halfWiredIdentityOnly,
|
|
129
|
+
halfWiredBlueprintsOnly,
|
|
130
|
+
allEphemeral,
|
|
131
|
+
registryFusion,
|
|
132
|
+
malformedLocator,
|
|
133
|
+
liveRowMount,
|
|
134
|
+
remintMount,
|
|
135
|
+
];
|
|
136
|
+
// =============================================================================
|
|
137
|
+
// Locator grammar + the mount predicate
|
|
138
|
+
// =============================================================================
|
|
139
|
+
/** The resource URI every render locator hangs off. */
|
|
140
|
+
export const GGUI_RENDER_RESOURCE_URI = 'ui://ggui/render';
|
|
141
|
+
/**
|
|
142
|
+
* Build the URI a probe reads. The kit owns the grammar so no case
|
|
143
|
+
* spells a well-formed locator out by hand; a `raw` locator passes
|
|
144
|
+
* through verbatim, which is the point of that arm.
|
|
145
|
+
*/
|
|
146
|
+
export function renderLocatorUri(locator, registeredKeys = {}) {
|
|
147
|
+
if (locator.kind === 'raw')
|
|
148
|
+
return locator.uri;
|
|
149
|
+
const base = `${GGUI_RENDER_RESOURCE_URI}/${locator.session}`;
|
|
150
|
+
const keyRef = locator.key;
|
|
151
|
+
if (keyRef === undefined)
|
|
152
|
+
return base;
|
|
153
|
+
if (keyRef.kind === 'literal')
|
|
154
|
+
return `${base}/${keyRef.value}`;
|
|
155
|
+
const key = registeredKeys[keyRef.seed];
|
|
156
|
+
if (key === undefined) {
|
|
157
|
+
throw new Error(`no key was reported for registry seed '${keyRef.seed}'`);
|
|
158
|
+
}
|
|
159
|
+
return `${base}/${key}`;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Does this render meta carry enough to paint something? One of the
|
|
163
|
+
* three delivery channels a runtime knows: a static component URL, a
|
|
164
|
+
* live channel, or a server-emitted system card.
|
|
165
|
+
*
|
|
166
|
+
* The live channel needs BOTH halves — an endpoint with no token cannot
|
|
167
|
+
* be opened, and a token with no endpoint has nowhere to go — so a
|
|
168
|
+
* half-declared one is not a channel.
|
|
169
|
+
*/
|
|
170
|
+
export function declaresDeliveryChannel(meta) {
|
|
171
|
+
if (typeof meta.codeUrl === 'string' && meta.codeUrl.length > 0)
|
|
172
|
+
return true;
|
|
173
|
+
if (typeof meta.kind === 'string' && meta.kind.length > 0)
|
|
174
|
+
return true;
|
|
175
|
+
return (typeof meta.wsUrl === 'string' &&
|
|
176
|
+
meta.wsUrl.length > 0 &&
|
|
177
|
+
typeof meta.wsToken === 'string' &&
|
|
178
|
+
meta.wsToken.length > 0);
|
|
179
|
+
}
|
|
180
|
+
// =============================================================================
|
|
181
|
+
// Runner
|
|
182
|
+
// =============================================================================
|
|
183
|
+
/**
|
|
184
|
+
* Grade a server against the catalog.
|
|
185
|
+
*
|
|
186
|
+
* One scenario is prepared per case, every probe is read against it in
|
|
187
|
+
* order, then the cross-probe obligations are checked. A case
|
|
188
|
+
* contributes at most one failure — the first obligation it broke —
|
|
189
|
+
* so the result reads as one verdict per case.
|
|
190
|
+
*
|
|
191
|
+
* A conformant server produces an empty `failed` array. An empty
|
|
192
|
+
* `passed` array is NOT success: a run where every case skipped proves
|
|
193
|
+
* nothing, and callers should treat it the way the WebSocket runner's
|
|
194
|
+
* consumers already do.
|
|
195
|
+
*/
|
|
196
|
+
export async function runResourceReadConformance(drive, options = {}) {
|
|
197
|
+
const passed = [];
|
|
198
|
+
const failed = [];
|
|
199
|
+
const skipped = [];
|
|
200
|
+
for (const raw of resourceReadCases) {
|
|
201
|
+
const testCase = parseCase(raw);
|
|
202
|
+
if (options.only !== undefined && !options.only.includes(testCase.name))
|
|
203
|
+
continue;
|
|
204
|
+
let prepared;
|
|
205
|
+
try {
|
|
206
|
+
prepared = await drive({
|
|
207
|
+
caseName: testCase.name,
|
|
208
|
+
server: testCase.server,
|
|
209
|
+
caller: testCase.caller,
|
|
210
|
+
seeds: testCase.seeds,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
skipped.push({
|
|
215
|
+
name: testCase.name,
|
|
216
|
+
reason: `driver refused to prepare the scenario: ${errorText(err)}`,
|
|
217
|
+
});
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
let verdict;
|
|
221
|
+
try {
|
|
222
|
+
verdict = await gradeCase(testCase, prepared);
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
// A teardown that throws costs THIS case its cleanup and nothing
|
|
226
|
+
// else. Unguarded, one flaky dispose propagates out of the loop
|
|
227
|
+
// and takes the whole scorecard with it — every case after it
|
|
228
|
+
// goes ungraded, and the run reports nothing rather than the
|
|
229
|
+
// verdicts it had already earned. Cleanup is not a verdict, so it
|
|
230
|
+
// cannot produce one either: the throw is dropped here, not
|
|
231
|
+
// recorded as a failure of the server.
|
|
232
|
+
if (prepared.dispose !== undefined) {
|
|
233
|
+
await prepared.dispose().catch(() => undefined);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (verdict.kind === 'pass')
|
|
237
|
+
passed.push(testCase.name);
|
|
238
|
+
else if (verdict.kind === 'fail')
|
|
239
|
+
failed.push(verdict.failure);
|
|
240
|
+
else
|
|
241
|
+
skipped.push({ name: testCase.name, reason: verdict.reason });
|
|
242
|
+
}
|
|
243
|
+
return { passed, failed, skipped };
|
|
244
|
+
}
|
|
245
|
+
async function gradeCase(testCase, prepared) {
|
|
246
|
+
const registeredKeys = prepared.registeredKeys ?? {};
|
|
247
|
+
const outcomes = new Map();
|
|
248
|
+
for (const probe of testCase.reads) {
|
|
249
|
+
let uri;
|
|
250
|
+
try {
|
|
251
|
+
uri = renderLocatorUri(probe.locator, registeredKeys);
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
return {
|
|
255
|
+
kind: 'skip',
|
|
256
|
+
reason: `probe '${probe.as}' reads a locator the driver did not resolve — ${errorText(err)}`,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
let outcome;
|
|
260
|
+
try {
|
|
261
|
+
outcome = await prepared.read(uri);
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
return {
|
|
265
|
+
kind: 'fail',
|
|
266
|
+
failure: {
|
|
267
|
+
name: testCase.name,
|
|
268
|
+
probe: probe.as,
|
|
269
|
+
obligation: 'a read resolves with an outcome; a protocol failure is a typed error frame, not a throw',
|
|
270
|
+
expected: probe.expect,
|
|
271
|
+
actual: `read('${uri}') threw: ${errorText(err)}`,
|
|
272
|
+
message: `reading '${uri}' threw instead of resolving. A read that cannot succeed still answers — with one typed JSON-RPC error.`,
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
outcomes.set(probe.as, outcome);
|
|
277
|
+
const probeFailure = gradeProbe(testCase, probe, uri, outcome);
|
|
278
|
+
if (probeFailure !== null)
|
|
279
|
+
return { kind: 'fail', failure: probeFailure };
|
|
280
|
+
}
|
|
281
|
+
const disclosure = gradeDisclosure(testCase, outcomes);
|
|
282
|
+
if (disclosure !== null)
|
|
283
|
+
return { kind: 'fail', failure: disclosure };
|
|
284
|
+
const fusion = gradeIndistinguishable(testCase, outcomes);
|
|
285
|
+
if (fusion !== null)
|
|
286
|
+
return { kind: 'fail', failure: fusion };
|
|
287
|
+
return { kind: 'pass' };
|
|
288
|
+
}
|
|
289
|
+
function gradeProbe(testCase, probe, uri, outcome) {
|
|
290
|
+
const fail = (obligation, expected, message) => ({
|
|
291
|
+
name: testCase.name,
|
|
292
|
+
probe: probe.as,
|
|
293
|
+
obligation,
|
|
294
|
+
expected,
|
|
295
|
+
actual: outcome,
|
|
296
|
+
message,
|
|
297
|
+
});
|
|
298
|
+
if (probe.expect.kind === 'live-mount') {
|
|
299
|
+
if (outcome.kind !== 'mount') {
|
|
300
|
+
return fail('a resolvable locator returns a result, not an error', 'a result whose contents declare a delivery channel', `reading '${uri}' failed, but this locator resolves on this server — the read had mount material to hand back.`);
|
|
301
|
+
}
|
|
302
|
+
if (!declaresDeliveryChannel(outcome.renderMeta)) {
|
|
303
|
+
return fail('any successful result IS a live mount', 'render meta declaring a component URL, a live channel (endpoint AND token), or a system card', `reading '${uri}' succeeded with contents that declare no delivery channel — a shell that can never paint anything. A read with nothing to mount answers with a typed error instead.`);
|
|
304
|
+
}
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
if (outcome.kind !== 'error') {
|
|
308
|
+
return fail('a read that cannot be mounted answers with a typed error, never a success-shaped result', probe.expect, `reading '${uri}' returned a result, but this locator cannot be mounted on this server.`);
|
|
309
|
+
}
|
|
310
|
+
const dataCode = readDataCode(outcome.error.data);
|
|
311
|
+
if (probe.expect.kind === 'outside-typed-set') {
|
|
312
|
+
// The NUMBER is deliberately not graded here, and an earlier draft
|
|
313
|
+
// of this arm got it wrong by banning the two canonical ones. MCP
|
|
314
|
+
// itself assigns the resource-missing number to a `resources/read`
|
|
315
|
+
// of a URI the server does not serve, so a framework that maps every
|
|
316
|
+
// unrecognized URI onto it is behaving correctly and would fail such
|
|
317
|
+
// a check forever. What survives is the classification: the four
|
|
318
|
+
// codes classify reads that NAME a locator, and this URI names none.
|
|
319
|
+
//
|
|
320
|
+
// That still leaves the two distinguishable, which is the point —
|
|
321
|
+
// a genuine miss carries the resource-read classification and the
|
|
322
|
+
// constant message; a malformed URI carries no classification at
|
|
323
|
+
// all, whatever number it rides on.
|
|
324
|
+
if (dataCode !== undefined) {
|
|
325
|
+
return fail('the four resource-read codes classify reads of a locator; a URI that names none is not one', 'an error carrying no resource-read classification on error.data.code', `reading '${uri}' — which names no render locator — was classified as '${dataCode}'. That classification is for reads of a locator; this is a malformed request, and giving it a locator verdict tells a host to stop retrying a request it should fix instead.`);
|
|
326
|
+
}
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
if (outcome.error.code !== probe.expect.jsonRpcCode) {
|
|
330
|
+
return fail('each classification rides on its canonical JSON-RPC number', probe.expect.jsonRpcCode, `reading '${uri}' answered on ${outcome.error.code}; this class rides on ${probe.expect.jsonRpcCode}. A host routes on the number before it reads anything else — a deterministic outcome reported as a malfunction invites a retry that cannot succeed.`);
|
|
331
|
+
}
|
|
332
|
+
if (dataCode !== probe.expect.dataCode) {
|
|
333
|
+
return fail('the fine-grained class rides on error.data.code', probe.expect.dataCode, `reading '${uri}' carried data.code ${dataCode === undefined ? '(absent)' : `'${dataCode}'`}; the catalog expects '${probe.expect.dataCode}'.`);
|
|
334
|
+
}
|
|
335
|
+
if (typeof outcome.error.message !== 'string') {
|
|
336
|
+
// Presence-and-type only, and NOT a ggui obligation: JSON-RPC 2.0
|
|
337
|
+
// requires an error object to carry a string `message`. An earlier
|
|
338
|
+
// draft required it to be non-empty, which is a rule no ruling
|
|
339
|
+
// establishes — a server whose constant message is the empty string
|
|
340
|
+
// is conformant, however unhelpful. Kept because the fusion
|
|
341
|
+
// comparison serializes this field, so a frame missing it entirely
|
|
342
|
+
// is malformed at the transport level before this catalog has an
|
|
343
|
+
// opinion.
|
|
344
|
+
return fail("JSON-RPC requires an error object to carry a string 'message'", 'a string message field', `reading '${uri}' answered with an error carrying no string message.`);
|
|
345
|
+
}
|
|
346
|
+
if (probe.expect.detailAbsent === true && dataHasDetail(outcome.error.data)) {
|
|
347
|
+
return fail('a NOT_FOUND body is constant — it carries no detail', 'error.data with no `detail` key', `reading '${uri}' attached a 'detail' to a NOT_FOUND. Whatever varies between a refused read and a read of a locator that never existed is an existence oracle for other callers' renders, and a diagnostic is exactly what varies.`);
|
|
348
|
+
}
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
function gradeDisclosure(testCase, outcomes) {
|
|
352
|
+
const secrets = testCase.disclosesNothing ?? [];
|
|
353
|
+
if (secrets.length === 0)
|
|
354
|
+
return null;
|
|
355
|
+
for (const [probeName, outcome] of outcomes) {
|
|
356
|
+
if (outcome.kind !== 'error')
|
|
357
|
+
continue;
|
|
358
|
+
const serialized = JSON.stringify(outcome.error);
|
|
359
|
+
for (const secret of secrets) {
|
|
360
|
+
if (serialized.includes(secret)) {
|
|
361
|
+
return {
|
|
362
|
+
name: testCase.name,
|
|
363
|
+
probe: probeName,
|
|
364
|
+
obligation: 'an error frame names nothing about the render it refused',
|
|
365
|
+
expected: `no occurrence of ${JSON.stringify(secret)}`,
|
|
366
|
+
actual: serialized,
|
|
367
|
+
message: `the error frame for probe '${probeName}' contains ${JSON.stringify(secret)}. A caller with no claim to this render learned it exists.`,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
function gradeIndistinguishable(testCase, outcomes) {
|
|
375
|
+
const group = testCase.indistinguishable ?? [];
|
|
376
|
+
if (group.length < 2)
|
|
377
|
+
return null;
|
|
378
|
+
// Session segments are caller-supplied and differ BY CONSTRUCTION
|
|
379
|
+
// between the probes being compared, so they are normalized out
|
|
380
|
+
// before the byte comparison. A server that echoes one back is not
|
|
381
|
+
// excused by this — `disclosesNothing` catches that, and it runs
|
|
382
|
+
// first.
|
|
383
|
+
const sessions = testCase.reads
|
|
384
|
+
.flatMap((probe) => (probe.locator.kind === 'render' ? [probe.locator.session] : []))
|
|
385
|
+
.sort((a, b) => b.length - a.length);
|
|
386
|
+
let reference = null;
|
|
387
|
+
for (const probeName of group) {
|
|
388
|
+
const outcome = outcomes.get(probeName);
|
|
389
|
+
if (outcome === undefined || outcome.kind !== 'error') {
|
|
390
|
+
return {
|
|
391
|
+
name: testCase.name,
|
|
392
|
+
probe: probeName,
|
|
393
|
+
obligation: 'a refused read and a read of a locator that never existed are the same bytes',
|
|
394
|
+
expected: 'an error frame to compare',
|
|
395
|
+
actual: outcome ?? '(no outcome recorded)',
|
|
396
|
+
message: `probe '${probeName}' is named in this case's indistinguishability group but did not produce an error frame, so the two reads are already distinguishable — one failed and one did not.`,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
const normalized = normalizeSessions(JSON.stringify(outcome.error), sessions);
|
|
400
|
+
if (reference === null) {
|
|
401
|
+
reference = { probe: probeName, frame: normalized };
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
if (normalized !== reference.frame) {
|
|
405
|
+
return {
|
|
406
|
+
name: testCase.name,
|
|
407
|
+
probe: probeName,
|
|
408
|
+
obligation: 'a refused read and a read of a locator that never existed are the same bytes',
|
|
409
|
+
expected: reference.frame,
|
|
410
|
+
actual: normalized,
|
|
411
|
+
message: `probes '${reference.probe}' and '${probeName}' produced different error frames. One read a locator that resolves for somebody else and one read a locator that never existed; any difference between them answers "does this render exist?" for a caller with no claim to it.`,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
function normalizeSessions(frame, sessions) {
|
|
418
|
+
let out = frame;
|
|
419
|
+
for (const session of sessions) {
|
|
420
|
+
out = out.split(session).join('<session>');
|
|
421
|
+
}
|
|
422
|
+
return out;
|
|
423
|
+
}
|
|
424
|
+
/** The two numbers the resource-read classification rides on. */
|
|
425
|
+
const CANONICAL_NUMBERS = {
|
|
426
|
+
NOT_FOUND: -32002,
|
|
427
|
+
MOUNT_UNAVAILABLE: -32006,
|
|
428
|
+
};
|
|
429
|
+
function readDataCode(data) {
|
|
430
|
+
if (!isRecord(data))
|
|
431
|
+
return undefined;
|
|
432
|
+
const code = data['code'];
|
|
433
|
+
return typeof code === 'string' ? code : undefined;
|
|
434
|
+
}
|
|
435
|
+
function dataHasDetail(data) {
|
|
436
|
+
return isRecord(data) && 'detail' in data;
|
|
437
|
+
}
|
|
438
|
+
function errorText(err) {
|
|
439
|
+
return err instanceof Error ? err.message : String(err);
|
|
440
|
+
}
|
|
441
|
+
// =============================================================================
|
|
442
|
+
// Case-authoring trust boundary
|
|
443
|
+
// =============================================================================
|
|
444
|
+
const SEED_KINDS = [
|
|
445
|
+
'identity-record',
|
|
446
|
+
'durable-blueprint',
|
|
447
|
+
'committed-render',
|
|
448
|
+
'uncommitted-render',
|
|
449
|
+
'registered-blueprint',
|
|
450
|
+
];
|
|
451
|
+
const DATA_CODES = [
|
|
452
|
+
'NOT_FOUND',
|
|
453
|
+
'BLUEPRINT_UNRESOLVABLE',
|
|
454
|
+
'NOT_SUPPORTED',
|
|
455
|
+
'NOT_MOUNTABLE',
|
|
456
|
+
];
|
|
457
|
+
const SUBSTRATE_WIRINGS = [
|
|
458
|
+
'all',
|
|
459
|
+
'none',
|
|
460
|
+
'identity-only',
|
|
461
|
+
'blueprints-only',
|
|
462
|
+
'all-ephemeral',
|
|
463
|
+
];
|
|
464
|
+
const EXPECTATION_KINDS = [
|
|
465
|
+
'typed-error',
|
|
466
|
+
'outside-typed-set',
|
|
467
|
+
'live-mount',
|
|
468
|
+
];
|
|
469
|
+
/**
|
|
470
|
+
* Validating parse of one authored case.
|
|
471
|
+
*
|
|
472
|
+
* The input is `unknown` BY DESIGN — this function IS the trust
|
|
473
|
+
* boundary, so it never leans on the static union it produces evidence
|
|
474
|
+
* for. The catalog's JSON enters the type system through a compile-time
|
|
475
|
+
* cast (tsc widens JSON-module string literals), which means the static
|
|
476
|
+
* type is the claim being checked here, not support for it. Same
|
|
477
|
+
* posture as the WebSocket runner's `parseSetupStep`.
|
|
478
|
+
*
|
|
479
|
+
* An unknown seed kind, locator kind, expectation kind or
|
|
480
|
+
* classification is a CASE-AUTHORING error: the runner throws, aborting
|
|
481
|
+
* the run loudly. It is never a skip and never a fail — those verdicts
|
|
482
|
+
* describe the server under test, not the catalog.
|
|
483
|
+
*
|
|
484
|
+
* Exported for the kit's own meta-tests and reachable from the
|
|
485
|
+
* `./resource-read-conformance` subpath, but NOT re-exported from the
|
|
486
|
+
* package root — the runner calls it for you, and a consumer needing it
|
|
487
|
+
* directly is authoring their own catalog rather than grading against
|
|
488
|
+
* this one.
|
|
489
|
+
*/
|
|
490
|
+
export function parseCase(input) {
|
|
491
|
+
if (!isRecord(input) || typeof input['name'] !== 'string' || input['name'].length === 0) {
|
|
492
|
+
throw new Error(`protocol-conformance: a resource-read case is malformed — expected an object with a non-empty string 'name'. Received: ${JSON.stringify(input)}`);
|
|
493
|
+
}
|
|
494
|
+
const name = input['name'];
|
|
495
|
+
const bad = (problem) => new Error(`protocol-conformance: resource-read case '${name}' is malformed — ${problem}.`);
|
|
496
|
+
rejectUnknownKeys(bad, input, CASE_KEYS, 'the case');
|
|
497
|
+
if (typeof input['description'] !== 'string' || input['description'].length === 0) {
|
|
498
|
+
throw bad("'description' must be a non-empty string — it is what a failure report names");
|
|
499
|
+
}
|
|
500
|
+
const server = input['server'];
|
|
501
|
+
if (!isRecord(server))
|
|
502
|
+
throw bad("'server' must be an object declaring the deployment shape");
|
|
503
|
+
rejectUnknownKeys(bad, server, SERVER_KEYS, "'server'");
|
|
504
|
+
for (const flag of ['blueprintRegistry', 'staticDelivery', 'liveChannel']) {
|
|
505
|
+
if (server[flag] !== undefined && typeof server[flag] !== 'boolean') {
|
|
506
|
+
throw bad(`'server.${flag}' must be a boolean when present`);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
const caller = input['caller'];
|
|
510
|
+
if (caller !== 'owner' && caller !== 'other') {
|
|
511
|
+
throw bad(`'caller' must be 'owner' or 'other'. Received: ${JSON.stringify(caller)}`);
|
|
512
|
+
}
|
|
513
|
+
const rawSeeds = input['seeds'];
|
|
514
|
+
if (!Array.isArray(rawSeeds))
|
|
515
|
+
throw bad("'seeds' must be an array (possibly empty)");
|
|
516
|
+
const seeds = rawSeeds.map((seed) => parseSeed(bad, seed));
|
|
517
|
+
const rawReads = input['reads'];
|
|
518
|
+
if (!Array.isArray(rawReads) || rawReads.length === 0) {
|
|
519
|
+
throw bad("'reads' must be a non-empty array — a case that reads nothing grades nothing");
|
|
520
|
+
}
|
|
521
|
+
const probeNames = new Set();
|
|
522
|
+
const reads = rawReads.map((probe) => {
|
|
523
|
+
const parsed = parseProbe(bad, probe);
|
|
524
|
+
if (probeNames.has(parsed.as))
|
|
525
|
+
throw bad(`two probes named '${parsed.as}'`);
|
|
526
|
+
probeNames.add(parsed.as);
|
|
527
|
+
return parsed;
|
|
528
|
+
});
|
|
529
|
+
const group = input['indistinguishable'];
|
|
530
|
+
if (group !== undefined) {
|
|
531
|
+
if (!Array.isArray(group))
|
|
532
|
+
throw bad("'indistinguishable' must be an array of probe names");
|
|
533
|
+
if (group.length === 1) {
|
|
534
|
+
throw bad("'indistinguishable' names a single probe — a group of one asserts nothing, which reads as an obligation being graded when it is not");
|
|
535
|
+
}
|
|
536
|
+
for (const ref of group) {
|
|
537
|
+
if (typeof ref !== 'string' || !probeNames.has(ref)) {
|
|
538
|
+
throw bad(`the indistinguishability group names unknown probe '${String(ref)}'`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
const secrets = input['disclosesNothing'];
|
|
543
|
+
if (secrets !== undefined &&
|
|
544
|
+
(!Array.isArray(secrets) || secrets.some((s) => typeof s !== 'string' || s.length === 0))) {
|
|
545
|
+
throw bad("'disclosesNothing' must be an array of non-empty strings");
|
|
546
|
+
}
|
|
547
|
+
// Constructed rather than asserted: every field below came through a
|
|
548
|
+
// check above, so the returned value carries evidence for its type
|
|
549
|
+
// instead of a claim about it.
|
|
550
|
+
return {
|
|
551
|
+
name,
|
|
552
|
+
description: input['description'],
|
|
553
|
+
server: {
|
|
554
|
+
durableSubstrate: parseSubstrate(bad, server['durableSubstrate']),
|
|
555
|
+
...(server['blueprintRegistry'] === true ? { blueprintRegistry: true } : {}),
|
|
556
|
+
...(server['staticDelivery'] === true ? { staticDelivery: true } : {}),
|
|
557
|
+
...(server['liveChannel'] === true ? { liveChannel: true } : {}),
|
|
558
|
+
},
|
|
559
|
+
caller,
|
|
560
|
+
seeds,
|
|
561
|
+
reads,
|
|
562
|
+
...(group !== undefined ? { indistinguishable: group.map(String) } : {}),
|
|
563
|
+
...(secrets !== undefined ? { disclosesNothing: secrets.map(String) } : {}),
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Reject any key outside the authored vocabulary.
|
|
568
|
+
*
|
|
569
|
+
* A typo is otherwise SILENT and expensive: `"liveChanel": true` leaves
|
|
570
|
+
* the flag undefined, so the case brings up a server with no live
|
|
571
|
+
* channel and grades that instead — passing, while asserting something
|
|
572
|
+
* other than what it reads as asserting. Every closed shape in this
|
|
573
|
+
* module is checked, because each one is a scenario the catalog claims
|
|
574
|
+
* to have graded.
|
|
575
|
+
*/
|
|
576
|
+
function rejectUnknownKeys(bad, value, known, what) {
|
|
577
|
+
for (const key of Object.keys(value)) {
|
|
578
|
+
if (!known.includes(key)) {
|
|
579
|
+
throw bad(`${what} carries unknown key '${key}' — the vocabulary is closed: ${known.join(', ')}`);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
const CASE_KEYS = [
|
|
584
|
+
'name',
|
|
585
|
+
'description',
|
|
586
|
+
'server',
|
|
587
|
+
'caller',
|
|
588
|
+
'seeds',
|
|
589
|
+
'reads',
|
|
590
|
+
'indistinguishable',
|
|
591
|
+
'disclosesNothing',
|
|
592
|
+
];
|
|
593
|
+
const SERVER_KEYS = [
|
|
594
|
+
'durableSubstrate',
|
|
595
|
+
'blueprintRegistry',
|
|
596
|
+
'staticDelivery',
|
|
597
|
+
'liveChannel',
|
|
598
|
+
];
|
|
599
|
+
const PROBE_KEYS = ['as', 'locator', 'expect'];
|
|
600
|
+
const SEED_KEYS = {
|
|
601
|
+
'identity-record': ['kind', 'session', 'key', 'blueprint'],
|
|
602
|
+
'durable-blueprint': ['kind', 'componentRef', 'body'],
|
|
603
|
+
'committed-render': ['kind', 'session'],
|
|
604
|
+
'uncommitted-render': ['kind', 'session'],
|
|
605
|
+
'registered-blueprint': ['kind', 'as'],
|
|
606
|
+
};
|
|
607
|
+
const LOCATOR_KEYS = {
|
|
608
|
+
render: ['kind', 'session', 'key'],
|
|
609
|
+
raw: ['kind', 'uri'],
|
|
610
|
+
};
|
|
611
|
+
const KEY_KEYS = {
|
|
612
|
+
literal: ['kind', 'value'],
|
|
613
|
+
registered: ['kind', 'seed'],
|
|
614
|
+
};
|
|
615
|
+
const EXPECTATION_KEYS = {
|
|
616
|
+
'typed-error': ['kind', 'jsonRpcCode', 'dataCode', 'detailAbsent'],
|
|
617
|
+
'outside-typed-set': ['kind'],
|
|
618
|
+
'live-mount': ['kind'],
|
|
619
|
+
};
|
|
620
|
+
function parseSubstrate(bad, value) {
|
|
621
|
+
switch (value) {
|
|
622
|
+
case 'all':
|
|
623
|
+
case 'none':
|
|
624
|
+
case 'identity-only':
|
|
625
|
+
case 'blueprints-only':
|
|
626
|
+
case 'all-ephemeral':
|
|
627
|
+
return value;
|
|
628
|
+
default:
|
|
629
|
+
throw bad(`'server.durableSubstrate' must be one of: ${SUBSTRATE_WIRINGS.join(', ')}. Received: ${JSON.stringify(value)}`);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
function requireText(bad, value, what) {
|
|
633
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
634
|
+
throw bad(`${what} must be a non-empty string`);
|
|
635
|
+
}
|
|
636
|
+
return value;
|
|
637
|
+
}
|
|
638
|
+
function parseSeed(bad, seed) {
|
|
639
|
+
if (!isRecord(seed))
|
|
640
|
+
throw bad(`a seed must be an object. Received: ${JSON.stringify(seed)}`);
|
|
641
|
+
const seedKeys = SEED_KEYS[String(seed['kind'])];
|
|
642
|
+
if (seedKeys !== undefined)
|
|
643
|
+
rejectUnknownKeys(bad, seed, seedKeys, `a '${String(seed['kind'])}' seed`);
|
|
644
|
+
switch (seed['kind']) {
|
|
645
|
+
case 'identity-record': {
|
|
646
|
+
const blueprint = seed['blueprint'];
|
|
647
|
+
if (blueprint !== 'named' && blueprint !== 'unnamed') {
|
|
648
|
+
throw bad("an identity-record seed's 'blueprint' must be 'named' or 'unnamed'");
|
|
649
|
+
}
|
|
650
|
+
return {
|
|
651
|
+
kind: 'identity-record',
|
|
652
|
+
session: requireText(bad, seed['session'], "an identity-record seed's 'session'"),
|
|
653
|
+
key: requireText(bad, seed['key'], "an identity-record seed's 'key'"),
|
|
654
|
+
blueprint,
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
case 'durable-blueprint': {
|
|
658
|
+
const componentRef = seed['componentRef'];
|
|
659
|
+
const body = seed['body'];
|
|
660
|
+
if (componentRef !== 'present' && componentRef !== 'absent') {
|
|
661
|
+
throw bad("a durable-blueprint seed's 'componentRef' must be 'present' or 'absent'");
|
|
662
|
+
}
|
|
663
|
+
if (body !== 'stored' && body !== 'purged') {
|
|
664
|
+
throw bad("a durable-blueprint seed's 'body' must be 'stored' or 'purged'");
|
|
665
|
+
}
|
|
666
|
+
return { kind: 'durable-blueprint', componentRef, body };
|
|
667
|
+
}
|
|
668
|
+
case 'committed-render':
|
|
669
|
+
return {
|
|
670
|
+
kind: 'committed-render',
|
|
671
|
+
session: requireText(bad, seed['session'], "a committed-render seed's 'session'"),
|
|
672
|
+
};
|
|
673
|
+
case 'uncommitted-render':
|
|
674
|
+
return {
|
|
675
|
+
kind: 'uncommitted-render',
|
|
676
|
+
session: requireText(bad, seed['session'], "an uncommitted-render seed's 'session'"),
|
|
677
|
+
};
|
|
678
|
+
case 'registered-blueprint':
|
|
679
|
+
return {
|
|
680
|
+
kind: 'registered-blueprint',
|
|
681
|
+
as: requireText(bad, seed['as'], "a registered-blueprint seed's 'as'"),
|
|
682
|
+
};
|
|
683
|
+
default:
|
|
684
|
+
throw bad(`unknown seed kind '${String(seed['kind'])}'. The seed vocabulary is closed: ${SEED_KINDS.join(', ')}`);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
function parseProbe(bad, probe) {
|
|
688
|
+
if (!isRecord(probe))
|
|
689
|
+
throw bad(`a probe must be an object. Received: ${JSON.stringify(probe)}`);
|
|
690
|
+
const as = requireText(bad, probe['as'], "a probe's 'as' name");
|
|
691
|
+
rejectUnknownKeys(bad, probe, PROBE_KEYS, `probe '${as}'`);
|
|
692
|
+
return {
|
|
693
|
+
as,
|
|
694
|
+
locator: parseLocator(bad, as, probe['locator']),
|
|
695
|
+
expect: parseExpectation(bad, as, probe['expect']),
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
function parseLocator(bad, as, locator) {
|
|
699
|
+
if (!isRecord(locator))
|
|
700
|
+
throw bad(`probe '${as}' authors no locator`);
|
|
701
|
+
const locatorKeys = LOCATOR_KEYS[String(locator['kind'])];
|
|
702
|
+
if (locatorKeys !== undefined) {
|
|
703
|
+
rejectUnknownKeys(bad, locator, locatorKeys, `probe '${as}' locator`);
|
|
704
|
+
}
|
|
705
|
+
switch (locator['kind']) {
|
|
706
|
+
case 'render': {
|
|
707
|
+
const session = requireText(bad, locator['session'], `probe '${as}' locator 'session'`);
|
|
708
|
+
const key = locator['key'];
|
|
709
|
+
if (key === undefined)
|
|
710
|
+
return { kind: 'render', session };
|
|
711
|
+
return { kind: 'render', session, key: parseKey(bad, as, key) };
|
|
712
|
+
}
|
|
713
|
+
case 'raw':
|
|
714
|
+
return { kind: 'raw', uri: requireText(bad, locator['uri'], `probe '${as}' locator 'uri'`) };
|
|
715
|
+
default:
|
|
716
|
+
throw bad(`probe '${as}' authors unknown locator kind '${String(locator['kind'])}'. The vocabulary is closed: render, raw`);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
function parseKey(bad, as, key) {
|
|
720
|
+
if (!isRecord(key))
|
|
721
|
+
throw bad(`probe '${as}' authors a malformed blueprint key`);
|
|
722
|
+
const keyKeys = KEY_KEYS[String(key['kind'])];
|
|
723
|
+
if (keyKeys !== undefined)
|
|
724
|
+
rejectUnknownKeys(bad, key, keyKeys, `probe '${as}' blueprint key`);
|
|
725
|
+
switch (key['kind']) {
|
|
726
|
+
case 'literal':
|
|
727
|
+
return { kind: 'literal', value: requireText(bad, key['value'], `probe '${as}' key value`) };
|
|
728
|
+
case 'registered':
|
|
729
|
+
return {
|
|
730
|
+
kind: 'registered',
|
|
731
|
+
seed: requireText(bad, key['seed'], `probe '${as}' registered-key seed name`),
|
|
732
|
+
};
|
|
733
|
+
default:
|
|
734
|
+
throw bad(`probe '${as}' authors unknown key kind '${String(key['kind'])}'. The vocabulary is closed: literal, registered`);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
function parseExpectation(bad, as, expect) {
|
|
738
|
+
if (!isRecord(expect))
|
|
739
|
+
throw bad(`probe '${as}' authors no expectation`);
|
|
740
|
+
const expectKeys = EXPECTATION_KEYS[String(expect['kind'])];
|
|
741
|
+
if (expectKeys !== undefined) {
|
|
742
|
+
rejectUnknownKeys(bad, expect, expectKeys, `probe '${as}' expectation`);
|
|
743
|
+
}
|
|
744
|
+
switch (expect['kind']) {
|
|
745
|
+
case 'outside-typed-set':
|
|
746
|
+
return { kind: 'outside-typed-set' };
|
|
747
|
+
case 'live-mount':
|
|
748
|
+
return { kind: 'live-mount' };
|
|
749
|
+
case 'typed-error': {
|
|
750
|
+
const dataCode = parseDataCode(bad, as, expect['dataCode']);
|
|
751
|
+
const canonical = dataCode === 'NOT_FOUND'
|
|
752
|
+
? CANONICAL_NUMBERS.NOT_FOUND
|
|
753
|
+
: CANONICAL_NUMBERS.MOUNT_UNAVAILABLE;
|
|
754
|
+
if (expect['jsonRpcCode'] !== canonical) {
|
|
755
|
+
throw bad(`probe '${as}' pins ${String(expect['jsonRpcCode'])} for '${dataCode}', which rides on ${canonical}`);
|
|
756
|
+
}
|
|
757
|
+
const detailAbsent = expect['detailAbsent'];
|
|
758
|
+
if (detailAbsent !== undefined && typeof detailAbsent !== 'boolean') {
|
|
759
|
+
throw bad(`probe '${as}' authors a non-boolean 'detailAbsent'`);
|
|
760
|
+
}
|
|
761
|
+
return {
|
|
762
|
+
kind: 'typed-error',
|
|
763
|
+
jsonRpcCode: canonical,
|
|
764
|
+
dataCode,
|
|
765
|
+
...(detailAbsent === true ? { detailAbsent: true } : {}),
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
default:
|
|
769
|
+
throw bad(`probe '${as}' authors unknown expectation kind '${String(expect['kind'])}'. The vocabulary is closed: ${EXPECTATION_KINDS.join(', ')}`);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
function parseDataCode(bad, as, value) {
|
|
773
|
+
switch (value) {
|
|
774
|
+
case 'NOT_FOUND':
|
|
775
|
+
case 'BLUEPRINT_UNRESOLVABLE':
|
|
776
|
+
case 'NOT_SUPPORTED':
|
|
777
|
+
case 'NOT_MOUNTABLE':
|
|
778
|
+
return value;
|
|
779
|
+
default:
|
|
780
|
+
throw bad(`probe '${as}' authors unknown classification '${String(value)}'. The vocabulary is closed: ${DATA_CODES.join(', ')}`);
|
|
781
|
+
}
|
|
782
|
+
}
|