gentle-shell-bridge 0.3.0 → 0.3.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 (75) hide show
  1. package/LICENSE +202 -0
  2. package/docs/contract.md +1 -1
  3. package/package.json +13 -1
  4. package/src/api.ts +1 -1
  5. package/.codegraph/.gitignore +0 -5
  6. package/PRD.md +0 -144
  7. package/examples/bash-card.ts +0 -1694
  8. package/gentle-shell-bridge-0.3.0.tgz +0 -0
  9. package/odd/tasks/palette-sections.md +0 -30
  10. package/odd/tasks/plugin-keymap.md +0 -32
  11. package/odd/tasks/plugin-surface-contract.md +0 -55
  12. package/test/api.test.ts +0 -395
  13. package/test/apply-on-move.test.ts +0 -477
  14. package/test/arm.test.ts +0 -220
  15. package/test/bridge-ready.test.ts +0 -309
  16. package/test/bus.test.ts +0 -165
  17. package/test/button-rows.test.ts +0 -409
  18. package/test/capture.test.ts +0 -356
  19. package/test/card-style-rows.test.ts +0 -510
  20. package/test/card-style-transform.test.ts +0 -379
  21. package/test/card-style-wiring.test.ts +0 -258
  22. package/test/card-styles-e2e.test.ts +0 -491
  23. package/test/card-styles.test.ts +0 -324
  24. package/test/card-transcript-path.test.ts +0 -178
  25. package/test/config.test.ts +0 -93
  26. package/test/e2e.test.ts +0 -443
  27. package/test/example-bash-identity.test.ts +0 -372
  28. package/test/example-bash-matrix.test.ts +0 -856
  29. package/test/example-designs.test.ts +0 -734
  30. package/test/example-neon-transform.test.ts +0 -283
  31. package/test/example-plain-style.test.ts +0 -1268
  32. package/test/extension.test.ts +0 -160
  33. package/test/fake-events.ts +0 -58
  34. package/test/fake-host.ts +0 -211
  35. package/test/fake-sidebar.ts +0 -516
  36. package/test/fake-transcript.ts +0 -486
  37. package/test/frame-telemetry.test.ts +0 -417
  38. package/test/hook.test.ts +0 -196
  39. package/test/manifest.test.ts +0 -105
  40. package/test/new-category.test.ts +0 -979
  41. package/test/notices.test.ts +0 -328
  42. package/test/plugin-integration-lines.test.ts +0 -979
  43. package/test/plugin-surface.test.ts +0 -152
  44. package/test/presence.test.ts +0 -459
  45. package/test/rail-surfaces.test.ts +0 -457
  46. package/test/rail-width.test.ts +0 -423
  47. package/test/regression.test.ts +0 -483
  48. package/test/repaint-api.test.ts +0 -200
  49. package/test/reserved-categories.test.ts +0 -361
  50. package/test/row-labels.test.ts +0 -284
  51. package/test/row-names.test.ts +0 -473
  52. package/test/row-origin.test.ts +0 -395
  53. package/test/section-interaction.test.ts +0 -364
  54. package/test/section-panel.test.ts +0 -403
  55. package/test/sections-rows.test.ts +0 -291
  56. package/test/sidebar-backend.test.ts +0 -471
  57. package/test/sidebar-e2e.test.ts +0 -331
  58. package/test/sidebar-hover.test.ts +0 -276
  59. package/test/sidebar-independent.test.ts +0 -349
  60. package/test/sidebar-render.test.ts +0 -281
  61. package/test/sidebar-sections.test.ts +0 -245
  62. package/test/status-card-styles.test.ts +0 -201
  63. package/test/status-section-frame.test.ts +0 -181
  64. package/test/status-sections.test.ts +0 -243
  65. package/test/status.test.ts +0 -209
  66. package/test/store.test.ts +0 -192
  67. package/test/surface-repaint-cost.test.ts +0 -520
  68. package/test/tool-design-chain.test.ts +0 -483
  69. package/test/tool-design-cost.test.ts +0 -473
  70. package/test/tool-design-rows.test.ts +0 -359
  71. package/test/tool-designs.test.ts +0 -745
  72. package/test/transform-pipeline.test.ts +0 -465
  73. package/test/transforms-bus.test.ts +0 -233
  74. package/test/verifier-width-regressions.test.ts +0 -164
  75. 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
- });