diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bccb8ac90..04d5d8564 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/scripts/postinstall.js b/scripts/postinstall.js index a477dd912..46c78cd96 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -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, + }) + } } } diff --git a/src/bundled-extension-paths.ts b/src/bundled-extension-paths.ts new file mode 100644 index 000000000..a6765f7b8 --- /dev/null +++ b/src/bundled-extension-paths.ts @@ -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); +} diff --git a/src/loader.ts b/src/loader.ts index 9a0d03c87..a54050b9b 100644 --- a/src/loader.ts +++ b/src/loader.ts @@ -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 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 diff --git a/src/resources/extensions/gsd/tests/git-service.test.ts b/src/resources/extensions/gsd/tests/git-service.test.ts index eaaa4e478..68ed0ec20 100644 --- a/src/resources/extensions/gsd/tests/git-service.test.ts +++ b/src/resources/extensions/gsd/tests/git-service.test.ts @@ -230,8 +230,8 @@ async function main(): Promise { 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 { 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 { 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 { 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 { // 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 { 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 { // 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 { // 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 { // 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 { // 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 { // 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 { 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 { // 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 { // 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 { // 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 { 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 { { 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 { // 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 { 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"); diff --git a/src/resources/extensions/gsd/tests/idle-recovery.test.ts b/src/resources/extensions/gsd/tests/idle-recovery.test.ts index 11cb1df29..60d952c27 100644 --- a/src/resources/extensions/gsd/tests/idle-recovery.test.ts +++ b/src/resources/extensions/gsd/tests/idle-recovery.test.ts @@ -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"); diff --git a/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts b/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts index f2a229b34..d5b761075 100644 --- a/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +++ b/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts @@ -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); diff --git a/src/resources/extensions/gsd/tests/orphaned-branch.test.ts b/src/resources/extensions/gsd/tests/orphaned-branch.test.ts index 3a8bbbb9d..31ef1a877 100644 --- a/src/resources/extensions/gsd/tests/orphaned-branch.test.ts +++ b/src/resources/extensions/gsd/tests/orphaned-branch.test.ts @@ -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 diff --git a/src/resources/extensions/gsd/tests/worktree-integration.test.ts b/src/resources/extensions/gsd/tests/worktree-integration.test.ts index b8c5324fc..e2cf0cc1a 100644 --- a/src/resources/extensions/gsd/tests/worktree-integration.test.ts +++ b/src/resources/extensions/gsd/tests/worktree-integration.test.ts @@ -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 { // ── Verify main tree baseline ────────────────────────────────────────────── @@ -124,7 +124,7 @@ async function main(): Promise { 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 { 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 { 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"); diff --git a/src/resources/extensions/gsd/tests/worktree-manager.test.ts b/src/resources/extensions/gsd/tests/worktree-manager.test.ts index defcae12d..ca6b738f3 100644 --- a/src/resources/extensions/gsd/tests/worktree-manager.test.ts +++ b/src/resources/extensions/gsd/tests/worktree-manager.test.ts @@ -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 { console.log("\n=== worktreeBranchName ==="); @@ -95,7 +95,7 @@ async function main(): Promise { "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"); diff --git a/src/resources/extensions/gsd/tests/worktree.test.ts b/src/resources/extensions/gsd/tests/worktree.test.ts index 5aa0d64e4..69de83435 100644 --- a/src/resources/extensions/gsd/tests/worktree.test.ts +++ b/src/resources/extensions/gsd/tests/worktree.test.ts @@ -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 { console.log("\n=== ensureSliceBranch ==="); @@ -85,7 +85,7 @@ async function main(): Promise { 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 { "- [ ] **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 { 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 { "- [ ] **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 { 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 { "- [ ] **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 { // 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 { "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 { "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 { // 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 { // 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 { 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 { // 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"); diff --git a/src/resources/extensions/gsd/worktree-manager.ts b/src/resources/extensions/gsd/worktree-manager.ts index b1207f07f..edbec38eb 100644 --- a/src/resources/extensions/gsd/worktree-manager.ts +++ b/src/resources/extensions/gsd/worktree-manager.ts @@ -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(); + 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), }); } diff --git a/src/resources/extensions/gsd/worktree.ts b/src/resources/extensions/gsd/worktree.ts index 38f450c7f..1a4e65d5c 100644 --- a/src/resources/extensions/gsd/worktree.ts +++ b/src/resources/extensions/gsd/worktree.ts @@ -72,11 +72,12 @@ export function captureIntegrationBranch(basePath: string, milestoneId: string): * Returns null if not inside a GSD worktree (.gsd/worktrees//). */ 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; } diff --git a/src/resources/extensions/shared/bundled-extension-paths.ts b/src/resources/extensions/shared/bundled-extension-paths.ts new file mode 100644 index 000000000..a0ddd4443 --- /dev/null +++ b/src/resources/extensions/shared/bundled-extension-paths.ts @@ -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); +} diff --git a/src/resources/extensions/subagent/index.ts b/src/resources/extensions/subagent/index.ts index f598327e3..da8496bec 100644 --- a/src/resources/extensions/subagent/index.ts +++ b/src/resources/extensions/subagent/index.ts @@ -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((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, diff --git a/src/tests/app-smoke.test.ts b/src/tests/app-smoke.test.ts index 1cbbcab1c..61e28cebb 100644 --- a/src/tests/app-smoke.test.ts +++ b/src/tests/app-smoke.test.ts @@ -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') diff --git a/src/tests/bundled-extension-paths.test.ts b/src/tests/bundled-extension-paths.test.ts new file mode 100644 index 000000000..8bb21184a --- /dev/null +++ b/src/tests/bundled-extension-paths.test.ts @@ -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); +}); diff --git a/src/tests/integration/pack-install.test.ts b/src/tests/integration/pack-install.test.ts index 7f3f12e76..7afcfa586 100644 --- a/src/tests/integration/pack-install.test.ts +++ b/src/tests/integration/pack-install.test.ts @@ -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 { // ═══════════════════════════════════════════════════════════════════════════ 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 }); } }); diff --git a/src/tests/postinstall.test.ts b/src/tests/postinstall.test.ts new file mode 100644 index 000000000..b533084a1 --- /dev/null +++ b/src/tests/postinstall.test.ts @@ -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", + ); +}); diff --git a/src/tests/tool-bootstrap.test.ts b/src/tests/tool-bootstrap.test.ts index b19b6740b..ef5f20315 100644 --- a/src/tests/tool-bootstrap.test.ts +++ b/src/tests/tool-bootstrap.test.ts @@ -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 });