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,243 +0,0 @@
1
- /**
2
- * `/bridge-status` for the sidebar surface (S8, S12).
3
- *
4
- * The command must answer "why did my section not appear": the registered sections, their persisted
5
- * visibility, the failures of the most recent sidebar frame, and whether the rail was intercepted at
6
- * all. It must do so WITHOUT running a section's `lines()`/`meta()` — diagnostics never build UI.
7
- *
8
- * The pre-existing fields (providers, backend, last capture, failures, store problems, duplicate
9
- * settings) keep their exact output for a run that has no sections: that is why every new block is
10
- * emitted only when it has something to say.
11
- */
12
-
13
- import assert from "node:assert/strict";
14
- import { mkdtempSync, 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 { registerRows, registerSection, resetBusForTests, resetSectionsForTests } from "../src/bus.ts";
20
- import {
21
- disableSidebarBackend,
22
- installSidebarBackend,
23
- resetSidebarStateForTests,
24
- } from "../src/sidebar.ts";
25
- import {
26
- installSectionRail,
27
- resetSectionFailuresForTests,
28
- } from "../src/sidebar-render.ts";
29
- import { collectStatus, formatStatus } from "../src/status.ts";
30
- import { resetStoreStateForTests } from "../src/store.ts";
31
- import type { RowProvider, SidebarSection } from "../src/types.ts";
32
- import { setSectionVisible } from "../src/visibility.ts";
33
- import {
34
- fakeTui,
35
- registerNativeFooter,
36
- registerNativeRail,
37
- renderSidebar,
38
- resetSidebar,
39
- } from "./fake-sidebar.ts";
40
-
41
- let configHome: string;
42
- let previousConfigHome: string | undefined;
43
-
44
- test.before(() => {
45
- configHome = mkdtempSync(join(tmpdir(), "gsb-status-"));
46
- previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
47
- process.env.GENTLE_PI_CONFIG_HOME = configHome;
48
- });
49
-
50
- test.after(() => {
51
- if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
52
- else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
53
- rmSync(configHome, { recursive: true, force: true });
54
- });
55
-
56
- test.beforeEach(() => {
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
- const statusLines = (): string[] => formatStatus(collectStatus());
75
-
76
- test("the sections table reports id, title, visibility and where the order comes from", () => {
77
- registerSection(section("late", { title: "LATE", order: 5 }));
78
- registerSection(section("plain", { title: "PLAIN" }));
79
- registerSection(section("first", { title: "FIRST", order: 1 }));
80
-
81
- const snapshot = collectStatus();
82
-
83
- assert.deepEqual(snapshot.sections, [
84
- { id: "first", title: "FIRST", visible: true, order: 1, rowLabel: "first" },
85
- { id: "late", title: "LATE", visible: true, order: 5, rowLabel: "late" },
86
- { id: "plain", title: "PLAIN", visible: true, order: "registration", rowLabel: "plain" },
87
- ]);
88
- assert.deepEqual(statusLines().filter((line) => line.includes("FIRST") || line.includes("PLAIN")), [
89
- ' - first "FIRST": visible, order 1, row "first"',
90
- ' - plain "PLAIN": visible, registration order, row "plain"',
91
- ]);
92
- assert.ok(statusLines().includes("sections: 3"));
93
- });
94
-
95
- test("the table shows the persisted visibility, not a guess", () => {
96
- registerSection(section("music", { title: "MUSIC" }));
97
- registerSection(section("quiet", { title: "QUIET", defaultVisible: false }));
98
-
99
- setSectionVisible("music", false);
100
-
101
- assert.deepEqual(
102
- collectStatus().sections?.map((entry) => [entry.id, entry.visible]),
103
- [
104
- ["music", false],
105
- ["quiet", false],
106
- ],
107
- );
108
-
109
- setSectionVisible("quiet", true);
110
- assert.equal(collectStatus().sections?.find((entry) => entry.id === "quiet")?.visible, true);
111
- });
112
-
113
- test("section failures from the last frame are reported with their id and phase", () => {
114
- registerNativeFooter();
115
- installSidebarBackend(() => fakeTui());
116
- installSectionRail();
117
- registerSection(
118
- section("broken", {
119
- title: "BROKEN",
120
- lines: () => {
121
- throw new Error("lines exploded");
122
- },
123
- }),
124
- );
125
- registerSection(section("fine", { title: "FINE" }));
126
-
127
- renderSidebar(40);
128
-
129
- const snapshot = collectStatus();
130
- assert.deepEqual(snapshot.sectionFailures, [
131
- { sectionId: "broken", phase: "lines", message: "lines exploded" },
132
- ]);
133
- const lines = statusLines();
134
- const failuresAt = lines.indexOf("section failures: 1");
135
- assert.ok(failuresAt !== -1, "the failure block is emitted");
136
- assert.deepEqual(lines.slice(failuresAt, failuresAt + 2), [
137
- "section failures: 1",
138
- " - broken (lines): lines exploded",
139
- ]);
140
- });
141
-
142
- test("diagnostics never execute a section's lines() or meta()", () => {
143
- let linesCalls = 0;
144
- let metaCalls = 0;
145
- registerSection(
146
- section("costly", {
147
- title: "COSTLY",
148
- meta: () => {
149
- metaCalls += 1;
150
- return "m";
151
- },
152
- lines: () => {
153
- linesCalls += 1;
154
- return ["never built for a status report"];
155
- },
156
- }),
157
- );
158
-
159
- collectStatus();
160
- formatStatus(collectStatus());
161
-
162
- assert.equal(linesCalls, 0, "a status report must not build section content");
163
- assert.equal(metaCalls, 0);
164
- });
165
-
166
- test("the sidebar state answers the missing-section question", () => {
167
- // Registered but never armed: the report says so instead of staying silent.
168
- registerSection(section("music", { title: "MUSIC" }));
169
- assert.equal(collectStatus().sidebar, undefined);
170
- assert.ok(statusLines().includes("sidebar: not armed"));
171
-
172
- // Intercepted with the part present.
173
- registerNativeRail("todo", { render: () => ["▾ TODO"] });
174
- registerNativeFooter();
175
- assert.equal(installSidebarBackend(() => fakeTui()).outcome, "intercepted");
176
- assert.deepEqual(collectStatus().sidebar, { state: "intercepted", rail: "wrapped" });
177
- assert.ok(statusLines().includes("sidebar: intercepted (rail wrapped)"));
178
-
179
- // Intercepted, but the host has not registered the part we append to yet.
180
- resetSidebar();
181
- resetSidebarStateForTests();
182
- registerNativeRail("todo", { render: () => ["▾ TODO"] });
183
- assert.deepEqual(installSidebarBackend(() => fakeTui()).rail, "pending");
184
- assert.deepEqual(collectStatus().sidebar, { state: "intercepted", rail: "pending" });
185
-
186
- // A tui without rail state: reported, never invented.
187
- resetSidebar();
188
- resetSidebarStateForTests();
189
- installSidebarBackend(() => undefined);
190
- assert.deepEqual(collectStatus().sidebar, { state: "no-sidebar-state" });
191
- assert.ok(statusLines().includes("sidebar: no-sidebar-state"));
192
-
193
- // A resolver that throws keeps its reason.
194
- resetSidebarStateForTests();
195
- installSidebarBackend(() => {
196
- throw new Error("no tui yet");
197
- });
198
- assert.deepEqual(collectStatus().sidebar, {
199
- state: "no-sidebar-state",
200
- reason: "no tui yet",
201
- });
202
-
203
- // Disabled after a successful arm: unarmed again, and the report says so.
204
- resetSidebar();
205
- resetSidebarStateForTests();
206
- registerNativeFooter();
207
- installSidebarBackend(() => fakeTui());
208
- disableSidebarBackend();
209
- assert.deepEqual(collectStatus().sidebar, { state: "unarmed" });
210
- });
211
-
212
- test("the pre-existing fields are still reported alongside the new ones", () => {
213
- const provider: RowProvider = { id: "p", category: "Cards", rows: () => [] };
214
- registerRows(provider);
215
- registerSection(section("music", { title: "MUSIC" }));
216
-
217
- const snapshot = collectStatus();
218
-
219
- assert.deepEqual(snapshot.providers, [{ id: "p", category: "Cards" }]);
220
- assert.equal(snapshot.backend, "none");
221
- assert.deepEqual(snapshot.failures, []);
222
- assert.deepEqual(snapshot.storeProblems, []);
223
-
224
- const lines = statusLines();
225
- assert.ok(lines.includes("providers: 1"));
226
- assert.ok(lines.includes("backend: none"));
227
- assert.ok(lines.includes("last capture: none"));
228
- assert.ok(lines.includes("last failures: none"));
229
- assert.ok(lines.includes("duplicate settings: no build yet"));
230
- assert.ok(lines.includes("sections: 1"));
231
- });
232
-
233
- test("a run with no sections keeps the historical output byte for byte", () => {
234
- assert.deepEqual(statusLines(), [
235
- "gentle-shell-bridge status",
236
- "providers: none",
237
- "backend: none",
238
- "last capture: none",
239
- "last failures: none",
240
- "store problems: none",
241
- "duplicate settings: no build yet",
242
- ]);
243
- });
@@ -1,209 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import test from "node:test";
3
-
4
- import { resetBusForTests, registerRows } from "../src/bus.ts";
5
- import {
6
- getCaptureDiagnostics,
7
- installCaptureBackend,
8
- resetCaptureStateForTests,
9
- } from "../src/capture.ts";
10
- import { resetHookStateForTests, installHookBackend } from "../src/hook.ts";
11
- import { resetRowBuildStateForTests } from "../src/rows.ts";
12
- import { createRowContext } from "../src/rows.ts";
13
- import {
14
- collectStatus,
15
- findDuplicateSettings,
16
- formatStatus,
17
- type StatusSnapshot,
18
- } from "../src/status.ts";
19
- import { resetStoreStateForTests } from "../src/store.ts";
20
- import type { CustomizeRow, RowProvider } from "../src/types.ts";
21
-
22
- const HOOK_KEY = Symbol.for("gentle-pi.customize-row-providers");
23
- const hookSlot = (): Record<symbol, unknown> => globalThis as unknown as Record<symbol, unknown>;
24
-
25
- test.beforeEach(() => {
26
- resetBusForTests();
27
- resetCaptureStateForTests();
28
- resetRowBuildStateForTests();
29
- resetHookStateForTests();
30
- resetStoreStateForTests();
31
- });
32
-
33
- test.afterEach(() => {
34
- delete hookSlot()[HOOK_KEY];
35
- resetHookStateForTests();
36
- });
37
-
38
- // No row-level category: `findDuplicateSettings` must fall back to the provider's category, which
39
- // is what distinguishes the two providers in the cross-category case below.
40
- const choiceRow = (setting: string, value: string): CustomizeRow => ({
41
- label: setting,
42
- choice: { setting, value, current: () => false },
43
- action: () => {},
44
- });
45
-
46
- const settingProvider = (id: string, category: string, setting: string): RowProvider => ({
47
- id,
48
- category,
49
- rows: () => [choiceRow(setting, "a"), choiceRow(setting, "b")],
50
- });
51
-
52
- test("an empty state formats every field as none", () => {
53
- const snapshot = collectStatus();
54
-
55
- assert.deepEqual(snapshot, {
56
- providers: [],
57
- backend: "none",
58
- failures: [],
59
- storeProblems: [],
60
- });
61
- assert.equal(snapshot.duplicates, undefined, "no build has happened, so duplicates are unknown");
62
- assert.deepEqual(formatStatus(snapshot), [
63
- "gentle-shell-bridge status",
64
- "providers: none",
65
- "backend: none",
66
- "last capture: none",
67
- "last failures: none",
68
- "store problems: none",
69
- "duplicate settings: no build yet",
70
- ]);
71
- });
72
-
73
- test("a populated state formats providers, backend, capture, failures, store and duplicates", () => {
74
- const snapshot: StatusSnapshot = {
75
- providers: [
76
- { id: "loon.banner-source", category: "Banner" },
77
- { id: "other.card", category: "Cards" },
78
- ],
79
- backend: "capture",
80
- lastCapture: { kind: "injected", injectedRows: 2 },
81
- failures: [{ providerId: "boom", message: "provider exploded", rowsDropped: true }],
82
- storeProblems: [
83
- {
84
- pluginId: "loon",
85
- path: "/tmp/x/gentle-shell-bridge/loon.json",
86
- kind: "invalid-json",
87
- message: "Unexpected token",
88
- },
89
- ],
90
- duplicates: [
91
- { category: "Banner", setting: "Loon → Banner source", providerIds: ["loon.banner-source", "other.card"] },
92
- ],
93
- };
94
-
95
- assert.deepEqual(formatStatus(snapshot), [
96
- "gentle-shell-bridge status",
97
- "providers: 2",
98
- " - loon.banner-source (Banner)",
99
- " - other.card (Cards)",
100
- "backend: capture",
101
- "last capture: injected 2 rows",
102
- "last failures: 1",
103
- " - boom: provider exploded (rows dropped)",
104
- "store problems: 1",
105
- " - loon (/tmp/x/gentle-shell-bridge/loon.json): invalid-json: Unexpected token (reads fall back to defaults; the file is left as is)",
106
- "duplicate settings: 1",
107
- ' - Banner / "Loon → Banner source": loon.banner-source, other.card',
108
- ]);
109
- });
110
-
111
- test("an uncaptured outcome with a reason is reported, not hidden", () => {
112
- const snapshot: StatusSnapshot = {
113
- providers: [],
114
- backend: "capture",
115
- lastCapture: { kind: "uncaptured", injectedRows: 0, reason: "no-capture" },
116
- failures: [],
117
- storeProblems: [],
118
- duplicates: [],
119
- };
120
-
121
- assert.ok(formatStatus(snapshot).includes("last capture: uncaptured (no-capture)"));
122
- });
123
-
124
- test("collectStatus reports the capture backend and its last outcome and failures", () => {
125
- const providers: RowProvider[] = [
126
- { id: "ok", category: "Cards", rows: () => [{ label: "fine", action: () => {} }] },
127
- {
128
- id: "boom",
129
- category: "Banner",
130
- rows: () => {
131
- throw new Error("provider exploded");
132
- },
133
- },
134
- ];
135
- for (const provider of providers) registerRows(provider);
136
-
137
- const host = {
138
- custom(factory: (ui: unknown, theme: unknown) => unknown) {
139
- return factory(undefined, undefined);
140
- },
141
- };
142
- installCaptureBackend(host);
143
- const rows: CustomizeRow[] = [
144
- { category: "Cards", label: "one", action: () => {} },
145
- { category: "Cards", label: "two", action: () => {} },
146
- { category: "Cards", label: "three", action: () => {} },
147
- ];
148
- host.custom(() => {
149
- rows.map((row) => row.category);
150
- return { invalidate: () => {}, render: () => [], handleInput: () => {}, dispose: () => {} };
151
- });
152
-
153
- const snapshot = collectStatus();
154
-
155
- assert.equal(snapshot.backend, "capture");
156
- assert.deepEqual(snapshot.lastCapture, { kind: "injected", injectedRows: 1 });
157
- assert.deepEqual(snapshot.failures, [
158
- { providerId: "boom", message: "provider exploded", rowsDropped: true },
159
- ]);
160
- assert.equal(getCaptureDiagnostics().armedTargets, 1);
161
- });
162
-
163
- test("collectStatus reports the hook backend when the registry is in use", () => {
164
- hookSlot()[HOOK_KEY] = [];
165
- installHookBackend();
166
-
167
- assert.equal(collectStatus().backend, "hook");
168
- });
169
-
170
- test("duplicate category + setting across two providers is detected", () => {
171
- const providers = [
172
- settingProvider("loon.banner-source", "Banner", "Loon → Banner source"),
173
- settingProvider("other.banner", "Banner", "Loon → Banner source"),
174
- ];
175
-
176
- assert.deepEqual(findDuplicateSettings(providers, createRowContext(undefined)), [
177
- { category: "Banner", setting: "Loon → Banner source", providerIds: ["loon.banner-source", "other.banner"] },
178
- ]);
179
- });
180
-
181
- test("one provider's own toggle rows are not a duplicate", () => {
182
- const providers = [settingProvider("loon.banner-source", "Banner", "Loon → Banner source")];
183
-
184
- assert.deepEqual(findDuplicateSettings(providers, createRowContext(undefined)), []);
185
- });
186
-
187
- test("the same setting in different categories is not a duplicate", () => {
188
- const providers = [
189
- settingProvider("a", "Banner", "Loon → Banner source"),
190
- settingProvider("b", "Cards", "Loon → Banner source"),
191
- ];
192
-
193
- assert.deepEqual(findDuplicateSettings(providers, createRowContext(undefined)), []);
194
- });
195
-
196
- test("a throwing provider is skipped by the duplicate scan instead of breaking it", () => {
197
- const providers: RowProvider[] = [
198
- {
199
- id: "boom",
200
- category: "Banner",
201
- rows: () => {
202
- throw new Error("duplicate scan exploded");
203
- },
204
- },
205
- settingProvider("a", "Banner", "Shared setting"),
206
- ];
207
-
208
- assert.deepEqual(findDuplicateSettings(providers, createRowContext(undefined)), []);
209
- });
@@ -1,192 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import {
3
- existsSync,
4
- mkdirSync,
5
- mkdtempSync,
6
- readFileSync,
7
- readdirSync,
8
- rmSync,
9
- writeFileSync,
10
- } from "node:fs";
11
- import { tmpdir } from "node:os";
12
- import { join } from "node:path";
13
- import test from "node:test";
14
-
15
- import {
16
- createStore,
17
- getStoreDiagnostics,
18
- resetStoreStateForTests,
19
- STORE_SCHEMA_VERSION,
20
- } from "../src/store.ts";
21
-
22
- let configHome: string;
23
- let previousConfigHome: string | undefined;
24
-
25
- const bridgeDir = (): string => join(configHome, "gentle-shell-bridge");
26
- const storePath = (pluginId: string): string => join(bridgeDir(), `${pluginId}.json`);
27
-
28
- test.beforeEach(() => {
29
- configHome = mkdtempSync(join(tmpdir(), "gsb-store-"));
30
- previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
31
- process.env.GENTLE_PI_CONFIG_HOME = configHome;
32
- resetStoreStateForTests();
33
- });
34
-
35
- test.afterEach(() => {
36
- if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
37
- else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
38
- rmSync(configHome, { recursive: true, force: true });
39
- });
40
-
41
- test("round-trips a value through a namespaced file", () => {
42
- const store = createStore("loon");
43
-
44
- assert.equal(store.get("bannerSource", "activity"), "activity");
45
- assert.equal(store.path, storePath("loon"));
46
-
47
- store.set("bannerSource", "output");
48
-
49
- assert.equal(store.get("bannerSource", "activity"), "output");
50
- const document = JSON.parse(readFileSync(storePath("loon"), "utf8"));
51
- assert.equal(document.schemaVersion, STORE_SCHEMA_VERSION);
52
- assert.equal(document.schemaVersion, 1);
53
- assert.deepEqual(document.values, { bannerSource: "output" });
54
- });
55
-
56
- test("a second store instance reads what the first wrote", () => {
57
- createStore("loon").set("bannerSource", "output");
58
-
59
- assert.equal(createStore("loon").get("bannerSource", "activity"), "output");
60
- });
61
-
62
- test("a missing file reads the caller's default and creates nothing", () => {
63
- const store = createStore("loon");
64
-
65
- assert.equal(store.get("bannerSource", "activity"), "activity");
66
- assert.deepEqual(getStoreDiagnostics(), [], "a missing file is not a problem to report");
67
- assert.equal(existsSync(bridgeDir()), false, "reading must not create the bridge directory");
68
- });
69
-
70
- test("malformed JSON reads the default, is reported, and is left untouched", () => {
71
- mkdirSync(bridgeDir(), { recursive: true });
72
- writeFileSync(storePath("loon"), "{ not json", "utf8");
73
-
74
- const store = createStore("loon");
75
-
76
- assert.equal(store.get("bannerSource", "activity"), "activity");
77
- const problems = getStoreDiagnostics();
78
- assert.equal(problems.length, 1);
79
- assert.equal(problems[0].pluginId, "loon");
80
- assert.equal(problems[0].kind, "invalid-json");
81
- assert.equal(problems[0].path, storePath("loon"));
82
- assert.equal(
83
- readFileSync(storePath("loon"), "utf8"),
84
- "{ not json",
85
- "the malformed file is neither repaired nor deleted",
86
- );
87
- });
88
-
89
- test("a later set after a malformed file writes a valid document", () => {
90
- mkdirSync(bridgeDir(), { recursive: true });
91
- writeFileSync(storePath("loon"), "{ not json", "utf8");
92
- const store = createStore("loon");
93
-
94
- store.set("bannerSource", "output");
95
-
96
- const document = JSON.parse(readFileSync(storePath("loon"), "utf8"));
97
- assert.equal(document.schemaVersion, 1);
98
- assert.deepEqual(document.values, { bannerSource: "output" });
99
- assert.equal(store.get("bannerSource", "activity"), "output");
100
- assert.deepEqual(getStoreDiagnostics(), [], "a valid write clears that plugin's problem");
101
- });
102
-
103
- test("a document that is not an object reads the default and is reported", () => {
104
- mkdirSync(bridgeDir(), { recursive: true });
105
- writeFileSync(storePath("loon"), "[1,2,3]", "utf8");
106
-
107
- const store = createStore("loon");
108
-
109
- assert.equal(store.get("bannerSource", "activity"), "activity");
110
- assert.equal(getStoreDiagnostics()[0]?.kind, "not-an-object");
111
- });
112
-
113
- test("an unknown schemaVersion reads the default and is reported", () => {
114
- mkdirSync(bridgeDir(), { recursive: true });
115
- writeFileSync(
116
- storePath("loon"),
117
- JSON.stringify({ schemaVersion: 99, values: { keep: "me" }, extraTopLevel: true }),
118
- "utf8",
119
- );
120
-
121
- const store = createStore("loon");
122
-
123
- assert.equal(store.get("bannerSource", "activity"), "activity");
124
- assert.equal(getStoreDiagnostics()[0]?.kind, "unsupported-schema-version");
125
- });
126
-
127
- test("a later set after an unknown schemaVersion writes only the caller's keys and reports the discarded payload", () => {
128
- mkdirSync(bridgeDir(), { recursive: true });
129
- writeFileSync(
130
- storePath("loon"),
131
- JSON.stringify({ schemaVersion: 99, values: { keep: "me" }, extraTopLevel: true }),
132
- "utf8",
133
- );
134
- const store = createStore("loon");
135
-
136
- store.set("added", 1);
137
-
138
- const document = JSON.parse(readFileSync(storePath("loon"), "utf8"));
139
- assert.equal(document.schemaVersion, 1);
140
- assert.deepEqual(
141
- document.values,
142
- { added: 1 },
143
- "a future schema's payload is never re-labelled as schema 1",
144
- );
145
- assert.equal(
146
- "extraTopLevel" in document,
147
- false,
148
- "keys the bridge does not understand are not carried into the valid document",
149
- );
150
- const problem = getStoreDiagnostics()[0];
151
- assert.equal(problem?.kind, "unsupported-schema-version");
152
- assert.match(problem?.message ?? "", /discarded/);
153
- });
154
-
155
- test("writes are atomic: the temp file is gone and the target parses completely", () => {
156
- const store = createStore("loon");
157
-
158
- for (let index = 0; index < 5; index += 1) {
159
- store.set("counter", index);
160
- assert.deepEqual(readdirSync(bridgeDir()), ["loon.json"], `no temp file after write ${index}`);
161
- assert.equal(JSON.parse(readFileSync(storePath("loon"), "utf8")).values.counter, index);
162
- }
163
- });
164
-
165
- test("two plugin ids never touch each other's file", () => {
166
- const loon = createStore("loon");
167
- const card = createStore("cards");
168
-
169
- loon.set("bannerSource", "output");
170
- card.set("style", "rounded");
171
-
172
- assert.deepEqual(readdirSync(bridgeDir()).sort(), ["cards.json", "loon.json"]);
173
- assert.equal(loon.get("style", "none"), "none");
174
- assert.equal(card.get("bannerSource", "none"), "none");
175
- assert.equal(loon.get("bannerSource", "activity"), "output");
176
- assert.equal(card.get("style", "plain"), "rounded");
177
- });
178
-
179
- test("invalid plugin ids are rejected before any filesystem access", () => {
180
- const invalidIds = ["", ".", "..", "../escape", "a/b", "a\\b", "nested/../escape", "loon..v2"];
181
-
182
- for (const pluginId of invalidIds) {
183
- assert.throws(
184
- () => createStore(pluginId),
185
- /invalid plugin id/,
186
- `plugin id ${JSON.stringify(pluginId)} must be rejected`,
187
- );
188
- }
189
-
190
- assert.equal(existsSync(bridgeDir()), false, "rejection happens before the directory is created");
191
- assert.equal(existsSync(join(configHome, "escape.json")), false, "nothing escaped the bridge dir");
192
- });