Files
gstack/test/skill-e2e-plan-design-with-ui.test.ts
T
Garry TanandClaude Fable 5 6841183c35 refactor(test): mechanical sweep — 298 paid-test timeouts onto eval-budget tiers
69 files, both shapes (trailing bun-test budgets and runner
timeout/timeoutMs options), ROUND-UP ONLY so nothing that passed can
start failing: 75 → JUDGE_MS, 137 → CAPTURE_MS, 74 → CAPTURE_LONG_MS,
9 → PTY_MS, 3 → PTY_LONG_MS. Raw >=60s literal count in the paid scope:
395 → 97, of which 51 are non-timeout noise (fixture dates, run IDs)
and 46 are enumerated justified holds (comment-carrying calibrated
budgets, poll-loop constants, utility spawn waits, and the seven
physical-ceiling 1_500_000 sites). The eval-budgets policy ratchet
keeps the residue from regrowing.

Known collapse: where an inner runner budget and its enclosing test
budget now share a tier, the old stagger is gone — an overrun surfaces
as a bun test timeout instead of a graceful runner timeout
(diagnosability trade, not a correctness one).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 05:25:06 +00:00

159 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* /plan-design-review with UI scope (gate, paid, real-PTY).
*
* Counterpart to the existing no-UI early-exit test. When the input plan
* DOES describe UI changes, /plan-design-review must NOT early-exit and
* must reach a real skill numbered-option AskUserQuestion (its first design-rating
* question), with the captured evidence NOT echoing the early-exit phrase.
*
* Why: today we only test the negative path (no-UI → early-exit). A
* regression that flips the UI-detection logic — making EVERY plan early-
* exit — would pass the no-UI test (vacuously) and ship undetected. This
* test is the positive coverage.
*
* How: launch claude in plan mode in the gstack repo cwd (so the skill
* registry is loaded). Send /plan-design-review with the fixture path
* inline so the skill reviews the UI-heavy plan rather than git diff or
* .claude/plans/. Drive past permission dialogs. Wait for a numbered-
* option list that is NOT a permission dialog. Assert evidence does NOT
* contain "no UI scope".
*/
import { test } from 'bun:test';
import { PTY_MS } from './helpers/eval-budgets';
import { describeE2ETier } from './helpers/e2e-gate';
import * as path from 'path';
import {
launchClaudePty,
isNumberedOptionListVisible,
isPermissionDialogVisible,
parseNumberedOptions,
isPlanReadyVisible,
} from './helpers/claude-pty-runner';
const describeE2E = describeE2ETier('gate');
const ROOT = path.resolve(import.meta.dir, '..');
const FIXTURE = path.join(ROOT, 'test', 'fixtures', 'plans', 'ui-heavy-feature.md');
describeE2E('/plan-design-review with UI scope (gate)', () => {
test(
'reaches a real skill AskUserQuestion (or plan_ready) without echoing the no-UI early-exit phrase',
async () => {
const fixtureRelPath = path.relative(ROOT, FIXTURE);
const session = await launchClaudePty({
permissionMode: 'plan',
// LIVE-REPO CWD: PTY session needs the repo cwd — skill registry,
// hermetic pre-trusted dir, and the repo-relative fixture path above.
cwd: ROOT,
timeoutMs: PTY_MS,
seedSkills: true,
});
let outcome: 'real_question' | 'plan_ready' | 'timeout' | 'exited' = 'timeout';
let evidence = '';
let debugBuffer = ''; // captured at end so timeout error has data
try {
await Bun.sleep(8000);
const since = session.mark();
// Send the slash command alone first; then provide the UI-heavy
// plan content as a follow-up message. Claude Code rejects slash
// commands with trailing arguments unless the skill defines them.
session.send('/plan-design-review\r');
await Bun.sleep(3000);
session.send(
`Please review this plan for UI scope:\n\n` +
`Title: User Dashboard Page\n` +
`New React page UserDashboard.tsx with three subcomponents: ` +
`ActivityFeed, NotificationsPanel, QuickActions. ` +
`Tailwind CSS responsive layout (mobile/desktop breakpoints), ` +
`loading skeletons, empty states, hover states on every interactive element, ` +
`modal dialog for "mark all read", toast notifications for action feedback. ` +
`Reference plan file: ${fixtureRelPath}\r`
);
// 600s, not 360s: the skill preamble (update-check, session bookkeeping,
// learnings) plus extended model thinking can take ~6 minutes before the
// scope-gate AskUserQuestion renders — a 360s budget expired seconds
// before the (correct) AUQ appeared in the observed failure transcript.
const budgetMs = 600_000;
const start = Date.now();
let lastPermSig = '';
while (Date.now() - start < budgetMs) {
await Bun.sleep(2500);
if (session.exited()) {
outcome = 'exited';
evidence = session.visibleSince(since).slice(-3000);
break;
}
const visible = session.visibleSince(since);
// Classify the recent tail only — old permission text persists
// in visibleSince(since) and would otherwise re-trigger forever.
// 5KB window: plan-design-review Step 0 renders a numbered AUQ with
// box dividers + per-option descriptions + footer prompt. The full
// rendering frequently exceeds 2.5KB, especially after TTY cursor-
// positioning escapes resolve through stripAnsi. A 2.5KB tail can
// capture the cursor `1.` line without capturing the line that has
// `2.`, defeating isNumberedOptionListVisible. 5KB comfortably
// covers the full AUQ block without including stale scrollback.
const recentTail = visible.slice(-5000);
// Real skill AskUserQuestion visible (not a permission dialog)?
if (
isNumberedOptionListVisible(recentTail) &&
parseNumberedOptions(recentTail).length >= 2 &&
!isPermissionDialogVisible(recentTail)
) {
outcome = 'real_question';
evidence = visible.slice(-3000);
break;
}
// Permission dialog: grant once per unique rendering.
if (isPermissionDialogVisible(recentTail)) {
const sig = visible.slice(-500);
if (sig !== lastPermSig) {
lastPermSig = sig;
session.send('1\r');
await Bun.sleep(1500);
continue;
}
}
// Plan-ready terminal — also acceptable (skill ran end-to-end
// and surfaced claude's "Ready to execute" prompt).
if (isPlanReadyVisible(visible)) {
outcome = 'plan_ready';
evidence = visible.slice(-3000);
break;
}
}
// Capture buffer state at end so a timeout error has diagnostic data.
debugBuffer = session.visibleSince(since).slice(-4000);
} finally {
await session.close();
}
// PASS: real_question or plan_ready, AND evidence does NOT echo the
// early-exit phrase.
if (outcome === 'exited' || outcome === 'timeout') {
throw new Error(
`plan-design-review with UI scope FAILED: outcome=${outcome}\n` +
`--- buffer at timeout (last 4KB) ---\n${debugBuffer || evidence}`,
);
}
const NO_UI_PHRASE = /no\s+UI\s+scope|isn'?t\s+applicable/i;
if (NO_UI_PHRASE.test(evidence)) {
throw new Error(
`plan-design-review early-exited despite UI-heavy fixture.\n` +
`--- evidence (last 3KB) ---\n${evidence}`,
);
}
},
PTY_MS,
);
});