mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 04:10:47 +02:00
feat: componentize GStack 2 runtime and release integrity
This commit is contained in:
@@ -10,6 +10,10 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
#if DEBUG
|
||||
import DebugBridgeCore
|
||||
#endif
|
||||
@@ -48,13 +52,47 @@ struct ContentView: View {
|
||||
Text("StateServer should be on :9999")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
Button("Tap (\(counter))") {
|
||||
counter += 1
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.accessibilityIdentifier("tap-button")
|
||||
#if canImport(UIKit)
|
||||
FixtureButton(counter: $counter)
|
||||
.frame(minWidth: 120, minHeight: 44)
|
||||
#else
|
||||
Button("Tap (\(counter))") { counter += 1 }
|
||||
.accessibilityIdentifier("tap-button")
|
||||
#endif
|
||||
}
|
||||
.padding()
|
||||
.accessibilityIdentifier("fixture-content")
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(UIKit)
|
||||
/// A real UIKit control inside the SwiftUI fixture. The DebugBridge scanner is
|
||||
/// intentionally in-process (not XCTest's private accessibility daemon), so a
|
||||
/// UIViewRepresentable gives the physical-device lane a public, enumerable
|
||||
/// accessibility element while still exercising SwiftUI state updates.
|
||||
struct FixtureButton: UIViewRepresentable {
|
||||
@Binding var counter: Int
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(self) }
|
||||
|
||||
func makeUIView(context: Context) -> UIButton {
|
||||
let button = UIButton(type: .system)
|
||||
button.configuration = .borderedProminent()
|
||||
button.accessibilityIdentifier = "tap-button"
|
||||
button.addTarget(context.coordinator, action: #selector(Coordinator.tap), for: .touchUpInside)
|
||||
return button
|
||||
}
|
||||
|
||||
func updateUIView(_ button: UIButton, context: Context) {
|
||||
context.coordinator.parent = self
|
||||
button.setTitle("Tap (\(counter))", for: .normal)
|
||||
button.accessibilityLabel = "Tap (\(counter))"
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject {
|
||||
var parent: FixtureButton
|
||||
init(_ parent: FixtureButton) { self.parent = parent }
|
||||
@objc func tap() { parent.counter += 1 }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { hostname } from "node:os";
|
||||
import {
|
||||
acquireLock,
|
||||
cleanupRuntime,
|
||||
@@ -33,6 +34,22 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("runtime cleanup boundary", () => {
|
||||
test("immediately reclaims a same-host lock whose owner PID is dead", async () => {
|
||||
const home = await temporaryHome();
|
||||
const lockPath = runtimeLifecycleLockPath(home);
|
||||
await fs.mkdir(lockPath, { mode: 0o700 });
|
||||
await fs.writeFile(path.join(lockPath, "owner.json"), JSON.stringify({
|
||||
token: "dead-owner",
|
||||
pid: 2_147_483_647,
|
||||
hostname: hostname(),
|
||||
createdAt: new Date().toISOString(),
|
||||
}));
|
||||
const started = Date.now();
|
||||
const release = await acquireLock(lockPath, { staleMs: 60 * 60 * 1000, timeoutMs: 1_000 });
|
||||
expect(Date.now() - started).toBeLessThan(1_000);
|
||||
await release();
|
||||
});
|
||||
|
||||
test("retries transient Windows lock creation and rename races", async () => {
|
||||
const home = await temporaryHome();
|
||||
const lockPath = path.join(home, "locks", "windows-race.lock");
|
||||
|
||||
@@ -46,7 +46,7 @@ describe("Context.dev privacy and failure contract", () => {
|
||||
const home = path.join(root, "state");
|
||||
const stream = { write: (_value: string) => {} };
|
||||
try {
|
||||
expect(await main(["context", "setup", "--consent"], {
|
||||
expect(await main(["context", "setup", "--consent", "--offline"], {
|
||||
env: { GSTACK_HOME: home, CONTEXT_DEV_API_KEY: "future-format-12345" },
|
||||
cwd: root,
|
||||
stdout: stream,
|
||||
@@ -54,12 +54,67 @@ describe("Context.dev privacy and failure contract", () => {
|
||||
})).toBe(0);
|
||||
expect(await loadConfig(home)).toMatchObject({
|
||||
network: { mode: "context", consent: true, selection: "context" },
|
||||
context: { validation: { status: "unverified", checkedAt: null } },
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Context setup becomes ready only after provider validation succeeds", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-context-verified-"));
|
||||
const home = path.join(root, "state");
|
||||
const stream = { write: (_value: string) => {} };
|
||||
let validated = 0;
|
||||
try {
|
||||
expect(await main(["context", "setup", "--consent"], {
|
||||
env: { GSTACK_HOME: home, CONTEXT_DEV_API_KEY: "future-format-12345" },
|
||||
cwd: root,
|
||||
stdout: stream,
|
||||
stderr: stream,
|
||||
contextClientFactory: () => ({
|
||||
scrapeMarkdown: async () => { validated += 1; return { success: true }; },
|
||||
}),
|
||||
})).toBe(0);
|
||||
expect(validated).toBe(1);
|
||||
expect(await loadConfig(home)).toMatchObject({
|
||||
network: { mode: "context", consent: true, selection: "context" },
|
||||
context: { validation: { status: "verified" } },
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("CLI exposes only the allowlisted public Context.dev operations", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-context-cli-"));
|
||||
const home = path.join(root, "state");
|
||||
let output = "";
|
||||
const stream = { write: (value: string) => { output += value; } };
|
||||
const calls: Array<[string, unknown, unknown]> = [];
|
||||
const client = {
|
||||
scrapeMarkdown: async (target: string, options: unknown) => { calls.push(["markdown", target, options]); return { markdown: "hello" }; },
|
||||
scrapeHtml: async (target: string, options: unknown) => { calls.push(["html", target, options]); return { html: "<p>hello</p>" }; },
|
||||
crawl: async (target: string, options: unknown) => { calls.push(["crawl", target, options]); return { pages: [] }; },
|
||||
sitemap: async (target: string, options: unknown) => { calls.push(["sitemap", target, options]); return { links: [] }; },
|
||||
screenshot: async (target: string, options: unknown) => { calls.push(["screenshot", target, options]); return { image: "omitted" }; },
|
||||
};
|
||||
const common = {
|
||||
env: { GSTACK_HOME: home }, cwd: root, stdout: stream, stderr: stream,
|
||||
contextClientFactory: () => client,
|
||||
};
|
||||
try {
|
||||
expect(await main(["context", "scrape-markdown", "https://example.com", "--main-content"], common)).toBe(0);
|
||||
expect(output).toBe("hello\n");
|
||||
output = "";
|
||||
expect(await main(["context", "crawl", "https://example.com", "--max-pages", "2", "--max-depth", "1", "--json"], common)).toBe(0);
|
||||
expect(calls).toContainEqual(["crawl", "https://example.com", { maxPages: 2, maxDepth: 1 }]);
|
||||
expect(await main(["context", "sitemap", "example.com", "--max-links", "0"], common)).toBe(2);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("public config rejects nested secret-shaped fields", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-context-config-"));
|
||||
const home = path.join(root, "state");
|
||||
|
||||
@@ -2,14 +2,18 @@ import { describe, expect, test } from "bun:test";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
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 { summarizeRuntimeBundle } from "../scripts/gstack2/audit-runtime-bundle";
|
||||
import {
|
||||
DEFAULT_CAPABILITY_LAUNCHERS,
|
||||
DEFAULT_RUNTIME_BUNDLE,
|
||||
DEFAULT_RUNTIME_HELPERS,
|
||||
MAX_RUNTIME_BUNDLE_BYTES,
|
||||
defaultBunBuilder,
|
||||
installManagedRuntime,
|
||||
normalizeManagedBrowserTree,
|
||||
runtimeNativePackagePaths,
|
||||
uninstallManagedRuntime,
|
||||
runCommand,
|
||||
@@ -27,12 +31,50 @@ 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("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-"));
|
||||
try {
|
||||
const valid = path.join(root, "valid");
|
||||
const version = path.join(valid, "Framework", "Versions", "145");
|
||||
await fs.mkdir(version, { recursive: true });
|
||||
await fs.writeFile(path.join(version, "Chrome Framework"), "binary\n", { mode: 0o755 });
|
||||
await fs.symlink("145", path.join(valid, "Framework", "Versions", "Current"), "dir");
|
||||
await fs.symlink("Versions/Current/Chrome Framework", path.join(valid, "Framework", "Chrome Framework"), "file");
|
||||
expect(await normalizeManagedBrowserTree(valid)).toBe(2);
|
||||
expect((await fs.lstat(path.join(valid, "Framework", "Versions", "Current"))).isDirectory()).toBe(true);
|
||||
const executable = path.join(valid, "Framework", "Chrome Framework");
|
||||
expect((await fs.lstat(executable)).isFile()).toBe(true);
|
||||
expect((await fs.stat(executable)).mode & 0o111).not.toBe(0);
|
||||
|
||||
const cases = ["escape", "absolute", "dangling", "loop", "nested-escape"];
|
||||
for (const name of cases) await fs.mkdir(path.join(root, name));
|
||||
await fs.writeFile(path.join(root, "outside"), "outside\n");
|
||||
await fs.symlink("../outside", path.join(root, "escape", "link"));
|
||||
await fs.symlink(path.join(root, "absolute"), path.join(root, "absolute", "link"));
|
||||
await fs.symlink("missing", path.join(root, "dangling", "link"));
|
||||
await fs.symlink("b", path.join(root, "loop", "a"));
|
||||
await fs.symlink("a", path.join(root, "loop", "b"));
|
||||
const nestedTarget = path.join(root, "nested-escape", "target");
|
||||
await fs.mkdir(nestedTarget);
|
||||
await fs.symlink("../../outside", path.join(nestedTarget, "evil"));
|
||||
await fs.symlink("target", path.join(root, "nested-escape", "link"), "dir");
|
||||
for (const name of cases) {
|
||||
await expect(normalizeManagedBrowserTree(path.join(root, name)))
|
||||
.rejects.toMatchObject({ code: "INSTALL_BROWSER_PAYLOAD_INVALID" });
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("installs, validates, activates, and writes an uninstall-friendly manifest", async () => {
|
||||
await withFixture(async ({ source, home }) => {
|
||||
const result = await installFixture(source, home, "2.0.0");
|
||||
|
||||
expect(result.pointer.status).toBe("active");
|
||||
expect(result.pointer.current).toBe("2.0.0");
|
||||
expect(result.consumedScratch).toBe(true);
|
||||
expect(await readJson(path.join(home, "versions", "current.json"))).toMatchObject({ current: "2.0.0" });
|
||||
expect(await fs.readFile(path.join(result.path, "cap", "tool"), "utf8")).toContain("fixture capability");
|
||||
expect((await fs.lstat(path.join(result.path, "runtime", "cli.js"))).isSymbolicLink()).toBe(false);
|
||||
@@ -114,6 +156,159 @@ describe("GStack 2 managed runtime installer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("managed Chromium stages transactionally without mutating the source checkout", async () => {
|
||||
await withFixture(async ({ source, home }) => {
|
||||
await fs.mkdir(path.join(source, "node_modules", "playwright"), { recursive: true });
|
||||
await fs.writeFile(path.join(source, "node_modules", "playwright", "cli.js"), "fixture\n");
|
||||
const entries = [
|
||||
{ path: "runtime" },
|
||||
{ path: "bin/gstack", executable: true },
|
||||
{ path: ".gstack-runtime-browsers", build: "browser" },
|
||||
];
|
||||
const phases: string[] = [];
|
||||
const run = async (_command: string, args: string[], options: { env?: Record<string, string>; superviseTree?: boolean; timeoutMs?: number } = {}) => {
|
||||
if (!args[0]?.endsWith(path.join("node_modules", "playwright", "cli.js"))) return { code: 0, stdout: "", stderr: "" };
|
||||
expect(args.slice(1)).toEqual(["install", "--no-shell", "chromium"]);
|
||||
expect(options.superviseTree).toBe(true);
|
||||
expect(options.timeoutMs).toBe(15 * 60_000);
|
||||
const target = options.env?.PLAYWRIGHT_BROWSERS_PATH;
|
||||
if (!target) throw new Error("missing fixture browser destination");
|
||||
await fs.mkdir(path.join(target, "chromium-fixture"), { recursive: true });
|
||||
await fs.writeFile(path.join(target, "chromium-fixture", "chrome"), "fixture\n", { mode: 0o755 });
|
||||
return { code: 0, stdout: "", stderr: "" };
|
||||
};
|
||||
const result = await installManagedRuntime({
|
||||
sourceDir: source,
|
||||
home,
|
||||
version: "browser-transaction",
|
||||
entries,
|
||||
capabilities: { browse: ".gstack-runtime-browsers/chromium-fixture/chrome" },
|
||||
runCommand: run,
|
||||
smokeTest: async () => {},
|
||||
onPhase: (event: { phase: string }) => phases.push(event.phase),
|
||||
});
|
||||
expect(await exists(path.join(source, ".gstack-runtime-browsers"))).toBe(false);
|
||||
expect(await exists(path.join(result.path, ".gstack-runtime-browsers", "chromium-fixture", "chrome"))).toBe(true);
|
||||
expect((await fs.readdir(path.join(home, "tmp"))).some((name) => name.startsWith("install-"))).toBe(false);
|
||||
expect(phases).toEqual([
|
||||
"copy-source:start",
|
||||
"copy-source:complete",
|
||||
"managed-chromium:start",
|
||||
"managed-chromium:complete",
|
||||
"bundle-validation:start",
|
||||
"bundle-validation:complete",
|
||||
"activation:start",
|
||||
"activation:complete",
|
||||
]);
|
||||
|
||||
await expect(installManagedRuntime({
|
||||
sourceDir: source,
|
||||
home: path.join(home, "failed"),
|
||||
version: "browser-failure",
|
||||
entries,
|
||||
capabilities: { browse: ".gstack-runtime-browsers/chromium-fixture/chrome" },
|
||||
runCommand: async () => { throw new Error("download failed"); },
|
||||
smokeTest: async () => {},
|
||||
})).rejects.toMatchObject({ code: "INSTALL_BROWSER_DOWNLOAD_FAILED" });
|
||||
expect(await exists(path.join(source, ".gstack-runtime-browsers"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("ordinary source installs ignore an untracked browser cache while prepared artifacts retain their verified cache", async () => {
|
||||
await withFixture(async ({ source, home }) => {
|
||||
await fs.mkdir(path.join(source, "node_modules", "playwright"), { recursive: true });
|
||||
await fs.writeFile(path.join(source, "node_modules", "playwright", "cli.js"), "fixture\n");
|
||||
await fs.mkdir(path.join(source, ".gstack-runtime-browsers", "untrusted"), { recursive: true });
|
||||
await fs.writeFile(path.join(source, ".gstack-runtime-browsers", "untrusted", "chrome"), "untrusted\n", { mode: 0o755 });
|
||||
const entries = [
|
||||
{ path: "runtime" },
|
||||
{ path: "bin/gstack", executable: true },
|
||||
{ path: ".gstack-runtime-browsers", build: "browser" },
|
||||
];
|
||||
let downloads = 0;
|
||||
const result = await installManagedRuntime({
|
||||
sourceDir: source,
|
||||
home,
|
||||
version: "browser-source-cache-ignored",
|
||||
entries,
|
||||
capabilities: { browse: ".gstack-runtime-browsers/fresh/chrome" },
|
||||
runCommand: async (_command: string, args: string[], options: { env?: Record<string, string> } = {}) => {
|
||||
downloads += 1;
|
||||
expect(args.slice(1)).toEqual(["install", "--no-shell", "chromium"]);
|
||||
const target = options.env?.PLAYWRIGHT_BROWSERS_PATH;
|
||||
if (!target) throw new Error("missing fixture browser destination");
|
||||
await fs.mkdir(path.join(target, "fresh"), { recursive: true });
|
||||
await fs.writeFile(path.join(target, "fresh", "chrome"), "fresh\n", { mode: 0o755 });
|
||||
return { code: 0, stdout: "", stderr: "" };
|
||||
},
|
||||
smokeTest: async () => {},
|
||||
});
|
||||
expect(downloads).toBe(1);
|
||||
expect(await exists(path.join(result.path, ".gstack-runtime-browsers", "fresh", "chrome"))).toBe(true);
|
||||
expect(await exists(path.join(result.path, ".gstack-runtime-browsers", "untrusted", "chrome"))).toBe(false);
|
||||
expect(await fs.readFile(path.join(source, ".gstack-runtime-browsers", "untrusted", "chrome"), "utf8")).toBe("untrusted\n");
|
||||
|
||||
let preparedDownloadAttempted = false;
|
||||
const prepared = await installManagedRuntime({
|
||||
sourceDir: source,
|
||||
home: path.join(home, "prepared-home"),
|
||||
version: "browser-prepared-cache",
|
||||
entries,
|
||||
capabilities: { browse: ".gstack-runtime-browsers/untrusted/chrome" },
|
||||
buildMissing: false,
|
||||
preparedSource: true,
|
||||
runCommand: async () => {
|
||||
preparedDownloadAttempted = true;
|
||||
throw new Error("prepared artifacts must not download Chromium again");
|
||||
},
|
||||
smokeTest: async () => {},
|
||||
});
|
||||
expect(preparedDownloadAttempted).toBe(false);
|
||||
expect(await fs.readFile(path.join(prepared.path, ".gstack-runtime-browsers", "untrusted", "chrome"), "utf8")).toBe("untrusted\n");
|
||||
});
|
||||
});
|
||||
|
||||
test("ordinary source installs ignore an untracked Bun executable while prepared artifacts retain and probe theirs", async () => {
|
||||
await withFixture(async ({ source, home }) => {
|
||||
const relative = path.join(".gstack-runtime-tools", process.platform === "win32" ? "bun.exe" : "bun");
|
||||
const sourceBun = path.join(source, relative);
|
||||
await fs.mkdir(path.dirname(sourceBun), { recursive: true });
|
||||
await fs.writeFile(sourceBun, "untrusted checkout executable\n", { mode: 0o755 });
|
||||
const entries = [
|
||||
{ path: "runtime" },
|
||||
{ path: "bin/gstack", executable: true },
|
||||
{ path: relative, build: "managed-bun", executable: true },
|
||||
];
|
||||
const installed = await installManagedRuntime({
|
||||
sourceDir: source,
|
||||
home,
|
||||
version: "bun-source-cache-ignored",
|
||||
entries,
|
||||
capabilities: { bun: relative },
|
||||
bunCommand: process.execPath,
|
||||
smokeTest: async () => {},
|
||||
});
|
||||
expect(await fs.readFile(sourceBun, "utf8")).toBe("untrusted checkout executable\n");
|
||||
expect((await fs.stat(path.join(installed.path, relative))).size).toBeGreaterThan(1024 * 1024);
|
||||
expect(installed.pointer.current).toBe("bun-source-cache-ignored");
|
||||
|
||||
await fs.copyFile(process.execPath, sourceBun);
|
||||
if (process.platform !== "win32") await fs.chmod(sourceBun, 0o755);
|
||||
const prepared = await installManagedRuntime({
|
||||
sourceDir: source,
|
||||
home: path.join(home, "prepared"),
|
||||
version: "bun-prepared-cache",
|
||||
entries,
|
||||
capabilities: { bun: relative },
|
||||
buildMissing: false,
|
||||
preparedSource: true,
|
||||
smokeTest: async () => {},
|
||||
});
|
||||
expect(prepared.pointer.current).toBe("bun-prepared-cache");
|
||||
expect((await readJson(path.join(prepared.path, ".gstack-bundle.json"))).tools.bun.version).toBe("1.3.14");
|
||||
});
|
||||
});
|
||||
|
||||
test("default capability builds never regenerate the Agent Skills tree", async () => {
|
||||
const calls: Array<{ command: string; args: string[] }> = [];
|
||||
await defaultBunBuilder({
|
||||
@@ -159,7 +354,28 @@ describe("GStack 2 managed runtime installer", () => {
|
||||
}
|
||||
|
||||
await withFixture(async ({ home }) => {
|
||||
const result = await installManagedRuntime({ sourceDir: REPO_ROOT, home, version: "helper-contract-test" });
|
||||
const result = await installManagedRuntime({
|
||||
sourceDir: REPO_ROOT,
|
||||
home,
|
||||
version: "helper-contract-test",
|
||||
runCommand: async (command: string, args: string[], options: { env?: Record<string, string> } = {}) => {
|
||||
if (args[0] === "--eval" && args[1]?.includes("process.execPath")) {
|
||||
return { code: 0, stdout: process.execPath, stderr: "" };
|
||||
}
|
||||
if (args[0]?.endsWith(path.join("node_modules", "playwright", "cli.js"))) {
|
||||
const browserRoot = options.env?.PLAYWRIGHT_BROWSERS_PATH;
|
||||
if (!browserRoot) throw new Error("fixture browser root missing");
|
||||
await fs.mkdir(path.join(browserRoot, "chromium-fixture"), { recursive: true });
|
||||
await fs.writeFile(path.join(browserRoot, "chromium-fixture", "chrome"), "fixture\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: "" };
|
||||
}
|
||||
if (args[0] === "--version") return { code: 0, stdout: "v20.18.0\n", stderr: "" };
|
||||
return { code: 0, stdout: "gstack runtime fixture\n", stderr: "" };
|
||||
},
|
||||
});
|
||||
for (const helper of contract.helpers) {
|
||||
const stable = path.join(home, "bin", helper.name);
|
||||
const stat = await fs.lstat(stable);
|
||||
@@ -180,11 +396,24 @@ describe("GStack 2 managed runtime installer", () => {
|
||||
|
||||
const next = await runInstalledLauncher(home, "gstack-next-version", ["--help"], { capture: true });
|
||||
expect(next.stdout).toContain("Usage: gstack-next-version");
|
||||
if (process.platform !== "win32") {
|
||||
const node = (await runCommand("node", ["-p", "process.execPath"], { capture: true })).stdout.trim();
|
||||
const nodeOnlyEnv = {
|
||||
...process.env,
|
||||
PATH: "/usr/bin:/bin",
|
||||
GSTACK_NODE: node,
|
||||
BUN_CMD: "",
|
||||
};
|
||||
const managedBun = await runInstalledLauncher(home, "bun", ["--version"], { capture: true, env: nodeOnlyEnv });
|
||||
expect(managedBun.stdout.trim()).toBe("1.3.14");
|
||||
const nodeOnlyHelper = await runInstalledLauncher(home, "gstack-next-version", ["--help"], { capture: true, env: nodeOnlyEnv });
|
||||
expect(nodeOnlyHelper.stdout).toContain("Usage: gstack-next-version");
|
||||
}
|
||||
const sourced = await runCommand("bash", ["-c", '. "$1"; type read_secret_to_env', "_", path.join(home, "bin", "gstack-gbrain-lib.sh")], { capture: true });
|
||||
expect(sourced.stdout).toContain("read_secret_to_env");
|
||||
const syncAlias = await runInstalledLauncher(home, "gstack-gbrain-sync", ["--help"], { capture: true });
|
||||
expect(`${syncAlias.stdout}${syncAlias.stderr}`).toContain("gstack-gbrain-sync");
|
||||
const syncTypeScriptAlias = await runCommand("bun", [path.join(home, "bin", "gstack-gbrain-sync.ts"), "--help"], { capture: true });
|
||||
const syncTypeScriptAlias = await runInstalledLauncher(home, "gstack-gbrain-sync.ts", ["--help"], { capture: true });
|
||||
expect(`${syncTypeScriptAlias.stdout}${syncTypeScriptAlias.stderr}`).toContain("gstack-gbrain-sync");
|
||||
|
||||
const body = path.join(home, "audit-body.txt");
|
||||
@@ -222,6 +451,57 @@ describe("GStack 2 managed runtime installer", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("supervised timeout terminates descendants before returning cleanup authority", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-command-tree-timeout-"));
|
||||
const marker = path.join(root, "descendant-wrote-after-timeout");
|
||||
const childProgram = `setTimeout(() => require("node:fs").writeFileSync(${JSON.stringify(marker)}, "unsafe"), 500); setInterval(() => {}, 1000);`;
|
||||
const parentProgram = `require("node:child_process").spawn(process.execPath, ["--eval", ${JSON.stringify(childProgram)}], { stdio: "ignore" }); setInterval(() => {}, 1000);`;
|
||||
try {
|
||||
await expect(runCommand(process.execPath, ["--eval", parentProgram], {
|
||||
capture: true,
|
||||
timeoutMs: 100,
|
||||
killGraceMs: 5_000,
|
||||
superviseTree: true,
|
||||
})).rejects.toMatchObject({ code: "INSTALL_COMMAND_TIMEOUT" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 700));
|
||||
expect(await exists(marker)).toBe(false);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("supervised cancellation terminates descendants before the installer process exits", async () => {
|
||||
if (process.platform === "win32") return;
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-command-tree-cancel-"));
|
||||
const ready = path.join(root, "ready");
|
||||
const marker = path.join(root, "descendant-wrote-after-cancel");
|
||||
const childProgram = `setTimeout(() => require("node:fs").writeFileSync(${JSON.stringify(marker)}, "unsafe"), 700); setInterval(() => {}, 1000);`;
|
||||
const parentProgram = `require("node:child_process").spawn(process.execPath, ["--eval", ${JSON.stringify(childProgram)}], { stdio: "ignore" }); setInterval(() => {}, 1000);`;
|
||||
const harness = [
|
||||
`import { runCommand } from ${JSON.stringify(pathToFileURL(path.join(REPO_ROOT, "runtime", "install.js")).href)};`,
|
||||
`import fs from "node:fs";`,
|
||||
`fs.writeFileSync(${JSON.stringify(ready)}, "ready");`,
|
||||
`try { await runCommand(process.execPath, ["--eval", ${JSON.stringify(parentProgram)}], { superviseTree: true, timeoutMs: 60000 }); } catch { process.exitCode = 0; }`,
|
||||
].join("\n");
|
||||
try {
|
||||
const process_ = spawn("node", ["--input-type=module", "--eval", harness], { stdio: "ignore" });
|
||||
for (let attempt = 0; attempt < 100 && !await exists(ready); attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
expect(await exists(ready)).toBe(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
process_.kill("SIGINT");
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error("cancellation harness did not exit")), 5_000);
|
||||
process_.once("exit", () => { clearTimeout(timeout); resolve(null); });
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 900));
|
||||
expect(await exists(marker)).toBe(false);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("selects one deterministic native dependency closure per supported host", () => {
|
||||
expect(runtimeNativePackagePaths({ platform: "darwin", arch: "arm64" })).toEqual([
|
||||
"node_modules/@img/colour",
|
||||
@@ -397,6 +677,15 @@ describe("GStack 2 managed runtime installer", () => {
|
||||
await fs.chmod(cli, originalMode);
|
||||
}
|
||||
|
||||
await fs.writeFile(manifestPath, `${JSON.stringify({
|
||||
...manifest,
|
||||
files: manifest.files.map((file: { size: number }, index: number) =>
|
||||
index === 0 ? { ...file, size: MAX_RUNTIME_BUNDLE_BYTES + 1 } : file),
|
||||
}, null, 2)}\n`);
|
||||
await expect(validateRuntimeBundle(result.path, { version: "2.0.0" })).rejects.toMatchObject({
|
||||
code: "INSTALL_VALIDATION_FAILED",
|
||||
});
|
||||
|
||||
await fs.writeFile(manifestPath, `${JSON.stringify({ ...manifest, files: [] }, null, 2)}\n`);
|
||||
await expect(validateRuntimeBundle(result.path, { version: "2.0.0" })).rejects.toMatchObject({
|
||||
code: "INSTALL_VALIDATION_FAILED",
|
||||
@@ -645,7 +934,11 @@ describe("GStack 2 managed runtime installer", () => {
|
||||
await fs.writeFile(path.join(root, "package.json"), '{"type":"module","dependencies":{"sharp":"1.0.0"},"devDependencies":{"test-only-sdk":"1.0.0"}}\n');
|
||||
await fs.writeFile(path.join(root, "node_modules", "sharp", "package.json"), '{"name":"sharp","main":"index.js"}\n');
|
||||
await fs.writeFile(path.join(root, "node_modules", "sharp", "index.js"), 'module.exports = require("@img/sharp-fixture");\n');
|
||||
await fs.writeFile(path.join(runtime, "install.js"), 'console.log(`installer=${process.release.name}`);\n');
|
||||
await fs.writeFile(path.join(runtime, "install.js"), `import { spawnSync } from "node:child_process";
|
||||
const result = spawnSync(process.env.BUN_CMD || "bun", ["install", "--production", "--frozen-lockfile"], { stdio: "inherit" });
|
||||
if (result.status !== 0) process.exit(result.status || 1);
|
||||
console.log(\`installer=\${process.release.name}\`);
|
||||
`);
|
||||
await fs.writeFile(path.join(fakeBin, "bun"), `#!/bin/sh
|
||||
printf '%s\\n' "$*" >> "$BUN_LOG"
|
||||
mkdir -p "$FIXTURE_ROOT/node_modules/@img/sharp-fixture"
|
||||
@@ -653,7 +946,7 @@ printf '{"name":"@img/sharp-fixture","main":"index.js"}\\n' > "$FIXTURE_ROOT/nod
|
||||
printf 'module.exports = {}\\n' > "$FIXTURE_ROOT/node_modules/@img/sharp-fixture/index.js"
|
||||
`, { mode: 0o755 });
|
||||
|
||||
const result = await runCommand(path.join(root, "setup"), [], {
|
||||
const result = await runCommand(path.join(root, "setup"), ["--install-now", "--yes"], {
|
||||
capture: true,
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -666,7 +959,7 @@ printf 'module.exports = {}\\n' > "$FIXTURE_ROOT/node_modules/@img/sharp-fixture
|
||||
expect(await fs.readFile(log, "utf8")).toContain("install --production --frozen-lockfile");
|
||||
expect(result.stdout).toContain("installer=node");
|
||||
|
||||
const second = await runCommand(path.join(root, "setup"), [], {
|
||||
const second = await runCommand(path.join(root, "setup"), ["--install-now", "--yes"], {
|
||||
capture: true,
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -756,6 +1049,8 @@ async function createSource(source: string) {
|
||||
await fs.mkdir(path.join(source, "cap"), { 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.writeFile(path.join(source, "bin", "gstack"), `#!/usr/bin/env node
|
||||
import { main } from "../runtime/cli.js";
|
||||
process.exitCode = await main(process.argv.slice(2));
|
||||
|
||||
@@ -8,12 +8,14 @@ import {
|
||||
ensureManagedHome,
|
||||
purgeManagedHomeUnlocked,
|
||||
setupRuntime,
|
||||
stageUpgrade,
|
||||
uninstallManagedRuntime,
|
||||
} from "../runtime/index.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
const configBin = path.resolve(import.meta.dir, "../bin/gstack-config");
|
||||
const gstackBin = path.resolve(import.meta.dir, "../bin/gstack");
|
||||
const updateCheckBin = path.resolve(import.meta.dir, "../bin/gstack-update-check");
|
||||
|
||||
async function root() {
|
||||
const result = await fs.mkdtemp(path.join(os.tmpdir(), "gstack2-safety-config-"));
|
||||
@@ -26,6 +28,29 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("managed-home destructive boundary", () => {
|
||||
test("rejects symlinked tmp and versions directories before runtime mutation", async () => {
|
||||
if (process.platform === "win32") return;
|
||||
const base = await root();
|
||||
const home = path.join(base, "home");
|
||||
const outside = path.join(base, "outside");
|
||||
await ensureManagedHome(home);
|
||||
await fs.mkdir(outside);
|
||||
await fs.writeFile(path.join(outside, "keep"), "keep\n");
|
||||
|
||||
await fs.symlink(outside, path.join(home, "tmp"), "dir");
|
||||
await expect(uninstallManagedRuntime(home)).rejects.toMatchObject({ code: "MANAGED_HOME_SUBDIRECTORY_UNSAFE" });
|
||||
await fs.rm(path.join(home, "tmp"));
|
||||
|
||||
const source = path.join(base, "source");
|
||||
await fs.mkdir(source);
|
||||
await fs.writeFile(path.join(source, "payload"), "payload\n");
|
||||
await fs.symlink(outside, path.join(home, "versions"), "dir");
|
||||
await expect(stageUpgrade({ home, sourceDir: source, version: "1.0.0" }))
|
||||
.rejects.toMatchObject({ code: "MANAGED_HOME_SUBDIRECTORY_UNSAFE" });
|
||||
expect(await fs.readFile(path.join(outside, "keep"), "utf8")).toBe("keep\n");
|
||||
expect(await fs.readdir(outside)).toEqual(["keep"]);
|
||||
});
|
||||
|
||||
test("claims only a new or empty directory and leaves nonempty input untouched", async () => {
|
||||
const base = await root();
|
||||
const occupied = path.join(base, "occupied");
|
||||
@@ -162,6 +187,32 @@ describe("managed-home destructive boundary", () => {
|
||||
});
|
||||
|
||||
describe("one config authority", () => {
|
||||
test("Agent Skills owns updates and passive GStack release requests are off", async () => {
|
||||
const base = await root();
|
||||
const home = path.join(base, "state");
|
||||
const remoteVersion = path.join(base, "remote-version");
|
||||
await fs.writeFile(remoteVersion, "999.0.0\n");
|
||||
|
||||
const config = spawnSync(process.execPath, [configBin, "get", "update_check"], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, GSTACK_HOME: home },
|
||||
});
|
||||
expect(config.status).toBe(0);
|
||||
expect(config.stdout).toBe("false");
|
||||
|
||||
const passive = spawnSync(updateCheckBin, [], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
GSTACK_HOME: home,
|
||||
GSTACK_REMOTE_URL: `file://${remoteVersion}`,
|
||||
},
|
||||
});
|
||||
expect(passive.status).toBe(0);
|
||||
expect(passive.stdout).toBe("");
|
||||
expect(await fs.stat(path.join(home, "last-update-check")).catch(() => null)).toBeNull();
|
||||
});
|
||||
|
||||
test("compatibility helper and runtime config share config.json", async () => {
|
||||
const home = path.join(await root(), "state");
|
||||
const run = (args: string[]) => spawnSync(process.execPath, [configBin, ...args], {
|
||||
@@ -194,7 +245,8 @@ describe("one config authority", () => {
|
||||
expect(JSON.parse(await fs.readFile(path.join(home, "config.json"), "utf8")).telemetry).toBe("anonymous");
|
||||
const setup = run(["setup"]);
|
||||
expect(setup.status).toBe(0);
|
||||
expect(setup.stdout).toContain("gstack is ready");
|
||||
expect(setup.stdout).toContain("GStack state initialized");
|
||||
expect(setup.stdout).toContain("optional runtime: unchanged");
|
||||
});
|
||||
|
||||
test("legacy YAML is read-only migration input and JSON takes authority on write", async () => {
|
||||
@@ -231,4 +283,21 @@ describe("one config authority", () => {
|
||||
expect(install).not.toContain('"gstack-team-init": helper');
|
||||
expect(config).not.toMatch(/\.claude\/skills|gstack-relink|gen:skill-docs:user/);
|
||||
});
|
||||
|
||||
test("runtime remediation never assumes a host-specific skill path or legacy setup script", async () => {
|
||||
const sources = [
|
||||
"runtime/install.js",
|
||||
"make-pdf/src/setup.ts",
|
||||
"make-pdf/src/browseClient.ts",
|
||||
"make-pdf/src/diagram-prepass.ts",
|
||||
];
|
||||
for (const relative of sources) {
|
||||
const source = await fs.readFile(path.resolve(import.meta.dir, "..", relative), "utf8");
|
||||
expect(source, relative).not.toMatch(/(?:re-?run|run)\s+`?\.\/setup/i);
|
||||
expect(source, relative).not.toMatch(/(?:to fix|missing|unsafe|invalid)[\s\S]{0,240}(?:~\/)?\.claude\/skills/i);
|
||||
}
|
||||
const pdfSetup = await fs.readFile(path.resolve(import.meta.dir, "../make-pdf/src/setup.ts"), "utf8");
|
||||
expect(pdfSetup).toContain("gstack doctor --skill-api 2.0");
|
||||
expect(pdfSetup).toContain("--capability pdf");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
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";
|
||||
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 { bashCandidates, resolveBashCommand } from "../runtime/tooling.js";
|
||||
import {
|
||||
BOOTSTRAP_RUNTIME_VERSION,
|
||||
OFFICIAL_MANIFEST_URL,
|
||||
main as bootstrapMain,
|
||||
} from "../runtime/runtime-bootstrap.mjs";
|
||||
|
||||
function capture() {
|
||||
let value = "";
|
||||
return { stream: { write: (chunk: string) => { value += chunk; } }, value: () => value };
|
||||
}
|
||||
|
||||
describe("GStack runtime setup UX", () => {
|
||||
test("capability selection keeps the core and excludes unselected heavyweight surfaces", () => {
|
||||
const surface = runtimeSurfaceForCapabilities(["browser"]);
|
||||
const paths = surface.entries.map((entry) => entry.path);
|
||||
expect(paths).toContain("runtime");
|
||||
expect(paths.some((entry) => entry.startsWith("browse/"))).toBe(true);
|
||||
expect(paths.some((entry) => entry.startsWith("design/"))).toBe(false);
|
||||
expect(paths.some((entry) => entry.startsWith("make-pdf/"))).toBe(false);
|
||||
expect(surface.capabilities.browse).toBeTruthy();
|
||||
expect(surface.capabilities["gstack-design"]).toBeUndefined();
|
||||
for (const target of Object.values(surface.capabilities)) {
|
||||
expect(paths.some((root) => target === root || target.startsWith(`${root}/`))).toBe(true);
|
||||
}
|
||||
const core = runtimeSurfaceForCapabilities([]);
|
||||
const corePaths = core.entries.map((entry) => entry.path);
|
||||
for (const target of Object.values(core.capabilities)) {
|
||||
expect(corePaths.some((root) => target === root || target.startsWith(`${root}/`))).toBe(true);
|
||||
}
|
||||
expect(runtimeSlotVersion("2.0.0", ["pdf", "browser"]))
|
||||
.toBe(runtimeSlotVersion("2.0.0", ["browser", "pdf"]));
|
||||
expect(runtimeSlotVersion("2.0.0", ["browser"]))
|
||||
.not.toBe(runtimeSlotVersion("2.0.0", ["browser", "pdf"]));
|
||||
const expected = {
|
||||
browser: ["browser"],
|
||||
design: ["design"],
|
||||
diagram: ["browser", "diagram"],
|
||||
pdf: ["browser", "diagram", "pdf"],
|
||||
...(process.platform === "darwin" ? { ios: ["ios"] } : {}),
|
||||
};
|
||||
for (const [capability, dependencies] of Object.entries(expected)) {
|
||||
expect(runtimeSurfaceForCapabilities([capability]).selected).toEqual(dependencies);
|
||||
}
|
||||
});
|
||||
|
||||
test("later capability installs retain already approved capabilities unless replacement is explicit", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-setup-retain-"));
|
||||
const home = path.join(root, "home");
|
||||
const source = path.resolve(import.meta.dir, "..");
|
||||
const paths = resolveRuntimePaths({ home });
|
||||
const active = path.join(paths.versions, "existing");
|
||||
try {
|
||||
await fs.mkdir(active, { recursive: true });
|
||||
await fs.writeFile(paths.versionPointer, JSON.stringify({
|
||||
schemaVersion: 2, status: "active", current: "existing", lastKnownGood: "existing",
|
||||
}));
|
||||
await fs.writeFile(path.join(active, ".gstack-bundle.json"), JSON.stringify({
|
||||
selectedCapabilities: ["design"],
|
||||
}));
|
||||
const retained = capture();
|
||||
expect(await runInstallerCli([
|
||||
"--source", source, "--home", home, "--capabilities", "pdf", "--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",
|
||||
], { stdout: replaced.stream, stderr: replaced.stream })).toBe(0);
|
||||
expect(JSON.parse(replaced.value()).preview.capabilities).toEqual(["browser", "diagram", "pdf"]);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("dry-run previews capabilities and exact bytes without creating state", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-setup-preview-"));
|
||||
const home = path.join(root, "home");
|
||||
const source = path.resolve(import.meta.dir, "..");
|
||||
const output = capture();
|
||||
try {
|
||||
expect(await runInstallerCli([
|
||||
"--source", source,
|
||||
"--home", home,
|
||||
"--capabilities", "core",
|
||||
"--dry-run",
|
||||
"--json",
|
||||
], { stdout: output.stream, stderr: output.stream })).toBe(0);
|
||||
const result = JSON.parse(output.value());
|
||||
expect(result).toMatchObject({ ok: true, action: "dry-run", mutated: false });
|
||||
expect(result.preview.capabilities).toEqual([]);
|
||||
expect(result.preview.bytes).toBeGreaterThan(0);
|
||||
expect(result.preview.materializations.find((item) => item.kind === "managed-bun-capture")).toMatchObject({
|
||||
available: true,
|
||||
version: "1.3.14",
|
||||
});
|
||||
await expect(fs.stat(home)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Node-only dry-run explains a missing source-build Bun without mutating or failing", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-node-only-preview-"));
|
||||
const home = path.join(root, "home");
|
||||
const output = capture();
|
||||
try {
|
||||
expect(await runInstallerCli([
|
||||
"--source", path.resolve(import.meta.dir, ".."),
|
||||
"--home", home,
|
||||
"--capabilities", "browser",
|
||||
"--dry-run",
|
||||
"--json",
|
||||
], {
|
||||
stdout: output.stream,
|
||||
stderr: output.stream,
|
||||
env: { ...process.env, BUN_CMD: "definitely-missing-bun" },
|
||||
installOptions: { runCommand: async () => { throw new Error("Bun absent"); } },
|
||||
})).toBe(0);
|
||||
const preview = JSON.parse(output.value()).preview;
|
||||
expect(preview.materializations.find((item) => item.kind === "managed-bun-capture")).toMatchObject({
|
||||
available: false,
|
||||
command: "definitely-missing-bun",
|
||||
});
|
||||
expect(preview.materializations.find((item) => item.kind === "playwright-chromium-download")).toBeTruthy();
|
||||
await expect(fs.stat(home)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
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");
|
||||
try {
|
||||
await fs.mkdir(path.dirname(bash), { recursive: true });
|
||||
await fs.writeFile(bash, "fixture\n");
|
||||
const env = { ProgramFiles: root };
|
||||
expect(bashCandidates(env, "win32")).toContain(bash);
|
||||
expect(await resolveBashCommand(env, "win32")).toBe(bash);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("state initialization does not make doctor claim the runtime is installed", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-doctor-missing-runtime-"));
|
||||
const home = path.join(root, "home");
|
||||
try {
|
||||
await setupRuntime({ home, cwd: root });
|
||||
const report = await runDoctor({ home, cwd: root, nodeCommand: process.execPath });
|
||||
expect(report.ok).toBe(false);
|
||||
expect(report.checks.find((check) => check.id === "managed-runtime")).toMatchObject({ status: "fail" });
|
||||
expect(report.checks.find((check) => check.id === "capability:browser")).toMatchObject({ status: "warn" });
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("doctor fails closed when installed skills require a different runtime API", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-doctor-api-"));
|
||||
const home = path.join(root, "home");
|
||||
try {
|
||||
await setupRuntime({ home, cwd: root });
|
||||
const paths = resolveRuntimePaths({ home });
|
||||
const active = path.join(paths.versions, "fixture");
|
||||
await fs.mkdir(active, { recursive: true });
|
||||
await fs.writeFile(path.join(active, ".gstack-bundle.json"), JSON.stringify({
|
||||
compatibility: { skillApi: "2.0" },
|
||||
selectedCapabilities: [],
|
||||
capabilities: {},
|
||||
}));
|
||||
await fs.writeFile(paths.versionPointer, JSON.stringify({
|
||||
schemaVersion: 2, status: "active", current: "fixture", lastKnownGood: "fixture",
|
||||
}));
|
||||
const report = await runDoctor({
|
||||
home, cwd: root, nodeCommand: process.execPath, expectedSkillApi: "3.0",
|
||||
});
|
||||
expect(report.ok).toBe(false);
|
||||
expect(report.checks.find((check) => check.id === "managed-runtime")).toMatchObject({
|
||||
status: "fail",
|
||||
details: { expectedSkillApi: "3.0" },
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("doctor rejects selected capabilities whose declared dependencies are absent", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-doctor-deps-"));
|
||||
const home = path.join(root, "home");
|
||||
try {
|
||||
await setupRuntime({ home, cwd: root });
|
||||
const paths = resolveRuntimePaths({ home });
|
||||
const active = path.join(paths.versions, "fixture");
|
||||
await fs.mkdir(active, { recursive: true });
|
||||
await fs.writeFile(path.join(active, ".gstack-bundle.json"), JSON.stringify({
|
||||
compatibility: { skillApi: "2.0" },
|
||||
selectedCapabilities: ["pdf"],
|
||||
capabilities: { "make-pdf": "make-pdf/dist/pdf" },
|
||||
}));
|
||||
await fs.writeFile(paths.versionPointer, JSON.stringify({
|
||||
schemaVersion: 2, status: "active", current: "fixture", lastKnownGood: "fixture",
|
||||
}));
|
||||
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" });
|
||||
expect(report.checks.find((check) => check.id === "capability:pdf")?.message).toContain("browser, diagram");
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("doctor resolves the exact Chromium executable from the managed slot", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-doctor-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");
|
||||
const executable = path.join(browserRoot, "chromium-fixture", process.platform === "win32" ? "chrome.exe" : "chrome");
|
||||
await fs.mkdir(path.dirname(executable), { recursive: true });
|
||||
await fs.mkdir(path.dirname(managedBun), { recursive: true });
|
||||
await fs.mkdir(playwright, { recursive: true });
|
||||
await fs.writeFile(executable, "fixture\n", { mode: 0o755 });
|
||||
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 = { executablePath: () => ${JSON.stringify(executable)} };\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",
|
||||
}));
|
||||
const report = await runDoctor({ home, cwd: root, nodeCommand: process.execPath });
|
||||
expect(report.checks.find((check) => check.id === "capability:browser")).toMatchObject({
|
||||
status: "pass",
|
||||
details: { executable },
|
||||
});
|
||||
expect(report.checks.find((check) => check.id === "runtime-tool:bun")).toMatchObject({ status: "pass" });
|
||||
} 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;
|
||||
expect(await bootstrapMain(["--help"], {
|
||||
stdout: output.stream,
|
||||
stderr: output.stream,
|
||||
fetch: async () => { fetches += 1; throw new Error("unexpected fetch"); },
|
||||
})).toBe(0);
|
||||
expect(output.value()).toContain("--capability");
|
||||
expect(fetches).toBe(0);
|
||||
});
|
||||
|
||||
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-"));
|
||||
try {
|
||||
const linked = path.join(root, "runtime-bootstrap.mjs");
|
||||
await fs.symlink(path.resolve(import.meta.dir, "../runtime/runtime-bootstrap.mjs"), linked);
|
||||
const result = spawnSync(process.execPath, [linked, "--help"], { encoding: "utf8" });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain("Usage: node runtime-bootstrap.mjs");
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("bootstrap refuses an artifact whose SHA-256 does not match the official manifest", async () => {
|
||||
const output = capture();
|
||||
const target = `${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`;
|
||||
const artifactUrl = `https://github.com/time-attack/gstack/releases/download/v${BOOTSTRAP_RUNTIME_VERSION}/fixture.tar.gz`;
|
||||
let calls = 0;
|
||||
const fetch_ = async (url: string) => {
|
||||
calls += 1;
|
||||
if (url === OFFICIAL_MANIFEST_URL) {
|
||||
return {
|
||||
ok: true,
|
||||
url,
|
||||
json: async () => ({
|
||||
schemaVersion: 1,
|
||||
version: BOOTSTRAP_RUNTIME_VERSION,
|
||||
skillApi: "2.0",
|
||||
artifacts: { [target]: { url: artifactUrl, sha256: "0".repeat(64), bytes: 8, format: "tar.gz" } },
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
url: artifactUrl,
|
||||
arrayBuffer: async () => new TextEncoder().encode("tampered").buffer,
|
||||
};
|
||||
};
|
||||
expect(await bootstrapMain(["install", "--capability", "browser"], {
|
||||
stdout: output.stream,
|
||||
stderr: output.stream,
|
||||
fetch: fetch_,
|
||||
})).toBe(1);
|
||||
expect(calls).toBe(2);
|
||||
expect(output.value()).toContain("SHA-256 mismatch");
|
||||
});
|
||||
|
||||
test("bootstrap rejects physical iOS on non-macOS before any network request", async () => {
|
||||
const output = capture();
|
||||
let fetches = 0;
|
||||
expect(await bootstrapMain(["install", "--capability", "ios"], {
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
stdout: output.stream,
|
||||
stderr: output.stream,
|
||||
fetch: async () => { fetches += 1; throw new Error("unexpected fetch"); },
|
||||
})).toBe(1);
|
||||
expect(fetches).toBe(0);
|
||||
expect(output.value()).toContain("only on macOS");
|
||||
});
|
||||
|
||||
test("official Linux bootstrap rejects musl explicitly before any network request", async () => {
|
||||
const output = capture();
|
||||
let fetches = 0;
|
||||
expect(await bootstrapMain(["install", "--capability", "browser"], {
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
libc: "musl",
|
||||
stdout: output.stream,
|
||||
stderr: output.stream,
|
||||
fetch: async () => { fetches += 1; throw new Error("unexpected fetch"); },
|
||||
})).toBe(1);
|
||||
expect(fetches).toBe(0);
|
||||
expect(output.value()).toContain("require glibc Linux");
|
||||
});
|
||||
|
||||
test("declared Cosign bundles must bind the official release workflow identity", async () => {
|
||||
const output = capture();
|
||||
const target = `${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`;
|
||||
const artifactUrl = `https://github.com/time-attack/gstack/releases/download/v${BOOTSTRAP_RUNTIME_VERSION}/fixture.tar.gz`;
|
||||
let calls = 0;
|
||||
expect(await bootstrapMain(["install", "--capability", "browser"], {
|
||||
stdout: output.stream,
|
||||
stderr: output.stream,
|
||||
fetch: async (url: string) => {
|
||||
calls += 1;
|
||||
return {
|
||||
ok: true,
|
||||
url,
|
||||
json: async () => ({
|
||||
schemaVersion: 1,
|
||||
version: BOOTSTRAP_RUNTIME_VERSION,
|
||||
skillApi: "2.0",
|
||||
artifacts: {
|
||||
[target]: {
|
||||
url: artifactUrl,
|
||||
sha256: "0".repeat(64),
|
||||
bytes: 8,
|
||||
format: "tar.gz",
|
||||
cosignBundleUrl: `${artifactUrl}.sigstore.json`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
})).toBe(1);
|
||||
expect(calls).toBe(1);
|
||||
expect(output.value()).toContain("does not bind the official GStack release workflow");
|
||||
});
|
||||
|
||||
test("developer source fallback is explicit and does not mark the source as a prepared release", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-bootstrap-source-"));
|
||||
const runtime = path.join(root, "runtime");
|
||||
const log = path.join(root, "args.json");
|
||||
const output = capture();
|
||||
try {
|
||||
await fs.mkdir(runtime);
|
||||
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`);
|
||||
const previous = process.env.BOOTSTRAP_TEST_LOG;
|
||||
process.env.BOOTSTRAP_TEST_LOG = log;
|
||||
try {
|
||||
expect(await bootstrapMain([
|
||||
"install", "--source", root, "--capability", "pdf", "--home", path.join(root, "home"),
|
||||
], { 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("--install-now");
|
||||
expect(args).toContain("--yes");
|
||||
expect(args).toContain("browser,diagram,pdf");
|
||||
expect(args).not.toContain("--prepared");
|
||||
expect(output.value()).toContain("Developer-only source install");
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -105,6 +105,22 @@ describe("gstack 2 upgrade, migration, and cleanup", () => {
|
||||
expect(await readJson(paths.versionPointer)).toMatchObject({ status: "active", current: "2.0.0" });
|
||||
});
|
||||
|
||||
test("public upgrades never consume caller-owned input even when given the internal option name", async () => {
|
||||
const root = await temporaryRoot();
|
||||
const home = path.join(root, "state");
|
||||
const source = path.join(root, "caller-source");
|
||||
await fs.mkdir(source);
|
||||
await fs.writeFile(path.join(source, "keep"), "caller-owned\n");
|
||||
const result = await stageUpgrade({
|
||||
home,
|
||||
sourceDir: source,
|
||||
version: "1.0.0",
|
||||
consumeInstallerScratch: true,
|
||||
});
|
||||
expect(result.consumedSource).toBe(false);
|
||||
expect(await fs.readFile(path.join(source, "keep"), "utf8")).toBe("caller-owned\n");
|
||||
});
|
||||
|
||||
test("raw staging rejects empty and symlinked source directories", async () => {
|
||||
const root = await temporaryRoot();
|
||||
const home = path.join(root, "state");
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as fsp from 'node:fs/promises';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { SOURCE_ASSIGNMENTS } from '../scripts/gstack2/assignments';
|
||||
import {
|
||||
ROOT,
|
||||
legacySections,
|
||||
renderLegacyBody,
|
||||
renderPortedLegacyBody,
|
||||
renderPortedLegacySection,
|
||||
retiredInvocationPattern,
|
||||
} from '../scripts/gstack2/render-legacy';
|
||||
import { TREE_NAMES } from '../scripts/gstack2/types';
|
||||
|
||||
const temporary: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporary.splice(0).map((entry) => fsp.rm(entry, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
function ownerModule(source: string): string {
|
||||
const assignment = SOURCE_ASSIGNMENTS.find((entry) => entry.source === source);
|
||||
if (!assignment) throw new Error(`Unknown source ${source}`);
|
||||
return path.join(ROOT, 'skills', assignment.tree, 'references', 'legacy', `${source}.md`);
|
||||
}
|
||||
|
||||
describe('GStack 2 canonical skill UX', () => {
|
||||
test('keeps exactly six public skills while excluding the retired shared onboarding machine', () => {
|
||||
const publicSkills = fs.readdirSync(path.join(ROOT, 'skills'), { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && fs.existsSync(path.join(ROOT, 'skills', entry.name, 'SKILL.md')))
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
expect(publicSkills).toEqual([...TREE_NAMES].sort());
|
||||
|
||||
for (const assignment of SOURCE_ASSIGNMENTS) {
|
||||
const body = fs.readFileSync(ownerModule(assignment.source), 'utf8');
|
||||
expect(body, assignment.source).not.toContain('## Preamble (run first)');
|
||||
expect(body, assignment.source).not.toMatch(
|
||||
/MODEL_OVERLAY: claude|CLAUDE_PLAN_FILE|Add routing rules to CLAUDE\.md|Boil the Ocean principle|TEL_PROMPTED|PROACTIVE_PROMPTED/,
|
||||
);
|
||||
expect(body, assignment.source).not.toContain('cd <SKILL_DIR> && ./setup');
|
||||
}
|
||||
});
|
||||
|
||||
test('resolves retired user-facing recommendations without rewriting package paths', () => {
|
||||
for (const assignment of SOURCE_ASSIGNMENTS) {
|
||||
const body = fs.readFileSync(ownerModule(assignment.source), 'utf8');
|
||||
expect(body, assignment.source).not.toMatch(retiredInvocationPattern());
|
||||
}
|
||||
expect(fs.readFileSync(ownerModule('plan-ceo-review'), 'utf8'))
|
||||
.toContain('$plan --mode Discovery --module office-hours');
|
||||
expect(fs.readFileSync(ownerModule('open-gstack-browser'), 'utf8'))
|
||||
.toContain('$GSTACK_BIN/gstack runtime path extension');
|
||||
});
|
||||
|
||||
test('packages one consent-first host-neutral bootstrap with every selected skill', () => {
|
||||
const source = fs.readFileSync(path.join(ROOT, 'runtime', 'runtime-bootstrap.mjs'));
|
||||
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 contract = JSON.parse(fs.readFileSync(path.join(ROOT, 'skills', tree, 'references', 'support', 'runtime-contract.json'), 'utf8'));
|
||||
expect(bootstrap, tree).toEqual(source);
|
||||
expect(contract, tree).toEqual({ schemaVersion: 1, runtimeVersion: '2.0.0', skillApi: '2.0' });
|
||||
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('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('`pdf` depends on `diagram`');
|
||||
expect(runtime, tree).toContain('`all` means those five and intentionally excludes visible Chromium');
|
||||
expect(runtime, tree).toContain('summed compressed bytes');
|
||||
expect(runtime, tree).toContain('B=$GSTACK_BIN/browse');
|
||||
expect(runtime, tree).toContain('BUN_CMD=$GSTACK_BIN/bun');
|
||||
expect(runtime, tree).toContain('discovers Git for Windows Bash');
|
||||
expect(runtime, tree).toContain('Python is not a global GStack prerequisite');
|
||||
}
|
||||
});
|
||||
|
||||
test('defers visible Chromium until a workflow reaches a headed point of use', () => {
|
||||
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('never requires `browser-headless`');
|
||||
}
|
||||
expect(fs.readFileSync(ownerModule('browse'), 'utf8')).not.toContain('browser-visible');
|
||||
expect(fs.readFileSync(ownerModule('qa-only'), 'utf8')).not.toContain('browser-visible');
|
||||
});
|
||||
|
||||
test('binds retained runtime helper variables to stable host-neutral paths', () => {
|
||||
for (const assignment of SOURCE_ASSIGNMENTS) {
|
||||
const body = fs.readFileSync(ownerModule(assignment.source), 'utf8');
|
||||
expect(body, assignment.source).not.toMatch(/bun\.sh\/install|bun run \$GSTACK_BIN|command -v bun/);
|
||||
if (!/\$(?:GSTACK_BIN|GSTACK_ROOT|GSTACK_STATE_ROOT)\b|\$(?:B|D|P)\b/.test(body)) continue;
|
||||
expect(body, assignment.source).toContain('## Host-neutral runtime bindings');
|
||||
expect(body, assignment.source).toContain('GSTACK_BIN="$GSTACK_HOME/bin"');
|
||||
expect(body, assignment.source).toContain('BUN_CMD="$GSTACK_BIN/bun"');
|
||||
expect(body, assignment.source).toContain('B="$GSTACK_BIN/browse"');
|
||||
}
|
||||
});
|
||||
|
||||
test('the packaged post-approval bootstrap reaches the managed installer without enrolling a host', async () => {
|
||||
const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'gstack-skill-bootstrap-'));
|
||||
temporary.push(root);
|
||||
const source = path.join(root, 'reviewed checkout');
|
||||
const home = path.join(root, 'runtime home');
|
||||
await fsp.mkdir(path.join(source, 'runtime'), { recursive: true });
|
||||
await fsp.writeFile(path.join(source, 'runtime', 'install.js'), [
|
||||
'import fs from "node:fs";',
|
||||
'import path from "node:path";',
|
||||
'const home = process.argv[process.argv.indexOf("--home") + 1];',
|
||||
'fs.mkdirSync(home, { recursive: true });',
|
||||
'fs.writeFileSync(path.join(home, "argv.json"), JSON.stringify(process.argv.slice(2)));',
|
||||
].join('\n'));
|
||||
|
||||
const packaged = path.join(ROOT, 'skills', 'qa', 'references', 'support', 'runtime-bootstrap.mjs');
|
||||
const module = await import(`${pathToFileURL(packaged).href}?test=${Date.now()}`);
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
const code = await module.main([
|
||||
'install', '--source', source, '--capability', 'browser', '--home', home, '--yes',
|
||||
], {
|
||||
stdout: { write: (chunk: string) => { stdout += chunk; } },
|
||||
stderr: { write: (chunk: string) => { stderr += chunk; } },
|
||||
});
|
||||
expect(code).toBe(0);
|
||||
expect(stderr).toContain('Developer-only source install');
|
||||
expect(stdout).toContain('No coding host was enrolled');
|
||||
expect(JSON.parse(await fsp.readFile(path.join(home, 'argv.json'), 'utf8'))).toContain('--install-now');
|
||||
});
|
||||
|
||||
test('cuts canonical active prompt bytes by at least half without dropping carved specialist phases', () => {
|
||||
const baselineBytes = SOURCE_ASSIGNMENTS.reduce(
|
||||
(total, assignment) => total + Buffer.byteLength(renderLegacyBody(assignment.source)),
|
||||
0,
|
||||
);
|
||||
const canonicalBytes = SOURCE_ASSIGNMENTS.reduce(
|
||||
(total, assignment) => total + Buffer.byteLength(renderPortedLegacyBody(assignment.source)),
|
||||
0,
|
||||
);
|
||||
expect(canonicalBytes).toBeLessThan(baselineBytes * 0.5);
|
||||
|
||||
for (const section of legacySections()) {
|
||||
const assignment = SOURCE_ASSIGNMENTS.find((entry) => entry.source === section.source)!;
|
||||
const filename = path.basename(section.relativePath).replace(/\.tmpl$/, '');
|
||||
const reference = `references/sections/${section.source}/${filename}`;
|
||||
const module = fs.readFileSync(ownerModule(section.source), 'utf8');
|
||||
const packaged = fs.readFileSync(path.join(ROOT, 'skills', assignment.tree, reference), 'utf8');
|
||||
expect(module, section.relativePath).toContain(reference);
|
||||
expect(packaged.trim(), section.relativePath).toBe(renderPortedLegacySection(section).trim());
|
||||
expect(packaged, section.relativePath).not.toMatch(retiredInvocationPattern());
|
||||
}
|
||||
|
||||
for (const tree of TREE_NAMES) {
|
||||
const artifactRoot = path.join(ROOT, 'skills', tree, 'references', 'artifacts');
|
||||
if (!fs.existsSync(artifactRoot)) continue;
|
||||
const pending = [artifactRoot];
|
||||
while (pending.length) {
|
||||
const current = pending.pop()!;
|
||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
const target = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) pending.push(target);
|
||||
else if (entry.name.endsWith('.md')) {
|
||||
expect(fs.readFileSync(target, 'utf8'), path.relative(ROOT, target)).not.toMatch(retiredInvocationPattern());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 15_000);
|
||||
});
|
||||
@@ -41,6 +41,17 @@ describe('GStack 2 structured dispatch', () => {
|
||||
expect(localSpec.mutation).toBe('spec-only');
|
||||
});
|
||||
|
||||
test('missing review mutation authority fails closed', () => {
|
||||
for (const audit_focus of ['broad', 'performance', 'deep']) {
|
||||
const review = routeStructured({ audit_focus });
|
||||
expect(review.tree, audit_focus).toBe('review');
|
||||
expect(review.mutation, audit_focus).toBe('report-only');
|
||||
}
|
||||
|
||||
expect(routeStructured({ audit_focus: 'broad', mutation_authorized: true }).mutation)
|
||||
.toBe('fix-safe');
|
||||
});
|
||||
|
||||
test('system-functional QA loads preserved report/fix and root-cause modules', () => {
|
||||
expect(routeStructured({ surface: 'developer-workflow', channels: ['cli', 'api'], mutation_authorized: false }))
|
||||
.toMatchObject({
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, "..");
|
||||
const read = (relative: string) => fs.readFileSync(path.join(ROOT, relative), "utf8");
|
||||
|
||||
describe("release and CI hardening", () => {
|
||||
test("every workflow has explicit permissions and immutable action refs", () => {
|
||||
const workflowRoot = path.join(ROOT, ".github", "workflows");
|
||||
for (const name of fs.readdirSync(workflowRoot).filter((entry) => entry.endsWith(".yml"))) {
|
||||
const source = fs.readFileSync(path.join(workflowRoot, name), "utf8");
|
||||
expect(source, `${name} must declare top-level permissions`).toMatch(/^permissions:\s*$/m);
|
||||
for (const match of source.matchAll(/\buses:\s*[^\s@]+@([^\s#]+)/g)) {
|
||||
expect(match[1], `${name} contains a mutable action ref`).toMatch(/^[a-f0-9]{40}$/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("paid eval secrets cannot run against fork PR code", () => {
|
||||
const source = read(".github/workflows/evals.yml");
|
||||
const guard = "github.event.pull_request.head.repo.full_name == github.repository";
|
||||
expect(source.match(new RegExp(guard.replaceAll(".", "\\."), "g"))?.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
test("npm package is an explicit small runtime-control surface", () => {
|
||||
const pkg = JSON.parse(read("package.json"));
|
||||
expect(pkg.version).toBe(read("VERSION").trim());
|
||||
expect(pkg.gstack).toEqual({ packageRole: "runtime-control", runtimeVersion: "2.0.0", skillApi: "2.0" });
|
||||
expect(pkg.bin).toEqual({
|
||||
gstack: "./bin/gstack",
|
||||
"gstack-runtime-bootstrap": "./runtime/runtime-bootstrap.mjs",
|
||||
});
|
||||
expect(pkg.files).toEqual(["bin/gstack", "runtime", "README.md", "LICENSE", "VERSION"]);
|
||||
expect(pkg.dependencies["puppeteer-core"]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("runtime identity is aligned independently of the legacy four-slot release counter", () => {
|
||||
for (const file of ["runtime/index.js", "runtime/install.js", "runtime/runtime-bootstrap.mjs"]) {
|
||||
expect(read(file), file).toContain('"2.0.0"');
|
||||
}
|
||||
expect(read("docs/gstack-2/RELEASE-INTEGRITY.md")).toContain("intentionally different namespaces");
|
||||
});
|
||||
|
||||
test("release workflow emits all six signed byte-counted artifacts", () => {
|
||||
const workflow = read(".github/workflows/release-artifacts.yml");
|
||||
for (const target of ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64", "windows-arm64", "windows-x64"]) {
|
||||
expect(workflow).toContain(`target: ${target}`);
|
||||
}
|
||||
expect(workflow).toContain("cosign sign-blob --yes --bundle");
|
||||
expect(workflow).toContain("actions/attest-build-provenance@");
|
||||
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('chromium.launch({ headless: true, channel: "chromium" })');
|
||||
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");
|
||||
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"');
|
||||
});
|
||||
|
||||
test("redistributed Bun is pinned and carries the exact tagged license inventory", () => {
|
||||
const workflow = read(".github/workflows/release-artifacts.yml");
|
||||
expect(workflow).toContain("bun-version: 1.3.14");
|
||||
const license = read("runtime/licenses/BUN-LICENSE-1.3.14.md");
|
||||
expect(createHash("sha256").update(license).digest("hex"))
|
||||
.toBe("2cb858b2db8fc793bca2093489c5bc8eee615d002cc4924254904044c27a0afa");
|
||||
const source = read("runtime/licenses/BUN-SOURCE.md");
|
||||
expect(source).toContain("2c6160ec8fb853f7e8f97d9b249e756c9b0ac44860a68b6bf4f1b0bcbc5c3741");
|
||||
expect(source).toContain("bun-v1.3.14");
|
||||
const installer = read("runtime/install.js");
|
||||
expect(installer).toContain('entry("runtime")');
|
||||
expect(installer).toContain('entry(managedBunRelativePath(), "managed-bun", true)');
|
||||
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 (IS_COMPILED && !NODE_SERVER_SCRIPT)");
|
||||
});
|
||||
|
||||
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("doctor --json");
|
||||
expect(workflow).toContain("runtime/cli.js uninstall");
|
||||
});
|
||||
|
||||
test("physical-iOS docs match the immutable five-iteration artifact", () => {
|
||||
const artifact = JSON.parse(read("docs/gstack-2/evidence/ios-physical-device-2026-07-20T17-49-19-302Z.json"));
|
||||
expect(artifact.passed).toBe(true);
|
||||
expect(artifact.requiredIterations).toBe(5);
|
||||
expect(artifact.passedIterations).toBe(5);
|
||||
expect(artifact.iterations).toHaveLength(5);
|
||||
expect(artifact.iterations.every((iteration: { passed: boolean }) => iteration.passed)).toBe(true);
|
||||
for (const file of ["STATUS.md", "TEST-EVIDENCE.md", "ARCHITECTURE.md", "HOST-COMPATIBILITY.md", "IOS-PHYSICAL-DEVICE.md"]) {
|
||||
expect(read(`docs/gstack-2/${file}`), file).toContain("ios-physical-device-2026-07-20T17-49-19-302Z.json");
|
||||
}
|
||||
});
|
||||
|
||||
test("public-tool decisions stay inside the accepted architecture", () => {
|
||||
const adr = read("docs/gstack-2/adr/0001-public-infrastructure-tools.md");
|
||||
expect(adr).toContain("Vercel Agent Skills CLI");
|
||||
expect(adr).toContain("Sigstore Cosign");
|
||||
expect(adr).toContain("No cloud-browser provider");
|
||||
});
|
||||
|
||||
test("unavailable governance/static gates are explicit rather than claimed green", () => {
|
||||
const policy = read("docs/gstack-2/RELEASE-INTEGRITY.md");
|
||||
expect(policy).toContain("not claimed by the current six-artifact release matrix");
|
||||
expect(policy).toContain("typecheck as not yet enforceable");
|
||||
expect(policy).toContain("No `CODEOWNERS` file is invented");
|
||||
expect(read(".github/workflows/quality-gate.yml")).toContain("gate-secret-scan.mjs");
|
||||
});
|
||||
});
|
||||
@@ -94,6 +94,20 @@ describe('physical-device harness invariants', () => {
|
||||
expect(classifyXcodebuildFailure('error: cannot find value in scope'))
|
||||
.toBe('build_failed');
|
||||
});
|
||||
|
||||
test('uses fresh HTTP connections for StateServer close responses', () => {
|
||||
const harness = readFileSync(HARNESS_PATH, 'utf8');
|
||||
expect(harness).toContain("connection: 'close'");
|
||||
});
|
||||
|
||||
test('fixture exposes a public UIKit accessibility control to the in-process scanner', () => {
|
||||
const app = readFileSync(
|
||||
join(FIXTURE_PATH, 'Sources/FixtureApp/FixtureAppApp.swift'),
|
||||
'utf8',
|
||||
);
|
||||
expect(app).toContain('struct FixtureButton: UIViewRepresentable');
|
||||
expect(app).toContain('button.accessibilityIdentifier = "tap-button"');
|
||||
});
|
||||
});
|
||||
describeIfDevice('ios physical-device path', () => {
|
||||
test('Xcode/CoreDevice setup gates pass for one selected wired iPhone', () => {
|
||||
|
||||
Reference in New Issue
Block a user