@zenodinh/pi-render 0.0.0-stage → 0.1.0
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 +21 -0
- package/README.md +110 -2
- package/index.ts +279 -0
- package/package.json +66 -5
- package/src/commands/canvas.test.ts +150 -0
- package/src/commands/canvas.ts +57 -0
- package/src/core/code-theme.test.ts +266 -0
- package/src/core/code-theme.ts +275 -0
- package/src/core/log.test.ts +127 -0
- package/src/core/log.ts +57 -0
- package/src/core/paint.test.ts +322 -0
- package/src/core/paint.ts +64 -0
- package/src/core/registry.test.ts +307 -0
- package/src/core/registry.ts +135 -0
- package/src/core/settings.test.ts +183 -0
- package/src/core/settings.ts +119 -0
- package/src/core/types/code-theme.ts +24 -0
- package/src/core/types/host.ts +160 -0
- package/src/core/types/log.ts +28 -0
- package/src/core/types/paint.ts +54 -0
- package/src/core/types/registry.ts +44 -0
- package/src/core/types/settings.ts +31 -0
- package/src/core/types.ts +34 -0
- package/src/renderers/content/artifacts/artifacts.test.ts +199 -0
- package/src/renderers/content/artifacts/cache.ts +234 -0
- package/src/renderers/content/artifacts/cards.test.ts +216 -0
- package/src/renderers/content/artifacts/cards.ts +136 -0
- package/src/renderers/content/artifacts/engines-extra.test.ts +556 -0
- package/src/renderers/content/artifacts/engines.ts +396 -0
- package/src/renderers/content/artifacts/local-binary.test.ts +207 -0
- package/src/renderers/content/artifacts/local-binary.ts +80 -0
- package/src/renderers/content/artifacts/prereqs.ts +128 -0
- package/src/renderers/content/artifacts/server.ts +181 -0
- package/src/renderers/content/code-panel.ts +161 -0
- package/src/renderers/content/image-card.test.ts +170 -0
- package/src/renderers/content/image-card.ts +252 -0
- package/src/renderers/content/index.ts +79 -0
- package/src/renderers/content/json-panel.ts +116 -0
- package/src/renderers/content/panels.test.ts +188 -0
- package/src/renderers/content/table.test.ts +209 -0
- package/src/renderers/content/table.ts +174 -0
- package/src/renderers/content/transformer.test.ts +254 -0
- package/src/renderers/content/types.ts +20 -0
- package/src/renderers/tool/index.ts +113 -0
- package/src/renderers/tool/resolver.test.ts +257 -0
- package/src/renderers/tool/runtime.test.ts +313 -0
- package/src/renderers/tool/runtime.ts +267 -0
- package/src/renderers/tool/specs/bash.test.ts +110 -0
- package/src/renderers/tool/specs/bash.ts +168 -0
- package/src/renderers/tool/specs/codemode.test.ts +212 -0
- package/src/renderers/tool/specs/codemode.ts +248 -0
- package/src/renderers/tool/specs/edit.test.ts +260 -0
- package/src/renderers/tool/specs/edit.ts +213 -0
- package/src/renderers/tool/specs/ls.test.ts +173 -0
- package/src/renderers/tool/specs/ls.ts +136 -0
- package/src/renderers/tool/specs/read.test.ts +340 -0
- package/src/renderers/tool/specs/read.ts +296 -0
- package/src/renderers/tool/specs/search.test.ts +197 -0
- package/src/renderers/tool/specs/search.ts +325 -0
- package/src/renderers/tool/specs/write.test.ts +145 -0
- package/src/renderers/tool/specs/write.ts +142 -0
- package/src/renderers/tool/types.ts +45 -0
- package/themes/dracula-soft.json +81 -0
- package/themes/one-dark.json +80 -0
- package/themes/themes.test.ts +251 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* registry through its seam — createRegistry(store, log), driven with a fake store and fake logger.
|
|
3
|
+
*
|
|
4
|
+
* Every scenario's first meaningful action is a createRegistry call; nothing below the seam (the Map,
|
|
5
|
+
* the validators, readEntry) is imported.
|
|
6
|
+
*
|
|
7
|
+
* invented: the two doubles stand in for T-07 core/settings and T-06 core/log, which land in the same
|
|
8
|
+
* wave; they satisfy the structural contracts and record every call in order.
|
|
9
|
+
* invented: settings documents are hand-built to the SA §3 literal (version + modules map) — T-07 owns
|
|
10
|
+
* the on-disk file, so there is no recorded payload to reuse here.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, expect, test } from "vitest";
|
|
13
|
+
import { createRegistry } from "./registry.ts";
|
|
14
|
+
import type { LogEntry, Logger } from "./types/log.ts";
|
|
15
|
+
import type { ModuleDescriptor, SettingSpec } from "./types/registry.ts";
|
|
16
|
+
import type { SettingsDoc, SettingsStore } from "./types/settings.ts";
|
|
17
|
+
|
|
18
|
+
const EMPTY_DOC: SettingsDoc = { version: 1, modules: {} };
|
|
19
|
+
|
|
20
|
+
const COLLAPSE_AT: SettingSpec = { name: "collapseAt", kind: "number", default: 5 };
|
|
21
|
+
const COMPACT: SettingSpec = { name: "compact", kind: "toggle", default: false };
|
|
22
|
+
const CODE_THEME: SettingSpec = {
|
|
23
|
+
name: "codeTheme",
|
|
24
|
+
kind: "enum",
|
|
25
|
+
values: ["dracula-soft", "one-dark-pro"],
|
|
26
|
+
default: "dracula-soft",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const BASH: ModuleDescriptor = { key: "row.bash", name: "Bash row", defaultEnabled: true, settings: [COLLAPSE_AT] };
|
|
30
|
+
const TABLE: ModuleDescriptor = {
|
|
31
|
+
key: "content.table",
|
|
32
|
+
name: "Table",
|
|
33
|
+
defaultEnabled: true,
|
|
34
|
+
settings: [COLLAPSE_AT, COMPACT],
|
|
35
|
+
};
|
|
36
|
+
const CODETHEME: ModuleDescriptor = {
|
|
37
|
+
key: "codetheme",
|
|
38
|
+
name: "Code theme",
|
|
39
|
+
defaultEnabled: true,
|
|
40
|
+
settings: [CODE_THEME],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
interface FakeStore extends SettingsStore {
|
|
44
|
+
/** Every store interaction in call order — a read-path disk touch shows up here. Required. */
|
|
45
|
+
readonly calls: string[];
|
|
46
|
+
/** Documents handed to save(), in order. Required. */
|
|
47
|
+
readonly saved: SettingsDoc[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function fakeStore(initial: SettingsDoc): FakeStore {
|
|
51
|
+
const calls: string[] = [];
|
|
52
|
+
const saved: SettingsDoc[] = [];
|
|
53
|
+
// The store owns its document (T-07 parses the file per instance), so a write cannot leak between tests.
|
|
54
|
+
let doc = structuredClone(initial);
|
|
55
|
+
return {
|
|
56
|
+
calls,
|
|
57
|
+
saved,
|
|
58
|
+
load() {
|
|
59
|
+
calls.push("load");
|
|
60
|
+
return doc; // cached, like T-07: a re-read must come from an explicit load call, not implicitly
|
|
61
|
+
},
|
|
62
|
+
save(next) {
|
|
63
|
+
calls.push("save");
|
|
64
|
+
doc = next;
|
|
65
|
+
saved.push(next);
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface FakeLog {
|
|
71
|
+
readonly log: Logger;
|
|
72
|
+
readonly entries: LogEntry[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function fakeLog(): FakeLog {
|
|
76
|
+
const entries: LogEntry[] = [];
|
|
77
|
+
const seen = new Set<string>();
|
|
78
|
+
const log: Logger = {
|
|
79
|
+
logLine(scope, message) {
|
|
80
|
+
entries.push({ scope, message, time: 0 });
|
|
81
|
+
},
|
|
82
|
+
logOnce(key, scope, message) {
|
|
83
|
+
if (seen.has(key)) return;
|
|
84
|
+
seen.add(key);
|
|
85
|
+
entries.push({ scope, message, time: 0 });
|
|
86
|
+
},
|
|
87
|
+
drain() {
|
|
88
|
+
return entries.splice(0, entries.length);
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
return { log, entries };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
describe("registry", () => {
|
|
95
|
+
// spec: defineModule(row.bash, defaultEnabled true); setSettings("row.bash", { enabled: false }) ->
|
|
96
|
+
// the NEXT isEnabled("row.bash") is false and the saved doc carries enabled: false.
|
|
97
|
+
// fails_when: a disable never reaches the next read (the predecessor's toggle freeze).
|
|
98
|
+
test("AC-1 a settings write lands on the next isEnabled read", () => {
|
|
99
|
+
const store = fakeStore(EMPTY_DOC);
|
|
100
|
+
const registry = createRegistry(store, fakeLog().log);
|
|
101
|
+
registry.defineModule(BASH);
|
|
102
|
+
|
|
103
|
+
expect(registry.isEnabled("row.bash")).toBe(true);
|
|
104
|
+
|
|
105
|
+
registry.setSettings("row.bash", { enabled: false });
|
|
106
|
+
|
|
107
|
+
expect(registry.isEnabled("row.bash")).toBe(false);
|
|
108
|
+
expect(store.saved).toHaveLength(1);
|
|
109
|
+
expect(store.saved[0]?.modules["row.bash"]?.enabled).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// spec: isEnabled("row.nope") -> false; getSettings("row.nope") -> {}; neither throws.
|
|
113
|
+
// fails_when: a lookup for a key nothing registered throws.
|
|
114
|
+
test("AC-2 an unknown key reads false and {} without throwing", () => {
|
|
115
|
+
const registry = createRegistry(fakeStore(EMPTY_DOC), fakeLog().log);
|
|
116
|
+
|
|
117
|
+
expect(registry.isEnabled("row.nope")).toBe(false);
|
|
118
|
+
expect(registry.getSettings("row.nope")).toEqual({});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// spec: defineModule(BASH) then two more descriptors under "row.bash" -> the first name/default/
|
|
122
|
+
// settings survive and exactly one log entry fires.
|
|
123
|
+
// fails_when: a later descriptor overwrites the first, or each duplicate logs again.
|
|
124
|
+
test("AC-3 a duplicate define keeps the first descriptor and logs exactly once", () => {
|
|
125
|
+
const store = fakeStore(EMPTY_DOC);
|
|
126
|
+
const { log, entries } = fakeLog();
|
|
127
|
+
const registry = createRegistry(store, log);
|
|
128
|
+
|
|
129
|
+
registry.defineModule(BASH);
|
|
130
|
+
registry.defineModule({ ...BASH, name: "Impostor", defaultEnabled: false });
|
|
131
|
+
registry.defineModule({
|
|
132
|
+
...BASH,
|
|
133
|
+
name: "Impostor again",
|
|
134
|
+
settings: [{ name: "other", kind: "toggle", default: false }],
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
expect(registry.isEnabled("row.bash")).toBe(true);
|
|
138
|
+
expect(registry.getSettings("row.bash")).toEqual({ collapseAt: 5 });
|
|
139
|
+
expect(entries).toHaveLength(1);
|
|
140
|
+
expect(entries[0]?.message).toContain("row.bash");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// spec: stored row.bash.settings = { collapseAt: "3" } (a string under a number spec) with
|
|
144
|
+
// content.table.settings = { collapseAt: 7, ghost: 1 } -> row.bash reads the default 5, content.table
|
|
145
|
+
// reads 7 with the unspec'd "ghost" dropped.
|
|
146
|
+
// fails_when: one malformed key corrupts the others (or the malformed value is handed to a renderer).
|
|
147
|
+
test("AC-4 a malformed stored value reads its default while other keys read stored values", () => {
|
|
148
|
+
const store = fakeStore({
|
|
149
|
+
version: 1,
|
|
150
|
+
modules: {
|
|
151
|
+
"row.bash": { enabled: true, settings: { collapseAt: "3" } },
|
|
152
|
+
"content.table": { enabled: false, settings: { collapseAt: 7, ghost: 1 } },
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
const { log, entries } = fakeLog();
|
|
156
|
+
const registry = createRegistry(store, log);
|
|
157
|
+
registry.defineModule(BASH);
|
|
158
|
+
registry.defineModule(TABLE);
|
|
159
|
+
|
|
160
|
+
expect(registry.getSettings("row.bash")).toEqual({ collapseAt: 5 });
|
|
161
|
+
expect(registry.getSettings("content.table")).toEqual({ collapseAt: 7, compact: false });
|
|
162
|
+
expect(registry.isEnabled("content.table")).toBe(false);
|
|
163
|
+
expect(entries).toHaveLength(1);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// spec: stored row.bash = 7 (not a slice) -> row.bash reads defaultEnabled true plus spec defaults
|
|
167
|
+
// while content.table still reads its stored enabled: false and collapseAt: 7.
|
|
168
|
+
// fails_when: a malformed module entry poisons its neighbours or throws at boot.
|
|
169
|
+
test("AC-4 a malformed stored entry reads that module's defaults while other keys read stored values", () => {
|
|
170
|
+
const incomplete = {
|
|
171
|
+
version: 1,
|
|
172
|
+
modules: { "row.bash": 7, "content.table": { enabled: false, settings: { collapseAt: 7 } } },
|
|
173
|
+
};
|
|
174
|
+
const store = fakeStore(incomplete as unknown as SettingsDoc); // boundary: the disk can hold any JSON
|
|
175
|
+
const { log, entries } = fakeLog();
|
|
176
|
+
const registry = createRegistry(store, log);
|
|
177
|
+
registry.defineModule(BASH);
|
|
178
|
+
registry.defineModule(TABLE);
|
|
179
|
+
|
|
180
|
+
expect(registry.isEnabled("row.bash")).toBe(true);
|
|
181
|
+
expect(registry.getSettings("row.bash")).toEqual({ collapseAt: 5 });
|
|
182
|
+
expect(registry.isEnabled("content.table")).toBe(false);
|
|
183
|
+
expect(registry.getSettings("content.table")).toEqual({ collapseAt: 7, compact: false });
|
|
184
|
+
expect(entries).toHaveLength(1);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// spec: one createRegistry (one store.load), 5 read invocations, one setSettings -> the store saw
|
|
188
|
+
// exactly ["load", "save"]; the reads logged nothing.
|
|
189
|
+
// fails_when: the registry reaches outside its seam — a per-render disk read or a pushed write.
|
|
190
|
+
test("AC-5 every call stays inside the injected store and logger seam", () => {
|
|
191
|
+
const store = fakeStore(EMPTY_DOC);
|
|
192
|
+
const { log, entries } = fakeLog();
|
|
193
|
+
const registry = createRegistry(store, log);
|
|
194
|
+
registry.defineModule(CODETHEME);
|
|
195
|
+
|
|
196
|
+
for (let i = 0; i < 5; i++) {
|
|
197
|
+
registry.isEnabled("codetheme");
|
|
198
|
+
registry.getSettings("codetheme");
|
|
199
|
+
}
|
|
200
|
+
expect(store.calls).toEqual(["load"]);
|
|
201
|
+
expect(entries).toEqual([]);
|
|
202
|
+
|
|
203
|
+
registry.setSettings("codetheme", { codeTheme: "one-dark-pro" });
|
|
204
|
+
|
|
205
|
+
expect(store.calls).toEqual(["load", "save"]);
|
|
206
|
+
expect(registry.getSettings("codetheme")).toEqual({ codeTheme: "one-dark-pro" });
|
|
207
|
+
expect(store.calls).toEqual(["load", "save"]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// spec: setSettings("content.table", { collapseAt: 9, compact: true }) -> both land on the next read
|
|
211
|
+
// and both are in the saved doc.
|
|
212
|
+
// fails_when: a patch is validated but never applied or never persisted.
|
|
213
|
+
test("a valid patch merges into the stored settings and persists", () => {
|
|
214
|
+
const store = fakeStore(EMPTY_DOC);
|
|
215
|
+
const registry = createRegistry(store, fakeLog().log);
|
|
216
|
+
registry.defineModule(TABLE);
|
|
217
|
+
|
|
218
|
+
registry.setSettings("content.table", { collapseAt: 9, compact: true });
|
|
219
|
+
|
|
220
|
+
expect(registry.getSettings("content.table")).toEqual({ collapseAt: 9, compact: true });
|
|
221
|
+
expect(store.saved[0]?.modules["content.table"]?.settings).toEqual({ collapseAt: 9, compact: true });
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// spec: setSettings("row.bash", { collapseAt: "3" }) -> no throw, the stored settings stay at the
|
|
225
|
+
// default 5, enabled is untouched, exactly one log entry.
|
|
226
|
+
// fails_when: an invalid patch value is stored, throws, or logs a line per frame it is seen.
|
|
227
|
+
test("an invalid patch value is rejected with one log entry and never throws", () => {
|
|
228
|
+
const store = fakeStore(EMPTY_DOC);
|
|
229
|
+
const { log, entries } = fakeLog();
|
|
230
|
+
const registry = createRegistry(store, log);
|
|
231
|
+
registry.defineModule(BASH);
|
|
232
|
+
|
|
233
|
+
registry.setSettings("row.bash", { collapseAt: "3" });
|
|
234
|
+
|
|
235
|
+
expect(registry.getSettings("row.bash")).toEqual({ collapseAt: 5 });
|
|
236
|
+
expect(registry.isEnabled("row.bash")).toBe(true);
|
|
237
|
+
expect(entries).toHaveLength(1);
|
|
238
|
+
expect(entries[0]?.message).toBe("ignored invalid value for row.bash.collapseAt");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// spec: a patch naming a setting no spec declares -> rejected with one log entry and absent from the
|
|
242
|
+
// effective settings.
|
|
243
|
+
// fails_when: an unvalidatable key is written into the doc and handed back to a renderer.
|
|
244
|
+
test("a patch naming an unknown setting is rejected with one log entry", () => {
|
|
245
|
+
const store = fakeStore(EMPTY_DOC);
|
|
246
|
+
const { log, entries } = fakeLog();
|
|
247
|
+
const registry = createRegistry(store, log);
|
|
248
|
+
registry.defineModule(BASH);
|
|
249
|
+
|
|
250
|
+
registry.setSettings("row.bash", { nope: 1 });
|
|
251
|
+
|
|
252
|
+
expect(registry.getSettings("row.bash")).toEqual({ collapseAt: 5 });
|
|
253
|
+
expect(entries).toHaveLength(1);
|
|
254
|
+
expect(entries[0]?.message).toBe("ignored invalid value for row.bash.nope");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// spec: setSettings("codetheme", { codeTheme: "solarized" }) -> rejected, default kept, one log entry;
|
|
258
|
+
// then { codeTheme: "one-dark-pro" } -> accepted.
|
|
259
|
+
// fails_when: an enum value outside the spec's list reaches the code that uses it.
|
|
260
|
+
test("an enum value outside the spec is rejected while an allowed value lands", () => {
|
|
261
|
+
const store = fakeStore(EMPTY_DOC);
|
|
262
|
+
const { log, entries } = fakeLog();
|
|
263
|
+
const registry = createRegistry(store, log);
|
|
264
|
+
registry.defineModule(CODETHEME);
|
|
265
|
+
|
|
266
|
+
registry.setSettings("codetheme", { codeTheme: "solarized" });
|
|
267
|
+
expect(registry.getSettings("codetheme")).toEqual({ codeTheme: "dracula-soft" });
|
|
268
|
+
expect(entries).toHaveLength(1);
|
|
269
|
+
|
|
270
|
+
registry.setSettings("codetheme", { codeTheme: "one-dark-pro" });
|
|
271
|
+
expect(registry.getSettings("codetheme")).toEqual({ codeTheme: "one-dark-pro" });
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// spec: setSettings("row.nope", { collapseAt: 9 }) and a non-object patch both no-op with one log
|
|
275
|
+
// entry each and never throw or save.
|
|
276
|
+
// fails_when: a stale panel target or an erased-type patch corrupts the doc.
|
|
277
|
+
test("a write for an unknown key or a non-object patch is ignored, never throws", () => {
|
|
278
|
+
const store = fakeStore(EMPTY_DOC);
|
|
279
|
+
const { log, entries } = fakeLog();
|
|
280
|
+
const registry = createRegistry(store, log);
|
|
281
|
+
registry.defineModule(BASH);
|
|
282
|
+
|
|
283
|
+
registry.setSettings("row.nope", { collapseAt: 9 });
|
|
284
|
+
registry.setSettings("row.bash", "collapseAt" as unknown as object); // boundary: TS types are erased
|
|
285
|
+
|
|
286
|
+
expect(registry.isEnabled("row.nope")).toBe(false);
|
|
287
|
+
expect(registry.getSettings("row.bash")).toEqual({ collapseAt: 5 });
|
|
288
|
+
expect(store.saved).toHaveLength(0);
|
|
289
|
+
expect(entries).toHaveLength(2);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// spec: a stored enabled that is not a boolean ("yes") falls back to spec defaultEnabled (false) and
|
|
293
|
+
// logs once, rather than being read as a truthy string.
|
|
294
|
+
// fails_when: a malformed enabled flag is trusted and a module turns itself on.
|
|
295
|
+
test("a malformed stored enabled falls back to the default and logs once", () => {
|
|
296
|
+
const store = fakeStore({
|
|
297
|
+
version: 1,
|
|
298
|
+
modules: { "row.bash": { enabled: "yes", settings: {} } },
|
|
299
|
+
} as unknown as SettingsDoc); // boundary: the disk can hold any JSON
|
|
300
|
+
const { log, entries } = fakeLog();
|
|
301
|
+
const registry = createRegistry(store, log);
|
|
302
|
+
registry.defineModule({ ...BASH, defaultEnabled: false });
|
|
303
|
+
|
|
304
|
+
expect(registry.isEnabled("row.bash")).toBe(false);
|
|
305
|
+
expect(entries).toHaveLength(1);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* registry.ts — the pluggable module registry: descriptors in, enabled flags and settings out.
|
|
3
|
+
*
|
|
4
|
+
* Boundary: the /render panel writes through setSettings; renderers poll isEnabled/getSettings per
|
|
5
|
+
* invocation and never write. A write lands on the next read — the registry pushes no redraw, and the
|
|
6
|
+
* injected store plus logger are the only collaborators it touches. The stored document and the patch
|
|
7
|
+
* both arrive as untrusted JSON, so every field is narrowed here before it is trusted.
|
|
8
|
+
*
|
|
9
|
+
* shape: closure returning an object literal — trigger #4, one Map of state plus methods, no subclassing.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Logger } from "./types/log.ts";
|
|
13
|
+
import type { ModuleDescriptor, Registry, SettingSpec } from "./types/registry.ts";
|
|
14
|
+
import type { ModuleSettings, SettingsDoc, SettingsStore } from "./types/settings.ts";
|
|
15
|
+
|
|
16
|
+
const SCOPE = "registry";
|
|
17
|
+
|
|
18
|
+
/** One registered module with its resolved state — the Map value the isEnabled hot path reads. */
|
|
19
|
+
interface ModuleEntry {
|
|
20
|
+
readonly descriptor: ModuleDescriptor;
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
settings: Record<string, unknown>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** boundary: a persisted slice and a settings patch both cross as untrusted JSON and narrow here. */
|
|
26
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
27
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// shape: dispatch object — trigger #1, 3 setting kinds, validators stateless.
|
|
31
|
+
const FITS: Record<SettingSpec["kind"], (value: unknown, values: string[] | undefined) => boolean> = {
|
|
32
|
+
toggle: (value) => typeof value === "boolean",
|
|
33
|
+
number: (value) => typeof value === "number" && Number.isFinite(value),
|
|
34
|
+
enum: (value, values) => typeof value === "string" && (values ?? []).includes(value),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function fitsSpec(spec: SettingSpec, value: unknown): boolean {
|
|
38
|
+
return FITS[spec.kind](value, spec.values);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function logRejected(log: Logger, key: string, name: string): void {
|
|
42
|
+
log.logOnce(`invalid:${key}:${name}`, SCOPE, `ignored invalid value for ${key}.${name}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Reads one module's persisted slice into resolved state; anything malformed falls back, logged once. */
|
|
46
|
+
function readEntry(doc: SettingsDoc, descriptor: ModuleDescriptor, log: Logger): ModuleEntry {
|
|
47
|
+
const stored: unknown = doc.modules[descriptor.key];
|
|
48
|
+
const slice = isRecord(stored) ? stored : {};
|
|
49
|
+
const enabled = slice.enabled;
|
|
50
|
+
const bag = slice.settings;
|
|
51
|
+
const settings: Record<string, unknown> = {};
|
|
52
|
+
let malformed = stored !== undefined && !isRecord(stored);
|
|
53
|
+
malformed ||= enabled !== undefined && typeof enabled !== "boolean";
|
|
54
|
+
malformed ||= bag !== undefined && !isRecord(bag);
|
|
55
|
+
|
|
56
|
+
for (const spec of descriptor.settings) {
|
|
57
|
+
// Object.hasOwn, not a bare lookup: a declared name must not read an inherited member.
|
|
58
|
+
const value = isRecord(bag) && Object.hasOwn(bag, spec.name) ? bag[spec.name] : undefined;
|
|
59
|
+
if (value === undefined) continue;
|
|
60
|
+
if (fitsSpec(spec, value)) settings[spec.name] = value;
|
|
61
|
+
else malformed = true;
|
|
62
|
+
}
|
|
63
|
+
if (malformed)
|
|
64
|
+
log.logOnce(`stored:${descriptor.key}`, SCOPE, `malformed stored entry for ${descriptor.key} — using defaults`);
|
|
65
|
+
|
|
66
|
+
return { descriptor, enabled: typeof enabled === "boolean" ? enabled : descriptor.defaultEnabled, settings };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createRegistry(store: SettingsStore, log: Logger): Registry {
|
|
70
|
+
const modules = new Map<string, ModuleEntry>();
|
|
71
|
+
// One read at boot: no render path may touch the disk, so a later write updates this cache directly.
|
|
72
|
+
const doc = store.load();
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
defineModule(descriptor: ModuleDescriptor): void {
|
|
76
|
+
if (modules.has(descriptor.key)) {
|
|
77
|
+
log.logOnce(
|
|
78
|
+
`duplicate:${descriptor.key}`,
|
|
79
|
+
SCOPE,
|
|
80
|
+
`duplicate module ${descriptor.key} ignored — first definition wins`,
|
|
81
|
+
);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
modules.set(descriptor.key, readEntry(doc, descriptor, log));
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
isEnabled(key: string): boolean {
|
|
88
|
+
return modules.get(key)?.enabled ?? false;
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
getSettings(key: string): Record<string, unknown> {
|
|
92
|
+
const entry = modules.get(key);
|
|
93
|
+
if (entry === undefined) return {};
|
|
94
|
+
const out: Record<string, unknown> = {};
|
|
95
|
+
for (const spec of entry.descriptor.settings) {
|
|
96
|
+
out[spec.name] = Object.hasOwn(entry.settings, spec.name) ? entry.settings[spec.name] : spec.default;
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
setSettings(key: string, patch: object): void {
|
|
102
|
+
const entry = modules.get(key);
|
|
103
|
+
if (entry === undefined) {
|
|
104
|
+
log.logOnce(`unknown:${key}`, SCOPE, `ignored settings write for unknown module ${key}`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const raw: unknown = patch;
|
|
108
|
+
if (!isRecord(raw)) {
|
|
109
|
+
log.logOnce(`patch:${key}`, SCOPE, `ignored non-object settings patch for ${key}`);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let enabled = entry.enabled;
|
|
114
|
+
const bag: Record<string, unknown> = { ...entry.settings };
|
|
115
|
+
const enabledPatch = raw.enabled;
|
|
116
|
+
if (enabledPatch !== undefined) {
|
|
117
|
+
if (typeof enabledPatch === "boolean") enabled = enabledPatch;
|
|
118
|
+
else logRejected(log, key, "enabled");
|
|
119
|
+
}
|
|
120
|
+
for (const name of Object.keys(raw)) {
|
|
121
|
+
if (name === "enabled") continue;
|
|
122
|
+
const spec = entry.descriptor.settings.find((s) => s.name === name);
|
|
123
|
+
const value = raw[name];
|
|
124
|
+
if (spec !== undefined && fitsSpec(spec, value)) bag[name] = value;
|
|
125
|
+
else logRejected(log, key, name);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const next: ModuleSettings = { enabled, settings: bag };
|
|
129
|
+
doc.modules[key] = next;
|
|
130
|
+
entry.enabled = enabled;
|
|
131
|
+
entry.settings = bag;
|
|
132
|
+
store.save(doc);
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// Fixture provenance — every document below is invented: pi-render itself writes this file, so there
|
|
2
|
+
// is no upstream payload to record; the file bytes are the fixture. The log double and the fs mock are
|
|
3
|
+
// local, so this ticket stays independent of T-06's logger implementation.
|
|
4
|
+
|
|
5
|
+
// The `node:fs` ESM namespace is not configurable, so read/write/rename are wrapped to *observe* the
|
|
6
|
+
// real syscalls without replacing them.
|
|
7
|
+
vi.mock("node:fs", async (importOriginal) => {
|
|
8
|
+
const actual = await importOriginal<typeof import("node:fs")>();
|
|
9
|
+
return {
|
|
10
|
+
...actual,
|
|
11
|
+
readFileSync: vi.fn(actual.readFileSync),
|
|
12
|
+
writeFileSync: vi.fn(actual.writeFileSync),
|
|
13
|
+
renameSync: vi.fn(actual.renameSync),
|
|
14
|
+
};
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// shape: none — test module: every behavior is asserted only through the createSettingsStore seam.
|
|
18
|
+
import * as fs from "node:fs";
|
|
19
|
+
import { tmpdir } from "node:os";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
22
|
+
import type { LogEntry, Logger, SettingsDoc } from "../core/types.ts";
|
|
23
|
+
import { createSettingsStore } from "./settings.ts";
|
|
24
|
+
|
|
25
|
+
const DEFAULTS: SettingsDoc = { version: 1, modules: {} };
|
|
26
|
+
|
|
27
|
+
function readCount(): number {
|
|
28
|
+
return vi.mocked(fs.readFileSync).mock.calls.length;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function createRecordingLogger(): Logger & { entries: LogEntry[] } {
|
|
32
|
+
const entries: LogEntry[] = [];
|
|
33
|
+
const push = (scope: string, message: string): void => {
|
|
34
|
+
entries.push({ scope, message, time: Date.now() });
|
|
35
|
+
};
|
|
36
|
+
return {
|
|
37
|
+
entries,
|
|
38
|
+
logLine: push,
|
|
39
|
+
logOnce: (_key, scope, message) => push(scope, message),
|
|
40
|
+
drain: () => entries.splice(0),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("core/settings — persisted settings document", () => {
|
|
45
|
+
let dir: string;
|
|
46
|
+
let path: string;
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
vi.clearAllMocks();
|
|
50
|
+
dir = fs.mkdtempSync(join(tmpdir(), "pi-render-settings-"));
|
|
51
|
+
path = join(dir, "settings.json");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
vi.unstubAllEnvs();
|
|
56
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("default path: with no injected path the doc lands under the host agent dir", () => {
|
|
60
|
+
vi.stubEnv("PI_CODING_AGENT_DIR", dir);
|
|
61
|
+
const doc: SettingsDoc = { version: 1, modules: { "row.bash": { enabled: false } } };
|
|
62
|
+
createSettingsStore().save(doc);
|
|
63
|
+
|
|
64
|
+
expect(JSON.parse(fs.readFileSync(join(dir, "pi-render", "settings.json"), "utf8"))).toEqual(doc);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("AC-1: after the first load every load is served from memory, not the disk", () => {
|
|
68
|
+
const store = createSettingsStore({ path });
|
|
69
|
+
expect(store.load()).toEqual(DEFAULTS);
|
|
70
|
+
expect(readCount()).toBe(1); // the one and only disk read
|
|
71
|
+
|
|
72
|
+
expect(store.load()).toEqual(DEFAULTS);
|
|
73
|
+
expect(store.load()).toEqual(DEFAULTS);
|
|
74
|
+
expect(readCount()).toBe(1); // a re-read per call is the #21 contributor returning
|
|
75
|
+
|
|
76
|
+
// A cache miss would be observable: the file now holds a different doc, yet the cached one reads back.
|
|
77
|
+
fs.writeFileSync(path, JSON.stringify({ version: 1, modules: { "row.bash": { enabled: false } } }));
|
|
78
|
+
expect(store.load()).toEqual(DEFAULTS);
|
|
79
|
+
expect(readCount()).toBe(1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("AC-1: save() reaches the disk and refreshes the cache without a read", () => {
|
|
83
|
+
const store = createSettingsStore({ path });
|
|
84
|
+
store.load();
|
|
85
|
+
const doc2: SettingsDoc = { version: 1, modules: { "row.bash": { enabled: false, settings: { collapseAt: 3 } } } };
|
|
86
|
+
store.save(doc2);
|
|
87
|
+
|
|
88
|
+
expect(readCount()).toBe(1); // save wrote; it did not read
|
|
89
|
+
expect(store.load()).toEqual(doc2);
|
|
90
|
+
expect(readCount()).toBe(1);
|
|
91
|
+
expect(createSettingsStore({ path }).load()).toEqual(doc2); // fresh store: the save is durable
|
|
92
|
+
expect(readCount()).toBe(2);
|
|
93
|
+
|
|
94
|
+
// Cached: replacing the file's content does not change what this store returns.
|
|
95
|
+
fs.writeFileSync(path, JSON.stringify({ version: 1, modules: {} }));
|
|
96
|
+
expect(store.load()).toEqual(doc2);
|
|
97
|
+
expect(readCount()).toBe(2);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("AC-2: a malformed doc returns defaults with exactly one log line, no throw", () => {
|
|
101
|
+
fs.writeFileSync(path, "{ not json");
|
|
102
|
+
const log = createRecordingLogger();
|
|
103
|
+
const store = createSettingsStore({ path, logger: log });
|
|
104
|
+
|
|
105
|
+
expect(() => store.load()).not.toThrow();
|
|
106
|
+
expect(store.load()).toEqual(DEFAULTS);
|
|
107
|
+
expect(log.entries).toHaveLength(1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("AC-2: a parseable non-document returns defaults with exactly one log line, no throw", () => {
|
|
111
|
+
fs.writeFileSync(path, JSON.stringify({ version: 1, modules: "not-a-map" }));
|
|
112
|
+
const log = createRecordingLogger();
|
|
113
|
+
const store = createSettingsStore({ path, logger: log });
|
|
114
|
+
|
|
115
|
+
expect(() => store.load()).not.toThrow();
|
|
116
|
+
expect(store.load()).toEqual(DEFAULTS);
|
|
117
|
+
expect(log.entries).toHaveLength(1);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("AC-2: an absent file is a first run, not a fault — defaults with no log line", () => {
|
|
121
|
+
const log = createRecordingLogger();
|
|
122
|
+
const store = createSettingsStore({ path, logger: log });
|
|
123
|
+
|
|
124
|
+
expect(store.load()).toEqual(DEFAULTS);
|
|
125
|
+
expect(log.entries).toHaveLength(0);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("AC-3: an entry missing its fields reads defaults while the other entry persists", () => {
|
|
129
|
+
fs.writeFileSync(
|
|
130
|
+
path,
|
|
131
|
+
JSON.stringify({
|
|
132
|
+
modules: {
|
|
133
|
+
"row.bash": { enabled: false, settings: { collapseAt: 3 } },
|
|
134
|
+
"content.table": {},
|
|
135
|
+
},
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const doc = createSettingsStore({ path }).load();
|
|
140
|
+
expect(doc.version).toBe(1); // doc-level default filled
|
|
141
|
+
expect(doc.modules["row.bash"]).toEqual({ enabled: false, settings: { collapseAt: 3 } });
|
|
142
|
+
expect(doc.modules["content.table"]).toEqual({ enabled: true });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("AC-3: a module key absent from the doc does not wipe the other keys", () => {
|
|
146
|
+
fs.writeFileSync(path, JSON.stringify({ version: 1, modules: { "row.bash": { enabled: false } } }));
|
|
147
|
+
|
|
148
|
+
const doc = createSettingsStore({ path }).load();
|
|
149
|
+
expect(doc.version).toBe(1);
|
|
150
|
+
expect(doc.modules["row.bash"]).toEqual({ enabled: false });
|
|
151
|
+
expect("content.table" in doc.modules).toBe(false); // absent here; the caller applies that module's defaults
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("AC-4: the final path is never written in place — only swapped by rename", () => {
|
|
155
|
+
const store = createSettingsStore({ path });
|
|
156
|
+
const doc1: SettingsDoc = { version: 1, modules: { "row.bash": { enabled: true } } };
|
|
157
|
+
const doc2: SettingsDoc = { version: 1, modules: { "content.table": { enabled: false } } };
|
|
158
|
+
store.save(doc1);
|
|
159
|
+
|
|
160
|
+
const writeSpy = vi.mocked(fs.writeFileSync);
|
|
161
|
+
const renameSpy = vi.mocked(fs.renameSync);
|
|
162
|
+
writeSpy.mockClear();
|
|
163
|
+
renameSpy.mockClear();
|
|
164
|
+
store.save(doc2);
|
|
165
|
+
|
|
166
|
+
// A write to `path` itself is the torn-write case: the final file is only ever complete.
|
|
167
|
+
expect(writeSpy.mock.calls.map(([file]) => String(file))).toEqual([`${path}.tmp`]);
|
|
168
|
+
expect(renameSpy.mock.calls.map(([from, to]) => [String(from), String(to)])).toEqual([[`${path}.tmp`, path]]);
|
|
169
|
+
expect(JSON.parse(fs.readFileSync(path, "utf8"))).toEqual(doc2);
|
|
170
|
+
expect(fs.existsSync(`${path}.tmp`)).toBe(false); // no leftover temp file
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("AC-4: a reader of the final path sees the old doc complete, then the new doc complete", () => {
|
|
174
|
+
const store = createSettingsStore({ path });
|
|
175
|
+
const doc1: SettingsDoc = { version: 1, modules: { "row.bash": { enabled: true } } };
|
|
176
|
+
const doc2: SettingsDoc = { version: 1, modules: { "content.table": { enabled: false } } };
|
|
177
|
+
store.save(doc1);
|
|
178
|
+
expect(JSON.parse(fs.readFileSync(path, "utf8"))).toEqual(doc1);
|
|
179
|
+
|
|
180
|
+
store.save(doc2);
|
|
181
|
+
expect(JSON.parse(fs.readFileSync(path, "utf8"))).toEqual(doc2);
|
|
182
|
+
});
|
|
183
|
+
});
|