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,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
|
-
});
|
package/test/status.test.ts
DELETED
|
@@ -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
|
-
});
|
package/test/store.test.ts
DELETED
|
@@ -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
|
-
});
|