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.
- package/LICENSE +202 -0
- package/docs/contract.md +1 -1
- package/package.json +13 -1
- package/src/api.ts +1 -1
- package/.codegraph/.gitignore +0 -5
- package/PRD.md +0 -144
- package/examples/bash-card.ts +0 -1694
- package/gentle-shell-bridge-0.3.0.tgz +0 -0
- package/odd/tasks/palette-sections.md +0 -30
- package/odd/tasks/plugin-keymap.md +0 -32
- package/odd/tasks/plugin-surface-contract.md +0 -55
- package/test/api.test.ts +0 -395
- package/test/apply-on-move.test.ts +0 -477
- package/test/arm.test.ts +0 -220
- package/test/bridge-ready.test.ts +0 -309
- package/test/bus.test.ts +0 -165
- package/test/button-rows.test.ts +0 -409
- package/test/capture.test.ts +0 -356
- package/test/card-style-rows.test.ts +0 -510
- package/test/card-style-transform.test.ts +0 -379
- package/test/card-style-wiring.test.ts +0 -258
- package/test/card-styles-e2e.test.ts +0 -491
- package/test/card-styles.test.ts +0 -324
- package/test/card-transcript-path.test.ts +0 -178
- package/test/config.test.ts +0 -93
- package/test/e2e.test.ts +0 -443
- package/test/example-bash-identity.test.ts +0 -372
- package/test/example-bash-matrix.test.ts +0 -856
- package/test/example-designs.test.ts +0 -734
- package/test/example-neon-transform.test.ts +0 -283
- package/test/example-plain-style.test.ts +0 -1268
- package/test/extension.test.ts +0 -160
- package/test/fake-events.ts +0 -58
- package/test/fake-host.ts +0 -211
- package/test/fake-sidebar.ts +0 -516
- package/test/fake-transcript.ts +0 -486
- package/test/frame-telemetry.test.ts +0 -417
- package/test/hook.test.ts +0 -196
- package/test/manifest.test.ts +0 -105
- package/test/new-category.test.ts +0 -979
- package/test/notices.test.ts +0 -328
- package/test/plugin-integration-lines.test.ts +0 -979
- package/test/plugin-surface.test.ts +0 -152
- package/test/presence.test.ts +0 -459
- package/test/rail-surfaces.test.ts +0 -457
- package/test/rail-width.test.ts +0 -423
- package/test/regression.test.ts +0 -483
- package/test/repaint-api.test.ts +0 -200
- package/test/reserved-categories.test.ts +0 -361
- package/test/row-labels.test.ts +0 -284
- package/test/row-names.test.ts +0 -473
- package/test/row-origin.test.ts +0 -395
- package/test/section-interaction.test.ts +0 -364
- package/test/section-panel.test.ts +0 -403
- package/test/sections-rows.test.ts +0 -291
- package/test/sidebar-backend.test.ts +0 -471
- package/test/sidebar-e2e.test.ts +0 -331
- package/test/sidebar-hover.test.ts +0 -276
- package/test/sidebar-independent.test.ts +0 -349
- package/test/sidebar-render.test.ts +0 -281
- package/test/sidebar-sections.test.ts +0 -245
- package/test/status-card-styles.test.ts +0 -201
- package/test/status-section-frame.test.ts +0 -181
- package/test/status-sections.test.ts +0 -243
- package/test/status.test.ts +0 -209
- package/test/store.test.ts +0 -192
- package/test/surface-repaint-cost.test.ts +0 -520
- package/test/tool-design-chain.test.ts +0 -483
- package/test/tool-design-cost.test.ts +0 -473
- package/test/tool-design-rows.test.ts +0 -359
- package/test/tool-designs.test.ts +0 -745
- package/test/transform-pipeline.test.ts +0 -465
- package/test/transforms-bus.test.ts +0 -233
- package/test/verifier-width-regressions.test.ts +0 -164
- package/tsconfig.json +0 -18
|
@@ -1,349 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Regressions found by independent review, each pinned before it was fixed.
|
|
3
|
-
*
|
|
4
|
-
* B1 — a sparse `lines()` used to take down its healthy siblings (S8).
|
|
5
|
-
* B2 — a prototype-named id could not round-trip through the visibility map (S5).
|
|
6
|
-
* B3 — the status output grew lines for a row-only session (S12).
|
|
7
|
-
*
|
|
8
|
-
* Plus the review's smaller findings: prune records when the host deletes a part, never let a
|
|
9
|
-
* throwing `setWidget` accessor escape the session handler, and document by behaviour that the
|
|
10
|
-
* rail wrapper spreads own ENUMERABLE fields only.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import assert from "node:assert/strict";
|
|
14
|
-
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
15
|
-
import { tmpdir } from "node:os";
|
|
16
|
-
import { join } from "node:path";
|
|
17
|
-
import test from "node:test";
|
|
18
|
-
|
|
19
|
-
import {
|
|
20
|
-
registerSection,
|
|
21
|
-
resetBusForTests,
|
|
22
|
-
resetSectionsForTests,
|
|
23
|
-
snapshotSections,
|
|
24
|
-
} from "../src/bus.ts";
|
|
25
|
-
import { disableCaptureBackend, resetCaptureStateForTests } from "../src/capture.ts";
|
|
26
|
-
import gentleShellBridge, {
|
|
27
|
-
STATUS_COMMAND,
|
|
28
|
-
type BridgeCommandContext,
|
|
29
|
-
type BridgeCommandHandler,
|
|
30
|
-
type BridgeExtensionApi,
|
|
31
|
-
type BridgeSessionHandler,
|
|
32
|
-
} from "../src/extension.ts";
|
|
33
|
-
import { resetHookStateForTests } from "../src/hook.ts";
|
|
34
|
-
import { resetRowBuildStateForTests } from "../src/rows.ts";
|
|
35
|
-
import {
|
|
36
|
-
SIDEBAR_RAIL_KEY,
|
|
37
|
-
disableSidebarBackend,
|
|
38
|
-
getSidebarDiagnostics,
|
|
39
|
-
installSidebarBackend,
|
|
40
|
-
resetSidebarStateForTests,
|
|
41
|
-
setRailContributor,
|
|
42
|
-
} from "../src/sidebar.ts";
|
|
43
|
-
import {
|
|
44
|
-
getSectionFailures,
|
|
45
|
-
installSectionRail,
|
|
46
|
-
resetSectionFailuresForTests,
|
|
47
|
-
} from "../src/sidebar-render.ts";
|
|
48
|
-
import { collectStatus, formatStatus } from "../src/status.ts";
|
|
49
|
-
import { resetStoreStateForTests } from "../src/store.ts";
|
|
50
|
-
import type { SidebarSection } from "../src/types.ts";
|
|
51
|
-
import { getSectionVisibility, isSectionVisible, setSectionVisible } from "../src/visibility.ts";
|
|
52
|
-
import { createFakeHost, type FakeHost } from "./fake-host.ts";
|
|
53
|
-
import {
|
|
54
|
-
cardBody,
|
|
55
|
-
cardTitles,
|
|
56
|
-
fakeState,
|
|
57
|
-
fakeTui,
|
|
58
|
-
registerNativeFooter,
|
|
59
|
-
renderSidebar,
|
|
60
|
-
resetSidebar,
|
|
61
|
-
type Rail,
|
|
62
|
-
} from "./fake-sidebar.ts";
|
|
63
|
-
|
|
64
|
-
const HOOK_KEY = Symbol.for("gentle-pi.customize-row-providers");
|
|
65
|
-
const hookSlot = (): Record<symbol, unknown> => globalThis as unknown as Record<symbol, unknown>;
|
|
66
|
-
|
|
67
|
-
let configHome: string;
|
|
68
|
-
let previousConfigHome: string | undefined;
|
|
69
|
-
|
|
70
|
-
test.before(() => {
|
|
71
|
-
configHome = mkdtempSync(join(tmpdir(), "gsb-independent-"));
|
|
72
|
-
previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
|
|
73
|
-
process.env.GENTLE_PI_CONFIG_HOME = configHome;
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
test.after(() => {
|
|
77
|
-
if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
|
|
78
|
-
else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
|
|
79
|
-
rmSync(configHome, { recursive: true, force: true });
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
test.beforeEach(() => {
|
|
83
|
-
rmSync(join(configHome, "gentle-shell-bridge"), { recursive: true, force: true });
|
|
84
|
-
resetBusForTests();
|
|
85
|
-
resetSectionsForTests();
|
|
86
|
-
resetSidebar();
|
|
87
|
-
resetSidebarStateForTests();
|
|
88
|
-
resetSectionFailuresForTests();
|
|
89
|
-
resetStoreStateForTests();
|
|
90
|
-
resetCaptureStateForTests();
|
|
91
|
-
resetHookStateForTests();
|
|
92
|
-
resetRowBuildStateForTests();
|
|
93
|
-
disableCaptureBackend();
|
|
94
|
-
disableSidebarBackend();
|
|
95
|
-
setRailContributor(undefined);
|
|
96
|
-
delete hookSlot()[HOOK_KEY];
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
const section = (id: string, extra: Partial<SidebarSection> = {}): SidebarSection => ({
|
|
100
|
-
id,
|
|
101
|
-
title: id.toUpperCase(),
|
|
102
|
-
lines: () => [`body ${id}`],
|
|
103
|
-
...extra,
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
const storePath = (): string => join(configHome, "gentle-shell-bridge", "bridge-sections.json");
|
|
107
|
-
|
|
108
|
-
/** Native part first, then the interception and the section renderer. */
|
|
109
|
-
function arm(): void {
|
|
110
|
-
registerNativeFooter();
|
|
111
|
-
installSidebarBackend(() => fakeTui());
|
|
112
|
-
installSectionRail();
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
interface Entry {
|
|
116
|
-
sessionHandlers: BridgeSessionHandler[];
|
|
117
|
-
commands: Array<{ name: string; handler: BridgeCommandHandler }>;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const loadEntry = (): Entry => {
|
|
121
|
-
const sessionHandlers: BridgeSessionHandler[] = [];
|
|
122
|
-
const commands: Entry["commands"] = [];
|
|
123
|
-
const pi: BridgeExtensionApi = {
|
|
124
|
-
// `gentle-pi` is present, so `session_start` arms and reports it (S9/T12).
|
|
125
|
-
getCommands: () => [{ name: "gentle:customize" }],
|
|
126
|
-
on(_event, handler) {
|
|
127
|
-
sessionHandlers.push(handler);
|
|
128
|
-
},
|
|
129
|
-
registerCommand(name, command) {
|
|
130
|
-
commands.push({ name, handler: command.handler });
|
|
131
|
-
},
|
|
132
|
-
};
|
|
133
|
-
gentleShellBridge(pi);
|
|
134
|
-
return { sessionHandlers, commands };
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
const armThroughEntry = (host: FakeHost): Entry => {
|
|
138
|
-
const entry = loadEntry();
|
|
139
|
-
entry.sessionHandlers[0]({}, { hasUI: true, ui: host.ui });
|
|
140
|
-
return entry;
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
const printStatus = (entry: Entry): string[] => {
|
|
144
|
-
const printed: string[] = [];
|
|
145
|
-
const ctx: BridgeCommandContext = { ui: { notify: (message: string) => printed.push(message) } };
|
|
146
|
-
entry.commands.find((command) => command.name === STATUS_COMMAND)?.handler("", ctx);
|
|
147
|
-
return printed.join("\n").split("\n");
|
|
148
|
-
};
|
|
149
|
-
|
|
150
|
-
// ---------------------------------------------------------------------------------------------
|
|
151
|
-
// B1 — a broken section must never take down its siblings or the whole contribution.
|
|
152
|
-
// ---------------------------------------------------------------------------------------------
|
|
153
|
-
|
|
154
|
-
test("B1: a sparse lines() loses only its own body and leaves its siblings rendering", () => {
|
|
155
|
-
registerSection(section("bad", { title: "BAD", lines: () => new Array(2) as unknown as string[] }));
|
|
156
|
-
registerSection(section("good", { title: "GOOD", lines: () => ["good row"] }));
|
|
157
|
-
arm();
|
|
158
|
-
|
|
159
|
-
assert.deepEqual(cardTitles(), ["FOOTER", "BAD", "GOOD"], "the whole contribution still renders");
|
|
160
|
-
assert.deepEqual(cardBody("BAD"), [], "the broken body is dropped");
|
|
161
|
-
assert.deepEqual(cardBody("GOOD"), ["good row"], "the healthy sibling is untouched");
|
|
162
|
-
|
|
163
|
-
const failure = getSectionFailures().find((entry) => entry.sectionId === "bad");
|
|
164
|
-
assert.equal(failure?.phase, "lines");
|
|
165
|
-
assert.match(failure?.message ?? "", /sparse array/u);
|
|
166
|
-
assert.deepEqual(
|
|
167
|
-
getSidebarDiagnostics().lastFailures,
|
|
168
|
-
[],
|
|
169
|
-
"the backend never saw a throwing contributor: the failure stayed inside the section",
|
|
170
|
-
);
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
test("B1: a hole in the middle of the body is a per-section failure, not a crash", () => {
|
|
174
|
-
const sparse: string[] = ["first"];
|
|
175
|
-
sparse[2] = "third"; // length 3, hole at index 1
|
|
176
|
-
registerSection(section("holey", { title: "HOLEY", lines: () => sparse }));
|
|
177
|
-
registerSection(section("good", { title: "GOOD", lines: () => ["still here"] }));
|
|
178
|
-
arm();
|
|
179
|
-
|
|
180
|
-
assert.deepEqual(cardTitles(), ["FOOTER", "HOLEY", "GOOD"]);
|
|
181
|
-
assert.deepEqual(cardBody("HOLEY"), []);
|
|
182
|
-
assert.deepEqual(cardBody("GOOD"), ["still here"]);
|
|
183
|
-
assert.match(getSectionFailures().find((e) => e.sectionId === "holey")?.message ?? "", /sparse/u);
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
test("B1: the backend's own recorded failures reach /bridge-status", () => {
|
|
187
|
-
registerNativeFooter();
|
|
188
|
-
installSidebarBackend(() => fakeTui());
|
|
189
|
-
setRailContributor({
|
|
190
|
-
render: () => {
|
|
191
|
-
throw new Error("contributor exploded");
|
|
192
|
-
},
|
|
193
|
-
});
|
|
194
|
-
renderSidebar(40);
|
|
195
|
-
|
|
196
|
-
const snapshot = collectStatus();
|
|
197
|
-
assert.deepEqual(snapshot.sidebarFailures, [
|
|
198
|
-
{ key: "footer", phase: "contribute", message: "contributor exploded" },
|
|
199
|
-
]);
|
|
200
|
-
const lines = formatStatus(snapshot);
|
|
201
|
-
const at = lines.indexOf("sidebar failures: 1");
|
|
202
|
-
assert.ok(at !== -1, "the block is emitted");
|
|
203
|
-
assert.deepEqual(lines.slice(at, at + 2), [
|
|
204
|
-
"sidebar failures: 1",
|
|
205
|
-
" - footer (contribute): contributor exploded",
|
|
206
|
-
]);
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
// ---------------------------------------------------------------------------------------------
|
|
210
|
-
// B2 — prototype-shaped identities must be rejected at the door and survive the store.
|
|
211
|
-
// ---------------------------------------------------------------------------------------------
|
|
212
|
-
|
|
213
|
-
test("B2: a prototype-shaped or unusable section id is rejected and registers nothing", () => {
|
|
214
|
-
for (const id of ["__proto__", "constructor", "prototype", "", " "]) {
|
|
215
|
-
assert.throws(
|
|
216
|
-
() => registerSection(section(id)),
|
|
217
|
-
/invalid section id/u,
|
|
218
|
-
`id ${JSON.stringify(id)} must be rejected`,
|
|
219
|
-
);
|
|
220
|
-
}
|
|
221
|
-
assert.deepEqual(snapshotSections(), [], "a rejected id must not half-register");
|
|
222
|
-
|
|
223
|
-
registerSection(section("ok"));
|
|
224
|
-
assert.deepEqual(
|
|
225
|
-
snapshotSections().map((entry) => entry.id),
|
|
226
|
-
["ok"],
|
|
227
|
-
"a valid id still registers afterwards",
|
|
228
|
-
);
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
test("B2: visibility survives a prototype-named key and one toggle never erases another", () => {
|
|
232
|
-
setSectionVisible("__proto__", false);
|
|
233
|
-
const map = getSectionVisibility();
|
|
234
|
-
assert.equal(Object.getPrototypeOf(map), null, "no inherited setter can swallow a key");
|
|
235
|
-
assert.equal(isSectionVisible(map, { id: "__proto__" }), false);
|
|
236
|
-
|
|
237
|
-
setSectionVisible("other", true);
|
|
238
|
-
|
|
239
|
-
const after = getSectionVisibility();
|
|
240
|
-
assert.equal(isSectionVisible(after, { id: "__proto__" }), false, "the earlier key survived");
|
|
241
|
-
assert.equal(isSectionVisible(after, { id: "other" }), true);
|
|
242
|
-
|
|
243
|
-
const stored = JSON.parse(readFileSync(storePath(), "utf8"));
|
|
244
|
-
assert.equal(Object.hasOwn(stored.values.sections, "__proto__"), true);
|
|
245
|
-
assert.equal(stored.values.sections["__proto__"], false);
|
|
246
|
-
assert.equal(stored.values.sections.other, true);
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
// ---------------------------------------------------------------------------------------------
|
|
250
|
-
// B3 — a row-only session must keep the pre-feature output, through the real entry.
|
|
251
|
-
// ---------------------------------------------------------------------------------------------
|
|
252
|
-
|
|
253
|
-
test("B3: a row-only session through the entry keeps the pre-feature status output", () => {
|
|
254
|
-
const host = createFakeHost(); // no setWidget: the sidebar cannot arm
|
|
255
|
-
const entry = armThroughEntry(host);
|
|
256
|
-
host.buildMenu();
|
|
257
|
-
|
|
258
|
-
assert.deepEqual(printStatus(entry), [
|
|
259
|
-
"gentle-shell-bridge status",
|
|
260
|
-
"gentle-pi: present",
|
|
261
|
-
"providers: none",
|
|
262
|
-
"backend: capture",
|
|
263
|
-
"last capture: injected 0 rows",
|
|
264
|
-
"last failures: none",
|
|
265
|
-
"store problems: none",
|
|
266
|
-
"duplicate settings: none",
|
|
267
|
-
]);
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
test("B3: the internal Sections provider is never listed, and the rail state only when worth it", () => {
|
|
271
|
-
registerSection(section("music", { title: "MUSIC" }));
|
|
272
|
-
const host = createFakeHost();
|
|
273
|
-
const entry = armThroughEntry(host);
|
|
274
|
-
host.buildMenu();
|
|
275
|
-
|
|
276
|
-
const printed = printStatus(entry);
|
|
277
|
-
assert.equal(printed.includes("bridge.sections"), false, "bridge plumbing is not a plugin provider");
|
|
278
|
-
assert.ok(printed.includes("providers: none"));
|
|
279
|
-
assert.ok(printed.includes("sections: 1"));
|
|
280
|
-
assert.ok(
|
|
281
|
-
printed.some((line) => line.startsWith("sidebar: not armed")),
|
|
282
|
-
"with a section registered, an unreachable rail must be reported",
|
|
283
|
-
);
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
// ---------------------------------------------------------------------------------------------
|
|
287
|
-
// The review's smaller findings.
|
|
288
|
-
// ---------------------------------------------------------------------------------------------
|
|
289
|
-
|
|
290
|
-
test("a part deleted or cleared by the host is no longer reported as wrapped", () => {
|
|
291
|
-
registerNativeFooter();
|
|
292
|
-
installSidebarBackend(() => fakeTui());
|
|
293
|
-
assert.equal(getSidebarDiagnostics().wrappedRails, 1);
|
|
294
|
-
|
|
295
|
-
fakeState().parts.delete(SIDEBAR_RAIL_KEY);
|
|
296
|
-
|
|
297
|
-
assert.equal(getSidebarDiagnostics().wrappedRails, 0, "a deleted part is not a wrapped rail");
|
|
298
|
-
assert.equal(collectStatus().sidebar?.rail, "pending");
|
|
299
|
-
|
|
300
|
-
registerNativeFooter();
|
|
301
|
-
installSidebarBackend(() => fakeTui());
|
|
302
|
-
assert.equal(getSidebarDiagnostics().wrappedRails, 1);
|
|
303
|
-
|
|
304
|
-
fakeState().parts.clear();
|
|
305
|
-
|
|
306
|
-
assert.equal(getSidebarDiagnostics().wrappedRails, 0, "a cleared map wraps nothing");
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
test("a throwing setWidget accessor is reported and never escapes the session handler", () => {
|
|
310
|
-
registerSection(section("music", { title: "MUSIC" }));
|
|
311
|
-
const host = createFakeHost();
|
|
312
|
-
Object.defineProperty(host.ui, "setWidget", {
|
|
313
|
-
configurable: true,
|
|
314
|
-
get() {
|
|
315
|
-
throw new Error("ui exploded");
|
|
316
|
-
},
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
const entry = armThroughEntry(host); // must not throw
|
|
320
|
-
host.buildMenu();
|
|
321
|
-
|
|
322
|
-
assert.ok(host.rendered().includes("music"), "the row side still works");
|
|
323
|
-
const printed = printStatus(entry);
|
|
324
|
-
assert.ok(
|
|
325
|
-
printed.some((line) => line.includes("sidebar: not armed") && line.includes("ui exploded")),
|
|
326
|
-
`expected an unarmed report with the reason, got: ${printed.join(" | ")}`,
|
|
327
|
-
);
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
test("the wrapper carries own enumerable fields only", () => {
|
|
331
|
-
const prototype = { inheritedMethod: () => "from the prototype" };
|
|
332
|
-
const rail = Object.create(prototype) as Rail;
|
|
333
|
-
rail.render = () => ["▾ NATIVE"];
|
|
334
|
-
let backing = "first";
|
|
335
|
-
Object.defineProperty(rail, "live", { get: () => backing, enumerable: true });
|
|
336
|
-
Object.defineProperty(rail, "hiddenField", { value: "not enumerable", enumerable: false });
|
|
337
|
-
registerNativeFooter(rail);
|
|
338
|
-
|
|
339
|
-
installSidebarBackend(() => fakeTui());
|
|
340
|
-
|
|
341
|
-
const wrapped = fakeState().parts.get(SIDEBAR_RAIL_KEY) as Record<string, unknown> | undefined;
|
|
342
|
-
assert.ok(wrapped !== undefined);
|
|
343
|
-
assert.equal(typeof wrapped.render, "function");
|
|
344
|
-
assert.equal("inheritedMethod" in wrapped, false, "prototype methods are not carried");
|
|
345
|
-
assert.equal("hiddenField" in wrapped, false, "non-enumerable fields are not carried");
|
|
346
|
-
assert.equal(wrapped.live, "first", "a getter is snapshotted when the wrapper is built");
|
|
347
|
-
backing = "second";
|
|
348
|
-
assert.equal(wrapped.live, "first", "and it does not track the original afterwards");
|
|
349
|
-
});
|
|
@@ -1,281 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Section rendering on the host's rail (S3, S7, S8), against the fake host.
|
|
3
|
-
*
|
|
4
|
-
* Every assertion here is about FRAMED output: the bridge contributes rows and card groups, and the
|
|
5
|
-
* host's own frame draws the card. A raw string in a rail's output is not a rendered section.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import assert from "node:assert/strict";
|
|
9
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
10
|
-
import { tmpdir } from "node:os";
|
|
11
|
-
import { join } from "node:path";
|
|
12
|
-
import test from "node:test";
|
|
13
|
-
|
|
14
|
-
import { registerSection, resetBusForTests, resetSectionsForTests } from "../src/bus.ts";
|
|
15
|
-
import {
|
|
16
|
-
SIDEBAR_RAIL_KEY,
|
|
17
|
-
disableSidebarBackend,
|
|
18
|
-
installSidebarBackend,
|
|
19
|
-
resetSidebarStateForTests,
|
|
20
|
-
} from "../src/sidebar.ts";
|
|
21
|
-
import {
|
|
22
|
-
MAX_SECTION_BODY_ROWS,
|
|
23
|
-
getSectionFailures,
|
|
24
|
-
installSectionRail,
|
|
25
|
-
resetSectionFailuresForTests,
|
|
26
|
-
} from "../src/sidebar-render.ts";
|
|
27
|
-
import { resetStoreStateForTests } from "../src/store.ts";
|
|
28
|
-
import type { SidebarSection } from "../src/types.ts";
|
|
29
|
-
import { setSectionVisible } from "../src/visibility.ts";
|
|
30
|
-
import {
|
|
31
|
-
cardBody,
|
|
32
|
-
cardTitles,
|
|
33
|
-
fakeState,
|
|
34
|
-
fakeTui,
|
|
35
|
-
registerNativeFooter,
|
|
36
|
-
renderSidebar,
|
|
37
|
-
resetSidebar,
|
|
38
|
-
} from "./fake-sidebar.ts";
|
|
39
|
-
|
|
40
|
-
let configHome: string;
|
|
41
|
-
let previousConfigHome: string | undefined;
|
|
42
|
-
|
|
43
|
-
test.before(() => {
|
|
44
|
-
configHome = mkdtempSync(join(tmpdir(), "gsb-sections-"));
|
|
45
|
-
previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
|
|
46
|
-
process.env.GENTLE_PI_CONFIG_HOME = configHome;
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test.after(() => {
|
|
50
|
-
if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
|
|
51
|
-
else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
|
|
52
|
-
rmSync(configHome, { recursive: true, force: true });
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
test.beforeEach(() => {
|
|
56
|
-
// The visibility store is a real file: each test starts from nothing on disk.
|
|
57
|
-
rmSync(join(configHome, "gentle-shell-bridge"), { recursive: true, force: true });
|
|
58
|
-
resetBusForTests();
|
|
59
|
-
resetSectionsForTests();
|
|
60
|
-
resetSidebar();
|
|
61
|
-
resetSidebarStateForTests();
|
|
62
|
-
resetSectionFailuresForTests();
|
|
63
|
-
resetStoreStateForTests();
|
|
64
|
-
disableSidebarBackend();
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
const section = (id: string, extra: Partial<SidebarSection> = {}): SidebarSection => ({
|
|
68
|
-
id,
|
|
69
|
-
title: id.toUpperCase(),
|
|
70
|
-
lines: () => [`body ${id}`],
|
|
71
|
-
...extra,
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
/** Native host first, then the bridge's interception and the section renderer. */
|
|
75
|
-
function arm(): void {
|
|
76
|
-
registerNativeFooter();
|
|
77
|
-
installSidebarBackend(() => fakeTui());
|
|
78
|
-
installSectionRail();
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const rail = () => fakeState().parts.get(SIDEBAR_RAIL_KEY);
|
|
82
|
-
|
|
83
|
-
test("sections are framed after the native content, in the registered order", () => {
|
|
84
|
-
// Registered out of order on purpose: the T2 snapshot decides, not insertion order.
|
|
85
|
-
registerSection(section("weather", { title: "WEATHER", lines: () => ["18° clear"], order: 2 }));
|
|
86
|
-
registerSection(
|
|
87
|
-
section("music", {
|
|
88
|
-
title: "MUSIC",
|
|
89
|
-
meta: () => "1 playing",
|
|
90
|
-
lines: () => ["track one", "album two"],
|
|
91
|
-
order: 1,
|
|
92
|
-
}),
|
|
93
|
-
);
|
|
94
|
-
arm();
|
|
95
|
-
|
|
96
|
-
assert.deepEqual(cardTitles(), ["FOOTER", "MUSIC", "WEATHER"], "native first, then ordered sections");
|
|
97
|
-
assert.deepEqual(cardBody("MUSIC"), ["track one", "album two"]);
|
|
98
|
-
|
|
99
|
-
const heading = renderSidebar().find((row) => row.includes("MUSIC"));
|
|
100
|
-
assert.ok(heading !== undefined);
|
|
101
|
-
assert.ok(heading.includes("▾ MUSIC"), "the host's frame carries the section title");
|
|
102
|
-
assert.ok(heading.includes("1 playing"), "meta() is drawn as the right-aligned count");
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
test("no body row is ever wider than the width the host passes", () => {
|
|
106
|
-
registerSection(
|
|
107
|
-
section("wide", {
|
|
108
|
-
title: "WIDE",
|
|
109
|
-
meta: () => "a meta string that cannot possibly fit here",
|
|
110
|
-
lines: () => ["a body row that is certainly much too long for a narrow rail"],
|
|
111
|
-
}),
|
|
112
|
-
);
|
|
113
|
-
arm();
|
|
114
|
-
|
|
115
|
-
const width = 24;
|
|
116
|
-
const contribution = rail()?.render(width) ?? [];
|
|
117
|
-
for (const row of contribution) {
|
|
118
|
-
assert.ok(row.length <= width, `contribution row ${JSON.stringify(row)} exceeds ${width}`);
|
|
119
|
-
}
|
|
120
|
-
assert.ok(
|
|
121
|
-
contribution.some((row) => row.endsWith("…")),
|
|
122
|
-
"a clipped row is marked, not silently cut",
|
|
123
|
-
);
|
|
124
|
-
for (const row of renderSidebar(width)) {
|
|
125
|
-
// The host leaves one blank row between two sections; every drawn row must fit the width.
|
|
126
|
-
if (row === "") continue;
|
|
127
|
-
assert.equal(row.length, width, `framed row ${JSON.stringify(row)} does not fit ${width}`);
|
|
128
|
-
}
|
|
129
|
-
assert.ok(cardTitles(width).includes("WIDE"), "the heading survives a width the meta does not fit");
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
test("the body is capped and says what it elided", () => {
|
|
133
|
-
const rows = Array.from({ length: MAX_SECTION_BODY_ROWS + 4 }, (_, index) => `row ${index + 1}`);
|
|
134
|
-
registerSection(section("many", { title: "MANY", lines: () => rows }));
|
|
135
|
-
arm();
|
|
136
|
-
|
|
137
|
-
const body = cardBody("MANY");
|
|
138
|
-
assert.equal(body.length, MAX_SECTION_BODY_ROWS, "the cap is the contract, not a suggestion");
|
|
139
|
-
assert.equal(body.at(-1), `… +${rows.length - (MAX_SECTION_BODY_ROWS - 1)} more`);
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
test("hidden means not built: no card and no lines()/meta() call", () => {
|
|
143
|
-
let linesCalls = 0;
|
|
144
|
-
let metaCalls = 0;
|
|
145
|
-
registerSection(
|
|
146
|
-
section("quiet", {
|
|
147
|
-
title: "QUIET",
|
|
148
|
-
meta: () => {
|
|
149
|
-
metaCalls += 1;
|
|
150
|
-
return "quiet";
|
|
151
|
-
},
|
|
152
|
-
lines: () => {
|
|
153
|
-
linesCalls += 1;
|
|
154
|
-
return ["never built"];
|
|
155
|
-
},
|
|
156
|
-
}),
|
|
157
|
-
);
|
|
158
|
-
arm();
|
|
159
|
-
|
|
160
|
-
assert.deepEqual(cardTitles(), ["FOOTER", "QUIET"]);
|
|
161
|
-
assert.equal(linesCalls, 1);
|
|
162
|
-
assert.equal(metaCalls, 1);
|
|
163
|
-
|
|
164
|
-
setSectionVisible("quiet", false);
|
|
165
|
-
linesCalls = 0;
|
|
166
|
-
metaCalls = 0;
|
|
167
|
-
|
|
168
|
-
assert.deepEqual(cardTitles(), ["FOOTER"], "a hidden section is not drawn");
|
|
169
|
-
assert.equal(linesCalls, 0, "and its body is not built at all");
|
|
170
|
-
assert.equal(metaCalls, 0, "not even its meta");
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
test("a throwing lines() drops that body only and is recorded with the section id", () => {
|
|
174
|
-
registerSection(
|
|
175
|
-
section("broken", {
|
|
176
|
-
title: "BROKEN",
|
|
177
|
-
lines: () => {
|
|
178
|
-
throw new Error("lines exploded");
|
|
179
|
-
},
|
|
180
|
-
}),
|
|
181
|
-
);
|
|
182
|
-
registerSection(section("fine", { title: "FINE", lines: () => ["fine row"] }));
|
|
183
|
-
arm();
|
|
184
|
-
|
|
185
|
-
assert.deepEqual(cardTitles(), ["FOOTER", "BROKEN", "FINE"], "both sections still have a heading");
|
|
186
|
-
assert.deepEqual(cardBody("BROKEN"), [], "the broken body is dropped, not half-drawn");
|
|
187
|
-
assert.deepEqual(cardBody("FINE"), ["fine row"], "the sibling section is untouched");
|
|
188
|
-
assert.deepEqual(cardBody("FOOTER"), ["native row 1", "native row 2"]);
|
|
189
|
-
|
|
190
|
-
const failure = getSectionFailures().find((entry) => entry.sectionId === "broken");
|
|
191
|
-
assert.ok(failure !== undefined, "the failure is on record for /bridge-status");
|
|
192
|
-
assert.equal(failure.phase, "lines");
|
|
193
|
-
assert.equal(failure.message, "lines exploded");
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
test("a throwing meta() drops the body too, per the spec's failure rule", () => {
|
|
197
|
-
registerSection(
|
|
198
|
-
section("badm", {
|
|
199
|
-
title: "BADM",
|
|
200
|
-
meta: () => {
|
|
201
|
-
throw new Error("meta exploded");
|
|
202
|
-
},
|
|
203
|
-
lines: () => ["body that must not be shown"],
|
|
204
|
-
}),
|
|
205
|
-
);
|
|
206
|
-
registerSection(section("good", { title: "GOOD", lines: () => ["good row"] }));
|
|
207
|
-
arm();
|
|
208
|
-
|
|
209
|
-
assert.deepEqual(cardTitles(), ["FOOTER", "BADM", "GOOD"]);
|
|
210
|
-
assert.deepEqual(cardBody("BADM"), []);
|
|
211
|
-
|
|
212
|
-
const failure = getSectionFailures().find((entry) => entry.sectionId === "badm");
|
|
213
|
-
assert.equal(failure?.phase, "meta");
|
|
214
|
-
assert.equal(failure?.message, "meta exploded");
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
test("a non-string line is a failure, never coerced", () => {
|
|
218
|
-
registerSection(
|
|
219
|
-
section("bad", {
|
|
220
|
-
title: "BAD",
|
|
221
|
-
// The shape is wrong on purpose: a plugin can return anything at runtime.
|
|
222
|
-
lines: () => ["fine", 42 as unknown as string],
|
|
223
|
-
}),
|
|
224
|
-
);
|
|
225
|
-
arm();
|
|
226
|
-
|
|
227
|
-
assert.deepEqual(cardTitles(), ["FOOTER", "BAD"]);
|
|
228
|
-
assert.deepEqual(cardBody("BAD"), []);
|
|
229
|
-
assert.equal(getSectionFailures().find((entry) => entry.sectionId === "bad")?.phase, "lines");
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
test("a non-array lines() return is a failure, never coerced", () => {
|
|
233
|
-
registerSection(
|
|
234
|
-
section("notarray", {
|
|
235
|
-
title: "NOTARRAY",
|
|
236
|
-
lines: () => "just a string" as unknown as string[],
|
|
237
|
-
}),
|
|
238
|
-
);
|
|
239
|
-
arm();
|
|
240
|
-
|
|
241
|
-
assert.deepEqual(cardBody("NOTARRAY"), []);
|
|
242
|
-
assert.equal(getSectionFailures().find((entry) => entry.sectionId === "notarray")?.phase, "lines");
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
test("a section registered after the first frame appears on the next one", () => {
|
|
246
|
-
registerSection(section("first", { title: "FIRST" }));
|
|
247
|
-
arm();
|
|
248
|
-
assert.deepEqual(cardTitles(), ["FOOTER", "FIRST"]);
|
|
249
|
-
|
|
250
|
-
registerSection(section("later", { title: "LATER", order: 1 }));
|
|
251
|
-
|
|
252
|
-
assert.deepEqual(cardTitles(), ["FOOTER", "LATER", "FIRST"], "no reinstall, next frame");
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
test("a disposed section leaves no card and no group behind", () => {
|
|
256
|
-
const section$ = section("gone", { title: "GONE" });
|
|
257
|
-
const dispose = registerSection(section$);
|
|
258
|
-
registerSection(section("stays", { title: "STAYS" }));
|
|
259
|
-
arm();
|
|
260
|
-
assert.deepEqual(cardTitles(), ["FOOTER", "GONE", "STAYS"]);
|
|
261
|
-
|
|
262
|
-
dispose();
|
|
263
|
-
|
|
264
|
-
assert.deepEqual(cardTitles(), ["FOOTER", "STAYS"]);
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
test("no visible section contributes nothing, leaving the host's own rows untouched", () => {
|
|
268
|
-
registerSection(section("off", { title: "OFF", defaultVisible: false }));
|
|
269
|
-
arm();
|
|
270
|
-
|
|
271
|
-
assert.deepEqual(rail()?.render(40), ["▾ FOOTER", "native row 1", "native row 2"]);
|
|
272
|
-
assert.deepEqual(rail()?.cardGroups?.(40), { groups: [{ start: 0, end: 3 }] });
|
|
273
|
-
assert.deepEqual(cardTitles(), ["FOOTER"]);
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
test("a non-positive width contributes nothing instead of returning a broken row", () => {
|
|
277
|
-
registerSection(section("any", { title: "ANY" }));
|
|
278
|
-
arm();
|
|
279
|
-
|
|
280
|
-
assert.deepEqual(rail()?.render(0), ["▾ FOOTER", "native row 1", "native row 2"]);
|
|
281
|
-
});
|