mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 11:45:20 +02:00
7665adf4fe
* feat: CDP connect — control real Chrome/Comet via Playwright Add `connectCDP()` to BrowserManager: connects to a running browser via Chrome DevTools Protocol. All existing browse commands work unchanged through Playwright's abstraction layer. - chrome-launcher.ts: browser discovery, CDP probe, auto-relaunch with rollback - browser-manager.ts: connectCDP(), mode guards (close/closeTab/recreateContext/handoff), auto-reconnect on browser restart, getRefMap() for extension API - server.ts: CDP branch in start(), /health gains mode field, /refs endpoint, idle timer only resets on /command (not passive endpoints) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: browse connect/disconnect/focus CLI commands - connect: pre-server command that discovers browser, starts server in CDP mode - disconnect: drops CDP connection, restarts in headless mode - focus: brings browser window to foreground via osascript (macOS) - status: now shows Mode: cdp | launched | headed - startServer() accepts extra env vars for CDP URL/port passthrough Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: CDP-aware skill templates — skip cookie import in real browser mode Skills now check `$B status` for CDP mode and skip: - /qa: cookie import prompt, user-agent override, headless workarounds - /design-review: cookie import for authenticated pages - /setup-browser-cookies: returns "not needed" in CDP mode Regenerated SKILL.md files from updated templates. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: activity streaming — SSE endpoint for Chrome extension Side Panel Real-time browse command feed via Server-Sent Events: - activity.ts: ActivityEntry type, CircularBuffer (capacity 1000), privacy filtering (redacts passwords, auth tokens, sensitive URL params), cursor-based gap detection, async subscriber notification - server.ts: /activity/stream SSE, /activity/history REST, handleCommand instrumented with command_start/command_end events - 18 unit tests for filterArgs privacy, emitActivity, subscribe lifecycle Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: Chrome extension Side Panel + Conductor API proposal Chrome extension (Manifest V3, sideload): - Side Panel with live activity feed, @ref overlays, dark terminal aesthetic - Background worker: health polling, SSE relay, ref fetching - Popup: port config, connection status, side panel launcher - Content script: floating ref panel with @ref badges Conductor API proposal (docs/designs/CONDUCTOR_SESSION_API.md): - SSE endpoint for full Claude Code session mirroring in Side Panel - Discovery via HTTP endpoint (not filesystem — extensions can't read files) TODOS.md: add $B watch, multi-agent tabs, cross-platform CDP, Web Store publishing. Mark CDP mode as shipped. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: detect Conductor runtime, skip osascript quit for sandboxed apps macOS App Management blocks Electron apps (Conductor) from quitting other apps via osascript. Now detects the runtime environment: - terminal/claude-code/codex: can manage apps freely - conductor: prints manual restart instructions + polls for 60s detectRuntime() checks env vars and parent process. When Chrome needs restart but we can't quit it, prints step-by-step instructions and waits for the user to restart Chrome with --remote-debugging-port. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: detect Conductor via actual env vars (CONDUCTOR_WORKSPACE_NAME) Previous detection checked CONDUCTOR_WORKSPACE_ID which doesn't exist. Conductor sets CONDUCTOR_WORKSPACE_NAME, CONDUCTOR_BIN_DIR, CONDUCTOR_PORT, and __CFBundleIdentifier=com.conductor.app. Check these FIRST because Conductor sessions also have ANTHROPIC_API_KEY (which was matching claude-code). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: connection status pill — floating indicator when gstack controls Chrome Small pill in bottom-right corner of every page: "● gstack · 3 refs" Shows when connected via CDP, fades to 30% opacity after 3s, full on hover. Disappears entirely when disconnected. Background worker now notifies content scripts on connect/disconnect state changes so the pill appears/disappears without polling. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: Chrome requires --user-data-dir for remote debugging Chrome refuses --remote-debugging-port without an explicit --user-data-dir. Add userDataDir to BrowserBinary registry (macOS Application Support paths) and pass it in both auto-launch and manual restart instructions. Fix double-quoting in CLI manual restart instructions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: Chrome must be fully quit before launching with --remote-debugging-port Chrome refuses to enable CDP on its default profile when another instance is running (even with explicit --user-data-dir). The only reliable path: fully quit Chrome first, then relaunch with the flag. Updated instructions to emphasize this clearly with verification step. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: bin/chrome-cdp — quit Chrome and relaunch with CDP in one command Quits Chrome gracefully, waits for full exit, relaunches with --remote-debugging-port, polls until CDP is ready. Usage: chrome-cdp [port] Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use Playwright channel:chrome instead of broken connectOverCDP Playwright's connectOverCDP hangs with Chrome 146 due to CDP protocol version mismatch. Switch to channel:'chrome' which uses Playwright's native pipe protocol to launch the system Chrome binary directly. This is simpler and more reliable: - No CDP port discovery needed - No --remote-debugging-port or --user-data-dir hassles - $B connect just works — launches real Chrome headed window - All Playwright APIs (snapshot, click, fill) work unchanged bin/chrome-cdp updated with symlinked profile approach (kept for manual CDP use cases, but $B connect no longer needs it). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: green border + gstack label on controlled Chrome window Injects a 2px green border and small "gstack" label on every page loaded in the controlled Chrome window via context.addInitScript(). Users can instantly tell which Chrome window Claude controls. Also fixes close() for channel:chrome mode (uses browser.close() not browser.disconnect() which doesn't exist). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: cleanup chrome-launcher runtime detection, remove puppeteer-core dep Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style(design): redesign controlled Chrome indicator Replace crude green border + label with polished indicator: - 2px shimmer gradient at top edge (green→cyan→green, 3s loop) - Floating pill bottom-right with frosted glass bg, fades to 25% opacity after 4s so it doesn't compete with page content - prefers-reduced-motion disables shimmer animation - Much more subtle — looks like a developer tool, not broken CSS Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: document real browser mode + Chrome extension in BROWSER.md and README.md BROWSER.md: new sections for connect/disconnect/focus commands, Chrome extension Side Panel install, CDP-aware skills, activity streaming. Updated command reference table, key components, env vars, source map. README.md: updated /browse description, added "Real browser mode" to What's New section. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: step-by-step Chrome extension install guide in BROWSER.md Replace terse bullet points with numbered walkthrough covering: developer mode toggle, load unpacked, macOS file picker tip (Cmd+Shift+G), pin extension, configure port, open side panel. Added troubleshooting section. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add Cmd+Shift+. tip for hidden folders in macOS file picker macOS hides folders starting with . by default. Added both shortcuts: Cmd+Shift+G (paste path directly) and Cmd+Shift+. (show hidden files). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: integrate hidden folder tips into the install flow naturally Move Cmd+Shift+G and Cmd+Shift+. tips inline with the file picker step instead of as a separate tip block after it. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: auto-load Chrome extension when $B connect launches Chrome Extension auto-loads via --load-extension flag — no manual chrome://extensions install needed. findExtensionPath() checks repo root, global install, and dev paths. Also adds bin/gstack-extension helper for manual install in regular Chrome, and rewrites BROWSER.md install docs with auto-load as primary path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: /connect-chrome skill — one command to launch Chrome with Side Panel New skill that runs $B connect, verifies the connection, guides the user to open the Side Panel, and demos the live activity feed. Extension auto-loads via --load-extension so no manual chrome://extensions install needed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use launchPersistentContext for Chrome extension loading Playwright's chromium.launch() silently ignores --load-extension. Switch to launchPersistentContext with ignoreDefaultArgs to remove --disable-extensions flag. Use bundled Chromium (real Chrome blocks unpacked extensions). Fixed port 34567 for CDP mode so the extension auto-connects. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: sync extension to DESIGN.md — amber accent, zinc neutrals, grain texture Import design system from gstack-website. Update all extension colors: green (#4ade80) → amber (#F59E0B/#FBBF24), zinc gray neutrals, grain texture overlay. Regenerate icons as amber "G" monogram on dark background. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: sidebar chat with Claude Code — icon opens side panel directly Replace popup flyout with direct side panel open on icon click. Primary UI is now a chat interface that sends messages to Claude Code via file queue. Activity/Refs tabs moved behind a debug toggle in the footer. Command bar with history, auto-poll for responses, amber design system. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: sidebar agent — Claude-powered chat backend via file queue Add /sidebar-command, /sidebar-response, and /sidebar-chat endpoints to the browse server. sidebar-agent.ts watches the command queue file, spawns claude -p with browse context for each message, and streams responses back to the sidebar chat. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove duplicate gstack pill overlay, hide crash restore bubble The addInitScript indicator and the extension's content script were both injecting bottom-right pills, causing duplicates. Remove the pill from addInitScript (extension handles it). Replace --restore-last-session with --hide-crash-restore-bubble to suppress the "Chromium didn't shut down correctly" dialog. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: state file authority — CDP server cannot be silently replaced Hardens the connect/disconnect lifecycle: - ensureServer() refuses to auto-start headless when CDP server is alive - $B connect does full cleanup: SIGTERM → 2s → SIGKILL, profile locks, state - shutdown() cleans Chromium SingletonLock/Socket/Cookie files - uncaughtException/unhandledRejection handlers do emergency cleanup This prevents the bug where a headless server overwrites the CDP server's state file, causing $B commands to hit the wrong browser. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: sidebar agent streaming events + session state management Enhance sidebar-agent.ts with: - Live streaming of claude -p events (tool_use, text, result) to sidebar - Session state file for BROWSE_STATE_FILE propagation to claude subprocess - Improved logging (stderr, exit codes, event types) - stdin.end() to prevent claude waiting for input - summarizeToolInput() with path shortening for compact sidebar display Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: sidebar chat UI — streaming events, agent status, reconnect retry Sidebar panel improvements: - Chat tab renders streaming agent events (tool_use, text, result) - Thinking dots animation while agent processes - Agent error display with styled error blocks - tryConnect() with 2s retry loop for initial connection - Debug tabs (Activity/Refs) hidden behind gear toggle - Clear chat button - Compact tool call display with path shortening Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: server-integrated sidebar agent with sessions and message queue Move the sidebar agent from a separate bun process into server.ts: - Agent spawns claude -p directly when messages arrive via /sidebar-command - In-memory chat buffer backed by per-session chat.jsonl on disk - Session manager: create, load, persist, list sessions - Message queue (cap 5) with agent status tracking (idle/processing/hung) - Stop/kill endpoints with queue dismiss support - /health now returns agent status + session info - All sidebar endpoints require Bearer auth - Agent killed on server shutdown - 120s timeout detects hung claude processes Eliminates: file-queue polling, separate sidebar-agent.ts process, stale auth tokens, state file conflicts between processes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: extension auth + token flow for server-integrated agent Update Chrome extension to use Bearer auth on all sidebar endpoints: - background.js captures auth token from /health, exposes via getToken msg - background.js sets openPanelOnActionClick for direct side panel access - sidepanel.js gets token from background, sends in all fetch headers - Health broadcasts include token so sidebar auto-authenticates - Removes popup from manifest — icon click opens side panel directly Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: self-healing sidebar — reconnect banner, state machine, copy button Sidebar UI now handles disconnection gracefully: - Connection state machine: connected → reconnecting → dead - Amber pulsing banner during reconnect (2s retry, 30 attempts) - Red "Server offline" banner with Reconnect + Copy /connect-chrome buttons - Green "Reconnected" toast that fades after 3s on successful reconnect - Copy button lets user paste /connect-chrome into any Claude Code session Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: crash handling — save session, kill agent, distinct exit codes Hardened shutdown/crash behavior: - Browser disconnect exits with code 2 (distinct from crash code 1) - emergencyCleanup kills agent subprocess and saves session state - Clean shutdown saves session before exit (chat history persists) - Clear user message on browser disconnect: "Run $B connect to reconnect" Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: worktree-per-session isolation for sidebar agent Each sidebar session gets an isolated git worktree so the agent's file operations don't conflict with the user's working directory: - createWorktree() creates detached HEAD worktree in ~/.gstack/worktrees/ - Falls back to main cwd for non-git repos or on creation failure - Handles collision cleanup from prior crashes - removeWorktree() cleans up on session switch and shutdown - worktreePath persisted in session.json Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(qa): ISSUE-001 — disconnect blocked by CDP guard in ensureServer $B disconnect was routed through ensureServer() which refused to start a headless server when a CDP state file existed. Disconnect is now handled before ensureServer() (like connect), with force-kill + cleanup fallback when the CDP server is unresponsive. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: resolve claude binary path for daemon-spawned agent The browse server runs as a daemon and may not inherit the user's shell PATH. Add findClaudeBin() that checks ~/.local/bin/claude (standard install location), which claude, and common system paths. Shows a clear error in the sidebar chat if claude CLI is not found. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: resolve claude symlinks + check Conductor bundled binary posix_spawn fails on symlinks in compiled bun binaries. Now: - Checks Conductor app's bundled binary first (not a symlink) - Scans ~/.local/share/claude/versions/ for direct versioned binaries - Uses fs.realpathSync() to resolve symlinks before spawning Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: compiled bun binary cannot posix_spawn — use external agent process Compiled bun binaries fail posix_spawn on ALL executables (even /bin/bash). The server now writes to an agent queue file, and a separate non-compiled bun process (sidebar-agent.ts) reads the queue, spawns claude, and POSTs events back via /sidebar-agent/event. Changes: - server.ts: spawnClaude writes to queue file instead of spawning directly - server.ts: new /sidebar-agent/event endpoint for agent → server relay - server.ts: fix result event field name (event.text vs event.result) - sidebar-agent.ts: rewritten to poll queue file, relay events via HTTP - cli.ts: $B connect auto-starts sidebar-agent as non-compiled bun process Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: loading spinner on sidebar open while connecting to server Shows an amber spinner with "Connecting..." when the sidebar first opens, replacing the empty state. After the first successful /sidebar-chat poll: - If chat history exists: renders it immediately - If no history: shows the welcome message Prevents the jarring empty-then-populated flash on sidebar open. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: zero-friction side panel — auto-open on install, pill is clickable Three changes to eliminate manual side panel setup: - Auto-open side panel on extension install/update (onInstalled listener) - gstack pill (bottom-right) is now clickable — opens the side panel - Pill has pointer-events: auto so clicks always register (was: none) User no longer needs to find the puzzle piece icon, pin the extension, or know the side panel exists. It opens automatically on first launch and can be re-opened by clicking the floating gstack pill. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: kill CDP naming, delete chrome-launcher.ts dead code The connectCDP() method and connectionMode: 'cdp' naming was a legacy artifact — real Chrome was tried but failed (silently blocks --load-extension), so the implementation already used Playwright's bundled Chromium via launchPersistentContext(). The naming was misleading. Changes: - Delete chrome-launcher.ts (361 LOC) — only import was in unreachable attemptReconnect() method - Delete dead attemptReconnect() and reconnecting field - Delete preExistingTabIds (was for protecting real Chrome tabs we never connect to) - Rename connectCDP() → launchHeaded() - Rename connectionMode: 'cdp' → 'headed' across all files - Replace BROWSE_CDP_URL/BROWSE_CDP_PORT env vars with BROWSE_HEADED=1 - Regenerate SKILL.md files for updated command descriptions - Move BrowserManager unit tests to browser-manager-unit.test.ts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: converge handoff into connect — extension loads on handoff Handoff now uses launchPersistentContext() with extension auto-loading, same as the connect/launchHeaded() path. This means when the agent gets stuck (2FA, CAPTCHA) and hands off to the user, the Chrome extension + side panel are available automatically. Before: handoff used chromium.launch() + newContext() — no extension After: handoff uses chromium.launchPersistentContext() — extension loads Also sets connectionMode to 'headed' and disables dialog auto-accept on handoff, matching connect behavior. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: gate sidebar chat behind --chat flag $B connect (default): headed Chromium + extension with Activity + Refs tabs only. No separate agent spawned. Clean, no confusion. $B connect --chat: same + Chat tab with standalone claude -p agent. Shows experimental banner: "Standalone mode — this is a separate agent from your workspace." Implementation: - cli.ts: parse --chat, set BROWSE_SIDEBAR_CHAT env, conditionally spawn sidebar-agent - server.ts: gate /sidebar-* routes behind chatEnabled, return 403 when disabled, include chatEnabled in /health response - sidepanel.js: applyChatEnabled() hides/shows Chat tab + banner - background.js: forward chatEnabled from health response - sidepanel.html/css: experimental banner with amber styling Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: file drop relay + $B inbox command Sidebar agent now writes structured messages to .context/sidebar-inbox/ when processing user input. The workspace agent can read these via $B inbox to see what the user reported from the browser. File drop format: .context/sidebar-inbox/{timestamp}-observation.json { type, timestamp, page: {url}, userMessage, sidebarSessionId } Atomic writes (tmp + rename) prevent partial reads. $B inbox --clear removes messages after display. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: $B watch — passive observation mode Claude enters read-only mode and captures periodic snapshots (every 5s) while the user browses. Mutation commands (click, fill, etc.) are blocked during watch. $B watch stop exits and returns a summary with the last snapshot. Requires headed mode ($B connect). This is the inverse of the scout pattern — the workspace agent watches through the browser instead of the sidebar relaying to it. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add coverage for sidebar-agent, file-drop, and watch mode 33 new tests covering: - Sidebar agent queue parsing (valid/malformed/empty JSONL) - writeToInbox file drop (directory creation, atomic writes, JSON format) - Inbox command (display, sorting, --clear, malformed file handling) - Watch mode state machine (start/stop cycles, snapshots, duration) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: TODOS cleanup + Chrome vs Chromium exploration doc - Update TODOS.md: mark CDP mode, $B watch, sidebar scout as SHIPPED - Delete dead "cross-platform CDP browser discovery" TODO - Rename dependencies from "CDP connect" to "headed mode" - Add docs/designs/CHROME_VS_CHROMIUM_EXPLORATION.md memorializing the architecture exploration and decision to use Playwright Chromium Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add Conductor Chrome sidebar integration design doc Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: sidebar-agent validates cwd before spawning claude The queue entry may reference a worktree that was cleaned up between sessions. Now falls back to process.cwd() if the path doesn't exist, preventing silent spawn failures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: gen-skill-docs resolver merge + preamble tier gate + plan file discovery The local RESOLVERS record in gen-skill-docs.ts was shadowing the imported canonical resolvers, causing stale test coverage and preamble generators to be used instead of the authoritative versions in resolvers/. Changes: - Merge imported RESOLVERS with local overrides (spread + override pattern) - Fix preamble tier gate: tier 1 skills no longer get AskUserQuestion format - Make plan file discovery host-agnostic (search multiple plan dirs) - Add missing E2E tier entries for ship/review plan completion tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: ungate sidebar agent + raise timeout to 5 minutes (v0.12.0) Sidebar chat is now always available in headed mode — no --chat flag needed. Agent tasks get 5 minutes instead of 2, enabling multi-page workflows like navigating directories and filling forms across pages. Changes: - cli.ts: remove --chat flag, always set BROWSE_SIDEBAR_CHAT=1, always spawn agent - server.ts: remove chatEnabled gate (403 response), raise AGENT_TIMEOUT_MS to 300s - sidebar-agent.ts: raise child process timeout from 120s to 300s Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: headed mode + sidebar agent documentation (v0.12.0) - README: sidebar agent section, personal automation example (school parent portal), two auth paths (manual login + cookie import), DevTools MCP mention - BROWSER.md: sidebar agent section with usage, timeout, session isolation, authentication, and random delay documentation - connect-chrome template: add sidebar chat onboarding step - CHANGELOG: v0.12.0 entry covering headed mode, sidebar agent, extension - VERSION: bump to 0.12.0.0 - TODOS: Chrome DevTools MCP integration as P0 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: regenerate SKILL.md files Generated from updated templates + resolver merge. Key changes: - Tier 1 skills no longer include AskUserQuestion format section - Ship/review skills now include coverage gate with thresholds - Connect-chrome skill includes sidebar chat onboarding step - Plan file discovery uses host-agnostic paths Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: regenerate Codex connect-chrome skill Updated preamble with proactive prompt and sidebar chat onboarding step. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: network idle, state persistence, iframe support, chain pipe format (v0.12.1.0) (#516) * feat: network idle detection + chain pipe format - Upgrade click/fill/select from domcontentloaded to networkidle wait (2s timeout, best-effort). Catches XHR/fetch triggered by interactions. - Add pipe-delimited format to chain as JSON fallback: $B chain 'goto url | click @e5 | snapshot -ic' - Add post-loop networkidle wait in chain when last command was a write. - Frame-aware: commands use target (getActiveFrameOrPage) for locator ops, page-only ops (goto/back/forward/reload) guard against frame context. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: $B state save/load + $B frame — new browse commands - state save/load: persist cookies + URLs to .gstack/browse-states/{name}.json File perms 0o600, name sanitized to [a-zA-Z0-9_-]. V1 skips localStorage (breaks on load-before-navigate). Load replaces session via closeAllPages(). - frame: switch command context to iframe via CSS selector, @ref, --name, or --url. 'frame main' returns to main frame. Execution target abstraction (getActiveFrameOrPage) across read-commands, snapshot, and write-commands. - Frame context cleared on tab switch, navigation, resume, and handoff. - Snapshot shows [Context: iframe src="..."] header when in frame. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add tests for network idle, chain pipe format, state, and frame - Network idle: click on fetch button waits for XHR, static click is fast - Chain pipe: pipe-delimited commands, quoted args, JSON still works - State: save/load round-trip, name sanitization, missing state error - Frame: switch to iframe + back, snapshot context header, fill in frame, goto-in-frame guard, usage error New fixtures: network-idle.html (fetch + static buttons), iframe.html (srcdoc) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: review fixes — iframe ref scoping, detached frame recovery, state validation - snapshot.ts: ref locators, cursor-interactive scan, and cursor locator now use target (frame-aware) instead of page — fixes @ref clicking in iframes - browser-manager.ts: getActiveFrameOrPage auto-recovers from detached frames via isDetached() check - meta-commands.ts: state load resets activeFrame, elementHandle disposed after contentFrame(), state file schema validation (cookies + pages arrays), filter empty pipe segments in chain tokenizer - write-commands.ts: upload command uses target.locator() for frame support Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: regenerate SKILL.md files + rebuild binary Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.12.1.0) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1550 lines
60 KiB
TypeScript
1550 lines
60 KiB
TypeScript
import { describe, test, expect } from 'bun:test';
|
||
import { validateSkill, extractRemoteSlugPatterns, extractWeightsFromTable } from './helpers/skill-parser';
|
||
import { ALL_COMMANDS, COMMAND_DESCRIPTIONS, READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from '../browse/src/commands';
|
||
import { SNAPSHOT_FLAGS } from '../browse/src/snapshot';
|
||
import * as fs from 'fs';
|
||
import * as path from 'path';
|
||
|
||
const ROOT = path.resolve(import.meta.dir, '..');
|
||
|
||
describe('SKILL.md command validation', () => {
|
||
test('all $B commands in SKILL.md are valid browse commands', () => {
|
||
const result = validateSkill(path.join(ROOT, 'SKILL.md'));
|
||
expect(result.invalid).toHaveLength(0);
|
||
expect(result.valid.length).toBeGreaterThan(0);
|
||
});
|
||
|
||
test('all snapshot flags in SKILL.md are valid', () => {
|
||
const result = validateSkill(path.join(ROOT, 'SKILL.md'));
|
||
expect(result.snapshotFlagErrors).toHaveLength(0);
|
||
});
|
||
|
||
test('all $B commands in browse/SKILL.md are valid browse commands', () => {
|
||
const result = validateSkill(path.join(ROOT, 'browse', 'SKILL.md'));
|
||
expect(result.invalid).toHaveLength(0);
|
||
expect(result.valid.length).toBeGreaterThan(0);
|
||
});
|
||
|
||
test('all snapshot flags in browse/SKILL.md are valid', () => {
|
||
const result = validateSkill(path.join(ROOT, 'browse', 'SKILL.md'));
|
||
expect(result.snapshotFlagErrors).toHaveLength(0);
|
||
});
|
||
|
||
test('all $B commands in qa/SKILL.md are valid browse commands', () => {
|
||
const qaSkill = path.join(ROOT, 'qa', 'SKILL.md');
|
||
if (!fs.existsSync(qaSkill)) return; // skip if missing
|
||
const result = validateSkill(qaSkill);
|
||
expect(result.invalid).toHaveLength(0);
|
||
});
|
||
|
||
test('all snapshot flags in qa/SKILL.md are valid', () => {
|
||
const qaSkill = path.join(ROOT, 'qa', 'SKILL.md');
|
||
if (!fs.existsSync(qaSkill)) return;
|
||
const result = validateSkill(qaSkill);
|
||
expect(result.snapshotFlagErrors).toHaveLength(0);
|
||
});
|
||
|
||
test('all $B commands in qa-only/SKILL.md are valid browse commands', () => {
|
||
const qaOnlySkill = path.join(ROOT, 'qa-only', 'SKILL.md');
|
||
if (!fs.existsSync(qaOnlySkill)) return;
|
||
const result = validateSkill(qaOnlySkill);
|
||
expect(result.invalid).toHaveLength(0);
|
||
});
|
||
|
||
test('all snapshot flags in qa-only/SKILL.md are valid', () => {
|
||
const qaOnlySkill = path.join(ROOT, 'qa-only', 'SKILL.md');
|
||
if (!fs.existsSync(qaOnlySkill)) return;
|
||
const result = validateSkill(qaOnlySkill);
|
||
expect(result.snapshotFlagErrors).toHaveLength(0);
|
||
});
|
||
|
||
test('all $B commands in plan-design-review/SKILL.md are valid browse commands', () => {
|
||
const skill = path.join(ROOT, 'plan-design-review', 'SKILL.md');
|
||
if (!fs.existsSync(skill)) return;
|
||
const result = validateSkill(skill);
|
||
expect(result.invalid).toHaveLength(0);
|
||
});
|
||
|
||
test('all snapshot flags in plan-design-review/SKILL.md are valid', () => {
|
||
const skill = path.join(ROOT, 'plan-design-review', 'SKILL.md');
|
||
if (!fs.existsSync(skill)) return;
|
||
const result = validateSkill(skill);
|
||
expect(result.snapshotFlagErrors).toHaveLength(0);
|
||
});
|
||
|
||
test('all $B commands in design-review/SKILL.md are valid browse commands', () => {
|
||
const skill = path.join(ROOT, 'design-review', 'SKILL.md');
|
||
if (!fs.existsSync(skill)) return;
|
||
const result = validateSkill(skill);
|
||
expect(result.invalid).toHaveLength(0);
|
||
});
|
||
|
||
test('all snapshot flags in design-review/SKILL.md are valid', () => {
|
||
const skill = path.join(ROOT, 'design-review', 'SKILL.md');
|
||
if (!fs.existsSync(skill)) return;
|
||
const result = validateSkill(skill);
|
||
expect(result.snapshotFlagErrors).toHaveLength(0);
|
||
});
|
||
|
||
test('all $B commands in design-consultation/SKILL.md are valid browse commands', () => {
|
||
const skill = path.join(ROOT, 'design-consultation', 'SKILL.md');
|
||
if (!fs.existsSync(skill)) return;
|
||
const result = validateSkill(skill);
|
||
expect(result.invalid).toHaveLength(0);
|
||
});
|
||
|
||
test('all snapshot flags in design-consultation/SKILL.md are valid', () => {
|
||
const skill = path.join(ROOT, 'design-consultation', 'SKILL.md');
|
||
if (!fs.existsSync(skill)) return;
|
||
const result = validateSkill(skill);
|
||
expect(result.snapshotFlagErrors).toHaveLength(0);
|
||
});
|
||
|
||
test('all $B commands in autoplan/SKILL.md are valid browse commands', () => {
|
||
const skill = path.join(ROOT, 'autoplan', 'SKILL.md');
|
||
if (!fs.existsSync(skill)) return;
|
||
const result = validateSkill(skill);
|
||
expect(result.invalid).toHaveLength(0);
|
||
});
|
||
|
||
test('all snapshot flags in autoplan/SKILL.md are valid', () => {
|
||
const skill = path.join(ROOT, 'autoplan', 'SKILL.md');
|
||
if (!fs.existsSync(skill)) return;
|
||
const result = validateSkill(skill);
|
||
expect(result.snapshotFlagErrors).toHaveLength(0);
|
||
});
|
||
});
|
||
|
||
describe('Command registry consistency', () => {
|
||
test('COMMAND_DESCRIPTIONS covers all commands in sets', () => {
|
||
const allCmds = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]);
|
||
const descKeys = new Set(Object.keys(COMMAND_DESCRIPTIONS));
|
||
for (const cmd of allCmds) {
|
||
expect(descKeys.has(cmd)).toBe(true);
|
||
}
|
||
});
|
||
|
||
test('COMMAND_DESCRIPTIONS has no extra commands not in sets', () => {
|
||
const allCmds = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]);
|
||
for (const key of Object.keys(COMMAND_DESCRIPTIONS)) {
|
||
expect(allCmds.has(key)).toBe(true);
|
||
}
|
||
});
|
||
|
||
test('ALL_COMMANDS matches union of all sets', () => {
|
||
const union = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]);
|
||
expect(ALL_COMMANDS.size).toBe(union.size);
|
||
for (const cmd of union) {
|
||
expect(ALL_COMMANDS.has(cmd)).toBe(true);
|
||
}
|
||
});
|
||
|
||
test('SNAPSHOT_FLAGS option keys are valid SnapshotOptions fields', () => {
|
||
const validKeys = new Set([
|
||
'interactive', 'compact', 'depth', 'selector',
|
||
'diff', 'annotate', 'outputPath', 'cursorInteractive',
|
||
]);
|
||
for (const flag of SNAPSHOT_FLAGS) {
|
||
expect(validKeys.has(flag.optionKey)).toBe(true);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('Usage string consistency', () => {
|
||
// Normalize a usage string to its structural skeleton for comparison.
|
||
// Replaces <param-names> with <>, [optional] with [], strips parenthetical hints.
|
||
// This catches format mismatches (e.g., <name>:<value> vs <name> <value>)
|
||
// without tripping on abbreviation differences (e.g., <sel> vs <selector>).
|
||
function skeleton(usage: string): string {
|
||
return usage
|
||
.replace(/\(.*?\)/g, '') // strip parenthetical hints like (e.g., Enter, Tab)
|
||
.replace(/<[^>]*>/g, '<>') // normalize <param-name> → <>
|
||
.replace(/\[[^\]]*\]/g, '[]') // normalize [optional] → []
|
||
.replace(/\s+/g, ' ') // collapse whitespace
|
||
.trim();
|
||
}
|
||
|
||
// Cross-check Usage: patterns in implementation against COMMAND_DESCRIPTIONS
|
||
test('implementation Usage: structural format matches COMMAND_DESCRIPTIONS', () => {
|
||
const implFiles = [
|
||
path.join(ROOT, 'browse', 'src', 'write-commands.ts'),
|
||
path.join(ROOT, 'browse', 'src', 'read-commands.ts'),
|
||
path.join(ROOT, 'browse', 'src', 'meta-commands.ts'),
|
||
];
|
||
|
||
// Extract "Usage: browse <pattern>" from throw new Error(...) calls
|
||
const usagePattern = /throw new Error\(['"`]Usage:\s*browse\s+(.+?)['"`]\)/g;
|
||
const implUsages = new Map<string, string>();
|
||
|
||
for (const file of implFiles) {
|
||
const content = fs.readFileSync(file, 'utf-8');
|
||
let match;
|
||
while ((match = usagePattern.exec(content)) !== null) {
|
||
const usage = match[1].split('\\n')[0].trim();
|
||
const cmd = usage.split(/\s/)[0];
|
||
implUsages.set(cmd, usage);
|
||
}
|
||
}
|
||
|
||
// Compare structural skeletons
|
||
const mismatches: string[] = [];
|
||
for (const [cmd, implUsage] of implUsages) {
|
||
const desc = COMMAND_DESCRIPTIONS[cmd];
|
||
if (!desc) continue;
|
||
if (!desc.usage) continue;
|
||
const descSkel = skeleton(desc.usage);
|
||
const implSkel = skeleton(implUsage);
|
||
if (descSkel !== implSkel) {
|
||
mismatches.push(`${cmd}: docs "${desc.usage}" (${descSkel}) vs impl "${implUsage}" (${implSkel})`);
|
||
}
|
||
}
|
||
|
||
expect(mismatches).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('Generated SKILL.md freshness', () => {
|
||
test('no unresolved {{placeholders}} in generated SKILL.md', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
|
||
const unresolved = content.match(/\{\{\w+\}\}/g);
|
||
expect(unresolved).toBeNull();
|
||
});
|
||
|
||
test('no unresolved {{placeholders}} in generated browse/SKILL.md', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'browse', 'SKILL.md'), 'utf-8');
|
||
const unresolved = content.match(/\{\{\w+\}\}/g);
|
||
expect(unresolved).toBeNull();
|
||
});
|
||
|
||
test('generated SKILL.md has AUTO-GENERATED header', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('AUTO-GENERATED');
|
||
});
|
||
});
|
||
|
||
// --- Update check preamble validation ---
|
||
|
||
describe('Update check preamble', () => {
|
||
const skillsWithUpdateCheck = [
|
||
'SKILL.md', 'browse/SKILL.md', 'qa/SKILL.md',
|
||
'qa-only/SKILL.md',
|
||
'setup-browser-cookies/SKILL.md',
|
||
'ship/SKILL.md', 'review/SKILL.md',
|
||
'plan-ceo-review/SKILL.md', 'plan-eng-review/SKILL.md',
|
||
'retro/SKILL.md',
|
||
'office-hours/SKILL.md', 'investigate/SKILL.md',
|
||
'plan-design-review/SKILL.md',
|
||
'design-review/SKILL.md',
|
||
'design-consultation/SKILL.md',
|
||
'document-release/SKILL.md',
|
||
'canary/SKILL.md',
|
||
'benchmark/SKILL.md',
|
||
'land-and-deploy/SKILL.md',
|
||
'setup-deploy/SKILL.md',
|
||
'cso/SKILL.md',
|
||
];
|
||
|
||
for (const skill of skillsWithUpdateCheck) {
|
||
test(`${skill} update check line ends with || true`, () => {
|
||
const content = fs.readFileSync(path.join(ROOT, skill), 'utf-8');
|
||
// The second line of the bash block must end with || true
|
||
// to avoid exit code 1 when _UPD is empty (up to date)
|
||
const match = content.match(/\[ -n "\$_UPD" \].*$/m);
|
||
expect(match).not.toBeNull();
|
||
expect(match![0]).toContain('|| true');
|
||
});
|
||
}
|
||
|
||
test('all skills with update check are generated from .tmpl', () => {
|
||
for (const skill of skillsWithUpdateCheck) {
|
||
const tmplPath = path.join(ROOT, skill + '.tmpl');
|
||
expect(fs.existsSync(tmplPath)).toBe(true);
|
||
}
|
||
});
|
||
|
||
test('update check bash block exits 0 when up to date', () => {
|
||
// Simulate the exact preamble command from SKILL.md
|
||
const result = Bun.spawnSync(['bash', '-c',
|
||
'_UPD=$(echo "" || true); [ -n "$_UPD" ] && echo "$_UPD" || true'
|
||
], { stdout: 'pipe', stderr: 'pipe' });
|
||
expect(result.exitCode).toBe(0);
|
||
});
|
||
|
||
test('update check bash block exits 0 when upgrade available', () => {
|
||
const result = Bun.spawnSync(['bash', '-c',
|
||
'_UPD=$(echo "UPGRADE_AVAILABLE 0.3.3 0.4.0" || true); [ -n "$_UPD" ] && echo "$_UPD" || true'
|
||
], { stdout: 'pipe', stderr: 'pipe' });
|
||
expect(result.exitCode).toBe(0);
|
||
expect(result.stdout.toString().trim()).toBe('UPGRADE_AVAILABLE 0.3.3 0.4.0');
|
||
});
|
||
});
|
||
|
||
// --- Part 7: Cross-skill path consistency (A1) ---
|
||
|
||
describe('Cross-skill path consistency', () => {
|
||
test('REMOTE_SLUG derivation pattern is identical across files that use it', () => {
|
||
const patterns = extractRemoteSlugPatterns(ROOT, ['qa', 'review']);
|
||
const allPatterns: string[] = [];
|
||
|
||
for (const [, filePatterns] of patterns) {
|
||
allPatterns.push(...filePatterns);
|
||
}
|
||
|
||
// Should find at least 2 occurrences (qa/SKILL.md + review/greptile-triage.md)
|
||
expect(allPatterns.length).toBeGreaterThanOrEqual(2);
|
||
|
||
// All occurrences must be character-for-character identical
|
||
const unique = new Set(allPatterns);
|
||
if (unique.size > 1) {
|
||
const variants = Array.from(unique);
|
||
throw new Error(
|
||
`REMOTE_SLUG pattern differs across files:\n` +
|
||
variants.map((v, i) => ` ${i + 1}: ${v}`).join('\n')
|
||
);
|
||
}
|
||
});
|
||
|
||
test('all greptile-history write references specify both per-project and global paths', () => {
|
||
const filesToCheck = [
|
||
'review/SKILL.md',
|
||
'ship/SKILL.md',
|
||
'review/greptile-triage.md',
|
||
];
|
||
|
||
for (const file of filesToCheck) {
|
||
const filePath = path.join(ROOT, file);
|
||
if (!fs.existsSync(filePath)) continue;
|
||
const content = fs.readFileSync(filePath, 'utf-8');
|
||
|
||
const hasBoth = (content.includes('per-project') && content.includes('global')) ||
|
||
(content.includes('$REMOTE_SLUG/greptile-history') && content.includes('~/.gstack/greptile-history'));
|
||
|
||
expect(hasBoth).toBe(true);
|
||
}
|
||
});
|
||
|
||
test('greptile-triage.md contains both project and global history paths', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'review', 'greptile-triage.md'), 'utf-8');
|
||
expect(content).toContain('$REMOTE_SLUG/greptile-history.md');
|
||
expect(content).toContain('~/.gstack/greptile-history.md');
|
||
});
|
||
|
||
test('retro/SKILL.md reads global greptile-history (not per-project)', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'retro', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('~/.gstack/greptile-history.md');
|
||
// Should NOT reference per-project path for reads
|
||
expect(content).not.toContain('$REMOTE_SLUG/greptile-history.md');
|
||
});
|
||
});
|
||
|
||
// --- Part 7: QA skill structure validation (A2) ---
|
||
|
||
describe('QA skill structure validation', () => {
|
||
const qaContent = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md'), 'utf-8');
|
||
|
||
test('qa/SKILL.md has all 11 phases', () => {
|
||
const phases = [
|
||
'Phase 1', 'Initialize',
|
||
'Phase 2', 'Authenticate',
|
||
'Phase 3', 'Orient',
|
||
'Phase 4', 'Explore',
|
||
'Phase 5', 'Document',
|
||
'Phase 6', 'Wrap Up',
|
||
'Phase 7', 'Triage',
|
||
'Phase 8', 'Fix Loop',
|
||
'Phase 9', 'Final QA',
|
||
'Phase 10', 'Report',
|
||
'Phase 11', 'TODOS',
|
||
];
|
||
for (const phase of phases) {
|
||
expect(qaContent).toContain(phase);
|
||
}
|
||
});
|
||
|
||
test('has all four QA modes defined', () => {
|
||
const modes = [
|
||
'Diff-aware',
|
||
'Full',
|
||
'Quick',
|
||
'Regression',
|
||
];
|
||
for (const mode of modes) {
|
||
expect(qaContent).toContain(mode);
|
||
}
|
||
|
||
// Mode triggers/flags
|
||
expect(qaContent).toContain('--quick');
|
||
expect(qaContent).toContain('--regression');
|
||
});
|
||
|
||
test('has all three tiers defined', () => {
|
||
const tiers = ['Quick', 'Standard', 'Exhaustive'];
|
||
for (const tier of tiers) {
|
||
expect(qaContent).toContain(tier);
|
||
}
|
||
});
|
||
|
||
test('health score weights sum to 100%', () => {
|
||
const weights = extractWeightsFromTable(qaContent);
|
||
expect(weights.size).toBeGreaterThan(0);
|
||
|
||
let sum = 0;
|
||
for (const pct of weights.values()) {
|
||
sum += pct;
|
||
}
|
||
expect(sum).toBe(100);
|
||
});
|
||
|
||
test('health score has all 8 categories', () => {
|
||
const weights = extractWeightsFromTable(qaContent);
|
||
const expectedCategories = [
|
||
'Console', 'Links', 'Visual', 'Functional',
|
||
'UX', 'Performance', 'Content', 'Accessibility',
|
||
];
|
||
for (const cat of expectedCategories) {
|
||
expect(weights.has(cat)).toBe(true);
|
||
}
|
||
expect(weights.size).toBe(8);
|
||
});
|
||
|
||
test('has four mode definitions (Diff-aware/Full/Quick/Regression)', () => {
|
||
expect(qaContent).toContain('### Diff-aware');
|
||
expect(qaContent).toContain('### Full');
|
||
expect(qaContent).toContain('### Quick');
|
||
expect(qaContent).toContain('### Regression');
|
||
});
|
||
|
||
test('output structure references report directory layout', () => {
|
||
expect(qaContent).toContain('qa-report-');
|
||
expect(qaContent).toContain('baseline.json');
|
||
expect(qaContent).toContain('screenshots/');
|
||
expect(qaContent).toContain('.gstack/qa-reports/');
|
||
});
|
||
});
|
||
|
||
// --- Part 7: Greptile history format consistency (A3) ---
|
||
|
||
describe('Greptile history format consistency', () => {
|
||
test('greptile-triage.md defines the canonical history format', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'review', 'greptile-triage.md'), 'utf-8');
|
||
expect(content).toContain('<YYYY-MM-DD>');
|
||
expect(content).toContain('<owner/repo>');
|
||
expect(content).toContain('<type');
|
||
expect(content).toContain('<file-pattern>');
|
||
expect(content).toContain('<category>');
|
||
});
|
||
|
||
test('review/SKILL.md and ship/SKILL.md both reference greptile-triage.md for write details', () => {
|
||
const reviewContent = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
|
||
const shipContent = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
|
||
|
||
expect(reviewContent.toLowerCase()).toContain('greptile-triage.md');
|
||
expect(shipContent.toLowerCase()).toContain('greptile-triage.md');
|
||
});
|
||
|
||
test('greptile-triage.md defines all 9 valid categories', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'review', 'greptile-triage.md'), 'utf-8');
|
||
const categories = [
|
||
'race-condition', 'null-check', 'error-handling', 'style',
|
||
'type-safety', 'security', 'performance', 'correctness', 'other',
|
||
];
|
||
for (const cat of categories) {
|
||
expect(content).toContain(cat);
|
||
}
|
||
});
|
||
});
|
||
|
||
// --- Hardcoded branch name detection in templates ---
|
||
|
||
describe('No hardcoded branch names in SKILL templates', () => {
|
||
const tmplFiles = [
|
||
'ship/SKILL.md.tmpl',
|
||
'review/SKILL.md.tmpl',
|
||
'qa/SKILL.md.tmpl',
|
||
'plan-ceo-review/SKILL.md.tmpl',
|
||
'retro/SKILL.md.tmpl',
|
||
'document-release/SKILL.md.tmpl',
|
||
'plan-eng-review/SKILL.md.tmpl',
|
||
'plan-design-review/SKILL.md.tmpl',
|
||
'codex/SKILL.md.tmpl',
|
||
];
|
||
|
||
// Patterns that indicate hardcoded 'main' in git commands
|
||
const gitMainPatterns = [
|
||
/\bgit\s+diff\s+(?:origin\/)?main\b/,
|
||
/\bgit\s+log\s+(?:origin\/)?main\b/,
|
||
/\bgit\s+fetch\s+origin\s+main\b/,
|
||
/\bgit\s+merge\s+origin\/main\b/,
|
||
/\borigin\/main\b/,
|
||
];
|
||
|
||
// Lines that are allowed to mention 'main' (fallback logic, prose)
|
||
const allowlist = [
|
||
/fall\s*back\s+to\s+`main`/i,
|
||
/fall\s*back\s+to\s+`?main`?/i,
|
||
/typically\s+`?main`?/i,
|
||
/If\s+on\s+`main`/i, // old pattern — should not exist
|
||
];
|
||
|
||
for (const tmplFile of tmplFiles) {
|
||
test(`${tmplFile} has no hardcoded 'main' in git commands`, () => {
|
||
const filePath = path.join(ROOT, tmplFile);
|
||
if (!fs.existsSync(filePath)) return;
|
||
const lines = fs.readFileSync(filePath, 'utf-8').split('\n');
|
||
const violations: string[] = [];
|
||
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const line = lines[i];
|
||
const isAllowlisted = allowlist.some(p => p.test(line));
|
||
if (isAllowlisted) continue;
|
||
|
||
for (const pattern of gitMainPatterns) {
|
||
if (pattern.test(line)) {
|
||
violations.push(`Line ${i + 1}: ${line.trim()}`);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (violations.length > 0) {
|
||
throw new Error(
|
||
`${tmplFile} has hardcoded 'main' in git commands:\n` +
|
||
violations.map(v => ` ${v}`).join('\n')
|
||
);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
// --- Part 7b: TODOS-format.md reference consistency ---
|
||
|
||
describe('TODOS-format.md reference consistency', () => {
|
||
test('review/TODOS-format.md exists and defines canonical format', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'review', 'TODOS-format.md'), 'utf-8');
|
||
expect(content).toContain('**What:**');
|
||
expect(content).toContain('**Why:**');
|
||
expect(content).toContain('**Priority:**');
|
||
expect(content).toContain('**Effort:**');
|
||
expect(content).toContain('## Completed');
|
||
});
|
||
|
||
test('skills that write TODOs reference TODOS-format.md', () => {
|
||
const shipContent = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
|
||
const ceoPlanContent = fs.readFileSync(path.join(ROOT, 'plan-ceo-review', 'SKILL.md'), 'utf-8');
|
||
const engPlanContent = fs.readFileSync(path.join(ROOT, 'plan-eng-review', 'SKILL.md'), 'utf-8');
|
||
|
||
expect(shipContent).toContain('TODOS-format.md');
|
||
expect(ceoPlanContent).toContain('TODOS-format.md');
|
||
expect(engPlanContent).toContain('TODOS-format.md');
|
||
});
|
||
});
|
||
|
||
// --- v0.4.1 feature coverage: RECOMMENDATION format, session awareness, enum completeness ---
|
||
|
||
describe('v0.4.1 preamble features', () => {
|
||
// Tier 1 skills have core preamble only (no AskUserQuestion format)
|
||
const tier1Skills = ['SKILL.md', 'browse/SKILL.md', 'setup-browser-cookies/SKILL.md', 'benchmark/SKILL.md'];
|
||
|
||
// Tier 2+ skills have AskUserQuestion format with RECOMMENDATION
|
||
const tier2PlusSkills = [
|
||
'qa/SKILL.md', 'qa-only/SKILL.md',
|
||
'ship/SKILL.md', 'review/SKILL.md',
|
||
'plan-ceo-review/SKILL.md', 'plan-eng-review/SKILL.md',
|
||
'retro/SKILL.md',
|
||
'office-hours/SKILL.md', 'investigate/SKILL.md',
|
||
'plan-design-review/SKILL.md',
|
||
'design-review/SKILL.md',
|
||
'design-consultation/SKILL.md',
|
||
'document-release/SKILL.md',
|
||
'canary/SKILL.md',
|
||
'land-and-deploy/SKILL.md',
|
||
'setup-deploy/SKILL.md',
|
||
'cso/SKILL.md',
|
||
];
|
||
|
||
const skillsWithPreamble = [...tier1Skills, ...tier2PlusSkills];
|
||
|
||
for (const skill of tier2PlusSkills) {
|
||
test(`${skill} contains RECOMMENDATION format`, () => {
|
||
const content = fs.readFileSync(path.join(ROOT, skill), 'utf-8');
|
||
expect(content).toContain('RECOMMENDATION: Choose');
|
||
expect(content).toContain('AskUserQuestion');
|
||
});
|
||
}
|
||
|
||
for (const skill of skillsWithPreamble) {
|
||
test(`${skill} contains session awareness`, () => {
|
||
const content = fs.readFileSync(path.join(ROOT, skill), 'utf-8');
|
||
expect(content).toContain('_SESSIONS');
|
||
});
|
||
}
|
||
|
||
for (const skill of skillsWithPreamble) {
|
||
test(`${skill} contains escalation protocol`, () => {
|
||
const content = fs.readFileSync(path.join(ROOT, skill), 'utf-8');
|
||
expect(content).toContain('DONE_WITH_CONCERNS');
|
||
expect(content).toContain('BLOCKED');
|
||
expect(content).toContain('NEEDS_CONTEXT');
|
||
});
|
||
}
|
||
});
|
||
|
||
// --- Structural tests for new skills ---
|
||
|
||
describe('office-hours skill structure', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'office-hours', 'SKILL.md'), 'utf-8');
|
||
|
||
// Original structural assertions
|
||
for (const section of ['Phase 1', 'Phase 2', 'Phase 3', 'Phase 4', 'Phase 5', 'Phase 6',
|
||
'Design Doc', 'Supersedes', 'APPROVED', 'Premise Challenge',
|
||
'Alternatives', 'Smart-skip']) {
|
||
test(`contains ${section}`, () => expect(content).toContain(section));
|
||
}
|
||
|
||
// Dual-mode structure
|
||
for (const section of ['Startup mode', 'Builder mode']) {
|
||
test(`contains ${section}`, () => expect(content).toContain(section));
|
||
}
|
||
|
||
// Mode detection question
|
||
test('contains explicit mode detection question', () => {
|
||
expect(content).toContain("what's your goal");
|
||
});
|
||
|
||
// Six forcing questions (startup mode)
|
||
for (const question of ['Demand Reality', 'Status Quo', 'Desperate Specificity',
|
||
'Narrowest Wedge', 'Observation & Surprise', 'Future-Fit']) {
|
||
test(`contains forcing question: ${question}`, () => expect(content).toContain(question));
|
||
}
|
||
|
||
// Builder mode questions
|
||
test('contains builder brainstorming questions', () => {
|
||
expect(content).toContain('coolest version');
|
||
expect(content).toContain('delightful');
|
||
});
|
||
|
||
// Intrapreneurship adaptation
|
||
test('contains intrapreneurship adaptation', () => {
|
||
expect(content).toContain('Intrapreneurship');
|
||
});
|
||
|
||
// YC founder discovery engine
|
||
test('contains YC apply CTA with ref tracking', () => {
|
||
expect(content).toContain('ycombinator.com/apply?ref=gstack');
|
||
});
|
||
|
||
test('contains "What I noticed" design doc section', () => {
|
||
expect(content).toContain('What I noticed about how you think');
|
||
});
|
||
|
||
test('contains golden age framing', () => {
|
||
expect(content).toContain('golden age');
|
||
});
|
||
|
||
test('contains Garry Tan personal plea', () => {
|
||
expect(content).toContain('Garry Tan, the creator of GStack');
|
||
});
|
||
|
||
test('contains founder signal synthesis phase', () => {
|
||
expect(content).toContain('Founder Signal Synthesis');
|
||
});
|
||
|
||
test('contains three-tier decision rubric', () => {
|
||
expect(content).toContain('Top tier');
|
||
expect(content).toContain('Middle tier');
|
||
expect(content).toContain('Base tier');
|
||
});
|
||
|
||
test('contains anti-slop examples', () => {
|
||
expect(content).toContain('GOOD:');
|
||
expect(content).toContain('BAD:');
|
||
});
|
||
|
||
test('contains "One more thing" transition beat', () => {
|
||
expect(content).toContain('One more thing');
|
||
});
|
||
|
||
// Operating principles per mode
|
||
test('contains startup operating principles', () => {
|
||
expect(content).toContain('Specificity is the only currency');
|
||
});
|
||
|
||
test('contains builder operating principles', () => {
|
||
expect(content).toContain('Delight is the currency');
|
||
});
|
||
|
||
// Spec Review Loop (Phase 5.5)
|
||
test('contains spec review loop', () => {
|
||
expect(content).toContain('Spec Review Loop');
|
||
});
|
||
|
||
test('contains adversarial review dimensions', () => {
|
||
for (const dim of ['Completeness', 'Consistency', 'Clarity', 'Scope', 'Feasibility']) {
|
||
expect(content).toContain(dim);
|
||
}
|
||
});
|
||
|
||
test('contains subagent dispatch instruction', () => {
|
||
expect(content).toMatch(/Agent.*tool|subagent/i);
|
||
});
|
||
|
||
test('contains max 3 iterations', () => {
|
||
expect(content).toMatch(/3.*iteration|maximum.*3/i);
|
||
});
|
||
|
||
test('contains quality score', () => {
|
||
expect(content).toContain('quality score');
|
||
});
|
||
|
||
test('contains spec review metrics path', () => {
|
||
expect(content).toContain('spec-review.jsonl');
|
||
});
|
||
|
||
test('contains convergence guard', () => {
|
||
expect(content).toMatch(/convergence/i);
|
||
});
|
||
|
||
// Visual Sketch (Phase 4.5)
|
||
test('contains visual sketch section', () => {
|
||
expect(content).toContain('Visual Sketch');
|
||
});
|
||
|
||
test('contains wireframe generation', () => {
|
||
expect(content).toMatch(/wireframe|sketch/i);
|
||
});
|
||
|
||
test('contains DESIGN.md awareness', () => {
|
||
expect(content).toContain('DESIGN.md');
|
||
});
|
||
|
||
test('contains browse rendering', () => {
|
||
expect(content).toContain('$B goto');
|
||
expect(content).toContain('$B screenshot');
|
||
});
|
||
|
||
test('contains rough aesthetic instruction', () => {
|
||
expect(content).toMatch(/rough|hand-drawn/i);
|
||
});
|
||
});
|
||
|
||
describe('investigate skill structure', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'investigate', 'SKILL.md'), 'utf-8');
|
||
for (const section of ['Iron Law', 'Root Cause', 'Pattern Analysis', 'Hypothesis',
|
||
'DEBUG REPORT', '3-strike', 'BLOCKED']) {
|
||
test(`contains ${section}`, () => expect(content).toContain(section));
|
||
}
|
||
});
|
||
|
||
// --- Contributor mode preamble structure validation ---
|
||
|
||
describe('Contributor mode preamble structure', () => {
|
||
const skillsWithPreamble = [
|
||
'SKILL.md', 'browse/SKILL.md', 'qa/SKILL.md',
|
||
'qa-only/SKILL.md',
|
||
'setup-browser-cookies/SKILL.md',
|
||
'ship/SKILL.md', 'review/SKILL.md',
|
||
'plan-ceo-review/SKILL.md', 'plan-eng-review/SKILL.md',
|
||
'retro/SKILL.md',
|
||
'plan-design-review/SKILL.md',
|
||
'design-review/SKILL.md',
|
||
'design-consultation/SKILL.md',
|
||
'document-release/SKILL.md',
|
||
'canary/SKILL.md',
|
||
'benchmark/SKILL.md',
|
||
'land-and-deploy/SKILL.md',
|
||
'setup-deploy/SKILL.md',
|
||
];
|
||
|
||
for (const skill of skillsWithPreamble) {
|
||
test(`${skill} has 0-10 rating in contributor mode`, () => {
|
||
const content = fs.readFileSync(path.join(ROOT, skill), 'utf-8');
|
||
expect(content).toContain('0-10');
|
||
expect(content).toContain('Rating');
|
||
});
|
||
|
||
test(`${skill} has "what would make this a 10" field`, () => {
|
||
const content = fs.readFileSync(path.join(ROOT, skill), 'utf-8');
|
||
expect(content).toContain('What would make this a 10');
|
||
});
|
||
|
||
test(`${skill} uses periodic reflection (not per-command)`, () => {
|
||
const content = fs.readFileSync(path.join(ROOT, skill), 'utf-8');
|
||
expect(content).toContain('workflow step');
|
||
expect(content).not.toContain('After you use gstack-provided CLIs');
|
||
});
|
||
}
|
||
});
|
||
|
||
describe('Enum & Value Completeness in review checklist', () => {
|
||
const checklist = fs.readFileSync(path.join(ROOT, 'review', 'checklist.md'), 'utf-8');
|
||
|
||
test('checklist has Enum & Value Completeness section', () => {
|
||
expect(checklist).toContain('Enum & Value Completeness');
|
||
});
|
||
|
||
test('Enum & Value Completeness is classified as CRITICAL', () => {
|
||
// It should appear under Pass 1 — CRITICAL, not Pass 2
|
||
const pass1Start = checklist.indexOf('### Pass 1');
|
||
const pass2Start = checklist.indexOf('### Pass 2');
|
||
const enumStart = checklist.indexOf('Enum & Value Completeness');
|
||
expect(enumStart).toBeGreaterThan(pass1Start);
|
||
expect(enumStart).toBeLessThan(pass2Start);
|
||
});
|
||
|
||
test('Enum & Value Completeness mentions tracing through consumers', () => {
|
||
expect(checklist).toContain('Trace it through every consumer');
|
||
expect(checklist).toContain('case');
|
||
expect(checklist).toContain('allowlist');
|
||
});
|
||
|
||
test('Enum & Value Completeness is in the severity classification as CRITICAL', () => {
|
||
const gateSection = checklist.slice(checklist.indexOf('## Severity Classification'));
|
||
// The ASCII art has CRITICAL on the left and INFORMATIONAL on the right
|
||
// Enum & Value Completeness should appear on a line with the CRITICAL tree (├─ or └─)
|
||
const enumLine = gateSection.split('\n').find(l => l.includes('Enum & Value Completeness'));
|
||
expect(enumLine).toBeDefined();
|
||
// It's on the left (CRITICAL) side — starts with ├─ or └─
|
||
expect(enumLine!.trimStart().startsWith('├─') || enumLine!.trimStart().startsWith('└─')).toBe(true);
|
||
});
|
||
|
||
test('Fix-First Heuristic exists in checklist and is referenced by review + ship', () => {
|
||
expect(checklist).toContain('## Fix-First Heuristic');
|
||
expect(checklist).toContain('AUTO-FIX');
|
||
expect(checklist).toContain('ASK');
|
||
|
||
const reviewSkill = fs.readFileSync(path.join(ROOT, 'review/SKILL.md'), 'utf-8');
|
||
const shipSkill = fs.readFileSync(path.join(ROOT, 'ship/SKILL.md'), 'utf-8');
|
||
expect(reviewSkill).toContain('AUTO-FIX');
|
||
expect(reviewSkill).toContain('[AUTO-FIXED]');
|
||
expect(shipSkill).toContain('AUTO-FIX');
|
||
expect(shipSkill).toContain('[AUTO-FIXED]');
|
||
});
|
||
});
|
||
|
||
// --- Completeness Principle spot-check ---
|
||
|
||
describe('Completeness Principle in generated SKILL.md files', () => {
|
||
const skillsWithPreamble = [
|
||
'SKILL.md', 'browse/SKILL.md', 'qa/SKILL.md',
|
||
'qa-only/SKILL.md',
|
||
'setup-browser-cookies/SKILL.md',
|
||
'ship/SKILL.md', 'review/SKILL.md',
|
||
'plan-ceo-review/SKILL.md', 'plan-eng-review/SKILL.md',
|
||
'retro/SKILL.md',
|
||
'plan-design-review/SKILL.md',
|
||
'design-review/SKILL.md',
|
||
'design-consultation/SKILL.md',
|
||
'document-release/SKILL.md',
|
||
'cso/SKILL.md', ];
|
||
|
||
for (const skill of skillsWithPreamble) {
|
||
test(`${skill} contains Completeness Principle section`, () => {
|
||
const content = fs.readFileSync(path.join(ROOT, skill), 'utf-8');
|
||
expect(content).toContain('Completeness Principle');
|
||
expect(content).toContain('Boil the Lake');
|
||
});
|
||
}
|
||
|
||
test('Completeness Principle includes compression table in tier 2+ skills', () => {
|
||
// Root is tier 1 (no completeness). Check tier 2+ skill.
|
||
const content = fs.readFileSync(path.join(ROOT, 'cso', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('CC+gstack');
|
||
expect(content).toContain('Compression');
|
||
});
|
||
});
|
||
|
||
// --- Part 7: Planted-bug fixture validation (A4) ---
|
||
|
||
describe('Planted-bug fixture validation', () => {
|
||
test('qa-eval ground truth has exactly 5 planted bugs', () => {
|
||
const groundTruth = JSON.parse(
|
||
fs.readFileSync(path.join(ROOT, 'test', 'fixtures', 'qa-eval-ground-truth.json'), 'utf-8')
|
||
);
|
||
expect(groundTruth.bugs).toHaveLength(5);
|
||
expect(groundTruth.total_bugs).toBe(5);
|
||
});
|
||
|
||
test('qa-eval-spa ground truth has exactly 5 planted bugs', () => {
|
||
const groundTruth = JSON.parse(
|
||
fs.readFileSync(path.join(ROOT, 'test', 'fixtures', 'qa-eval-spa-ground-truth.json'), 'utf-8')
|
||
);
|
||
expect(groundTruth.bugs).toHaveLength(5);
|
||
expect(groundTruth.total_bugs).toBe(5);
|
||
});
|
||
|
||
test('qa-eval-checkout ground truth has exactly 5 planted bugs', () => {
|
||
const groundTruth = JSON.parse(
|
||
fs.readFileSync(path.join(ROOT, 'test', 'fixtures', 'qa-eval-checkout-ground-truth.json'), 'utf-8')
|
||
);
|
||
expect(groundTruth.bugs).toHaveLength(5);
|
||
expect(groundTruth.total_bugs).toBe(5);
|
||
});
|
||
|
||
test('qa-eval.html contains the planted bugs', () => {
|
||
const html = fs.readFileSync(path.join(ROOT, 'browse', 'test', 'fixtures', 'qa-eval.html'), 'utf-8');
|
||
// BUG 1: broken link
|
||
expect(html).toContain('/nonexistent-404-page');
|
||
// BUG 2: disabled submit
|
||
expect(html).toContain('disabled');
|
||
// BUG 3: overflow
|
||
expect(html).toContain('overflow: hidden');
|
||
// BUG 4: missing alt
|
||
expect(html).toMatch(/<img[^>]*src="\/logo\.png"[^>]*>/);
|
||
expect(html).not.toMatch(/<img[^>]*src="\/logo\.png"[^>]*alt=/);
|
||
// BUG 5: console error
|
||
expect(html).toContain("Cannot read properties of undefined");
|
||
});
|
||
|
||
test('review-eval-vuln.rb contains expected vulnerability patterns', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'test', 'fixtures', 'review-eval-vuln.rb'), 'utf-8');
|
||
expect(content).toContain('params[:id]');
|
||
expect(content).toContain('update_column');
|
||
});
|
||
});
|
||
|
||
// --- CEO review mode validation ---
|
||
|
||
describe('CEO review mode validation', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'plan-ceo-review', 'SKILL.md'), 'utf-8');
|
||
|
||
test('has all four CEO review modes defined', () => {
|
||
const modes = ['SCOPE EXPANSION', 'SELECTIVE EXPANSION', 'HOLD SCOPE', 'SCOPE REDUCTION'];
|
||
for (const mode of modes) {
|
||
expect(content).toContain(mode);
|
||
}
|
||
});
|
||
|
||
test('has CEO plan persistence step', () => {
|
||
expect(content).toContain('ceo-plans');
|
||
expect(content).toContain('status: ACTIVE');
|
||
});
|
||
|
||
test('has docs/designs promotion section', () => {
|
||
expect(content).toContain('docs/designs');
|
||
expect(content).toContain('PROMOTED');
|
||
});
|
||
|
||
test('mode quick reference has four columns', () => {
|
||
expect(content).toContain('EXPANSION');
|
||
expect(content).toContain('SELECTIVE');
|
||
expect(content).toContain('HOLD SCOPE');
|
||
expect(content).toContain('REDUCTION');
|
||
});
|
||
|
||
// Skill chaining (benefits-from)
|
||
test('contains prerequisite skill offer for office-hours', () => {
|
||
expect(content).toContain('Prerequisite Skill Offer');
|
||
expect(content).toContain('/office-hours');
|
||
});
|
||
|
||
test('contains mid-session detection', () => {
|
||
expect(content).toContain('Mid-session detection');
|
||
expect(content).toMatch(/still figuring out|seems lost/i);
|
||
});
|
||
|
||
// Spec review on CEO plans
|
||
test('contains spec review loop for CEO plan documents', () => {
|
||
expect(content).toContain('Spec Review Loop');
|
||
});
|
||
});
|
||
|
||
// --- gstack-slug helper ---
|
||
|
||
describe('gstack-slug', () => {
|
||
const SLUG_BIN = path.join(ROOT, 'bin', 'gstack-slug');
|
||
|
||
test('binary exists and is executable', () => {
|
||
expect(fs.existsSync(SLUG_BIN)).toBe(true);
|
||
const stat = fs.statSync(SLUG_BIN);
|
||
expect(stat.mode & 0o111).toBeGreaterThan(0);
|
||
});
|
||
|
||
test('outputs SLUG and BRANCH lines in a git repo', () => {
|
||
const result = Bun.spawnSync([SLUG_BIN], { cwd: ROOT, stdout: 'pipe', stderr: 'pipe' });
|
||
expect(result.exitCode).toBe(0);
|
||
const output = result.stdout.toString();
|
||
expect(output).toContain('SLUG=');
|
||
expect(output).toContain('BRANCH=');
|
||
});
|
||
|
||
test('SLUG does not contain forward slashes', () => {
|
||
const result = Bun.spawnSync([SLUG_BIN], { cwd: ROOT, stdout: 'pipe', stderr: 'pipe' });
|
||
const slug = result.stdout.toString().match(/SLUG=(.*)/)?.[1] ?? '';
|
||
expect(slug).not.toContain('/');
|
||
expect(slug.length).toBeGreaterThan(0);
|
||
});
|
||
|
||
test('BRANCH does not contain forward slashes', () => {
|
||
const result = Bun.spawnSync([SLUG_BIN], { cwd: ROOT, stdout: 'pipe', stderr: 'pipe' });
|
||
const branch = result.stdout.toString().match(/BRANCH=(.*)/)?.[1] ?? '';
|
||
expect(branch).not.toContain('/');
|
||
expect(branch.length).toBeGreaterThan(0);
|
||
});
|
||
|
||
test('output is eval-compatible (KEY=VALUE format)', () => {
|
||
const result = Bun.spawnSync([SLUG_BIN], { cwd: ROOT, stdout: 'pipe', stderr: 'pipe' });
|
||
const lines = result.stdout.toString().trim().split('\n');
|
||
expect(lines.length).toBe(2);
|
||
expect(lines[0]).toMatch(/^SLUG=.+/);
|
||
expect(lines[1]).toMatch(/^BRANCH=.+/);
|
||
});
|
||
|
||
test('output values contain only safe characters (no shell metacharacters)', () => {
|
||
const result = Bun.spawnSync([SLUG_BIN], { cwd: ROOT, stdout: 'pipe', stderr: 'pipe' });
|
||
const slug = result.stdout.toString().match(/SLUG=(.*)/)?.[1] ?? '';
|
||
const branch = result.stdout.toString().match(/BRANCH=(.*)/)?.[1] ?? '';
|
||
// Only alphanumeric, dot, dash, underscore are allowed (#133)
|
||
expect(slug).toMatch(/^[a-zA-Z0-9._-]+$/);
|
||
expect(branch).toMatch(/^[a-zA-Z0-9._-]+$/);
|
||
});
|
||
test('eval sets variables under bash with set -euo pipefail', () => {
|
||
const result = Bun.spawnSync(
|
||
['bash', '-c', 'set -euo pipefail; eval "$(./bin/gstack-slug 2>/dev/null)"; echo "SLUG=$SLUG"; echo "BRANCH=$BRANCH"'],
|
||
{ cwd: ROOT, stdout: 'pipe', stderr: 'pipe' }
|
||
);
|
||
expect(result.exitCode).toBe(0);
|
||
const output = result.stdout.toString();
|
||
expect(output).toMatch(/^SLUG=.+/m);
|
||
expect(output).toMatch(/^BRANCH=.+/m);
|
||
});
|
||
|
||
test('no templates or bin scripts use source process substitution for gstack-slug', () => {
|
||
const result = Bun.spawnSync(
|
||
['grep', '-r', 'source <(.*gstack-slug', '--include=*.tmpl', '--include=gstack-review-*', '.'],
|
||
{ cwd: ROOT, stdout: 'pipe', stderr: 'pipe' }
|
||
);
|
||
// grep returns exit code 1 when no matches found — that's what we want
|
||
expect(result.stdout.toString().trim()).toBe('');
|
||
});
|
||
});
|
||
|
||
// --- Test Bootstrap validation ---
|
||
|
||
describe('Test Bootstrap ({{TEST_BOOTSTRAP}}) integration', () => {
|
||
test('TEST_BOOTSTRAP resolver produces valid content', () => {
|
||
const qaContent = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md'), 'utf-8');
|
||
expect(qaContent).toContain('Test Framework Bootstrap');
|
||
expect(qaContent).toContain('RUNTIME:ruby');
|
||
expect(qaContent).toContain('RUNTIME:node');
|
||
expect(qaContent).toContain('RUNTIME:python');
|
||
expect(qaContent).toContain('no-test-bootstrap');
|
||
expect(qaContent).toContain('BOOTSTRAP_DECLINED');
|
||
});
|
||
|
||
test('TEST_BOOTSTRAP appears in qa/SKILL.md', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('Test Framework Bootstrap');
|
||
expect(content).toContain('TESTING.md');
|
||
expect(content).toContain('CLAUDE.md');
|
||
});
|
||
|
||
test('TEST_BOOTSTRAP appears in ship/SKILL.md', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('Test Framework Bootstrap');
|
||
expect(content).toContain('Step 2.5');
|
||
});
|
||
|
||
test('TEST_BOOTSTRAP appears in design-review/SKILL.md', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'design-review', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('Test Framework Bootstrap');
|
||
});
|
||
|
||
test('TEST_BOOTSTRAP does NOT appear in qa-only/SKILL.md', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'qa-only', 'SKILL.md'), 'utf-8');
|
||
expect(content).not.toContain('Test Framework Bootstrap');
|
||
// But should have the recommendation note
|
||
expect(content).toContain('No test framework detected');
|
||
expect(content).toContain('Run `/qa` to bootstrap');
|
||
});
|
||
|
||
test('bootstrap includes framework knowledge table', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('vitest');
|
||
expect(content).toContain('minitest');
|
||
expect(content).toContain('pytest');
|
||
expect(content).toContain('cargo test');
|
||
expect(content).toContain('phpunit');
|
||
expect(content).toContain('ExUnit');
|
||
});
|
||
|
||
test('bootstrap includes CI/CD pipeline generation', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('.github/workflows/test.yml');
|
||
expect(content).toContain('GitHub Actions');
|
||
});
|
||
|
||
test('bootstrap includes first real tests step', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('First real tests');
|
||
expect(content).toContain('git log --since=30.days');
|
||
expect(content).toContain('Prioritize by risk');
|
||
});
|
||
|
||
test('bootstrap includes vibe coding philosophy', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('vibe coding');
|
||
expect(content).toContain('100% test coverage');
|
||
});
|
||
|
||
test('WebSearch is in allowed-tools for qa, ship, design-review', () => {
|
||
const qa = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md'), 'utf-8');
|
||
const ship = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
|
||
const qaDesign = fs.readFileSync(path.join(ROOT, 'design-review', 'SKILL.md'), 'utf-8');
|
||
expect(qa).toContain('WebSearch');
|
||
expect(ship).toContain('WebSearch');
|
||
expect(qaDesign).toContain('WebSearch');
|
||
});
|
||
});
|
||
|
||
// --- Phase 8e.5 regression test validation ---
|
||
|
||
describe('Phase 8e.5 regression test generation', () => {
|
||
test('qa/SKILL.md contains Phase 8e.5', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('8e.5. Regression Test');
|
||
expect(content).toContain('test(qa): regression test');
|
||
expect(content).toContain('WTF-likelihood exclusion');
|
||
});
|
||
|
||
test('qa/SKILL.md Rule 13 is amended for regression tests', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('Only modify tests when generating regression tests in Phase 8e.5');
|
||
expect(content).not.toContain('Never modify tests or CI configuration');
|
||
});
|
||
|
||
test('design-review has CSS-aware Phase 8e.5 variant', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'design-review', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('8e.5. Regression Test (design-review variant)');
|
||
expect(content).toContain('CSS-only');
|
||
expect(content).toContain('test(design): regression test');
|
||
});
|
||
|
||
test('regression test includes full attribution comment format', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('// Regression: ISSUE-NNN');
|
||
expect(content).toContain('// Found by /qa on');
|
||
expect(content).toContain('// Report: .gstack/qa-reports/');
|
||
});
|
||
|
||
test('regression test uses auto-incrementing names', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('auto-incrementing');
|
||
expect(content).toContain('max number + 1');
|
||
});
|
||
});
|
||
|
||
// --- Step 3.4 coverage audit validation ---
|
||
|
||
describe('Step 3.4 test coverage audit', () => {
|
||
test('ship/SKILL.md contains Step 3.4', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('Step 3.4: Test Coverage Audit');
|
||
expect(content).toContain('CODE PATH COVERAGE');
|
||
});
|
||
|
||
test('Step 3.4 includes quality scoring rubric', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('★★★');
|
||
expect(content).toContain('★★');
|
||
expect(content).toContain('edge cases AND error paths');
|
||
expect(content).toContain('happy path only');
|
||
});
|
||
|
||
test('Step 3.4 includes before/after test count', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('Count test files before');
|
||
expect(content).toContain('Count test files after');
|
||
});
|
||
|
||
test('ship PR body includes Test Coverage section', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('## Test Coverage');
|
||
});
|
||
|
||
test('ship rules include test generation rule', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('Step 3.4 generates coverage tests');
|
||
expect(content).toContain('Never commit failing tests');
|
||
});
|
||
|
||
test('Step 3.4 includes vibe coding philosophy', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('vibe coding becomes yolo coding');
|
||
});
|
||
|
||
test('Step 3.4 traces actual codepaths, not just syntax', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('Trace every codepath');
|
||
expect(content).toContain('Trace data flow');
|
||
expect(content).toContain('Diagram the execution');
|
||
});
|
||
|
||
test('Step 3.4 maps user flows and interaction edge cases', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('Map user flows');
|
||
expect(content).toContain('Interaction edge cases');
|
||
expect(content).toContain('Double-click');
|
||
expect(content).toContain('Navigate away');
|
||
expect(content).toContain('Error states the user can see');
|
||
expect(content).toContain('Empty/zero/boundary states');
|
||
});
|
||
|
||
test('Step 3.4 diagram includes USER FLOW COVERAGE section', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('USER FLOW COVERAGE');
|
||
expect(content).toContain('Code paths:');
|
||
expect(content).toContain('User flows:');
|
||
});
|
||
});
|
||
|
||
// --- Retro test health validation ---
|
||
|
||
describe('Retro test health tracking', () => {
|
||
test('retro/SKILL.md has test health data gathering commands', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'retro', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('# 10. Test file count');
|
||
expect(content).toContain('# 11. Regression test commits');
|
||
expect(content).toContain('# 12. Test files changed');
|
||
});
|
||
|
||
test('retro/SKILL.md has Test Health metrics row', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'retro', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('Test Health');
|
||
expect(content).toContain('regression tests');
|
||
});
|
||
|
||
test('retro/SKILL.md has Test Health narrative section', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'retro', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('### Test Health');
|
||
expect(content).toContain('Total test files');
|
||
expect(content).toContain('vibe coding safe');
|
||
});
|
||
|
||
test('retro JSON schema includes test_health field', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'retro', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('test_health');
|
||
expect(content).toContain('total_test_files');
|
||
expect(content).toContain('regression_test_commits');
|
||
});
|
||
});
|
||
|
||
// --- QA report template regression tests section ---
|
||
|
||
describe('QA report template', () => {
|
||
test('qa-report-template.md has Regression Tests section', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'qa', 'templates', 'qa-report-template.md'), 'utf-8');
|
||
expect(content).toContain('## Regression Tests');
|
||
expect(content).toContain('committed / deferred / skipped');
|
||
expect(content).toContain('### Deferred Tests');
|
||
expect(content).toContain('**Precondition:**');
|
||
});
|
||
});
|
||
|
||
// --- Codex skill validation ---
|
||
|
||
describe('Codex skill', () => {
|
||
test('codex/SKILL.md exists and has correct frontmatter', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'codex', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('name: codex');
|
||
expect(content).toContain('version: 1.0.0');
|
||
expect(content).toContain('allowed-tools:');
|
||
});
|
||
|
||
test('codex/SKILL.md contains all three modes', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'codex', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('Step 2A: Review Mode');
|
||
expect(content).toContain('Step 2B: Challenge');
|
||
expect(content).toContain('Step 2C: Consult Mode');
|
||
});
|
||
|
||
test('codex/SKILL.md contains gate verdict logic', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'codex', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('[P1]');
|
||
expect(content).toContain('GATE: PASS');
|
||
expect(content).toContain('GATE: FAIL');
|
||
});
|
||
|
||
test('codex/SKILL.md contains session continuity', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'codex', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('codex-session-id');
|
||
expect(content).toContain('codex exec resume');
|
||
});
|
||
|
||
test('codex/SKILL.md contains cost tracking', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'codex', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('tokens used');
|
||
expect(content).toContain('Est. cost');
|
||
});
|
||
|
||
test('codex/SKILL.md contains cross-model comparison', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'codex', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('CROSS-MODEL ANALYSIS');
|
||
expect(content).toContain('Agreement rate');
|
||
});
|
||
|
||
test('codex/SKILL.md contains review log persistence', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'codex', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('codex-review');
|
||
expect(content).toContain('gstack-review-log');
|
||
});
|
||
|
||
test('codex/SKILL.md uses which for binary discovery, not hardcoded path', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'codex', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('which codex');
|
||
expect(content).not.toContain('/opt/homebrew/bin/codex');
|
||
});
|
||
|
||
test('codex/SKILL.md contains error handling for missing binary and auth', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'codex', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('NOT_FOUND');
|
||
expect(content).toContain('codex login');
|
||
});
|
||
|
||
test('codex/SKILL.md uses mktemp for temp files', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'codex', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('mktemp');
|
||
});
|
||
|
||
test('adversarial review in /review auto-scales by diff size', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('Adversarial review (auto-scaled)');
|
||
// Diff size thresholds
|
||
expect(content).toContain('< 50');
|
||
expect(content).toContain('50–199');
|
||
expect(content).toContain('200+');
|
||
// All three tiers present
|
||
expect(content).toContain('Small');
|
||
expect(content).toContain('Medium tier');
|
||
expect(content).toContain('Large tier');
|
||
// Claude adversarial subagent dispatch
|
||
expect(content).toContain('Agent tool');
|
||
expect(content).toContain('FIXABLE');
|
||
expect(content).toContain('INVESTIGATE');
|
||
// Codex fallback logic
|
||
expect(content).toContain('CODEX_NOT_AVAILABLE');
|
||
expect(content).toContain('fall back to the Claude adversarial subagent');
|
||
// Review log uses new skill name
|
||
expect(content).toContain('adversarial-review');
|
||
expect(content).toContain('xhigh');
|
||
expect(content).toContain('ADVERSARIAL REVIEW SYNTHESIS');
|
||
});
|
||
|
||
test('adversarial review in /ship auto-scales by diff size', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('Adversarial review (auto-scaled)');
|
||
expect(content).toContain('< 50');
|
||
expect(content).toContain('200+');
|
||
expect(content).toContain('adversarial-review');
|
||
expect(content).toContain('xhigh');
|
||
expect(content).toContain('Investigate and fix');
|
||
});
|
||
|
||
test('codex-host ship/review do NOT contain adversarial review step', () => {
|
||
// .agents/ is gitignored — generate on demand
|
||
Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'codex'], {
|
||
cwd: ROOT, stdout: 'pipe', stderr: 'pipe',
|
||
});
|
||
const shipContent = fs.readFileSync(path.join(ROOT, '.agents', 'skills', 'gstack-ship', 'SKILL.md'), 'utf-8');
|
||
expect(shipContent).not.toContain('codex review --base');
|
||
expect(shipContent).not.toContain('CODEX_REVIEWS');
|
||
|
||
const reviewContent = fs.readFileSync(path.join(ROOT, '.agents', 'skills', 'gstack-review', 'SKILL.md'), 'utf-8');
|
||
expect(reviewContent).not.toContain('codex review --base');
|
||
expect(reviewContent).not.toContain('codex_reviews');
|
||
expect(reviewContent).not.toContain('CODEX_REVIEWS');
|
||
expect(reviewContent).not.toContain('adversarial-review');
|
||
expect(reviewContent).not.toContain('Investigate and fix');
|
||
});
|
||
|
||
test('codex integration in /plan-eng-review offers plan critique', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'plan-eng-review', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('Codex');
|
||
expect(content).toContain('codex exec');
|
||
});
|
||
|
||
test('/review persists a review-log entry for ship readiness', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('"skill":"review"');
|
||
expect(content).toContain('"issues_found":N');
|
||
expect(content).toContain('Persist Eng Review result');
|
||
});
|
||
|
||
test('Review Readiness Dashboard includes Adversarial Review row', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('Adversarial');
|
||
expect(content).toContain('codex-review');
|
||
});
|
||
});
|
||
|
||
// --- Trigger phrase validation ---
|
||
|
||
describe('Skill trigger phrases', () => {
|
||
// Skills that must have "Use when" trigger phrases in their description.
|
||
// Excluded: root gstack (browser tool), gstack-upgrade (gstack-specific),
|
||
// humanizer (text tool)
|
||
const SKILLS_REQUIRING_TRIGGERS = [
|
||
'qa', 'qa-only', 'ship', 'review', 'investigate', 'office-hours',
|
||
'plan-ceo-review', 'plan-eng-review', 'plan-design-review',
|
||
'design-review', 'design-consultation', 'retro', 'document-release',
|
||
'codex', 'browse', 'setup-browser-cookies',
|
||
];
|
||
|
||
for (const skill of SKILLS_REQUIRING_TRIGGERS) {
|
||
test(`${skill}/SKILL.md has "Use when" trigger phrases`, () => {
|
||
const skillPath = path.join(ROOT, skill, 'SKILL.md');
|
||
if (!fs.existsSync(skillPath)) return;
|
||
const content = fs.readFileSync(skillPath, 'utf-8');
|
||
// Extract description from frontmatter
|
||
const frontmatterEnd = content.indexOf('---', 4);
|
||
const frontmatter = content.slice(0, frontmatterEnd);
|
||
expect(frontmatter).toMatch(/Use when/i);
|
||
});
|
||
}
|
||
|
||
// Skills with proactive triggers should have "Proactively suggest" in description
|
||
const SKILLS_REQUIRING_PROACTIVE = [
|
||
'qa', 'qa-only', 'ship', 'review', 'investigate', 'office-hours',
|
||
'plan-ceo-review', 'plan-eng-review', 'plan-design-review',
|
||
'design-review', 'design-consultation', 'retro', 'document-release',
|
||
];
|
||
|
||
for (const skill of SKILLS_REQUIRING_PROACTIVE) {
|
||
test(`${skill}/SKILL.md has "Proactively suggest" phrase`, () => {
|
||
const skillPath = path.join(ROOT, skill, 'SKILL.md');
|
||
if (!fs.existsSync(skillPath)) return;
|
||
const content = fs.readFileSync(skillPath, 'utf-8');
|
||
const frontmatterEnd = content.indexOf('---', 4);
|
||
const frontmatter = content.slice(0, frontmatterEnd);
|
||
expect(frontmatter).toMatch(/Proactively suggest/i);
|
||
});
|
||
}
|
||
});
|
||
|
||
// ─── Codex Skill Validation ──────────────────────────────────
|
||
|
||
describe('Codex skill validation', () => {
|
||
const AGENTS_DIR = path.join(ROOT, '.agents', 'skills');
|
||
|
||
// .agents/ is gitignored (v0.11.2.0) — generate on demand for tests
|
||
Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'codex'], {
|
||
cwd: ROOT, stdout: 'pipe', stderr: 'pipe',
|
||
});
|
||
|
||
// Discover all Claude skills with templates (except /codex which is Claude-only)
|
||
const CLAUDE_SKILLS_WITH_TEMPLATES = (() => {
|
||
const skills: string[] = [];
|
||
for (const entry of fs.readdirSync(ROOT, { withFileTypes: true })) {
|
||
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
||
if (entry.name === 'codex') continue; // Claude-only skill
|
||
if (fs.existsSync(path.join(ROOT, entry.name, 'SKILL.md.tmpl'))) {
|
||
skills.push(entry.name);
|
||
}
|
||
}
|
||
return skills;
|
||
})();
|
||
|
||
test('all skills (except /codex) have both Claude and Codex variants', () => {
|
||
for (const skillDir of CLAUDE_SKILLS_WITH_TEMPLATES) {
|
||
// Claude variant
|
||
const claudeMd = path.join(ROOT, skillDir, 'SKILL.md');
|
||
expect(fs.existsSync(claudeMd)).toBe(true);
|
||
|
||
// Codex variant
|
||
const codexName = skillDir.startsWith('gstack-') ? skillDir : `gstack-${skillDir}`;
|
||
const codexMd = path.join(AGENTS_DIR, codexName, 'SKILL.md');
|
||
expect(fs.existsSync(codexMd)).toBe(true);
|
||
}
|
||
// Root template has both too
|
||
expect(fs.existsSync(path.join(ROOT, 'SKILL.md'))).toBe(true);
|
||
expect(fs.existsSync(path.join(AGENTS_DIR, 'gstack', 'SKILL.md'))).toBe(true);
|
||
});
|
||
|
||
test('/codex skill is Claude-only — no Codex variant', () => {
|
||
// Claude variant should exist
|
||
expect(fs.existsSync(path.join(ROOT, 'codex', 'SKILL.md'))).toBe(true);
|
||
// Codex variant must NOT exist
|
||
expect(fs.existsSync(path.join(AGENTS_DIR, 'gstack-codex', 'SKILL.md'))).toBe(false);
|
||
});
|
||
|
||
test('Codex skill names follow gstack-{name} convention', () => {
|
||
const codexDirs = fs.readdirSync(AGENTS_DIR);
|
||
for (const dir of codexDirs) {
|
||
// Every directory should start with gstack
|
||
expect(dir.startsWith('gstack')).toBe(true);
|
||
// Root is just 'gstack', others are 'gstack-{name}'
|
||
if (dir !== 'gstack') {
|
||
expect(dir.startsWith('gstack-')).toBe(true);
|
||
}
|
||
}
|
||
});
|
||
|
||
test('$B commands in Codex SKILL.md files are valid browse commands', () => {
|
||
const codexDirs = fs.readdirSync(AGENTS_DIR);
|
||
for (const dir of codexDirs) {
|
||
const skillMd = path.join(AGENTS_DIR, dir, 'SKILL.md');
|
||
if (!fs.existsSync(skillMd)) continue;
|
||
const content = fs.readFileSync(skillMd, 'utf-8');
|
||
// Only validate if the skill contains $B commands
|
||
if (!content.includes('$B ')) continue;
|
||
const result = validateSkill(skillMd);
|
||
expect(result.invalid).toHaveLength(0);
|
||
}
|
||
});
|
||
});
|
||
|
||
// --- Repo mode and test failure triage validation ---
|
||
|
||
describe('Repo mode preamble validation', () => {
|
||
test('generated SKILL.md preamble contains REPO_MODE output', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('REPO_MODE:');
|
||
expect(content).toContain('gstack-repo-mode');
|
||
});
|
||
|
||
test('tier 3+ skills contain See Something Say Something section', () => {
|
||
// Root SKILL.md is tier 1 (no Repo Mode). Check a tier 3 skill instead.
|
||
const content = fs.readFileSync(path.join(ROOT, 'plan-ceo-review', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('See Something, Say Something');
|
||
expect(content).toContain('REPO_MODE');
|
||
expect(content).toContain('solo');
|
||
expect(content).toContain('collaborative');
|
||
});
|
||
});
|
||
|
||
describe('Test failure triage in ship skill', () => {
|
||
test('ship/SKILL.md contains Test Failure Ownership Triage', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('Test Failure Ownership Triage');
|
||
});
|
||
|
||
test('ship/SKILL.md triage uses git diff for classification', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('git diff origin/<base>...HEAD --name-only');
|
||
});
|
||
|
||
test('ship/SKILL.md triage has solo and collaborative paths', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('REPO_MODE');
|
||
expect(content).toContain('solo');
|
||
expect(content).toContain('collaborative');
|
||
expect(content).toContain('Investigate and fix now');
|
||
expect(content).toContain('Add as P0 TODO');
|
||
});
|
||
|
||
test('ship/SKILL.md triage has GitHub issue assignment for collaborative mode', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('gh issue create');
|
||
expect(content).toContain('--assignee');
|
||
});
|
||
|
||
test('{{TEST_FAILURE_TRIAGE}} placeholder is fully resolved in ship/SKILL.md', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
|
||
expect(content).not.toContain('{{TEST_FAILURE_TRIAGE}}');
|
||
});
|
||
|
||
test('ship/SKILL.md uses in-branch language for stop condition', () => {
|
||
const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
|
||
expect(content).toContain('In-branch test failures');
|
||
});
|
||
});
|