gentle-shell-bridge 0.3.0 → 0.3.2

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 (76) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +91 -722
  3. package/docs/contract.md +92 -35
  4. package/package.json +13 -1
  5. package/src/api.ts +1 -1
  6. package/.codegraph/.gitignore +0 -5
  7. package/PRD.md +0 -144
  8. package/examples/bash-card.ts +0 -1694
  9. package/gentle-shell-bridge-0.3.0.tgz +0 -0
  10. package/odd/tasks/palette-sections.md +0 -30
  11. package/odd/tasks/plugin-keymap.md +0 -32
  12. package/odd/tasks/plugin-surface-contract.md +0 -55
  13. package/test/api.test.ts +0 -395
  14. package/test/apply-on-move.test.ts +0 -477
  15. package/test/arm.test.ts +0 -220
  16. package/test/bridge-ready.test.ts +0 -309
  17. package/test/bus.test.ts +0 -165
  18. package/test/button-rows.test.ts +0 -409
  19. package/test/capture.test.ts +0 -356
  20. package/test/card-style-rows.test.ts +0 -510
  21. package/test/card-style-transform.test.ts +0 -379
  22. package/test/card-style-wiring.test.ts +0 -258
  23. package/test/card-styles-e2e.test.ts +0 -491
  24. package/test/card-styles.test.ts +0 -324
  25. package/test/card-transcript-path.test.ts +0 -178
  26. package/test/config.test.ts +0 -93
  27. package/test/e2e.test.ts +0 -443
  28. package/test/example-bash-identity.test.ts +0 -372
  29. package/test/example-bash-matrix.test.ts +0 -856
  30. package/test/example-designs.test.ts +0 -734
  31. package/test/example-neon-transform.test.ts +0 -283
  32. package/test/example-plain-style.test.ts +0 -1268
  33. package/test/extension.test.ts +0 -160
  34. package/test/fake-events.ts +0 -58
  35. package/test/fake-host.ts +0 -211
  36. package/test/fake-sidebar.ts +0 -516
  37. package/test/fake-transcript.ts +0 -486
  38. package/test/frame-telemetry.test.ts +0 -417
  39. package/test/hook.test.ts +0 -196
  40. package/test/manifest.test.ts +0 -105
  41. package/test/new-category.test.ts +0 -979
  42. package/test/notices.test.ts +0 -328
  43. package/test/plugin-integration-lines.test.ts +0 -979
  44. package/test/plugin-surface.test.ts +0 -152
  45. package/test/presence.test.ts +0 -459
  46. package/test/rail-surfaces.test.ts +0 -457
  47. package/test/rail-width.test.ts +0 -423
  48. package/test/regression.test.ts +0 -483
  49. package/test/repaint-api.test.ts +0 -200
  50. package/test/reserved-categories.test.ts +0 -361
  51. package/test/row-labels.test.ts +0 -284
  52. package/test/row-names.test.ts +0 -473
  53. package/test/row-origin.test.ts +0 -395
  54. package/test/section-interaction.test.ts +0 -364
  55. package/test/section-panel.test.ts +0 -403
  56. package/test/sections-rows.test.ts +0 -291
  57. package/test/sidebar-backend.test.ts +0 -471
  58. package/test/sidebar-e2e.test.ts +0 -331
  59. package/test/sidebar-hover.test.ts +0 -276
  60. package/test/sidebar-independent.test.ts +0 -349
  61. package/test/sidebar-render.test.ts +0 -281
  62. package/test/sidebar-sections.test.ts +0 -245
  63. package/test/status-card-styles.test.ts +0 -201
  64. package/test/status-section-frame.test.ts +0 -181
  65. package/test/status-sections.test.ts +0 -243
  66. package/test/status.test.ts +0 -209
  67. package/test/store.test.ts +0 -192
  68. package/test/surface-repaint-cost.test.ts +0 -520
  69. package/test/tool-design-chain.test.ts +0 -483
  70. package/test/tool-design-cost.test.ts +0 -473
  71. package/test/tool-design-rows.test.ts +0 -359
  72. package/test/tool-designs.test.ts +0 -745
  73. package/test/transform-pipeline.test.ts +0 -465
  74. package/test/transforms-bus.test.ts +0 -233
  75. package/test/verifier-width-regressions.test.ts +0 -164
  76. package/tsconfig.json +0 -18
