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