repositories / dotfiles
dotfiles
bugabingas dorkfiles
owned by admin
termux/theme.test.mjs
Rawimport 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);
});
});