Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "codeviewer",
"version": "1.2.0",
"version": "1.2.1",
"description": "A fast, read-only code viewer with a real built-in terminal and live git indicators. Zero dependencies.",
"license": "MIT",
"author": "21no.de",
Expand Down
141 changes: 141 additions & 0 deletions test/boxdraw.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { describe, expect, test } from "bun:test";
import { drawProcedural, isProcedural } from "../src/client/term/boxdraw.ts";

type Op = [string, ...number[]];

function fakeCtx() {
const ops: Op[] = [];
const stack: number[] = [];
const ctx = {
fillStyle: "#fff",
strokeStyle: "",
lineWidth: 0,
globalAlpha: 1,
save: () => (stack.push(ctx.globalAlpha), ops.push(["save"])),
restore: () => (ctx.globalAlpha = stack.pop()!, ops.push(["restore"])),
beginPath: () => ops.push(["begin"]),
stroke: () => ops.push(["stroke"]),
moveTo: (x: number, y: number) => ops.push(["moveTo", x, y]),
lineTo: (x: number, y: number) => ops.push(["lineTo", x, y]),
arcTo: (...a: number[]) => ops.push(["arcTo", ...a]),
fillRect: (x: number, y: number, w: number, h: number) => ops.push(["rect", x, y, w, h]),
};
return { ctx: ctx as unknown as CanvasRenderingContext2D, raw: ctx, ops };
}

const draw = (cp: number, w = 10, h = 20, dpr = 1) => {
const f = fakeCtx();
drawProcedural(f.ctx, cp, 0, 0, w, h, dpr);
return f;
};

describe("boxdraw: isProcedural", () => {
test("covers lines and blocks, excludes dashed/diagonals and other ranges", () => {
expect(isProcedural(0x2500)).toBe(true); // ─
expect(isProcedural(0x254c)).toBe(false); // dashed
expect(isProcedural(0x2571)).toBe(false); // diagonal
expect(isProcedural(0x256d)).toBe(true); // rounded
expect(isProcedural(0x2580)).toBe(true);
expect(isProcedural(0x259f)).toBe(true);
expect(isProcedural(0x25a0)).toBe(false);
expect(isProcedural(0x41)).toBe(false);
});
});

describe("boxdraw: lines", () => {
test("light horizontal is two arms overlapping at the center, spanning the cell", () => {
expect(draw(0x2500).ops).toEqual([["rect", 0, 9, 5, 1], ["rect", 4, 9, 6, 1]]);
});

test("light vertical", () => {
expect(draw(0x2502).ops).toEqual([["rect", 4, 0, 1, 10], ["rect", 4, 9, 1, 11]]);
});

test("heavy line is twice as thick", () => {
expect(draw(0x2501).ops).toEqual([["rect", 0, 9, 5, 2], ["rect", 4, 9, 6, 2]]);
});

test("dpr scales thickness", () => {
const { ops } = draw(0x2502, 10, 20, 2);
expect(ops[0]![3]).toBe(2);
});

test("corner draws two segments that meet", () => {
expect(draw(0x250c).ops).toEqual([["rect", 4, 9, 1, 11], ["rect", 4, 9, 6, 1]]); // ┌
});

test("cross draws four segments", () => {
expect(draw(0x253c).ops.length).toBe(4);
});

test("double lines draw two parallel rects per arm", () => {
expect(draw(0x2550).ops).toEqual([["rect", 0, 8, 6, 1], ["rect", 0, 10, 6, 1], ["rect", 3, 8, 7, 1], ["rect", 3, 10, 7, 1]]);
expect(draw(0x2551).ops.length).toBe(4);
});

test("mixed heavy/light and half lines", () => {
expect(draw(0x257a).ops).toEqual([["rect", 4, 9, 6, 2]]); // heavy right
expect(draw(0x2574).ops.length).toBe(1); // light left
expect(draw(0x2578).ops.length).toBe(1); // heavy left
expect(draw(0x257c).ops).toEqual([["rect", 0, 9, 5, 1], ["rect", 4, 9, 6, 2]]); // light left, heavy right
});

test("rounded corners stroke an arc with the foreground color", () => {
for (const cp of [0x256d, 0x256e, 0x256f, 0x2570]) {
const { ops, raw } = draw(cp);
expect(ops[0]![0]).toBe("save");
expect(ops.some((o) => o[0] === "arcTo")).toBe(true);
expect(ops.some((o) => o[0] === "stroke")).toBe(true);
expect(ops.at(-1)![0]).toBe("restore");
expect(raw.strokeStyle).toBe("#fff");
}
});
});

