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,979 +0,0 @@
1
- /**
2
- * T33/T34 / S18: a provider's BRAND-NEW category reaches the user's menu.
3
- *
4
- * E8, verified against the host: the view derives its page list ONCE, in its constructor —
5
- * `this.categories = [...new Set(options.rows.map(row => row.category ?? "Settings"))]`
6
- * (`lib/visual-customize-view.ts:387`) — while `categoryRows()` filters `options.rows` live on every
7
- * read (`:391-393`). A row that joins an EXISTING page therefore appears even when it is pushed
8
- * after the view exists; a row that names a NEW page cannot, because nothing can select a page the
9
- * list does not hold.
10
- *
11
- * The bridge appends the providers' rows BEFORE the constructor's `map` reads the array, and only
12
- * for an array PROVEN to be the menu's rows (`src/capture.ts`): the host's own mapper must derive
13
- * `category ?? "Settings"` for the array's own elements AND for two synthetic rows the host never
14
- * had — one with no category, one naming a page that cannot exist — and it must not throw. The
15
- * same mapper is also run over the rows to append BEFORE they are pushed (S20).
16
- *
17
- * That early step is PROVISIONAL (S20): it is a guess made before the factory finished, so it is
18
- * reconciled against the late decision at the end of the factory, and rolled back on a throw, on
19
- * another array, on ambiguity or on an async factory. The net guarantee this suite pins: after any
20
- * factory invocation, no array holds bridge rows except the one the late decision chose.
21
- *
22
- * Through the real entry (`publishBridgeApi().registerRows`, `armBridge`) and a view that computes
23
- * its pages in its constructor exactly like the host's, this suite proves:
24
- *
25
- * - the new page appears once, AFTER the host's own pages and never before them;
26
- * - its toggle and its button rows are in it, in the provider's order;
27
- * - two providers in the same new page share one page;
28
- * - a provider in an existing page still joins it (no regression);
29
- * - `Keymaps` is still refused and never creates a page;
30
- * - an incidental row-shaped array in the same factory is never written to;
31
- * - nested capture does not steal the outer menu's page, and an inner rollback never touches it;
32
- * - a host that invokes its factory in a later microtask still gets its page;
33
- * - calling the factory twice does not duplicate rows or pages;
34
- * - `/bridge-status` reports the injection exactly as before;
35
- * - JD-A-001..005, the five defects the adversarial review found in the early step, each stay dead.
36
- */
37
-
38
- import assert from "node:assert/strict";
39
- import { mkdtempSync, rmSync } from "node:fs";
40
- import { tmpdir } from "node:os";
41
- import { join } from "node:path";
42
- import test from "node:test";
43
-
44
- import { publishBridgeApi, resetBridgeApiForTests } from "../src/api.ts";
45
- import { armBridge } from "../src/arm.ts";
46
- import {
47
- registerCardStyle,
48
- resetBusForTests,
49
- resetCardStylesForTests,
50
- resetSectionsForTests,
51
- resetTransformsForTests,
52
- } from "../src/bus.ts";
53
- import { resetCardStyleRowStateForTests } from "../src/card-style-rows.ts";
54
- import { HOST_CARD_STYLE_SETTING } from "../src/card-style-line.ts";
55
- import { disableCaptureBackend, getCaptureDiagnostics, resetCaptureStateForTests } from "../src/capture.ts";
56
- import { resetHookStateForTests } from "../src/hook.ts";
57
- import { resetNoticesForTests } from "../src/notices.ts";
58
- import {
59
- getRowNameRejections,
60
- resetRowNameRejectionsForTests,
61
- RESERVED_CATEGORY_RULE,
62
- } from "../src/row-names.ts";
63
- import { resetRowBuildStateForTests } from "../src/rows.ts";
64
- import { collectStatus, formatStatus } from "../src/status.ts";
65
- import { resetStoreStateForTests } from "../src/store.ts";
66
- import type { CardStyleRegistration, CustomizeRow, RowLabel } from "../src/types.ts";
67
-
68
- const labelText = (label: RowLabel | undefined): string => {
69
- if (typeof label === "function") return label();
70
- return label ?? "";
71
- };
72
-
73
- const visible = (row: CustomizeRow): boolean => {
74
- if (row.visible === undefined) return true;
75
- try {
76
- return row.visible();
77
- } catch {
78
- return false; // the host's renderer fails closed
79
- }
80
- };
81
-
82
- /**
83
- * "Byte-identical", in the only sense that matters here: the same row objects, in the same order,
84
- * with nothing added and nothing removed. `assert.deepEqual` would accept a rebuild that happens to
85
- * look alike, which is exactly the trace a rollback must not leave.
86
- */
87
- const sameRows = (rows: readonly CustomizeRow[], before: readonly CustomizeRow[]): boolean =>
88
- rows.length === before.length && rows.every((row, index) => row === before[index]);
89
-
90
- /** The bridge's own rows wherever they are, by the label the provider gave them. */
91
- const bridgeLabels = (rows: readonly CustomizeRow[]): string[] =>
92
- rows.map((row) => labelText(row.label)).filter((label) => label.startsWith("Loon"));
93
-
94
- /** Rows an incidental array can hold: row-shaped, above the capture threshold, no provider identity. */
95
- const incidentalRows = (prefix: string, category: string): CustomizeRow[] => [
96
- { category, label: `${prefix} one`, action: () => {} },
97
- { category, label: `${prefix} two`, action: () => {} },
98
- { category, label: `${prefix} three`, action: () => {} },
99
- ];
100
-
101
- /** A registered card style, as little as the splice needs (`src/card-style-rows.ts:376-390`). */
102
- const cardStyle = (id: string): CardStyleRegistration => ({
103
- id,
104
- label: id,
105
- frame: {
106
- top: (ctx: { width: number }) => "▛".repeat(ctx.width),
107
- line: (ctx: { text: string }) => ctx.text,
108
- bottom: (ctx: { width: number }) => "▙".repeat(ctx.width),
109
- },
110
- });
111
-
112
- /** The host's own `Card style` line, shaped like `extensions/gentle-shell.ts:3405-3419`. */
113
- const hostStyleRow = (value: "neon" | "float"): CustomizeRow => ({
114
- category: "Cards",
115
- label: () => `Card style: ${value}`,
116
- action: () => {},
117
- choice: { setting: HOST_CARD_STYLE_SETTING, value, current: () => value === "neon" },
118
- });
119
-
120
- /** The host's menu with its own `Card style` line inside `Cards`, so a splice can be observed. */
121
- const rowsWithHostLine = (): CustomizeRow[] => [
122
- { category: "Cards", label: "Card bash: builtin", action: () => {} },
123
- { category: "Cards", label: "Card output: builtin", action: () => {} },
124
- hostStyleRow("neon"),
125
- hostStyleRow("float"),
126
- { category: "Layout", label: "Layout: builtin", action: () => {} },
127
- ];
128
-
129
- /**
130
- * The host's view, reduced to the two lines the feature depends on — transcribed, not approximated:
131
- * `lib/visual-customize-view.ts:387` (pages derived once, in the constructor) and `:391-393` (a
132
- * page's rows filtered from the live array on every read). The view itself is private to the host
133
- * (`extensions/gentle-shell.ts:3550-3561` returns an adapter), so the harness below is the only one
134
- * allowed to read it.
135
- */
136
- class HostCustomizeView {
137
- private readonly categories: string[];
138
- readonly pane: "categories" | "controls";
139
- private readonly options: { rows: CustomizeRow[] };
140
-
141
- constructor(options: {
142
- rows: CustomizeRow[];
143
- /**
144
- * The host's ONE page derivation, replaceable so a test can give it an observable side effect
145
- * (an impure mapper, JD-A-003) without pretending the host has a different shape. The default
146
- * is the transcribed line above.
147
- */
148
- deriveCategories?: (rows: CustomizeRow[]) => string[];
149
- }) {
150
- this.options = options;
151
- const derive = options.deriveCategories;
152
- this.categories =
153
- derive !== undefined
154
- ? derive(options.rows)
155
- : [...new Set(options.rows.map((row) => row.category ?? "Settings"))];
156
- this.pane = options.rows.some((row) => row.category) ? "categories" : "controls";
157
- }
158
-
159
- /** What the CATEGORIES pane lists (`:527` reads `this.categories.length`). */
160
- pages(): string[] {
161
- return [...this.categories];
162
- }
163
-
164
- /** One page's visible rows, as the controls pane draws them (`:391-393`). */
165
- rowsOf(page: string): CustomizeRow[] {
166
- return this.options.rows.filter(
167
- (row) => (row.category ?? "Settings") === page && visible(row),
168
- );
169
- }
170
- }
171
-
172
- /** The host's own pages: four rows over three of them, above the capture threshold. */
173
- const hostRows = (): CustomizeRow[] => [
174
- { category: "Cards", label: "Card bash: builtin", action: () => {} },
175
- { category: "Cards", label: "Card output: builtin", action: () => {} },
176
- { category: "Banner", label: "Banner: builtin", action: () => {} },
177
- { category: "Layout", label: "Layout: builtin", action: () => {} },
178
- ];
179
-
180
- const THEME = {
181
- fg: (_color: string, text: string): string => text,
182
- bg: (_color: string, text: string): string => text,
183
- };
184
-
185
- type HostFactory = (ui: unknown, theme: unknown, ...rest: unknown[]) => unknown;
186
-
187
- interface MenuHostOptions {
188
- rows?: CustomizeRow[];
189
- /** A row-shaped array the factory maps BEFORE the menu, as a host helper might. */
190
- incidental?: CustomizeRow[];
191
- /** A nested `custom` call the factory makes and abandons, as a provider's own UI work could. */
192
- nested?: CustomizeRow[];
193
- /** Resolve the factory in a later microtask, like `Promise.resolve().then(factory)`. */
194
- deferred?: boolean;
195
- /** Extra work the factory does BEFORE it builds its view, in the host's own order. */
196
- preBuild?: (rows: CustomizeRow[]) => void;
197
- /** Extra work the factory does AFTER it built its view — the last thing before it returns. */
198
- postBuild?: () => void;
199
- /** The factory's own result is a promise, while the view was built synchronously (JD-A-005). */
200
- factoryPromise?: boolean;
201
- /** The host's page derivation, so a test can count or throw on the host's own mapper. */
202
- deriveCategories?: (rows: CustomizeRow[]) => string[];
203
- }
204
-
205
- interface MenuHost {
206
- ui: { custom: (factory: HostFactory, ...rest: unknown[]) => unknown };
207
- build: () => unknown;
208
- view: () => HostCustomizeView;
209
- liveRows: () => CustomizeRow[];
210
- invalidateCount: () => number;
211
- }
212
-
213
- /** `extensions/gentle-shell.ts:3550-3562`: the view is built inside the factory, which returns an adapter. */
214
- function createMenuHost(options: MenuHostOptions = {}): MenuHost {
215
- const rows = options.rows ?? hostRows();
216
- let view: HostCustomizeView | undefined;
217
- let invalidations = 0;
218
-
219
- const build = (): unknown => {
220
- view = new HostCustomizeView({ rows, ...(options.deriveCategories === undefined ? {} : { deriveCategories: options.deriveCategories }) });
221
- return {
222
- render: (): string[] => view?.pages() ?? [],
223
- handleInput: (): void => {},
224
- invalidate: (): void => {
225
- invalidations += 1;
226
- },
227
- dispose: (): void => {},
228
- };
229
- };
230
-
231
- const ui = {
232
- custom(inner: HostFactory, ..._rest: unknown[]): unknown {
233
- if (options.deferred === true) {
234
- return Promise.resolve().then(() => inner(ui, THEME, { keybindings: "fake" }, () => {}));
235
- }
236
- return inner(ui, THEME, { keybindings: "fake" }, () => {});
237
- },
238
- };
239
-
240
- const factory: HostFactory = () => {
241
- if (options.incidental !== undefined) options.incidental.map((row) => row.label);
242
- if (options.nested !== undefined) {
243
- const nested = options.nested;
244
- try {
245
- ui.custom(() => {
246
- nested.map((row) => row.label);
247
- throw new Error("nested factory exploded");
248
- });
249
- } catch {
250
- // the host survives a nested failure and finishes its own menu
251
- }
252
- }
253
- options.preBuild?.(rows);
254
- const built = build();
255
- options.postBuild?.();
256
- return options.factoryPromise === true ? Promise.resolve(built) : built;
257
- };
258
-
259
- return {
260
- ui,
261
- build: () => ui.custom(factory),
262
- view: () => {
263
- assert.ok(view !== undefined, "the host built no view");
264
- return view;
265
- },
266
- liveRows: () => rows,
267
- invalidateCount: () => invalidations,
268
- };
269
- }
270
-
271
- /** The real entry: arm the bridge on a host-shaped `ctx.ui`, then ask the host to build its menu. */
272
- const menuHost = (options: MenuHostOptions = {}): MenuHost => {
273
- const host = createMenuHost(options);
274
- armBridge(host.ui);
275
- return host;
276
- };
277
-
278
- /** A toggle row: consecutive rows sharing one `choice.setting` are one line in the host. */
279
- const toggles = (setting: string, label: string, values: [string, string]): CustomizeRow[] =>
280
- values.map((value) => ({
281
- label,
282
- choice: { setting, value, current: () => value === values[0] },
283
- action: () => {},
284
- }));
285
-
286
- /** A button row: `action` and no `choice`, the host's own `Reset`/`Profiles` shape. */
287
- const button = (label: string): CustomizeRow => ({ label, action: () => {} });
288
-
289
- /** The page the example contributes in the live TUI (L6: a new category with a toggle and a button). */
290
- const loonPage = (): CustomizeRow[] => [
291
- ...toggles("Loon heading", "Loon heading", ["default", "tool"]),
292
- button("Loon repaint"),
293
- ];
294
-
295
- let configHome: string;
296
- let previousConfigHome: string | undefined;
297
-
298
- test.before(() => {
299
- configHome = mkdtempSync(join(tmpdir(), "gsb-new-category-"));
300
- previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
301
- process.env.GENTLE_PI_CONFIG_HOME = configHome;
302
- });
303
-
304
- test.after(() => {
305
- if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
306
- else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
307
- rmSync(configHome, { recursive: true, force: true });
308
- });
309
-
310
- test.beforeEach(() => {
311
- rmSync(join(configHome, "gentle-shell-bridge"), { recursive: true, force: true });
312
- resetBridgeApiForTests();
313
- resetBusForTests();
314
- resetSectionsForTests();
315
- resetCardStylesForTests();
316
- resetCardStyleRowStateForTests();
317
- resetTransformsForTests();
318
- resetRowNameRejectionsForTests();
319
- resetCaptureStateForTests();
320
- resetHookStateForTests();
321
- resetRowBuildStateForTests();
322
- resetNoticesForTests();
323
- resetStoreStateForTests();
324
- disableCaptureBackend();
325
- });
326
-
327
- // ---------------------------------------------------------------------------------------------
328
- // The page exists, once, after the host's own pages
329
- // ---------------------------------------------------------------------------------------------
330
-
331
- test("a provider's new page appears once, after the host's own pages and never before them", () => {
332
- publishBridgeApi().registerRows({ id: "loon.page", category: "Loon page", rows: loonPage });
333
-
334
- const host = menuHost();
335
- host.build();
336
-
337
- assert.deepEqual(
338
- host.view().pages(),
339
- ["Cards", "Banner", "Layout", "Loon page"],
340
- "the host's three pages come first, then the provider's own page exactly once",
341
- );
342
- assert.equal(host.view().pane, "categories", "a menu of pages is still the categories pane");
343
- assert.deepEqual(getCaptureDiagnostics().lastOutcome, { kind: "injected", injectedRows: 3 });
344
- assert.equal(host.invalidateCount(), 1, "the adapter is invalidated once, as before");
345
- });
346
-
347
- test("the new page holds its toggle and its button, in the provider's order", () => {
348
- publishBridgeApi().registerRows({ id: "loon.page", category: "Loon page", rows: loonPage });
349
-
350
- const host = menuHost();
351
- host.build();
352
- const rows = host.view().rowsOf("Loon page");
353
-
354
- assert.deepEqual(
355
- rows.map((row) => labelText(row.label)),
356
- ["Loon heading", "Loon heading", "Loon repaint"],
357
- "the toggle's two values and then the button, in the order the provider gave",
358
- );
359
- assert.deepEqual(
360
- rows.map((row) => row.choice?.value ?? "button"),
361
- ["default", "tool", "button"],
362
- "the first two are the one setting's values; the last carries no choice at all",
363
- );
364
- assert.deepEqual(
365
- rows.map((row) => row.category),
366
- ["Loon page", "Loon page", "Loon page"],
367
- "the page's own name is on every row the view filters by",
368
- );
369
- assert.equal("choice" in rows[2]!, false, "and a button never gains one");
370
- });
371
-
372
- // ---------------------------------------------------------------------------------------------
373
- // Two providers, one page / an existing page unchanged
374
- // ---------------------------------------------------------------------------------------------
375
-
376
- test("two providers naming the same new page share it, in registration order", () => {
377
- publishBridgeApi().registerRows({
378
- id: "loon.first",
379
- category: "Loon page",
380
- rows: () => [button("Loon alpha")],
381
- });
382
- publishBridgeApi().registerRows({
383
- id: "loon.second",
384
- category: "Loon page",
385
- rows: () => [button("Loon beta")],
386
- });
387
-
388
- const host = menuHost();
389
- host.build();
390
-
391
- assert.deepEqual(
392
- host.view().pages().filter((page) => page === "Loon page"),
393
- ["Loon page"],
394
- "one page, not one per provider",
395
- );
396
- assert.deepEqual(
397
- host.view().rowsOf("Loon page").map((row) => labelText(row.label)),
398
- ["Loon alpha", "Loon beta"],
399
- "both providers' rows land in it, in the order they registered",
400
- );
401
- });
402
-
403
- test("a provider in an existing page still joins it and adds no page (PRESERVE)", () => {
404
- publishBridgeApi().registerRows({
405
- id: "loon.cards",
406
- category: "Cards",
407
- rows: () => [button("Loon card")],
408
- });
409
-
410
- const host = menuHost();
411
- host.build();
412
-
413
- assert.deepEqual(
414
- host.view().pages(),
415
- ["Cards", "Banner", "Layout"],
416
- "the page list is unchanged by a provider that joins an existing page",
417
- );
418
- assert.deepEqual(
419
- host.view().rowsOf("Cards").map((row) => labelText(row.label)),
420
- ["Card bash: builtin", "Card output: builtin", "Loon card"],
421
- "and its row is appended to that page, after the host's own rows",
422
- );
423
- });
424
-
425
- test("a single ROW naming a new page creates it, with the provider's own page untouched", () => {
426
- publishBridgeApi().registerRows({
427
- id: "loon.row-page",
428
- category: "Cards",
429
- rows: () => [
430
- button("Loon card"),
431
- { category: "Loon page", label: "Loon alpha", action: () => {} },
432
- ],
433
- });
434
-
435
- const host = menuHost();
436
- host.build();
437
-
438
- assert.deepEqual(
439
- host.view().pages(),
440
- ["Cards", "Banner", "Layout", "Loon page"],
441
- "the row's OWN category is a page, exactly like a provider's category",
442
- );
443
- assert.deepEqual(
444
- host.view().rowsOf("Loon page").map((row) => labelText(row.label)),
445
- ["Loon alpha"],
446
- "and only that row is in it",
447
- );
448
- assert.deepEqual(
449
- host.view().rowsOf("Cards").map((row) => labelText(row.label)),
450
- ["Card bash: builtin", "Card output: builtin", "Loon card"],
451
- "the sibling without its own category stays on the provider's page",
452
- );
453
- });
454
-
455
- // ---------------------------------------------------------------------------------------------
456
- // Keymaps is still closed to plugins
457
- // ---------------------------------------------------------------------------------------------
458
-
459
- test("Keymaps is refused at registration and per row, and never becomes a page", () => {
460
- const api = publishBridgeApi();
461
- assert.throws(
462
- () => api.registerRows({ id: "loon.keymaps", category: "Keymaps", rows: () => [] }),
463
- new RegExp(`reserved category "Keymaps"`),
464
- "a provider standing in Keymaps is refused",
465
- );
466
-
467
- api.registerRows({
468
- id: "loon.mixed",
469
- category: "Cards",
470
- rows: () => [
471
- { category: "Keymaps", label: "Loon bind", action: () => {} },
472
- button("Loon kept"),
473
- ],
474
- });
475
-
476
- const host = menuHost();
477
- host.build();
478
-
479
- assert.equal(
480
- host.view().pages().includes("Keymaps"),
481
- false,
482
- "no key-capture page is created for a plugin",
483
- );
484
- assert.deepEqual(
485
- host.liveRows().filter((row) => row.category === "Keymaps"),
486
- [],
487
- "and no row is left behind on it",
488
- );
489
- assert.deepEqual(
490
- host.view().rowsOf("Cards").map((row) => labelText(row.label)),
491
- ["Card bash: builtin", "Card output: builtin", "Loon kept"],
492
- "the row's siblings still reach their own page",
493
- );
494
- assert.deepEqual(
495
- getRowNameRejections().map((rejection) => [rejection.field, rejection.value, rejection.rule]),
496
- [["category", "Keymaps", "reserved category"]],
497
- `the refusal is reported, with the rule ${JSON.stringify(RESERVED_CATEGORY_RULE)}`,
498
- );
499
- });
500
-
501
- // ---------------------------------------------------------------------------------------------
502
- // The qualification: an incidental row-shaped array is never written to
503
- // ---------------------------------------------------------------------------------------------
504
-
505
- test("an incidental row-shaped array in the same factory is not injected into, and the page still appears", () => {
506
- publishBridgeApi().registerRows({ id: "loon.page", category: "Loon page", rows: loonPage });
507
- const incidental: CustomizeRow[] = [
508
- { category: "Cards", label: "Incidental one", action: () => {} },
509
- { category: "Cards", label: "Incidental two", action: () => {} },
510
- { category: "Banner", label: "Incidental three", action: () => {} },
511
- ];
512
-
513
- const host = menuHost({ incidental });
514
- host.build();
515
-
516
- assert.deepEqual(
517
- incidental.map((row) => labelText(row.label)),
518
- ["Incidental one", "Incidental two", "Incidental three"],
519
- "an array mapped to its labels fails the page-derivation proof and is left byte-identical",
520
- );
521
- assert.ok(
522
- host.view().pages().includes("Loon page"),
523
- "the menu array itself was proven, so its page appears",
524
- );
525
- assert.deepEqual(getCaptureDiagnostics().lastOutcome, { kind: "injected", injectedRows: 3 });
526
- });
527
-
528
- // ---------------------------------------------------------------------------------------------
529
- // Nested capture still cannot steal the outer menu
530
- // ---------------------------------------------------------------------------------------------
531
-
532
- test("a nested custom call that throws does not steal the outer menu's page", () => {
533
- publishBridgeApi().registerRows({ id: "loon.page", category: "Loon page", rows: loonPage });
534
- const nested: CustomizeRow[] = [
535
- { category: "Cards", label: "Nested one", action: () => {} },
536
- { category: "Cards", label: "Nested two", action: () => {} },
537
- { category: "Cards", label: "Nested three", action: () => {} },
538
- ];
539
-
540
- const host = menuHost({ nested });
541
- host.build();
542
-
543
- assert.deepEqual(
544
- nested.map((row) => labelText(row.label)),
545
- ["Nested one", "Nested two", "Nested three"],
546
- "the nested session that threw resolves nothing, so its array receives nothing",
547
- );
548
- assert.deepEqual(
549
- host.view().pages(),
550
- ["Cards", "Banner", "Layout", "Loon page"],
551
- "the outer menu keeps its own capture and its new page",
552
- );
553
- assert.equal(
554
- host.view().rowsOf("Loon page").length,
555
- 3,
556
- "and receives the rows exactly once",
557
- );
558
- });
559
-
560
- // ---------------------------------------------------------------------------------------------
561
- // A deferred host still captures
562
- // ---------------------------------------------------------------------------------------------
563
-
564
- test("a host that invokes the factory in a later microtask still gets its page", async () => {
565
- publishBridgeApi().registerRows({ id: "loon.page", category: "Loon page", rows: loonPage });
566
-
567
- const host = menuHost({ deferred: true });
568
- await host.build();
569
-
570
- assert.deepEqual(
571
- host.view().pages(),
572
- ["Cards", "Banner", "Layout", "Loon page"],
573
- "the probe brackets the factory invocation, so the microtask is covered",
574
- );
575
- assert.deepEqual(getCaptureDiagnostics().lastOutcome, { kind: "injected", injectedRows: 3 });
576
- });
577
-
578
- // ---------------------------------------------------------------------------------------------
579
- // Building the same menu twice
580
- // ---------------------------------------------------------------------------------------------
581
-
582
- test("calling the factory twice neither duplicates the page nor its rows", () => {
583
- publishBridgeApi().registerRows({ id: "loon.page", category: "Loon page", rows: loonPage });
584
-
585
- const host = menuHost();
586
- host.build();
587
- const afterFirst = host.view().pages();
588
- host.build();
589
-
590
- assert.deepEqual(afterFirst, ["Cards", "Banner", "Layout", "Loon page"]);
591
- assert.deepEqual(
592
- host.view().pages(),
593
- ["Cards", "Banner", "Layout", "Loon page"],
594
- "the second build sees the same list, not a second page",
595
- );
596
- assert.deepEqual(
597
- host.view().rowsOf("Loon page").map((row) => labelText(row.label)),
598
- ["Loon heading", "Loon heading", "Loon repaint"],
599
- "and the same three rows",
600
- );
601
- assert.equal(host.invalidateCount(), 1, "the array's marker keeps the injection to one");
602
- assert.deepEqual(getCaptureDiagnostics().lastOutcome, {
603
- kind: "already-injected",
604
- injectedRows: 0,
605
- });
606
- });
607
-
608
- // ---------------------------------------------------------------------------------------------
609
- // /bridge-status still tells the truth about the capture
610
- // ---------------------------------------------------------------------------------------------
611
-
612
- test("/bridge-status reports the injection that delivered the page", () => {
613
- publishBridgeApi().registerRows({ id: "loon.page", category: "Loon page", rows: loonPage });
614
-
615
- const host = menuHost();
616
- host.build();
617
- const lines = formatStatus(collectStatus());
618
-
619
- assert.ok(lines.includes("backend: capture"), "the capture backend is the active one");
620
- assert.ok(
621
- lines.includes("last capture: injected 3 rows"),
622
- "the outcome is the one that put the page in, not a second build",
623
- );
624
-
625
- host.build();
626
- assert.ok(
627
- formatStatus(collectStatus()).includes("last capture: already injected"),
628
- "a rebuild is reported as already injected, exactly as before",
629
- );
630
- });
631
-
632
- // ---------------------------------------------------------------------------------------------
633
- // S20 / JD-A-001..005: the early step is PROVISIONAL and is reconciled with the late decision
634
- // ---------------------------------------------------------------------------------------------
635
-
636
- // JD-A-001: the first proof accepted any array whose mapper echoed its own categories, so an
637
- // incidental array mapped with `row => row.category` (or with a constant equal to every row's
638
- // category) received the rows, blocked the real menu behind `session.injected`, and diagnostics
639
- // still said `injected`.
640
- test("JD-A-001: an incidental array mapped to its categories, or to a constant, is left alone and blocks nothing", () => {
641
- publishBridgeApi().registerRows({ id: "loon.page", category: "Loon page", rows: loonPage });
642
-
643
- const echo: CustomizeRow[] = incidentalRows("Echo", "Cards");
644
- const constant: CustomizeRow[] = incidentalRows("Constant", "Cards");
645
- const echoBefore = [...echo];
646
- const constantBefore = [...constant];
647
-
648
- const host = menuHost({
649
- preBuild: () => {
650
- echo.map((row) => row.category); // `row => row.category`
651
- constant.map(() => "Cards"); // a constant equal to every row's category
652
- },
653
- });
654
- host.build();
655
-
656
- assert.ok(
657
- sameRows(echo, echoBefore),
658
- "an array echoing its own categories never passes the synthetic rows' proof, so it stays byte-identical",
659
- );
660
- assert.ok(
661
- sameRows(constant, constantBefore),
662
- "and neither does an array mapped to a constant, however well it fits those rows",
663
- );
664
- assert.deepEqual(
665
- host.view().pages(),
666
- ["Cards", "Banner", "Layout", "Loon page"],
667
- "the real menu still receives the rows, so its page exists",
668
- );
669
- assert.equal(
670
- host.view().rowsOf("Loon page").length,
671
- 3,
672
- "and holds them once: nothing was left in the incidental arrays",
673
- );
674
- assert.deepEqual(getCaptureDiagnostics().lastOutcome, { kind: "injected", injectedRows: 3 });
675
- });
676
-
677
- // JD-A-002: the array was recorded on the FIRST map call and never proved again, so mapping the
678
- // menu's rows to labels first and to categories later lost the page.
679
- test("JD-A-002: the page still appears when the menu's rows are mapped to labels before their categories", () => {
680
- publishBridgeApi().registerRows({ id: "loon.page", category: "Loon page", rows: loonPage });
681
-
682
- const host = menuHost({ preBuild: (rows) => void rows.map((row) => row.label) });
683
- host.build();
684
-
685
- assert.deepEqual(
686
- host.view().pages(),
687
- ["Cards", "Banner", "Layout", "Loon page"],
688
- "the later category map is proved on its own, not blocked by the earlier label map",
689
- );
690
- assert.equal(host.view().rowsOf("Loon page").length, 3, "and the page holds the provider's rows once");
691
- assert.deepEqual(getCaptureDiagnostics().lastOutcome, { kind: "injected", injectedRows: 3 });
692
- });
693
-
694
- // JD-A-003 (first half): the proof ran the mapper of whatever row-like array it met, so an impure
695
- // mapper ran extra times with nothing bounding the cost. Every bridge row the mapper must now read
696
- // is read exactly twice: once by the pre-push check, once by the host's own map.
697
- test("JD-A-003: an impure mapper reads each bridge row exactly twice, once before it is pushed", () => {
698
- publishBridgeApi().registerRows({ id: "loon.page", category: "Loon page", rows: loonPage });
699
-
700
- const calls = { all: 0, bridge: 0 };
701
- const host = menuHost({
702
- deriveCategories: (rows) =>
703
- [
704
- ...new Set(
705
- rows.map((row) => {
706
- calls.all += 1;
707
- if (labelText(row.label).startsWith("Loon")) calls.bridge += 1;
708
- return row.category ?? "Settings";
709
- }),
710
- ),
711
- ],
712
- });
713
- host.build();
714
-
715
- assert.deepEqual(host.view().pages(), ["Cards", "Banner", "Layout", "Loon page"]);
716
- assert.equal(
717
- calls.bridge,
718
- 6,
719
- "three bridge rows, read twice each: once by the pre-push check, once by the host's own map",
720
- );
721
- assert.equal(
722
- calls.all,
723
- 16,
724
- "four host rows proved, two synthetic proof rows, three rows checked before the push, " +
725
- "seven rows mapped by the host — and nothing beyond that (JD-A-003: the cost is bounded)",
726
- );
727
- });
728
-
729
- // JD-A-003 (second half): the proof ran the mapper over host rows only, so a mapper that throws on
730
- // a bridge row broke the factory AFTER the rows had been appended.
731
- test("JD-A-003: a mapper that throws on a bridge row never meets one, so the factory survives", () => {
732
- publishBridgeApi().registerRows({ id: "loon.page", category: "Loon page", rows: loonPage });
733
-
734
- const host = menuHost({
735
- deriveCategories: (rows) =>
736
- [
737
- ...new Set(
738
- rows.map((row) => {
739
- if (labelText(row.label).startsWith("Loon")) throw new Error("bridge row exploded");
740
- return row.category ?? "Settings";
741
- }),
742
- ),
743
- ],
744
- });
745
-
746
- assert.doesNotThrow(() => host.build(), "the host's own map never reads a row it cannot read");
747
- assert.deepEqual(
748
- host.view().pages(),
749
- ["Cards", "Banner", "Layout"],
750
- "no page is claimed: the host's mapper cannot read a bridge row, so the proof fails and the page list is the host's own",
751
- );
752
- assert.deepEqual(
753
- host.liveRows().map((row) => labelText(row.label)),
754
- [
755
- "Card bash: builtin",
756
- "Card output: builtin",
757
- "Banner: builtin",
758
- "Layout: builtin",
759
- "Loon heading",
760
- "Loon heading",
761
- "Loon repaint",
762
- ],
763
- "the rows still arrive through the late path, after the page list was fixed",
764
- );
765
- assert.deepEqual(getCaptureDiagnostics().lastOutcome, { kind: "injected", injectedRows: 3 });
766
- });
767
-
768
- // JD-A-004: the early injection mutated the array before the factory returned, so a factory that
769
- // mapped categories and then threw left its rows and its marker behind.
770
- test("JD-A-004: a factory that maps categories and then throws leaves the array exactly as it was", () => {
771
- publishBridgeApi().registerRows({ id: "loon.page", category: "Loon page", rows: loonPage });
772
-
773
- const host = menuHost({
774
- postBuild: () => {
775
- throw new Error("factory exploded after the map");
776
- },
777
- });
778
- const before = [...host.liveRows()];
779
-
780
- assert.throws(() => host.build(), /factory exploded after the map/);
781
- assert.ok(sameRows(host.liveRows(), before), "the host's own rows, in the host's own order, and nothing else");
782
- assert.deepEqual(
783
- Object.getOwnPropertySymbols(host.liveRows()),
784
- [],
785
- "no injection marker is left, so a later build may still inject once",
786
- );
787
- assert.deepEqual(getCaptureDiagnostics().lastOutcome, {
788
- kind: "uncaptured",
789
- injectedRows: 0,
790
- reason: "factory-threw",
791
- });
792
- });
793
-
794
- // JD-A-005: an async factory built its view synchronously, received the early rows, and was then
795
- // reported `uncaptured (async-factory)` with the rows still in the array.
796
- test("JD-A-005: a factory that returns a promise keeps nothing and reports the previous outcome", async () => {
797
- publishBridgeApi().registerRows({ id: "loon.page", category: "Loon page", rows: loonPage });
798
-
799
- const host = menuHost({ factoryPromise: true });
800
- const before = [...host.liveRows()];
801
-
802
- const result = host.build();
803
- assert.ok(result instanceof Promise, "the promise is returned unchanged");
804
- await result;
805
-
806
- assert.ok(
807
- sameRows(host.liveRows(), before),
808
- "the early rows went with the guess: no late decision ran, so nothing may stay injected",
809
- );
810
- assert.deepEqual(Object.getOwnPropertySymbols(host.liveRows()), [], "and the marker went with them");
811
- assert.equal(host.invalidateCount(), 0, "nothing was injected, so nothing was invalidated");
812
- assert.deepEqual(getCaptureDiagnostics().lastOutcome, {
813
- kind: "uncaptured",
814
- injectedRows: 0,
815
- reason: "async-factory",
816
- });
817
- });
818
-
819
- // ---------------------------------------------------------------------------------------------
820
- // The net guarantee: after any factory invocation, only the chosen array holds bridge rows
821
- // ---------------------------------------------------------------------------------------------
822
-
823
- test("the net guarantee: a rollback leaves no bridge row and no marker anywhere", async () => {
824
- publishBridgeApi().registerRows({ id: "loon.page", category: "Loon page", rows: loonPage });
825
-
826
- // (a) the factory throws after the guess injected
827
- const incidentalThrow: CustomizeRow[] = incidentalRows("Throw", "Cards");
828
- const throwing = menuHost({
829
- preBuild: () => void incidentalThrow.map((row) => row.label),
830
- postBuild: () => {
831
- throw new Error("factory exploded after the map");
832
- },
833
- });
834
- const throwingBefore = [...throwing.liveRows()];
835
- assert.throws(() => throwing.build(), /factory exploded after the map/);
836
- assert.deepEqual(bridgeLabels(throwing.liveRows()), [], "a throw leaves the host's array free of bridge rows");
837
- assert.deepEqual(bridgeLabels(incidentalThrow), [], "and the incidental array was never touched");
838
- assert.ok(sameRows(throwing.liveRows(), throwingBefore));
839
-
840
- // (b) the factory returns a promise: no late decision will ever run
841
- const incidentalAsync: CustomizeRow[] = incidentalRows("Async", "Cards");
842
- const asyncHost = menuHost({
843
- preBuild: () => void incidentalAsync.map((row) => row.label),
844
- factoryPromise: true,
845
- });
846
- const asyncBefore = [...asyncHost.liveRows()];
847
- await asyncHost.build();
848
- assert.deepEqual(bridgeLabels(asyncHost.liveRows()), [], "an async factory keeps nothing either");
849
- assert.deepEqual(bridgeLabels(incidentalAsync), []);
850
- assert.ok(sameRows(asyncHost.liveRows(), asyncBefore));
851
-
852
- // (c) the late decision is ambiguous: two arrays proved to be the menu's rows
853
- const twin: CustomizeRow[] = incidentalRows("Twin", "Cards");
854
- const twinBefore = [...twin];
855
- const ambiguous = menuHost({ preBuild: () => void twin.map((row) => row.category ?? "Settings") });
856
- const ambiguousBefore = [...ambiguous.liveRows()];
857
- ambiguous.build();
858
- assert.deepEqual(
859
- getCaptureDiagnostics().lastOutcome,
860
- { kind: "uncaptured", injectedRows: 0, reason: "ambiguous" },
861
- "two arrays that both prove themselves are ambiguous, exactly like two row-like arrays",
862
- );
863
- assert.deepEqual(bridgeLabels(ambiguous.liveRows()), [], "so neither of them keeps a bridge row");
864
- assert.deepEqual(bridgeLabels(twin), []);
865
- assert.ok(sameRows(twin, twinBefore));
866
- assert.ok(sameRows(ambiguous.liveRows(), ambiguousBefore));
867
-
868
- // (d) the chosen array is the ONLY one that holds them, and the incidental one beside it is free
869
- const incidentalChosen: CustomizeRow[] = incidentalRows("Chosen", "Cards");
870
- const chosen = menuHost({ preBuild: () => void incidentalChosen.map((row) => row.label) });
871
- chosen.build();
872
- assert.deepEqual(bridgeLabels(chosen.liveRows()), ["Loon heading", "Loon heading", "Loon repaint"]);
873
- assert.deepEqual(bridgeLabels(incidentalChosen), []);
874
- assert.deepEqual(getCaptureDiagnostics().lastOutcome, { kind: "injected", injectedRows: 3 });
875
- });
876
-
877
- // S1 / PRESERVE: the card style line is a ROW the bridge writes into the array it guessed, so a
878
- // rollback has to put the host's own rows — and only those — back.
879
- test("a rolled-back guess gives the host's own Card style rows back untouched (PRESERVE)", async () => {
880
- publishBridgeApi().registerRows({ id: "loon.page", category: "Loon page", rows: loonPage });
881
- registerCardStyle(cardStyle("loon.cute"));
882
-
883
- const midBuild: string[][] = [];
884
- const host = menuHost({
885
- rows: rowsWithHostLine(),
886
- factoryPromise: true,
887
- // Observed INSIDE the factory, while the guess is live: this is the splice the rollback must undo.
888
- postBuild: () => {
889
- midBuild.push(host.liveRows().map((row) => String(row.choice?.value ?? "")));
890
- },
891
- });
892
- const before = [...host.liveRows()];
893
-
894
- await host.build();
895
-
896
- assert.ok(
897
- midBuild[0]?.includes("loon.cute") === true,
898
- "the provisional step did splice the registered value into the host's line",
899
- );
900
- assert.deepEqual(getCaptureDiagnostics().lastOutcome, {
901
- kind: "uncaptured",
902
- injectedRows: 0,
903
- reason: "async-factory",
904
- });
905
- assert.ok(
906
- sameRows(host.liveRows(), before),
907
- "the host's own rows come back by identity, its own Card style rows included",
908
- );
909
- assert.deepEqual(
910
- host.liveRows().filter((row) => row.choice?.value === "loon.cute"),
911
- [],
912
- "the value the provisional splice inserted went with the guess",
913
- );
914
- });
915
-
916
- // S20: "including for nested sessions: an inner factory's rollback must never touch the outer
917
- // session's array."
918
- test("a nested session's rollback never touches the outer session's rows", () => {
919
- publishBridgeApi().registerRows({ id: "loon.page", category: "Loon page", rows: loonPage });
920
-
921
- const inner: CustomizeRow[] = incidentalRows("Inner", "Cards");
922
- const innerBefore = [...inner];
923
- const host = menuHost({
924
- postBuild: () => {
925
- try {
926
- // A nested menu that proves itself, receives the rows, and then throws: its own rollback
927
- // runs in the inner factory's `finally`.
928
- host.ui.custom(() => {
929
- inner.map((row) => row.category ?? "Settings");
930
- throw new Error("nested exploded after its map");
931
- });
932
- } catch {
933
- // the host survives a nested failure and finishes its own menu
934
- }
935
- },
936
- });
937
-
938
- host.build();
939
-
940
- assert.ok(sameRows(inner, innerBefore), "the nested session's array received nothing, permanently");
941
- assert.deepEqual(
942
- host.view().pages(),
943
- ["Cards", "Banner", "Layout", "Loon page"],
944
- "and the outer menu keeps the page its own guess earned",
945
- );
946
- assert.equal(host.view().rowsOf("Loon page").length, 3, "with the rows exactly once");
947
- assert.equal(
948
- bridgeLabels(host.liveRows()).length,
949
- 3,
950
- "the nested rollback removed the inner rows without touching the outer ones",
951
- );
952
- assert.deepEqual(getCaptureDiagnostics().lastOutcome, { kind: "injected", injectedRows: 3 });
953
- });
954
-
955
- // E10: found by the parent after the second review round. The rollback ran inside the `finally`
956
- // BEFORE the session stack was popped and `Array.prototype.map` was restored, and nothing guarded it.
957
- // A host that freezes its rows after the view was built and then throws made the rollback itself
958
- // throw: the host's own error was replaced by a TypeError and the patched `map` stayed installed for
959
- // the whole process, which is exactly what that `finally` exists to prevent.
960
- test("E10: a rollback that cannot touch a frozen array never masks the host's error nor leaks the map patch", () => {
961
- publishBridgeApi().registerRows({ id: "loon.page", category: "Loon page", rows: loonPage });
962
- const nativeMap = Array.prototype.map;
963
- const rows = [...hostRows()];
964
- const host = menuHost({
965
- rows,
966
- postBuild: () => {
967
- Object.freeze(rows);
968
- throw new Error("host exploded after freezing its rows");
969
- },
970
- });
971
-
972
- assert.throws(() => host.build(), /host exploded after freezing its rows/, "the host's own error is the one that surfaces");
973
- assert.equal(Array.prototype.map, nativeMap, "Array.prototype.map is the native one again");
974
- assert.deepEqual(getCaptureDiagnostics().lastOutcome, {
975
- kind: "uncaptured",
976
- injectedRows: 0,
977
- reason: "factory-threw",
978
- });
979
- });