Skip to content
Open
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
27 changes: 23 additions & 4 deletions packages/argue-cli/src/view.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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<string> {
const file = join(await mkdtemp(join(tmpdir(), "argue-view-")), "report.html");
const escaped = url.replaceAll("&", "&amp;").replaceAll('"', "&quot;");
await writeFile(file, `<!doctype html><meta http-equiv="refresh" content="0;url=${escaped}">`, "utf8");
return file;
}

export async function launchBrowser(url: string, options: LaunchBrowserOptions = {}): Promise<void> {
const platform = options.platform ?? process.platform;
const spawn =
Expand All @@ -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}`);
}
Expand Down
21 changes: 20 additions & 1 deletion packages/argue-cli/test/view.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -9,6 +9,7 @@ import {
encodeReportForUrl,
launchBrowser,
listCompletedRuns,
MAX_ARGV_URL_CHARS,
MAX_ENCODED_BYTES,
openReportInViewer,
resolveLatestRequestId
Expand Down Expand Up @@ -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("&", "&amp;"));
});

it("rejects unknown platforms with a clear error", async () => {
await expect(launchBrowser("https://example.com/", { platform: "aix", spawn: vi.fn() })).rejects.toThrow(
/Unsupported platform/
Expand Down