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,477 +0,0 @@
1
- /**
2
- * Move the cursor, press Enter to apply (A1–A4, L4).
3
- *
4
- * The user's report: a line the bridge added changes its value the moment ←/→ touches it — the `›`
5
- * cursor and "Enter applies" never appear — while the host's own `Stats` line moves the cursor and
6
- * applies on Enter. The host's rule is PER ROW, not per line, so the fix is a row field:
7
- *
8
- * - `lib/visual-customize-view.ts:552-566` steps the cursor inside one setting line and runs
9
- * `rows[next].action()` only while `rows[next].choice?.applyOnMove !== false`;
10
- * - `:409-417` lands a line on its current value, which is the cursor the user sees first;
11
- * - `:590-592` marks the focused non-current value with `›`, and `:35-38,751` prints
12
- * `←/→ choose · Enter apply` for a focused line whose row defers.
13
- *
14
- * `KeyDriver` below is that rule, modelled once and driven over the REAL rows the bridge emits —
15
- * the `Sections` toggle, the card-style values in both modes (mixed with the host's own), and a
16
- * plugin provider's own rows (a fixture with the shape the worked example used to demonstrate).
17
- * It counts the actions it runs, so "a move applies
18
- * nothing" and "Enter applies exactly once" are observations, not assumptions.
19
- */
20
-
21
- import assert from "node:assert/strict";
22
- import { mkdtempSync, readFileSync, rmSync } from "node:fs";
23
- import { tmpdir } from "node:os";
24
- import { join } from "node:path";
25
- import test from "node:test";
26
-
27
- import { BRIDGE_API_KEY, publishBridgeApi, resetBridgeApiForTests } from "../src/api.ts";
28
- import { armBridge } from "../src/arm.ts";
29
- import {
30
- registerCardStyle,
31
- registerRows,
32
- registerSection,
33
- resetBusForTests,
34
- resetCardStylesForTests,
35
- resetSectionsForTests,
36
- } from "../src/bus.ts";
37
- import { installCardStyleRows, resetCardStyleRowStateForTests } from "../src/card-style-rows.ts";
38
- import { HOST_CARD_STYLE_SETTING } from "../src/card-style-line.ts";
39
- import { getCardStyleSelection } from "../src/card-styles.ts";
40
- import { disableCaptureBackend, resetCaptureStateForTests } from "../src/capture.ts";
41
- import { resetHookStateForTests } from "../src/hook.ts";
42
- import { getRepaintDiagnostics, resetRepaintForTests } from "../src/repaint.ts";
43
- import { buildRows, resetRowBuildStateForTests } from "../src/rows.ts";
44
- import { installSectionRows } from "../src/sections-rows.ts";
45
- import { resetStoreStateForTests } from "../src/store.ts";
46
- import type { CardStyleRegistration, CustomizeRow, InlineHelpers, RowContext, SidebarSection } from "../src/types.ts";
47
- import { createFakeHost, type FakeRow } from "./fake-host.ts";
48
-
49
- const inlineHelpers: InlineHelpers = {
50
- input: async () => undefined,
51
- confirm: async () => true,
52
- capture: async () => undefined,
53
- };
54
-
55
- const isVisible = (row: FakeRow): boolean => {
56
- if (row.visible === undefined) return true;
57
- try {
58
- return row.visible();
59
- } catch {
60
- return false; // the renderer fails closed
61
- }
62
- };
63
-
64
- interface KeyDriver {
65
- /** Focus one setting line: the host lands on the value that is current (`:409-417`). */
66
- focus: (setting: string) => { first: number; count: number; index: number };
67
- /** ←/→ inside the focused line: move the cursor, then apply ONLY if the row allows it (`:565`). */
68
- arrow: (direction: -1 | 1) => Promise<void>;
69
- /** Enter on the focused value: always applies (`:575-577`). */
70
- enter: () => Promise<void>;
71
- /** How many row actions the host has run so far. */
72
- actions: () => number;
73
- /** The row the cursor sits on. */
74
- focused: () => FakeRow;
75
- /** The line the host would draw right now (`:583-595`): `●` current, `›` the cursor. */
76
- line: () => string;
77
- }
78
-
79
- /**
80
- * The host's keyboard rule over whatever rows are live right now, taken row by row from
81
- * `lib/visual-customize-view.ts` — including the per-row deferral the bridge rows now carry.
82
- */
83
- function keyDriver(rowsNow: () => FakeRow[]): KeyDriver {
84
- const rows = (): FakeRow[] => rowsNow().filter(isVisible);
85
- let setting: string | undefined;
86
- let index = 0;
87
- let actions = 0;
88
-
89
- const range = (name: string): { first: number; count: number } => {
90
- const all = rows();
91
- const first = all.findIndex((row) => row.choice?.setting === name);
92
- if (first < 0) throw new Error(`keyDriver: no setting line ${JSON.stringify(name)}`);
93
- let count = 1;
94
- while (first + count < all.length && all[first + count]?.choice?.setting === name) count += 1;
95
- return { first, count };
96
- };
97
-
98
- const run = async (row: FakeRow): Promise<void> => {
99
- actions += 1;
100
- await row.action?.(inlineHelpers);
101
- };
102
-
103
- return {
104
- focus(name) {
105
- const { first, count } = range(name);
106
- setting = name;
107
- index = first;
108
- const all = rows();
109
- for (let i = first; i < first + count; i += 1) {
110
- try {
111
- if (all[i]!.choice?.current() === true) {
112
- index = i;
113
- break;
114
- }
115
- } catch {
116
- // An unreadable value lands on the first choice, exactly as the host does.
117
- }
118
- }
119
- return { first, count, index };
120
- },
121
- async arrow(direction) {
122
- if (setting === undefined) throw new Error("keyDriver: focus a line first");
123
- const { first, count } = range(setting);
124
- const next = index + direction;
125
- // Stepping out of the value range does nothing; the host leaves the cursor where it is.
126
- if (next < first || next >= first + count) return;
127
- index = next;
128
- const row = rows()[next]!;
129
- // THE rule under test (`visual-customize-view.ts:565`): an explicit `false` defers to Enter.
130
- if (row.choice?.applyOnMove !== false) await run(row);
131
- },
132
- async enter() {
133
- if (setting === undefined) throw new Error("keyDriver: focus a line first");
134
- await run(rows()[index]!);
135
- },
136
- actions: () => actions,
137
- focused: () => rows()[index]!,
138
- line() {
139
- if (setting === undefined) throw new Error("keyDriver: focus a line first");
140
- const { first, count } = range(setting);
141
- const marks = rows()
142
- .slice(first, first + count)
143
- .map((row, offset) => {
144
- const current = row.choice?.current() === true;
145
- const cursor = !current && first + offset === index;
146
- return `${current ? "●" : cursor ? "›" : "○"} ${row.choice?.value ?? ""}`;
147
- });
148
- return `${setting} ${marks.join(" ")}`;
149
- },
150
- };
151
- }
152
-
153
- let configHome: string;
154
- let previousConfigHome: string | undefined;
155
-
156
- test.before(() => {
157
- configHome = mkdtempSync(join(tmpdir(), "gsb-apply-on-move-"));
158
- previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
159
- process.env.GENTLE_PI_CONFIG_HOME = configHome;
160
- });
161
-
162
- test.after(() => {
163
- if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
164
- else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
165
- rmSync(configHome, { recursive: true, force: true });
166
- });
167
-
168
- test.beforeEach(() => {
169
- rmSync(join(configHome, "gentle-shell-bridge"), { recursive: true, force: true });
170
- delete (globalThis as unknown as Record<symbol, unknown>)[BRIDGE_API_KEY];
171
- resetBridgeApiForTests();
172
- resetBusForTests();
173
- resetSectionsForTests();
174
- resetCardStylesForTests();
175
- resetCardStyleRowStateForTests();
176
- resetCaptureStateForTests();
177
- resetHookStateForTests();
178
- resetRowBuildStateForTests();
179
- resetRepaintForTests();
180
- resetStoreStateForTests();
181
- disableCaptureBackend();
182
- });
183
-
184
- const ctx = (): RowContext => ({ theme: undefined, configHome });
185
-
186
- const section = (id: string, extra: Partial<SidebarSection> = {}): SidebarSection => ({
187
- id,
188
- title: id.toUpperCase(),
189
- lines: () => [`body ${id}`],
190
- ...extra,
191
- });
192
-
193
- const frame = () => ({
194
- top: (given: { width: number }) => "▛".repeat(given.width),
195
- line: (given: { text: string }) => given.text,
196
- bottom: (given: { width: number }) => "▙".repeat(given.width),
197
- });
198
-
199
- const style = (id: string, extra: Partial<CardStyleRegistration> = {}): CardStyleRegistration => ({
200
- id,
201
- label: id,
202
- frame: frame(),
203
- ...extra,
204
- });
205
-
206
- /** The host's own `Card style` row, shaped like `extensions/gentle-shell.ts:3405-3419`. */
207
- const hostStyleRow = (value: "neon" | "float", actions: string[]): FakeRow => ({
208
- category: "Cards",
209
- label: () => `Card style: ${value}`,
210
- preview: () => ({ title: `Cards · ${value}`, sample: `${value} chrome + prompt` }),
211
- action: async () => {
212
- actions.push(value);
213
- },
214
- choice: { setting: HOST_CARD_STYLE_SETTING, value, current: () => value === "float" },
215
- });
216
-
217
- const rowsWithHostLine = (actions: string[]): FakeRow[] => [
218
- { category: "Cards", label: "Card bash: builtin", action: () => {} },
219
- { category: "Cards", label: "Card output: builtin", action: () => {} },
220
- { category: "Layout", label: "Layout: builtin", action: () => {} },
221
- hostStyleRow("neon", actions),
222
- hostStyleRow("float", actions),
223
- ];
224
-
225
- const sectionStorePath = (): string => join(configHome, "gentle-shell-bridge", "bridge-sections.json");
226
-
227
- const storedSections = (): unknown => {
228
- try {
229
- return JSON.parse(readFileSync(sectionStorePath(), "utf8"));
230
- } catch {
231
- return undefined;
232
- }
233
- };
234
-
235
- // ---------------------------------------------------------------------------------------------
236
- // The bridge's own `Sections` toggle
237
- // ---------------------------------------------------------------------------------------------
238
-
239
- test("the Sections toggle moves with ←/→ and applies only on Enter (A1, A4)", async () => {
240
- registerSection(section("loon.music", { title: "MUSIC" }));
241
- installSectionRows();
242
- const host = createFakeHost();
243
- armBridge(host.ui);
244
- host.buildMenu();
245
-
246
- const keys = keyDriver(host.liveRows);
247
- keys.focus("music");
248
-
249
- assert.equal(keys.focused().choice?.applyOnMove, false, "the toggle defers to Enter");
250
-
251
- await keys.arrow(1);
252
-
253
- assert.equal(keys.focused().choice?.value, "hidden", "the cursor moved to the other value");
254
- assert.equal(keys.actions(), 0, "moving applied nothing");
255
- assert.deepEqual(storedSections(), undefined, "and wrote no state");
256
- assert.equal(getRepaintDiagnostics().lastTrigger, undefined, "nor asked for a frame");
257
- assert.equal(keys.line(), "music ● shown › hidden", "the cursor is drawn on the value Enter would apply");
258
-
259
- await keys.enter();
260
-
261
- assert.equal(keys.actions(), 1, "Enter applied the choice exactly once");
262
- assert.deepEqual(storedSections(), {
263
- schemaVersion: 1,
264
- values: { sections: { "loon.music": false } },
265
- });
266
- assert.equal(
267
- getRepaintDiagnostics().lastTrigger?.cause,
268
- 'section "loon.music" → hidden',
269
- "and the rail repaint still happens on the write, not on the move",
270
- );
271
- assert.equal(keys.line(), "music ○ shown ● hidden");
272
- });
273
-
274
- // ---------------------------------------------------------------------------------------------
275
- // Card styles: the fallback line and the host's own line
276
- // ---------------------------------------------------------------------------------------------
277
-
278
- test("a registered card style defers to Enter on the fallback line (A1)", async () => {
279
- const host = createFakeHost();
280
- armBridge(host.ui);
281
- installCardStyleRows();
282
- registerCardStyle(style("loon.cute", { label: "cute" }));
283
- host.buildMenu();
284
-
285
- const keys = keyDriver(host.liveRows);
286
- keys.focus("Bridge card style");
287
-
288
- await keys.arrow(1);
289
-
290
- assert.equal(keys.focused().choice?.value, "cute", "the cursor moved onto the registered style");
291
- assert.equal(keys.actions(), 0, "moving applied nothing");
292
- assert.equal(getCardStyleSelection(), "native", "the stored selection is untouched");
293
- assert.equal(getRepaintDiagnostics().lastTrigger, undefined, "and no surface was repainted");
294
- assert.equal(keys.line(), "Bridge card style ● native › cute");
295
-
296
- await keys.enter();
297
-
298
- assert.equal(keys.actions(), 1, "Enter applied exactly once");
299
- assert.equal(getCardStyleSelection(), "loon.cute");
300
- assert.equal(getRepaintDiagnostics().lastTrigger?.cause, "card style → loon.cute");
301
- assert.deepEqual(getRepaintDiagnostics().lastTrigger?.surfaces, ["conversation", "rail"]);
302
- });
303
-
304
- test("the fallback line's native value defers too (A1)", async () => {
305
- const host = createFakeHost();
306
- armBridge(host.ui);
307
- installCardStyleRows();
308
- registerCardStyle(style("loon.cute", { label: "cute" }));
309
- host.buildMenu();
310
-
311
- const keys = keyDriver(host.liveRows);
312
- keys.focus("Bridge card style");
313
- await keys.arrow(1);
314
- await keys.enter(); // select cute first, so native is no longer current
315
- assert.equal(getCardStyleSelection(), "loon.cute");
316
- const before = keys.actions();
317
-
318
- keys.focus("Bridge card style");
319
- await keys.arrow(-1);
320
-
321
- assert.equal(keys.focused().choice?.value, "native", "the cursor moved back to native");
322
- assert.equal(keys.actions(), before, "moving applied nothing");
323
- assert.equal(getCardStyleSelection(), "loon.cute", "cute is still selected");
324
-
325
- await keys.enter();
326
- assert.equal(keys.actions(), before + 1, "Enter applied exactly once");
327
- assert.equal(getCardStyleSelection(), "native");
328
- });
329
-
330
- test("on the host's line the host's values keep their own field and the bridge's value defers (A3)", async () => {
331
- const hostActions: string[] = [];
332
- const host = createFakeHost({ rows: rowsWithHostLine(hostActions) });
333
- armBridge(host.ui);
334
- installCardStyleRows();
335
- registerCardStyle(style("loon.cute", { label: "cute" }));
336
- host.buildMenu();
337
-
338
- const live = host.liveRows();
339
- const hostRows = live.filter((row) => row.choice?.setting === HOST_CARD_STYLE_SETTING);
340
- assert.equal(hostRows.length, 3, "neon, float and the spliced bridge value on ONE line");
341
- assert.equal(hostRows[0]?.choice?.applyOnMove, undefined, "the host's neon row is the host's");
342
- assert.equal(hostRows[1]?.choice?.applyOnMove, undefined, "and so is float");
343
- assert.equal(hostRows[2]?.choice?.applyOnMove, false, "the bridge's value defers to Enter");
344
-
345
- const keys = keyDriver(host.liveRows);
346
- keys.focus(HOST_CARD_STYLE_SETTING);
347
-
348
- // The host's own value applies on the move, exactly as before: the bridge did not rewrite it.
349
- await keys.arrow(-1);
350
- assert.deepEqual(hostActions, ["neon"], "the host's own action ran on the move");
351
- keys.focus(HOST_CARD_STYLE_SETTING);
352
-
353
- // The bridge's value on the SAME line only takes focus.
354
- await keys.arrow(1);
355
- await keys.arrow(1);
356
- assert.equal(keys.focused().choice?.value, "cute", "the cursor reached the bridge value");
357
- assert.equal(getCardStyleSelection(), "native", "moving onto it applied nothing");
358
- assert.deepEqual(hostActions, ["neon"], "and ran no host action either");
359
-
360
- await keys.enter();
361
- assert.equal(getCardStyleSelection(), "loon.cute", "Enter applied the bridge value");
362
- });
363
-
364
- // ---------------------------------------------------------------------------------------------
365
- // A plugin's own provider rows (a fixture, not the worked example)
366
- // ---------------------------------------------------------------------------------------------
367
- //
368
- // The example contributes a tool DESIGN now, and the bridge owns the design line: its rows are not a
369
- // provider's, so the provider path is driven by a fixture with the same shape and the same rules.
370
- // Nothing here is specific to bash; the values are just two choices on one line.
371
-
372
- test("a plugin's provider rows move without applying and apply on Enter (A1, A2)", async () => {
373
- publishBridgeApi();
374
- let stored = "native";
375
- const values = ["native", "bash heading"];
376
- registerRows({
377
- id: "loon.apply-on-move",
378
- category: "Cards",
379
- name: "Loon example",
380
- rows: () =>
381
- values.map((value) => ({
382
- category: "Cards",
383
- label: "Bash design",
384
- choice: {
385
- setting: "Bash design",
386
- value,
387
- current: (): boolean => stored === value,
388
- },
389
- action: async (): Promise<void> => {
390
- stored = value;
391
- },
392
- })),
393
- });
394
-
395
- const rows = buildRows(ctx()).rows;
396
- const design = rows.filter((row) => row.choice?.setting === "Bash design");
397
- assert.deepEqual(
398
- design.map((row) => row.choice?.value),
399
- ["native", "bash heading"],
400
- "the provider's two values are on one line",
401
- );
402
-
403
- const keys = keyDriver(() => rows);
404
- keys.focus("Bash design");
405
-
406
- await keys.arrow(1);
407
-
408
- assert.equal(keys.focused().choice?.value, "bash heading", "the cursor moved onto the other value");
409
- assert.equal(keys.actions(), 0, "moving applied nothing");
410
- assert.equal(design[0]?.choice?.current(), true, "the stored value is still the first one");
411
- assert.equal(design[1]?.choice?.current(), false);
412
- assert.equal(keys.line(), "Bash design ● native › bash heading");
413
-
414
- await keys.enter();
415
-
416
- assert.equal(keys.actions(), 1, "Enter applied exactly once");
417
- assert.equal(design[0]?.choice?.current(), false, "and the stored value became the other one");
418
- assert.equal(design[1]?.choice?.current(), true);
419
- });
420
-
421
- // ---------------------------------------------------------------------------------------------
422
- // The build boundary: every provider choice defers, and the provider's objects are untouched
423
- // ---------------------------------------------------------------------------------------------
424
-
425
- test("the build defers every provider choice on a copy, never mutating the provider's row (A2)", () => {
426
- const authored: CustomizeRow = {
427
- category: "Cards",
428
- label: "Loon mode",
429
- choice: { setting: "Loon mode", value: "first", current: () => true },
430
- action: () => {},
431
- };
432
- const alreadyDeferring: CustomizeRow = {
433
- category: "Cards",
434
- label: "Loon other",
435
- choice: { setting: "Loon other", value: "one", current: () => true, applyOnMove: false },
436
- action: () => {},
437
- };
438
- const explicitImmediate: CustomizeRow = {
439
- category: "Cards",
440
- label: "Loon third",
441
- choice: { setting: "Loon third", value: "one", current: () => true, applyOnMove: true },
442
- action: () => {},
443
- };
444
- const plain: CustomizeRow = { category: "Cards", label: "Loon action", action: () => {} };
445
- registerRows({
446
- id: "test.apply-on-move",
447
- category: "Cards",
448
- rows: () => [authored, alreadyDeferring, explicitImmediate, plain],
449
- });
450
-
451
- const built = buildRows(ctx()).rows;
452
-
453
- assert.equal(built.length, 4, "no row was dropped");
454
- for (const row of built) {
455
- if (row.choice !== undefined) {
456
- assert.equal(row.choice.applyOnMove, false, `${String(row.choice.setting)} defers to Enter`);
457
- }
458
- }
459
-
460
- // The emitted copy is not the provider's row, and the provider's own objects are untouched.
461
- assert.notEqual(built[0], authored, "the row reached the host as a copy");
462
- assert.notEqual(built[0]?.choice, authored.choice, "and so did its choice");
463
- assert.equal(authored.choice?.applyOnMove, undefined, "the provider's choice was not mutated");
464
- assert.equal(built[0]?.label, authored.label, "every other field is carried as the same reference");
465
- assert.equal(built[0]?.action, authored.action);
466
-
467
- assert.equal(built[1], alreadyDeferring, "an authored false already conforms: no copy is needed");
468
- assert.equal(explicitImmediate.choice?.applyOnMove, true, "an authored true is not mutated either");
469
- assert.equal(built[2]?.choice?.applyOnMove, false, "but the emitted copy defers");
470
- // A row without a choice is a BUTTON row (S16). The bridge hands the host a copy so it can contain
471
- // a failing `action`; nothing else about it changes and the provider's object is never mutated.
472
- assert.notEqual(built[3], plain, "a button row is a copy, so its action can be contained (S16)");
473
- assert.equal(built[3]?.label, plain.label, "its label is carried as the same reference");
474
- assert.notEqual(built[3]?.action, plain.action, "its action is the contained wrapper");
475
- assert.equal(built[3]?.choice, undefined, "no choice is invented for a button");
476
- assert.deepEqual(Object.keys(plain).sort(), ["action", "category", "label"], "the provider's row is not mutated");
477
- });
package/test/arm.test.ts DELETED
@@ -1,220 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import test from "node:test";
3
-
4
- import { armBridge } from "../src/arm.ts";
5
- import { registerRows, resetBusForTests } from "../src/bus.ts";
6
- import { getCaptureDiagnostics, resetCaptureStateForTests } from "../src/capture.ts";
7
- import { getHookDiagnostics, resetHookStateForTests } from "../src/hook.ts";
8
- import type { CustomizeRow, RowProvider } from "../src/types.ts";
9
- import { createFakeHost } from "./fake-host.ts";
10
-
11
- const HOOK_KEY = Symbol.for("gentle-pi.customize-row-providers");
12
- const CAPTURE_WRAPPED = Symbol.for("gentle-shell-bridge.capture.wrapped.v1");
13
-
14
- const hookSlot = (): Record<symbol, unknown> => globalThis as unknown as Record<symbol, unknown>;
15
- const clearHook = (): void => {
16
- delete hookSlot()[HOOK_KEY];
17
- };
18
-
19
- test.beforeEach(() => {
20
- resetBusForTests();
21
- resetCaptureStateForTests();
22
- resetHookStateForTests();
23
- });
24
-
25
- test.afterEach(() => {
26
- clearHook();
27
- resetHookStateForTests();
28
- });
29
-
30
- const labelsProvider = (id: string, category: string, labels: string[]): RowProvider => ({
31
- id,
32
- category,
33
- rows: () => labels.map((label) => ({ label, action: () => {} })),
34
- });
35
-
36
- interface PushedProvider {
37
- id: string;
38
- rows: (ctx?: unknown) => CustomizeRow[];
39
- }
40
-
41
- test("selects the hook backend and patches nothing on ui", () => {
42
- const registry: PushedProvider[] = [];
43
- hookSlot()[HOOK_KEY] = registry;
44
- const host = createFakeHost();
45
- registerRows(labelsProvider("p1", "Cards", ["Hooked row"]));
46
- const customBefore = host.ui.custom;
47
-
48
- const result = armBridge(host.ui);
49
-
50
- assert.deepEqual(result, { outcome: "hooked", backend: "hook" });
51
- assert.equal(host.ui.custom, customBefore, "ui.custom identity is unchanged");
52
- assert.equal(Object.hasOwn(host.ui, CAPTURE_WRAPPED), false, "no marker on the instance");
53
- const proto: object | null = Object.getPrototypeOf(host.ui) as object | null;
54
- assert.equal(
55
- proto === null ? false : Object.hasOwn(proto, CAPTURE_WRAPPED),
56
- false,
57
- "no marker on the prototype",
58
- );
59
- assert.equal(getCaptureDiagnostics().armedTargets, 0, "the capture backend never armed");
60
- assert.equal(registry.length, 1);
61
-
62
- // Rows land through the registry's own call path.
63
- assert.deepEqual(
64
- registry[0].rows({}).map((row) => row.label),
65
- ["Hooked row"],
66
- );
67
- });
68
-
69
- test("an unusable hook shape falls back to capture and records why", () => {
70
- hookSlot()[HOOK_KEY] = { register: 5 };
71
- const host = createFakeHost();
72
- registerRows(labelsProvider("p1", "Cards", ["Fallback row"]));
73
-
74
- const result = armBridge(host.ui);
75
-
76
- assert.deepEqual(result, {
77
- outcome: "fallback-capture",
78
- backend: "capture",
79
- reason: "hook-unusable",
80
- });
81
- assert.equal(getHookDiagnostics().shape, "unusable");
82
-
83
- host.buildMenu();
84
- assert.deepEqual(host.rendered().slice(4), ["Fallback row"]);
85
- assert.equal(host.invalidateCount(), 1);
86
- });
87
-
88
- test("arms capture when the host exposes no registry", () => {
89
- const host = createFakeHost();
90
- registerRows(labelsProvider("p1", "Cards", ["Captured row"]));
91
-
92
- const result = armBridge(host.ui);
93
-
94
- assert.deepEqual(result, { outcome: "capture-armed", backend: "capture" });
95
- host.buildMenu();
96
- assert.deepEqual(host.rendered().slice(4), ["Captured row"]);
97
- });
98
-
99
- test("two session_start arms wrap once and inject once", () => {
100
- const host = createFakeHost();
101
- registerRows(labelsProvider("p1", "Cards", ["Once"]));
102
-
103
- const first = armBridge(host.ui);
104
- const second = armBridge(host.ui);
105
-
106
- assert.deepEqual(first, { outcome: "capture-armed", backend: "capture" });
107
- assert.deepEqual(second, { outcome: "already-armed", backend: "capture" });
108
- assert.equal(getCaptureDiagnostics().armedTargets, 1, "one wrapper, not two");
109
-
110
- host.buildMenu();
111
- assert.deepEqual(
112
- host.rendered().filter((label) => label === "Once"),
113
- ["Once"],
114
- );
115
- assert.equal(host.invalidateCount(), 1, "one invalidate per menu build");
116
-
117
- host.buildMenu();
118
- assert.deepEqual(
119
- host.rendered().filter((label) => label === "Once"),
120
- ["Once"],
121
- "a second build of the same menu still injects once",
122
- );
123
- assert.equal(host.invalidateCount(), 1);
124
- });
125
-
126
- test("a prototype-only ui wraps where custom was found, and a second arm is a no-op", () => {
127
- const host = createFakeHost({ customShape: "prototype" });
128
- registerRows(labelsProvider("p1", "Cards", ["Proto row"]));
129
-
130
- const first = armBridge(host.ui);
131
- const second = armBridge(host.ui);
132
-
133
- assert.deepEqual(first, { outcome: "capture-armed", backend: "capture" });
134
- assert.deepEqual(second, { outcome: "already-armed", backend: "capture" });
135
- assert.equal(Object.hasOwn(host.ui, "custom"), false, "the instance gained no own custom");
136
- const proto: object | null = Object.getPrototypeOf(host.ui) as object | null;
137
- assert.equal(
138
- proto === null ? false : Object.hasOwn(proto, CAPTURE_WRAPPED),
139
- true,
140
- "the prototype carries the wrapper",
141
- );
142
-
143
- host.buildMenu();
144
- assert.deepEqual(
145
- host.rendered().filter((label) => label === "Proto row"),
146
- ["Proto row"],
147
- );
148
- assert.equal(host.invalidateCount(), 1);
149
- });
150
-
151
- test("a no-UI context is a reported no-op, not a throw", () => {
152
- assert.deepEqual(armBridge(undefined), {
153
- outcome: "no-ui",
154
- backend: "none",
155
- reason: "ui-missing",
156
- });
157
- assert.deepEqual(armBridge(null), {
158
- outcome: "no-ui",
159
- backend: "none",
160
- reason: "ui-missing",
161
- });
162
- assert.deepEqual(armBridge({}), {
163
- outcome: "no-ui",
164
- backend: "none",
165
- reason: "custom-missing",
166
- });
167
- });
168
-
169
- test("importing the arm module has no side effects", async () => {
170
- const armedBefore = getCaptureDiagnostics().armedTargets;
171
- const mapBefore = Array.prototype.map;
172
-
173
- // The query string is what makes a second module instance, so the specifier is computed: `tsc`
174
- // types a non-literal dynamic import as a value import, not as a path to resolve on disk.
175
- const freshSpecifier = "../src/arm.ts?fresh=1";
176
- const fresh: typeof import("../src/arm.ts") = await import(freshSpecifier);
177
-
178
- assert.equal(getCaptureDiagnostics().armedTargets, armedBefore, "import arms nothing");
179
- assert.equal(Array.prototype.map, mapBefore, "import patches nothing");
180
- assert.equal(typeof fresh.armBridge, "function");
181
-
182
- // A menu built without arming must show no provider rows: nothing armed itself on import.
183
- const host = createFakeHost();
184
- registerRows(labelsProvider("p1", "Cards", ["Must not appear"]));
185
- host.buildMenu();
186
- assert.deepEqual(host.rendered().slice(4), []);
187
- });
188
-
189
- test("the same providers produce the same rows through either backend", () => {
190
- const providers = (): void => {
191
- registerRows(labelsProvider("first", "Cards", ["First a"]));
192
- registerRows({
193
- id: "boom",
194
- category: "Banner",
195
- rows: () => {
196
- throw new Error("shared provider exploded");
197
- },
198
- });
199
- registerRows(labelsProvider("second", "Layout", ["Second b"]));
200
- };
201
-
202
- // Capture backend.
203
- providers();
204
- const host = createFakeHost();
205
- armBridge(host.ui);
206
- host.buildMenu();
207
- const capturedLabels = host.rendered().slice(4);
208
-
209
- // Hook backend, fresh bus with the same providers.
210
- resetBusForTests();
211
- resetCaptureStateForTests();
212
- providers();
213
- const registry: PushedProvider[] = [];
214
- hookSlot()[HOOK_KEY] = registry;
215
- armBridge(createFakeHost().ui);
216
- const hookedLabels = registry[0].rows({}).map((row) => String(row.label));
217
-
218
- assert.deepEqual(capturedLabels, ["First a", "Second b"]);
219
- assert.deepEqual(hookedLabels, capturedLabels, "same providers, same rows, same order");
220
- });