@@ -1,417 +0,0 @@
1
- /**
2
- * Frame telemetry (F1–F4): every frame records, per transform, whether it ran, how many lines it
3
- * received, how many candidate cards it examined, how many it recognised and how many it rewrote —
4
- * as COUNTS, and never any of the frame's own content.
5
- *
6
- * This is the instrument the bash-card defect (a real TUI where switching the example's toggle
7
- * changed nothing visible) needs before any fix is invented: it answers "did the pipeline reach the
8
- * conversation, and did the example's grammar recognise a real bash card there?" from the same
9
- * report the author already reads.
10
- *
11
- * What these tests hold the record to:
12
- *
13
- * - counts for a frame with zero candidates, with candidates and no match, and with matches;
14
- * - the counters are the frame's own: they reset per frame and never accumulate;
15
- * - a transform that throws reports `failed` with the counts it reached;
16
- * - a transform that reports nonsense, or nothing at all, loses only its counts — never the frame;
17
- * - `/bridge-status` renders the line in each of those cases, and a session with no transform keeps
18
- * its historical report byte for byte;
19
- * - no line of the frame is stored or displayed, not even smuggled in as a "shape name".
20
- */
21
-
22
- import assert from "node:assert/strict";
23
- import { mkdtempSync, rmSync } from "node:fs";
24
- import { tmpdir } from "node:os";
25
- import { join } from "node:path";
26
- import test from "node:test";
27
-
28
- import { registerTransform, resetTransformsForTests } from "../src/bus.ts";
29
- import { resetCardStyleTransformForTests } from "../src/card-style-transform.ts";
30
- import { disableCardStyleTransform, resetCardStyleWiringForTests } from "../src/card-style-wiring.ts";
31
- import { setCardStyleSelection } from "../src/card-styles.ts";
32
- import { disableCaptureBackend, resetCaptureStateForTests } from "../src/capture.ts";
33
- import { resetHookStateForTests } from "../src/hook.ts";
34
- import { resetNoticesForTests } from "../src/notices.ts";
35
- import { resetRowBuildStateForTests } from "../src/rows.ts";
36
- import { disableSidebarBackend, resetSidebarStateForTests } from "../src/sidebar.ts";
37
- import { resetSectionFailuresForTests } from "../src/sidebar-render.ts";
38
- import { collectStatus, formatStatus } from "../src/status.ts";
39
- import { resetStoreStateForTests } from "../src/store.ts";
40
- import {
41
- applyTransforms,
42
- getTransformFrameOutcomes,
43
- resetTransformPipelineForTests,
44
- } from "../src/transform-pipeline.ts";
45
- import type { TransformContext } from "../src/types.ts";
46
-
47
- let configHome: string;
48
- let previousConfigHome: string | undefined;
49
-
50
- test.before(() => {
51
- configHome = mkdtempSync(join(tmpdir(), "gsb-telemetry-"));
52
- previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
53
- process.env.GENTLE_PI_CONFIG_HOME = configHome;
54
- });
55
-
56
- test.after(() => {
57
- if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
58
- else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
59
- rmSync(configHome, { recursive: true, force: true });
60
- });
61
-
62
- test.beforeEach(() => {
63
- rmSync(join(configHome, "gentle-shell-bridge"), { recursive: true, force: true });
64
- resetTransformsForTests();
65
- resetTransformPipelineForTests();
66
- resetCardStyleTransformForTests();
67
- resetCardStyleWiringForTests();
68
- resetCaptureStateForTests();
69
- resetHookStateForTests();
70
- resetRowBuildStateForTests();
71
- resetSidebarStateForTests();
72
- resetSectionFailuresForTests();
73
- resetNoticesForTests();
74
- resetStoreStateForTests();
75
- disableCaptureBackend();
76
- disableSidebarBackend();
77
- disableCardStyleTransform();
78
- setCardStyleSelection("native");
79
- });
80
-
81
- const statusLines = (): string[] => formatStatus(collectStatus());
82
-
83
- /** The transforms block: from its first line up to the next block that can follow it. */
84
- const transformBlock = (lines: readonly string[]): string[] => {
85
- const start = lines.findIndex((line) => line.startsWith("transforms: "));
86
- if (start === -1) return [];
87
- const rest = lines.slice(start);
88
- const end = rest.findIndex((line) => line.startsWith("notices"));
89
- return end === -1 ? rest : rest.slice(0, end);
90
- };
91
-
92
- /**
93
- * The counts a transform records for itself. `report` is how a transform tells the bridge what its
94
- * own grammar saw; the bridge adds the line count and the outcome, so the two can never disagree
95
- * about what the frame did.
96
- */
97
- const counts = (
98
- candidates: number,
99
- recognised: number,
100
- rewritten: number,
101
- shapes?: Record<string, number>,
102
- ): { candidates: number; recognised: number; rewritten: number; shapes?: Record<string, number> } => ({
103
- candidates,
104
- recognised,
105
- rewritten,
106
- ...(shapes === undefined ? {} : { shapes }),
107
- });
108
-
109
- // ---------------------------------------------------------------------------------------------
110
- // F1 — the counts a frame records
111
- // ---------------------------------------------------------------------------------------------
112
-
113
- test("a frame with zero candidates records the lines it received and zeroed counts", () => {
114
- registerTransform({
115
- id: "probe",
116
- transform: (lines, ctx: TransformContext) => {
117
- ctx.report?.(counts(0, 0, 0));
118
- return lines;
119
- },
120
- });
121
-
122
- applyTransforms(["one", "two", "three"], 40);
123
-
124
- assert.deepEqual(getTransformFrameOutcomes(), [
125
- {
126
- id: "probe",
127
- status: "skipped",
128
- counts: { lines: 3, candidates: 0, recognised: 0, rewritten: 0, shapes: [] },
129
- },
130
- ]);
131
- });
132
-
133
- test("a frame with candidates and no match records the candidates and no recognition", () => {
134
- registerTransform({
135
- id: "probe",
136
- transform: (lines, ctx: TransformContext) => {
137
- ctx.report?.(counts(2, 0, 0, {}));
138
- return lines;
139
- },
140
- });
141
-
142
- applyTransforms(["a", "b"], 40);
143
-
144
- assert.deepEqual(getTransformFrameOutcomes(), [
145
- {
146
- id: "probe",
147
- status: "skipped",
148
- counts: { lines: 2, candidates: 2, recognised: 0, rewritten: 0, shapes: [] },
149
- },
150
- ]);
151
- });
152
-
153
- test("a frame with matches records recognition, rewrites and the shape that matched", () => {
154
- registerTransform({
155
- id: "probe",
156
- transform: (lines, ctx: TransformContext) => {
157
- ctx.report?.(counts(3, 1, 1, { float: 1 }));
158
- return lines.map((line) => `>${line}`);
159
- },
160
- });
161
-
162
- assert.deepEqual(applyTransforms(["a"], 40), [">a"]);
163
- assert.deepEqual(getTransformFrameOutcomes(), [
164
- {
165
- id: "probe",
166
- status: "applied",
167
- counts: {
168
- lines: 1,
169
- candidates: 3,
170
- recognised: 1,
171
- rewritten: 1,
172
- shapes: [{ name: "float", count: 1 }],
173
- },
174
- },
175
- ]);
176
- });
177
-
178
- test("the counts belong to the frame that produced them: they reset, never accumulate", () => {
179
- let frame = 0;
180
- registerTransform({
181
- id: "probe",
182
- transform: (lines, ctx: TransformContext) => {
183
- frame += 1;
184
- ctx.report?.(frame === 1 ? counts(2, 1, 1, { float: 1 }) : counts(1, 0, 0, {}));
185
- return lines;
186
- },
187
- });
188
-
189
- applyTransforms(["a", "b", "c"], 40);
190
- const first = getTransformFrameOutcomes()[0]?.counts;
191
- applyTransforms(["x"], 40);
192
- const second = getTransformFrameOutcomes()[0]?.counts;
193
-
194
- assert.deepEqual(first, {
195
- lines: 3,
196
- candidates: 2,
197
- recognised: 1,
198
- rewritten: 1,
199
- shapes: [{ name: "float", count: 1 }],
200
- });
201
- assert.deepEqual(
202
- second,
203
- { lines: 1, candidates: 1, recognised: 0, rewritten: 0, shapes: [] },
204
- "the second frame's record replaces the first one instead of adding to it",
205
- );
206
- });
207
-
208
- test("a transform that never reports still records the lines and zeroed counts", () => {
209
- registerTransform({ id: "silent", transform: (lines) => lines });
210
-
211
- applyTransforms(["a", "b"], 40);
212
-
213
- assert.deepEqual(getTransformFrameOutcomes(), [
214
- {
215
- id: "silent",
216
- status: "skipped",
217
- counts: { lines: 2, candidates: 0, recognised: 0, rewritten: 0, shapes: [] },
218
- },
219
- ]);
220
- });
221
-
222
- // ---------------------------------------------------------------------------------------------
223
- // F1/F4 — a failed transform keeps the counts it reached
224
- // ---------------------------------------------------------------------------------------------
225
-
226
- test("a transform that throws is failed with the counts it reached and the lines it received", () => {
227
- registerTransform({
228
- id: "boom",
229
- transform: (lines, ctx: TransformContext) => {
230
- ctx.report?.(counts(4, 2, 0, { float: 2 }));
231
- throw new Error("boom after two cards");
232
- },
233
- });
234
-
235
- assert.deepEqual(applyTransforms(["a", "b", "c", "d", "e"], 40), ["a", "b", "c", "d", "e"]);
236
- assert.deepEqual(getTransformFrameOutcomes(), [
237
- {
238
- id: "boom",
239
- status: "failed",
240
- message: "boom after two cards",
241
- counts: {
242
- lines: 5,
243
- candidates: 4,
244
- recognised: 2,
245
- rewritten: 0,
246
- shapes: [{ name: "float", count: 2 }],
247
- },
248
- },
249
- ]);
250
- });
251
-
252
- // ---------------------------------------------------------------------------------------------
253
- // F1/F4 — junk in, nothing out: the record is counts only, and recording never throws
254
- // ---------------------------------------------------------------------------------------------
255
-
256
- test("nonsense counts are clamped to zero and never thrown into the frame", () => {
257
- registerTransform({
258
- id: "liar",
259
- transform: (lines, ctx: TransformContext) => {
260
- assert.doesNotThrow(() => {
261
- ctx.report?.({
262
- candidates: -3,
263
- recognised: Number.NaN,
264
- rewritten: Number.POSITIVE_INFINITY,
265
- } as unknown as Parameters<NonNullable<TransformContext["report"]>>[0]);
266
- ctx.report?.("not counts" as unknown as Parameters<NonNullable<TransformContext["report"]>>[0]);
267
- });
268
- return lines.map((line) => `${line}!`);
269
- },
270
- });
271
-
272
- assert.deepEqual(applyTransforms(["a"], 40), ["a!"]);
273
- assert.deepEqual(getTransformFrameOutcomes(), [
274
- {
275
- id: "liar",
276
- status: "applied",
277
- counts: { lines: 1, candidates: 0, recognised: 0, rewritten: 0, shapes: [] },
278
- },
279
- ]);
280
- });
281
-
282
- test("a shape name is a name: anything with a space, an escape or a long tail is dropped", () => {
283
- registerTransform({
284
- id: "probe",
285
- transform: (lines, ctx: TransformContext) => {
286
- ctx.report?.(
287
- counts(4, 2, 0, {
288
- "ls -1 /home": 1,
289
- "\u001b[31mred\u001b[39m": 1,
290
- THIS_IS_A_NAME_THAT_IS_WAY_TOO_LONG_TO_BE_ONE: 1,
291
- "Bash Card": 1,
292
- float: 2,
293
- }),
294
- );
295
- return lines;
296
- },
297
- });
298
-
299
- applyTransforms(["a"], 40);
300
-
301
- assert.deepEqual(getTransformFrameOutcomes()[0]?.counts.shapes, [{ name: "float", count: 2 }]);
302
- });
303
-
304
- test("no line of the frame is recorded, displayed or smuggled in as a shape name", () => {
305
- const secret = "SECRET-OUTPUT-THAT-MUST-NOT-BE-STORED";
306
- registerTransform({
307
- id: "probe",
308
- transform: (lines, ctx: TransformContext) => {
309
- ctx.report?.(
310
- counts(1, 1, 1, { [secret]: 1, [secret.toLowerCase()]: 1, float: 1 }),
311
- );
312
- return lines;
313
- },
314
- });
315
-
316
- applyTransforms([secret, `a line with ${secret} in it`], 40);
317
-
318
- const stored = JSON.stringify(getTransformFrameOutcomes());
319
- const shown = statusLines().join("\n");
320
- for (const text of [stored, shown]) {
321
- assert.equal(text.includes(secret), false, "the frame's content never leaves the frame");
322
- assert.equal(
323
- text.includes(secret.toLowerCase()),
324
- false,
325
- "and a shape name cannot carry it either",
326
- );
327
- }
328
- assert.equal(
329
- stored.includes("\u001b"),
330
- false,
331
- "and no paint from the frame is kept: counts and ids only",
332
- );
333
- });
334
-
335
- // ---------------------------------------------------------------------------------------------
336
- // F2 — `/bridge-status`, one line per registered transform
337
- // ---------------------------------------------------------------------------------------------
338
-
339
- test("`/bridge-status` prints a zero-candidate frame with its counts", () => {
340
- registerTransform({
341
- id: "probe",
342
- transform: (lines, ctx: TransformContext) => {
343
- ctx.report?.(counts(0, 0, 0));
344
- return lines;
345
- },
346
- });
347
- applyTransforms(["one", "two", "three"], 40);
348
-
349
- assert.deepEqual(transformBlock(statusLines()), [
350
- "transforms: 1 registered",
351
- " - probe (registration order): skipped, 3 lines, 0 candidates, 0 recognised, 0 rewritten",
352
- ]);
353
- });
354
-
355
- test("`/bridge-status` prints candidates with no match, singular counted properly", () => {
356
- registerTransform({
357
- id: "probe",
358
- transform: (lines, ctx: TransformContext) => {
359
- ctx.report?.(counts(1, 0, 0));
360
- return lines;
361
- },
362
- });
363
- applyTransforms(["one"], 40);
364
-
365
- assert.deepEqual(transformBlock(statusLines()), [
366
- "transforms: 1 registered",
367
- " - probe (registration order): skipped, 1 line, 1 candidate, 0 recognised, 0 rewritten",
368
- ]);
369
- });
370
-
371
- test("`/bridge-status` prints the matched shape by name next to the counts", () => {
372
- registerTransform({
373
- id: "probe",
374
- transform: (lines, ctx: TransformContext) => {
375
- ctx.report?.(counts(3, 2, 1, { float: 1, plain: 1 }));
376
- return lines.map((line) => `>${line}`);
377
- },
378
- });
379
- applyTransforms(["one"], 40);
380
-
381
- assert.deepEqual(transformBlock(statusLines()), [
382
- "transforms: 1 registered",
383
- " - probe (registration order): applied, 1 line, 3 candidates, 2 recognised, 1 rewritten, " +
384
- "shapes: float 1, plain 1",
385
- ]);
386
- });
387
-
388
- test("`/bridge-status` prints a failed frame with the counts it reached", () => {
389
- registerTransform({
390
- id: "boom",
391
- transform: (lines, ctx: TransformContext) => {
392
- ctx.report?.(counts(2, 1, 0, { float: 1 }));
393
- throw new Error("exploded");
394
- },
395
- });
396
- applyTransforms(["one", "two"], 40);
397
-
398
- assert.deepEqual(transformBlock(statusLines()), [
399
- "transforms: 1 registered",
400
- " - boom (registration order): failed (exploded), 2 lines, 2 candidates, 1 recognised, " +
401
- "0 rewritten, shapes: float 1",
402
- ]);
403
- });
404
-
405
- test("a session with no transform keeps the historical report byte for byte", () => {
406
- assert.deepEqual(statusLines(), [
407
- "gentle-shell-bridge status",
408
- "providers: none",
409
- "backend: none",
410
- "last capture: none",
411
- "last failures: none",
412
- "store problems: none",
413
- "duplicate settings: no build yet",
414
- ]);
415
- assert.equal(collectStatus().transforms, undefined);
416
- assert.deepEqual(getTransformFrameOutcomes(), []);
417
- });
package/test/hook.test.ts DELETED
@@ -1,196 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import test from "node:test";
3
-
4
- import { registerRows, resetBusForTests } from "../src/bus.ts";
5
- import {
6
- getHookDiagnostics,
7
- installHookBackend,
8
- resetHookStateForTests,
9
- } from "../src/hook.ts";
10
- import type { CustomizeRow, RowProvider } from "../src/types.ts";
11
-
12
- /**
13
- * The host registry key (S4). No host has been observed emitting it, so the tests define the
14
- * accepted shapes; the module documents that the branch is unverified.
15
- */
16
- const HOOK_KEY = Symbol.for("gentle-pi.customize-row-providers");
17
-
18
- /** The only writes these tests make to globalThis, cleaned up in afterEach. */
19
- const hookSlot = (): Record<symbol, unknown> => globalThis as unknown as Record<symbol, unknown>;
20
- const setHook = (value: unknown): void => {
21
- hookSlot()[HOOK_KEY] = value;
22
- };
23
- const clearHook = (): void => {
24
- delete hookSlot()[HOOK_KEY];
25
- };
26
-
27
- test.beforeEach(() => {
28
- resetBusForTests();
29
- resetHookStateForTests();
30
- });
31
-
32
- test.afterEach(() => {
33
- clearHook();
34
- resetHookStateForTests();
35
- });
36
-
37
- const labelsProvider = (id: string, category: string, labels: string[]): RowProvider => ({
38
- id,
39
- category,
40
- rows: () => labels.map((label) => ({ label, action: () => {} })),
41
- });
42
-
43
- interface PushedProvider {
44
- id: string;
45
- rows: (ctx?: unknown) => CustomizeRow[];
46
- }
47
-
48
- test("no registry at all is reported as no-hook", () => {
49
- const result = installHookBackend();
50
-
51
- assert.equal(result.installed, false);
52
- assert.equal(result.outcome, "no-hook");
53
- });
54
-
55
- test("an array registry receives exactly one fan-out provider", () => {
56
- const registry: PushedProvider[] = [];
57
- setHook(registry);
58
- registerRows(labelsProvider("p1", "Cards", ["Row one"]));
59
-
60
- const first = installHookBackend();
61
- const second = installHookBackend();
62
-
63
- assert.equal(first.installed, true);
64
- assert.equal(first.outcome, "hooked");
65
- assert.equal(first.shape, "array");
66
- assert.equal(second.installed, true, "a second arm is a no-op, not a second provider");
67
- assert.equal(registry.length, 1, "exactly one fan-out provider is pushed");
68
- assert.equal(typeof registry[0].rows, "function");
69
- });
70
-
71
- test("the pushed provider builds rows from the shared snapshot, in subscription order", () => {
72
- const registry: PushedProvider[] = [];
73
- setHook(registry);
74
- registerRows(labelsProvider("first", "Cards", ["First a"]));
75
- registerRows(labelsProvider("second", "Banner", ["Second b"]));
76
- installHookBackend();
77
-
78
- const rows = registry[0].rows({ theme: "host-theme", configHome: "/host/home" });
79
-
80
- assert.deepEqual(
81
- rows.map((row) => [row.category, row.label]),
82
- [
83
- ["Cards", "First a"],
84
- ["Banner", "Second b"],
85
- ],
86
- );
87
- });
88
-
89
- test("the pushed provider isolates a throwing provider and records the failure", () => {
90
- const registry: PushedProvider[] = [];
91
- setHook(registry);
92
- registerRows(labelsProvider("before", "Cards", ["Before ok"]));
93
- registerRows({
94
- id: "boom",
95
- category: "Banner",
96
- rows: () => {
97
- throw new Error("hook provider exploded");
98
- },
99
- });
100
- registerRows(labelsProvider("after", "Layout", ["After ok"]));
101
- installHookBackend();
102
-
103
- const rows = registry[0].rows({});
104
-
105
- assert.deepEqual(
106
- rows.map((row) => row.label),
107
- ["Before ok", "After ok"],
108
- );
109
- assert.deepEqual(getHookDiagnostics().lastFailures, [
110
- { providerId: "boom", message: "hook provider exploded", rowsDropped: true },
111
- ]);
112
- });
113
-
114
- test("a host context is reused when it carries a theme and a config home", () => {
115
- const registry: PushedProvider[] = [];
116
- setHook(registry);
117
- let received: unknown;
118
- registerRows({
119
- id: "ctx",
120
- category: "Cards",
121
- rows: (ctx) => {
122
- received = ctx;
123
- return [];
124
- },
125
- });
126
- installHookBackend();
127
-
128
- registry[0].rows({ theme: { name: "host-theme" }, configHome: "/host/home" });
129
-
130
- assert.deepEqual(received, { theme: { name: "host-theme" }, configHome: "/host/home" });
131
- });
132
-
133
- test("an object with a callable register is a usable registry", () => {
134
- const pushed: PushedProvider[] = [];
135
- setHook({
136
- register(provider: PushedProvider) {
137
- pushed.push(provider);
138
- },
139
- });
140
- registerRows(labelsProvider("p1", "Cards", ["Via register"]));
141
-
142
- const result = installHookBackend();
143
-
144
- assert.equal(result.outcome, "hooked");
145
- assert.equal(result.shape, "register");
146
- assert.equal(pushed.length, 1);
147
- assert.deepEqual(
148
- pushed[0].rows({}).map((row) => row.label),
149
- ["Via register"],
150
- );
151
- });
152
-
153
- test("unusable registry shapes are reported and never mistaken for a hook", () => {
154
- const unusable: unknown[] = [
155
- 42,
156
- "providers",
157
- true,
158
- {},
159
- { register: 5 },
160
- { register: null },
161
- () => {},
162
- ];
163
-
164
- for (const value of unusable) {
165
- resetHookStateForTests();
166
- setHook(value);
167
-
168
- const result = installHookBackend();
169
-
170
- assert.equal(result.installed, false, `value ${String(value)} must not install a hook`);
171
- assert.equal(result.outcome, "unusable-hook", `value ${String(value)} is unusable`);
172
- assert.equal(
173
- getHookDiagnostics().shape,
174
- "unusable",
175
- "the unusable shape is recorded for diagnostics",
176
- );
177
- }
178
- });
179
-
180
- test("the registry is marked so a second package copy does not push a second provider", () => {
181
- const registry: PushedProvider[] = [];
182
- setHook(registry);
183
- installHookBackend();
184
-
185
- const armed = Symbol.for("gentle-shell-bridge.hook.armed.v1");
186
- assert.equal(Object.hasOwn(registry, armed), true, "the registry carries the armed marker");
187
- assert.equal(
188
- Object.prototype.propertyIsEnumerable.call(registry, armed),
189
- false,
190
- "the marker is non-enumerable, so the host never iterates it",
191
- );
192
- // A second copy of this module has its own module scope but the same globalThis slot and the
193
- // same registry, which carries the marker.
194
- assert.equal(installHookBackend().installed, true);
195
- assert.equal(registry.length, 1, "still exactly one fan-out provider");
196
- });