Files
gstack/test/touchfiles.test.ts
0530392821 v1.81.0.0 feat: Aside is the browser gstack drives first; every browsing skill, the PDF/diagram renderer, and web research; the bundled browser stays the automatic fallback (#2810)
* feat(aside): browser-driver contract, cookbook, research and fallback resolvers

{{ASIDE_SETUP}} (readiness probe + ten rules for driving the user's real browser), {{ASIDE_COOKBOOK}} (script shapes verified live against Aside CLI 1.26: one flow per aside repl script, CDP console hook before navigation, evidence lines, session-directory artifact handoff, GSTACK_STEP_OK sentinel), {{ASIDE_RESEARCH}} (research through aside exec, WebSearch when Aside is absent, knowledge otherwise) and {{BROWSE_FALLBACK}} (the fifteen-row Aside-step to $B-command table plus the rules that differ, so every browsing skill keeps working on gstack's own headless browser). test/aside-driver.test.ts pins the sentences and asserts every browsing skill carries the Aside block followed by the fallback; test/helpers/aside-available.ts is the shared live-Aside probe.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(render): Aside-first local-HTML renderer with the bundled browser as fallback

lib/aside-render.ts serves the HTML's directory on loopback (Aside refuses file:// URLs), opens it with waitUntil load, prints through CDP Page.printToPDF so tagged output, outlines, header/footer templates and page numbers survive, emulates device metrics for sized screenshots, and writes in-page evaluations to files; when Aside is absent it runs the same spec through the browse daemon (newtab, load, js, pdf, screenshot, closetab) and reports ENGINE=aside|browse. bin/gstack-render.ts is the CLI skill templates call. lib/claude-bin.ts and lib/error-handling.ts become the canonical copies (browse/src re-exports them).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(browse): /browse drives Aside first, with the $B reference behind the fallback

Contract, cookbook, mode choice (aside repl by default, aside exec for reading), report format, the fallback section, and the full command reference carved on demand.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(qa): /qa and /qa-only drive Aside, fall back to $B

QA_METHODOLOGY runs every phase as Aside scripts (orient, explore, document, re-test, mobile viewport via CDP emulation, links via HEAD fetch); the authenticate phase is 'you are already signed in'; a 13th rule requires consent before mutating actions on non-local targets; the fallback section translates each step onto $B. The qa E2E tests run on whichever engine is present.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(design): design-review, design-consultation, design-shotgun, plan-design-review, design-html drive Aside

Design-system extraction is one script printing FONTS/COLORS/HEADINGS/TOUCH_TARGETS/NAV; competitor research confirms the exact URLs before opening them in the real browser and runs on the bundled browser when Aside is absent; design-html's viewport screenshots, sketches and comparison boards render through gstack-render.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(deploy): benchmark, canary, land-and-deploy Step 7, devex-review drive Aside

One aside repl script per page prints NAV/PAINT/LCP/RESOURCES/SCRIPTS/CSS/SUMMARY (benchmark), CONSOLE_ERRORS/NAV/TEXT + screenshot (canary, re-run every 60s), and the post-deploy check reads responseStatus from the navigation entry; each carries the $B fallback.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(third-party-actions): Aside is the recommended driver; gstack's visible browser stays the fallback

The readiness probe is lifted from {{ASIDE_SETUP}} at gen time (byte-identity pinned) and rule 3 points at browse/SKILL.md for how to drive; the consent question offers Aside first and gstack's own visible browser (handoff/resume for sign-in) as the fallback, as v1.72 framed it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(scrape): /scrape reads pages through Aside; the browser-skills runtime rides the fallback

Look-then-extract scripts build the JSON inside the page and print it between JSON_START/JSON_END; aside exec for fuzzy intents; on the $B fallback the browser-skills match/prototype flow and /skillify apply as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(make-pdf): print through Aside first, the bundled browser otherwise

asideClient.ts replaces the direct $B client with one render() call per PDF (the exact option mapping the browse pdf command had: paper, margins, header/footer/page numbers, tagged, outline, printBackground, preferCSSPageSize, Paged.js wait); the diagram pre-pass, oversized-image downscale and DOCX rasters each run as one render script with per-fence try/catch; exit 4 now means no browser is available and names both remedies; $P setup reports which engine it found. The e2e gates run on whichever engine is present, so the Linux lane exercises the fallback.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(diagram): the triplet is one gstack-render call

SVG, PNG and excalidraw from one invocation over the content-addressed bundle staged under /tmp/gstack-render; every diagram type gets an excalidraw export; gstack-render picks the engine and prints ENGINE=; the diagram E2E gates on either engine.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(research): web research runs in Aside first, WebSearch second

The planning, review, design, security and investigate skills research through {{ASIDE_RESEARCH}}; WebSearch stays in allowed-tools as the fallback; testing.ts's bootstrap step follows; skeleton ceilings ratcheted for the research block.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(setup,gen-skill-docs): prune renders of skills that no longer exist

setup gains _prune_stale_generated for every host tree and the doc generator removes gstack-* output dirs it did not write, so a skill removed from the source tree can never linger in an install.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test: registries, budgets and suite reconciled for Aside-first with the $B fallback

Touchfiles + E2E tiers gain the Aside keys, coverage matrix and eval baselines updated, size budget re-baselined to parity-baseline-v1.80.0.0.json (the contract plus fallback ride in every browsing skill), parity ceilings ratcheted with measured values, LLM-judge prompts and the E2E fixtures speak Aside-first, browse-fallback.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs: Aside first, gstack browser fallback

README, BROWSER.md, docs/, CONTRIBUTING, CLAUDE.md, ARCHITECTURE, AGENTS.md, TODOS and the root router describe the one product story: Aside is the browser gstack drives first; the bundled headless browser is the automatic fallback (Linux, Windows, app closed) where cookie import, GStack Browser, pair-agent and browser-skills still apply.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore: regenerate SKILL.md docs, llms.txt, agents digest, ship goldens, context-budget fixture

bun run gen:skill-docs over the templates; goldens re-rendered; context-budget ceilings recaptured.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* v1.80.0.0: Aside is the browser gstack drives first; the bundled browser is the fallback

MINOR: new capability across ten skills, the renderer and research; nothing removed. CHANGELOG release summary + itemized changes; VERSION 1.80.0.0; package.json 1.80.0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(todos): file non-Claude host ownership-gate and version-heading pin follow-ups

Two follow-ups from the /plan-ceo-review + /plan-eng-review pass on merging
PR #2804 with main's v1.80.0.0 ownership gate: bring the Codex/Factory/
OpenCode/Cursor/Kiro copy loops and the stale-render prune under the
.gstack-owned marker rule, and a free test pinning that the CHANGELOG top
heading equals VERSION (the collision that git cannot see).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: pre-landing review fixes for the Aside-first branch

Review army + adversarial passes (Claude and Codex) on the merged branch:

setup
- _prune_stale_generated scans the host dirs too (the generator already
  removed the render before setup ran, so the host branch was dead), skips
  symlinks in the render tree (rm -rf on a slash-terminated link empties its
  target), removes a host symlink only when it resolves into gstack, cleans a
  bannered real dir through _cleanup_weak_dir, recognizes frontmatter-renamed
  skills, and logs through log. The always-run codex render passes every host
  dir that may link to it.
- NEEDS_BUILD checks all three binaries (with $_EXE) and lib/ sources; the
  browser hint and the bootstrap summary honor GSTACK_SKIP_ASIDE, treat a
  requested skip as a request, and derive one skill list.

lib/aside-render.ts + bin/gstack-render.ts
- The loopback server carries a per-render secret path, checks containment on
  the real path (symlink escapes are 403), and rejects malformed encoding.
- Inline eval results are one base64 line, so page text cannot forge
  ASIDE_DIR= or the sentinel; the last ASIDE_DIR wins.
- runProc escalates SIGTERM to SIGKILL, bounds every wait, and clears every
  timer (an uncleared one kept gstack-render alive after printing OK).
- renderTmpDir refuses a shared /tmp name owned by someone else; the work dir
  and server are created inside try; goto's budget follows the render budget.
- probeAside classifies a present-but-failing CLI as ASIDE_NOT_RUNNING like
  the skills' bash probe; render() retries on gstack's own browser when Aside
  could not start or its private CDP bridge is gone (never on a page error
  or a timeout of a running script); the CLI reports the engine that actually
  rendered, exits 0 on --help, rejects non-numeric flags, documents
  --wait-timeout, fences EVAL/PAGE_ERRORS as untrusted content, and names the
  daemon's cookie-import JS lock remedy.
- The browse path passes --scale only when asked (a scale change rebuilds
  the daemon context) and restores the viewport after a sized screenshot.

resolvers / templates
- The bash probe honors GSTACK_SKIP_ASIDE and has a perl deadline on stock
  macOS; .local is no longer LOCAL (mDNS); same-origin filters compare parsed
  origins; link status is HEAD-checked only on LOCAL targets; every
  aside exec goes through the receipted _aside_exec prelude
  ({{ASIDE_EXEC_PRELUDE}}), including nine template blocks that called it
  bare; the design sketch and diagram staging use private directories.
- The generator prunes only bannered renders and never a host whose
  generation failed.

Docs, stale comments and dead code cleaned; goldens re-rendered; tests
updated and added for every behavior above.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test: coverage for the render CLI, setup rebuild check, make-pdf exit codes, and prose $B spans

New free tests from the ship coverage audit: test/gstack-render-cli.test.ts
(argv guards, --help, output contract with a fake daemon, failure and
serve-root paths, no-browser case, prompt exit), test/setup-needs-build.test.ts
(every binary and source set flips NEEDS_BUILD, Windows suffixes),
make-pdf/test/cli-exit-codes.test.ts and setup-smoke.test.ts (error to exit
code mapping, runSetup stages, renderPdf's engine), and prose-span cases for
extractBrowseCommands in test/skill-parser.test.ts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs: CHANGELOG and TODOS cover the review fixes (v1.81.0.0)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs: sync project docs with the v1.81.0.0 review fixes

BROWSER.md, ARCHITECTURE.md, CONTRIBUTING.md, README.md, CLAUDE.md,
docs/TESTING_INTERNALS.md and docs/PROJECT_STRUCTURE.md now describe the
shipped renderer and setup: the loopback render server's per-render secret
path and real-path containment, ENGINE= naming the engine that actually
rendered (mid-run retry on gstack's own browser), EVAL/PAGE_ERRORS fenced as
untrusted content, --wait-timeout and the CLI's argv guards, the receipted
_aside_exec prelude ({{ASIDE_EXEC_PRELUDE}} in the placeholder table), the
LOCAL host rule without .local, LOCAL-only HEAD checks in the links script,
GSTACK_SKIP_ASIDE across probe/renderer/setup, the ownership-gated
retired-skill prune, the widened NEEDS_BUILD check, and the new free tests
(gstack-render-cli, setup-prune-stale-generated, setup-browser-hint,
setup-needs-build, make-pdf cli-exit-codes and setup-smoke).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs: CHANGELOG states the precise mid-run retry rule

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(test): skill-e2e-bws slices the $B setup block from the Browser fallback section

browse/SKILL.md no longer has '## SETUP' / '## Core QA Patterns' (Aside is the
primary driver; the $B block moved under 'Browser fallback'), so the gate test
sliced an empty block and handed the agent nothing to run. Anchor on
'### Find the `$B` binary' up to the next heading. 7/7 pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(test): gate POSIX-only fixtures off Windows

windows-free-tests: the gstack-render CLI tests drive a shebang fake browse
that CreateProcess cannot exec, and two NEEDS_BUILD cases assert an execute
bit and a bare-name miss that MSYS bash does not have (test -x ignores mode
bits and resolves design -> design.exe). Those describes and cases now
self-skip on win32; argument guards, --help, the no-browser case, and every
other rebuild-check case still run there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(render): runProc waits for the exit code until the kill deadline; newtab retries once on a cold daemon

A process whose pipes have reached EOF is exiting, but runProc gave the exit
code only five seconds to arrive and then returned null, which run() reports
as a failed command. Under CI's six-shard load one such render failed with the
artifact already written. The SIGTERM/SIGKILL timers already bound the wait,
so the exit race now runs to the kill deadline.

The first CLI call auto-starts the browse daemon; on a cold start it can
answer 'Unable to connect' once while the server is still coming up. That
single case is retried after 1.5s; every other newtab failure is not.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(aside-render): warm the daemon before live fallback cases; failures name the render error

- Live fallback cases run 'goto about:blank' up to twice before asserting and
  skip (never fail) when the daemon cannot come up.
- expectOk() puts r.error and the browse transcript into the assertion so a
  failed render is diagnosable from the CI log.
- The argv-contract cases dump the fake's log on a miss.
- File default timeout is 30s: the subject is the CLI contract, not latency.
- Two cases pin the cold-daemon newtab retry and that other errors are not
  retried.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs: CHANGELOG notes the cold-start tolerance of the bundled-browser renderer

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Sina <sdroid674+github@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 08:54:25 -07:00

459 lines
20 KiB
TypeScript

/**
* Unit tests for diff-based test selection.
* Free (no API calls), runs with `bun test`.
*/
import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import {
matchGlob,
selectTests,
detectBaseBranch,
E2E_TOUCHFILES,
E2E_TIERS,
LLM_JUDGE_TOUCHFILES,
GLOBAL_TOUCHFILES,
} from './helpers/touchfiles';
const ROOT = path.resolve(import.meta.dir, '..');
// --- matchGlob ---
describe('matchGlob', () => {
test('** matches any depth of path segments', () => {
expect(matchGlob('browse/src/commands.ts', 'browse/src/**')).toBe(true);
expect(matchGlob('browse/src/deep/nested/file.ts', 'browse/src/**')).toBe(true);
expect(matchGlob('browse/src/cli.ts', 'browse/src/**')).toBe(true);
});
test('** does not match unrelated paths', () => {
expect(matchGlob('browse/src/commands.ts', 'qa/**')).toBe(false);
expect(matchGlob('review/SKILL.md', 'qa/**')).toBe(false);
});
test('exact match works', () => {
expect(matchGlob('SKILL.md', 'SKILL.md')).toBe(true);
expect(matchGlob('SKILL.md.tmpl', 'SKILL.md')).toBe(false);
expect(matchGlob('qa/SKILL.md', 'SKILL.md')).toBe(false);
});
test('* matches within a single segment', () => {
expect(matchGlob('test/fixtures/review-eval-enum.rb', 'test/fixtures/review-eval-enum*.rb')).toBe(true);
expect(matchGlob('test/fixtures/review-eval-enum-diff.rb', 'test/fixtures/review-eval-enum*.rb')).toBe(true);
expect(matchGlob('test/fixtures/review-eval-vuln.rb', 'test/fixtures/review-eval-enum*.rb')).toBe(false);
});
test('dots in patterns are escaped correctly', () => {
expect(matchGlob('SKILL.md', 'SKILL.md')).toBe(true);
expect(matchGlob('SKILLxmd', 'SKILL.md')).toBe(false);
});
test('** at end matches files in the directory', () => {
expect(matchGlob('qa/SKILL.md', 'qa/**')).toBe(true);
expect(matchGlob('qa/SKILL.md.tmpl', 'qa/**')).toBe(true);
expect(matchGlob('qa/templates/report.md', 'qa/**')).toBe(true);
});
});
// --- selectTests ---
describe('selectTests', () => {
test('browse/src change selects browse and qa tests', () => {
const result = selectTests(['browse/src/commands.ts'], E2E_TOUCHFILES);
expect(result.selected).toContain('browse-basic');
expect(result.selected).toContain('browse-snapshot');
expect(result.selected).toContain('qa-quick');
expect(result.selected).toContain('qa-fix-loop');
expect(result.selected).toContain('design-review-fix');
expect(result.reason).toBe('diff');
// Should NOT include unrelated tests
expect(result.selected).not.toContain('plan-ceo-review');
expect(result.selected).not.toContain('retro');
expect(result.selected).not.toContain('document-release');
});
test('aside resolver change selects the Aside-driven skill tests', () => {
const result = selectTests(['scripts/resolvers/aside.ts'], E2E_TOUCHFILES);
expect(result.selected).toContain('aside-browse-basic');
expect(result.selected).toContain('aside-browse-flow');
expect(result.selected).toContain('qa-quick');
expect(result.selected).toContain('qa-fix-loop');
expect(result.selected).toContain('design-review-fix');
expect(result.reason).toBe('diff');
expect(result.selected).not.toContain('plan-ceo-review');
expect(result.selected).not.toContain('retro');
});
test('skill-specific change selects only that skill and related tests', () => {
const result = selectTests(['plan-ceo-review/SKILL.md'], E2E_TOUCHFILES);
expect(result.selected).toContain('plan-ceo-review');
expect(result.selected).toContain('plan-ceo-review-selective');
expect(result.selected).toContain('plan-ceo-review-benefits');
expect(result.selected).toContain('plan-ceo-review-expansion-energy');
expect(result.selected).toContain('codex-offered-ceo-review');
expect(result.selected).toContain('plan-ceo-review-format-mode');
expect(result.selected).toContain('plan-ceo-review-format-approach');
// v1.10.2.0 plan-mode handshake entries also depend on plan-ceo-review/**
expect(result.selected).toContain('plan-ceo-review-plan-mode');
expect(result.selected).toContain('plan-mode-no-op');
expect(result.selected).toContain('plan-ceo-review-prosons-cadence');
expect(result.selected).toContain('plan-review-prosons-format');
expect(result.selected).toContain('plan-review-prosons-hardstop-neg');
expect(result.selected).toContain('plan-review-prosons-neutral-neg');
// v1.13.x real-PTY E2E batch entries that also depend on plan-ceo-review/**
expect(result.selected).toContain('auq-format-gate');
expect(result.selected).toContain('plan-ceo-mode-routing');
expect(result.selected).toContain('autoplan-chain-pty');
// Per-finding count + review-report-at-bottom (v1.21.x)
expect(result.selected).toContain('plan-ceo-finding-count');
// v1.22+ AskUserQuestion-blocked regression: auto-decide-preserved
// also depends on plan-ceo-review/** (autoplan-auto-mode test was
// removed in v1.28 — see commit message for the rationale).
expect(result.selected).toContain('auto-decide-preserved');
// v1.27+ gate-tier reviewCount-floor regression for transcript bug
expect(result.selected).toContain('plan-ceo-finding-floor');
// garrytan/askuserquestion-split-on-overflow: split-overflow periodic
// E2E test also depends on plan-ceo-review/** (5-option scope decision
// regression for the "drop to fit 4 options" failure mode).
expect(result.selected).toContain('plan-ceo-split-overflow');
// v2 plan Phase B carve: the section-loading E2E depends on plan-ceo-review/**.
expect(result.selected).toContain('plan-ceo-section-loading');
expect(result.selected.length).toBe(21);
expect(result.skipped.length).toBe(Object.keys(E2E_TOUCHFILES).length - 21);
});
test('global touchfile triggers ALL tests', () => {
const result = selectTests(['test/helpers/session-runner.ts'], E2E_TOUCHFILES);
expect(result.selected.length).toBe(Object.keys(E2E_TOUCHFILES).length);
expect(result.skipped.length).toBe(0);
expect(result.reason).toContain('global');
});
test('gen-skill-docs.ts is a scoped touchfile, not global', () => {
const result = selectTests(['scripts/gen-skill-docs.ts'], E2E_TOUCHFILES);
// Should select tests that list gen-skill-docs.ts in their touchfiles, not ALL tests
expect(result.selected.length).toBeGreaterThan(0);
expect(result.selected.length).toBeLessThan(Object.keys(E2E_TOUCHFILES).length);
expect(result.reason).toBe('diff');
// Should include tests that depend on gen-skill-docs.ts
expect(result.selected).toContain('skillmd-setup-discovery');
expect(result.selected).toContain('session-awareness');
expect(result.selected).toContain('journey-ideation');
// Should NOT include tests that don't depend on it
expect(result.selected).not.toContain('retro');
expect(result.selected).not.toContain('cso-full-audit');
});
test('unrelated file selects nothing', () => {
const result = selectTests(['README.md'], E2E_TOUCHFILES);
expect(result.selected).toEqual([]);
expect(result.skipped.length).toBe(Object.keys(E2E_TOUCHFILES).length);
});
test('empty changed files selects nothing', () => {
const result = selectTests([], E2E_TOUCHFILES);
expect(result.selected).toEqual([]);
});
test('multiple changed files union their selections', () => {
const result = selectTests(
['plan-ceo-review/SKILL.md', 'retro/SKILL.md.tmpl'],
E2E_TOUCHFILES,
);
expect(result.selected).toContain('plan-ceo-review');
expect(result.selected).toContain('plan-ceo-review-selective');
expect(result.selected).toContain('retro');
expect(result.selected).toContain('retro-base-branch');
// Also selects journey routing tests (*/SKILL.md.tmpl matches retro/SKILL.md.tmpl)
expect(result.selected.length).toBeGreaterThanOrEqual(4);
});
test('works with LLM_JUDGE_TOUCHFILES', () => {
const result = selectTests(['qa/SKILL.md'], LLM_JUDGE_TOUCHFILES);
expect(result.selected).toContain('qa/SKILL.md workflow');
expect(result.selected).toContain('qa/SKILL.md health rubric');
expect(result.selected).toContain('qa/SKILL.md anti-refusal');
expect(result.selected.length).toBe(3);
});
test('SKILL.md.tmpl root template selects root-dependent tests and routing tests', () => {
const result = selectTests(['SKILL.md.tmpl'], E2E_TOUCHFILES);
// Should select the 7 tests that depend on root SKILL.md
expect(result.selected).toContain('skillmd-setup-discovery');
expect(result.selected).toContain('session-awareness');
expect(result.selected).toContain('session-awareness');
// Also selects journey routing tests (SKILL.md.tmpl in their touchfiles)
expect(result.selected).toContain('journey-ideation');
// Should NOT select unrelated non-routing tests
expect(result.selected).not.toContain('plan-ceo-review');
expect(result.selected).not.toContain('retro');
});
test('global touchfiles work for LLM-judge tests too', () => {
const result = selectTests(['test/helpers/session-runner.ts'], LLM_JUDGE_TOUCHFILES);
expect(result.selected.length).toBe(Object.keys(LLM_JUDGE_TOUCHFILES).length);
});
});
// --- detectBaseBranch ---
describe('detectBaseBranch', () => {
test('detects local main branch', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'touchfiles-test-'));
const run = (cmd: string, args: string[]) =>
spawnSync(cmd, args, { cwd: dir, stdio: 'pipe', timeout: 5000 });
run('git', ['init']);
run('git', ['config', 'user.email', 'test@test.com']);
run('git', ['config', 'user.name', 'Test']);
fs.writeFileSync(path.join(dir, 'test.txt'), 'hello\n');
run('git', ['add', '.']);
run('git', ['commit', '-m', 'init']);
const result = detectBaseBranch(dir);
// Should find 'main' (or 'master' depending on git default)
expect(result).toMatch(/^(main|master)$/);
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
});
test('returns null for empty repo with no branches', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'touchfiles-test-'));
const run = (cmd: string, args: string[]) =>
spawnSync(cmd, args, { cwd: dir, stdio: 'pipe', timeout: 5000 });
run('git', ['init']);
// No commits = no branches
const result = detectBaseBranch(dir);
expect(result).toBeNull();
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
});
test('returns null for non-git directory', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'touchfiles-test-'));
const result = detectBaseBranch(dir);
expect(result).toBeNull();
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
});
});
// --- Completeness: every testName in skill-e2e-*.test.ts has a TOUCHFILES entry ---
describe('TOUCHFILES completeness', () => {
test('every E2E testName has a TOUCHFILES entry', () => {
// Read all split E2E test files
const testDir = path.join(ROOT, 'test');
const e2eFiles = fs.readdirSync(testDir).filter(f => f.startsWith('skill-e2e-') && f.endsWith('.test.ts'));
let e2eContent = '';
for (const f of e2eFiles) {
e2eContent += fs.readFileSync(path.join(testDir, f), 'utf-8') + '\n';
}
// Extract all testName: 'value' entries
const testNameRegex = /testName:\s*['"`]([^'"`]+)['"`]/g;
const testNames: string[] = [];
let match;
while ((match = testNameRegex.exec(e2eContent)) !== null) {
let name = match[1];
// Handle template literals like `qa-${label}` — these expand to
// qa-b6-static, qa-b7-spa, qa-b8-checkout
if (name.includes('${')) continue; // skip template literals, check expanded forms below
testNames.push(name);
}
// Add the template-expanded testNames from runPlantedBugEval calls
const plantedBugRegex = /runPlantedBugEval\([^,]+,\s*[^,]+,\s*['"`]([^'"`]+)['"`]\)/g;
while ((match = plantedBugRegex.exec(e2eContent)) !== null) {
testNames.push(`qa-${match[1]}`);
}
expect(testNames.length).toBeGreaterThan(0);
const missing = testNames.filter(name => !(name in E2E_TOUCHFILES));
if (missing.length > 0) {
throw new Error(
`E2E tests missing TOUCHFILES entries: ${missing.join(', ')}\n` +
`Add these to E2E_TOUCHFILES in test/helpers/touchfiles.ts`,
);
}
});
test('E2E_TIERS covers exactly the same tests as E2E_TOUCHFILES', () => {
const touchfileKeys = new Set(Object.keys(E2E_TOUCHFILES));
const tierKeys = new Set(Object.keys(E2E_TIERS));
const missingFromTiers = [...touchfileKeys].filter(k => !tierKeys.has(k));
const extraInTiers = [...tierKeys].filter(k => !touchfileKeys.has(k));
if (missingFromTiers.length > 0) {
throw new Error(
`E2E tests missing TIER entries: ${missingFromTiers.join(', ')}\n` +
`Add these to E2E_TIERS in test/helpers/touchfiles.ts`,
);
}
if (extraInTiers.length > 0) {
throw new Error(
`E2E_TIERS has extra entries not in E2E_TOUCHFILES: ${extraInTiers.join(', ')}\n` +
`Remove these from E2E_TIERS or add to E2E_TOUCHFILES`,
);
}
});
test('E2E_TIERS only contains valid tier values', () => {
const validTiers = ['gate', 'periodic'];
for (const [name, tier] of Object.entries(E2E_TIERS)) {
if (!validTiers.includes(tier)) {
throw new Error(`E2E_TIERS['${name}'] has invalid tier '${tier}'. Valid: ${validTiers.join(', ')}`);
}
}
});
test('every LLM-judge test has a TOUCHFILES entry', () => {
const llmContent = fs.readFileSync(
path.join(ROOT, 'test', 'skill-llm-eval.test.ts'),
'utf-8',
);
// Extract test names from addTest({ name: '...' }) calls
const nameRegex = /name:\s*['"`]([^'"`]+)['"`]/g;
const testNames: string[] = [];
let match;
while ((match = nameRegex.exec(llmContent)) !== null) {
testNames.push(match[1]);
}
// Deduplicate (some tests call addTest with the same name)
const unique = [...new Set(testNames)];
expect(unique.length).toBeGreaterThan(0);
const missing = unique.filter(name => !(name in LLM_JUDGE_TOUCHFILES));
if (missing.length > 0) {
throw new Error(
`LLM-judge tests missing TOUCHFILES entries: ${missing.join(', ')}\n` +
`Add these to LLM_JUDGE_TOUCHFILES in test/helpers/touchfiles.ts`,
);
}
});
});
// --- dependency paths exist on disk ---
//
// The axis nobody guarded: a dep-list entry can point at a file that was
// deleted long ago (browse/src/sidebar-agent.ts sat in three entries for 48
// versions), and diff-based selection then silently never triggers those
// tests. Globs are skipped (they describe patterns, not files); every literal
// path must exist.
describe('touchfile dependency paths exist', () => {
const allEntries: Array<[string, string]> = [];
for (const [name, deps] of Object.entries(E2E_TOUCHFILES)) {
for (const dep of deps) allEntries.push([name, dep]);
}
for (const [name, deps] of Object.entries(LLM_JUDGE_TOUCHFILES)) {
for (const dep of deps) allEntries.push([name, dep]);
}
for (const dep of GLOBAL_TOUCHFILES) allEntries.push(['(global)', dep]);
test('every non-glob dependency path exists', () => {
const stale = allEntries
.filter(([, dep]) => !dep.includes('*'))
.filter(([, dep]) => !fs.existsSync(path.join(ROOT, dep)));
if (stale.length > 0) {
throw new Error(
`Touchfile dep lists reference files that do not exist:\n` +
stale.map(([name, dep]) => ` ${name} -> ${dep}`).join('\n') +
`\nDelete or update these entries in test/helpers/touchfiles.ts — ` +
`diff-based selection silently skips tests whose deps are gone.`,
);
}
});
test('every glob dependency anchors to a directory that exists', () => {
// Cheap sanity for globs, two shapes: 'dir/**' (prefix ends with '/')
// must have the directory itself; 'dir/file-prefix*.ext' must have the
// containing directory. Catches 'deleted-dir/**' rot without a full
// filesystem walk; deliberately does not chase file-prefix staleness.
const stale = allEntries
.filter(([, dep]) => dep.includes('*'))
.map(([name, dep]) => {
const prefix = dep.split('*')[0];
const anchor = prefix.endsWith('/') ? prefix.slice(0, -1) : path.dirname(prefix);
return [name, dep, anchor] as const;
})
.filter(([, , anchor]) => anchor.length > 0 && anchor !== '.' && !fs.existsSync(path.join(ROOT, anchor)));
if (stale.length > 0) {
throw new Error(
`Touchfile glob deps whose anchor directory does not exist:\n` +
stale.map(([name, dep]) => ` ${name} -> ${dep}`).join('\n'),
);
}
});
});
// --- Reverse invariant: every selection key names a LIVING test ---
// The forward invariants above catch stale dep PATHS; nothing caught stale
// KEYS. The 2026-08 audit found 15 phantom E2E keys (6 gate-tier) selecting
// tests that existed nowhere — the merge-blocking census counted work that
// could not run. A key earns its place by appearing as a quoted testName in
// a living paid test file; constructed names get a reasoned exception.
describe('reverse invariant — keys must name living paid tests', () => {
const { isPaidTestFile } = require('./helpers/paid-test-set') as typeof import('./helpers/paid-test-set');
/** Keys whose testNames are CONSTRUCTED at runtime (template literals), so
* a quoted-occurrence scan cannot see them. Each entry needs the file that
* constructs it. Shrink-only: prefer literal names in new tests. */
const CONSTRUCTED_NAME_EXCEPTIONS: Record<string, string> = {};
const paidSources: string[] = [];
for (const name of fs.readdirSync(path.join(ROOT, 'test'))) {
const rel = `test/${name}`;
if (!isPaidTestFile(rel)) continue;
paidSources.push(fs.readFileSync(path.join(ROOT, rel), 'utf-8'));
}
const quotedSomewhere = (key: string): boolean =>
paidSources.some((src) =>
src.includes(`'${key}'`) || src.includes(`"${key}"`) || src.includes('`' + key + '`'));
/** Clause (b) of liveness: constructed testNames (template literals) bind
* through SELF-REGISTRATION — the 2026-08 dep-list sweep put each test
* FILE into its key's dep list, and the parent mapper keeps a shard on
* that registration union. So a key is alive when its name is quoted in a
* paid file OR its dep list names an existing paid test file. */
const registeredToLivingFile = (key: string): boolean =>
(E2E_TOUCHFILES[key] ?? []).some((dep) =>
/\.test\.ts$/.test(dep) && isPaidTestFile(dep) && fs.existsSync(path.join(ROOT, dep)));
test('every E2E_TOUCHFILES key is declared in a living paid test file', () => {
expect(paidSources.length).toBeGreaterThan(50); // scan-rot guard
const phantoms = Object.keys(E2E_TOUCHFILES)
.filter((key) => !(key in CONSTRUCTED_NAME_EXCEPTIONS))
.filter((key) => !quotedSomewhere(key) && !registeredToLivingFile(key));
expect(
phantoms,
`E2E_TOUCHFILES key(s) with NO declaring paid test — the census counts tests that cannot run. ` +
`Delete the key (both maps) or implement the test:\n ${phantoms.join('\n ')}`,
).toEqual([]);
});
test('every LLM_JUDGE_TOUCHFILES key is declared in a living paid test file', () => {
const phantoms = Object.keys(LLM_JUDGE_TOUCHFILES).filter((key) => !quotedSomewhere(key));
expect(
phantoms,
`LLM_JUDGE_TOUCHFILES key(s) with NO declaring test:\n ${phantoms.join('\n ')}`,
).toEqual([]);
});
test('constructed-name exceptions stay live (files exist and construct them)', () => {
const stale = Object.entries(CONSTRUCTED_NAME_EXCEPTIONS)
.filter(([, file]) => !fs.existsSync(path.join(ROOT, file)));
expect(stale.map(([k]) => k), 'exception points at a deleted file — remove the entry').toEqual([]);
});
});