diff --git a/packages/argue-cli/src/view.ts b/packages/argue-cli/src/view.ts index cbab6bd..429edd1 100644 --- a/packages/argue-cli/src/view.ts +++ b/packages/argue-cli/src/view.ts @@ -1,5 +1,7 @@ -import { access, readdir, readFile } from "node:fs/promises"; +import { access, mkdtemp, readdir, readFile, writeFile } from "node:fs/promises"; import { spawn as nodeSpawn } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { gzipSync } from "node:zlib"; import { REQUEST_ID_PATTERN } from "./request-id.js"; @@ -96,6 +98,21 @@ export type LaunchBrowserOptions = { spawn?: BrowserSpawnFn; }; +/** + * Longest URL we hand the OS handler as an argument. ARG_MAX is not the ceiling: + * handoff to an already-running browser truncates well below it (Chromium's Linux + * ProcessSingleton socket), cutting `#d=` short so the viewer cannot gunzip it. + * Above this size we hand over a temp redirect page instead. + */ +export const MAX_ARGV_URL_CHARS = 8_000; + +async function writeRedirectPage(url: string): Promise { + const file = join(await mkdtemp(join(tmpdir(), "argue-view-")), "report.html"); + const escaped = url.replaceAll("&", "&").replaceAll('"', """); + await writeFile(file, ``, "utf8"); + return file; +} + export async function launchBrowser(url: string, options: LaunchBrowserOptions = {}): Promise { const platform = options.platform ?? process.platform; const spawn = @@ -105,18 +122,20 @@ export async function launchBrowser(url: string, options: LaunchBrowserOptions = child.unref(); }); + const target = url.length > MAX_ARGV_URL_CHARS ? await writeRedirectPage(url) : url; + let cmd: string; let args: string[]; if (platform === "darwin") { cmd = "open"; - args = [url]; + args = [target]; } else if (platform === "linux") { cmd = "xdg-open"; - args = [url]; + args = [target]; } else if (platform === "win32") { // `start` is a cmd.exe builtin; first quoted arg is the window title (empty). cmd = "cmd"; - args = ["/c", "start", "", url]; + args = ["/c", "start", "", target]; } else { throw new Error(`Unsupported platform for launchBrowser: ${platform}`); } diff --git a/packages/argue-cli/test/view.test.ts b/packages/argue-cli/test/view.test.ts index e520435..41ccb68 100644 --- a/packages/argue-cli/test/view.test.ts +++ b/packages/argue-cli/test/view.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, writeFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { randomBytes } from "node:crypto"; @@ -9,6 +9,7 @@ import { encodeReportForUrl, launchBrowser, listCompletedRuns, + MAX_ARGV_URL_CHARS, MAX_ENCODED_BYTES, openReportInViewer, resolveLatestRequestId @@ -196,6 +197,24 @@ describe("launchBrowser", () => { expect(spawned).toEqual([{ cmd: "cmd", args: ["/c", "start", "", "https://example.com/#x"] }]); }); + it("hands over a redirect file instead of an oversized URL argument", async () => { + const url = `https://example.com/#v=1&d=${"a".repeat(MAX_ARGV_URL_CHARS)}`; + const spawned: Array<{ cmd: string; args: string[] }> = []; + await launchBrowser(url, { + platform: "linux", + spawn: (cmd, args) => { + spawned.push({ cmd, args }); + } + }); + + expect(spawned).toHaveLength(1); + const handedOver = spawned[0]!.args[0]!; + // The full URL survives inside the page, with `&` escaped so the parser + // cannot swallow `&d=` as an entity. + const page = await readFile(handedOver, "utf8"); + expect(page).toContain(url.replaceAll("&", "&")); + }); + it("rejects unknown platforms with a clear error", async () => { await expect(launchBrowser("https://example.com/", { platform: "aix", spawn: vi.fn() })).rejects.toThrow( /Unsupported platform/