| 1 | import { EnvironmentThemeFile } from "@t3tools/contracts"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import { assert, describe, it } from "@effect/vitest"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as FileSystem from "effect/FileSystem"; |
| 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as Path from "effect/Path"; |
| 8 | import * as Option from "effect/Option"; |
| 9 | import * as Queue from "effect/Queue"; |
| 10 | import * as Schema from "effect/Schema"; |
| 11 | import * as Scope from "effect/Scope"; |
| 12 | import * as Stream from "effect/Stream"; |
| 13 | |
| 14 | import * as ServerConfig from "./config.ts"; |
| 15 | import * as EnvironmentTheme from "./environmentTheme.ts"; |
| 16 | import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; |
| 17 | |
| 18 | const encodeThemeFile = Schema.encodeSync(Schema.fromJsonString(EnvironmentThemeFile)); |
| 19 | |
| 20 | const 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. */ |
| 28 | const 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. */ |
| 36 | const 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 | |
| 67 | const currentThemes = Effect.gen(function* () { |
| 68 | const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService; |
| 69 | return yield* environmentTheme.current; |
| 70 | }); |
| 71 | |
| 72 | it.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`. |
| 232 | describe("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 | }); |