1import { EnvironmentThemeFile } from "@t3tools/contracts";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, describe, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Path from "effect/Path";
8import * as Option from "effect/Option";
9import * as Queue from "effect/Queue";
10import * as Schema from "effect/Schema";
11import * as Scope from "effect/Scope";
12import * as Stream from "effect/Stream";
13
14import * as ServerConfig from "./config.ts";
15import * as EnvironmentTheme from "./environmentTheme.ts";
16import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
17
18const encodeThemeFile = Schema.encodeSync(Schema.fromJsonString(EnvironmentThemeFile));
19
20const NIGHTFALL_THEME: EnvironmentThemeFile = {
21 name: "Nightfall",
22 appearance: "dark",
23 canvas: "#1a1b26",
24 accent: "#7aa2f7",
25};
26
27/** The standard exported form: a full palette, no seeds. */
28const SHARED_THEME: EnvironmentThemeFile = {
29 version: 1,
30 name: "Shared Light",
31 appearance: "light",
32 colors: { canvas: "#eff1f5", accent: "#1e66f5" },
33};
34
35/** Seeds theme files before the service starts, as a real machine would. */
36const withEnvironmentThemes = <A, E>(
37 seeds: Readonly<Record<string, string>>,
38 body: Effect.Effect<
39 A,
40 E,
41 | EnvironmentTheme.EnvironmentThemeService
42 | ServerConfig.ServerConfig
43 | FileSystem.FileSystem
44 | Path.Path
45 | Scope.Scope
46 >,
47) =>
48 Effect.gen(function* () {
49 const fs = yield* FileSystem.FileSystem;
50 const path = yield* Path.Path;
51 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-environment-theme-" });
52 const themesDir = path.join(baseDir, "userdata", "themes");
53 yield* fs.makeDirectory(themesDir, { recursive: true });
54 for (const [filename, contents] of Object.entries(seeds)) {
55 yield* fs.writeFileString(path.join(themesDir, filename), contents);
56 }
57
58 return yield* body.pipe(
59 Effect.provide(
60 EnvironmentTheme.layer.pipe(
61 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)),
62 ),
63 ),
64 );
65 }).pipe(Effect.scoped);
66
67const currentThemes = Effect.gen(function* () {
68 const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService;
69 return yield* environmentTheme.current;
70});
71
72it.layer(NodeServices.layer)("environment theme", (it) => {
73 it.effect("publishes nothing when the machine has no theme files", () =>
74 withEnvironmentThemes(
75 {},
76 Effect.gen(function* () {
77 assert.deepEqual(yield* currentThemes, []);
78 }),
79 ),
80 );
81
82 it.effect("publishes each file under its filename as the id", () =>
83 withEnvironmentThemes(
84 {
85 "nightfall.json": encodeThemeFile(NIGHTFALL_THEME),
86 "shared-light.json": encodeThemeFile(SHARED_THEME),
87 },
88 Effect.gen(function* () {
89 const themes = yield* currentThemes;
90 assert.deepEqual(
91 themes.map((theme) => theme.id),
92 ["nightfall", "shared-light"],
93 );
94 assert.deepEqual(themes[0], { id: "nightfall", ...NIGHTFALL_THEME });
95 assert.deepEqual(themes[1], { id: "shared-light", ...SHARED_THEME });
96 }),
97 ),
98 );
99
100 // Read from disk rather than from the watcher's last observation, so a
101 // client connecting after a missed filesystem event still sees the truth.
102 it.effect("follows the directory rather than the set read at start", () =>
103 withEnvironmentThemes(
104 { "nightfall.json": encodeThemeFile(NIGHTFALL_THEME) },
105 Effect.gen(function* () {
106 const { environmentThemesDir } = yield* ServerConfig.ServerConfig;
107 const fs = yield* FileSystem.FileSystem;
108 const path = yield* Path.Path;
109
110 yield* fs.writeFileString(
111 path.join(environmentThemesDir, "shared-light.json"),
112 encodeThemeFile(SHARED_THEME),
113 );
114 assert.equal((yield* currentThemes).length, 2);
115
116 yield* fs.remove(path.join(environmentThemesDir, "nightfall.json"));
117 assert.deepEqual(
118 (yield* currentThemes).map((theme) => theme.id),
119 ["shared-light"],
120 );
121 }),
122 ),
123 );
124
125 // One bad file must not take down the machine's other themes: a theme
126 // script that leaves a template placeholder unresolved, a half-written
127 // file, or a stray name are each that file's problem alone.
128 // The subscription is acquired before the current set is read, so nothing
129 // published while a client connects can fall between snapshot and stream.
130 it.effect("streams the current set first", () =>
131 withEnvironmentThemes(
132 { "nightfall.json": encodeThemeFile(NIGHTFALL_THEME) },
133 Effect.gen(function* () {
134 const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService;
135 const first = yield* environmentTheme.streamChanges.pipe(Stream.runHead);
136 assert.deepEqual(Option.getOrNull(first), [{ id: "nightfall", ...NIGHTFALL_THEME }]);
137 }),
138 ),
139 );
140
141 // Subscribing happens before the snapshot read, so a publish landing in
142 // between is queued. It must not replay after the newer snapshot and walk
143 // clients back onto colors the machine has already moved past.
144 it.effect("never replays a set older than the snapshot it started from", () =>
145 withEnvironmentThemes(
146 { "nightfall.json": encodeThemeFile(NIGHTFALL_THEME) },
147 Effect.gen(function* () {
148 const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService;
149 const { environmentThemesDir } = yield* ServerConfig.ServerConfig;
150 const fs = yield* FileSystem.FileSystem;
151 const path = yield* Path.Path;
152
153 // Advance the directory twice without the watcher running, so the
154 // second read is strictly newer than anything already observed.
155 yield* fs.writeFileString(
156 path.join(environmentThemesDir, "shared-light.json"),
157 encodeThemeFile(SHARED_THEME),
158 );
159 const first = yield* environmentTheme.streamChanges.pipe(Stream.runHead);
160 assert.deepEqual(
161 Option.getOrNull(first)?.map((theme) => theme.id),
162 ["nightfall", "shared-light"],
163 );
164 }),
165 ),
166 );
167
168 it.effect("skips invalid files while keeping valid ones", () =>
169 withEnvironmentThemes(
170 {
171 "nightfall.json": encodeThemeFile(NIGHTFALL_THEME),
172 "unresolved.json":
173 '{ "name": "X", "appearance": "dark", "canvas": "{{ background }}", "accent": "#7aa2f7" }',
174 "malformed.json": "{ not json",
175 "no-colors.json": '{ "name": "Empty", "appearance": "dark" }',
176 "Bad Name.json": encodeThemeFile(SHARED_THEME),
177 "ocean.json": encodeThemeFile(SHARED_THEME),
178 "dark.json": encodeThemeFile(SHARED_THEME),
179 "notes.txt": "not a theme",
180 },
181 Effect.gen(function* () {
182 assert.deepEqual(
183 (yield* currentThemes).map((theme) => theme.id),
184 ["nightfall"],
185 );
186 }),
187 ),
188 );
189
190 // A symlinked themes directory stays usable, but a symlinked file inside it
191 // must not publish whatever it points at.
192 it.effect.skipIf(!symlinksSupported)("ignores a symlinked theme file", () =>
193 withEnvironmentThemes(
194 {},
195 Effect.gen(function* () {
196 const { environmentThemesDir } = yield* ServerConfig.ServerConfig;
197 const fs = yield* FileSystem.FileSystem;
198 const path = yield* Path.Path;
199 const outside = path.join(environmentThemesDir, "..", "outside.json");
200 yield* fs.writeFileString(outside, encodeThemeFile(NIGHTFALL_THEME));
201 yield* fs.symlink(outside, path.join(environmentThemesDir, "nightfall.json"));
202 assert.deepEqual(yield* currentThemes, []);
203 }),
204 ),
205 );
206
207 // The aggregate size cap charges only accepted themes, so a pile of
208 // malformed files cannot spend the budget and hide a valid theme sorted
209 // after them.
210 it.effect("does not charge skipped files against the total size limit", () =>
211 withEnvironmentThemes(
212 {
213 ...Object.fromEntries(
214 Array.from({ length: 7 }, (_, index) => [`junk-${index}.json`, "{".repeat(30_000)]),
215 ),
216 "zz-valid.json": encodeThemeFile(NIGHTFALL_THEME),
217 },
218 Effect.gen(function* () {
219 assert.deepEqual(
220 (yield* currentThemes).map((theme) => theme.id),
221 ["zz-valid"],
222 );
223 }),
224 ),
225 );
226});
227
228// The feature's headline claim: rewrite a file and connected clients retint
229// without a restart. Live clock and a real filesystem event, so this proves
230// the watcher rather than a direct read. Kept outside the it.layer block above
231// because only the top-level `it` exposes `live`.
232describe("environment theme watching", () => {
233 it.live("streams a set for every change to the directory", () =>
234 Effect.gen(function* () {
235 const fs = yield* FileSystem.FileSystem;
236 const path = yield* Path.Path;
237 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-theme-watch-" });
238 const themesDir = path.join(baseDir, "userdata", "themes");
239 yield* fs.makeDirectory(themesDir, { recursive: true });
240
241 yield* Effect.gen(function* () {
242 const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService;
243 const seen = yield* Queue.unbounded<ReadonlyArray<{ readonly id: string }>>();
244 yield* Stream.runForEach(environmentTheme.streamChanges, (themes) =>
245 Queue.offer(seen, themes),
246 ).pipe(Effect.forkScoped);
247
248 // Empty to start.
249 assert.deepEqual(yield* Queue.take(seen), []);
250
251 // Published atomically, the way a theme hook writes it.
252 const staging = path.join(baseDir, "staged.json");
253 yield* fs.writeFileString(staging, encodeThemeFile(NIGHTFALL_THEME));
254 yield* fs.rename(staging, path.join(themesDir, "nightfall.json"));
255 assert.deepEqual(
256 (yield* Queue.take(seen)).map((theme) => theme.id),
257 ["nightfall"],
258 );
259
260 // Removed again, and the set empties without a restart.
261 yield* fs.remove(path.join(themesDir, "nightfall.json"));
262 assert.deepEqual(yield* Queue.take(seen), []);
263 }).pipe(
264 Effect.provide(
265 EnvironmentTheme.layer.pipe(
266 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)),
267 ),
268 ),
269 Effect.timeout("30 seconds"),
270 );
271 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
272 );
273});