1import { EnvironmentThemeFile } from "@t3tools/contracts";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, 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 Queue from "effect/Queue";
9import * as Schema from "effect/Schema";
10import * as Scope from "effect/Scope";
11import * as Stream from "effect/Stream";
12
13import * as ServerConfig from "./config.ts";
14import * as EnvironmentTheme from "./environmentTheme.ts";
15import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
16
17const encodeThemeFile = Schema.encodeSync(Schema.fromJsonString(EnvironmentThemeFile));
18
19const NIGHTFALL_THEME: EnvironmentThemeFile = {
20 name: "Nightfall",
21 appearance: "dark",
22 canvas: "#1a1b26",
23 accent: "#7aa2f7",
24};
25
26const SHARED_THEME: EnvironmentThemeFile = {
27 version: 1,
28 name: "Shared Light",
29 appearance: "light",
30 colors: { canvas: "#eff1f5", accent: "#1e66f5" },
31};
32
33const withEnvironmentThemes = <A, E>(
34 seeds: Readonly<Record<string, string>>,
35 body: Effect.Effect<
36 A,
37 E,
38 | EnvironmentTheme.EnvironmentThemeService
39 | ServerConfig.ServerConfig
40 | FileSystem.FileSystem
41 | Path.Path
42 | Scope.Scope
43 >,
44) =>
45 Effect.gen(function* () {
46 const fs = yield* FileSystem.FileSystem;
47 const path = yield* Path.Path;
48 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-environment-theme-" });
49 const themesDir = path.join(baseDir, "userdata", "themes");
50 yield* fs.makeDirectory(themesDir, { recursive: true });
51 for (const [filename, contents] of Object.entries(seeds)) {
52 yield* fs.writeFileString(path.join(themesDir, filename), contents);
53 }
54
55 return yield* body.pipe(
56 Effect.provide(
57 EnvironmentTheme.layer.pipe(
58 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)),
59 ),
60 ),
61 );
62 }).pipe(Effect.scoped);
63
64const currentThemes = Effect.gen(function* () {
65 const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService;
66 return yield* environmentTheme.current;
67});
68
69it.layer(NodeServices.layer)("environment theme", (it) => {
70 it.effect("follows the directory rather than the set read at start", () =>
71 withEnvironmentThemes(
72 { "nightfall.json": encodeThemeFile(NIGHTFALL_THEME) },
73 Effect.gen(function* () {
74 const { environmentThemesDir } = yield* ServerConfig.ServerConfig;
75 const fs = yield* FileSystem.FileSystem;
76 const path = yield* Path.Path;
77
78 yield* fs.writeFileString(
79 path.join(environmentThemesDir, "shared-light.json"),
80 encodeThemeFile(SHARED_THEME),
81 );
82 assert.equal((yield* currentThemes).length, 2);
83
84 yield* fs.remove(path.join(environmentThemesDir, "nightfall.json"));
85 assert.deepEqual(
86 (yield* currentThemes).map((theme) => theme.id),
87 ["shared-light"],
88 );
89 }),
90 ),
91 );
92
93 it.effect("never replays a set older than the snapshot it started from", () =>
94 withEnvironmentThemes(
95 { "nightfall.json": encodeThemeFile(NIGHTFALL_THEME) },
96 Effect.gen(function* () {
97 const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService;
98 const { environmentThemesDir } = yield* ServerConfig.ServerConfig;
99 const fs = yield* FileSystem.FileSystem;
100 const path = yield* Path.Path;
101
102 yield* fs.writeFileString(
103 path.join(environmentThemesDir, "shared-light.json"),
104 encodeThemeFile(SHARED_THEME),
105 );
106 const seen = yield* Queue.unbounded<ReadonlyArray<{ readonly id: string }>>();
107 yield* Stream.runForEach(environmentTheme.streamChanges, (themes) =>
108 Queue.offer(seen, themes),
109 ).pipe(Effect.forkScoped);
110 assert.deepEqual(
111 (yield* Queue.take(seen)).map((theme) => theme.id),
112 ["nightfall", "shared-light"],
113 );
114 yield* fs.remove(path.join(environmentThemesDir, "nightfall.json"));
115 yield* currentThemes;
116 assert.deepEqual(
117 (yield* Queue.take(seen)).map((theme) => theme.id),
118 ["shared-light"],
119 );
120 }),
121 ),
122 );
123
124 it.effect("skips invalid files while keeping valid ones", () =>
125 withEnvironmentThemes(
126 {
127 "nightfall.json": encodeThemeFile(NIGHTFALL_THEME),
128 "notepad.json": `\uFEFF${encodeThemeFile(SHARED_THEME)}`,
129 "unresolved.json":
130 '{ "name": "X", "appearance": "dark", "canvas": "{{ background }}", "accent": "#7aa2f7" }',
131 "malformed.json": "{ not json",
132 "no-colors.json": '{ "name": "Empty", "appearance": "dark" }',
133 "Bad Name.json": encodeThemeFile(SHARED_THEME),
134 "ocean.json": encodeThemeFile(SHARED_THEME),
135 "dark.json": encodeThemeFile(SHARED_THEME),
136 "notes.txt": "not a theme",
137 },
138 Effect.gen(function* () {
139 assert.deepEqual(
140 (yield* currentThemes).map((theme) => theme.id),
141 ["nightfall", "notepad"],
142 );
143 }),
144 ),
145 );
146
147 it.effect.skipIf(!symlinksSupported)("ignores a symlinked theme file", () =>
148 withEnvironmentThemes(
149 {},
150 Effect.gen(function* () {
151 const { environmentThemesDir } = yield* ServerConfig.ServerConfig;
152 const fs = yield* FileSystem.FileSystem;
153 const path = yield* Path.Path;
154 const outside = path.join(environmentThemesDir, "..", "outside.json");
155 yield* fs.writeFileString(outside, encodeThemeFile(NIGHTFALL_THEME));
156 yield* fs.symlink(outside, path.join(environmentThemesDir, "nightfall.json"));
157 assert.deepEqual(yield* currentThemes, []);
158 }),
159 ),
160 );
161
162 it.effect("does not charge skipped files against the total size limit", () =>
163 withEnvironmentThemes(
164 {
165 ...Object.fromEntries(
166 Array.from({ length: 7 }, (_, index) => [`junk-${index}.json`, "{".repeat(30_000)]),
167 ),
168 "zz-valid.json": encodeThemeFile(NIGHTFALL_THEME),
169 },
170 Effect.gen(function* () {
171 assert.deepEqual(
172 (yield* currentThemes).map((theme) => theme.id),
173 ["zz-valid"],
174 );
175 }),
176 ),
177 );
178});