Luigit
repositories / dotfiles

dotfiles

bugabingas dorkfiles

owned by admin

termux/theme.test.mjs

Raw
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);
    });
});