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,483 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
- import test from "node:test";
6
-
7
- import { armBridge } from "../src/arm.ts";
8
- import { registerRows, resetBusForTests } from "../src/bus.ts";
9
- import {
10
- disableCaptureBackend,
11
- getCaptureDiagnostics,
12
- installCaptureBackend,
13
- resetCaptureStateForTests,
14
- } from "../src/capture.ts";
15
- import {
16
- getHookDiagnostics,
17
- installHookBackend,
18
- resetHookStateForTests,
19
- } from "../src/hook.ts";
20
- import { resetRowBuildStateForTests } from "../src/rows.ts";
21
- import { collectStatus, formatStatus } from "../src/status.ts";
22
- import { createStore, getStoreDiagnostics, resetStoreStateForTests } from "../src/store.ts";
23
- import type { CustomizeRow, RowProvider } from "../src/types.ts";
24
- import { createFakeHost } from "./fake-host.ts";
25
-
26
- const HOOK_KEY = Symbol.for("gentle-pi.customize-row-providers");
27
- const hookSlot = (): Record<symbol, unknown> => globalThis as unknown as Record<symbol, unknown>;
28
- const setHook = (value: unknown): void => {
29
- hookSlot()[HOOK_KEY] = value;
30
- };
31
-
32
- let configHome: string | undefined;
33
- let previousConfigHome: string | undefined;
34
-
35
- test.beforeEach(() => {
36
- resetBusForTests();
37
- resetCaptureStateForTests();
38
- resetHookStateForTests();
39
- resetStoreStateForTests();
40
- resetRowBuildStateForTests();
41
- delete hookSlot()[HOOK_KEY];
42
- });
43
-
44
- test.afterEach(() => {
45
- disableCaptureBackend();
46
- Array.prototype.map = pristineMap;
47
- delete hookSlot()[HOOK_KEY];
48
- resetHookStateForTests();
49
- if (configHome !== undefined) {
50
- rmSync(configHome, { recursive: true, force: true });
51
- configHome = undefined;
52
- }
53
- if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
54
- else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
55
- });
56
-
57
- const pristineMap: typeof Array.prototype.map = Array.prototype.map;
58
-
59
- const actionRows = (prefix: string): CustomizeRow[] => [
60
- { category: "Cards", label: `${prefix} 1`, action: () => {} },
61
- { category: "Cards", label: `${prefix} 2`, action: () => {} },
62
- { category: "Cards", label: `${prefix} 3`, action: () => {} },
63
- ];
64
-
65
- const labelsProvider = (id: string, category: string, values: string[]): RowProvider => ({
66
- id,
67
- category,
68
- rows: () => values.map((label) => ({ label, action: () => {} })),
69
- });
70
-
71
- const adapter = (counters?: { invalidations: number }) => ({
72
- render: () => [],
73
- handleInput: () => {},
74
- invalidate: () => {
75
- if (counters !== undefined) counters.invalidations += 1;
76
- },
77
- dispose: () => {},
78
- });
79
-
80
- const rowLabels = (rows: CustomizeRow[]): unknown[] => rows.map((row) => row.label);
81
-
82
- type LooseUi = { custom?: (factory: (ui: unknown, theme: unknown) => unknown) => unknown };
83
-
84
- // B1 — silent wrong-target capture.
85
- test("a nested factory that maps rows and throws does not steal the outer menu's capture", () => {
86
- registerRows(labelsProvider("p1", "Cards", ["Provider row"]));
87
- const host = createFakeHost();
88
- installCaptureBackend(host.ui);
89
- const outer = actionRows("outer");
90
- const nested = actionRows("nested");
91
-
92
- host.ui.custom!((_ui, _theme) => {
93
- try {
94
- host.ui.custom!(() => {
95
- nested.map((row) => row.label);
96
- throw new Error("nested factory exploded");
97
- });
98
- } catch {
99
- // the host survives a nested failure
100
- }
101
- outer.map((row) => row.label);
102
- return adapter();
103
- });
104
-
105
- assert.deepEqual(rowLabels(outer), ["outer 1", "outer 2", "outer 3", "Provider row"]);
106
- assert.deepEqual(rowLabels(nested), ["nested 1", "nested 2", "nested 3"]);
107
- assert.deepEqual(getCaptureDiagnostics().lastOutcome, { kind: "injected", injectedRows: 1 });
108
- });
109
-
110
- test("an incidental row-shaped array before the menu is never mistaken for the menu", () => {
111
- registerRows(labelsProvider("p1", "Cards", ["Provider row"]));
112
- const host = createFakeHost();
113
- installCaptureBackend(host.ui);
114
- const incidental = actionRows("incidental");
115
- const menu = actionRows("menu");
116
- const counters = { invalidations: 0 };
117
-
118
- host.ui.custom!(() => {
119
- incidental.map((row) => row.label);
120
- menu.map((row) => row.label);
121
- return adapter(counters);
122
- });
123
-
124
- assert.deepEqual(rowLabels(incidental), ["incidental 1", "incidental 2", "incidental 3"]);
125
- assert.deepEqual(rowLabels(menu), ["menu 1", "menu 2", "menu 3"]);
126
- assert.equal(counters.invalidations, 0, "nothing was injected, so nothing was invalidated");
127
- assert.deepEqual(getCaptureDiagnostics().lastOutcome, {
128
- kind: "uncaptured",
129
- injectedRows: 0,
130
- reason: "ambiguous",
131
- });
132
- });
133
-
134
- // B2 — provider reentry.
135
- test("a provider that opens a nested menu gives that menu its own injection", () => {
136
- const host = createFakeHost();
137
- installCaptureBackend(host.ui);
138
- const nestedMenu = actionRows("nested menu");
139
- const nestedCounters = { invalidations: 0 };
140
- let opened = false;
141
-
142
- registerRows({
143
- id: "reentrant",
144
- category: "Cards",
145
- rows: () => {
146
- if (!opened) {
147
- opened = true;
148
- host.ui.custom!(() => {
149
- nestedMenu.map((row) => row.label);
150
- return adapter(nestedCounters);
151
- });
152
- }
153
- return [{ label: "Reentrant row", action: () => {} }];
154
- },
155
- });
156
-
157
- host.buildMenu();
158
-
159
- assert.deepEqual(
160
- rowLabels(nestedMenu),
161
- ["nested menu 1", "nested menu 2", "nested menu 3", "Reentrant row"],
162
- "the nested menu built during the provider's own rows() received its own injection",
163
- );
164
- assert.equal(nestedCounters.invalidations, 1);
165
- assert.deepEqual(
166
- host.rendered().filter((label) => label === "Reentrant row"),
167
- ["Reentrant row"],
168
- );
169
- assert.deepEqual(getCaptureDiagnostics().lastOutcome, { kind: "injected", injectedRows: 1 });
170
- });
171
-
172
- // B3 — inherited marker.
173
- test("a fresh own custom on an instance that inherits a wrapped prototype rearms", () => {
174
- registerRows(labelsProvider("p1", "Cards", ["Rebind row"]));
175
- const proto = {
176
- custom(factory: (ui: unknown, theme: unknown) => unknown) {
177
- return factory(undefined, {});
178
- },
179
- };
180
- const ui = Object.create(proto) as LooseUi;
181
-
182
- const first = installCaptureBackend(ui);
183
- assert.equal(first.target, "prototype");
184
- assert.equal(first.armed, true);
185
-
186
- // A rebind gives the instance its own, new `custom` while the prototype stays wrapped.
187
- const rows = actionRows("rebound");
188
- const counters = { invalidations: 0 };
189
- ui.custom = (factory) => factory(undefined, {});
190
-
191
- const second = installCaptureBackend(ui);
192
-
193
- assert.equal(second.armed, true, "a new own custom must arm again, not inherit 'armed'");
194
- assert.equal(second.target, "instance");
195
- ui.custom!(() => {
196
- rows.map((row) => row.label);
197
- return adapter(counters);
198
- });
199
- assert.deepEqual(rowLabels(rows), ["rebound 1", "rebound 2", "rebound 3", "Rebind row"]);
200
- assert.equal(counters.invalidations, 1);
201
- });
202
-
203
- // B4 — duplicate injection through unmarkable registries and backend transitions.
204
- test("a frozen register registry receives one provider across two arms", () => {
205
- const accepted: unknown[] = [];
206
- const frozen = Object.freeze({
207
- register(provider: unknown) {
208
- accepted.push(provider);
209
- },
210
- });
211
- setHook(frozen);
212
-
213
- assert.equal(installHookBackend().installed, true);
214
- assert.equal(installHookBackend().installed, true);
215
-
216
- assert.equal(accepted.length, 1, "dedupe must not depend on marking the host registry");
217
- assert.equal(getHookDiagnostics().acceptedProviders, 1);
218
- });
219
-
220
- test("a capture-to-hook transition disables the capture wrapper", () => {
221
- registerRows(labelsProvider("p1", "Cards", ["Once"]));
222
- const host = createFakeHost();
223
- const originalCustom = host.ui.custom;
224
- installCaptureBackend(host.ui);
225
- assert.equal(getCaptureDiagnostics().armedTargets, 1);
226
-
227
- const registry: unknown[] = [];
228
- setHook(registry);
229
- const result = armBridge(host.ui);
230
-
231
- assert.deepEqual(result, { outcome: "hooked", backend: "hook" });
232
- assert.equal(getCaptureDiagnostics().armedTargets, 0, "the previous path is disabled");
233
- assert.equal(host.ui.custom, originalCustom, "the host's own custom is restored");
234
-
235
- host.buildMenu();
236
- assert.deepEqual(
237
- host.rendered().filter((label) => label === "Once"),
238
- [],
239
- "the capture path must not inject a second copy once the hook is active",
240
- );
241
- const pushed = registry[0] as { rows: (ctx?: unknown) => CustomizeRow[] };
242
- assert.deepEqual(
243
- pushed.rows({}).map((row) => row.label),
244
- ["Once"],
245
- "the rows come through the registry, exactly once",
246
- );
247
- });
248
-
249
- // B5 — hook installation failures must be honest.
250
- test("a frozen array registry falls back to capture instead of throwing", () => {
251
- registerRows(labelsProvider("p1", "Cards", ["Fallback row"]));
252
- setHook(Object.freeze([]));
253
- const host = createFakeHost();
254
-
255
- assert.doesNotThrow(() => installHookBackend());
256
- assert.equal(installHookBackend().installed, false);
257
-
258
- const result = armBridge(host.ui);
259
- assert.deepEqual(result, {
260
- outcome: "fallback-capture",
261
- backend: "capture",
262
- reason: "hook-unusable",
263
- });
264
- host.buildMenu();
265
- assert.deepEqual(host.rendered().slice(4), ["Fallback row"]);
266
- });
267
-
268
- test("a throwing push falls back to capture with a recorded reason", () => {
269
- const throwing: unknown[] = [];
270
- Object.defineProperty(throwing, "push", {
271
- value: () => {
272
- throw new Error("push exploded");
273
- },
274
- });
275
- setHook(throwing);
276
-
277
- const result = installHookBackend();
278
-
279
- assert.equal(result.installed, false);
280
- assert.match(String(result.reason), /push exploded/);
281
- assert.equal(getHookDiagnostics().installed, false);
282
- });
283
-
284
- test("a throwing register falls back to capture with a recorded reason", () => {
285
- setHook({
286
- register() {
287
- throw new Error("register exploded");
288
- },
289
- });
290
-
291
- const result = installHookBackend();
292
-
293
- assert.equal(result.installed, false);
294
- assert.match(String(result.reason), /register exploded/);
295
- });
296
-
297
- test("a throwing register getter is reported as unusable, not a crash", () => {
298
- const hostile: Record<string, unknown> = {};
299
- Object.defineProperty(hostile, "register", {
300
- get() {
301
- throw new Error("getter exploded");
302
- },
303
- });
304
- setHook(hostile);
305
-
306
- const result = installHookBackend();
307
-
308
- assert.equal(result.installed, false);
309
- assert.equal(result.outcome, "unusable-hook");
310
- });
311
-
312
- test("a no-op push is not reported as hooked", () => {
313
- const noop: unknown[] = [];
314
- Object.defineProperty(noop, "push", { value: () => 0 });
315
- setHook(noop);
316
-
317
- const result = installHookBackend();
318
-
319
- assert.equal(result.installed, false, "success requires the provider to be accepted");
320
- assert.equal(getHookDiagnostics().installed, false);
321
- assert.equal(getHookDiagnostics().acceptedProviders, 0);
322
- });
323
-
324
- test("a register that returns false is not reported as hooked", () => {
325
- setHook({ register: () => false });
326
-
327
- const result = installHookBackend();
328
-
329
- assert.equal(result.installed, false);
330
- assert.equal(getHookDiagnostics().acceptedProviders, 0);
331
- });
332
-
333
- // B6 — store and status truthfulness.
334
- test("an unknown schema version reads defaults even when the key exists", () => {
335
- configHome = mkdtempSync(join(tmpdir(), "gsb-regression-"));
336
- previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
337
- process.env.GENTLE_PI_CONFIG_HOME = configHome;
338
- const bridgeDir = join(configHome, "gentle-shell-bridge");
339
- mkdirSync(bridgeDir, { recursive: true });
340
- const path = join(bridgeDir, "foreign.json");
341
- writeFileSync(path, JSON.stringify({ schemaVersion: 99, values: { mode: "foreign" } }), "utf8");
342
-
343
- const store = createStore("foreign");
344
-
345
- assert.equal(store.get("mode", "default"), "default");
346
- assert.equal(store.get("absent", "default"), "default");
347
-
348
- store.set("mode", "ours");
349
-
350
- const document = JSON.parse(readFileSync(path, "utf8"));
351
- assert.equal(document.schemaVersion, 1);
352
- assert.equal(document.values.mode, "ours");
353
- });
354
-
355
- test("collectStatus never runs provider builders and reports no build yet", () => {
356
- let calls = 0;
357
- registerRows({
358
- id: "counter",
359
- category: "Cards",
360
- rows: () => {
361
- calls += 1;
362
- return [{ label: "counter row", action: () => {} }];
363
- },
364
- });
365
-
366
- const snapshot = collectStatus();
367
-
368
- assert.equal(calls, 0, "diagnostics must not evaluate providers");
369
- assert.equal(snapshot.duplicates, undefined);
370
- assert.ok(formatStatus(snapshot).includes("duplicate settings: no build yet"));
371
- });
372
-
373
- test("a successful hook fan-out records the same capture outcome shape", () => {
374
- const registry: unknown[] = [];
375
- setHook(registry);
376
- registerRows(labelsProvider("p1", "Cards", ["Hooked row"]));
377
- installHookBackend();
378
- const pushed = registry[0] as { rows: (ctx?: unknown) => CustomizeRow[] };
379
-
380
- const rows = pushed.rows({});
381
- const snapshot = collectStatus();
382
-
383
- assert.equal(rows.length, 1);
384
- assert.equal(snapshot.backend, "hook");
385
- assert.deepEqual(snapshot.lastCapture, { kind: "injected", injectedRows: 1 });
386
- });
387
-
388
- test("removing the host registry clears the hook state and status stops reporting hook", () => {
389
- setHook([]);
390
- assert.equal(installHookBackend().outcome, "hooked");
391
- assert.equal(getHookDiagnostics().installed, true);
392
-
393
- delete hookSlot()[HOOK_KEY];
394
-
395
- assert.equal(getHookDiagnostics().installed, false, "hook state follows the live registry");
396
- assert.equal(installHookBackend().outcome, "no-hook");
397
-
398
- const host = createFakeHost();
399
- assert.equal(armBridge(host.ui).backend, "capture");
400
- assert.equal(collectStatus().backend, "capture");
401
- });
402
-
403
- // F1 — a host that invokes the factory asynchronously must still be captured.
404
- test("a host that invokes the factory in a later microtask still captures", async () => {
405
- registerRows(labelsProvider("p1", "Cards", ["Deferred row"]));
406
- const menu = actionRows("deferred menu");
407
- const ui = {
408
- custom: (factory: (ui: unknown, theme: unknown) => unknown) =>
409
- Promise.resolve().then(() => factory(undefined, {})),
410
- };
411
- installCaptureBackend(ui);
412
-
413
- const result = await ui.custom(() => {
414
- menu.map((row) => row.label);
415
- return adapter();
416
- });
417
-
418
- assert.ok(result !== undefined);
419
- assert.deepEqual(rowLabels(menu), [
420
- "deferred menu 1",
421
- "deferred menu 2",
422
- "deferred menu 3",
423
- "Deferred row",
424
- ]);
425
- assert.deepEqual(getCaptureDiagnostics().lastOutcome, { kind: "injected", injectedRows: 1 });
426
- });
427
-
428
- // F2 — a wrapper retained by foreign code must stop capturing once disabled.
429
- test("a wrapper retained by foreign code is a pass-through after disable", () => {
430
- registerRows(labelsProvider("p1", "Cards", ["Must not appear"]));
431
- const menu = actionRows("after disable");
432
- const host = createFakeHost();
433
- installCaptureBackend(host.ui);
434
- const wrapperA = host.ui.custom;
435
- assert.equal(typeof wrapperA, "function");
436
-
437
- // Foreign code keeps a reference to wrapper A and wraps it in its own `custom`.
438
- const foreign = (factory: (ui: unknown, theme: unknown) => unknown) => wrapperA!(factory);
439
- host.ui.custom = foreign;
440
- installCaptureBackend(host.ui); // wrapper B around `foreign`
441
-
442
- disableCaptureBackend();
443
- disableCaptureBackend();
444
-
445
- host.ui.custom(() => {
446
- menu.map((row) => row.label);
447
- return adapter();
448
- });
449
-
450
- assert.equal(host.ui.custom, foreign, "the foreign wrapper is preserved, not clobbered");
451
- assert.deepEqual(rowLabels(menu), ["after disable 1", "after disable 2", "after disable 3"]);
452
- assert.equal(getCaptureDiagnostics().armedTargets, 0);
453
- });
454
-
455
- // F3 — a future schema's payload must not be re-labelled as schema 1.
456
- test("an unknown-schema payload is not carried into a written schema-1 document", () => {
457
- configHome = mkdtempSync(join(tmpdir(), "gsb-regression-"));
458
- previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
459
- process.env.GENTLE_PI_CONFIG_HOME = configHome;
460
- const bridgeDir = join(configHome, "gentle-shell-bridge");
461
- mkdirSync(bridgeDir, { recursive: true });
462
- const path = join(bridgeDir, "future.json");
463
- writeFileSync(path, JSON.stringify({ schemaVersion: 99, values: { mode: "foreign" } }), "utf8");
464
- const store = createStore("future");
465
-
466
- store.set("other", true);
467
-
468
- const document = JSON.parse(readFileSync(path, "utf8"));
469
- assert.equal(document.schemaVersion, 1);
470
- assert.deepEqual(document.values, { other: true }, "only what the caller set is written");
471
- assert.equal(store.get("mode", "default"), "default", "the foreign payload is not resurrected");
472
- assert.equal(store.get("other", false), true);
473
-
474
- const problems = getStoreDiagnostics();
475
- assert.equal(problems.length, 1);
476
- assert.equal(problems[0].kind, "unsupported-schema-version");
477
- assert.match(problems[0].message, /discarded/i);
478
-
479
- // The file is now a usable schema-1 document, so later writes preserve its values.
480
- store.set("third", 1);
481
- const after = JSON.parse(readFileSync(path, "utf8"));
482
- assert.deepEqual(after.values, { other: true, third: 1 });
483
- });
@@ -1,200 +0,0 @@
1
- /**
2
- * The plugin-facing repaint (T4, retuned by T23 and T36 of `odd/tasks/bash-card-identity.md`): a frame
3
- * for the cards already on screen, not only the next one.
4
- *
5
- * ONE path now, for both kinds of value (`./repaint.ts` explains why): the host's sidebar revision is
6
- * bumped and one DIFFERENTIAL `requestRender()` is asked for.
7
- *
8
- * - `requestRepaint()` — a PLUGIN's value. Its wrapper reads the value on every render pass, so the
9
- * drawn card changes without any cache being dropped; measuring the old invalidate + force on a
10
- * real transcript is why: 753 ms vs 21 ms at 600 children (S13/T23).
11
- * - `requestSurfaceRepaint()` — a BRIDGE-owned row (the restored example `plain`, a section toggle).
12
- * T4 also dropped every mounted component's frame cache (`tui.invalidate()`) and forced the frame
13
- * (`requestRender(true)`); S19/T35 measured that on real pi components at 50/200/600 children and
14
- * proved the rows are byte-identical without either, on the conversation, on a rail host panel and
15
- * on a bridge section hidden/collapsed/expanded. 600 children: 697/306/99 ms with them, 420/16/3
16
- * ms without. `test/surface-repaint-cost.test.ts` bounds the work.
17
- *
18
- * Everything asserted here is the order and the arguments the host actually observes, plus the
19
- * `/bridge-status` record: no timers, no internal fields.
20
- */
21
-
22
- import assert from "node:assert/strict";
23
- import test from "node:test";
24
-
25
- import { bridgeApi } from "../src/api.ts";
26
- import {
27
- HOST_SIDEBAR_CACHE,
28
- getRepaintDiagnostics,
29
- requestRepaint,
30
- requestSurfaceRepaint,
31
- resetRepaintForTests,
32
- setRepaintHost,
33
- } from "../src/repaint.ts";
34
-
35
- interface HostCall {
36
- readonly name: "invalidate" | "requestRender";
37
- readonly args: readonly unknown[];
38
- }
39
-
40
- /**
41
- * The `tui` a host hands the widget factory, as far as a repaint reads it: `invalidate()` and
42
- * `requestRender(force?)`, recorded in the order the bridge calls them.
43
- */
44
- function recordingTui(options: { invalidate?: "present" | "absent" | "throws" } = {}): {
45
- calls: HostCall[];
46
- requestRenderCalls: () => number;
47
- host: Record<string, unknown>;
48
- } {
49
- const calls: HostCall[] = [];
50
- const mode = options.invalidate ?? "present";
51
- const host: Record<string, unknown> = {
52
- terminal: {},
53
- requestRender(force?: boolean): void {
54
- calls.push({ name: "requestRender", args: force === undefined ? [] : [force] });
55
- },
56
- };
57
- if (mode === "present") {
58
- host.invalidate = (): void => {
59
- calls.push({ name: "invalidate", args: [] });
60
- };
61
- }
62
- if (mode === "throws") {
63
- host.invalidate = (): void => {
64
- calls.push({ name: "invalidate", args: [] });
65
- throw new Error("this component refused to drop its frame");
66
- };
67
- }
68
- return {
69
- calls,
70
- requestRenderCalls: () => calls.filter((call) => call.name === "requestRender").length,
71
- host,
72
- };
73
- }
74
-
75
- test.beforeEach(() => {
76
- resetRepaintForTests();
77
- });
78
-
79
- test("T23: requestRepaint asks for ONE differential frame and drops no cache", () => {
80
- const tui = recordingTui();
81
- setRepaintHost(tui.host);
82
-
83
- const trigger = requestRepaint();
84
-
85
- assert.deepEqual(
86
- tui.calls.map((call) => call.name),
87
- ["requestRender"],
88
- "no tui.invalidate(): the plugin's wrapper reads its value on every pass",
89
- );
90
- assert.deepEqual(
91
- tui.calls[0]?.args,
92
- [],
93
- "a differential frame, not a forced full redraw of the whole buffer",
94
- );
95
- assert.deepEqual(trigger, {
96
- cause: "a plugin asked for a repaint",
97
- surfaces: ["conversation", "rail"],
98
- outcome: "repainted",
99
- });
100
- assert.deepEqual(getRepaintDiagnostics().lastTrigger, trigger);
101
-
102
- // The host's own part-owner step still runs: the rail repaints from its revision counter.
103
- const terminal = tui.host.terminal as Record<symbol, { revision: number } | undefined>;
104
- assert.equal(terminal[HOST_SIDEBAR_CACHE]?.revision, 1, "invalidateSidebar(tui)'s one step");
105
- });
106
-
107
- test("T36: the bridge-owned path is one differential frame plus the sidebar revision", () => {
108
- const tui = recordingTui();
109
- setRepaintHost(tui.host);
110
-
111
- const trigger = requestSurfaceRepaint({ cause: "card style → neon", surfaces: ["conversation"] });
112
-
113
- assert.deepEqual(
114
- tui.calls.map((call) => call.name),
115
- ["requestRender"],
116
- "no tui.invalidate(): the wrapper restyles the host's own rows on every pass, so dropping every " +
117
- "Markdown cache changed no row and cost 600 ms at 600 children (S19/T35)",
118
- );
119
- assert.deepEqual(
120
- tui.calls[0]?.args,
121
- [],
122
- "a differential frame, not a forced full redraw: the force rewrote 6.9 MB for the same rows",
123
- );
124
- const terminal = tui.host.terminal as Record<symbol, { revision: number } | undefined>;
125
- assert.equal(terminal[HOST_SIDEBAR_CACHE]?.revision, 1, "invalidateSidebar(tui)'s step is kept");
126
- assert.deepEqual(trigger, {
127
- cause: "card style → neon",
128
- surfaces: ["conversation"],
129
- outcome: "repainted",
130
- });
131
- assert.deepEqual(getRepaintDiagnostics().lastTrigger, trigger);
132
- });
133
-
134
- test("T4: with no host captured the request is reported, not thrown", () => {
135
- const trigger = requestRepaint();
136
-
137
- assert.deepEqual(trigger, {
138
- cause: "a plugin asked for a repaint",
139
- surfaces: ["conversation", "rail"],
140
- outcome: "unavailable",
141
- reason: "no tui has been captured, so no surface can be repainted",
142
- });
143
- assert.equal(getRepaintDiagnostics().hostCaptured, false);
144
- assert.deepEqual(getRepaintDiagnostics().lastTrigger, trigger);
145
- });
146
-
147
- test("T36: an absent or throwing tui.invalidate() cannot cost either path a frame", () => {
148
- const bare = recordingTui({ invalidate: "absent" });
149
- setRepaintHost(bare.host);
150
- assert.equal(requestSurfaceRepaint({ cause: "card style → neon", surfaces: ["rail"] }).outcome, "repainted");
151
- assert.deepEqual(bare.calls.map((call) => call.name), ["requestRender"], "nothing to invalidate");
152
- assert.deepEqual(bare.calls[0]?.args, []);
153
-
154
- resetRepaintForTests();
155
- const hostile = recordingTui({ invalidate: "throws" });
156
- setRepaintHost(hostile.host);
157
- assert.equal(
158
- requestSurfaceRepaint({ cause: "card style → neon", surfaces: ["rail"] }).outcome,
159
- "repainted",
160
- "a refused cache is not a failed frame",
161
- );
162
- assert.deepEqual(
163
- hostile.calls.map((call) => call.name),
164
- ["requestRender"],
165
- "and it is not reached at all: the tree is never walked",
166
- );
167
- assert.deepEqual(hostile.calls[0]?.args, []);
168
-
169
- resetRepaintForTests();
170
- const pluginHostile = recordingTui({ invalidate: "throws" });
171
- setRepaintHost(pluginHostile.host);
172
- assert.equal(requestRepaint().outcome, "repainted");
173
- assert.deepEqual(pluginHostile.calls.map((call) => call.name), ["requestRender"]);
174
- assert.deepEqual(pluginHostile.calls[0]?.args, []);
175
- });
176
-
177
- test("T4: a captured object with no requestRender() is reported as unavailable", () => {
178
- setRepaintHost({ terminal: {}, invalidate: () => {} });
179
-
180
- const trigger = requestRepaint();
181
-
182
- assert.equal(trigger.outcome, "unavailable");
183
- assert.equal(
184
- trigger.reason,
185
- "the captured tui has no requestRender(), so no frame can be asked for",
186
- );
187
- });
188
-
189
- test("T4: the published bridge API carries requestRepaint for a plugin that never imports this package", () => {
190
- const tui = recordingTui();
191
- setRepaintHost(tui.host);
192
-
193
- const api = bridgeApi();
194
- assert.equal(typeof api?.requestRepaint, "function", "the plugin-facing slot publishes it");
195
- api?.requestRepaint?.();
196
-
197
- assert.deepEqual(tui.calls.map((call) => call.name), ["requestRender"]);
198
- assert.deepEqual(tui.calls[0]?.args, []);
199
- assert.equal(getRepaintDiagnostics().lastTrigger?.outcome, "repainted");
200
- });