@ggui-ai/protocol-conformance 0.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +89 -0
  3. package/dist/cli.d.ts +15 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +193 -0
  6. package/dist/conformance-host.d.ts +155 -0
  7. package/dist/conformance-host.d.ts.map +1 -0
  8. package/dist/conformance-host.js +45 -0
  9. package/dist/fixtures/bootstrap-protocol/bootstrap-bundle-fetch-failed.json +26 -0
  10. package/dist/fixtures/bootstrap-protocol/bootstrap-meta-missing.json +32 -0
  11. package/dist/fixtures/bootstrap-protocol/bootstrap-success.json +17 -0
  12. package/dist/fixtures/bootstrap-protocol/index.d.ts +5 -0
  13. package/dist/fixtures/bootstrap-protocol/index.d.ts.map +1 -0
  14. package/dist/fixtures/bootstrap-protocol/index.js +24 -0
  15. package/dist/fixtures/canvas-mode-wire-shapes/canvas-bootstrap-mutual-exclusion.json +25 -0
  16. package/dist/fixtures/canvas-mode-wire-shapes/canvas-lifecycle-channel-emits-handshake-started.json +30 -0
  17. package/dist/fixtures/canvas-mode-wire-shapes/canvas-navigated-updates-active-stack-item.json +30 -0
  18. package/dist/fixtures/canvas-mode-wire-shapes/host-context-observed-persists.json +22 -0
  19. package/dist/fixtures/canvas-mode-wire-shapes/index.d.ts +5 -0
  20. package/dist/fixtures/canvas-mode-wire-shapes/index.d.ts.map +1 -0
  21. package/dist/fixtures/canvas-mode-wire-shapes/index.js +47 -0
  22. package/dist/fixtures/index.d.ts +78 -0
  23. package/dist/fixtures/index.d.ts.map +1 -0
  24. package/dist/fixtures/index.js +87 -0
  25. package/dist/fixtures/observability-events/index.d.ts +5 -0
  26. package/dist/fixtures/observability-events/index.d.ts.map +1 -0
  27. package/dist/fixtures/observability-events/index.js +22 -0
  28. package/dist/fixtures/observability-events/observability-contract-error-emitted.json +29 -0
  29. package/dist/fixtures/observability-events/observability-wired-tool-invoked.json +28 -0
  30. package/dist/fixtures/refresh-semantics/index.d.ts +5 -0
  31. package/dist/fixtures/refresh-semantics/index.d.ts.map +1 -0
  32. package/dist/fixtures/refresh-semantics/index.js +19 -0
  33. package/dist/fixtures/refresh-semantics/stream-refresh-success.json +40 -0
  34. package/dist/fixtures/reserved-channel-authority/index.d.ts +4 -0
  35. package/dist/fixtures/reserved-channel-authority/index.d.ts.map +1 -0
  36. package/dist/fixtures/reserved-channel-authority/index.js +21 -0
  37. package/dist/fixtures/reserved-channel-authority/props-update-roundtrip.json +34 -0
  38. package/dist/fixtures/reserved-channel-authority/stream-schema-violation.json +45 -0
  39. package/dist/fixtures/schema-version-handshake/index.d.ts +4 -0
  40. package/dist/fixtures/schema-version-handshake/index.d.ts.map +1 -0
  41. package/dist/fixtures/schema-version-handshake/index.js +20 -0
  42. package/dist/fixtures/schema-version-handshake/version-match.json +16 -0
  43. package/dist/fixtures/schema-version-handshake/version-mismatch.json +26 -0
  44. package/dist/fixtures/wired-action-dispatch/index.d.ts +5 -0
  45. package/dist/fixtures/wired-action-dispatch/index.d.ts.map +1 -0
  46. package/dist/fixtures/wired-action-dispatch/index.js +29 -0
  47. package/dist/fixtures/wired-action-dispatch/wired-action-success.json +36 -0
  48. package/dist/fixtures/wired-action-dispatch/wired-action-tool-not-found.json +31 -0
  49. package/dist/fixtures/wired-action-dispatch/wired-action-tool-threw.json +32 -0
  50. package/dist/fixtures/wired-action-dispatch/wired-action-tool-timeout.json +32 -0
  51. package/dist/index.d.ts +28 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +40 -0
  54. package/dist/loader.d.ts +19 -0
  55. package/dist/loader.d.ts.map +1 -0
  56. package/dist/loader.js +46 -0
  57. package/dist/match-behavior.d.ts +121 -0
  58. package/dist/match-behavior.d.ts.map +1 -0
  59. package/dist/match-behavior.js +385 -0
  60. package/dist/registration-conformance/cases/gate-export-not-registered.json +19 -0
  61. package/dist/registration-conformance/cases/gate-no-gadgets-accept.json +13 -0
  62. package/dist/registration-conformance/cases/gate-package-mismatch.json +19 -0
  63. package/dist/registration-conformance/cases/gate-priority-not-registered-outranks-mismatch.json +20 -0
  64. package/dist/registration-conformance/cases/gate-registered-resolves-accept.json +25 -0
  65. package/dist/registration-conformance/cases/gate-stdlib-hook-accept.json +27 -0
  66. package/dist/registration-conformance/cases/gate-typo-classifies-as-not-registered.json +27 -0
  67. package/dist/registration-conformance/index.d.ts +114 -0
  68. package/dist/registration-conformance/index.d.ts.map +1 -0
  69. package/dist/registration-conformance/index.js +63 -0
  70. package/dist/reporter.d.ts +32 -0
  71. package/dist/reporter.d.ts.map +1 -0
  72. package/dist/reporter.js +151 -0
  73. package/dist/resolution-conformance/cases/resolve-bundle-host-computed.json +13 -0
  74. package/dist/resolution-conformance/cases/resolve-default-host.json +12 -0
  75. package/dist/resolution-conformance/cases/resolve-explicit-bundle-and-style-url.json +14 -0
  76. package/dist/resolution-conformance/cases/resolve-explicit-bundle-url.json +12 -0
  77. package/dist/resolution-conformance/cases/resolve-loopback-host-http-scheme.json +13 -0
  78. package/dist/resolution-conformance/cases/resolve-package-only-no-version.json +8 -0
  79. package/dist/resolution-conformance/cases/resolve-stdlib-no-urls.json +10 -0
  80. package/dist/resolution-conformance/index.d.ts +76 -0
  81. package/dist/resolution-conformance/index.d.ts.map +1 -0
  82. package/dist/resolution-conformance/index.js +81 -0
  83. package/dist/run-conformance.d.ts +58 -0
  84. package/dist/run-conformance.d.ts.map +1 -0
  85. package/dist/run-conformance.js +286 -0
  86. package/dist/schema-conformance/cases/wire-bad-export-name-reject.json +11 -0
  87. package/dist/schema-conformance/cases/wire-bad-package-name-reject.json +11 -0
  88. package/dist/schema-conformance/cases/wire-component-export-accept.json +10 -0
  89. package/dist/schema-conformance/cases/wire-empty-package-reject.json +11 -0
  90. package/dist/schema-conformance/cases/wire-minimal-accept.json +10 -0
  91. package/dist/schema-conformance/cases/wire-multi-package-accept.json +14 -0
  92. package/dist/schema-conformance/cases/wire-prose-accept.json +15 -0
  93. package/dist/schema-conformance/cases/wire-registry-metadata-reject.json +13 -0
  94. package/dist/schema-conformance/cases/wire-stale-libraries-reject.json +14 -0
  95. package/dist/schema-conformance/cases/wire-stdlib-accept.json +10 -0
  96. package/dist/schema-conformance/cases/wire-transport-field-reject.json +15 -0
  97. package/dist/schema-conformance/cases/wire-version-field-reject.json +13 -0
  98. package/dist/schema-conformance/index.d.ts +71 -0
  99. package/dist/schema-conformance/index.d.ts.map +1 -0
  100. package/dist/schema-conformance/index.js +103 -0
  101. package/dist/types.d.ts +434 -0
  102. package/dist/types.d.ts.map +1 -0
  103. package/dist/types.js +62 -0
  104. package/dist/ws-transport.d.ts +42 -0
  105. package/dist/ws-transport.d.ts.map +1 -0
  106. package/dist/ws-transport.js +135 -0
  107. package/package.json +83 -0
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Match observed WS frames against a fixture's {@link ExpectedBehavior}.
3
+ *
4
+ * Each matcher is pure — it consumes a read-only array of frames the
5
+ * runner collected from the transport and returns either a pass or a
6
+ * {@link MatchFailure} the runner lifts into a
7
+ * {@link ConformanceFailure}. No side-effects, no hidden state — the
8
+ * runner owns orchestration.
9
+ *
10
+ * ## Path-A vs Path-B partition
11
+ *
12
+ * The kit's behavior vocabulary spans both wire-observable claims (the
13
+ * subscribe ack, contract-error envelopes on reserved channels, stream
14
+ * updates) AND surface-observable claims (DOM state after a props
15
+ * update, browser-side bootstrap failure modes). This file matches the
16
+ * **Path-A** subset — the wire-observable behaviors a runner can
17
+ * assert from frames alone, with no MCP-Apps-host adapter and no
18
+ * Playwright page. The Path-B subset (browser-host harness) is
19
+ * inherently NOT WS-reducible: bootstrap-failure modes are caused by
20
+ * the renderer-bundle fetch + `ui/initialize` round-trip the host
21
+ * speaks over postMessage; props-update assertions are assertions on
22
+ * the rendered DOM the renderer paints, not on WS frames the server
23
+ * emits. For these kinds we return `unmatchable-on-ws` with a sharp
24
+ * reason and the runner records SKIP — they are NOT failures of the
25
+ * server, they are claims a different driver must drive.
26
+ *
27
+ * The Path-B driver is a separate Playwright-based test suite — it
28
+ * picks up the `bootstrap-failure` fixtures and asserts on the
29
+ * iframe-host's `data-ggui-console-iframe-error` pane after
30
+ * route-based fault injection. A future packaged browser-host adapter
31
+ * will fold that capability into the kit so third-party adopters
32
+ * don't have to reimplement it. Until then, the partition is honest:
33
+ * Path A handles wire claims, Path B (out-of-process) handles
34
+ * browser claims, and `unmatchable-on-ws` is the partition seam.
35
+ *
36
+ * v1 Path-A scope:
37
+ *
38
+ * - `bootstrap-success` — subscribe produced an `ack` frame.
39
+ * - `version-mismatch` — subscribe produced an `error` frame with
40
+ * `code: 'UPGRADE_REQUIRED'`.
41
+ * - `contract-error` — stream frame on `_ggui:contract-error`
42
+ * channel matches `code` + `toolName` (+ optional `actionName` +
43
+ * `sourceAction`).
44
+ * - `stream-update` — stream frame on named channel matches
45
+ * declared value.
46
+ * - `no-op` — no frames observed after input dispatch.
47
+ * - `observability-event` — see below.
48
+ *
49
+ * v1 Path-B-only kinds (return `unmatchable-on-ws`):
50
+ *
51
+ * - `bootstrap-failure` — the fault surface lives in the host's
52
+ * bootstrap-fetch + `ui/initialize` round-trip; the fixture's
53
+ * `renderer-url-override` / `ui-initialize-response-override`
54
+ * setup directives are MCP-Apps-host concerns, not WS server
55
+ * concerns. The reference server's host adapter correctly throws
56
+ * "out of scope" on these directives — that's not a bug, it's
57
+ * the partition being respected.
58
+ * - `props-update` — the assertion is on rendered DOM after the
59
+ * server emits a `_ggui:props` frame; matchable on WS only as
60
+ * "frame was emitted", not as "DOM reflects it" — and the fixture
61
+ * vocabulary asserts the latter.
62
+ *
63
+ * ## `observability-event` — WS-evidence matcher
64
+ *
65
+ * The ggui protocol bar mandates that observability events follow
66
+ * from WS-observable evidence. The bar's claim is "any conformant host
67
+ * MUST emit `wired-tool-invoked` when the WS observes a wired-action's
68
+ * tool dispatch + result frame; any conformant host MUST emit
69
+ * `contract-error-emitted` when the WS observes a `_ggui:contract-
70
+ * error` envelope." So a Path-A pass on an `observability-event`
71
+ * fixture is the kit certifying "WS evidence the bar mandates a host
72
+ * mirror-emission for is present" — a true vendor-neutral conformance
73
+ * claim, not a renderer-internal probe.
74
+ *
75
+ * Two recognized event arms (extensibly closed via `(string & {})`):
76
+ *
77
+ * - `wired-tool-invoked` — assert a `stream` frame on the
78
+ * `_ggui:wired-tool-invoked` channel carrying
79
+ * `value: {toolName, actionName?}` matching `event.toolName`
80
+ * (+ optional `event.actionName`). The reference server emits
81
+ * this signal from `action-router.ts::dispatchAction()` after a
82
+ * successful handler resolution — every conformant host MUST
83
+ * produce equivalent WS evidence (or a richer mirror-emission a
84
+ * Path-B browser host can observe).
85
+ *
86
+ * - `contract-error-emitted` — assert a `stream` frame on the
87
+ * `_ggui:contract-error` channel whose canonical SPEC §4.4
88
+ * `ContractErrorPayload` carries `error.code === event.code` and
89
+ * `toolName === event.toolName` (+ optional `event.actionName`).
90
+ * This is the SAME frame {@link matchContractError} matches; the
91
+ * observability arm asserts the bar's "every live-channel envelope
92
+ * becomes an observability event" mirror without requiring a
93
+ * postMessage-capture harness.
94
+ *
95
+ * Other arms (`schema-version-mismatch`, `subscribe-failed`,
96
+ * future `(string & {})` tails) the matcher cannot ground in current
97
+ * WS evidence — return `unmatchable-on-ws` with a sharp reason so the
98
+ * runner records SKIP, not FAIL. These are Path-B candidates for the
99
+ * future browser-host adapter.
100
+ */
101
+ import type { ExpectedBehavior } from './types.js';
102
+ import type { ObservedFrame } from './ws-transport.js';
103
+ /**
104
+ * Matcher outcome. `pass` → the fixture's assertion is satisfied.
105
+ * `fail` → wire-level mismatch; the runner records a
106
+ * {@link ConformanceFailure}. `unmatchable-on-ws` → this behavior is
107
+ * not drivable over pure WS; the runner records a skip.
108
+ */
109
+ export type MatchResult = {
110
+ readonly kind: 'pass';
111
+ } | {
112
+ readonly kind: 'fail';
113
+ readonly expected: unknown;
114
+ readonly received: unknown;
115
+ readonly message: string;
116
+ } | {
117
+ readonly kind: 'unmatchable-on-ws';
118
+ readonly reason: string;
119
+ };
120
+ export declare function matchBehavior(behavior: ExpectedBehavior, frames: readonly ObservedFrame[]): MatchResult;
121
+ //# sourceMappingURL=match-behavior.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"match-behavior.d.ts","sourceRoot":"","sources":["../src/match-behavior.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmGG;AACH,OAAO,KAAK,EAGV,gBAAgB,EAMjB,MAAM,YAAY,CAAC;AACpB,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAEvD;;;;;GAKG;AACH,MAAM,MAAM,WAAW,GACnB;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GACzB;IACE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B,GACD;IAAE,QAAQ,CAAC,IAAI,EAAE,mBAAmB,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAEpE,wBAAgB,aAAa,CAC3B,QAAQ,EAAE,gBAAgB,EAC1B,MAAM,EAAE,SAAS,aAAa,EAAE,GAC/B,WAAW,CA2Cb"}
@@ -0,0 +1,385 @@
1
+ export function matchBehavior(behavior, frames) {
2
+ // Dispatch on the `kind` discriminator with explicit casts because
3
+ // the extensibly-closed `UnknownBehavior` (`kind: string & {}`) in
4
+ // the union widens the discriminant, preventing TS from narrowing
5
+ // on literals. We checked the literal above — the cast is sound.
6
+ if (behavior.kind === 'bootstrap-success') {
7
+ return matchBootstrapSuccess(behavior, frames);
8
+ }
9
+ if (behavior.kind === 'version-mismatch') {
10
+ return matchVersionMismatch(behavior, frames);
11
+ }
12
+ if (behavior.kind === 'contract-error') {
13
+ return matchContractError(behavior, frames);
14
+ }
15
+ if (behavior.kind === 'stream-update') {
16
+ return matchStreamUpdate(behavior, frames);
17
+ }
18
+ if (behavior.kind === 'no-op') {
19
+ return matchNoOp(behavior, frames);
20
+ }
21
+ if (behavior.kind === 'bootstrap-failure') {
22
+ return {
23
+ kind: 'unmatchable-on-ws',
24
+ reason: 'bootstrap-failure is a Path-B (browser-host) claim — the fault surface is the host\'s bootstrap-fetch + `ui/initialize` postMessage round-trip, not a WS frame the server emits. The `renderer-url-override` / `ui-initialize-response-override` setup directives are MCP-Apps-host concerns; the reference server\'s host adapter throws "out of scope" on them by design. Drive these fixtures via a Path-B browser-host harness (today: Slice M Playwright dispatch in `e2e/ggui-oss/tests/mcp-app-iframe.spec.ts`; future: packaged Phase-3.2 adapter inside the kit).',
25
+ };
26
+ }
27
+ if (behavior.kind === 'observability-event') {
28
+ return matchObservabilityEvent(behavior, frames);
29
+ }
30
+ if (behavior.kind === 'props-update') {
31
+ return {
32
+ kind: 'unmatchable-on-ws',
33
+ reason: 'props-update is a Path-B (browser-host) claim — the assertion is on rendered DOM (selector + attribute/text), not on the `_ggui:props` WS frame in isolation. Matchable on WS only as "frame was emitted"; the fixture vocabulary asserts "DOM reflects the update". Drive via a Path-B browser-host harness (today: Slice M Playwright dispatch in `e2e/ggui-oss/tests/mcp-app-iframe.spec.ts`; future: packaged Phase-3.2 adapter inside the kit).',
34
+ };
35
+ }
36
+ // Extensibly-closed union catch — unknown `kind` = new fixture
37
+ // vocabulary the runner doesn't recognize; skip cleanly.
38
+ return {
39
+ kind: 'unmatchable-on-ws',
40
+ reason: `Unknown expectedBehavior.kind='${behavior.kind}' — kit runner does not recognize this behavior. Check kit version.`,
41
+ };
42
+ }
43
+ // =============================================================================
44
+ // Per-behavior matchers
45
+ // =============================================================================
46
+ function matchBootstrapSuccess(_behavior, frames) {
47
+ const ack = frames.find((f) => f.kind === 'frame' && f.parsed['type'] === 'ack');
48
+ if (ack === undefined) {
49
+ return {
50
+ kind: 'fail',
51
+ expected: { type: 'ack' },
52
+ received: frames.map((f) => (f.kind === 'frame' ? f.parsed['type'] : 'unparseable')),
53
+ message: 'expected an `ack` frame after subscribe; received none within the observation window.',
54
+ };
55
+ }
56
+ // If an error frame arrived BEFORE the ack, that's a bootstrap
57
+ // failure we misclassified — surface it explicitly.
58
+ const error = frames.find((f) => f.kind === 'frame' && f.parsed['type'] === 'error');
59
+ if (error !== undefined) {
60
+ return {
61
+ kind: 'fail',
62
+ expected: { type: 'ack', precededByError: false },
63
+ received: { errorFrame: error.kind === 'frame' ? error.parsed : error },
64
+ message: 'expected bootstrap-success but received an error frame — the subscribe was rejected.',
65
+ };
66
+ }
67
+ return { kind: 'pass' };
68
+ }
69
+ function matchVersionMismatch(behavior, frames) {
70
+ const error = frames.find((f) => f.kind === 'frame' &&
71
+ f.parsed['type'] === 'error' &&
72
+ isRecord(f.parsed['payload']) &&
73
+ f.parsed['payload']['code'] === 'UPGRADE_REQUIRED');
74
+ if (error === undefined) {
75
+ return {
76
+ kind: 'fail',
77
+ expected: {
78
+ type: 'error',
79
+ payload: { code: 'UPGRADE_REQUIRED' },
80
+ serverVersion: behavior.serverVersion,
81
+ },
82
+ received: frames.map((f) => (f.kind === 'frame' ? f.parsed : f)),
83
+ message: 'expected an `error` frame with `payload.code === UPGRADE_REQUIRED` after subscribe; received none.',
84
+ };
85
+ }
86
+ return { kind: 'pass' };
87
+ }
88
+ function matchContractError(behavior, frames) {
89
+ const candidates = frames.filter((f) => f.kind === 'frame' &&
90
+ f.parsed['type'] === 'stream' &&
91
+ isRecord(f.parsed['payload']) &&
92
+ f.parsed['payload']['channel'] === '_ggui:contract-error');
93
+ if (candidates.length === 0) {
94
+ return {
95
+ kind: 'fail',
96
+ expected: {
97
+ channel: '_ggui:contract-error',
98
+ code: behavior.code,
99
+ toolName: behavior.toolName,
100
+ },
101
+ received: frames.map((f) => (f.kind === 'frame' ? f.parsed : f)),
102
+ message: 'expected a `stream` frame on the `_ggui:contract-error` reserved channel; none observed.',
103
+ };
104
+ }
105
+ // Check at least one candidate carries the expected code + tool.
106
+ //
107
+ // Canonical shape per SPEC §4.4 `ContractErrorPayload`: `code` is
108
+ // NESTED under `value.error.code`, alongside `value.error.message`
109
+ // and `value.error.causedBy`. `toolName` / `actionName` /
110
+ // `sourceAction` / `timestamp` live at the top of `value`. The
111
+ // matcher reads the canonical shape exclusively — the reference
112
+ // server (and `@ggui-ai/mcp-server`'s router) emits via
113
+ // `makeContractErrorPayload`, which is the authoritative builder.
114
+ // Any producer emitting a flat `code` field is a spec violation and
115
+ // will correctly fail this match — that's the point of Protocol #5
116
+ // (named failure modes) being assertable.
117
+ const match = candidates.find((f) => {
118
+ if (f.kind !== 'frame')
119
+ return false;
120
+ const payload = f.parsed['payload'];
121
+ if (!isRecord(payload))
122
+ return false;
123
+ const value = payload['value'];
124
+ if (!isRecord(value))
125
+ return false;
126
+ const error = value['error'];
127
+ if (!isRecord(error))
128
+ return false;
129
+ if (error['code'] !== behavior.code)
130
+ return false;
131
+ if (value['toolName'] !== behavior.toolName)
132
+ return false;
133
+ if (behavior.actionName !== undefined &&
134
+ value['actionName'] !== behavior.actionName) {
135
+ return false;
136
+ }
137
+ if (behavior.sourceAction !== undefined) {
138
+ const source = value['sourceAction'];
139
+ const sourceType = isRecord(source) ? source['type'] : source;
140
+ if (sourceType !== behavior.sourceAction)
141
+ return false;
142
+ }
143
+ return true;
144
+ });
145
+ if (match === undefined) {
146
+ return {
147
+ kind: 'fail',
148
+ expected: {
149
+ code: behavior.code,
150
+ toolName: behavior.toolName,
151
+ actionName: behavior.actionName,
152
+ sourceAction: behavior.sourceAction,
153
+ },
154
+ received: candidates.map((f) => (f.kind === 'frame' ? f.parsed : f)),
155
+ message: '`_ggui:contract-error` frame observed but payload did not match the expected code/tool/action triple.',
156
+ };
157
+ }
158
+ return { kind: 'pass' };
159
+ }
160
+ function matchStreamUpdate(behavior, frames) {
161
+ const match = frames.find((f) => f.kind === 'frame' &&
162
+ f.parsed['type'] === 'stream' &&
163
+ isRecord(f.parsed['payload']) &&
164
+ f.parsed['payload']['channel'] === behavior.channel &&
165
+ deepEqual(f.parsed['payload']['value'], behavior.value));
166
+ if (match === undefined) {
167
+ return {
168
+ kind: 'fail',
169
+ expected: { channel: behavior.channel, value: behavior.value },
170
+ received: frames
171
+ .filter((f) => f.kind === 'frame' && f.parsed['type'] === 'stream')
172
+ .map((f) => (f.kind === 'frame' ? f.parsed['payload'] : f)),
173
+ message: `expected a \`stream\` frame on channel '${behavior.channel}' with the declared value shape; none matched.`,
174
+ };
175
+ }
176
+ return { kind: 'pass' };
177
+ }
178
+ function matchNoOp(behavior, frames) {
179
+ // Exclude the initial `ack` (subscribe always produces one) — the
180
+ // no-op claim is about frames observed AFTER input dispatch, which
181
+ // the runner collects starting post-subscribe. If the runner is
182
+ // correctly collecting post-input frames, `ack` never appears here
183
+ // anyway, but double-filtering guards against caller bugs.
184
+ const interesting = frames.filter((f) => f.kind !== 'frame' || f.parsed['type'] !== 'ack');
185
+ if (interesting.length > 0) {
186
+ return {
187
+ kind: 'fail',
188
+ expected: { frames: [] },
189
+ received: interesting.map((f) => (f.kind === 'frame' ? f.parsed : f)),
190
+ message: `no-op behavior expected silence after input dispatch; observed ${interesting.length} frame(s). Reason given: ${behavior.reason}`,
191
+ };
192
+ }
193
+ return { kind: 'pass' };
194
+ }
195
+ /**
196
+ * Match an observability-event behavior against WS evidence the
197
+ * protocol-and-contract bar mandates a conformant host mirror-emit on.
198
+ *
199
+ * Two arms are matchable on pure WS today; everything else returns
200
+ * `unmatchable-on-ws` with a sharp reason — see the file-level
201
+ * docstring for the rationale.
202
+ */
203
+ function matchObservabilityEvent(behavior, frames) {
204
+ const { event } = behavior;
205
+ if (event.kind === 'wired-tool-invoked') {
206
+ return matchWiredToolInvoked(event, frames);
207
+ }
208
+ if (event.kind === 'contract-error-emitted') {
209
+ return matchContractErrorEmitted(event, frames);
210
+ }
211
+ return {
212
+ kind: 'unmatchable-on-ws',
213
+ reason: `observability-event of kind '${event.kind}' is a Path-B (browser-host) claim — not grounded in WS-observable evidence by the kit's matcher. Drive via a Path-B browser-host harness (today: \`page.exposeBinding\` in spec-side dispatch; future: packaged Phase-3.2 adapter inside the kit).`,
214
+ };
215
+ }
216
+ /**
217
+ * `wired-tool-invoked` — the WS evidence is a `stream` frame on the
218
+ * canonical `_ggui:wired-tool-invoked` channel carrying
219
+ * `value: {toolName, actionName?}`. The reference server's
220
+ * `action-router.ts::dispatchAction()` emits this after a successful
221
+ * handler resolution; the bar mandates equivalent WS evidence from any
222
+ * conformant host on the wired-tool-invoked path.
223
+ */
224
+ function matchWiredToolInvoked(event, frames) {
225
+ if (event.toolName === undefined) {
226
+ return {
227
+ kind: 'fail',
228
+ expected: { kind: 'wired-tool-invoked', toolName: '<required>' },
229
+ received: { event },
230
+ message: 'observability-event of kind `wired-tool-invoked` MUST declare `toolName` for the matcher to assert against; fixture is under-specified.',
231
+ };
232
+ }
233
+ const candidates = frames.filter((f) => f.kind === 'frame' &&
234
+ f.parsed['type'] === 'stream' &&
235
+ isRecord(f.parsed['payload']) &&
236
+ f.parsed['payload']['channel'] === '_ggui:wired-tool-invoked');
237
+ if (candidates.length === 0) {
238
+ return {
239
+ kind: 'fail',
240
+ expected: {
241
+ channel: '_ggui:wired-tool-invoked',
242
+ value: { toolName: event.toolName, actionName: event.actionName },
243
+ },
244
+ received: frames.map((f) => (f.kind === 'frame' ? f.parsed : f)),
245
+ message: 'expected a `stream` frame on `_ggui:wired-tool-invoked` (the WS-observable evidence the protocol bar mandates a conformant host mirror-emit `wired-tool-invoked` on); none observed.',
246
+ };
247
+ }
248
+ const match = candidates.find((f) => {
249
+ if (f.kind !== 'frame')
250
+ return false;
251
+ const payload = f.parsed['payload'];
252
+ if (!isRecord(payload))
253
+ return false;
254
+ const value = payload['value'];
255
+ if (!isRecord(value))
256
+ return false;
257
+ if (value['toolName'] !== event.toolName)
258
+ return false;
259
+ if (event.actionName !== undefined &&
260
+ value['actionName'] !== event.actionName) {
261
+ return false;
262
+ }
263
+ return true;
264
+ });
265
+ if (match === undefined) {
266
+ return {
267
+ kind: 'fail',
268
+ expected: {
269
+ channel: '_ggui:wired-tool-invoked',
270
+ value: { toolName: event.toolName, actionName: event.actionName },
271
+ },
272
+ received: candidates.map((f) => (f.kind === 'frame' ? f.parsed : f)),
273
+ message: '`_ggui:wired-tool-invoked` frame observed but value did not match the expected toolName/actionName.',
274
+ };
275
+ }
276
+ return { kind: 'pass' };
277
+ }
278
+ /**
279
+ * `contract-error-emitted` — the WS evidence is the canonical SPEC
280
+ * §4.4 `_ggui:contract-error` envelope itself. Per the bar, every such
281
+ * envelope MUST be mirrored as a `contract-error-emitted` observability
282
+ * event on the host side; asserting on the WS frame is asserting on
283
+ * the cause the bar makes load-bearing for the mirror.
284
+ */
285
+ function matchContractErrorEmitted(event, frames) {
286
+ if (event.code === undefined || event.toolName === undefined) {
287
+ return {
288
+ kind: 'fail',
289
+ expected: {
290
+ kind: 'contract-error-emitted',
291
+ code: event.code ?? '<required>',
292
+ toolName: event.toolName ?? '<required>',
293
+ },
294
+ received: { event },
295
+ message: 'observability-event of kind `contract-error-emitted` MUST declare both `code` and `toolName` for the matcher to assert against; fixture is under-specified.',
296
+ };
297
+ }
298
+ const candidates = frames.filter((f) => f.kind === 'frame' &&
299
+ f.parsed['type'] === 'stream' &&
300
+ isRecord(f.parsed['payload']) &&
301
+ f.parsed['payload']['channel'] === '_ggui:contract-error');
302
+ if (candidates.length === 0) {
303
+ return {
304
+ kind: 'fail',
305
+ expected: {
306
+ channel: '_ggui:contract-error',
307
+ code: event.code,
308
+ toolName: event.toolName,
309
+ },
310
+ received: frames.map((f) => (f.kind === 'frame' ? f.parsed : f)),
311
+ message: 'expected a `stream` frame on `_ggui:contract-error` (the WS-observable evidence the protocol bar mandates a conformant host mirror-emit `contract-error-emitted` on); none observed.',
312
+ };
313
+ }
314
+ // Same canonical-shape reader as `matchContractError` — code is
315
+ // nested under `payload.value.error.code`, toolName at the top of
316
+ // `payload.value`.
317
+ const match = candidates.find((f) => {
318
+ if (f.kind !== 'frame')
319
+ return false;
320
+ const payload = f.parsed['payload'];
321
+ if (!isRecord(payload))
322
+ return false;
323
+ const value = payload['value'];
324
+ if (!isRecord(value))
325
+ return false;
326
+ const error = value['error'];
327
+ if (!isRecord(error))
328
+ return false;
329
+ if (error['code'] !== event.code)
330
+ return false;
331
+ if (value['toolName'] !== event.toolName)
332
+ return false;
333
+ if (event.actionName !== undefined &&
334
+ value['actionName'] !== event.actionName) {
335
+ return false;
336
+ }
337
+ return true;
338
+ });
339
+ if (match === undefined) {
340
+ return {
341
+ kind: 'fail',
342
+ expected: {
343
+ channel: '_ggui:contract-error',
344
+ code: event.code,
345
+ toolName: event.toolName,
346
+ actionName: event.actionName,
347
+ },
348
+ received: candidates.map((f) => (f.kind === 'frame' ? f.parsed : f)),
349
+ message: '`_ggui:contract-error` frame observed but payload did not match the expected code/tool/action triple for the mandated mirror-emission.',
350
+ };
351
+ }
352
+ return { kind: 'pass' };
353
+ }
354
+ // =============================================================================
355
+ // Helpers
356
+ // =============================================================================
357
+ function isRecord(value) {
358
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
359
+ }
360
+ function deepEqual(a, b) {
361
+ if (a === b)
362
+ return true;
363
+ if (typeof a !== typeof b)
364
+ return false;
365
+ if (a === null || b === null)
366
+ return false;
367
+ if (typeof a !== 'object')
368
+ return false;
369
+ if (Array.isArray(a) !== Array.isArray(b))
370
+ return false;
371
+ if (Array.isArray(a) && Array.isArray(b)) {
372
+ if (a.length !== b.length)
373
+ return false;
374
+ return a.every((item, i) => deepEqual(item, b[i]));
375
+ }
376
+ const aObj = a;
377
+ const bObj = b;
378
+ const aKeys = Object.keys(aObj).sort();
379
+ const bKeys = Object.keys(bObj).sort();
380
+ if (aKeys.length !== bKeys.length)
381
+ return false;
382
+ if (!aKeys.every((k, i) => k === bKeys[i]))
383
+ return false;
384
+ return aKeys.every((k) => deepEqual(aObj[k], bObj[k]));
385
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "gate-export-not-registered",
3
+ "description": "The contract's `clientCapabilities.gadgets` references the export `useDoorDashCheckout` under `@acme/checkout` — a (package, export) pair absent from every descriptor in the app's registered `App.gadgets` catalog. The push-time gadget gate (SPEC §7.7.2 — `assertGadgetsRegistered`) MUST reject with code `gadget_not_registered`: the most-fundamental miss class, where the export name itself is unknown.",
4
+ "contract": {
5
+ "clientCapabilities": {
6
+ "gadgets": {
7
+ "@acme/checkout": { "useDoorDashCheckout": {} }
8
+ }
9
+ }
10
+ },
11
+ "appGadgets": [
12
+ {
13
+ "package": "@ggui-samples/leaflet",
14
+ "version": "1.0.0",
15
+ "exports": [{ "hook": "useLeafletMap" }]
16
+ }
17
+ ],
18
+ "expect": { "outcome": "reject", "code": "gadget_not_registered" }
19
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "gate-no-gadgets-accept",
3
+ "description": "A contract that declares no `clientCapabilities.gadgets` at all. The gate has nothing to check — a contract that uses no gadgets references no registry membership — so it MUST accept unconditionally, even against a non-empty app catalog. Guards against a gate that over-fires on the common gadget-free contract.",
4
+ "contract": {},
5
+ "appGadgets": [
6
+ {
7
+ "package": "@ggui-samples/leaflet",
8
+ "version": "2.3.1",
9
+ "exports": [{ "hook": "useLeafletMap" }]
10
+ }
11
+ ],
12
+ "expect": { "outcome": "accept" }
13
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "gate-package-mismatch",
3
+ "description": "The contract references `useLeafletMap` under `@acme/maps`, but the app registers an export of that exact name only under `@ggui-samples/leaflet`. The export NAME resolves — so this is not a typo or a hallucinated capability — but the (package, export) identity pair does not. The gate MUST reject with code `gadget_package_mismatch`: the lower-priority category, which fires only when no `gadget_not_registered` miss outranks it on the same push.",
4
+ "contract": {
5
+ "clientCapabilities": {
6
+ "gadgets": {
7
+ "@acme/maps": { "useLeafletMap": {} }
8
+ }
9
+ }
10
+ },
11
+ "appGadgets": [
12
+ {
13
+ "package": "@ggui-samples/leaflet",
14
+ "version": "1.0.0",
15
+ "exports": [{ "hook": "useLeafletMap" }]
16
+ }
17
+ ],
18
+ "expect": { "outcome": "reject", "code": "gadget_package_mismatch" }
19
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "gate-priority-not-registered-outranks-mismatch",
3
+ "description": "A single push that references BOTH an unknown export (`useDoorDashCheckout` under `@acme/checkout`) AND a package-mismatched export (`useLeafletMap` under `@acme/maps`, registered only under `@ggui-samples/leaflet`). The gate classifies every miss and MUST throw the most-fundamental category FIRST — `gadget_not_registered` outranks `gadget_package_mismatch`, because an author who got the export name wrong cannot act on a 'wrong package' message. The lower-priority package miss rides the rejection's `secondary` so the author corrects both in one round trip; the catalog asserts the primary outcome code.",
4
+ "contract": {
5
+ "clientCapabilities": {
6
+ "gadgets": {
7
+ "@acme/checkout": { "useDoorDashCheckout": {} },
8
+ "@acme/maps": { "useLeafletMap": {} }
9
+ }
10
+ }
11
+ },
12
+ "appGadgets": [
13
+ {
14
+ "package": "@ggui-samples/leaflet",
15
+ "version": "1.0.0",
16
+ "exports": [{ "hook": "useLeafletMap" }]
17
+ }
18
+ ],
19
+ "expect": { "outcome": "reject", "code": "gadget_not_registered" }
20
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "gate-registered-resolves-accept",
3
+ "description": "The positive baseline. The contract references a hook export (`useLeafletMap` from `@ggui-samples/leaflet`) and a package that exposes both a component and a hook export (`RevenueChart` + `useChartTheme` from `@ggui-samples/charts`). Every `(package, export)` pair the contract names resolves to a registered export by identity. The gate MUST accept — no reject, regardless of export kind.",
4
+ "contract": {
5
+ "clientCapabilities": {
6
+ "gadgets": {
7
+ "@ggui-samples/leaflet": { "useLeafletMap": {} },
8
+ "@ggui-samples/charts": { "RevenueChart": {}, "useChartTheme": {} }
9
+ }
10
+ }
11
+ },
12
+ "appGadgets": [
13
+ {
14
+ "package": "@ggui-samples/leaflet",
15
+ "version": "2.3.1",
16
+ "exports": [{ "hook": "useLeafletMap" }]
17
+ },
18
+ {
19
+ "package": "@ggui-samples/charts",
20
+ "version": "1.0.0",
21
+ "exports": [{ "component": "RevenueChart" }, { "hook": "useChartTheme" }]
22
+ }
23
+ ],
24
+ "expect": { "outcome": "accept" }
25
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "gate-stdlib-hook-accept",
3
+ "description": "The contract references `useGeolocation` under `@ggui-ai/gadgets` — one of the seven first-party browser-capability hooks. The app's catalog is the `STDLIB_GADGETS` baseline: the single `@ggui-ai/gadgets` package descriptor whose `exports` carry all seven stdlib hooks. The `(package, export)` pair resolves, so the gate MUST accept. (When a real app row carries no explicit catalog, the push handler substitutes `STDLIB_GADGETS` before calling the gate — this case provides that baseline directly.)",
4
+ "contract": {
5
+ "clientCapabilities": {
6
+ "gadgets": {
7
+ "@ggui-ai/gadgets": { "useGeolocation": {} }
8
+ }
9
+ }
10
+ },
11
+ "appGadgets": [
12
+ {
13
+ "package": "@ggui-ai/gadgets",
14
+ "version": "1.0.0",
15
+ "exports": [
16
+ { "hook": "useCamera" },
17
+ { "hook": "useClipboardPaste" },
18
+ { "hook": "useClipboardWrite" },
19
+ { "hook": "useFilePicker" },
20
+ { "hook": "useGeolocation" },
21
+ { "hook": "useMicrophone" },
22
+ { "hook": "useNotifications" }
23
+ ]
24
+ }
25
+ ],
26
+ "expect": { "outcome": "accept" }
27
+ }