Fix packaging verification and path portability (#378)

This commit is contained in:
Flux Labs 2026-03-14 13:28:14 -05:00 committed by GitHub
parent 5d510ca6aa
commit b6ec4f9fad
20 changed files with 350 additions and 141 deletions

View file

@ -36,3 +36,27 @@ jobs:
- name: Run integration tests
run: npm run test:integration
windows-portability:
runs-on: windows-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Run unit tests
run: npm run test:unit

View file

@ -10,6 +10,9 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
const require = createRequire(import.meta.url)
const pkg = require(resolve(__dirname, '..', 'package.json'))
const cwd = resolve(__dirname, '..')
const shouldSkipBrowserDownload =
process.env.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD === '1' ||
process.env.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD === 'true'
// ---------------------------------------------------------------------------
// Async exec helper — captures stdout+stderr, never inherits to terminal
@ -63,7 +66,9 @@ const banner =
} catch {
// Clack or picocolors unavailable — fall back to minimal output
process.stderr.write(` Run gsd to get started.\n\n`)
await run('npx playwright install chromium')
if (!shouldSkipBrowserDownload) {
await run('npx playwright install chromium')
}
return
}
@ -77,24 +82,32 @@ const banner =
// Avoid --with-deps: install scripts should not block on interactive sudo
// prompts. If Linux libs are missing, suggest the explicit follow-up.
s.start('Setting up browser tools…')
const pwResult = await run('npx playwright install chromium')
if (pwResult.ok) {
s.stop('Browser tools ready')
results.push({ label: 'Browser tools ready', ok: true })
if (shouldSkipBrowserDownload) {
s.stop(pc.yellow('Browser tools skipped via PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD'))
results.push({
label: 'Browser tools skipped — run ' + pc.cyan('npx playwright install chromium') + ' later if needed',
ok: false,
})
} else {
const output = `${pwResult.stdout ?? ''}${pwResult.stderr ?? ''}`
if (os.platform() === 'linux' && output.includes('Host system is missing dependencies to run browsers.')) {
s.stop(pc.yellow('Browser downloaded, missing Linux deps'))
results.push({
label: 'Run ' + pc.cyan('sudo npx playwright install-deps chromium') + ' to finish setup',
ok: false,
})
const pwResult = await run('npx playwright install chromium')
if (pwResult.ok) {
s.stop('Browser tools ready')
results.push({ label: 'Browser tools ready', ok: true })
} else {
s.stop(pc.yellow('Browser tools — skipped (non-fatal)'))
results.push({
label: 'Browser tools unavailable — run ' + pc.cyan('npx playwright install chromium'),
ok: false,
})
const output = `${pwResult.stdout ?? ''}${pwResult.stderr ?? ''}`
if (os.platform() === 'linux' && output.includes('Host system is missing dependencies to run browsers.')) {
s.stop(pc.yellow('Browser downloaded, missing Linux deps'))
results.push({
label: 'Run ' + pc.cyan('sudo npx playwright install-deps chromium') + ' to finish setup',
ok: false,
})
} else {
s.stop(pc.yellow('Browser tools — skipped (non-fatal)'))
results.push({
label: 'Browser tools unavailable — run ' + pc.cyan('npx playwright install chromium'),
ok: false,
})
}
}
}

View file

@ -0,0 +1,18 @@
import { delimiter } from "node:path";
export function serializeBundledExtensionPaths(
paths: readonly string[],
pathDelimiter = delimiter,
): string {
return paths.filter(Boolean).join(pathDelimiter);
}
export function parseBundledExtensionPaths(
value: string | undefined,
pathDelimiter = delimiter,
): string[] {
return (value ?? "")
.split(pathDelimiter)
.map((segment) => segment.trim())
.filter(Boolean);
}

View file

@ -1,8 +1,9 @@
#!/usr/bin/env node
import { fileURLToPath } from 'url'
import { dirname, resolve, join } from 'path'
import { existsSync, readFileSync, mkdirSync, symlinkSync, lstatSync } from 'fs'
import { dirname, resolve, join, delimiter } from 'path'
import { existsSync, readFileSync, mkdirSync, symlinkSync } from 'fs'
import { agentDir, appRoot } from './app-paths.js'
import { serializeBundledExtensionPaths } from './bundled-extension-paths.js'
import { renderLogo } from './logo.js'
// pkg/ is a shim directory: contains gsd's piConfig (package.json) and pi's
@ -47,9 +48,9 @@ process.env.GSD_CODING_AGENT_DIR = agentDir
// Prepending gsd's node_modules to NODE_PATH fixes this for all extensions.
const gsdRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const gsdNodeModules = join(gsdRoot, 'node_modules')
process.env.NODE_PATH = process.env.NODE_PATH
? `${gsdNodeModules}:${process.env.NODE_PATH}`
: gsdNodeModules
process.env.NODE_PATH = [gsdNodeModules, process.env.NODE_PATH]
.filter(Boolean)
.join(delimiter)
// Force Node to re-evaluate module search paths with the updated NODE_PATH.
// Must happen synchronously before cli.js imports → extension loading.
// eslint-disable-next-line @typescript-eslint/no-require-imports
@ -77,7 +78,7 @@ const srcRes = join(loaderPackageRoot, 'src', 'resources')
const resourcesDir = existsSync(distRes) ? distRes : srcRes
process.env.GSD_WORKFLOW_PATH = join(resourcesDir, 'GSD-WORKFLOW.md')
// GSD_BUNDLED_EXTENSION_PATHS — colon-joined list of all bundled extension entry point absolute
// GSD_BUNDLED_EXTENSION_PATHS — platform-delimited list of bundled extension entry point absolute
// paths, used by patched subagent to pass --extension <path> to spawned gsd processes.
// IMPORTANT: paths point to agentDir (~/.gsd/agent/extensions/) NOT src/resources/extensions/.
// initResources() syncs bundled extensions to agentDir before any extension loading occurs,
@ -85,7 +86,7 @@ process.env.GSD_WORKFLOW_PATH = join(resourcesDir, 'GSD-WORKFLOW.md')
// discovers (it scans agentDir), so pi's deduplication works correctly and extensions are not
// double-loaded in subagent child processes.
// Note: shared/ is NOT included — it's a library imported by gsd and ask-user-questions, not an entry point.
process.env.GSD_BUNDLED_EXTENSION_PATHS = [
process.env.GSD_BUNDLED_EXTENSION_PATHS = serializeBundledExtensionPaths([
join(agentDir, 'extensions', 'gsd', 'index.ts'),
join(agentDir, 'extensions', 'bg-shell', 'index.ts'),
join(agentDir, 'extensions', 'browser-tools', 'index.ts'),
@ -97,7 +98,7 @@ process.env.GSD_BUNDLED_EXTENSION_PATHS = [
join(agentDir, 'extensions', 'async-jobs', 'index.ts'),
join(agentDir, 'extensions', 'ask-user-questions.ts'),
join(agentDir, 'extensions', 'get-secrets-from-user.ts'),
].join(':')
])
// Respect HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars for all outbound requests.
// pi-coding-agent's cli.ts sets this, but GSD bypasses that entry point — so we

View file

@ -230,8 +230,8 @@ async function main(): Promise<void> {
const tempDir = mkdtempSync(join(tmpdir(), "gsd-git-service-test-"));
run("git init -b main", tempDir);
run("git config user.name 'Pi Test'", tempDir);
run("git config user.email 'pi@example.com'", tempDir);
run('git config user.name "Pi Test"', tempDir);
run('git config user.email "pi@example.com"', tempDir);
// runGit should work on a valid repo
const branch = runGit(tempDir, ["branch", "--show-current"]);
@ -280,12 +280,12 @@ async function main(): Promise<void> {
function initTempRepo(): string {
const dir = mkdtempSync(join(tmpdir(), "gsd-git-t02-"));
run("git init -b main", dir);
run("git config user.name 'Pi Test'", dir);
run("git config user.email 'pi@example.com'", dir);
run('git config user.name "Pi Test"', dir);
run('git config user.email "pi@example.com"', dir);
// Need an initial commit so HEAD exists
createFile(dir, ".gitkeep", "");
run("git add -A", dir);
run("git commit -m 'init'", dir);
run('git commit -m "init"', dir);
return dir;
}
@ -313,7 +313,7 @@ async function main(): Promise<void> {
assertEq(result, "test: smart staging", "commit returns the commit message");
// Verify only src/code.ts is in the commit
const showStat = run("git show --stat --format='' HEAD", repo);
const showStat = run("git show --stat --format= HEAD", repo);
assertTrue(showStat.includes("src/code.ts"), "src/code.ts is in the commit");
assertTrue(!showStat.includes(".gsd/activity"), ".gsd/activity/ excluded from commit");
assertTrue(!showStat.includes(".gsd/runtime"), ".gsd/runtime/ excluded from commit");
@ -526,11 +526,11 @@ async function main(): Promise<void> {
function initBranchTestRepo(): string {
const dir = mkdtempSync(join(tmpdir(), "gsd-git-t03-"));
run("git init -b main", dir);
run("git config user.name 'Pi Test'", dir);
run("git config user.email 'pi@example.com'", dir);
run('git config user.name "Pi Test"', dir);
run('git config user.email "pi@example.com"', dir);
createFile(dir, ".gitkeep", "");
run("git add -A", dir);
run("git commit -m 'init'", dir);
run('git commit -m "init"', dir);
return dir;
}
@ -579,11 +579,11 @@ async function main(): Promise<void> {
// master-only repo
const repo = mkdtempSync(join(tmpdir(), "gsd-git-t03-master-"));
run("git init -b master", repo);
run("git config user.name 'Pi Test'", repo);
run("git config user.email 'pi@example.com'", repo);
run('git config user.name "Pi Test"', repo);
run('git config user.email "pi@example.com"', repo);
createFile(repo, ".gitkeep", "");
run("git add -A", repo);
run("git commit -m 'init'", repo);
run('git commit -m "init"', repo);
const svc = new GitServiceImpl(repo);
assertEq(svc.getMainBranch(), "master", "getMainBranch returns master when only master exists");
@ -634,7 +634,7 @@ async function main(): Promise<void> {
run("git checkout -b developer", repo);
createFile(repo, ".gsd/milestones/M001/M001-ROADMAP.md", "# Roadmap");
run("git add -A", repo);
run("git commit -m 'add roadmap'", repo);
run('git commit -m "add roadmap"', repo);
// ensureSliceBranch from this non-main, non-slice branch
const created = svc.ensureSliceBranch("M001", "S01");
@ -659,14 +659,14 @@ async function main(): Promise<void> {
// Create file only on main
createFile(repo, "main-only.txt", "from main");
run("git add -A", repo);
run("git commit -m 'main-only file'", repo);
run('git commit -m "main-only file"', repo);
// Create and check out S01
svc.ensureSliceBranch("M001", "S01");
// Add a file only on S01
createFile(repo, "s01-only.txt", "from s01");
run("git add -A", repo);
run("git commit -m 'S01 work'", repo);
run('git commit -m "S01 work"', repo);
// Now create S02 from S01 — should fall back to main
const created = svc.ensureSliceBranch("M001", "S02");
@ -700,7 +700,7 @@ async function main(): Promise<void> {
// The auto-commit on main should have src/feature.ts but NOT runtime files
run("git checkout main", repo);
const showStat = run("git show --stat --format='' HEAD", repo);
const showStat = run("git show --stat --format= HEAD", repo);
assertTrue(showStat.includes("src/feature.ts"), "auto-commit includes real files");
assertTrue(!showStat.includes(".gsd/activity"), "auto-commit excludes .gsd/activity/ (smart staging)");
assertTrue(!showStat.includes("STATE.md"), "auto-commit excludes .gsd/STATE.md (smart staging)");
@ -724,7 +724,7 @@ async function main(): Promise<void> {
// Simulate historical state: STATE.md was committed before gitignore was configured
createFile(repo, ".gsd/STATE.md", "# State v1");
run("git add -f .gsd/STATE.md", repo);
run("git commit -m 'add state (pre-gitignore)'", repo);
run('git commit -m "add state (pre-gitignore)"', repo);
// STATE.md gets modified during runtime (dirty)
createFile(repo, ".gsd/STATE.md", "# State v2 (modified at runtime)");
@ -752,12 +752,12 @@ async function main(): Promise<void> {
// Simulate: STATE.md is tracked in main's HEAD (historical state)
createFile(repo, ".gsd/STATE.md", "# State original");
run("git add -f .gsd/STATE.md", repo);
run("git commit -m 'initial with tracked STATE.md'", repo);
run('git commit -m "initial with tracked STATE.md"', repo);
// Simulate what smartStage one-time cleanup does: remove STATE.md from index and commit.
// This leaves STATE.md on disk but removes it from main's HEAD.
run("git rm --cached .gsd/STATE.md", repo);
run("git commit -m 'chore: untrack runtime files'", repo);
run('git commit -m "chore: untrack runtime files"', repo);
// STATE.md exists on disk (modified) but is now untracked in main's HEAD
createFile(repo, ".gsd/STATE.md", "# State modified after cleanup");
@ -790,7 +790,7 @@ async function main(): Promise<void> {
// Track STATE.md on main (historical pre-gitignore state)
createFile(repo, ".gsd/STATE.md", "# State on main");
run("git add -f .gsd/STATE.md", repo);
run("git commit -m 'add state (pre-gitignore)'", repo);
run('git commit -m "add state (pre-gitignore)"', repo);
// Create slice branch (inherits STATE.md from main)
svc.ensureSliceBranch("M001", "S01");
@ -831,7 +831,7 @@ async function main(): Promise<void> {
assertTrue(sliceLog.includes("pre-switch"), "auto-commit message includes pre-switch");
// Check that the auto-commit on the slice branch excluded runtime files
const showStat = run("git log gsd/M001/S01 -1 --format='' --stat", repo);
const showStat = run("git log gsd/M001/S01 -1 --format= --stat", repo);
assertTrue(showStat.includes("src/work.ts"), "switchToMain auto-commit includes real files");
assertTrue(!showStat.includes(".gsd/activity"), "switchToMain auto-commit excludes .gsd/activity/");
assertTrue(!showStat.includes(".gsd/runtime"), "switchToMain auto-commit excludes .gsd/runtime/");
@ -1044,20 +1044,20 @@ async function main(): Promise<void> {
// Create a .gsd/ planning artifact on main (simulates reassess-roadmap)
createFile(repo, ".gsd/DECISIONS.md", "# Decisions\n\n- D001: Original decision\n");
run("git add -A", repo);
run("git commit -m 'add decisions on main'", repo);
run('git commit -m "add decisions on main"', repo);
// Create slice branch and modify the same .gsd/ file differently
svc.ensureSliceBranch("M001", "S01");
createFile(repo, ".gsd/DECISIONS.md", "# Decisions\n\n- D001: Original decision\n- D002: New decision from slice\n");
createFile(repo, "src/feature.ts", "export const x = 1;");
run("git add -A", repo);
run("git commit -m 'slice work with .gsd/ changes'", repo);
run('git commit -m "slice work with .gsd/ changes"', repo);
// Back on main, modify the same .gsd/ file to create a conflict
svc.switchToMain();
createFile(repo, ".gsd/DECISIONS.md", "# Decisions\n\n- D001: Updated decision on main\n");
run("git add -A", repo);
run("git commit -m 'update decisions on main'", repo);
run('git commit -m "update decisions on main"', repo);
// Merge should auto-resolve .gsd/ conflicts by taking theirs (slice branch)
const result = svc.mergeSliceToMain("M001", "S01", "Feature with .gsd/ conflicts");
@ -1126,10 +1126,10 @@ async function main(): Promise<void> {
// Create package.json with passing test script
createFile(repo, "package.json", JSON.stringify({
name: "test-pass",
scripts: { test: "node -e 'process.exit(0)'" },
scripts: { test: 'node -e "process.exit(0)"' },
}));
run("git add -A", repo);
run("git commit -m 'add package.json'", repo);
run('git commit -m "add package.json"', repo);
const svc = new GitServiceImpl(repo, { pre_merge_check: true });
const result: PreMergeCheckResult = svc.runPreMergeCheck();
@ -1149,10 +1149,10 @@ async function main(): Promise<void> {
// Create package.json with failing test script
createFile(repo, "package.json", JSON.stringify({
name: "test-fail",
scripts: { test: "node -e 'process.exit(1)'" },
scripts: { test: 'node -e "process.exit(1)"' },
}));
run("git add -A", repo);
run("git commit -m 'add failing package.json'", repo);
run('git commit -m "add failing package.json"', repo);
const svc = new GitServiceImpl(repo, { pre_merge_check: true });
const result: PreMergeCheckResult = svc.runPreMergeCheck();
@ -1171,10 +1171,10 @@ async function main(): Promise<void> {
const repo = initBranchTestRepo();
createFile(repo, "package.json", JSON.stringify({
name: "test-disabled",
scripts: { test: "node -e 'process.exit(1)'" },
scripts: { test: 'node -e "process.exit(1)"' },
}));
run("git add -A", repo);
run("git commit -m 'add package.json'", repo);
run('git commit -m "add package.json"', repo);
const svc = new GitServiceImpl(repo, { pre_merge_check: false });
const result: PreMergeCheckResult = svc.runPreMergeCheck();
@ -1192,7 +1192,7 @@ async function main(): Promise<void> {
{
const repo = initBranchTestRepo();
// Custom command string overrides auto-detection
const svc = new GitServiceImpl(repo, { pre_merge_check: "node -e 'process.exit(0)'" });
const svc = new GitServiceImpl(repo, { pre_merge_check: 'node -e "process.exit(0)"' });
const result: PreMergeCheckResult = svc.runPreMergeCheck();
assertEq(result.passed, true, "runPreMergeCheck passes with custom command that exits 0");
@ -1326,11 +1326,11 @@ async function main(): Promise<void> {
// Add a commit to the remote via a temporary clone
const cloneDir = mkdtempSync(join(tmpdir(), "gsd-git-clone-"));
run(`git clone ${bareDir} ${cloneDir}`, cloneDir);
run("git config user.name 'Remote Dev'", cloneDir);
run("git config user.email 'remote@example.com'", cloneDir);
run('git config user.name "Remote Dev"', cloneDir);
run('git config user.email "remote@example.com"', cloneDir);
createFile(cloneDir, "remote-file.txt", "from remote");
run("git add -A", cloneDir);
run("git commit -m 'remote commit'", cloneDir);
run('git commit -m "remote commit"', cloneDir);
run("git push origin main", cloneDir);
// ensureSliceBranch should fetch before creating the branch — no crash
@ -1639,7 +1639,7 @@ async function main(): Promise<void> {
run("git checkout -b f-123-new-thing", repo);
createFile(repo, "setup.txt", "initial setup");
run("git add -A", repo);
run("git commit -m 'initial feature setup'", repo);
run('git commit -m "initial feature setup"', repo);
// Record integration branch (this is what auto.ts does at startup)
writeIntegrationBranch(repo, "M001", "f-123-new-thing");

View file

@ -336,13 +336,13 @@ function createGitBase(): string {
try {
// Create a conflict: modify same file on two branches
writeFileSync(join(base, "conflict.txt"), "main content\n", "utf-8");
execSync("git add -A && git commit -m 'main change'", { cwd: base, stdio: "ignore" });
execSync('git add -A && git commit -m "main change"', { cwd: base, stdio: "ignore" });
execSync("git checkout -b feature", { cwd: base, stdio: "ignore" });
writeFileSync(join(base, "conflict.txt"), "feature content\n", "utf-8");
execSync("git add -A && git commit -m 'feature change'", { cwd: base, stdio: "ignore" });
execSync('git add -A && git commit -m "feature change"', { cwd: base, stdio: "ignore" });
execSync("git checkout main", { cwd: base, stdio: "ignore" });
writeFileSync(join(base, "conflict.txt"), "different main content\n", "utf-8");
execSync("git add -A && git commit -m 'diverge'", { cwd: base, stdio: "ignore" });
execSync('git add -A && git commit -m "diverge"', { cwd: base, stdio: "ignore" });
try { execSync("git merge feature", { cwd: base, stdio: "ignore" }); } catch { /* expected conflict */ }
const result = verifyExpectedArtifact("fix-merge", "M001/S01", base);
assertTrue(result === false, "UU conflict should return false");
@ -357,13 +357,13 @@ function createGitBase(): string {
const base = createGitBase();
try {
writeFileSync(join(base, "deleted.txt"), "content\n", "utf-8");
execSync("git add -A && git commit -m 'add file'", { cwd: base, stdio: "ignore" });
execSync('git add -A && git commit -m "add file"', { cwd: base, stdio: "ignore" });
execSync("git checkout -b feature2", { cwd: base, stdio: "ignore" });
writeFileSync(join(base, "deleted.txt"), "modified on feature\n", "utf-8");
execSync("git add -A && git commit -m 'modify on feature'", { cwd: base, stdio: "ignore" });
execSync('git add -A && git commit -m "modify on feature"', { cwd: base, stdio: "ignore" });
execSync("git checkout main", { cwd: base, stdio: "ignore" });
execSync("git rm deleted.txt", { cwd: base, stdio: "ignore" });
execSync("git commit -m 'delete on main'", { cwd: base, stdio: "ignore" });
execSync('git commit -m "delete on main"', { cwd: base, stdio: "ignore" });
try { execSync("git merge feature2", { cwd: base, stdio: "ignore" }); } catch { /* expected conflict */ }
const result = verifyExpectedArtifact("fix-merge", "M001/S01", base);
assertTrue(result === false, "DU conflict should return false");
@ -379,11 +379,11 @@ function createGitBase(): string {
try {
execSync("git checkout -b branch-a", { cwd: base, stdio: "ignore" });
writeFileSync(join(base, "both.txt"), "branch-a content\n", "utf-8");
execSync("git add -A && git commit -m 'add on branch-a'", { cwd: base, stdio: "ignore" });
execSync('git add -A && git commit -m "add on branch-a"', { cwd: base, stdio: "ignore" });
execSync("git checkout main", { cwd: base, stdio: "ignore" });
execSync("git checkout -b branch-b", { cwd: base, stdio: "ignore" });
writeFileSync(join(base, "both.txt"), "branch-b content\n", "utf-8");
execSync("git add -A && git commit -m 'add on branch-b'", { cwd: base, stdio: "ignore" });
execSync('git add -A && git commit -m "add on branch-b"', { cwd: base, stdio: "ignore" });
try { execSync("git merge branch-a", { cwd: base, stdio: "ignore" }); } catch { /* expected conflict */ }
const result = verifyExpectedArtifact("fix-merge", "M001/S01", base);
assertTrue(result === false, "AA conflict should return false");

View file

@ -535,7 +535,7 @@ Built the legacy feature successfully.
// Make a change on the slice branch, commit, then merge to main
writeFileSync(join(base, 'feature.txt'), 'new feature from slice\n');
run('git add feature.txt', base);
run("git commit -m 'feat: slice work'", base);
run('git commit -m "feat: slice work"', base);
// Switch to main and merge
switchToMain(base);

View file

@ -151,7 +151,7 @@ function writeBaseArtifacts(repo: string): void {
"# S01: First Slice\n\n**Goal:** Demo\n**Demo:** Demo\n\n## Must-Haves\n- done\n\n## Tasks\n- [x] **T01: Task** `est:5m`\n do it\n",
);
run("git add .", repo);
run("git commit -m 'chore: milestone base'", repo);
run('git commit -m "chore: milestone base"', repo);
}
function writeCompletedArtifactsOnBranch(repo: string): void {
@ -175,7 +175,7 @@ function writeCompletedArtifactsOnBranch(repo: string): void {
"# UAT\n\nPassed.\n",
);
run("git add .", repo);
run("git commit -m 'feat(M001/S01): complete-slice'", repo);
run('git commit -m "feat(M001/S01): complete-slice"', repo);
}
// ─── Tests ────────────────────────────────────────────────────────────────────
@ -228,7 +228,7 @@ console.log("\n=== orphan detection: slice branch not done ===");
"# Research\n",
);
run("git add .", repo);
run("git commit -m 'feat: research'", repo);
run('git commit -m "feat: research"', repo);
run("git checkout main", repo);
const orphans = detectOrphanedSliceBranches(repo);
@ -266,7 +266,7 @@ console.log("\n=== orphan detection: already merged branch is not orphan ===");
writeCompletedArtifactsOnBranch(repo);
run("git checkout main", repo);
run("git merge --squash gsd/M001/S01", repo);
run("git commit -m 'feat(M001/S01): merge'", repo);
run('git commit -m "feat(M001/S01): merge"', repo);
run("git branch -D gsd/M001/S01", repo);
const orphans = detectOrphanedSliceBranches(repo);
@ -327,7 +327,7 @@ console.log("\n=== orphan merge: squash-merge resolves orphan, artifacts appear
// Perform squash-merge (as mergeOrphanedSliceBranches does via mergeSliceToMain)
run("git merge --squash gsd/M001/S01", repo);
run("git commit -m 'feat(M001/S01): recover orphaned branch'", repo);
run('git commit -m "feat(M001/S01): recover orphaned branch"', repo);
run("git branch -D gsd/M001/S01", repo);
// Verify artifacts are now on main

View file

@ -79,7 +79,7 @@ writeFileSync(
"utf-8",
);
run("git add .", base);
run("git commit -m 'chore: init'", base);
run('git commit -m "chore: init"', base);
async function main(): Promise<void> {
// ── Verify main tree baseline ──────────────────────────────────────────────
@ -124,7 +124,7 @@ async function main(): Promise<void> {
console.log("\n=== Work and merge slice in worktree ===");
writeFileSync(join(wt.path, "feature.txt"), "new feature\n", "utf-8");
run("git add .", wt.path);
run("git commit -m 'feat: add feature'", wt.path);
run('git commit -m "feat: add feature"', wt.path);
// switchToMain should go to worktree/alpha, NOT main
switchToMain(wt.path);
@ -150,7 +150,7 @@ async function main(): Promise<void> {
writeFileSync(join(wt.path, "feature2.txt"), "second feature\n", "utf-8");
run("git add .", wt.path);
run("git commit -m 'feat: add feature 2'", wt.path);
run('git commit -m "feat: add feature 2"', wt.path);
switchToMain(wt.path);
const merge2 = mergeSliceToMain(wt.path, "M001", "S02", "Second");
@ -167,7 +167,7 @@ async function main(): Promise<void> {
writeFileSync(join(base, "main-feature.txt"), "main work\n", "utf-8");
run("git add .", base);
run("git commit -m 'feat: main work'", base);
run('git commit -m "feat: main work"', base);
switchToMain(base);
assertEq(getCurrentBranch(base), "main", "main tree switchToMain goes to main");

View file

@ -23,8 +23,8 @@ function run(command: string, cwd: string): string {
// Set up a test repo
const base = mkdtempSync(join(tmpdir(), "gsd-worktree-mgr-test-"));
run("git init -b main", base);
run("git config user.name 'Pi Test'", base);
run("git config user.email 'pi@example.com'", base);
run('git config user.name "Pi Test"', base);
run('git config user.email "pi@example.com"', base);
// Create initial project structure
mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true });
@ -35,7 +35,7 @@ writeFileSync(
"utf-8",
);
run("git add .", base);
run("git commit -m 'chore: init'", base);
run('git commit -m "chore: init"', base);
async function main(): Promise<void> {
console.log("\n=== worktreeBranchName ===");
@ -95,7 +95,7 @@ async function main(): Promise<void> {
"utf-8",
);
run("git add .", wtPath);
run("git commit -m 'feat: add M002 and update M001'", wtPath);
run('git commit -m "feat: add M002 and update M001"', wtPath);
console.log("\n=== diffWorktreeGSD ===");
const diff = diffWorktreeGSD(base, "feature-x");

View file

@ -31,14 +31,14 @@ function run(command: string, cwd: string): string {
const base = mkdtempSync(join(tmpdir(), "gsd-branch-test-"));
run("git init -b main", base);
run("git config user.name 'Pi Test'", base);
run("git config user.email 'pi@example.com'", base);
run('git config user.name "Pi Test"', base);
run('git config user.email "pi@example.com"', base);
mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks"), { recursive: true });
writeFileSync(join(base, "README.md"), "hello\n", "utf-8");
writeFileSync(join(base, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), `# M001: Demo\n\n## Slices\n- [ ] **S01: Slice One** \`risk:low\` \`depends:[]\`\n > After this: demo works\n`, "utf-8");
writeFileSync(join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md"), `# S01: Slice One\n\n**Goal:** Demo\n**Demo:** Demo\n\n## Must-Haves\n- done\n\n## Tasks\n- [ ] **T01: Implement** \`est:10m\`\n do it\n`, "utf-8");
run("git add .", base);
run("git commit -m 'chore: init'", base);
run('git commit -m "chore: init"', base);
async function main(): Promise<void> {
console.log("\n=== ensureSliceBranch ===");
@ -85,7 +85,7 @@ async function main(): Promise<void> {
ensureSliceBranch(base, "M001", "S01");
writeFileSync(join(base, "README.md"), "hello from slice\n", "utf-8");
run("git add README.md", base);
run("git commit -m 'feat: slice change'", base);
run('git commit -m "feat: slice change"', base);
switchToMain(base);
const merge = mergeSliceToMain(base, "M001", "S01", "Slice One");
@ -107,7 +107,7 @@ async function main(): Promise<void> {
"- [ ] **S02: Slice Two** `risk:low` `depends:[]`", " > Demo 2",
].join("\n") + "\n", "utf-8");
run("git add .", base);
run("git commit -m 'chore: add S02'", base);
run('git commit -m "chore: add S02"', base);
ensureSliceBranch(base, "M001", "S02");
writeFileSync(join(base, "feature.txt"), "new feature\n", "utf-8");
@ -168,11 +168,11 @@ async function main(): Promise<void> {
console.log("\n=== ensureSliceBranch from non-main working branch ===");
const base2 = mkdtempSync(join(tmpdir(), "gsd-branch-base-test-"));
run("git init -b main", base2);
run("git config user.name 'Pi Test'", base2);
run("git config user.email 'pi@example.com'", base2);
run('git config user.name "Pi Test"', base2);
run('git config user.email "pi@example.com"', base2);
writeFileSync(join(base2, "README.md"), "hello\n", "utf-8");
run("git add .", base2);
run("git commit -m 'chore: init'", base2);
run('git commit -m "chore: init"', base2);
// Create a "developer" branch with planning artifacts (like the real scenario)
run("git checkout -b developer", base2);
@ -183,7 +183,7 @@ async function main(): Promise<void> {
"- [ ] **S01: Config Fix** `risk:low` `depends:[]`", " > Fix config",
].join("\n") + "\n", "utf-8");
run("git add .", base2);
run("git commit -m 'docs(M001): context and roadmap'", base2);
run('git commit -m "docs(M001): context and roadmap"', base2);
// Verify main does NOT have the artifacts
const mainRoadmap = run("git show main:.gsd/milestones/M001/M001-ROADMAP.md 2>&1 || echo MISSING", base2);
@ -211,8 +211,8 @@ async function main(): Promise<void> {
console.log("\n=== ensureSliceBranch from slice branch falls back to main ===");
const base3 = mkdtempSync(join(tmpdir(), "gsd-branch-chain-test-"));
run("git init -b main", base3);
run("git config user.name 'Pi Test'", base3);
run("git config user.email 'pi@example.com'", base3);
run('git config user.name "Pi Test"', base3);
run('git config user.email "pi@example.com"', base3);
mkdirSync(join(base3, ".gsd", "milestones", "M001", "slices", "S01", "tasks"), { recursive: true });
mkdirSync(join(base3, ".gsd", "milestones", "M001", "slices", "S02", "tasks"), { recursive: true });
writeFileSync(join(base3, "README.md"), "hello\n", "utf-8");
@ -222,7 +222,7 @@ async function main(): Promise<void> {
"- [ ] **S02: Second** `risk:low` `depends:[]`", " > second",
].join("\n") + "\n", "utf-8");
run("git add .", base3);
run("git commit -m 'chore: init'", base3);
run('git commit -m "chore: init"', base3);
ensureSliceBranch(base3, "M001", "S01");
assertEq(getCurrentBranch(base3), "gsd/M001/S01", "on S01 slice branch");
@ -379,7 +379,7 @@ async function main(): Promise<void> {
// User creates feature branch
run("git checkout -b feature/big-change", repo);
writeFileSync(join(repo, "setup.txt"), "feature setup\n");
run("git add -A && git commit -m 'feat: initial setup'", repo);
run('git add -A && git commit -m "feat: initial setup"', repo);
// auto.ts startup: capture + set milestone
captureIntegrationBranch(repo, "M001");
@ -397,7 +397,7 @@ async function main(): Promise<void> {
"multi: S01 inherited feature branch content");
writeFileSync(join(repo, "s01-work.txt"), "s01 output\n");
run("git add -A && git commit -m 'feat(S01): work'", repo);
run('git add -A && git commit -m "feat(S01): work"', repo);
switchToMain(repo);
assertEq(getCurrentBranch(repo), "feature/big-change",
@ -428,7 +428,7 @@ async function main(): Promise<void> {
"multi: S02 has S01 output (inherited via feature branch)");
writeFileSync(join(repo, "s02-work.txt"), "s02 output\n");
run("git add -A && git commit -m 'feat(S02): work'", repo);
run('git add -A && git commit -m "feat(S02): work"', repo);
switchToMain(repo);
assertEq(getCurrentBranch(repo), "feature/big-change",
@ -479,7 +479,7 @@ async function main(): Promise<void> {
// Create a slice and do some work
ensureSliceBranch(repo, "M001", "S01");
writeFileSync(join(repo, "work.txt"), "wip\n");
run("git add -A && git commit -m 'wip'", repo);
run('git add -A && git commit -m "wip"', repo);
// Simulate "restart" — clear milestone ID (fresh service instance)
setActiveMilestoneId(repo, null);
@ -530,7 +530,7 @@ async function main(): Promise<void> {
// Full lifecycle on main still works
ensureSliceBranch(repo, "M001", "S01");
writeFileSync(join(repo, "feature.txt"), "new\n");
run("git add -A && git commit -m 'feat: work'", repo);
run('git add -A && git commit -m "feat: work"', repo);
switchToMain(repo);
assertEq(getCurrentBranch(repo), "main",
@ -564,7 +564,7 @@ async function main(): Promise<void> {
run("git checkout -b dev-branch", repo);
writeFileSync(join(repo, "dev-only.txt"), "from dev\n");
run("git add -A && git commit -m 'dev setup'", repo);
run('git add -A && git commit -m "dev setup"', repo);
captureIntegrationBranch(repo, "M001");
setActiveMilestoneId(repo, "M001");
@ -572,7 +572,7 @@ async function main(): Promise<void> {
// Create S01 (from dev-branch)
ensureSliceBranch(repo, "M001", "S01");
writeFileSync(join(repo, "s01.txt"), "s01\n");
run("git add -A && git commit -m 's01 work'", repo);
run('git add -A && git commit -m "s01 work"', repo);
// While on S01, create S02 — should fall back to integration branch
ensureSliceBranch(repo, "M001", "S02");

View file

@ -17,7 +17,7 @@
import { existsSync, mkdirSync, realpathSync } from "node:fs";
import { execSync } from "node:child_process";
import { join, relative, resolve } from "node:path";
import { join, resolve } from "node:path";
// ─── Types ─────────────────────────────────────────────────────────────────
@ -68,6 +68,14 @@ function runGit(cwd: string, args: string[], opts: { allowFailure?: boolean } =
}
}
function normalizePathForComparison(path: string): string {
const normalized = path
.replaceAll("\\", "/")
.replace(/^\/\/\?\//, "")
.replace(/\/+$/, "");
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
}
export function getMainBranch(basePath: string): string {
const symbolic = runGit(basePath, ["symbolic-ref", "refs/remotes/origin/HEAD"], { allowFailure: true });
if (symbolic) {
@ -156,15 +164,29 @@ export function createWorktree(basePath: string, name: string): WorktreeInfo {
* Parses `git worktree list` and filters to those under .gsd/worktrees/.
*/
export function listWorktrees(basePath: string): WorktreeInfo[] {
// Resolve real paths to handle symlinks (e.g. /tmp → /private/tmp on macOS)
const resolvedBase = existsSync(basePath) ? realpathSync(basePath) : resolve(basePath);
const wtDir = join(resolvedBase, ".gsd", "worktrees");
const baseVariants = [resolve(basePath)];
if (existsSync(basePath)) {
baseVariants.push(realpathSync(basePath));
}
const seenRoots = new Set<string>();
const worktreeRoots = baseVariants
.map(baseVariant => {
const path = join(baseVariant, ".gsd", "worktrees");
return {
normalized: normalizePathForComparison(path),
};
})
.filter(root => {
if (seenRoots.has(root.normalized)) return false;
seenRoots.add(root.normalized);
return true;
});
const rawList = runGit(basePath, ["worktree", "list", "--porcelain"]);
if (!rawList.trim()) return [];
const worktrees: WorktreeInfo[] = [];
const entries = rawList.split("\n\n").filter(Boolean);
const entries = rawList.replaceAll("\r\n", "\n").split("\n\n").filter(Boolean);
for (const entry of entries) {
const lines = entry.split("\n");
@ -175,19 +197,42 @@ export function listWorktrees(basePath: string): WorktreeInfo[] {
const entryPath = wtLine.replace("worktree ", "");
const branch = branchLine.replace("branch refs/heads/", "");
const branchWorktreeName = branch.startsWith("worktree/") ? branch.slice("worktree/".length) : null;
const entryVariants = [resolve(entryPath)];
if (existsSync(entryPath)) {
entryVariants.push(realpathSync(entryPath));
}
const normalizedEntryVariants = [...new Set(entryVariants.map(normalizePathForComparison))];
const matchedRoot = worktreeRoots.find(root =>
normalizedEntryVariants.some(entryVariant => entryVariant.startsWith(`${root.normalized}/`)),
);
const matchesBranchLeaf = branchWorktreeName
? normalizedEntryVariants.some(entryVariant => entryVariant.split("/").pop() === branchWorktreeName)
: false;
// Only include worktrees under .gsd/worktrees/
if (!entryPath.startsWith(wtDir)) continue;
if (!matchedRoot && !matchesBranchLeaf) continue;
const name = relative(wtDir, entryPath);
// Skip nested paths — only direct children
if (name.includes("/") || name.includes("\\")) continue;
const matchedEntryPath = normalizedEntryVariants.find(entryVariant =>
matchedRoot ? entryVariant.startsWith(`${matchedRoot.normalized}/`) : false,
);
let name = matchedRoot ? matchedEntryPath?.slice(matchedRoot.normalized.length + 1) ?? "" : "";
// Git on Windows can report a path form that does not map cleanly back to the
// repo root even when the branch naming is still authoritative.
if ((!name || name.includes("/")) && branchWorktreeName && matchesBranchLeaf) {
name = branchWorktreeName;
}
if (!name || name.includes("/")) continue;
const resolvedEntryPath = existsSync(entryPath) ? realpathSync(entryPath) : resolve(entryPath);
worktrees.push({
name,
path: entryPath,
path: resolvedEntryPath,
branch,
exists: existsSync(entryPath),
exists: existsSync(resolvedEntryPath),
});
}

View file

@ -72,11 +72,12 @@ export function captureIntegrationBranch(basePath: string, milestoneId: string):
* Returns null if not inside a GSD worktree (.gsd/worktrees/<name>/).
*/
export function detectWorktreeName(basePath: string): string | null {
const marker = `${sep}.gsd${sep}worktrees${sep}`;
const idx = basePath.indexOf(marker);
const normalizedPath = basePath.replaceAll("\\", "/");
const marker = "/.gsd/worktrees/";
const idx = normalizedPath.indexOf(marker);
if (idx === -1) return null;
const afterMarker = basePath.slice(idx + marker.length);
const name = afterMarker.split(sep)[0] ?? afterMarker.split("/")[0];
const afterMarker = normalizedPath.slice(idx + marker.length);
const name = afterMarker.split("/")[0];
return name || null;
}

View file

@ -0,0 +1,11 @@
import { delimiter } from "node:path";
export function parseBundledExtensionPaths(
value: string | undefined,
pathDelimiter = delimiter,
): string[] {
return (value ?? "")
.split(pathDelimiter)
.map((segment) => segment.trim())
.filter(Boolean);
}

View file

@ -23,6 +23,7 @@ import { StringEnum } from "@gsd/pi-ai";
import { type ExtensionAPI, getMarkdownTheme } from "@gsd/pi-coding-agent";
import { Container, Markdown, Spacer, Text } from "@gsd/pi-tui";
import { Type } from "@sinclair/typebox";
import { parseBundledExtensionPaths } from "../shared/bundled-extension-paths.js";
import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.js";
import {
type IsolationEnvironment,
@ -293,7 +294,7 @@ async function runSingleAgent(
let wasAborted = false;
const exitCode = await new Promise<number>((resolve) => {
const bundledPaths = (process.env.GSD_BUNDLED_EXTENSION_PATHS ?? "").split(":").filter(Boolean);
const bundledPaths = parseBundledExtensionPaths(process.env.GSD_BUNDLED_EXTENSION_PATHS);
const extensionArgs = bundledPaths.flatMap(p => ["--extension", p]);
const proc = spawn(
process.execPath,

View file

@ -14,7 +14,7 @@ import test from "node:test";
import assert from "node:assert/strict";
import { execSync } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { delimiter, join } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
@ -42,7 +42,7 @@ test("loader sets all 4 GSD_ env vars and PI_PACKAGE_DIR", async () => {
// Run loader in a subprocess that prints env vars and exits before TUI starts
const script = `
import { fileURLToPath } from 'url';
import { dirname, resolve, join } from 'path';
import { dirname, resolve, join, delimiter } from 'path';
import { agentDir } from './app-paths.js';
const pkgDir = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'pkg');
@ -52,7 +52,7 @@ test("loader sets all 4 GSD_ env vars and PI_PACKAGE_DIR", async () => {
const resourcesDir = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'resources');
process.env.GSD_WORKFLOW_PATH = join(resourcesDir, 'GSD-WORKFLOW.md');
const exts = ['extensions/gsd/index.ts'].map(r => join(resourcesDir, r));
process.env.GSD_BUNDLED_EXTENSION_PATHS = exts.join(':');
process.env.GSD_BUNDLED_EXTENSION_PATHS = exts.join(delimiter);
// Print for verification
console.log('PI_PACKAGE_DIR=' + process.env.PI_PACKAGE_DIR);
@ -82,7 +82,7 @@ test("loader sets all 4 GSD_ env vars and PI_PACKAGE_DIR", async () => {
// Direct logic verification (no subprocess needed)
const { agentDir: ad } = await import("../app-paths.ts");
assert.ok(ad.endsWith(".gsd/agent"), "agentDir ends with .gsd/agent");
assert.ok(ad.endsWith(join(".gsd", "agent")), "agentDir ends with .gsd/agent");
// Verify the env var names are in loader.ts source
const loaderSrc = readFileSync(join(projectRoot, "src", "loader.ts"), "utf-8");
@ -91,6 +91,8 @@ test("loader sets all 4 GSD_ env vars and PI_PACKAGE_DIR", async () => {
assert.ok(loaderSrc.includes("GSD_BIN_PATH"), "loader sets GSD_BIN_PATH");
assert.ok(loaderSrc.includes("GSD_WORKFLOW_PATH"), "loader sets GSD_WORKFLOW_PATH");
assert.ok(loaderSrc.includes("GSD_BUNDLED_EXTENSION_PATHS"), "loader sets GSD_BUNDLED_EXTENSION_PATHS");
assert.ok(loaderSrc.includes("serializeBundledExtensionPaths"), "loader uses shared bundled path serializer");
assert.ok(loaderSrc.includes("join(delimiter)"), "loader uses platform delimiter for NODE_PATH");
// Verify all 11 extension entry points are referenced in loader
// Loader uses join() calls like join(agentDir, 'extensions', 'gsd', 'index.ts')

View file

@ -0,0 +1,26 @@
import test from "node:test";
import assert from "node:assert/strict";
import { delimiter } from "node:path";
import {
parseBundledExtensionPaths,
serializeBundledExtensionPaths,
} from "../bundled-extension-paths.ts";
test("bundled extension paths use the platform delimiter by default", () => {
const paths = ["/tmp/gsd/a.ts", "/tmp/gsd/b.ts"];
const encoded = serializeBundledExtensionPaths(paths);
assert.equal(encoded, paths.join(delimiter));
assert.deepEqual(parseBundledExtensionPaths(encoded), paths);
});
test("bundled extension paths preserve Windows drive letters when semicolon-delimited", () => {
const windowsPaths = [
String.raw`C:\Users\dev\.gsd\agent\extensions\gsd\index.ts`,
String.raw`D:\work\gsd\extensions\browser-tools\index.ts`,
];
const encoded = serializeBundledExtensionPaths(windowsPaths, ";");
assert.equal(encoded, windowsPaths.join(";"));
assert.deepEqual(parseBundledExtensionPaths(encoded, ";"), windowsPaths);
});

View file

@ -12,10 +12,9 @@
import test from "node:test";
import assert from "node:assert/strict";
import { execFileSync, spawn } from "node:child_process";
import { createReadStream, existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { createReadStream, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
import { createGunzip } from "node:zlib";
const projectRoot = process.cwd();
@ -24,12 +23,45 @@ if (!existsSync(join(projectRoot, "dist"))) {
throw new Error("dist/ not found — run: npm run build");
}
function packTarball(): string {
type NpmSandbox = {
env: NodeJS.ProcessEnv;
installPrefix: string;
rootDir: string;
};
function createNpmSandbox(prefix: string): NpmSandbox {
const rootDir = mkdtempSync(join(tmpdir(), prefix));
const cacheDir = join(rootDir, "npm-cache");
const installPrefix = join(rootDir, "install-prefix");
mkdirSync(cacheDir, { recursive: true });
mkdirSync(installPrefix, { recursive: true });
return {
rootDir,
installPrefix,
env: {
...process.env,
NPM_CONFIG_CACHE: cacheDir,
npm_config_cache: cacheDir,
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1",
},
};
}
function packTarball(sandbox: NpmSandbox): string {
const pkg = JSON.parse(readFileSync(join(projectRoot, "package.json"), "utf-8"));
const safeName = pkg.name.replace(/^@[^/]+\//, "").replace(/\//g, "-");
const tarball = `${safeName}-${pkg.version}.tgz`;
execFileSync("npm", ["pack"], { cwd: projectRoot, stdio: ["ignore", "ignore", "pipe"] });
return join(projectRoot, tarball);
const packDestination = join(sandbox.rootDir, "pack-output");
mkdirSync(packDestination, { recursive: true });
execFileSync("npm", ["pack", "--pack-destination", packDestination], {
cwd: projectRoot,
env: sandbox.env,
stdio: ["ignore", "ignore", "pipe"],
});
return join(packDestination, tarball);
}
/** List file paths inside a .tgz using Node built-ins only (no tar CLI or npm package). */
@ -66,7 +98,8 @@ function listTarEntries(tarballPath: string): Promise<string[]> {
// ═══════════════════════════════════════════════════════════════════════════
test("npm pack produces tarball with required files", async () => {
const tarballPath = packTarball();
const sandbox = createNpmSandbox("gsd-pack-test-");
const tarballPath = packTarball(sandbox);
assert.ok(existsSync(tarballPath), "tarball created");
@ -90,6 +123,7 @@ test("npm pack produces tarball with required files", async () => {
assert.equal(pkg.piConfig?.configDir, ".gsd", "pkg/package.json piConfig.configDir is .gsd");
} finally {
rmSync(tarballPath, { force: true });
rmSync(sandbox.rootDir, { recursive: true, force: true });
}
});
@ -98,35 +132,43 @@ test("npm pack produces tarball with required files", async () => {
// ═══════════════════════════════════════════════════════════════════════════
test("tarball installs and gsd binary resolves", async () => {
const tarballPath = packTarball();
const tmp = mkdtempSync(join(tmpdir(), "gsd-install-test-"));
const sandbox = createNpmSandbox("gsd-install-test-");
const tarballPath = packTarball(sandbox);
try {
// Install from tarball into a temp prefix
execFileSync("npm", ["install", "--prefix", tmp, tarballPath, "--no-save"], {
env: { ...process.env, PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1" },
execFileSync("npm", ["install", "--prefix", sandbox.installPrefix, tarballPath, "--no-save"], {
env: sandbox.env,
stdio: ["ignore", "ignore", "pipe"],
});
// Verify the gsd bin exists in the installed package
const binName = process.platform === "win32" ? "gsd.cmd" : "gsd";
const installedBin = join(tmp, "node_modules", ".bin", binName);
const installedBin = join(sandbox.installPrefix, "node_modules", ".bin", binName);
assert.ok(existsSync(installedBin), `gsd binary exists in node_modules/.bin/ (${binName})`);
// Verify loader.js is executable (has shebang)
const installedLoader = join(tmp, "node_modules", "gsd-pi", "dist", "loader.js");
const installedLoader = join(sandbox.installPrefix, "node_modules", "gsd-pi", "dist", "loader.js");
const loaderContent = readFileSync(installedLoader, "utf-8");
if (process.platform !== "win32") {
assert.ok(loaderContent.startsWith("#!/usr/bin/env node"), "loader.js has node shebang");
}
// Verify bundled resources are present
const installedGsdExt = join(tmp, "node_modules", "gsd-pi", "src", "resources", "extensions", "gsd", "index.ts");
const installedGsdExt = join(
sandbox.installPrefix,
"node_modules",
"gsd-pi",
"src",
"resources",
"extensions",
"gsd",
"index.ts",
);
assert.ok(existsSync(installedGsdExt), "bundled gsd extension present in installed package");
} finally {
rmSync(tarballPath, { force: true });
rmSync(tmp, { recursive: true, force: true });
rmSync(sandbox.rootDir, { recursive: true, force: true });
}
});

View file

@ -0,0 +1,22 @@
import test from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const projectRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
test("postinstall respects PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD", () => {
const result = spawnSync("node", ["scripts/postinstall.js"], {
cwd: projectRoot,
env: { ...process.env, PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1" },
encoding: "utf-8",
});
assert.equal(result.status, 0, `postinstall exits cleanly: ${result.stderr}`);
assert.match(
result.stderr,
/PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD|Browser tools skipped/,
"postinstall reports that browser download was skipped",
);
});

View file

@ -6,6 +6,9 @@ import { tmpdir } from "node:os";
import { ensureManagedTools, resolveToolFromPath } from "../tool-bootstrap.js";
const FD_TARGET = process.platform === "win32" ? "fd.exe" : "fd";
const RG_TARGET = process.platform === "win32" ? "rg.exe" : "rg";
function makeExecutable(dir: string, name: string, content = "#!/bin/sh\nexit 0\n"): string {
const file = join(dir, name);
writeFileSync(file, content);
@ -39,10 +42,10 @@ test("ensureManagedTools provisions fd and rg into managed bin dir", () => {
const provisioned = ensureManagedTools(targetBin, sourceBin);
assert.equal(provisioned.length, 2);
assert.ok(existsSync(join(targetBin, "fd")));
assert.ok(existsSync(join(targetBin, "rg")));
assert.ok(lstatSync(join(targetBin, "fd")).isSymbolicLink() || lstatSync(join(targetBin, "fd")).isFile());
assert.ok(lstatSync(join(targetBin, "rg")).isSymbolicLink() || lstatSync(join(targetBin, "rg")).isFile());
assert.ok(existsSync(join(targetBin, FD_TARGET)));
assert.ok(existsSync(join(targetBin, RG_TARGET)));
assert.ok(lstatSync(join(targetBin, FD_TARGET)).isSymbolicLink() || lstatSync(join(targetBin, FD_TARGET)).isFile());
assert.ok(lstatSync(join(targetBin, RG_TARGET)).isSymbolicLink() || lstatSync(join(targetBin, RG_TARGET)).isFile());
} finally {
rmSync(tmp, { recursive: true, force: true });
}
@ -52,7 +55,7 @@ test("ensureManagedTools copies executable when symlink target already exists as
const tmp = mkdtempSync(join(tmpdir(), "gsd-tool-bootstrap-copy-"));
const sourceBin = join(tmp, "source-bin");
const targetBin = join(tmp, "target-bin");
const targetFd = join(targetBin, "fd");
const targetFd = join(targetBin, FD_TARGET);
mkdirSync(sourceBin, { recursive: true });
mkdirSync(targetBin, { recursive: true });