| 1 | // @effect-diagnostics nodeBuiltinImport:off - CLI integration exercises the filesystem boundary. |
| 2 | import * as NodeFS from "node:fs"; |
| 3 | import * as NodeOS from "node:os"; |
| 4 | import * as NodePath from "node:path"; |
| 5 | |
| 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 7 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 8 | import * as NetService from "@t3tools/shared/Net"; |
| 9 | import { assert, describe, it } from "@effect/vitest"; |
| 10 | import * as Effect from "effect/Effect"; |
| 11 | import * as Layer from "effect/Layer"; |
| 12 | import * as TestConsole from "effect/testing/TestConsole"; |
| 13 | import { Command } from "effect/cli"; |
| 14 | |
| 15 | import { cli } from "../binCli.ts"; |
| 16 | import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; |
| 17 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 18 | |
| 19 | // These force a failure with chmod, which Windows ignores for directories and |
| 20 | // cannot use to make a file unreadable, so the failure never happens there. |
| 21 | const windowsHost = HostProcess.Platform.defaultValue() === "win32"; |
| 22 | |
| 23 | const runCli = (args: ReadonlyArray<string>) => |
| 24 | Command.runWith(cli, { version: "0.0.0" })(args).pipe( |
| 25 | Effect.provide(Layer.mergeAll(NodeServices.layer, NetService.layer, TestConsole.layer)), |
| 26 | ); |
| 27 | |
| 28 | const makeBaseDir = () => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-theme-cli-")); |
| 29 | |
| 30 | const settingsPathFor = (baseDir: string) => NodePath.join(baseDir, "userdata", "settings.json"); |
| 31 | |
| 32 | const NIGHTFALL_THEME_JSON = `${JSON.stringify({ |
| 33 | name: "Nightfall", |
| 34 | appearance: "dark", |
| 35 | canvas: "#1a1b26", |
| 36 | accent: "#7aa2f7", |
| 37 | })}\n`; |
| 38 | const JUNK_THEME_JSON = `${JSON.stringify({ name: "Junk" })}\n`; |
| 39 | |
| 40 | const readSettings = (baseDir: string): Record<string, unknown> => { |
| 41 | const raw = NodeFS.readFileSync(settingsPathFor(baseDir), "utf8"); |
| 42 | return JSON.parse(raw) as Record<string, unknown>; |
| 43 | }; |
| 44 | |
| 45 | const writeSettings = (baseDir: string, settings: Record<string, unknown>) => { |
| 46 | NodeFS.mkdirSync(NodePath.dirname(settingsPathFor(baseDir)), { recursive: true }); |
| 47 | NodeFS.writeFileSync(settingsPathFor(baseDir), `${JSON.stringify(settings, null, 2)}\n`); |
| 48 | }; |
| 49 | |
| 50 | describe("t3 theme", () => { |
| 51 | it.effect("writes a default theme when no settings file exists yet", () => |
| 52 | Effect.gen(function* () { |
| 53 | const baseDir = makeBaseDir(); |
| 54 | yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]); |
| 55 | assert.equal(readSettings(baseDir).defaultTheme, "ocean"); |
| 56 | }), |
| 57 | ); |
| 58 | |
| 59 | // A provisioning command runs against settings written by whatever version |
| 60 | // happens to be installed, so it must not drop what it cannot interpret. |
| 61 | it.effect("preserves settings it does not recognise", () => |
| 62 | Effect.gen(function* () { |
| 63 | const baseDir = makeBaseDir(); |
| 64 | writeSettings(baseDir, { |
| 65 | enableProviderUpdateChecks: false, |
| 66 | somethingFromANewerBuild: { nested: true }, |
| 67 | }); |
| 68 | |
| 69 | yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]); |
| 70 | |
| 71 | const settings = readSettings(baseDir); |
| 72 | assert.equal(settings.defaultTheme, "ocean"); |
| 73 | assert.equal(settings.enableProviderUpdateChecks, false); |
| 74 | assert.deepEqual(settings.somethingFromANewerBuild, { nested: true }); |
| 75 | }), |
| 76 | ); |
| 77 | |
| 78 | it.effect("clears the default back to leaving fresh clients alone", () => |
| 79 | Effect.gen(function* () { |
| 80 | const baseDir = makeBaseDir(); |
| 81 | writeSettings(baseDir, { enableProviderUpdateChecks: false }); |
| 82 | |
| 83 | yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]); |
| 84 | yield* runCli(["theme", "clear", "--base-dir", baseDir]); |
| 85 | |
| 86 | const settings = readSettings(baseDir); |
| 87 | assert.equal(Object.hasOwn(settings, "defaultTheme"), false); |
| 88 | assert.equal(settings.enableProviderUpdateChecks, false); |
| 89 | }), |
| 90 | ); |
| 91 | |
| 92 | // Publishing a file and pointing at it are one step, so an integration |
| 93 | // (a desktop's theme hook) needs no knowledge of the themes directory. |
| 94 | it.effect("publishes a theme file under its filename and sets it", () => |
| 95 | Effect.gen(function* () { |
| 96 | const baseDir = makeBaseDir(); |
| 97 | const themeFile = NodePath.join(baseDir, "nightfall.json"); |
| 98 | NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); |
| 99 | |
| 100 | yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]); |
| 101 | |
| 102 | const published = NodePath.join(baseDir, "userdata", "themes", "nightfall.json"); |
| 103 | assert.equal(NodeFS.existsSync(published), true); |
| 104 | assert.equal(readSettings(baseDir).defaultTheme, "nightfall"); |
| 105 | // No rollback or staging residue after a successful set. |
| 106 | const residue = NodeFS.readdirSync(NodePath.dirname(published)).filter( |
| 107 | (entry) => !entry.endsWith(".json"), |
| 108 | ); |
| 109 | assert.deepEqual(residue, []); |
| 110 | }), |
| 111 | ); |
| 112 | |
| 113 | it.effect("publishes a theme file under an explicit id", () => |
| 114 | Effect.gen(function* () { |
| 115 | const baseDir = makeBaseDir(); |
| 116 | const themeFile = NodePath.join(baseDir, "t3code.json"); |
| 117 | NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); |
| 118 | |
| 119 | yield* runCli(["theme", "set", "--id", "nightfall", themeFile, "--base-dir", baseDir]); |
| 120 | |
| 121 | assert.equal( |
| 122 | NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes", "nightfall.json")), |
| 123 | true, |
| 124 | ); |
| 125 | assert.equal(readSettings(baseDir).defaultTheme, "nightfall"); |
| 126 | }), |
| 127 | ); |
| 128 | |
| 129 | it.effect("rejects a file that is not a theme and sets nothing", () => |
| 130 | Effect.gen(function* () { |
| 131 | const baseDir = makeBaseDir(); |
| 132 | const themeFile = NodePath.join(baseDir, "junk.json"); |
| 133 | NodeFS.writeFileSync(themeFile, JUNK_THEME_JSON); |
| 134 | |
| 135 | const failure = yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe( |
| 136 | Effect.flip, |
| 137 | ); |
| 138 | |
| 139 | assert.include(String(failure), "not a valid theme file"); |
| 140 | assert.equal(NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes")), false); |
| 141 | assert.equal(NodeFS.existsSync(settingsPathFor(baseDir)), false); |
| 142 | }), |
| 143 | ); |
| 144 | |
| 145 | // Publish and set are one command, so a settings file the set step cannot |
| 146 | // use must fail it before the themes directory is mutated -- not after, |
| 147 | // with a half-applied publish left behind. |
| 148 | it.effect("publishes nothing when the settings file cannot be used", () => |
| 149 | Effect.gen(function* () { |
| 150 | const baseDir = makeBaseDir(); |
| 151 | NodeFS.mkdirSync(NodePath.dirname(settingsPathFor(baseDir)), { recursive: true }); |
| 152 | NodeFS.writeFileSync(settingsPathFor(baseDir), "{ not json"); |
| 153 | const themeFile = NodePath.join(baseDir, "nightfall.json"); |
| 154 | NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); |
| 155 | |
| 156 | const failure = yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe( |
| 157 | Effect.flip, |
| 158 | ); |
| 159 | |
| 160 | assert.include(String(failure), "not a JSON object"); |
| 161 | assert.equal(NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes")), false); |
| 162 | }), |
| 163 | ); |
| 164 | |
| 165 | // set means set: a publish that rode along with a failed default write is |
| 166 | // rolled back rather than left mutating the environment's theme set. The |
| 167 | // userdata directory is made read-only while themes stays writable, so the |
| 168 | // failure lands after the publish -- the case the rollback exists for. |
| 169 | it.effect.skipIf(windowsHost)("rolls back a publish when the default cannot be written", () => |
| 170 | Effect.gen(function* () { |
| 171 | const baseDir = makeBaseDir(); |
| 172 | writeSettings(baseDir, {}); |
| 173 | const userdataDir = NodePath.dirname(settingsPathFor(baseDir)); |
| 174 | const themesDir = NodePath.join(userdataDir, "themes"); |
| 175 | NodeFS.mkdirSync(themesDir, { recursive: true }); |
| 176 | const themeFile = NodePath.join(baseDir, "nightfall.json"); |
| 177 | NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); |
| 178 | |
| 179 | NodeFS.chmodSync(userdataDir, 0o555); |
| 180 | try { |
| 181 | const failure = yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe( |
| 182 | Effect.flip, |
| 183 | ); |
| 184 | assert.include(String(failure), "Could not write"); |
| 185 | assert.equal(NodeFS.existsSync(NodePath.join(themesDir, "nightfall.json")), false); |
| 186 | } finally { |
| 187 | NodeFS.chmodSync(userdataDir, 0o755); |
| 188 | } |
| 189 | }), |
| 190 | ); |
| 191 | |
| 192 | // A symlink is a normal way to hand this command a theme -- desktop hooks |
| 193 | // symlink the current palette -- so the source is resolved, not refused. |
| 194 | it.effect.skipIf(!symlinksSupported)( |
| 195 | "publishes a theme file through a symlinked source path", |
| 196 | () => |
| 197 | Effect.gen(function* () { |
| 198 | const baseDir = makeBaseDir(); |
| 199 | const realFile = NodePath.join(baseDir, "real-nightfall.json"); |
| 200 | NodeFS.writeFileSync(realFile, NIGHTFALL_THEME_JSON); |
| 201 | const linkPath = NodePath.join(baseDir, "nightfall.json"); |
| 202 | NodeFS.symlinkSync(realFile, linkPath); |
| 203 | |
| 204 | yield* runCli(["theme", "set", linkPath, "--base-dir", baseDir]); |
| 205 | |
| 206 | assert.equal( |
| 207 | NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes", "nightfall.json")), |
| 208 | true, |
| 209 | ); |
| 210 | assert.equal(readSettings(baseDir).defaultTheme, "nightfall"); |
| 211 | }), |
| 212 | ); |
| 213 | |
| 214 | // The staging entry is created fresh with O_EXCL, so a symlink planted at |
| 215 | // its predictable name is cleared, never followed and written through. |
| 216 | it.effect.skipIf(!symlinksSupported)("never writes through a symlink at the staging path", () => |
| 217 | Effect.gen(function* () { |
| 218 | const baseDir = makeBaseDir(); |
| 219 | const themesDir = NodePath.join(baseDir, "userdata", "themes"); |
| 220 | NodeFS.mkdirSync(themesDir, { recursive: true }); |
| 221 | const victim = NodePath.join(baseDir, "victim.txt"); |
| 222 | NodeFS.writeFileSync(victim, "precious"); |
| 223 | NodeFS.symlinkSync(victim, NodePath.join(themesDir, `nightfall.json.staging-${process.pid}`)); |
| 224 | const themeFile = NodePath.join(baseDir, "nightfall.json"); |
| 225 | NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); |
| 226 | |
| 227 | yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]); |
| 228 | |
| 229 | assert.equal(NodeFS.readFileSync(victim, "utf8"), "precious"); |
| 230 | assert.equal(readSettings(baseDir).defaultTheme, "nightfall"); |
| 231 | }), |
| 232 | ); |
| 233 | |
| 234 | // Rollback moves the previous directory entry aside and back, so even an |
| 235 | // entry the watcher would never publish -- here a symlink -- comes back |
| 236 | // exactly as it was when the set fails. |
| 237 | it.effect.skipIf(!symlinksSupported || windowsHost)( |
| 238 | "restores a non-theme destination entry when the set fails", |
| 239 | () => |
| 240 | Effect.gen(function* () { |
| 241 | const baseDir = makeBaseDir(); |
| 242 | writeSettings(baseDir, {}); |
| 243 | const userdataDir = NodePath.dirname(settingsPathFor(baseDir)); |
| 244 | const themesDir = NodePath.join(userdataDir, "themes"); |
| 245 | NodeFS.mkdirSync(themesDir, { recursive: true }); |
| 246 | const outside = NodePath.join(baseDir, "outside.json"); |
| 247 | NodeFS.writeFileSync(outside, NIGHTFALL_THEME_JSON); |
| 248 | const destination = NodePath.join(themesDir, "nightfall.json"); |
| 249 | NodeFS.symlinkSync(outside, destination); |
| 250 | const themeFile = NodePath.join(baseDir, "nightfall.json"); |
| 251 | NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); |
| 252 | |
| 253 | NodeFS.chmodSync(userdataDir, 0o555); |
| 254 | try { |
| 255 | yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe(Effect.flip); |
| 256 | assert.equal(NodeFS.lstatSync(destination).isSymbolicLink(), true); |
| 257 | } finally { |
| 258 | NodeFS.chmodSync(userdataDir, 0o755); |
| 259 | } |
| 260 | }), |
| 261 | ); |
| 262 | |
| 263 | it.effect.skipIf(windowsHost)("restores the previous theme when a re-publish fails to set", () => |
| 264 | Effect.gen(function* () { |
| 265 | const baseDir = makeBaseDir(); |
| 266 | writeSettings(baseDir, {}); |
| 267 | const userdataDir = NodePath.dirname(settingsPathFor(baseDir)); |
| 268 | const themesDir = NodePath.join(userdataDir, "themes"); |
| 269 | NodeFS.mkdirSync(themesDir, { recursive: true }); |
| 270 | const publishedPath = NodePath.join(themesDir, "nightfall.json"); |
| 271 | const previous = |
| 272 | '{ "name": "Old Nightfall", "appearance": "dark", "canvas": "#000000", "accent": "#ffffff" }\n'; |
| 273 | NodeFS.writeFileSync(publishedPath, previous); |
| 274 | const themeFile = NodePath.join(baseDir, "nightfall.json"); |
| 275 | NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); |
| 276 | |
| 277 | NodeFS.chmodSync(userdataDir, 0o555); |
| 278 | try { |
| 279 | yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe(Effect.flip); |
| 280 | assert.equal(NodeFS.readFileSync(publishedPath, "utf8"), previous); |
| 281 | } finally { |
| 282 | NodeFS.chmodSync(userdataDir, 0o755); |
| 283 | } |
| 284 | }), |
| 285 | ); |
| 286 | |
| 287 | // A typo'd id written as the theme would silently never resolve anywhere; |
| 288 | // the id branch is as strict as the filename rule. |
| 289 | it.effect("rejects an id no client could resolve", () => |
| 290 | Effect.gen(function* () { |
| 291 | const baseDir = makeBaseDir(); |
| 292 | const failure = yield* runCli(["theme", "set", "Nightfall", "--base-dir", baseDir]).pipe( |
| 293 | Effect.flip, |
| 294 | ); |
| 295 | assert.include(String(failure), "not a valid theme id"); |
| 296 | assert.equal(NodeFS.existsSync(settingsPathFor(baseDir)), false); |
| 297 | }), |
| 298 | ); |
| 299 | |
| 300 | it.effect("rejects a path that does not exist instead of storing it as an id", () => |
| 301 | Effect.gen(function* () { |
| 302 | const baseDir = makeBaseDir(); |
| 303 | const failure = yield* runCli([ |
| 304 | "theme", |
| 305 | "set", |
| 306 | `${baseDir}/missing.json`, |
| 307 | "--base-dir", |
| 308 | baseDir, |
| 309 | ]).pipe(Effect.flip); |
| 310 | assert.include(String(failure), "Could not read"); |
| 311 | }), |
| 312 | ); |
| 313 | |
| 314 | // File-ness is decided by existence, not extension, so a generated file |
| 315 | // named for its target app still publishes. |
| 316 | it.effect("publishes an extensionless file", () => |
| 317 | Effect.gen(function* () { |
| 318 | const baseDir = makeBaseDir(); |
| 319 | const themeFile = NodePath.join(baseDir, "brand"); |
| 320 | NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); |
| 321 | |
| 322 | yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]); |
| 323 | |
| 324 | assert.equal( |
| 325 | NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes", "brand.json")), |
| 326 | true, |
| 327 | ); |
| 328 | assert.equal(readSettings(baseDir).defaultTheme, "brand"); |
| 329 | }), |
| 330 | ); |
| 331 | |
| 332 | it.effect("records a set generation and clears it with the theme", () => |
| 333 | Effect.gen(function* () { |
| 334 | const baseDir = makeBaseDir(); |
| 335 | yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]); |
| 336 | const setAt = readSettings(baseDir).defaultThemeSetAt; |
| 337 | assert.equal(typeof setAt, "string"); |
| 338 | |
| 339 | yield* runCli(["theme", "clear", "--base-dir", baseDir]); |
| 340 | const cleared = readSettings(baseDir); |
| 341 | assert.equal(Object.hasOwn(cleared, "defaultTheme"), false); |
| 342 | assert.equal(Object.hasOwn(cleared, "defaultThemeSetAt"), false); |
| 343 | }), |
| 344 | ); |
| 345 | |
| 346 | it.effect("honors T3CODE_HOME like the rest of the CLI", () => |
| 347 | Effect.gen(function* () { |
| 348 | const baseDir = makeBaseDir(); |
| 349 | yield* runCli(["theme", "set", "ocean"]).pipe( |
| 350 | Effect.provide( |
| 351 | ConfigProvider.layer(ConfigProvider.fromEnv({ env: { T3CODE_HOME: baseDir } })), |
| 352 | ), |
| 353 | ); |
| 354 | assert.equal(readSettings(baseDir).defaultTheme, "ocean"); |
| 355 | }), |
| 356 | ); |
| 357 | |
| 358 | // An unreadable settings file must never read as "no settings": writing a |
| 359 | // fresh sparse file over it would discard every key the user had. |
| 360 | it.effect.skipIf(windowsHost)("refuses to write when the settings file cannot be read", () => |
| 361 | Effect.gen(function* () { |
| 362 | const baseDir = makeBaseDir(); |
| 363 | writeSettings(baseDir, { enableProviderUpdateChecks: false }); |
| 364 | NodeFS.chmodSync(settingsPathFor(baseDir), 0o000); |
| 365 | |
| 366 | const failure = yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]).pipe( |
| 367 | Effect.flip, |
| 368 | ); |
| 369 | |
| 370 | NodeFS.chmodSync(settingsPathFor(baseDir), 0o644); |
| 371 | assert.include(String(failure), "Could not read"); |
| 372 | assert.equal(readSettings(baseDir).enableProviderUpdateChecks, false); |
| 373 | assert.equal(Object.hasOwn(readSettings(baseDir), "defaultTheme"), false); |
| 374 | }), |
| 375 | ); |
| 376 | |
| 377 | // A typo is syntactically a valid id, so shape validation alone would write |
| 378 | // a theme no client can resolve and report success. |
| 379 | it.effect("rejects an id that names no theme", () => |
| 380 | Effect.gen(function* () { |
| 381 | const baseDir = makeBaseDir(); |
| 382 | const failure = yield* runCli(["theme", "set", "ocian", "--base-dir", baseDir]).pipe( |
| 383 | Effect.flip, |
| 384 | ); |
| 385 | assert.include(String(failure), "No theme named"); |
| 386 | assert.equal(NodeFS.existsSync(settingsPathFor(baseDir)), false); |
| 387 | }), |
| 388 | ); |
| 389 | |
| 390 | it.effect("accepts an id a published file provides", () => |
| 391 | Effect.gen(function* () { |
| 392 | const baseDir = makeBaseDir(); |
| 393 | const themeFile = NodePath.join(baseDir, "nightfall.json"); |
| 394 | NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); |
| 395 | yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]); |
| 396 | |
| 397 | // Now resolvable by bare id, because the file published it. |
| 398 | yield* runCli(["theme", "clear", "--base-dir", baseDir]); |
| 399 | yield* runCli(["theme", "set", "nightfall", "--base-dir", baseDir]); |
| 400 | assert.equal(readSettings(baseDir).defaultTheme, "nightfall"); |
| 401 | }), |
| 402 | ); |
| 403 | |
| 404 | // The watcher skips files it cannot use, so accepting their filename would |
| 405 | // set a theme no client ever receives. |
| 406 | it.effect("rejects an id whose published file the watcher would skip", () => |
| 407 | Effect.gen(function* () { |
| 408 | const baseDir = makeBaseDir(); |
| 409 | const themesDir = NodePath.join(baseDir, "userdata", "themes"); |
| 410 | NodeFS.mkdirSync(themesDir, { recursive: true }); |
| 411 | NodeFS.writeFileSync(NodePath.join(themesDir, "broken.json"), "{ not json\n"); |
| 412 | |
| 413 | const failure = yield* runCli(["theme", "set", "broken", "--base-dir", baseDir]).pipe( |
| 414 | Effect.flip, |
| 415 | ); |
| 416 | assert.include(String(failure), "No theme named"); |
| 417 | }), |
| 418 | ); |
| 419 | |
| 420 | // Web and desktop cannot resolve the mobile default, and mobile does not |
| 421 | // follow this setting, so naming it would be a silent no-op. |
| 422 | it.effect("rejects the mobile default theme id", () => |
| 423 | Effect.gen(function* () { |
| 424 | const baseDir = makeBaseDir(); |
| 425 | const failure = yield* runCli(["theme", "set", "t3-code", "--base-dir", baseDir]).pipe( |
| 426 | Effect.flip, |
| 427 | ); |
| 428 | assert.include(String(failure), "No theme named"); |
| 429 | }), |
| 430 | ); |
| 431 | |
| 432 | // Deciding on existence alone would publish ./ocean instead of selecting the |
| 433 | // built-in, purely because of what happens to be in the working directory. |
| 434 | it.effect("treats a bare id as an id even when a file shares its name", () => |
| 435 | Effect.gen(function* () { |
| 436 | const baseDir = makeBaseDir(); |
| 437 | const cwdFile = NodePath.join(baseDir, "ocean"); |
| 438 | NodeFS.writeFileSync(cwdFile, NIGHTFALL_THEME_JSON); |
| 439 | |
| 440 | const previous = process.cwd(); |
| 441 | process.chdir(baseDir); |
| 442 | try { |
| 443 | yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]); |
| 444 | } finally { |
| 445 | process.chdir(previous); |
| 446 | } |
| 447 | |
| 448 | assert.equal(readSettings(baseDir).defaultTheme, "ocean"); |
| 449 | assert.equal( |
| 450 | NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes", "ocean.json")), |
| 451 | false, |
| 452 | ); |
| 453 | }), |
| 454 | ); |
| 455 | |
| 456 | // The watcher would skip an oversized file, so publishing one must not |
| 457 | // report success for a theme no client receives. |
| 458 | it.effect("rejects a theme file larger than the watcher will read", () => |
| 459 | Effect.gen(function* () { |
| 460 | const baseDir = makeBaseDir(); |
| 461 | const themeFile = NodePath.join(baseDir, "huge.json"); |
| 462 | const padding = "x".repeat(40 * 1024); |
| 463 | NodeFS.writeFileSync( |
| 464 | themeFile, |
| 465 | `{ "name": "Huge", "appearance": "dark", "canvas": "#1a1b26", "accent": "#7aa2f7", "note": "${padding}" }\n`, |
| 466 | ); |
| 467 | |
| 468 | const failure = yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe( |
| 469 | Effect.flip, |
| 470 | ); |
| 471 | assert.include(String(failure), "larger than"); |
| 472 | }), |
| 473 | ); |
| 474 | |
| 475 | it.effect("refuses a settings file that is not a JSON object", () => |
| 476 | Effect.gen(function* () { |
| 477 | const baseDir = makeBaseDir(); |
| 478 | NodeFS.mkdirSync(NodePath.dirname(settingsPathFor(baseDir)), { recursive: true }); |
| 479 | NodeFS.writeFileSync(settingsPathFor(baseDir), "[1, 2, 3]\n"); |
| 480 | |
| 481 | const failure = yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]).pipe( |
| 482 | Effect.flip, |
| 483 | ); |
| 484 | |
| 485 | assert.include(String(failure), "not a JSON object"); |
| 486 | }), |
| 487 | ); |
| 488 | }); |