mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 04:10:47 +02:00
Merge codex/gstack-2 into gstack2-runtime-integration
Reconcile the four integrated v2 runtime implementations (unified execution result contract, execution profiles, capability readiness, GitHub security) with main's browser-provider hardening. Conflict resolutions: - runtimeContract() generator: keep new execution-result + doctor-capability paragraphs, adopt main's `[matching browser flags]` fallback wording; regenerate the six RUNTIME.md. - package.json: keep the strict isolated test:gstack2 runner and marked 18.0.6 security bump; adopt main's playwright-core alias. - bun.lock: regenerated via bun install. - release-hardening.test.ts: adopt main's browser-provider assertions (resolveServerLaunchTarget, --browser managed smoke loop). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,13 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('GStack 2 standard installer surface', () => {
|
||||
test('documents the canonical public subpath instead of the legacy-bearing repository root', () => {
|
||||
for (const file of ['AGENTS.md', 'CLAUDE.md', 'README.md']) {
|
||||
const content = fs.readFileSync(path.join(DEFAULT_REPO_ROOT, file), 'utf8');
|
||||
expect(content, file).toContain('npx skills add time-attack/gstack/skills');
|
||||
}
|
||||
});
|
||||
|
||||
test('publishes exactly six uniquely named canonical skills', () => {
|
||||
const result = inspectRepository(DEFAULT_REPO_ROOT);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { main as runtimeMain } from "../runtime/cli.js";
|
||||
import { configSetBrowserChoice } from "../runtime/config.js";
|
||||
import { summarizeRuntimeBundle } from "../scripts/gstack2/audit-runtime-bundle";
|
||||
import {
|
||||
DEFAULT_CAPABILITY_LAUNCHERS,
|
||||
@@ -14,6 +15,8 @@ import {
|
||||
defaultBunBuilder,
|
||||
installManagedRuntime,
|
||||
normalizeManagedBrowserTree,
|
||||
runInstallerCli,
|
||||
runtimeReleaseComponentForPath,
|
||||
runtimeNativePackagePaths,
|
||||
uninstallManagedRuntime,
|
||||
runCommand,
|
||||
@@ -27,10 +30,26 @@ const ENTRIES = [
|
||||
{ path: "cap/tool", build: "fixture", executable: true },
|
||||
];
|
||||
const CAPABILITIES = { "fixture-tool": "cap/tool" };
|
||||
const BROWSER_ENTRIES = [
|
||||
{ path: "runtime" },
|
||||
{ path: "bin/gstack", executable: true },
|
||||
{ path: "browse/dist/browse", build: "fixture", executable: true },
|
||||
];
|
||||
const BROWSER_CAPABILITIES = { browse: "browse/dist/browse" };
|
||||
const REPO_ROOT = path.resolve(import.meta.dir, "..");
|
||||
const FULL_RUNTIME_TEST_TIMEOUT_MS = process.platform === "win32" ? 120_000 : 30_000;
|
||||
|
||||
describe("GStack 2 managed runtime installer", () => {
|
||||
test("release staging excludes Playwright bookkeeping and Windows dependency validators", () => {
|
||||
expect(runtimeReleaseComponentForPath(".gstack-runtime-browsers/.links/example")).toBeNull();
|
||||
expect(runtimeReleaseComponentForPath(".gstack-runtime-browsers/winldd-1007/DEPENDENCIES_VALIDATED")).toBeNull();
|
||||
expect(runtimeReleaseComponentForPath(".gstack-runtime-browsers/winldd-1007/winldd.exe")).toBeNull();
|
||||
expect(runtimeReleaseComponentForPath(".gstack-runtime-browsers/chromium_headless_shell-1208/chrome.exe"))
|
||||
.toBe("browser-headless");
|
||||
expect(() => runtimeReleaseComponentForPath(".gstack-runtime-browsers/unknown-1/payload"))
|
||||
.toThrow("Unknown managed browser payload path");
|
||||
});
|
||||
|
||||
test("browser link normalization accepts internal macOS-style links and rejects escape graphs", async () => {
|
||||
if (process.platform === "win32") return;
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-browser-links-"));
|
||||
@@ -102,6 +121,241 @@ describe("GStack 2 managed runtime installer", () => {
|
||||
}, { createDefaultSource: false });
|
||||
});
|
||||
|
||||
test("stable launchers inject the persisted installed-browser choice and honor clearing it", async () => {
|
||||
await withFixture(async ({ source, home }) => {
|
||||
await installFixture(source, home, "browser-config-launcher", {
|
||||
entries: BROWSER_ENTRIES,
|
||||
capabilities: BROWSER_CAPABILITIES,
|
||||
});
|
||||
const executable = await fs.realpath(process.execPath);
|
||||
await configSetBrowserChoice(home, { provider: "installed", executablePath: executable });
|
||||
const ambient = { ...process.env, GSTACK_CHROMIUM_PATH: path.join(home, "ambient-browser-must-not-win") };
|
||||
const selected = await runInstalledLauncher(home, "browse", [], { capture: true, env: ambient });
|
||||
expect(selected.stdout).toBe(executable);
|
||||
|
||||
await configSetBrowserChoice(home, { provider: "managed", executablePath: null });
|
||||
await expect(runInstalledLauncher(home, "browse", [], { capture: true, env: ambient }))
|
||||
.rejects.toThrow("Command failed");
|
||||
|
||||
await configSetBrowserChoice(home, null);
|
||||
await expect(runInstalledLauncher(home, "browse", [], { capture: true, env: ambient }))
|
||||
.rejects.toThrow("Command failed");
|
||||
});
|
||||
});
|
||||
|
||||
test("installed-browser launchers refuse visible commands before starting the browser binary", async () => {
|
||||
await withFixture(async ({ source, home }) => {
|
||||
const executable = await fs.realpath(process.execPath);
|
||||
await installFixture(source, home, "installed-visible-refusal", {
|
||||
entries: BROWSER_ENTRIES,
|
||||
capabilities: BROWSER_CAPABILITIES,
|
||||
browserChoice: { provider: "installed", executablePath: executable },
|
||||
});
|
||||
await configSetBrowserChoice(home, { provider: "installed", executablePath: executable });
|
||||
await expect(runInstalledLauncher(home, "browse", ["connect"], { capture: true }))
|
||||
.rejects.toMatchObject({ stderr: expect.stringContaining("Visible GStack Browser requires managed Chromium") });
|
||||
await expect(runInstalledLauncher(home, "browse", ["pair-agent"], { capture: true }))
|
||||
.rejects.toMatchObject({ stderr: expect.stringContaining("Visible GStack Browser requires managed Chromium") });
|
||||
await expect(runInstalledLauncher(home, "browse", ["handoff"], { capture: true }))
|
||||
.rejects.toMatchObject({ stderr: expect.stringContaining("Visible GStack Browser requires managed Chromium") });
|
||||
});
|
||||
});
|
||||
|
||||
test("managed headless launchers require the separately approved visible-browser slot", async () => {
|
||||
await withFixture(async ({ source, home }) => {
|
||||
await installFixture(source, home, "managed-visible-refusal", {
|
||||
entries: BROWSER_ENTRIES,
|
||||
capabilities: BROWSER_CAPABILITIES,
|
||||
browserChoice: { provider: "managed", executablePath: null },
|
||||
});
|
||||
await configSetBrowserChoice(home, { provider: "managed", executablePath: null });
|
||||
await expect(runInstalledLauncher(home, "browse", ["--headed"], { capture: true }))
|
||||
.rejects.toMatchObject({ stderr: expect.stringContaining("does not include visible Chromium") });
|
||||
await expect(runInstalledLauncher(home, "browse", ["connect"], { capture: true }))
|
||||
.rejects.toMatchObject({ stderr: expect.stringContaining("does not include visible Chromium") });
|
||||
await expect(runInstalledLauncher(home, "browse", ["handoff"], { capture: true }))
|
||||
.rejects.toMatchObject({ stderr: expect.stringContaining("does not include visible Chromium") });
|
||||
});
|
||||
});
|
||||
|
||||
test("design-only launchers do not require an unrelated browser selection", async () => {
|
||||
await withFixture(async ({ source, home }) => {
|
||||
const design = path.join(source, "design", "dist", "design");
|
||||
await fs.mkdir(path.dirname(design), { recursive: true });
|
||||
await fs.writeFile(design, "#!/bin/sh\nprintf 'design ready\\n'\n", { mode: 0o755 });
|
||||
await installManagedRuntime({
|
||||
sourceDir: source,
|
||||
home,
|
||||
version: "design-without-browser",
|
||||
entries: [...ENTRIES, { path: "design/dist/design", build: "fixture", executable: true }],
|
||||
capabilities: { ...CAPABILITIES, "gstack-design": "design/dist/design" },
|
||||
});
|
||||
await configSetBrowserChoice(home, null);
|
||||
expect((await runInstalledLauncher(home, "gstack-design", [], { capture: true })).stdout)
|
||||
.toContain("design ready");
|
||||
});
|
||||
});
|
||||
|
||||
test("browser config refuses a provider that does not match the active runtime slot", async () => {
|
||||
await withFixture(async ({ source, home }) => {
|
||||
const result = await installFixture(source, home, "installed-slot");
|
||||
const manifestPath = path.join(result.path, ".gstack-bundle.json");
|
||||
const manifest = await readJson(manifestPath);
|
||||
await fs.writeFile(manifestPath, JSON.stringify({
|
||||
...manifest,
|
||||
selectedCapabilities: ["browser"],
|
||||
runtimeComponents: ["browser-code", "core"],
|
||||
browserChoice: { provider: "installed", executablePath: process.execPath },
|
||||
}));
|
||||
const output = captureStream();
|
||||
expect(await runtimeMain(["config", "browser", "managed"], {
|
||||
cwd: source,
|
||||
env: { ...process.env, GSTACK_HOME: home },
|
||||
stdout: output.stream,
|
||||
stderr: output.stream,
|
||||
})).toBe(1);
|
||||
expect(output.value()).toContain("active runtime was installed for installed Chromium");
|
||||
|
||||
const selected = captureStream();
|
||||
expect(await runtimeMain(["config", "browser", "installed", process.execPath], {
|
||||
cwd: source,
|
||||
env: { ...process.env, GSTACK_HOME: home },
|
||||
stdout: selected.stream,
|
||||
stderr: selected.stream,
|
||||
})).toBe(0);
|
||||
expect((await readJson(path.join(home, "config.json"))).browser.provider).toBe("installed");
|
||||
});
|
||||
});
|
||||
|
||||
test("rollback validates a recorded installed browser before switching runtime slots", async () => {
|
||||
await withFixture(async ({ root, source, home }) => {
|
||||
const fallback = await installFixture(source, home, "installed-fallback");
|
||||
const staleBrowser = path.join(root, "removed-chromium");
|
||||
const fallbackManifestPath = path.join(fallback.path, ".gstack-bundle.json");
|
||||
const fallbackManifest = await readJson(fallbackManifestPath);
|
||||
await fs.writeFile(fallbackManifestPath, JSON.stringify({
|
||||
...fallbackManifest,
|
||||
selectedCapabilities: ["browser"],
|
||||
runtimeComponents: ["browser-code", "core"],
|
||||
browserChoice: { provider: "installed", executablePath: staleBrowser },
|
||||
}));
|
||||
|
||||
const current = await installFixture(source, home, "managed-current");
|
||||
const currentManifestPath = path.join(current.path, ".gstack-bundle.json");
|
||||
const currentManifest = await readJson(currentManifestPath);
|
||||
await fs.writeFile(currentManifestPath, JSON.stringify({
|
||||
...currentManifest,
|
||||
selectedCapabilities: ["browser"],
|
||||
runtimeComponents: ["browser-headless", "core"],
|
||||
browserChoice: { provider: "managed", executablePath: null },
|
||||
}));
|
||||
await configSetBrowserChoice(home, { provider: "managed", executablePath: null });
|
||||
|
||||
const output = captureStream();
|
||||
expect(await runtimeMain(["upgrade", "--rollback"], {
|
||||
cwd: source,
|
||||
env: { ...process.env, GSTACK_HOME: home },
|
||||
stdout: output.stream,
|
||||
stderr: output.stream,
|
||||
})).toBe(1);
|
||||
expect(output.value()).toContain("unavailable or not executable");
|
||||
expect((await readJson(path.join(home, "versions", "current.json"))).current).toBe("managed-current");
|
||||
expect((await readJson(path.join(home, "config.json"))).browser)
|
||||
.toEqual({ provider: "managed", executablePath: null });
|
||||
});
|
||||
});
|
||||
|
||||
test("rollback switches the runtime pointer and recorded browser choice in one recoverable transaction", async () => {
|
||||
await withFixture(async ({ source, home }) => {
|
||||
const executable = await fs.realpath(process.execPath);
|
||||
const fallback = await installFixture(source, home, "installed-fallback-valid");
|
||||
const fallbackManifestPath = path.join(fallback.path, ".gstack-bundle.json");
|
||||
const fallbackManifest = await readJson(fallbackManifestPath);
|
||||
await fs.writeFile(fallbackManifestPath, JSON.stringify({
|
||||
...fallbackManifest,
|
||||
selectedCapabilities: ["browser"],
|
||||
runtimeComponents: ["browser-code", "core"],
|
||||
browserChoice: { provider: "installed", executablePath: executable },
|
||||
}));
|
||||
|
||||
const current = await installFixture(source, home, "managed-current-valid");
|
||||
const currentManifestPath = path.join(current.path, ".gstack-bundle.json");
|
||||
const currentManifest = await readJson(currentManifestPath);
|
||||
await fs.writeFile(currentManifestPath, JSON.stringify({
|
||||
...currentManifest,
|
||||
selectedCapabilities: ["browser"],
|
||||
runtimeComponents: ["browser-headless", "core"],
|
||||
browserChoice: { provider: "managed", executablePath: null },
|
||||
}));
|
||||
await configSetBrowserChoice(home, { provider: "managed", executablePath: null });
|
||||
|
||||
const output = captureStream();
|
||||
expect(await runtimeMain(["upgrade", "--rollback"], {
|
||||
cwd: source,
|
||||
env: { ...process.env, GSTACK_HOME: home },
|
||||
stdout: output.stream,
|
||||
stderr: output.stream,
|
||||
})).toBe(0);
|
||||
expect(await readJson(path.join(home, "versions", "current.json")))
|
||||
.toMatchObject({ current: "installed-fallback-valid", lastKnownGood: "managed-current-valid" });
|
||||
expect((await readJson(path.join(home, "config.json"))).browser)
|
||||
.toEqual({ provider: "installed", executablePath: executable });
|
||||
expect(await exists(path.join(home, ".gstack-runtime-transaction.json"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("an installed-browser setup persists the choice only after activation and launches through it", async () => {
|
||||
await withFixture(async ({ root, source, home }) => {
|
||||
await fs.writeFile(path.join(source, "cap", "tool"), `#!/usr/bin/env node
|
||||
process.stdout.write(process.env.GSTACK_CHROMIUM_PATH || "unset");
|
||||
`, { mode: 0o755 });
|
||||
const executable = await fs.realpath(process.execPath);
|
||||
const output = captureStream();
|
||||
expect(await runInstallerCli([
|
||||
"--source", source,
|
||||
"--home", home,
|
||||
"--capabilities", "browser",
|
||||
"--browser", "installed",
|
||||
"--browser-path", executable,
|
||||
"--install-now",
|
||||
"--yes",
|
||||
"--json",
|
||||
], {
|
||||
stdout: output.stream,
|
||||
stderr: output.stream,
|
||||
prepareDependencies: async () => {},
|
||||
installOptions: { entries: BROWSER_ENTRIES, capabilities: BROWSER_CAPABILITIES },
|
||||
})).toBe(0);
|
||||
expect((await readJson(path.join(home, "config.json"))).browser)
|
||||
.toEqual({ provider: "installed", executablePath: executable });
|
||||
expect((await runInstalledLauncher(home, "browse", [], { capture: true })).stdout)
|
||||
.toBe(executable);
|
||||
|
||||
const failedHome = path.join(root, "failed-home", ".gstack");
|
||||
const failed = captureStream();
|
||||
expect(await runInstallerCli([
|
||||
"--source", source,
|
||||
"--home", failedHome,
|
||||
"--capabilities", "browser",
|
||||
"--browser", "installed",
|
||||
"--browser-path", executable,
|
||||
"--install-now",
|
||||
"--yes",
|
||||
"--json",
|
||||
], {
|
||||
stdout: failed.stream,
|
||||
stderr: failed.stream,
|
||||
prepareDependencies: async () => {},
|
||||
installOptions: {
|
||||
entries: BROWSER_ENTRIES,
|
||||
capabilities: BROWSER_CAPABILITIES,
|
||||
smokeTest: async () => { throw new Error("fixture smoke failure"); },
|
||||
},
|
||||
})).toBe(1);
|
||||
expect(await exists(path.join(failedHome, "config.json"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("accepts a symlink to the source root but rejects links inside the allowlist", async () => {
|
||||
if (process.platform === "win32") return;
|
||||
await withFixture(async ({ root, source, home }) => {
|
||||
@@ -409,6 +663,13 @@ describe("GStack 2 managed runtime installer", () => {
|
||||
await fs.writeFile(path.join(browserRoot, "chromium-fixture", "chrome"), "fixture\n", { mode: 0o755 });
|
||||
return { code: 0, stdout: "", stderr: "" };
|
||||
}
|
||||
const outfileIndex = args.indexOf("--outfile");
|
||||
if (outfileIndex >= 0 && typeof args[outfileIndex + 1] === "string") {
|
||||
const outfile = path.join(REPO_ROOT, args[outfileIndex + 1]);
|
||||
await fs.mkdir(path.dirname(outfile), { recursive: true });
|
||||
await fs.writeFile(outfile, "fixture runtime helper\n", { mode: 0o755 });
|
||||
return { code: 0, stdout: "", stderr: "" };
|
||||
}
|
||||
if (args[0] === "--version" && (command === process.execPath || command.includes(".gstack-runtime-tools"))) {
|
||||
return { code: 0, stdout: "1.3.14\n", stderr: "" };
|
||||
}
|
||||
@@ -756,14 +1017,17 @@ describe("GStack 2 managed runtime installer", () => {
|
||||
test("a launcher repairs a crash journal before resolving any runtime", async () => {
|
||||
await withFixture(async ({ source, home }) => {
|
||||
await installFixture(source, home, "1.0.0");
|
||||
await configSetBrowserChoice(home, { provider: "managed", executablePath: null });
|
||||
const pointer = await readJson(path.join(home, "versions", "current.json"));
|
||||
const manifest = await fs.readFile(path.join(home, "runtime-install.json"));
|
||||
const config = await fs.readFile(path.join(home, "config.json"));
|
||||
// Keep the launcher for this host executable so it can enter the shared
|
||||
// recovery path. The transaction restores the inactive host variant.
|
||||
const recoverableLauncher = process.platform === "win32" ? "gstack" : "gstack.cmd";
|
||||
const launcherPath = path.join(home, "bin", recoverableLauncher);
|
||||
const launcherBefore = await fs.readFile(launcherPath);
|
||||
await fs.writeFile(path.join(home, "runtime-install.json"), '{"activeVersion":"crashed"}\n');
|
||||
await configSetBrowserChoice(home, { provider: "installed", executablePath: process.execPath });
|
||||
await fs.writeFile(launcherPath, "candidate launcher\n");
|
||||
await fs.writeFile(path.join(home, "versions", "current.json"), `${JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
@@ -780,6 +1044,7 @@ describe("GStack 2 managed runtime installer", () => {
|
||||
previousPointerExists: true,
|
||||
previousPointer: pointer,
|
||||
files: [
|
||||
{ path: "config.json", existed: true, mode: 0o644, dataBase64: config.toString("base64") },
|
||||
{ path: "runtime-install.json", existed: true, mode: 0o600, dataBase64: manifest.toString("base64") },
|
||||
{ path: `bin/${recoverableLauncher}`, existed: true, mode: 0o644, dataBase64: launcherBefore.toString("base64") },
|
||||
],
|
||||
@@ -795,6 +1060,7 @@ describe("GStack 2 managed runtime installer", () => {
|
||||
const launched = await runInstalledLauncher(home, "gstack", ["doctor"], { capture: true });
|
||||
expect(launched.stdout).toContain("gstack fixture doctor");
|
||||
expect(await readJson(path.join(home, "versions", "current.json"))).toEqual(pointer);
|
||||
expect(await fs.readFile(path.join(home, "config.json"))).toEqual(config);
|
||||
expect(await fs.readFile(path.join(home, "runtime-install.json"))).toEqual(manifest);
|
||||
expect(await fs.readFile(launcherPath)).toEqual(launcherBefore);
|
||||
expect(await exists(path.join(home, ".gstack-runtime-transaction.json"))).toBe(false);
|
||||
@@ -1087,15 +1353,20 @@ async function createSource(source: string) {
|
||||
await fs.mkdir(path.join(source, "runtime"), { recursive: true });
|
||||
await fs.mkdir(path.join(source, "bin"), { recursive: true });
|
||||
await fs.mkdir(path.join(source, "cap"), { recursive: true });
|
||||
await fs.mkdir(path.join(source, "browse", "dist"), { recursive: true });
|
||||
await fs.writeFile(path.join(source, "package.json"), '{"name":"gstack","version":"2.0.0","type":"module"}\n');
|
||||
await fs.writeFile(path.join(source, "runtime", "cli.js"), fixtureCli(""));
|
||||
await fs.writeFile(path.join(source, "runtime", "tooling.js"),
|
||||
'export async function resolveBashCommand(env = process.env) { return env.GSTACK_BASH || "bash"; }\n');
|
||||
await fs.copyFile(path.join(REPO_ROOT, "runtime", "browser-choice.mjs"), path.join(source, "runtime", "browser-choice.mjs"));
|
||||
await fs.writeFile(path.join(source, "bin", "gstack"), `#!/usr/bin/env node
|
||||
import { main } from "../runtime/cli.js";
|
||||
process.exitCode = await main(process.argv.slice(2));
|
||||
`, { mode: 0o755 });
|
||||
await fs.writeFile(path.join(source, "cap", "tool"), "#!/bin/sh\nprintf 'fixture capability %s\\n' \"$*\"\n", { mode: 0o755 });
|
||||
await fs.writeFile(path.join(source, "browse", "dist", "browse"), `#!/usr/bin/env node
|
||||
process.stdout.write(process.env.GSTACK_CHROMIUM_PATH || "unset");
|
||||
`, { mode: 0o755 });
|
||||
}
|
||||
|
||||
function fixtureCli(label: string) {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, "..");
|
||||
const SCRIPT = path.join(ROOT, ".github", "scripts", "create-runtime-release-manifest.mjs");
|
||||
const WORKFLOW = path.join(ROOT, ".github", "workflows", "release-artifacts.yml");
|
||||
const TARGETS = ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64", "windows-arm64", "windows-x64"];
|
||||
const COMMON = ["core", "browser-code", "browser-headless", "browser-visible", "design", "diagram", "pdf"];
|
||||
|
||||
async function stageFixture(directory: string) {
|
||||
for (const target of TARGETS) {
|
||||
const components = [...COMMON, ...(target.startsWith("darwin-") ? ["ios"] : [])];
|
||||
for (const component of components) {
|
||||
const name = `gstack-runtime-2.0.0-${target}-${component}.tar.gz`;
|
||||
await fs.writeFile(path.join(directory, name), "fixture\n");
|
||||
await fs.writeFile(path.join(directory, `${name}.sha256`), `${"a".repeat(64)} ${name}\n`);
|
||||
await fs.writeFile(path.join(directory, `${name}.sigstore.json`), "{}\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("GStack runtime release channel", () => {
|
||||
test("release candidates retain runtime compatibility while binding URLs and signatures to the RC tag", async () => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-runtime-release-channel-"));
|
||||
try {
|
||||
await stageFixture(directory);
|
||||
const result = spawnSync(process.execPath, [SCRIPT, directory, "time-attack/gstack", "2.0.0", "v2.0.0-rc.1"], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
const manifest = JSON.parse(await fs.readFile(path.join(directory, "gstack-runtime-manifest.json"), "utf8"));
|
||||
expect(manifest.version).toBe("2.0.0");
|
||||
expect(manifest.targets["darwin-arm64"].components["browser-visible"]).toMatchObject({
|
||||
url: "https://github.com/time-attack/gstack/releases/download/v2.0.0-rc.1/gstack-runtime-2.0.0-darwin-arm64-browser-visible.tar.gz",
|
||||
certificateIdentity: "https://github.com/time-attack/gstack/.github/workflows/release-artifacts.yml@refs/tags/v2.0.0-rc.1",
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("release manifest generation rejects non-runtime tags before reading artifacts", async () => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-runtime-invalid-tag-"));
|
||||
try {
|
||||
const result = spawnSync(process.execPath, [SCRIPT, directory, "time-attack/gstack", "2.0.0", "main"], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(`${result.stdout}${result.stderr}`).toContain("Invalid runtime release tag");
|
||||
} finally {
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("release workflow publishes both RC and stable tags through the same signed manifest path", async () => {
|
||||
const workflow = await fs.readFile(WORKFLOW, "utf8");
|
||||
const buildSection = workflow.slice(workflow.indexOf(" build:"), workflow.indexOf("\n manifest:"));
|
||||
const manifestSection = workflow.slice(workflow.indexOf("\n manifest:"));
|
||||
expect(workflow).toContain("v2.0.0-rc.*");
|
||||
expect(workflow).toContain('2.0.0 "$GITHUB_REF_NAME"');
|
||||
expect(workflow).toContain("PRERELEASE_FLAG:");
|
||||
expect(workflow).toContain("--prerelease");
|
||||
expect(workflow).toContain('gh release create "$GITHUB_REF_NAME"');
|
||||
expect(workflow).toContain("pathToFileURL(p).href");
|
||||
expect(workflow).toContain('path").join(process.env.GITHUB_WORKSPACE,".gstack-runtime-smoke.html")');
|
||||
expect(workflow).toContain('release_dir="$(pwd -P)/release-output"');
|
||||
expect(workflow).toContain('archive="$release_dir/gstack-runtime-2.0.0-$TARGET-$component.tar.gz"');
|
||||
expect(workflow).not.toContain('archive="$GITHUB_WORKSPACE/release-output/');
|
||||
expect(workflow).not.toContain("goto about:blank");
|
||||
expect(buildSection).not.toContain("sigstore/cosign-installer");
|
||||
expect(manifestSection).toContain("sigstore/cosign-installer");
|
||||
expect(manifestSection.indexOf("Keyless-sign component archives"))
|
||||
.toBeLessThan(manifestSection.indexOf("Create strict six-target manifest"));
|
||||
});
|
||||
});
|
||||
@@ -249,6 +249,25 @@ describe("one config authority", () => {
|
||||
expect(setup.stdout).toContain("optional runtime: unchanged");
|
||||
});
|
||||
|
||||
test("generic config writes cannot create an incoherent browser selection", async () => {
|
||||
const base = await root();
|
||||
const home = path.join(base, "state");
|
||||
const project = path.join(base, "project");
|
||||
await fs.mkdir(project);
|
||||
const run = (args: string[]) => spawnSync(process.execPath, [gstackBin, ...args], {
|
||||
cwd: project,
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, GSTACK_HOME: home },
|
||||
});
|
||||
|
||||
for (const key of ["browser", "browser.provider", "browser.executablePath"]) {
|
||||
const result = run(["config", "set", key, "installed"]);
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain("gstack config browser");
|
||||
}
|
||||
expect(await fs.stat(path.join(home, "config.json")).catch(() => null)).toBeNull();
|
||||
});
|
||||
|
||||
test("legacy YAML is read-only migration input and JSON takes authority on write", async () => {
|
||||
const home = path.join(await root(), "legacy");
|
||||
await fs.mkdir(home);
|
||||
|
||||
@@ -3,13 +3,18 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
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,
|
||||
BOOTSTRAP_RELEASE_TAG,
|
||||
BOOTSTRAP_RUNTIME_VERSION,
|
||||
CAPABILITY_COMPONENTS,
|
||||
COMPONENT_DEPENDENCIES,
|
||||
@@ -27,7 +32,7 @@ function officialManifestFixture(target: string, customize?: (component: string,
|
||||
.filter((component) => component !== "ios" || target.startsWith("darwin-"))
|
||||
.map((component) => {
|
||||
const artifact: Record<string, unknown> = {
|
||||
url: `https://github.com/time-attack/gstack/releases/download/v${BOOTSTRAP_RUNTIME_VERSION}/${component}.tar.gz`,
|
||||
url: `https://github.com/time-attack/gstack/releases/download/${BOOTSTRAP_RELEASE_TAG}/${component}.tar.gz`,
|
||||
sha256: "0".repeat(64),
|
||||
bytes: 8,
|
||||
format: "tar.gz",
|
||||
@@ -45,6 +50,36 @@ function officialManifestFixture(target: string, customize?: (component: string,
|
||||
};
|
||||
}
|
||||
|
||||
async function createActiveRuntimeFixture(home: string, options: {
|
||||
bundleVersion: string;
|
||||
selectedCapabilities: string[];
|
||||
runtimeComponents: string[];
|
||||
browserChoice: { provider: "managed" | "installed"; executablePath: string | null };
|
||||
}) {
|
||||
const root = path.join(home, "versions", "active-slot");
|
||||
const payload = Buffer.from("verified active runtime payload\n");
|
||||
await fs.mkdir(root, { recursive: true });
|
||||
await fs.writeFile(path.join(root, "payload.txt"), payload);
|
||||
await fs.writeFile(path.join(root, ".gstack-bundle.json"), JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
version: options.bundleVersion,
|
||||
selectedCapabilities: options.selectedCapabilities,
|
||||
runtimeComponents: options.runtimeComponents,
|
||||
browserChoice: options.browserChoice,
|
||||
files: [{
|
||||
path: "payload.txt",
|
||||
size: payload.byteLength,
|
||||
sha256: createHash("sha256").update(payload).digest("hex"),
|
||||
}],
|
||||
}));
|
||||
await fs.writeFile(path.join(home, "versions", "current.json"), JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
status: "active",
|
||||
current: "active-slot",
|
||||
lastKnownGood: null,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("GStack runtime setup UX", () => {
|
||||
test("capability selection keeps the core and excludes unselected heavyweight surfaces", () => {
|
||||
const surface = runtimeSurfaceForCapabilities(["browser"]);
|
||||
@@ -95,13 +130,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 {
|
||||
@@ -145,6 +180,7 @@ describe("GStack runtime setup UX", () => {
|
||||
"--source", path.resolve(import.meta.dir, ".."),
|
||||
"--home", home,
|
||||
"--capabilities", "browser",
|
||||
"--browser", "managed",
|
||||
"--dry-run",
|
||||
"--json",
|
||||
], {
|
||||
@@ -165,6 +201,279 @@ 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("explicit install-later needs no browser selection and does not prompt or mutate", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-browser-later-"));
|
||||
try {
|
||||
const home = path.join(root, "home");
|
||||
const output = capture();
|
||||
const input = Readable.from([]) as Readable & { isTTY: boolean };
|
||||
input.isTTY = false;
|
||||
expect(await runInstallerCli([
|
||||
"--source", path.resolve(import.meta.dir, ".."),
|
||||
"--home", home,
|
||||
"--capabilities", "browser",
|
||||
"--install-later",
|
||||
"--json",
|
||||
], { stdin: input, stdout: output.stream, stderr: output.stream })).toBe(0);
|
||||
expect(JSON.parse(output.value())).toMatchObject({
|
||||
ok: true,
|
||||
action: "install-later",
|
||||
mutated: false,
|
||||
preview: null,
|
||||
});
|
||||
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("official previews retain an active installed-browser choice across same- and cross-release additions", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-bootstrap-retain-browser-"));
|
||||
const executable = await fs.realpath(process.execPath);
|
||||
const target = `${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`;
|
||||
try {
|
||||
for (const [name, bundleVersion, expectsReuse] of [
|
||||
["same", `${BOOTSTRAP_RUNTIME_VERSION}-caps-installed`, true],
|
||||
["cross", "1.9.0-caps-installed", false],
|
||||
] as const) {
|
||||
const home = path.join(root, name);
|
||||
await createActiveRuntimeFixture(home, {
|
||||
bundleVersion,
|
||||
selectedCapabilities: ["browser"],
|
||||
runtimeComponents: ["browser-code", "core"],
|
||||
browserChoice: { provider: "installed", executablePath: executable },
|
||||
});
|
||||
const output = capture();
|
||||
expect(await bootstrapMain([
|
||||
"preview", "--capability", "design", "--home", home, "--json",
|
||||
], {
|
||||
stdout: output.stream,
|
||||
stderr: output.stream,
|
||||
libc: process.platform === "linux" ? "glibc" : undefined,
|
||||
fetch: async (url: string) => ({ ok: true, url, json: async () => officialManifestFixture(target) }),
|
||||
})).toBe(0);
|
||||
const result = JSON.parse(output.value());
|
||||
expect(result.capabilities).toEqual(["browser", "design"]);
|
||||
expect(result.browser).toEqual({ provider: "installed", executablePath: executable });
|
||||
expect(result.components).toEqual(["browser-code", "core", "design"]);
|
||||
expect(result.components).not.toContain("browser-headless");
|
||||
expect(result.reusedComponents.length > 0).toBe(expectsReuse);
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("switching a reusable managed visible slot to installed drops visible payload from the exact plan", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-bootstrap-provider-switch-"));
|
||||
const home = path.join(root, "home");
|
||||
const executable = await fs.realpath(process.execPath);
|
||||
const target = `${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`;
|
||||
try {
|
||||
await createActiveRuntimeFixture(home, {
|
||||
bundleVersion: `${BOOTSTRAP_RUNTIME_VERSION}-caps-managed-visible`,
|
||||
selectedCapabilities: ["browser-visible"],
|
||||
runtimeComponents: ["browser-code", "browser-visible", "core"],
|
||||
browserChoice: { provider: "managed", executablePath: null },
|
||||
});
|
||||
const output = capture();
|
||||
expect(await bootstrapMain([
|
||||
"preview", "--capability", "browser", "--browser", "installed",
|
||||
"--browser-path", executable, "--home", home, "--json",
|
||||
], {
|
||||
stdout: output.stream,
|
||||
stderr: output.stream,
|
||||
libc: process.platform === "linux" ? "glibc" : undefined,
|
||||
fetch: async (url: string) => ({ ok: true, url, json: async () => officialManifestFixture(target) }),
|
||||
})).toBe(0);
|
||||
const result = JSON.parse(output.value());
|
||||
expect(result.capabilities).toEqual(["browser"]);
|
||||
expect(result.browser.provider).toBe("installed");
|
||||
expect(result.components).toEqual(["browser-code", "core"]);
|
||||
expect(result.components).not.toContain("browser-visible");
|
||||
expect(result.components).not.toContain("browser-headless");
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
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");
|
||||
@@ -238,6 +547,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" });
|
||||
@@ -273,6 +583,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",
|
||||
@@ -284,6 +595,80 @@ describe("GStack runtime setup UX", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("doctor reports and launches an internal managed visible-browser slot", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-doctor-visible-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 browserRoot = path.join(active, ".gstack-runtime-browsers");
|
||||
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.join(browserRoot, "chromium-fixture"), { recursive: true });
|
||||
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);
|
||||
await fs.writeFile(path.join(playwright, "index.mjs"),
|
||||
`export const chromium = { launch: async ({ headless, channel }) => { if (headless !== true || channel !== "chromium") throw new Error("expected full Chromium channel"); return { version: () => "fixture-visible", close: async () => {} }; } };\n`);
|
||||
await fs.writeFile(path.join(active, ".gstack-bundle.json"), JSON.stringify({
|
||||
compatibility: { skillApi: "2.0" },
|
||||
selectedCapabilities: ["browser-visible"],
|
||||
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: "managed", executablePath: null });
|
||||
const report = await runDoctor({ home, cwd: root, nodeCommand: process.execPath });
|
||||
expect(report.checks.find((check) => check.id === "capability:browser-visible")).toMatchObject({
|
||||
status: "pass",
|
||||
details: { browserRoot, version: "fixture-visible" },
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -296,6 +681,31 @@ describe("GStack runtime setup UX", () => {
|
||||
expect(fetches).toBe(0);
|
||||
});
|
||||
|
||||
test("missing official release stops before install with an actionable immutable-tag error", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-bootstrap-missing-release-"));
|
||||
const output = capture();
|
||||
let calls = 0;
|
||||
try {
|
||||
expect(await bootstrapMain([
|
||||
"preview", "--capability", "browser-visible", "--browser", "managed", "--home", path.join(root, "home"),
|
||||
], {
|
||||
stdout: output.stream,
|
||||
stderr: output.stream,
|
||||
fetch: async (url: string) => {
|
||||
calls += 1;
|
||||
return { ok: false, status: 404, url };
|
||||
},
|
||||
})).toBe(1);
|
||||
expect(calls).toBe(1);
|
||||
expect(output.value()).toContain(`Official runtime release ${BOOTSTRAP_RELEASE_TAG} is not published`);
|
||||
expect(output.value()).toContain(OFFICIAL_MANIFEST_URL);
|
||||
expect(output.value()).toContain("No files were downloaded or installed");
|
||||
expect(await fs.readdir(root)).toEqual([]);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("bootstrap executes through a symlinked or aliased filesystem path", async () => {
|
||||
if (process.platform === "win32") return;
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-bootstrap-link-"));
|
||||
@@ -329,7 +739,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_,
|
||||
@@ -355,7 +765,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",
|
||||
@@ -371,7 +781,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) => {
|
||||
@@ -403,7 +813,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;
|
||||
@@ -414,9 +824,50 @@ describe("GStack runtime setup UX", () => {
|
||||
expect(args).toContain("--yes");
|
||||
expect(args).toContain("browser,diagram,pdf");
|
||||
expect(args).not.toContain("--prepared");
|
||||
expect(args).toContain("--replace-capabilities");
|
||||
expect(output.value()).toContain("Developer-only source install");
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("developer source fallback can switch a retained managed visible slot to installed", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-bootstrap-source-switch-"));
|
||||
const source = path.join(root, "source");
|
||||
const runtime = path.join(source, "runtime");
|
||||
const home = path.join(root, "home");
|
||||
const log = path.join(root, "args.json");
|
||||
const executable = await fs.realpath(process.execPath);
|
||||
const output = capture();
|
||||
try {
|
||||
await fs.mkdir(runtime, { recursive: true });
|
||||
await fs.writeFile(path.join(runtime, "install.js"),
|
||||
`import fs from "node:fs"; fs.writeFileSync(process.env.BOOTSTRAP_TEST_LOG, JSON.stringify(process.argv.slice(2)));\n`);
|
||||
await createActiveRuntimeFixture(home, {
|
||||
bundleVersion: `${BOOTSTRAP_RUNTIME_VERSION}-caps-managed-visible`,
|
||||
selectedCapabilities: ["browser-visible"],
|
||||
runtimeComponents: ["browser-code", "browser-visible", "core"],
|
||||
browserChoice: { provider: "managed", executablePath: null },
|
||||
});
|
||||
const previous = process.env.BOOTSTRAP_TEST_LOG;
|
||||
process.env.BOOTSTRAP_TEST_LOG = log;
|
||||
try {
|
||||
expect(await bootstrapMain([
|
||||
"install", "--source", source, "--capability", "browser", "--browser", "installed",
|
||||
"--browser-path", executable, "--home", home, "--yes",
|
||||
], { stdout: output.stream, stderr: output.stream })).toBe(0);
|
||||
} finally {
|
||||
if (previous == null) delete process.env.BOOTSTRAP_TEST_LOG;
|
||||
else process.env.BOOTSTRAP_TEST_LOG = previous;
|
||||
}
|
||||
const args = JSON.parse(await fs.readFile(log, "utf8"));
|
||||
expect(args).toContain("browser");
|
||||
expect(args).toContain("installed");
|
||||
expect(args).toContain(executable);
|
||||
expect(args).toContain("--replace-capabilities");
|
||||
expect(args).not.toContain("browser-visible");
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,19 +76,22 @@ describe('GStack 2 canonical skill UX', () => {
|
||||
for (const tree of TREE_NAMES) {
|
||||
const runtime = fs.readFileSync(path.join(ROOT, 'skills', tree, 'references', 'RUNTIME.md'), 'utf8');
|
||||
const bootstrap = fs.readFileSync(path.join(ROOT, 'skills', tree, 'references', 'support', 'runtime-bootstrap.mjs'));
|
||||
const browserChoice = fs.readFileSync(path.join(ROOT, 'skills', tree, 'references', 'support', 'browser-choice.mjs'));
|
||||
const contract = JSON.parse(fs.readFileSync(path.join(ROOT, 'skills', tree, 'references', 'support', 'runtime-contract.json'), 'utf8'));
|
||||
const resultContract = JSON.parse(fs.readFileSync(path.join(ROOT, 'skills', tree, 'references', 'support', 'execution-result-contract.json'), 'utf8'));
|
||||
expect(bootstrap, tree).toEqual(source);
|
||||
expect(browserChoice, tree).toEqual(fs.readFileSync(path.join(ROOT, 'runtime', 'browser-choice.mjs')));
|
||||
expect(contract, tree).toEqual({ schemaVersion: 1, runtimeVersion: '2.0.0', skillApi: '2.0' });
|
||||
expect(resultContract.properties.status.enum, tree).toEqual(['success', 'degraded', 'unsupported', 'failed']);
|
||||
expect(resultContract.allOf[0].then.properties.evidence.minItems, tree).toBe(1);
|
||||
expect(runtime, tree).toContain('preview --capability <name>');
|
||||
expect(runtime, tree).toContain('It never downloads components or mutates runtime state.');
|
||||
expect(runtime, tree).toContain('install --capability <name> --yes');
|
||||
expect(runtime, tree).toContain('options --capability <name>');
|
||||
expect(runtime, tree).toContain('gstack config browser clear');
|
||||
expect(runtime, tree).toContain('Never run `./setup` inside a standard-installed skill directory');
|
||||
expect(runtime, tree).toContain('Deferring installation records no consent');
|
||||
expect(runtime, tree).toContain('Logical `browser` expands to `browser-code + browser-headless`');
|
||||
expect(runtime, tree).toContain('`browser-visible` expands to `browser-code + browser-visible` and does not require headless');
|
||||
expect(runtime, tree).toContain('With managed Chromium, logical `browser` expands to `browser-code + browser-headless`');
|
||||
expect(runtime, tree).toContain('Internal `browser-visible` expands to `browser-code + browser-visible` and is managed-only');
|
||||
expect(runtime, tree).toContain('`pdf` depends on `diagram`');
|
||||
expect(runtime, tree).toContain('`all` means those five and intentionally excludes visible Chromium');
|
||||
expect(runtime, tree).toContain('summed compressed bytes');
|
||||
@@ -103,8 +106,8 @@ describe('GStack 2 canonical skill UX', () => {
|
||||
for (const source of ['open-gstack-browser', 'pair-agent', 'setup-browser-cookies']) {
|
||||
const body = fs.readFileSync(ownerModule(source), 'utf8');
|
||||
expect(body, source).toContain('## Visible-browser point-of-use gate');
|
||||
expect(body, source).toContain('preview --capability browser-visible');
|
||||
expect(body, source).toContain('install --capability browser-visible --yes');
|
||||
expect(body, source).toContain('preview --capability browser-visible --browser managed');
|
||||
expect(body, source).toContain('install --capability browser-visible --browser managed --yes');
|
||||
expect(body, source).toContain('never requires `browser-headless`');
|
||||
}
|
||||
expect(fs.readFileSync(ownerModule('browse'), 'utf8')).not.toContain('browser-visible');
|
||||
@@ -142,7 +145,7 @@ describe('GStack 2 canonical skill UX', () => {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
const code = await module.main([
|
||||
'install', '--source', source, '--capability', 'browser', '--home', home, '--yes',
|
||||
'install', '--source', source, '--capability', 'browser', '--browser', 'managed', '--home', home, '--yes',
|
||||
], {
|
||||
stdout: { write: (chunk: string) => { stdout += chunk; } },
|
||||
stderr: { write: (chunk: string) => { stderr += chunk; } },
|
||||
|
||||
@@ -53,6 +53,7 @@ describe("release and CI hardening", () => {
|
||||
});
|
||||
expect(pkg.files).toEqual(["bin/gstack", "runtime", "README.md", "LICENSE", "VERSION"]);
|
||||
expect(pkg.dependencies["puppeteer-core"]).toBeUndefined();
|
||||
expect(pkg.dependencies.playwright).toBe("npm:playwright-core@^1.58.2");
|
||||
});
|
||||
|
||||
test("runtime identity is aligned independently of the legacy four-slot release counter", () => {
|
||||
@@ -72,15 +73,24 @@ describe("release and CI hardening", () => {
|
||||
expect(workflow).toContain("versions/current.json");
|
||||
expect(workflow).not.toContain('active="$GSTACK_HOME/versions/2.0.0"');
|
||||
expect(workflow).toContain(".gstack-runtime-browsers");
|
||||
expect(workflow).toContain('for (const options of [{ headless: true }, { headless: true, channel: "chromium" }])');
|
||||
expect(workflow).toContain("--browser managed");
|
||||
// Exercise both the bundled browser and the explicit Chromium channel. Keep
|
||||
// this semantic: the workflow intentionally loops over launch options so a
|
||||
// harmless refactor does not invalidate release hardening.
|
||||
expect(workflow).toMatch(/for \(const options of \[\{ headless: true \}, \{ headless: true, channel: ["']chromium["'] \}\]\)/);
|
||||
expect(workflow).toContain("chromium.launch(options)");
|
||||
expect(workflow).toContain("await browser.close()");
|
||||
expect(workflow).not.toContain("--with-deps");
|
||||
expect(workflow).toContain(".gstack-runtime-tools/bun");
|
||||
expect(workflow).toContain('"$GSTACK_HOME/bin/bun" --version');
|
||||
expect(workflow).toContain("BUN-LICENSE-1.3.14.md");
|
||||
expect(workflow).toContain("command -v bun");
|
||||
expect(workflow).toContain("GSTACK_NODE=\"$node_command\"");
|
||||
expect(workflow).toContain("goto about:blank");
|
||||
expect(workflow).toContain("pathToFileURL(p).href");
|
||||
expect(workflow).toContain('path").join(process.env.GITHUB_WORKSPACE,".gstack-runtime-smoke.html")');
|
||||
expect(workflow).not.toContain("goto about:blank");
|
||||
expect(read("scripts/gstack2/runtime-install-smoke.sh"))
|
||||
.toContain('./setup --home "$HOME_DIR" --browser managed --install-now --yes --json');
|
||||
const manifest = read(".github/scripts/create-runtime-release-manifest.mjs");
|
||||
expect(manifest).toContain("bytes: stat.size");
|
||||
expect(manifest).toContain('certificateOidcIssuer: "https://token.actions.githubusercontent.com"');
|
||||
@@ -98,17 +108,17 @@ describe("release and CI hardening", () => {
|
||||
const installer = read("runtime/install.js");
|
||||
expect(installer).toContain('entry("runtime")');
|
||||
expect(installer).toContain('entry(managedBunRelativePath(), "managed-bun", true)');
|
||||
expect(installer).not.toContain('entry("node_modules/playwright-core")');
|
||||
const browser = read("browse/src/cli.ts");
|
||||
expect(browser).toContain("Every installed/compiled client must use the adjacent Node-compatible daemon");
|
||||
expect(browser).toContain("if (isCompiled)");
|
||||
expect(browser).toContain("if (!nodeServerScript)");
|
||||
expect(browser).toContain("return { isCompiled, nodeServerScript, sourceServerScript: null }");
|
||||
expect(browser).toContain("export function resolveServerLaunchTarget(");
|
||||
expect(browser).toContain("server-node.mjs not found. Rebuild the managed browser runtime");
|
||||
});
|
||||
|
||||
test("Windows setup lane installs, doctors, and uninstalls rather than only building", () => {
|
||||
const workflow = read(".github/workflows/windows-setup-e2e.yml");
|
||||
expect(workflow).toContain("--dry-run --capabilities browser");
|
||||
expect(workflow).toContain("--install-now --yes --capabilities browser");
|
||||
expect(workflow).toContain("--dry-run --capabilities browser --browser managed");
|
||||
expect(workflow).toContain("--install-now --yes --capabilities browser --browser managed");
|
||||
expect(workflow).toContain("doctor --json");
|
||||
expect(workflow).toContain("runtime/cli.js uninstall");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user