import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import test from "node:test"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const justArgs = ["--shell", "nu", "--clear-shell-args", "--shell-arg=--no-config-file", "--shell-arg=--commands", "termux"]; function run(command, args, options = {}) { const result = spawnSync(command, args, { encoding: "utf8", timeout: 120_000, ...options }); assert.equal(result.error, undefined); return result; } function success(result) { assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); return result.stdout; } function failure(result, message) { assert.notEqual(result.status, 0); assert.match(result.stderr, message); } const nu = success(run("nu", ["--no-config-file", "-c", "$nu.current-exe"])).trim(); function weztermColors(palette) { const lua = readFileSync(join(root, "wezterm/bugabinga/nugu/init.lua"), "utf8"); const lookup = (role) => role.split(".").reduce((value, key) => value[key], palette); const result = {}; for (const [termux, wezterm] of [["foreground", "foreground"], ["background", "background"], ["cursor", "cursor_bg"]]) { result[termux] = lookup(lua.match(new RegExp(`\\b${wezterm} = palette\\.([\\w.]+),`))[1]); } for (const [section, offset, count] of [["ansi", 0, 8], ["brights", 8, 8], ["indexed", 16, 7]]) { const body = lua.match(new RegExp(`\\b${section} = \\{([\\s\\S]*?)\\},`))[1]; const roles = [...body.matchAll(/palette\.([\w.]+),/g)].map((match) => match[1]); assert.equal(roles.length, count); roles.forEach((role, index) => { result[`color${offset + index}`] = lookup(role); }); } return result; } test("Termux palettes and manual switching", async (t) => { const workspace = mkdtempSync(join(tmpdir(), "termux-theme-test-")); t.after(() => rmSync(workspace, { recursive: true, force: true })); const nugu = join(workspace, "nugu"); const modules = join(workspace, "nushell/modules"); const home = join(workspace, "home"); const settings = join(home, ".termux"); const bin = join(workspace, "bin"); for (const directory of [nugu, modules, settings, bin, join(home, ".config")]) mkdirSync(directory, { recursive: true }); for (const file of ["base.toml", "nugu.nu", "termux.nu", "justfile"]) cpSync(join(root, "nugu", file), join(nugu, file)); cpSync(join(root, "nushell/modules/termux-theme.nu"), join(modules, "termux-theme.nu")); symlinkSync(join(workspace, "nushell"), join(home, ".config/nushell"), "junction"); const module = join(home, ".config/nushell/modules/termux-theme.nu"); const active = join(settings, "colors.properties"); const backup = `${active}.before-nugu`; const keyboard = join(settings, "termux.properties"); const log = join(workspace, "reload.log"); const original = "# original palette\nbackground=#20201d\n"; writeFileSync(active, original); writeFileSync(keyboard, "extra-keys = []\nfullscreen = true\n"); const env = { ...process.env, HOME: home, USERPROFILE: home, TERMUX_VERSION: "test", PATH: bin, RELOAD_LOG: log }; const invoke = (mode, extraEnv = {}) => run(nu, ["--no-config-file", "-c", `use ${JSON.stringify(module)}; termux-theme ${mode}`], { cwd: home, env: { ...env, ...extraEnv } }); const themes = {}; await t.test("generate both variants from source, matching WezTerm, without selecting a mode", () => { success(run("just", justArgs, { cwd: nugu })); assert.equal(readFileSync(active, "utf8"), original); assert.equal(existsSync(backup), false); assert.equal(existsSync(join(workspace, "termux/colors.properties")), false); for (const mode of ["dark", "light"]) { const filename = `nugu-${mode}.properties`; const text = readFileSync(join(nugu, "output/termux", filename), "utf8"); themes[mode] = text; assert.equal(readFileSync(join(workspace, "termux/themes", filename), "utf8"), text); assert.equal(readFileSync(join(root, "termux/themes", filename), "utf8"), text); const lines = text.trim().split(/\r?\n/).filter((line) => !line.startsWith("#")); for (const line of lines) assert.match(line, /^(foreground|background|cursor|color\d+)=#[0-9a-f]{6}$/); const properties = Object.fromEntries(lines.map((line) => line.split("="))); assert.equal(Object.keys(properties).length, lines.length, "no duplicate properties"); const palette = JSON.parse(success(run(nu, ["--no-config-file", "-c", `use nugu.nu; nugu ${mode} | get palette | to json`], { cwd: nugu }))); assert.deepEqual(properties, weztermColors(palette)); } assert.notEqual(themes.dark, themes.light); failure(run(nu, ["--no-config-file", "-c", "use termux.nu; termux theme invalid"], { cwd: nugu }), /Expected dark or light/); }); await t.test("login.nu loads the command without a config.nu import", () => { const login = readFileSync(join(root, "nushell/login.nu"), "utf8"); const imports = login.match(/^use termux-theme\.nu$/gm); assert.equal(imports?.length, 1); assert.doesNotMatch(readFileSync(join(root, "nushell/config.nu"), "utf8"), /^use termux-theme\.nu$/m); const code = `const NU_LIB_DIRS = [${JSON.stringify(join(home, ".config/nushell/modules"))}]; ${imports[0]}; help termux-theme`; assert.match(success(run(nu, ["--no-config-file", "-c", code], { env })), /termux-theme/); assert.equal(existsSync(log), false); }); await t.test("invalid modes and missing Termux support do not write anything", () => { failure(invoke("invalid"), /Expected dark or light/); failure(invoke("dark"), /inside Termux/); assert.equal(readFileSync(active, "utf8"), original); assert.equal(existsSync(backup), false); }); const fake = join(bin, "reload.cjs"); writeFileSync(fake, 'require("node:fs").appendFileSync(process.env.RELOAD_LOG, "reload\\n"); process.exit(Number(process.env.RELOAD_EXIT ?? 0));\n'); if (process.platform === "win32") { writeFileSync(join(bin, "termux-reload-settings.cmd"), `@"${process.execPath}" "${fake}"\r\n`); } else { writeFileSync(join(bin, "termux-reload-settings"), `#!${process.execPath}\nrequire(${JSON.stringify(fake)});\n`, { mode: 0o755 }); } await t.test("import is inert and non-Termux use is rejected even with a reload executable", () => { success(run(nu, ["--no-config-file", "-c", `use ${JSON.stringify(module)}`], { env })); failure(invoke("dark", { TERMUX_VERSION: "" }), /inside Termux/); assert.equal(existsSync(log), false); assert.equal(readFileSync(active, "utf8"), original); }); await t.test("switch both ways, retain the original backup, and preserve touch settings", () => { for (const mode of ["dark", "light", "dark"]) { assert.match(success(invoke(mode)), new RegExp(`Termux: Nugu ${mode}`)); assert.equal(readFileSync(active, "utf8"), themes[mode]); assert.equal(readFileSync(backup, "utf8"), original); } assert.equal(readFileSync(log, "utf8"), "reload\nreload\nreload\n"); assert.equal(readFileSync(keyboard, "utf8"), "extra-keys = []\nfullscreen = true\n"); success(run("just", justArgs, { cwd: nugu })); assert.equal(readFileSync(active, "utf8"), themes.dark, "regeneration must not select a mode"); }); await t.test("missing palette fails before touching the current theme", () => { const source = join(workspace, "termux/themes/nugu-light.properties"); rmSync(source); failure(invoke("light"), /nugu-light/); assert.equal(readFileSync(active, "utf8"), themes.dark); writeFileSync(source, themes.light); }); await t.test("reload failures report that the file was saved, preserving the backup", () => { failure(invoke("light", { RELOAD_EXIT: "7" }), /saved, but Termux reload failed/); assert.equal(readFileSync(active, "utf8"), themes.light); assert.equal(readFileSync(backup, "utf8"), original); }); await t.test("refuse non-regular active files and backup paths", () => { for (const file of [active, backup]) { const content = readFileSync(file, "utf8"); rmSync(file); mkdirSync(file); failure(invoke("dark"), /Refusing non-regular file/); rmSync(file, { recursive: true }); writeFileSync(file, content); } assert.equal(readFileSync(active, "utf8"), themes.light); }); await t.test("refuse an active symlink instead of overwriting a palette source", { skip: process.platform === "win32" }, () => { rmSync(active); const target = join(workspace, "termux/themes/nugu-light.properties"); symlinkSync(target, active); failure(invoke("dark"), /Refusing non-regular file/); assert.equal(readFileSync(target, "utf8"), themes.light); rmSync(active); }); await t.test("refuse a symlinked settings directory without touching its target", () => { rmSync(settings, { recursive: true }); const target = join(workspace, "other-settings"); mkdirSync(target); writeFileSync(join(target, "colors.properties"), original); symlinkSync(target, settings, "junction"); failure(invoke("dark"), /regular directory/); assert.equal(readFileSync(join(target, "colors.properties"), "utf8"), original); rmSync(settings); }); await t.test("initialize absent settings without inventing a backup", () => { success(invoke("light")); assert.equal(readFileSync(active, "utf8"), themes.light); assert.equal(existsSync(backup), false); }); });