mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-13 16:38:56 +02:00
spawnSync/execSync/Bun.spawnSync BLOCK the main thread, so bun's in-process per-test timeout can never fire while one waits — a hung child (stdin read, network probe, dead daemon) wedges the whole shard until the runner's external wall-clock SIGKILL. This exact class reached main: free-tests run 33262077256, test/gstack-memory-ingest.test.ts (normally 2.3s) held shard 2 at the 360s wall while its five siblings finished in ~65s. Mechanical sweep in two waves (12 + 4 fan-out agents, every edit verified against its call site): default timeout: 30_000 (matches the free runner's per-test budget), 120_000 for genuinely slow ops (installs, builds, playwright, provider CLIs), helper wrappers fixed ONCE where call sites route through them. Sites that only LOOK like calls (string fixtures, grep needles, comments) were skipped with reasons — the enforcement commit that follows marks them exempt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
318 lines
15 KiB
TypeScript
318 lines
15 KiB
TypeScript
/**
|
|
* Regression test — bin/gstack-slug must resolve to the OUTERMOST project root
|
|
* along the cwd ancestor chain, not to a subdirectory that happens to contain
|
|
* a build/deploy marker.
|
|
*
|
|
* The bug this prevents (2026-05-25):
|
|
* `bin/gstack-slug` derived its slug from the literal `pwd` with no walk-up.
|
|
* When a session's cwd landed inside a subdir that had its own project-like
|
|
* marker (e.g. `.vercel/` dropped by `vercel --prod`, or a vendored `package.json`),
|
|
* the slug resolved to the subdir's basename — silently misfiling checkpoints,
|
|
* autosave state, and operational learnings under a phantom slug like `site`
|
|
* instead of the real project's slug like `loadout`.
|
|
*
|
|
* The fix walks up from `pwd` looking for canonical project-identity markers
|
|
* (`.git`, `.project.yaml`, `package.json`, `pyproject.toml`, `Cargo.toml`,
|
|
* `Gemfile`, `go.mod`) and takes the OUTERMOST match. Build/deploy artifacts
|
|
* (`.vercel`, `.next`, `dist`, `node_modules`, etc.) are NOT in the allow-list,
|
|
* so they cannot establish a phantom project root.
|
|
*
|
|
* Caching is self-healing: a stale cache entry for the literal pwd gets
|
|
* overwritten with the freshly-computed correct slug on the next invocation
|
|
* (no manual `rm -rf ~/.gstack/slug-cache/` required).
|
|
*
|
|
* Test pattern mirrors `test/migration-checkpoint-ownership.test.ts`:
|
|
* per-test `tmpHome`, `spawnSync` against the real bash script with the
|
|
* tmpHome injected as `HOME`, fixtures built on disk.
|
|
*/
|
|
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
import { spawnSync, type SpawnSyncReturns } from 'child_process';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const SCRIPT = path.join(ROOT, 'bin', 'gstack-slug');
|
|
|
|
function runSlug(
|
|
cwd: string,
|
|
tmpHome: string,
|
|
extraEnv: Record<string, string> = {},
|
|
): SpawnSyncReturns<string> {
|
|
// GSTACK_HOME must be pinned to the temp home too: the cache dir is
|
|
// GSTACK_HOME-aware (matching lib/bin-context.ts's native port), and a
|
|
// sibling test file leaking process.env.GSTACK_HOME in a shared-process
|
|
// shard would otherwise point the bin at a different cache than the one
|
|
// these tests seed and assert on.
|
|
// Scrub PATH so we always use system bash + system git; pass HOME so the
|
|
// script's cache writes land in tmpHome, never AJ's real ~/.gstack.
|
|
const env = { ...process.env, HOME: tmpHome, GSTACK_HOME: path.join(tmpHome, '.gstack'), ...extraEnv };
|
|
return spawnSync('bash', [SCRIPT], {
|
|
cwd,
|
|
env,
|
|
encoding: 'utf8',
|
|
timeout: 10_000,
|
|
});
|
|
}
|
|
|
|
function parseSlug(stdout: string): { slug: string; branch: string } {
|
|
const slugMatch = stdout.match(/^SLUG=([^\n]*)$/m);
|
|
const branchMatch = stdout.match(/^BRANCH=([^\n]*)$/m);
|
|
return {
|
|
slug: slugMatch ? slugMatch[1]! : '',
|
|
branch: branchMatch ? branchMatch[1]! : '',
|
|
};
|
|
}
|
|
|
|
function encodedCacheKey(absPath: string): string {
|
|
return absPath.replace(/\//g, '_');
|
|
}
|
|
|
|
describe('gstack-slug — outermost project-root resolution', () => {
|
|
let tmpHome: string;
|
|
let projectsRoot: string;
|
|
|
|
beforeEach(() => {
|
|
// realpathSync canonicalizes /var/folders/... -> /private/var/folders/... on
|
|
// macOS so that the cache key our test computes matches the cache key the
|
|
// bash script computes from `$(pwd)`. Without this the script writes to
|
|
// _private_var_folders_... and the test sees _var_folders_... (silent mismatch).
|
|
tmpHome = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-slug-test-')));
|
|
projectsRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-slug-projects-')));
|
|
});
|
|
|
|
afterEach(() => {
|
|
try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch {}
|
|
try { fs.rmSync(projectsRoot, { recursive: true, force: true }); } catch {}
|
|
});
|
|
|
|
// AC-1: the canonical loadout/site/.vercel reproduction.
|
|
test('AC-1: .git at root, .vercel in subdir — slug from subdir resolves to ROOT basename', () => {
|
|
const projectRoot = path.join(projectsRoot, 'loadout');
|
|
const siteSubdir = path.join(projectRoot, 'site');
|
|
fs.mkdirSync(path.join(projectRoot, '.git'), { recursive: true });
|
|
fs.mkdirSync(path.join(siteSubdir, '.vercel'), { recursive: true });
|
|
fs.writeFileSync(path.join(siteSubdir, '.vercel', 'project.json'), '{}\n');
|
|
|
|
const result = runSlug(siteSubdir, tmpHome);
|
|
expect(result.status).toBe(0);
|
|
const { slug } = parseSlug(result.stdout);
|
|
expect(slug).toBe('loadout');
|
|
expect(slug).not.toBe('site');
|
|
});
|
|
|
|
// AC-1 variant: package.json at root, node_modules-only in subdir.
|
|
test('AC-1 variant: package.json at root, node_modules-only subdir — slug = ROOT basename', () => {
|
|
const projectRoot = path.join(projectsRoot, 'monorepo');
|
|
const subdir = path.join(projectRoot, 'packages', 'web');
|
|
fs.mkdirSync(subdir, { recursive: true });
|
|
fs.writeFileSync(path.join(projectRoot, 'package.json'), '{}\n');
|
|
fs.mkdirSync(path.join(subdir, 'node_modules'), { recursive: true });
|
|
|
|
const result = runSlug(subdir, tmpHome);
|
|
expect(result.status).toBe(0);
|
|
const { slug } = parseSlug(result.stdout);
|
|
expect(slug).toBe('monorepo');
|
|
});
|
|
|
|
// AC-2: stale cache for the subdir's pwd gets self-healed.
|
|
test('AC-2: stale cache for subdir pwd is overwritten with correct outermost-root slug', () => {
|
|
const projectRoot = path.join(projectsRoot, 'loadout');
|
|
const siteSubdir = path.join(projectRoot, 'site');
|
|
fs.mkdirSync(path.join(projectRoot, '.git'), { recursive: true });
|
|
fs.mkdirSync(path.join(siteSubdir, '.vercel'), { recursive: true });
|
|
fs.writeFileSync(path.join(siteSubdir, '.vercel', 'project.json'), '{}\n');
|
|
|
|
// Pre-seed the cache with the WRONG value (simulating pre-fix poisoning).
|
|
const cacheDir = path.join(tmpHome, '.gstack', 'slug-cache');
|
|
fs.mkdirSync(cacheDir, { recursive: true });
|
|
const cacheKey = encodedCacheKey(siteSubdir);
|
|
const cacheFile = path.join(cacheDir, cacheKey);
|
|
fs.writeFileSync(cacheFile, 'site');
|
|
|
|
const result = runSlug(siteSubdir, tmpHome);
|
|
expect(result.status).toBe(0);
|
|
const { slug } = parseSlug(result.stdout);
|
|
expect(slug).toBe('loadout');
|
|
|
|
// The cache file itself must have been overwritten (self-healing).
|
|
const cachedAfter = fs.readFileSync(cacheFile, 'utf8').trim();
|
|
expect(cachedAfter).toBe('loadout');
|
|
});
|
|
|
|
// AC-3: no regression — cwd IS the project root.
|
|
test('AC-3: cwd is the project root with .git — slug = basename, no change in behavior', () => {
|
|
const projectRoot = path.join(projectsRoot, 'myproject');
|
|
fs.mkdirSync(path.join(projectRoot, '.git'), { recursive: true });
|
|
|
|
const result = runSlug(projectRoot, tmpHome);
|
|
expect(result.status).toBe(0);
|
|
const { slug } = parseSlug(result.stdout);
|
|
expect(slug).toBe('myproject');
|
|
});
|
|
|
|
// AC-4: no regression — no markers anywhere on the cwd ancestor chain.
|
|
test('AC-4: no project markers anywhere on cwd chain — slug = pwd basename (fallback)', () => {
|
|
// projectsRoot itself is just a tmp dir with no markers; create a deeper
|
|
// path inside it that also has no markers anywhere up to it.
|
|
const deep = path.join(projectsRoot, 'just', 'a', 'plain', 'folder');
|
|
fs.mkdirSync(deep, { recursive: true });
|
|
|
|
const result = runSlug(deep, tmpHome);
|
|
expect(result.status).toBe(0);
|
|
const { slug } = parseSlug(result.stdout);
|
|
expect(slug).toBe('folder');
|
|
});
|
|
|
|
// AC-5: no regression — real git remote takes precedence (slug from remote URL).
|
|
test('AC-5: project root has a real git remote — slug derived from remote URL', () => {
|
|
const projectRoot = path.join(projectsRoot, 'realgit');
|
|
fs.mkdirSync(projectRoot, { recursive: true });
|
|
// Initialize a real git repo with an origin remote so `git remote get-url`
|
|
// succeeds. (The script's step 2 reads the remote when there's no cache.)
|
|
const gitInit = spawnSync('git', ['init', '-q', '-b', 'main', projectRoot], {
|
|
encoding: 'utf8',
|
|
timeout: 30_000,
|
|
});
|
|
expect(gitInit.status).toBe(0);
|
|
const gitRemote = spawnSync(
|
|
'git',
|
|
['-C', projectRoot, 'remote', 'add', 'origin', 'https://github.com/foo/bar.git'],
|
|
{ encoding: 'utf8', timeout: 30_000 },
|
|
);
|
|
expect(gitRemote.status).toBe(0);
|
|
|
|
const result = runSlug(projectRoot, tmpHome);
|
|
expect(result.status).toBe(0);
|
|
const { slug } = parseSlug(result.stdout);
|
|
// Existing sed-based regex extracts "foo/bar" → "foo-bar" after tr '/' '-'.
|
|
expect(slug).toBe('foo-bar');
|
|
});
|
|
|
|
// AC-6: cache eviction is single-shot — does NOT touch other cache entries.
|
|
test('AC-6: cache eviction only rewrites the literal-pwd key, not other entries', () => {
|
|
const projectRoot = path.join(projectsRoot, 'loadout');
|
|
const siteSubdir = path.join(projectRoot, 'site');
|
|
fs.mkdirSync(path.join(projectRoot, '.git'), { recursive: true });
|
|
fs.mkdirSync(path.join(siteSubdir, '.vercel'), { recursive: true });
|
|
fs.writeFileSync(path.join(siteSubdir, '.vercel', 'project.json'), '{}\n');
|
|
|
|
const cacheDir = path.join(tmpHome, '.gstack', 'slug-cache');
|
|
fs.mkdirSync(cacheDir, { recursive: true });
|
|
|
|
// Seed the literal pwd key with the wrong value (will be evicted).
|
|
const targetKey = encodedCacheKey(siteSubdir);
|
|
fs.writeFileSync(path.join(cacheDir, targetKey), 'site');
|
|
|
|
// Seed an UNRELATED cache entry — must remain untouched.
|
|
const unrelatedKey = '_Users_someone_unrelated_project';
|
|
const unrelatedFile = path.join(cacheDir, unrelatedKey);
|
|
fs.writeFileSync(unrelatedFile, 'unrelated-value-must-survive');
|
|
|
|
const result = runSlug(siteSubdir, tmpHome);
|
|
expect(result.status).toBe(0);
|
|
|
|
// Target key got self-healed.
|
|
expect(fs.readFileSync(path.join(cacheDir, targetKey), 'utf8').trim()).toBe('loadout');
|
|
// Unrelated key is untouched.
|
|
expect(fs.readFileSync(unrelatedFile, 'utf8').trim()).toBe('unrelated-value-must-survive');
|
|
});
|
|
|
|
// AC-7: output contract is preserved exactly.
|
|
test('AC-7: stdout shape is `SLUG=<safe>\\nBRANCH=<safe>\\n`, sanitized to [a-zA-Z0-9._-]', () => {
|
|
const projectRoot = path.join(projectsRoot, 'loadout');
|
|
const siteSubdir = path.join(projectRoot, 'site');
|
|
fs.mkdirSync(path.join(projectRoot, '.git'), { recursive: true });
|
|
fs.mkdirSync(path.join(siteSubdir, '.vercel'), { recursive: true });
|
|
fs.writeFileSync(path.join(siteSubdir, '.vercel', 'project.json'), '{}\n');
|
|
|
|
const result = runSlug(siteSubdir, tmpHome);
|
|
expect(result.status).toBe(0);
|
|
// Exactly two lines (with trailing newline from the last `echo`).
|
|
expect(result.stdout).toMatch(/^SLUG=[a-zA-Z0-9._-]+\nBRANCH=[a-zA-Z0-9._-]+\n$/);
|
|
});
|
|
|
|
// Weak-marker case: content-only project folder (README.md, no .git, no package.json).
|
|
// This is the AJ-loadout shape: a folder of markdown content with a README at
|
|
// the root and a deploy-artifact-only subdir. Without README as a marker, the
|
|
// walk-up would find nothing and fall back to pwd basename = subdir name.
|
|
test('weak marker: README.md at root, .vercel-only subdir — slug = ROOT basename (loadout repro)', () => {
|
|
const projectRoot = path.join(projectsRoot, 'loadout');
|
|
const siteSubdir = path.join(projectRoot, 'site');
|
|
fs.mkdirSync(siteSubdir, { recursive: true });
|
|
fs.writeFileSync(path.join(projectRoot, 'README.md'), '# loadout\n');
|
|
fs.mkdirSync(path.join(siteSubdir, '.vercel'), { recursive: true });
|
|
fs.writeFileSync(path.join(siteSubdir, '.vercel', 'project.json'), '{}\n');
|
|
|
|
const result = runSlug(siteSubdir, tmpHome);
|
|
expect(result.status).toBe(0);
|
|
const { slug } = parseSlug(result.stdout);
|
|
expect(slug).toBe('loadout');
|
|
});
|
|
|
|
// Two-tier markers: a vendored sub-repo with its own .git keeps its own slug
|
|
// even when a weak-marker (README) parent is higher up. Strong beats weak.
|
|
test('two-tier: vendored sub-repo with .git wins over parent README (strong > weak)', () => {
|
|
const projectRoot = path.join(projectsRoot, 'loadout');
|
|
const subRepo = path.join(projectRoot, 'starter-pack');
|
|
fs.mkdirSync(subRepo, { recursive: true });
|
|
fs.writeFileSync(path.join(projectRoot, 'README.md'), '# loadout\n');
|
|
fs.mkdirSync(path.join(subRepo, '.git'), { recursive: true });
|
|
|
|
const result = runSlug(subRepo, tmpHome);
|
|
expect(result.status).toBe(0);
|
|
const { slug } = parseSlug(result.stdout);
|
|
expect(slug).toBe('starter-pack');
|
|
});
|
|
|
|
// Two-tier markers: weak marker still wins when no strong marker exists
|
|
// anywhere on the chain. Confirms loadout/site/.vercel → loadout case.
|
|
test('two-tier: weak marker chain falls back correctly when no strong marker exists', () => {
|
|
const projectRoot = path.join(projectsRoot, 'loadout');
|
|
const subdir = path.join(projectRoot, 'docs');
|
|
fs.mkdirSync(subdir, { recursive: true });
|
|
fs.writeFileSync(path.join(projectRoot, 'README.md'), '# loadout\n');
|
|
fs.writeFileSync(path.join(subdir, 'README.md'), '# docs\n');
|
|
|
|
const result = runSlug(subdir, tmpHome);
|
|
expect(result.status).toBe(0);
|
|
const { slug } = parseSlug(result.stdout);
|
|
// Outermost weak wins → loadout, not docs.
|
|
expect(slug).toBe('loadout');
|
|
});
|
|
|
|
// Edge case: GSTACK_PROJECT_SLUG env override wins over walk-up (documented escape hatch).
|
|
test('GSTACK_PROJECT_SLUG env override beats every other resolution path', () => {
|
|
const projectRoot = path.join(projectsRoot, 'loadout');
|
|
const siteSubdir = path.join(projectRoot, 'site');
|
|
fs.mkdirSync(path.join(projectRoot, '.git'), { recursive: true });
|
|
fs.mkdirSync(path.join(siteSubdir, '.vercel'), { recursive: true });
|
|
fs.writeFileSync(path.join(siteSubdir, '.vercel', 'project.json'), '{}\n');
|
|
|
|
const result = runSlug(siteSubdir, tmpHome, { GSTACK_PROJECT_SLUG: 'custom-override' });
|
|
expect(result.status).toBe(0);
|
|
const { slug } = parseSlug(result.stdout);
|
|
expect(slug).toBe('custom-override');
|
|
});
|
|
});
|
|
|
|
describe('_outermost_project_root termination (windows-free-tests regression)', () => {
|
|
// Under git-bash on Windows a mixed-form path walks C:/Users -> C: -> . -> .
|
|
// forever: dirname's fixed point there is never "/". The loop must break on
|
|
// the fixed point itself. Extract the function and drive it with hostile
|
|
// path forms under a hard timeout — a hang fails the spawn, not the suite.
|
|
const script = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-slug'), 'utf-8');
|
|
const fnMatch = script.match(/_outermost_project_root\(\) \{[\s\S]*?\n\}/);
|
|
|
|
test.each(['C:/Users/nobody/project', '.', '//server/share/dir'])(
|
|
'terminates on hostile path form: %s',
|
|
(hostile) => {
|
|
expect(fnMatch).not.toBeNull();
|
|
const r = Bun.spawnSync(['bash', '-c', `${fnMatch![0]}\n_outermost_project_root "$1"; echo TERMINATED`, '_', hostile], {
|
|
timeout: 5000,
|
|
});
|
|
expect(r.stdout.toString()).toContain('TERMINATED');
|
|
},
|
|
);
|
|
});
|