mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 04:10:47 +02:00
feat: require explicit browser provider consent
This commit is contained in:
@@ -3,10 +3,13 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { PassThrough, Readable } from "node:stream";
|
||||
import { runDoctor } from "../runtime/doctor.js";
|
||||
import { runInstallerCli, runtimeSlotVersion, runtimeSurfaceForCapabilities } from "../runtime/install.js";
|
||||
import { resolveRuntimePaths } from "../runtime/paths.js";
|
||||
import { setupRuntime } from "../runtime/setup.js";
|
||||
import { configSetBrowserChoice } from "../runtime/config.js";
|
||||
import { detectInstalledBrowsers, resolveBrowserChoice } from "../runtime/browser-choice.mjs";
|
||||
import { bashCandidates, resolveBashCommand } from "../runtime/tooling.js";
|
||||
import {
|
||||
BOOTSTRAP_SCHEMA_VERSION,
|
||||
@@ -96,13 +99,13 @@ describe("GStack runtime setup UX", () => {
|
||||
}));
|
||||
const retained = capture();
|
||||
expect(await runInstallerCli([
|
||||
"--source", source, "--home", home, "--capabilities", "pdf", "--dry-run", "--json",
|
||||
"--source", source, "--home", home, "--capabilities", "pdf", "--browser", "managed", "--dry-run", "--json",
|
||||
], { stdout: retained.stream, stderr: retained.stream })).toBe(0);
|
||||
expect(JSON.parse(retained.value()).preview.capabilities).toEqual(["browser", "design", "diagram", "pdf"]);
|
||||
|
||||
const replaced = capture();
|
||||
expect(await runInstallerCli([
|
||||
"--source", source, "--home", home, "--capabilities", "pdf", "--replace-capabilities", "--dry-run", "--json",
|
||||
"--source", source, "--home", home, "--capabilities", "pdf", "--browser", "managed", "--replace-capabilities", "--dry-run", "--json",
|
||||
], { stdout: replaced.stream, stderr: replaced.stream })).toBe(0);
|
||||
expect(JSON.parse(replaced.value()).preview.capabilities).toEqual(["browser", "diagram", "pdf"]);
|
||||
} finally {
|
||||
@@ -146,6 +149,7 @@ describe("GStack runtime setup UX", () => {
|
||||
"--source", path.resolve(import.meta.dir, ".."),
|
||||
"--home", home,
|
||||
"--capabilities", "browser",
|
||||
"--browser", "managed",
|
||||
"--dry-run",
|
||||
"--json",
|
||||
], {
|
||||
@@ -166,6 +170,183 @@ describe("GStack runtime setup UX", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("installed-browser preview skips every managed Chromium payload without persisting the choice", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-installed-preview-"));
|
||||
const home = path.join(root, "home");
|
||||
const output = capture();
|
||||
try {
|
||||
expect(await runInstallerCli([
|
||||
"--source", path.resolve(import.meta.dir, ".."),
|
||||
"--home", home,
|
||||
"--capabilities", "browser",
|
||||
"--browser", "installed",
|
||||
"--browser-path", process.execPath,
|
||||
"--dry-run",
|
||||
"--json",
|
||||
], { stdout: output.stream, stderr: output.stream })).toBe(0);
|
||||
const preview = JSON.parse(output.value()).preview;
|
||||
expect(preview.browser).toEqual({ provider: "installed", executablePath: await fs.realpath(process.execPath) });
|
||||
expect(preview.materializations.some((item) => item.kind === "playwright-chromium-download")).toBe(false);
|
||||
expect(runtimeSurfaceForCapabilities(["browser"], {
|
||||
browserChoice: preview.browser,
|
||||
}).entries.some((entry) => entry.path === ".gstack-runtime-browsers")).toBe(false);
|
||||
await expect(fs.stat(home)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("installed-browser detection preserves wrapper paths, deduplicates physical targets, and rejects invalid files", async () => {
|
||||
if (process.platform === "win32") return;
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-browser-detect-"));
|
||||
try {
|
||||
const physical = path.join(root, "snap");
|
||||
const chrome = path.join(root, "google-chrome");
|
||||
const chromium = path.join(root, "chromium");
|
||||
const invalid = path.join(root, "not-executable");
|
||||
await fs.writeFile(physical, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
|
||||
await fs.writeFile(invalid, "not executable\n", { mode: 0o644 });
|
||||
await fs.symlink(physical, chrome);
|
||||
await fs.symlink(physical, chromium);
|
||||
|
||||
const detected = await detectInstalledBrowsers({
|
||||
platform: "linux",
|
||||
env: { PATH: root },
|
||||
homeDir: root,
|
||||
});
|
||||
expect(detected).toEqual([{ name: "Google Chrome", executablePath: chrome }]);
|
||||
expect(await resolveBrowserChoice({ provider: "installed", executablePath: chrome }, { platform: "linux" }))
|
||||
.toEqual({ provider: "installed", executablePath: chrome });
|
||||
await expect(resolveBrowserChoice({ provider: "installed", executablePath: invalid }, { platform: "linux" }))
|
||||
.rejects.toMatchObject({ code: "BROWSER_PATH_INVALID" });
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("interactive browser choice covers managed, installed, later, and invalid selections without installing", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-browser-choice-"));
|
||||
const source = path.join(root, "minimal-source");
|
||||
const installedBrowser = path.join(root, "google-chrome");
|
||||
await fs.mkdir(source);
|
||||
await fs.writeFile(installedBrowser, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
|
||||
try {
|
||||
const cases = [
|
||||
{ answer: "m", label: "managed isolated Chromium", code: 0 },
|
||||
{ answer: "1", label: installedBrowser, code: 0 },
|
||||
{ answer: "l", label: "No browser provider was selected", code: 0 },
|
||||
{ answer: "9", label: "Invalid browser selection", code: 1 },
|
||||
];
|
||||
for (const [index, fixture] of cases.entries()) {
|
||||
const home = path.join(root, `home-${index}`);
|
||||
const output = new PassThrough();
|
||||
const input = new PassThrough() as PassThrough & { isTTY: boolean };
|
||||
input.isTTY = true;
|
||||
let outputValue = "";
|
||||
let answeredBrowser = false;
|
||||
let answeredInstall = false;
|
||||
output.on("data", (chunk) => {
|
||||
outputValue += String(chunk);
|
||||
if (!answeredBrowser && outputValue.includes("Select m, a browser number, or l")) {
|
||||
answeredBrowser = true;
|
||||
input.write(`${fixture.answer}\n`);
|
||||
}
|
||||
if (!answeredInstall && outputValue.includes("Install this optional local runtime now?")) {
|
||||
answeredInstall = true;
|
||||
input.end("later\n");
|
||||
}
|
||||
});
|
||||
const code = await runInstallerCli([
|
||||
"--source", source,
|
||||
"--home", home,
|
||||
"--capabilities", "browser",
|
||||
], {
|
||||
stdin: input,
|
||||
stdout: output,
|
||||
stderr: output,
|
||||
platform: "linux",
|
||||
env: { ...process.env, PATH: root },
|
||||
homeDir: root,
|
||||
});
|
||||
expect(code, outputValue).toBe(fixture.code);
|
||||
expect(outputValue).toContain(fixture.label);
|
||||
await expect(fs.stat(home)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("browser bootstrap options are local-only and browser preview requires an explicit choice", async () => {
|
||||
const output = capture();
|
||||
let fetches = 0;
|
||||
const browser = await fs.realpath(process.execPath);
|
||||
expect(await bootstrapMain([
|
||||
"options", "--capability", "browser", "--json",
|
||||
], {
|
||||
stdout: output.stream,
|
||||
stderr: output.stream,
|
||||
browserCandidates: [{ name: "Fixture Chromium", executablePath: browser }],
|
||||
fetch: async () => { fetches += 1; throw new Error("unexpected fetch"); },
|
||||
})).toBe(0);
|
||||
expect(JSON.parse(output.value())).toMatchObject({
|
||||
ok: true,
|
||||
action: "options",
|
||||
mutated: false,
|
||||
network: false,
|
||||
installed: [{ name: "Fixture Chromium", executablePath: browser }],
|
||||
});
|
||||
expect(fetches).toBe(0);
|
||||
|
||||
const missing = capture();
|
||||
expect(await bootstrapMain(["preview", "--capability", "browser"], {
|
||||
stdout: missing.stream,
|
||||
stderr: missing.stream,
|
||||
fetch: async () => { fetches += 1; throw new Error("unexpected fetch"); },
|
||||
})).toBe(1);
|
||||
expect(missing.value()).toContain("Choose a browser provider");
|
||||
expect(fetches).toBe(0);
|
||||
});
|
||||
|
||||
test("official installed-browser preview reports exact adapter bytes and omits browser binaries", async () => {
|
||||
const output = capture();
|
||||
const target = `${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`;
|
||||
let fetches = 0;
|
||||
expect(await bootstrapMain([
|
||||
"preview", "--capability", "browser", "--browser", "installed",
|
||||
"--browser-path", process.execPath, "--json",
|
||||
], {
|
||||
stdout: output.stream,
|
||||
stderr: output.stream,
|
||||
libc: process.platform === "linux" ? "glibc" : undefined,
|
||||
fetch: async (url: string) => {
|
||||
fetches += 1;
|
||||
return { ok: true, url, json: async () => officialManifestFixture(target) };
|
||||
},
|
||||
})).toBe(0);
|
||||
const result = JSON.parse(output.value());
|
||||
expect(result.browser).toEqual({ provider: "installed", executablePath: await fs.realpath(process.execPath) });
|
||||
expect(result.components).toEqual(["browser-code", "core"]);
|
||||
expect(result.downloads.map((item) => item.component)).toEqual(["browser-code", "core"]);
|
||||
expect(result.downloadBytes).toBe(16);
|
||||
expect(fetches).toBe(1);
|
||||
});
|
||||
|
||||
test("visible GStack Browser refuses installed Chrome before any network request", async () => {
|
||||
const output = capture();
|
||||
let fetches = 0;
|
||||
expect(await bootstrapMain([
|
||||
"preview", "--capability", "browser-visible", "--browser", "installed",
|
||||
"--browser-path", process.execPath,
|
||||
], {
|
||||
stdout: output.stream,
|
||||
stderr: output.stream,
|
||||
fetch: async () => { fetches += 1; throw new Error("unexpected fetch"); },
|
||||
})).toBe(1);
|
||||
expect(output.value()).toContain("requires managed Chromium");
|
||||
expect(fetches).toBe(0);
|
||||
});
|
||||
|
||||
test("Windows Bash discovery shared by doctor and launchers finds a standard Git installation", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-git-bash-"));
|
||||
const bash = path.join(root, "Git", "bin", "bash.exe");
|
||||
@@ -239,6 +420,7 @@ describe("GStack runtime setup UX", () => {
|
||||
await fs.writeFile(paths.versionPointer, JSON.stringify({
|
||||
schemaVersion: 2, status: "active", current: "fixture", lastKnownGood: "fixture",
|
||||
}));
|
||||
await configSetBrowserChoice(home, { provider: "managed", executablePath: null });
|
||||
const report = await runDoctor({ home, cwd: root, nodeCommand: process.execPath });
|
||||
expect(report.ok).toBe(false);
|
||||
expect(report.checks.find((check) => check.id === "capability:pdf")).toMatchObject({ status: "fail" });
|
||||
@@ -274,6 +456,7 @@ describe("GStack runtime setup UX", () => {
|
||||
await fs.writeFile(paths.versionPointer, JSON.stringify({
|
||||
schemaVersion: 2, status: "active", current: "fixture", lastKnownGood: "fixture",
|
||||
}));
|
||||
await configSetBrowserChoice(home, { provider: "managed", executablePath: null });
|
||||
const report = await runDoctor({ home, cwd: root, nodeCommand: process.execPath });
|
||||
expect(report.checks.find((check) => check.id === "capability:browser")).toMatchObject({
|
||||
status: "pass",
|
||||
@@ -285,6 +468,43 @@ describe("GStack runtime setup UX", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("doctor launches the explicitly selected installed browser through the same Playwright adapter", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-doctor-installed-browser-"));
|
||||
const home = path.join(root, "home");
|
||||
try {
|
||||
await setupRuntime({ home, cwd: root });
|
||||
const paths = resolveRuntimePaths({ home });
|
||||
const active = path.join(paths.versions, "fixture");
|
||||
const managedBun = path.join(active, ".gstack-runtime-tools", process.platform === "win32" ? "bun.exe" : "bun");
|
||||
const playwright = path.join(active, "node_modules", "playwright");
|
||||
await fs.mkdir(path.dirname(managedBun), { recursive: true });
|
||||
await fs.mkdir(playwright, { recursive: true });
|
||||
await fs.copyFile(process.execPath, managedBun);
|
||||
if (process.platform !== "win32") await fs.chmod(managedBun, 0o755);
|
||||
const executable = await fs.realpath(process.execPath);
|
||||
await fs.writeFile(path.join(playwright, "index.mjs"),
|
||||
`export const chromium = { launch: async ({ headless, executablePath }) => { if (headless !== true || executablePath !== ${JSON.stringify(executable)}) throw new Error("wrong installed-browser launch"); return { version: () => "fixture-installed", close: async () => {} }; } };\n`);
|
||||
await fs.writeFile(path.join(active, ".gstack-bundle.json"), JSON.stringify({
|
||||
compatibility: { skillApi: "2.0" },
|
||||
selectedCapabilities: ["browser"],
|
||||
capabilities: { browse: "browse/dist/browse" },
|
||||
tools: { bun: { path: path.relative(active, managedBun).split(path.sep).join("/"), version: "1.3.14" } },
|
||||
}));
|
||||
await fs.writeFile(paths.versionPointer, JSON.stringify({
|
||||
schemaVersion: 2, status: "active", current: "fixture", lastKnownGood: "fixture",
|
||||
}));
|
||||
await configSetBrowserChoice(home, { provider: "installed", executablePath: executable });
|
||||
const report = await runDoctor({ home, cwd: root, nodeCommand: process.execPath });
|
||||
expect(report.checks.find((check) => check.id === "browser-selection")).toMatchObject({ status: "pass" });
|
||||
expect(report.checks.find((check) => check.id === "capability:browser")).toMatchObject({
|
||||
status: "pass",
|
||||
details: { provider: "installed", executablePath: executable, version: "fixture-installed" },
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("bootstrap help has no dependency or network side effects", async () => {
|
||||
const output = capture();
|
||||
let fetches = 0;
|
||||
@@ -303,7 +523,7 @@ describe("GStack runtime setup UX", () => {
|
||||
let calls = 0;
|
||||
try {
|
||||
expect(await bootstrapMain([
|
||||
"preview", "--capability", "browser-visible", "--home", path.join(root, "home"),
|
||||
"preview", "--capability", "browser-visible", "--browser", "managed", "--home", path.join(root, "home"),
|
||||
], {
|
||||
stdout: output.stream,
|
||||
stderr: output.stream,
|
||||
@@ -355,7 +575,7 @@ describe("GStack runtime setup UX", () => {
|
||||
arrayBuffer: async () => new TextEncoder().encode("tampered").buffer,
|
||||
};
|
||||
};
|
||||
expect(await bootstrapMain(["install", "--capability", "browser", "--yes"], {
|
||||
expect(await bootstrapMain(["install", "--capability", "browser", "--browser", "managed", "--yes"], {
|
||||
stdout: output.stream,
|
||||
stderr: output.stream,
|
||||
fetch: fetch_,
|
||||
@@ -381,7 +601,7 @@ describe("GStack runtime setup UX", () => {
|
||||
test("official Linux bootstrap rejects musl explicitly before any network request", async () => {
|
||||
const output = capture();
|
||||
let fetches = 0;
|
||||
expect(await bootstrapMain(["install", "--capability", "browser"], {
|
||||
expect(await bootstrapMain(["install", "--capability", "browser", "--browser", "managed"], {
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
libc: "musl",
|
||||
@@ -397,7 +617,7 @@ describe("GStack runtime setup UX", () => {
|
||||
const output = capture();
|
||||
const target = `${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`;
|
||||
let calls = 0;
|
||||
expect(await bootstrapMain(["install", "--capability", "browser", "--yes"], {
|
||||
expect(await bootstrapMain(["install", "--capability", "browser", "--browser", "managed", "--yes"], {
|
||||
stdout: output.stream,
|
||||
stderr: output.stream,
|
||||
fetch: async (url: string) => {
|
||||
@@ -429,7 +649,7 @@ describe("GStack runtime setup UX", () => {
|
||||
process.env.BOOTSTRAP_TEST_LOG = log;
|
||||
try {
|
||||
expect(await bootstrapMain([
|
||||
"install", "--source", root, "--capability", "pdf", "--home", path.join(root, "home"), "--yes",
|
||||
"install", "--source", root, "--capability", "pdf", "--browser", "managed", "--home", path.join(root, "home"), "--yes",
|
||||
], { stdout: output.stream, stderr: output.stream })).toBe(0);
|
||||
} finally {
|
||||
if (previous == null) delete process.env.BOOTSTRAP_TEST_LOG;
|
||||
|
||||
Reference in New Issue
Block a user