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,152 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { afterEach, beforeEach, test } from "node:test";
3
- import { bridgeApi } from "../src/api.ts";
4
- import { resetNoticesForTests, snapshotNotices } from "../src/notices.ts";
5
- import {
6
- KEYMAP_RESOLVER_KEY,
7
- PLUGIN_SURFACE_KEY,
8
- registerCommands,
9
- registerKeymap,
10
- resolveKeymap,
11
- } from "../src/plugin-surface.ts";
12
-
13
- // The surface a plugin writes and the host reads: what a registration publishes, what the
14
- // disposer removes, which ids and labels are refused, and the key resolution that keeps ONE
15
- // implementation of environment-then-saved-then-default.
16
-
17
- interface Surface {
18
- schema?: unknown;
19
- keymaps?: () => unknown;
20
- commands?: () => unknown;
21
- }
22
-
23
- const STATE_KEY = Symbol.for("gentle-shell-bridge/plugin-surface/v1.state");
24
-
25
- /** The published surface, read the way the host reads it. */
26
- function surface(): Surface {
27
- return (globalThis as Record<symbol, unknown>)[PLUGIN_SURFACE_KEY] as Surface;
28
- }
29
-
30
- function readKeymaps(): Record<string, unknown>[] {
31
- return (surface().keymaps?.() ?? []) as Record<string, unknown>[];
32
- }
33
-
34
- function readCommands(): Record<string, unknown>[] {
35
- return (surface().commands?.() ?? []) as Record<string, unknown>[];
36
- }
37
-
38
- beforeEach(() => {
39
- // The surface object is published once per process; the state behind it is what a test resets.
40
- delete (globalThis as Record<symbol, unknown>)[STATE_KEY];
41
- delete (globalThis as Record<symbol, unknown>)[KEYMAP_RESOLVER_KEY];
42
- resetNoticesForTests();
43
- });
44
-
45
- afterEach(() => {
46
- delete (globalThis as Record<symbol, unknown>)[STATE_KEY];
47
- delete (globalThis as Record<symbol, unknown>)[KEYMAP_RESOLVER_KEY];
48
- });
49
-
50
- test("a registration reaches the published surface, and the disposer removes exactly that one", () => {
51
- const dispose = registerKeymap({ id: "panel:open", label: "Open panel", defaultKey: "alt+p" });
52
- assert.deepEqual(readKeymaps(), [{ id: "panel:open", label: "Open panel", defaultKey: "alt+p" }]);
53
- dispose();
54
- assert.deepEqual(readKeymaps(), []);
55
- });
56
-
57
- test("the disposer of a replaced registration never deletes its successor", () => {
58
- const first = registerKeymap({ id: "panel:open", label: "Open panel", defaultKey: "alt+p" });
59
- registerKeymap({ id: "panel:open", label: "Open the panel", defaultKey: "ctrl+p" });
60
- first();
61
- assert.deepEqual(readKeymaps(), [{ id: "panel:open", label: "Open the panel", defaultKey: "ctrl+p" }]);
62
- });
63
-
64
- test("a duplicate id replaces in place and is reported, never silently taken over", () => {
65
- registerKeymap({ id: "panel:open", label: "Open panel" });
66
- registerKeymap({ id: "panel:open", label: "Open the panel" });
67
- assert.deepEqual(readKeymaps().map((entry) => entry.label), ["Open the panel"]);
68
- assert.ok(snapshotNotices().entries.some((entry) => entry.message.includes('keymap "panel:open" was registered twice')));
69
- });
70
-
71
- test("a command: id is refused with the reason, so a plugin cannot claim a palette binding", () => {
72
- assert.throws(
73
- () => registerKeymap({ id: "command:gentle:changes", label: "Fake" }),
74
- /reserved.*command:/u,
75
- );
76
- });
77
-
78
- test("an empty id, label or an optional field of the wrong type is refused before anything is published", () => {
79
- assert.throws(() => registerKeymap({ id: " ", label: "Open panel" }), /id must be a non-empty string/u);
80
- assert.throws(() => registerKeymap({ id: "panel:open", label: "" }), /label must be a non-empty string/u);
81
- assert.throws(
82
- () => registerKeymap({ id: "panel:open", label: "Open panel", defaultKey: 7 as unknown as string }),
83
- /defaultKey must be a non-empty string/u,
84
- );
85
- assert.deepEqual(readKeymaps(), []);
86
- });
87
-
88
- test("the surface answers with a copy: what the host holds cannot change what the bridge owns", () => {
89
- registerKeymap({ id: "panel:open", label: "Open panel" });
90
- const held = readKeymaps();
91
- held.push({ id: "intruder", label: "Intruder" });
92
- assert.deepEqual(readKeymaps().map((entry) => entry.id), ["panel:open"]);
93
- });
94
-
95
- test("registerCommands takes one item or many, joins the section it names, and disposes exactly its own", () => {
96
- const disposeOne = registerCommands({ id: "panel:open", label: "Open panel", command: "panel:open", section: "Session" });
97
- const disposeMany = registerCommands([
98
- { id: "panel:close", label: "Close panel", command: "panel:close" },
99
- { id: "panel:focus", label: "Focus panel", command: "panel:focus", section: "Panel" },
100
- ]);
101
- assert.deepEqual(readCommands().map((entry) => entry.id), ["panel:open", "panel:close", "panel:focus"]);
102
- assert.equal(readCommands()[0]?.section, "Session");
103
- assert.equal(readCommands()[1]?.section, undefined);
104
- disposeOne();
105
- assert.deepEqual(readCommands().map((entry) => entry.id), ["panel:close", "panel:focus"]);
106
- disposeMany();
107
- assert.deepEqual(readCommands(), []);
108
- });
109
-
110
- test("the same id twice in one call is refused whole, so a batch never lands half-registered", () => {
111
- assert.throws(
112
- () => registerCommands([
113
- { id: "panel:close", label: "Close panel", command: "panel:close" },
114
- { id: "panel:close", label: "Close panel again", command: "panel:close" },
115
- ]),
116
- /appears twice in one registration/u,
117
- );
118
- assert.deepEqual(readCommands(), []);
119
- });
120
-
121
- test("a second call with the same id replaces in place and is reported, never a stack", () => {
122
- const first = registerCommands({ id: "panel:open", label: "Open panel", command: "panel:open" });
123
- registerCommands({ id: "panel:open", label: "Open the panel", command: "panel:open" });
124
- assert.deepEqual(readCommands().map((entry) => entry.label), ["Open the panel"]);
125
- first();
126
- assert.deepEqual(readCommands().map((entry) => entry.label), ["Open the panel"]);
127
- assert.ok(snapshotNotices().entries.some((entry) => entry.message.includes('palette item "panel:open" was registered twice')));
128
- });
129
-
130
- test("the published API object carries the three members and stays frozen", () => {
131
- const api = bridgeApi();
132
- assert.ok(api, "the API is published");
133
- assert.equal(typeof api.registerKeymap, "function");
134
- assert.equal(typeof api.registerCommands, "function");
135
- assert.equal(typeof api.resolveKeymap, "function");
136
- assert.ok(Object.isFrozen(api));
137
- });
138
-
139
- test("resolveKeymap says why it cannot answer when no shell publishes a resolver", () => {
140
- const resolution = resolveKeymap("panel:open");
141
- assert.equal(resolution.key, undefined);
142
- assert.match(resolution.unavailable ?? "", /gentle-pi\/keymaps\/v1/u);
143
- });
144
-
145
- test("resolveKeymap answers with the shell's key, and reports a throwing shell instead of throwing", () => {
146
- (globalThis as Record<symbol, unknown>)[KEYMAP_RESOLVER_KEY] = { schema: 1, effectiveKey: () => "ctrl+p" };
147
- assert.deepEqual(resolveKeymap("panel:open"), { key: "ctrl+p" });
148
- (globalThis as Record<symbol, unknown>)[KEYMAP_RESOLVER_KEY] = { schema: 1, effectiveKey: () => { throw new Error("boom"); } };
149
- assert.match(resolveKeymap("panel:open").unavailable ?? "", /boom/u);
150
- (globalThis as Record<symbol, unknown>)[KEYMAP_RESOLVER_KEY] = { schema: 2, effectiveKey: () => "ctrl+p" };
151
- assert.match(resolveKeymap("panel:open").unavailable ?? "", /does not publish its keymap resolver/u);
152
- });
@@ -1,459 +0,0 @@
1
- /**
2
- * T12/T13 — the RUNTIME presence gate for the required host package (S9).
3
- *
4
- * `gentle-pi` is the package that owns `/gentle:customize`, the surface this bridge feeds. Its
5
- * presence is never read from disk: the sanctioned check is `pi.getCommands()` including the
6
- * `gentle:customize` command, the same precedent gentle-shell itself uses
7
- * (`extensions/gentle-design.ts:137`, `extensions/gentle-guide.ts:62`). All extensions are loaded
8
- * before `session_start`, so that event is the moment the answer is real.
9
- *
10
- * What is asserted here is only what a user or a plugin can observe: whether a plugin's row reaches
11
- * the host's menu, whether the ONE tool-design resolver was registered, whether the sidebar widget
12
- * factory and the footer status surface were handed out, exactly which `ctx.ui.notify` messages
13
- * appeared, and the `/bridge-status` lines. No internal field is read.
14
- */
15
-
16
- import assert from "node:assert/strict";
17
- import { mkdtempSync, rmSync } from "node:fs";
18
- import { tmpdir } from "node:os";
19
- import { join } from "node:path";
20
- import test from "node:test";
21
-
22
- import { bridgeApi } from "../src/api.ts";
23
- import { registerRows, resetBusForTests } from "../src/bus.ts";
24
- import { resetCardStyleRowStateForTests } from "../src/card-style-rows.ts";
25
- import { resetCardStyleTransformForTests } from "../src/card-style-transform.ts";
26
- import { disableCardStyleTransform, resetCardStyleWiringForTests } from "../src/card-style-wiring.ts";
27
- import { getCaptureDiagnostics, resetCaptureStateForTests } from "../src/capture.ts";
28
- import gentleShellBridge, {
29
- STATUS_COMMAND,
30
- type BridgeCommandEntry,
31
- type BridgeSessionHandler,
32
- type BridgeExtensionApi,
33
- } from "../src/extension.ts";
34
- import { resetHookStateForTests } from "../src/hook.ts";
35
- import { resetNoticesForTests } from "../src/notices.ts";
36
- import { getPluginLineReport, resetPluginLinesForTests } from "../src/plugin-lines.ts";
37
- import {
38
- GENTLE_PI_COMMAND,
39
- checkGentlePi,
40
- getGentlePiPresence,
41
- hasGentlePi,
42
- resetPresenceForTests,
43
- } from "../src/presence.ts";
44
- import { resetRepaintForTests } from "../src/repaint.ts";
45
- import { resetRowBuildStateForTests } from "../src/rows.ts";
46
- import { disableSidebarBackend, resetSidebarStateForTests } from "../src/sidebar.ts";
47
- import { resetSectionFailuresForTests } from "../src/sidebar-render.ts";
48
- import { collectStatus, formatStatus } from "../src/status.ts";
49
- import { resetStoreStateForTests } from "../src/store.ts";
50
- import { resetToolDesignRowStateForTests } from "../src/tool-design-rows.ts";
51
- import { registerToolDesign, resetToolDesignsForTests } from "../src/tool-designs.ts";
52
- import { resetToolDesignWiringForTests } from "../src/tool-design-wiring.ts";
53
- import { createFakeHost } from "./fake-host.ts";
54
-
55
- let configHome: string;
56
- let previousConfigHome: string | undefined;
57
-
58
- test.before(() => {
59
- configHome = mkdtempSync(join(tmpdir(), "gsb-presence-"));
60
- previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
61
- process.env.GENTLE_PI_CONFIG_HOME = configHome;
62
- });
63
-
64
- test.after(() => {
65
- if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
66
- else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
67
- rmSync(configHome, { recursive: true, force: true });
68
- });
69
-
70
- test.beforeEach(() => {
71
- resetBusForTests();
72
- resetCaptureStateForTests();
73
- resetCardStyleRowStateForTests();
74
- resetCardStyleTransformForTests();
75
- resetCardStyleWiringForTests();
76
- resetHookStateForTests();
77
- resetNoticesForTests();
78
- resetPluginLinesForTests();
79
- resetRepaintForTests();
80
- resetRowBuildStateForTests();
81
- resetSectionFailuresForTests();
82
- resetSidebarStateForTests();
83
- resetStoreStateForTests();
84
- resetToolDesignRowStateForTests();
85
- resetToolDesignsForTests();
86
- resetToolDesignWiringForTests();
87
- resetPresenceForTests();
88
- disableCardStyleTransform();
89
- disableSidebarBackend();
90
- });
91
-
92
- // ---------------------------------------------------------------------------------------------
93
- // The harness: the real entry, a fake Pi whose command list can change, and one recording `ctx.ui`
94
- // ---------------------------------------------------------------------------------------------
95
-
96
- interface RecordingWorld {
97
- /** The API object the entry was loaded against; `getCommands` disappears for the absent case. */
98
- pi: BridgeExtensionApi;
99
- start: () => void;
100
- warnings: string[];
101
- widgets: number;
102
- statuses: Array<{ key: string; text?: string }>;
103
- resolvers: unknown[];
104
- host: ReturnType<typeof createFakeHost>;
105
- }
106
-
107
- /**
108
- * Load the real entry against a fake Pi. `getCommands` is a live factory, so a test can model the
109
- * host "showing up" later; passing `undefined` models a host that exposes no command surface at all.
110
- */
111
- const createWorld = (getCommands: (() => unknown) | undefined, rowLabel?: string): RecordingWorld => {
112
- const warnings: string[] = [];
113
- const statuses: Array<{ key: string; text?: string }> = [];
114
- const handlers: BridgeSessionHandler[] = [];
115
- const resolvers: unknown[] = [];
116
- let widgets = 0;
117
- const host = createFakeHost();
118
- const ui = host.ui as Record<string, unknown>;
119
- ui.notify = (message: string) => warnings.push(message);
120
- ui.setWidget = (_key: string, _factory: (tui: unknown, theme: unknown) => unknown) => {
121
- widgets += 1;
122
- };
123
- ui.setStatus = (key: string, text?: string) => statuses.push({ key, text });
124
-
125
- const pi: BridgeExtensionApi = {
126
- on(event, handler) {
127
- if (event === "session_start") handlers.push(handler);
128
- },
129
- registerCommand() {},
130
- registerToolRenderer(resolver) {
131
- resolvers.push(resolver);
132
- },
133
- };
134
- if (getCommands !== undefined) {
135
- // The host is a TRUST BOUNDARY: the cast models a host whose answer violates Pi's own type
136
- // (a non-array, an entry with no name, a throwing call), which is exactly what `./presence.ts`
137
- // re-validates at runtime.
138
- pi.getCommands = getCommands as () => readonly BridgeCommandEntry[];
139
- }
140
- gentleShellBridge(pi);
141
-
142
- if (rowLabel !== undefined) {
143
- registerRows({
144
- id: "presence.probe",
145
- category: "Banner",
146
- rows: () => [{ label: rowLabel, action: () => {} }],
147
- });
148
- registerToolDesign({
149
- tool: "bash",
150
- id: "presence.probe",
151
- label: "Probe",
152
- apply: (lines) => [...lines],
153
- });
154
- }
155
-
156
- return {
157
- pi,
158
- host,
159
- warnings,
160
- resolvers,
161
- get widgets() {
162
- return widgets;
163
- },
164
- statuses,
165
- start: () => {
166
- for (const handler of handlers) handler({}, { hasUI: true, ui });
167
- },
168
- };
169
- };
170
-
171
- /** `/bridge-status` through the command the entry registered, exactly as a user reads it. */
172
- const reportThrough = (): string[] => {
173
- const printed: string[] = [];
174
- // The entry registers exactly one command; a fresh load per report keeps this independent of the
175
- // world's handler bookkeeping.
176
- const commands: Array<{ name: string; handler: (args: string, ctx: unknown) => unknown }> = [];
177
- const pi = {
178
- on() {},
179
- registerCommand(name: string, command: { handler: (args: string, ctx: unknown) => unknown }) {
180
- commands.push({ name, handler: command.handler });
181
- },
182
- } as unknown as BridgeExtensionApi;
183
- gentleShellBridge(pi);
184
- const status = commands.find((command) => command.name === STATUS_COMMAND);
185
- assert.ok(status !== undefined, "the entry registers /bridge-status");
186
- status.handler("", { ui: { notify: (message: string) => printed.push(message) } });
187
- return printed.join("\n").split("\n");
188
- };
189
-
190
- // ---------------------------------------------------------------------------------------------
191
- // T13 — present: the bridge arms exactly as it does today
192
- // ---------------------------------------------------------------------------------------------
193
-
194
- test("present: session_start arms every surface exactly as today", () => {
195
- const world = createWorld(() => [{ name: GENTLE_PI_COMMAND }], "Probe row");
196
-
197
- world.start();
198
-
199
- assert.equal(getCaptureDiagnostics().armedTargets, 1, "the row bridge is armed");
200
- assert.equal(world.resolvers.length, 1, "the ONE tool-design resolver is registered");
201
- assert.ok(world.widgets >= 1, "the sidebar widget factory is handed to the host");
202
- assert.equal(
203
- getPluginLineReport().availability,
204
- "installed",
205
- "the footer status surface is installed, so a plugin's INTEGRATIONS line is painted",
206
- );
207
- assert.deepEqual(world.warnings, [], "a present host needs no warning");
208
-
209
- world.host.buildMenu();
210
- assert.ok(world.host.rendered().includes("Probe row"), "a plugin's row reaches the host's menu");
211
-
212
- const lines = reportThrough();
213
- assert.equal(lines[0], "gentle-shell-bridge status");
214
- assert.equal(lines[1], "gentle-pi: present");
215
- assert.ok(lines.some((line) => line.startsWith("providers: 1")), lines.join("\n"));
216
- });
217
-
218
- test("present: a second session_start changes nothing (arming stays idempotent)", () => {
219
- const world = createWorld(() => [{ name: GENTLE_PI_COMMAND }], "Probe row");
220
-
221
- world.start();
222
- world.start();
223
-
224
- assert.equal(getCaptureDiagnostics().armedTargets, 1);
225
- assert.equal(world.resolvers.length, 1, "one resolver, however many times the event fires");
226
- assert.equal(
227
- getPluginLineReport().availability,
228
- "installed",
229
- "a second event re-installs the same surface, it does not duplicate it",
230
- );
231
- });
232
-
233
- // ---------------------------------------------------------------------------------------------
234
- // T13 — absent: nothing armed, one warning, one honest status report
235
- // ---------------------------------------------------------------------------------------------
236
-
237
- test("absent: nothing is armed, one warning per absence, and the report says missing", () => {
238
- const world = createWorld(undefined, "Probe row");
239
-
240
- world.start();
241
- world.start();
242
- world.start();
243
-
244
- assert.equal(world.warnings.length, 1, "exactly one warning for the same absence");
245
- assert.match(world.warnings[0], /gentle-pi/u, "the warning names the missing package");
246
- assert.match(world.warnings[0], /missing/u);
247
-
248
- assert.equal(getCaptureDiagnostics().armedTargets, 0, "no wrapper is armed");
249
- assert.equal(world.resolvers.length, 0, "no tool-design resolver is registered");
250
- assert.equal(world.widgets, 0, "no sidebar widget factory is handed out");
251
- assert.deepEqual(world.statuses, [], "no footer line: the bridge never claims readiness");
252
- assert.equal(
253
- getPluginLineReport().availability,
254
- "not-armed",
255
- "the INTEGRATIONS surface is never installed, so no plugin is told 'ready'",
256
- );
257
-
258
- world.host.buildMenu();
259
- assert.equal(
260
- world.host.rendered().filter((label) => label === "Probe row").length,
261
- 0,
262
- "a plugin's row is recorded but never reaches the menu",
263
- );
264
-
265
- const lines = reportThrough();
266
- assert.deepEqual(lines.slice(0, 3), [
267
- "gentle-shell-bridge status",
268
- "gentle-pi: missing — the host has no getCommands()",
269
- "bridge: disabled — gentle-pi is required; no rows, sections, card styles or tool designs are armed",
270
- ]);
271
- for (const prefix of [
272
- "providers:",
273
- "sections:",
274
- "sidebar:",
275
- "card styles:",
276
- "tool designs:",
277
- "transforms:",
278
- "plugin lines:",
279
- ]) {
280
- assert.ok(
281
- !lines.some((line) => line.startsWith(prefix)),
282
- `no ${prefix} block while disabled: ${lines.join("\n")}`,
283
- );
284
- }
285
- });
286
-
287
- test("absent: a plugin's registrations still succeed and never throw", () => {
288
- const world = createWorld(undefined);
289
-
290
- assert.doesNotThrow(() =>
291
- bridgeApi()?.registerRows({
292
- id: "presence.offline",
293
- category: "Banner",
294
- rows: () => [{ label: "Offline", action: () => {} }],
295
- }),
296
- );
297
- const api = bridgeApi();
298
- assert.ok(api !== undefined, "the API object is still published while disabled");
299
- assert.equal(typeof api.registerToolDesign, "function");
300
- assert.doesNotThrow(() =>
301
- api.registerToolDesign?.({
302
- tool: "bash",
303
- id: "presence.offline",
304
- label: "Offline",
305
- apply: (lines) => [...lines],
306
- }),
307
- );
308
-
309
- world.start();
310
- world.host.buildMenu();
311
-
312
- assert.equal(getCaptureDiagnostics().armedTargets, 0);
313
- assert.equal(world.resolvers.length, 0);
314
- assert.equal(world.host.rendered().includes("Offline"), false);
315
- });
316
-
317
- // ---------------------------------------------------------------------------------------------
318
- // T13 — the check is a guard, not a crash
319
- // ---------------------------------------------------------------------------------------------
320
-
321
- test("the predicate is true only for a command list that names gentle:customize", () => {
322
- assert.equal(GENTLE_PI_COMMAND, "gentle:customize");
323
- assert.equal(hasGentlePi({ getCommands: () => [{ name: GENTLE_PI_COMMAND }] }), true);
324
- assert.equal(
325
- hasGentlePi({ getCommands: () => [{ name: "other:command" }, { name: GENTLE_PI_COMMAND }] }),
326
- true,
327
- "the list is searched, not required to be a single entry",
328
- );
329
-
330
- for (const [what, host] of [
331
- ["no getCommands", {}],
332
- ["an undefined host", undefined],
333
- ["a null host", null],
334
- ["a non-object host", "gentle-pi"],
335
- ["a non-function getCommands", { getCommands: "gentle:customize" }],
336
- ["a throwing getCommands", { getCommands: () => { throw new Error("host exploded"); } }],
337
- ["a non-array command list", { getCommands: () => "gentle:customize" }],
338
- ["an entry without a name", { getCommands: () => [{ label: GENTLE_PI_COMMAND }] }],
339
- ["a non-object entry", { getCommands: () => ["gentle:customize"] }],
340
- ["a non-string name", { getCommands: () => [{ name: 42 }] }],
341
- ["a near-miss name", { getCommands: () => [{ name: "gentle:customize " }] }],
342
- ["an empty list", { getCommands: () => [] }],
343
- ] as const) {
344
- assert.equal(hasGentlePi(host), false, `${what} means missing`);
345
- }
346
- });
347
-
348
- test("a throwing getCommands on session_start is missing, warned and never armed", () => {
349
- const world = createWorld(() => {
350
- throw new Error("host exploded");
351
- });
352
-
353
- assert.doesNotThrow(() => world.start());
354
-
355
- assert.equal(world.warnings.length, 1);
356
- assert.equal(getCaptureDiagnostics().armedTargets, 0);
357
- assert.equal(world.resolvers.length, 0);
358
- assert.equal(
359
- reportThrough()[1],
360
- "gentle-pi: missing — getCommands() threw: host exploded",
361
- );
362
- });
363
-
364
- // ---------------------------------------------------------------------------------------------
365
- // T13 — a late host: missing, then present
366
- // ---------------------------------------------------------------------------------------------
367
-
368
- test("a late session_start after gentle-pi appears enables the bridge exactly once", () => {
369
- let commands: unknown = [];
370
- const world = createWorld(() => commands, "Probe row");
371
-
372
- world.start();
373
- world.start();
374
- assert.equal(world.warnings.length, 1, "one warning for the one absence");
375
- assert.equal(getCaptureDiagnostics().armedTargets, 0);
376
-
377
- // gentle-pi shows up: the next session_start is the re-check.
378
- commands = [{ name: GENTLE_PI_COMMAND }];
379
- world.start();
380
-
381
- assert.equal(getCaptureDiagnostics().armedTargets, 1, "the row bridge arms on the re-check");
382
- assert.equal(world.resolvers.length, 1, "the resolver registers on the re-check");
383
- assert.ok(world.widgets >= 1, "the sidebar widget factory is installed on the re-check");
384
- assert.equal(getPluginLineReport().availability, "installed");
385
- assert.equal(world.warnings.length, 1, "enabling does not repeat the warning");
386
- world.host.buildMenu();
387
- assert.ok(world.host.rendered().includes("Probe row"));
388
-
389
- const present = reportThrough();
390
- assert.equal(present[1], "gentle-pi: present");
391
-
392
- // Exactly once: arming is idempotent and is not re-run as a second resolver or a second wrapper.
393
- world.start();
394
- assert.equal(getCaptureDiagnostics().armedTargets, 1);
395
- assert.equal(world.resolvers.length, 1);
396
- assert.equal(world.warnings.length, 1);
397
- });
398
-
399
- test("present then missing again warns again for the new absence", () => {
400
- let commands: unknown = [{ name: GENTLE_PI_COMMAND }];
401
- const world = createWorld(() => commands, "Probe row");
402
-
403
- world.start();
404
- assert.deepEqual(world.warnings, []);
405
-
406
- // The host vanishes: the next re-check must report it and warn again, exactly once.
407
- commands = [{ name: "other:command" }];
408
- world.start();
409
- world.start();
410
-
411
- assert.equal(world.warnings.length, 1, "one warning for the second absence");
412
- assert.match(world.warnings[0], /gentle-pi/u);
413
-
414
- const lines = reportThrough();
415
- assert.equal(lines[1], "gentle-pi: missing — getCommands() lists no gentle:customize");
416
- assert.ok(lines.some((line) => line.startsWith("bridge: disabled — gentle-pi is required")));
417
- for (const prefix of ["card styles:", "tool designs:", "plugin lines:", "sidebar:"]) {
418
- assert.ok(
419
- !lines.some((line) => line.startsWith(prefix)),
420
- `a vanished host must not report ${prefix} work: ${lines.join("\n")}`,
421
- );
422
- }
423
- });
424
-
425
- // ---------------------------------------------------------------------------------------------
426
- // The presence record: process-global, absent until the first check
427
- // ---------------------------------------------------------------------------------------------
428
-
429
- test("collectStatus carries no presence field until a check has run", () => {
430
- assert.equal(getGentlePiPresence(), undefined);
431
- assert.equal(collectStatus().gentlePi, undefined);
432
- assert.deepEqual(formatStatus(collectStatus()), [
433
- "gentle-shell-bridge status",
434
- "providers: none",
435
- "backend: none",
436
- "last capture: none",
437
- "last failures: none",
438
- "store problems: none",
439
- "duplicate settings: no build yet",
440
- ]);
441
- });
442
-
443
- test("the presence record is shared by every copy in the process", () => {
444
- const first = checkGentlePi({ getCommands: () => [{ name: GENTLE_PI_COMMAND }] });
445
- assert.deepEqual(first, { present: true, warnNow: false });
446
- assert.deepEqual(getGentlePiPresence(), { present: true });
447
- assert.deepEqual(collectStatus().gentlePi, { present: true });
448
-
449
- // The same process-global slot the next copy reads: no second warning for the same absence.
450
- const second = checkGentlePi({});
451
- assert.equal(second.present, false);
452
- assert.equal(second.warnNow, true, "the absence transition owes the one warning");
453
- const third = checkGentlePi({});
454
- assert.equal(third.warnNow, false, "the same absence is not warned twice");
455
- assert.deepEqual(getGentlePiPresence(), {
456
- present: false,
457
- reason: "the host has no getCommands()",
458
- });
459
- });