diff --git a/package.json b/package.json index 0830d70..bbbfb8b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/test/boxdraw.test.ts b/test/boxdraw.test.ts new file mode 100644 index 0000000..3c5bc15 --- /dev/null +++ b/test/boxdraw.test.ts @@ -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]]); + }); +}); diff --git a/test/browser.coverage.test.ts b/test/browser.coverage.test.ts new file mode 100644 index 0000000..b8ed09b --- /dev/null +++ b/test/browser.coverage.test.ts @@ -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">#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("