Files
Triple-C/app/src/lib/urlDetector.test.ts
T

311 lines
12 KiB
TypeScript
Raw Normal View History

2026-08-11 10:36:30 -07:00
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { UrlDetector, flatten, osc8Targets, usableLink } from "./urlDetector";
import type { UrlSource } from "./urlDetector";
2026-08-11 10:36:30 -07:00
const COLS = 80;
const enc = new TextEncoder();
/** Feed text and let the debounce + confirmation timers run. */
function feed(detector: UrlDetector, text: string) {
detector.feed(enc.encode(text));
vi.advanceTimersByTime(2000);
}
/** Hard-wrap the way a PTY does: a break every `cols` characters, nothing lost. */
function ptyWrap(text: string, cols = COLS): string {
const lines: string[] = [];
for (let i = 0; i < text.length; i += cols) lines.push(text.slice(i, i + cols));
return lines.join("\r\n");
}
/**
* One OSC 8 hyperlink emission: the whole URL in the parameter, a slice of it
* as visible text.
*
* This is what `claude setup-token` actually prints — measured against 2.1.226,
* a 346-character URL arrives as five of these, each carrying the complete URL
* and 80 characters of it on screen.
*/
function osc8(uri: string, visible: string): string {
return `\x1b]8;;${uri}\x07${visible}\x1b]8;;\x07`;
}
/** Slice `uri` into `width`-character visible pieces, each a full hyperlink. */
function slicedHyperlink(uri: string, width = COLS): string {
const parts: string[] = [];
for (let i = 0; i < uri.length; i += width) {
parts.push(osc8(uri, uri.slice(i, i + width)));
}
return parts.join("\r\n");
}
/** The URL Claude Code prints, at the length it really is. */
const SIGN_IN_URL =
"https://claude.ai/oauth/authorize?code=true&client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e" +
"&response_type=code&redirect_uri=https%3A%2F%2Fconsole.anthropic.com%2Foauth%2Fcode%2Fcallback" +
"&scope=org%3Acreate_api_key+user%3Aprofile+user%3Ainference&code_challenge=" +
"vJ8Kq2mN4pR7sT9wX1zA3bC5dE6fG8hJ0kL2mN4pQ6r&code_challenge_method=S256&state=aB3dE5gH7jK9";
2026-08-11 10:36:30 -07:00
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
describe("flatten", () => {
it("rejoins a break the terminal inserted at the width", () => {
expect(flatten("abcde\nfghij", 5)).toBe("abcdefghij");
});
it("keeps a break that arrived before the width as a separator", () => {
expect(flatten("abc\ndef", 5)).toBe("abc def");
});
it("rejoins nothing when the width isn't known", () => {
// Better to lose a wrapped URL than to invent one.
expect(flatten("abcde\nfghij", 0)).toBe("abcde fghij");
});
});
describe("UrlDetector", () => {
it("reconstructs a URL the PTY hard-wrapped mid-token", () => {
const seen: string[] = [];
const d = new UrlDetector((u) => seen.push(u), () => COLS);
const url =
"https://accounts.example.com/o/oauth2/auth?client_id=1234567890-abcdefghijklmnop.apps.example.com&redirect_uri=http%3A%2F%2Flocalhost%3A45678&scope=openid+email+profile";
feed(d, "Open this link:\r\n" + ptyWrap(url) + "\r\nWaiting for the browser…\r\n");
expect(seen).toEqual([url]);
});
it("does not glue the text that follows a link onto it", () => {
// The bug this file exists for. A terminal wrapping a paragraph emits the
// break *instead of* the space, so deleting every break produced
// `…/tag/preview-63f3c54Butitprovesyournitpick…` — a different host and
// path from the one on screen, opened on the user's machine.
const seen: string[] = [];
const d = new UrlDetector((u) => seen.push(u), () => COLS);
const url =
"https://repo.anhonesthost.net/CyberCoveLLC/Triple-C/releases/tag/preview-63f3c54-with-a-long-enough-suffix-to-scan";
feed(
d,
[
url,
"But it proves your nitpick perfectly: every file in it says 0.3.0.",
"That is the hard-coded patch number.",
].join("\r\n") + "\r\n",
);
expect(seen).toEqual([url]);
expect(seen[0]).not.toContain("But");
// And the host is exactly what was printed — no characters lost.
expect(new URL(seen[0]).host).toBe("repo.anhonesthost.net");
});
it("stops at the end of a short line even when more output follows", () => {
const seen: string[] = [];
const d = new UrlDetector((u) => seen.push(u), () => COLS);
const url = "https://example.com/" + "a".repeat(90);
feed(d, `${url}\r\nnext line of output\r\n`);
expect(seen).toEqual([url]);
});
it("joins the next line when a token ends exactly at the width", () => {
// The one case the width rule cannot decide: a URL whose length is an exact
// multiple of the column count looks identical to one that was cut. Pinned
// as known behaviour rather than pretended away — the toast still shows the
// whole candidate, and nothing opens without the user pressing Open.
const seen: string[] = [];
const d = new UrlDetector((u) => seen.push(u), () => COLS);
const url = "https://example.com/" + "c".repeat(2 * COLS - 20); // exactly 2 lines
feed(d, `${ptyWrap(url)}\r\nTAIL\r\n`);
expect(seen).toEqual([url + "TAIL"]);
});
it("ignores anything under the length threshold", () => {
const seen: string[] = [];
const d = new UrlDetector((u) => seen.push(u), () => COLS);
feed(d, "see https://example.com/short\r\nmore text\r\n");
expect(seen).toEqual([]);
});
it("emits a wrapped URL once, not once per chunk", () => {
const seen: string[] = [];
const d = new UrlDetector((u) => seen.push(u), () => COLS);
const url = "https://example.com/" + "b".repeat(120);
feed(d, ptyWrap(url));
feed(d, "\r\ndone\r\n");
expect(seen).toEqual([url]);
});
});
describe("usableLink", () => {
// Ports `usable_sign_in_link` from `commands/auth_token_commands.rs` — a junk
// filter, not the security decision. `sanitizeRelayUrl` is still what stands
// between any of this and `openUrl`.
it("accepts an ordinary authorize URL", () => {
expect(usableLink(SIGN_IN_URL)).toBe(true);
});
it("rejects a scheme that is not http(s)", () => {
expect(usableLink("file:///etc/passwd")).toBe(false);
expect(usableLink("javascript:alert(1)")).toBe(false);
});
it("rejects anything outside printable ASCII", () => {
// A control character is how a URL is smuggled past a display, and
// `new URL()` strips some of them silently.
expect(usableLink("https://example.com/\u0000x")).toBe(false);
expect(usableLink("https://exa\u200bmple.com/x")).toBe(false);
expect(usableLink("https://example.com/a b")).toBe(false);
});
});
describe("osc8Targets", () => {
it("lifts the whole URL out of a sliced emission", () => {
const raw = slicedHyperlink(SIGN_IN_URL);
// Every piece carries the complete URL, however little of it is on screen.
expect(new Set(osc8Targets(raw))).toEqual(new Set([SIGN_IN_URL]));
});
it("ignores the closing half of a hyperlink", () => {
expect(osc8Targets("\x1b]8;;\x07")).toEqual([]);
});
it("ignores other OSCs, including the URL relay's own", () => {
expect(osc8Targets("\x1b]0;a window title\x07")).toEqual([]);
expect(osc8Targets("\x1b]7777;open;aHR0cHM6Ly9leGFtcGxlLmNvbQ==\x07")).toEqual([]);
});
it("reads a hyperlink terminated by ST as well as by BEL", () => {
expect(osc8Targets(`\x1b]8;id=1;${SIGN_IN_URL}\x1b\\text`)).toEqual([SIGN_IN_URL]);
});
});
describe("UrlDetector — OSC 8", () => {
it("recovers the complete URL from a sliced hyperlink", () => {
// The bug this branch exists for. `ANSI_RE` strips OSC sequences wholesale,
// so the scraper never saw the parameter and reassembled the *visible*
// pieces instead — a URL that parses, points at claude.ai, and cannot
// authorise anything.
const seen: [string, UrlSource][] = [];
const d = new UrlDetector((u, src) => seen.push([u, src]), () => COLS);
feed(d, "Open this link to sign in:\r\n" + slicedHyperlink(SIGN_IN_URL) + "\r\ndone\r\n");
expect(seen[0]).toEqual([SIGN_IN_URL, "osc8"]);
});
it("does not emit the same hyperlink again when it is repainted", () => {
const seen: [string, UrlSource][] = [];
const d = new UrlDetector((u, s) => seen.push([u, s]), () => COLS);
feed(d, slicedHyperlink(SIGN_IN_URL) + "\r\n");
feed(d, slicedHyperlink(SIGN_IN_URL) + "\r\n");
expect(seen.filter(([u]) => u === SIGN_IN_URL)).toHaveLength(1);
});
it("marks a scraped candidate as a guess, so the slot can refuse it", () => {
// Nothing here decides precedence — that is `supersedes` in TerminalView —
// but it is what makes the decision possible.
const seen: [string, UrlSource][] = [];
const d = new UrlDetector((u, s) => seen.push([u, s]), () => COLS);
const url = "https://example.com/" + "z".repeat(120);
feed(d, url + "\r\nnext\r\n");
expect(seen).toEqual([[url, "heuristic"]]);
});
it("ignores a short hyperlink", () => {
// `ls --hyperlink` decorates every filename; none of that is a prompt.
const seen: string[] = [];
const d = new UrlDetector((u) => seen.push(u), () => COLS);
feed(d, osc8("https://example.com/a", "a") + "\r\nnext\r\n");
expect(seen).toEqual([]);
});
});
describe("UrlDetector — repaints and resizes", () => {
it("treats a bare CR as a line break", () => {
// A TUI repaints by returning to column 0 without a line feed. Splitting on
// `\r?\n` alone leaves a whole frame on one "line", which is then longer
// than the width — so the `===` test says "not wrapped" and a break the
// terminal really did insert is never rejoined.
const seen: string[] = [];
const d = new UrlDetector((u) => seen.push(u), () => COLS);
const url = "https://example.com/" + "q".repeat(100);
feed(d, "spinner frame one\rspinner frame two\r" + ptyWrap(url) + "\r\ndone\r\n");
expect(seen).toEqual([url]);
});
it("does not glue two repainted frames into one token", () => {
const seen: string[] = [];
const d = new UrlDetector((u) => seen.push(u), () => COLS);
feed(
d,
"https://example.com/" + "a".repeat(90) + "\rhttps://evil.tld/" + "b".repeat(90) + "\r\n\r\ndone\r\n",
);
for (const url of seen) expect(new URL(url).host).not.toBe("example.comhttps");
});
it("reassembles with the width the bytes were printed at, not the current one", () => {
// The scan runs 300 ms after the print. A resize inside that window used to
// change every join decision retroactively: text wrapped at 80 columns,
// rejoined against a width of 120, comes back as separate lines glued with
// spaces — or, the other way round, as a URL nobody printed.
const seen: string[] = [];
let cols = COLS;
const d = new UrlDetector((u) => seen.push(u), () => cols);
const url =
"https://accounts.example.com/o/oauth2/auth?client_id=1234567890-abcdefghijklmnop.apps.example.com&redirect_uri=http%3A%2F%2Flocalhost%3A45678";
d.feed(new TextEncoder().encode(ptyWrap(url) + "\r\nWaiting…\r\n"));
cols = 120; // the user drags the window wider before the debounce fires
vi.advanceTimersByTime(2000);
expect(seen).toEqual([url]);
});
it("drops text buffered at a width that no longer applies", () => {
// Half printed at 80, half at 120: no single width reassembles both, so the
// older half goes rather than being joined by a rule that is wrong for it.
const seen: string[] = [];
let cols = COLS;
const d = new UrlDetector((u) => seen.push(u), () => cols);
const url = "https://example.com/" + "m".repeat(120);
d.feed(new TextEncoder().encode(ptyWrap(url.slice(0, 100))));
cols = 120;
feed(d, url.slice(100) + "\r\ndone\r\n");
// Whatever survives, it is never a URL that was not printed.
for (const u of seen) expect(url.startsWith(u) || u.startsWith(url)).toBe(true);
});
});
describe("flatten — bare CR", () => {
it("splits on a lone CR as well as on LF", () => {
expect(flatten("abcde\rfghij", 5)).toBe("abcdefghij");
expect(flatten("abc\rdef", 5)).toBe("abc def");
});
it("counts CRLF as one break, not two", () => {
expect(flatten("abcde\r\nfghij", 5)).toBe("abcdefghij");
});
});