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,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
- });