describe("boxdraw: blocks", () => {
test("half blocks", () => {
expect(draw(0x2580).ops).toEqual([["rect", 0, 0, 10, 10]]);
expect(draw(0x2584).ops).toEqual([["rect", 0, 10, 10, 10]]);
expect(draw(0x258c).ops).toEqual([["rect", 0, 0, 5, 20]]);
expect(draw(0x2590).ops).toEqual([["rect", 5, 0, 5, 20]]);
});

test("full block and eighths", () => {
expect(draw(0x2588).ops).toEqual([["rect", 0, 0, 10, 20]]);
const low = draw(0x2581, 8, 16).ops[0]!;
expect(low.slice(1)).toEqual([0, 14, 8, 2]);
const left = draw(0x258f, 8, 16).ops[0]!;
expect(left.slice(1)).toEqual([0, 0, 1, 16]);
});

test("shades use alpha and restore it", () => {
const f = fakeCtx();
const alphas: number[] = [];
const orig = f.raw.fillRect;
f.raw.fillRect = (...a: [number, number, number, number]) => {
alphas.push(f.raw.globalAlpha);
orig(...a);
};
for (const cp of [0x2591, 0x2592, 0x2593]) drawProcedural(f.ctx, cp, 0, 0, 10, 20, 1);
expect(alphas).toEqual([0.25, 0.5, 0.75]);
expect(f.raw.globalAlpha).toBe(1);
});

test("upper and right eighths", () => {
expect(draw(0x2594, 8, 16).ops[0]!.slice(1)).toEqual([0, 0, 8, 2]);
expect(draw(0x2595, 8, 16).ops[0]!.slice(1)).toEqual([7, 0, 1, 16]);
});

test("quadrants", () => {
expect(draw(0x2596).ops).toEqual([["rect", 0, 10, 5, 10]]); // lower left
expect(draw(0x2597).ops).toEqual([["rect", 5, 10, 5, 10]]); // lower right
expect(draw(0x2598).ops).toEqual([["rect", 0, 0, 5, 10]]); // upper left
expect(draw(0x259b).ops.length).toBe(3); // UL+UR+LL
expect(draw(0x259f).ops.length).toBe(3); // UR+LL+LR
expect(draw(0x259a).ops.length).toBe(2); // UL+LR
expect(draw(0x259e).ops.length).toBe(2); // UR+LL
expect(draw(0x259c).ops.length).toBe(3);
expect(draw(0x2599).ops.length).toBe(3);
expect(draw(0x259d).ops).toEqual([["rect", 5, 0, 5, 10]]);
});
});
162 changes: 162 additions & 0 deletions test/browser.coverage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { afterEach, beforeEach, expect, test } from "bun:test";
import { chmod, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { appWindowArgs, bootPage, closeAppWindow, findAppBrowser, findMacApp, pickFolder, runAppWindow } from "../src/server/browser.ts";

let tmp = "";
const env = { PATH: process.env.PATH, B: process.env.CODEVIEWER_BROWSER };
beforeEach(async () => {
tmp = await mkdtemp(path.join(tmpdir(), "cv-browser-"));
});
afterEach(async () => {
process.env.PATH = env.PATH;
if (env.B === undefined) delete process.env.CODEVIEWER_BROWSER;
else process.env.CODEVIEWER_BROWSER = env.B;
await rm(tmp, { recursive: true, force: true });
});

async function script(name: string, body: string) {
const p = path.join(tmp, name);
await writeFile(p, `#!/bin/sh\n${body}\n`);
await chmod(p, 0o755);
return p;
}

test("appWindowArgs isolates the window: own profile, dead proxy, no resolver", () => {
const a = appWindowArgs("file:///x.html", "/tmp/prof");
expect(a).toContain("--app=file:///x.html");
expect(a).toContain("--user-data-dir=/tmp/prof");
expect(a).toContain("--proxy-server=127.0.0.1:9");
expect(a.some((x) => x.startsWith("--host-resolver-rules=MAP * ~NOTFOUND"))).toBe(true);
expect(a).toContain("--disable-extensions");
expect(a.every((x) => x.startsWith("--"))).toBe(true);
expect(a).not.toContain("--no-sandbox");
});

test("bootPage writes a private redirect page, strips unsafe chars, and cleans up once", async () => {
const url = 'http://127.0.0.1:1234/?t=abc"><script>alert(1)</script>#x';
const b = await bootPage(url);
const file = fileURLToPath(b.fileUrl);
expect(existsSync(file)).toBe(true);
const html = await readFile(file, "utf8");
expect(html).toContain('http-equiv="refresh"');
expect(html).not.toContain("<script>");
expect(html.match(/content="0;url=([^"]*)"/)![1]).toBe("http://127.0.0.1:1234/?t=abcscriptalert1/script#x");
expect(html).toContain("http://127.0.0.1:1234/?t=abc");
if (process.platform !== "win32") {
expect((await stat(file)).mode & 0o777).toBe(0o600);
expect((await stat(path.dirname(file))).mode & 0o777).toBe(0o700);
}
await b.cleanup();
expect(existsSync(path.dirname(file))).toBe(false);
await b.cleanup(); // idempotent
});

test("findAppBrowser prefers $CODEVIEWER_BROWSER when it exists", async () => {
const fake = await script("mybrowser", "exit 0");
process.env.CODEVIEWER_BROWSER = fake;
expect(findAppBrowser()).toEqual({ path: fake, bundled: false });
});

test("findAppBrowser ignores a missing $CODEVIEWER_BROWSER", () => {
process.env.CODEVIEWER_BROWSER = path.join(tmp, "nope");
const r = findAppBrowser();
if (r) expect(r.path).not.toBe(path.join(tmp, "nope"));
});

test.skipIf(process.platform === "darwin")("findAppBrowser finds a Chromium-family browser on PATH (Linux)", async () => {
delete process.env.CODEVIEWER_BROWSER;
const exeDir = path.dirname(process.execPath);
if (existsSync(path.join(exeDir, "chromium", "chrome"))) return; // bundled copy wins
const fake = await script("chromium", "exit 0");
process.env.PATH = tmp;
expect(findAppBrowser()).toEqual({ path: fake, bundled: false });
});

test.skipIf(process.platform === "darwin")("findAppBrowser returns null with nothing installed (Linux)", () => {
delete process.env.CODEVIEWER_BROWSER;
if (existsSync(path.join(path.dirname(process.execPath), "chromium", "chrome"))) return;
process.env.PATH = tmp;
expect(findAppBrowser()).toBeNull();
});

test("findMacApp is null off macOS and a .app path (or null) on macOS", () => {
const r = findMacApp();
if (process.platform !== "darwin") expect(r).toBeNull();
else if (r) expect(r.endsWith(".app")).toBe(true);
});

test("runAppWindow resolves 'closed' when the browser exits cleanly, and removes its temp profile", async () => {
const log = path.join(tmp, "args.txt");
const fake = await script("ok", `printf '%s\\n' "$@" > '${log}'\nexit 0`);
expect(await runAppWindow(fake, "http://127.0.0.1:9999/?t=secret")).toBe("closed");
const args = (await readFile(log, "utf8")).split("\n");
// The token-bearing URL must never reach argv; a boot page is used instead.
expect(args.join(" ")).not.toContain("secret");
const app = args.find((a) => a.startsWith("--app="))!;
expect(app.startsWith("--app=file://")).toBe(true);
const profile = args.find((a) => a.startsWith("--user-data-dir="))!.slice("--user-data-dir=".length);
expect(existsSync(profile)).toBe(false);
expect(existsSync(fileURLToPath(app.slice("--app=".length)))).toBe(false);
});

test("runAppWindow resolves 'failed' on a quick non-zero exit and explains sandbox errors", async () => {
const fake = await script("sandboxfail", `echo "No usable sandbox!" >&2\nexit 1`);
const errs: string[] = [];
const orig = console.error;
console.error = (...a: unknown[]) => void errs.push(a.join(" "));
try {
expect(await runAppWindow(fake, "http://127.0.0.1:1/")).toBe("failed");
} finally {
console.error = orig;
}
expect(errs.join("\n")).toContain("sandbox");
});

test("runAppWindow echoes the browser's stderr tail for other quick failures", async () => {
const fake = await script("otherfail", `echo "boom" >&2\nexit 2`);
const errs: string[] = [];
const orig = console.error;
console.error = (...a: unknown[]) => void errs.push(a.join(" "));
try {
expect(await runAppWindow(fake, "http://127.0.0.1:1/")).toBe("failed");
} finally {
console.error = orig;
}
expect(errs.join("\n")).toContain("boom");
});

test("closeAppWindow kills the running window so runAppWindow resolves", async () => {
const fake = await script("sleeper", "sleep 30");
const p = runAppWindow(fake, "http://127.0.0.1:1/");
await Bun.sleep(300);
closeAppWindow();
const r = await Promise.race([p, Bun.sleep(8000).then(() => "timeout")]);
expect(r === "closed" || r === "failed").toBe(true);
closeAppWindow(); // nothing running: no-op
});

test.skipIf(process.platform === "darwin")("pickFolder uses zenity, then kdialog, then falls back to home (Linux)", async () => {
process.env.PATH = tmp;
const home = (await import("node:os")).homedir();
expect(await pickFolder()).toBe(home);
// Bun.spawn resolves commands against the PATH the process started with, so the dialog
// branches run in a child process launched with the fake PATH.
const src = path.join(path.dirname(fileURLToPath(import.meta.url)), "../src/server/browser.ts");
const pick = async () => {
const p = Bun.spawn([process.execPath, "-e", `const { pickFolder } = await import(${JSON.stringify(src)}); console.log(JSON.stringify(await pickFolder()));`], {
env: { PATH: tmp, HOME: home },
stdout: "pipe",
});
return JSON.parse((await new Response(p.stdout).text()).trim());
};
await script("kdialog", `echo /picked/kde`);
expect(await pick()).toBe("/picked/kde");
await script("zenity", `echo /picked/gtk`);
expect(await pick()).toBe("/picked/gtk");
await script("zenity", `exit 1`); // cancel
expect(await pick()).toBeNull();
});
Loading
Loading