From 9165008f090dfdd7696121ea07d43722ce78ea5f Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 31 Jul 2026 11:46:42 +0200 Subject: [PATCH 1/9] test: modularize and refactor e2e testing framework --- __tests__/e2e/chat-launch.e2e.ts | 24 +- __tests__/e2e/empty-project.e2e.ts | 6 +- __tests__/e2e/global-setup.ts | 4 +- __tests__/e2e/happy-path.e2e.ts | 16 +- __tests__/e2e/helpers/index.ts | 145 ------ __tests__/e2e/helpers/jwt.ts | 16 - __tests__/e2e/helpers/keys.ts | 5 - __tests__/e2e/helpers/pty.ts | 173 ------- __tests__/e2e/helpers/strip-ansi.ts | 7 - __tests__/e2e/onboarding-invocation.e2e.ts | 2 +- __tests__/e2e/skip-onboarding.e2e.ts | 10 +- __tests__/e2e/skip-plugins.e2e.ts | 20 +- __tests__/e2e/skip-tools.e2e.ts | 8 +- __tests__/e2e/stale-auth.e2e.ts | 5 +- __tests__/e2e/stale-mcp-auth.e2e.ts | 16 +- __tests__/e2e/system-check-failure.e2e.ts | 16 +- .../e2e/{helpers => testing-framework}/env.ts | 13 + __tests__/e2e/testing-framework/index.ts | 13 + __tests__/e2e/testing-framework/mocks/auth.ts | 33 ++ .../mocks/binaries/claude.ts} | 2 +- .../mocks/binaries/codex.ts} | 2 +- .../mocks/binaries/cursor.ts} | 2 +- .../mocks/binaries/index.ts} | 20 +- .../mocks/binaries/streaming.ts} | 13 + .../e2e/testing-framework/mocks/index.ts | 7 + .../mocks/server.ts} | 39 +- __tests__/e2e/testing-framework/navigation.ts | 111 +++++ .../e2e/testing-framework/session-factory.ts | 91 ++++ .../e2e/testing-framework/terminal/index.ts | 2 + .../e2e/testing-framework/terminal/key-map.ts | 93 ++++ .../terminal}/screen-buffer.ts | 26 ++ .../e2e/testing-framework/terminal/session.ts | 427 ++++++++++++++++++ .../testing-framework/terminal/strip-ansi.ts | 16 + __tests__/e2e/testing-framework/utils.ts | 72 +++ __tests__/e2e/welcome-navigation.e2e.ts | 14 +- 35 files changed, 1039 insertions(+), 430 deletions(-) delete mode 100644 __tests__/e2e/helpers/index.ts delete mode 100644 __tests__/e2e/helpers/jwt.ts delete mode 100644 __tests__/e2e/helpers/keys.ts delete mode 100644 __tests__/e2e/helpers/pty.ts delete mode 100644 __tests__/e2e/helpers/strip-ansi.ts rename __tests__/e2e/{helpers => testing-framework}/env.ts (52%) create mode 100644 __tests__/e2e/testing-framework/index.ts create mode 100644 __tests__/e2e/testing-framework/mocks/auth.ts rename __tests__/e2e/{helpers/mock-claude.ts => testing-framework/mocks/binaries/claude.ts} (92%) rename __tests__/e2e/{helpers/mock-codex.ts => testing-framework/mocks/binaries/codex.ts} (93%) rename __tests__/e2e/{helpers/mock-cursor.ts => testing-framework/mocks/binaries/cursor.ts} (93%) rename __tests__/e2e/{helpers/mock-binaries.ts => testing-framework/mocks/binaries/index.ts} (50%) rename __tests__/e2e/{helpers/mock-streaming.ts => testing-framework/mocks/binaries/streaming.ts} (71%) create mode 100644 __tests__/e2e/testing-framework/mocks/index.ts rename __tests__/e2e/{helpers/mock-server.ts => testing-framework/mocks/server.ts} (65%) create mode 100644 __tests__/e2e/testing-framework/navigation.ts create mode 100644 __tests__/e2e/testing-framework/session-factory.ts create mode 100644 __tests__/e2e/testing-framework/terminal/index.ts create mode 100644 __tests__/e2e/testing-framework/terminal/key-map.ts rename __tests__/e2e/{helpers => testing-framework/terminal}/screen-buffer.ts (82%) create mode 100644 __tests__/e2e/testing-framework/terminal/session.ts create mode 100644 __tests__/e2e/testing-framework/terminal/strip-ansi.ts create mode 100644 __tests__/e2e/testing-framework/utils.ts diff --git a/__tests__/e2e/chat-launch.e2e.ts b/__tests__/e2e/chat-launch.e2e.ts index b9df036..5314fc8 100644 --- a/__tests__/e2e/chat-launch.e2e.ts +++ b/__tests__/e2e/chat-launch.e2e.ts @@ -4,21 +4,19 @@ import { createSession, navigateToOnboarding, navigateToPlugins, - ENTER, - ARROW_DOWN, CHAT_PROMPT_FILE, -} from './helpers/index.js'; +} from './testing-framework/index.js'; describe('when the user starts chat after onboarding', () => { it('includes code changes and report file in the prompt', async () => { using session = createSession(); await navigateToOnboarding(session); - await session.sendKey(ENTER); + await session.press('Enter'); await session.waitForText('onboarding complete', { timeout: 30_000 }); await session.waitForText('Continue work with Claude Code'); - await session.sendKey(ENTER); + await session.press('Enter'); const exitCode = await session.waitForExit(); expect(exitCode).toBe(0); @@ -34,11 +32,11 @@ describe('when the user starts chat after onboarding', () => { using session = createSession(); await navigateToOnboarding(session); - await session.sendKey(ARROW_DOWN); - await session.sendKey(ENTER); + await session.press('ArrowDown'); + await session.press('Enter'); await session.waitForText('Continue work with Claude Code'); - await session.sendKey(ENTER); + await session.press('Enter'); const exitCode = await session.waitForExit(); expect(exitCode).toBe(0); @@ -53,23 +51,23 @@ describe('when the user starts chat after onboarding', () => { using session = createSession(); await navigateToPlugins(session); - await session.sendKey(ENTER); + await session.press('Enter'); // Skip connecting tools await session.waitForText('Connect Confidence tools?'); - await session.sendKeyRepeat(ARROW_DOWN, 3); - await session.sendKey(ENTER); + await session.pressRepeat('ArrowDown', 3); + await session.press('Enter'); await session.waitForText('Skipped'); // Onboard — wait for options to render before pressing Enter await session.waitForText('Start onboarding?'); await session.waitForText('Skip for now'); - await session.sendKey(ENTER); + await session.press('Enter'); await session.waitForText('onboarding complete', { timeout: 30_000 }); // Chat await session.waitForText('Continue work with Claude Code'); - await session.sendKey(ENTER); + await session.press('Enter'); const exitCode = await session.waitForExit(); expect(exitCode).toBe(0); diff --git a/__tests__/e2e/empty-project.e2e.ts b/__tests__/e2e/empty-project.e2e.ts index 27fc03b..f08a78a 100644 --- a/__tests__/e2e/empty-project.e2e.ts +++ b/__tests__/e2e/empty-project.e2e.ts @@ -1,4 +1,4 @@ -import { createSession, ENTER } from './helpers/index.js'; +import { createSession } from './testing-framework/index.js'; describe('when the project is empty', () => { it('shows "Select framework" instead of "Start setup" on the Welcome screen', async () => { @@ -16,12 +16,12 @@ describe('when the project is empty', () => { // Welcome — no framework detected await session.waitForText('Select framework'); - await session.sendKey(ENTER); + await session.press('Enter'); // SelectFramework await session.waitForText('Select Framework'); await session.waitForText("Select your project's framework or language:"); - await session.sendKey(ENTER); + await session.press('Enter'); // Back to Welcome — now with framework set await session.waitForText('Start setup'); diff --git a/__tests__/e2e/global-setup.ts b/__tests__/e2e/global-setup.ts index a8bcb2b..db8cb74 100644 --- a/__tests__/e2e/global-setup.ts +++ b/__tests__/e2e/global-setup.ts @@ -1,8 +1,8 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { startMockServer, type MockServer } from './helpers/mock-server.js'; -import { createMockBinDir } from './helpers/mock-binaries.js'; +import { startMockServer, type MockServer } from './testing-framework/mocks/server.js'; +import { createMockBinDir } from './testing-framework/mocks/binaries/index.js'; let mockServer: MockServer; let tempBase: string; diff --git a/__tests__/e2e/happy-path.e2e.ts b/__tests__/e2e/happy-path.e2e.ts index ed46b47..05fa834 100644 --- a/__tests__/e2e/happy-path.e2e.ts +++ b/__tests__/e2e/happy-path.e2e.ts @@ -1,4 +1,4 @@ -import { createSession, simulateAuthCallback, ENTER, ARROW_DOWN } from './helpers/index.js'; +import { createSession, simulateAuthCallback } from './testing-framework/index.js'; describe('happy-path flow', () => { it('navigates Welcome → SystemCheck → Authenticate → InstallPlugins → ConnectTools → OnboardProject → Done', async () => { @@ -9,7 +9,7 @@ describe('happy-path flow', () => { await session.waitForText('Start setup'); expect(session.snapshot()).toMatchSnapshot('welcome'); session.checkpoint(); - await session.sendKey(ENTER); + await session.press('Enter'); // SystemCheck await session.waitForText('System Check'); @@ -20,7 +20,7 @@ describe('happy-path flow', () => { // Authenticate await session.waitForText('Sign in to Confidence'); await session.waitForText('Sign in to a Confidence account'); - await session.sendKey(ENTER); + await session.press('Enter'); await session.waitForText('Waiting for browser'); await simulateAuthCallback(); await session.waitForText('Authenticated'); @@ -32,21 +32,21 @@ describe('happy-path flow', () => { await session.waitForText('Which agent tool are you using?'); expect(session.snapshot()).toMatchSnapshot('install-plugins'); session.checkpoint(); - await session.sendKey(ENTER); + await session.press('Enter'); // ConnectTools await session.waitForText('Connect your AI to Confidence'); await session.waitForText('Connect Confidence tools?'); expect(session.snapshot()).toMatchSnapshot('connect-tools'); session.checkpoint(); - await session.sendKey(ENTER); + await session.press('Enter'); await session.waitForText('Connected successfully'); // OnboardProject await session.waitForText('Start onboarding?'); expect(session.snapshot()).toMatchSnapshot('onboard-project'); session.checkpoint(); - await session.sendKey(ENTER); + await session.press('Enter'); await session.waitForText('Installing @spotify-confidence/sdk'); await session.waitForText('onboarding complete', { timeout: 30_000 }); @@ -54,8 +54,8 @@ describe('happy-path flow', () => { await session.waitForText('Confidence is ready'); await session.waitForText("What's next?"); expect(session.snapshot()).toMatchSnapshot('done'); - await session.sendKey(ARROW_DOWN); - await session.sendKey(ENTER); + await session.press('ArrowDown'); + await session.press('Enter'); const exitCode = await session.waitForExit(); expect(exitCode).toBe(0); diff --git a/__tests__/e2e/helpers/index.ts b/__tests__/e2e/helpers/index.ts deleted file mode 100644 index 5169830..0000000 --- a/__tests__/e2e/helpers/index.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { TerminalSession } from './pty.js'; -import { ENTER, ARROW_DOWN } from './keys.js'; -import { AUTH_CALLBACK_PORT } from './env.js'; -import { ONBOARDING_INVOCATION_FILE } from './mock-binaries.js'; - -export { TerminalSession } from './pty.js'; -export { stripAnsi } from './strip-ansi.js'; -export { buildTestJwt } from './jwt.js'; -export { ARROW_DOWN, ARROW_UP, ENTER, ESCAPE } from './keys.js'; -export { AUTH_CALLBACK_PORT } from './env.js'; -export { CHAT_PROMPT_FILE, ONBOARDING_INVOCATION_FILE } from './mock-binaries.js'; - -const DEFAULT_TIMEOUT = 15_000; - -type ProjectType = 'react' | 'empty'; - -export function createSession({ - project = 'react', - extraArgs = [], - env = {}, - token, - systemPath, -}: { - project?: ProjectType; - extraArgs?: string[]; - env?: Record; - token?: string; - systemPath?: string; -} = {}): TerminalSession { - const mockBinDir = process.env.E2E_MOCK_BIN_DIR!; - const projectDir = mkdtempSync('/tmp/e2e-project-'); - - if (project === 'react') { - writeFileSync( - join(projectDir, 'package.json'), - JSON.stringify({ dependencies: { react: '^19.0.0' } }), - ); - } - - const sessionEnv: Record = { - PATH: `${mockBinDir}:${systemPath ?? process.env.PATH}`, - ...env, - }; - - if (token) { - const tokenDir = mkdtempSync(join(tmpdir(), 'e2e-tmp-')); - writeFileSync(join(tokenDir, 'confidence_token'), token, 'utf-8'); - sessionEnv.TMPDIR = tokenDir; - } - - const session = new TerminalSession({ - args: ['--debug', '--dir', projectDir, ...extraArgs], - env: sessionEnv, - cwd: projectDir, - }); - - session.addTempDir(projectDir); - return session; -} - -export async function simulateAuthCallback(): Promise { - const deadline = Date.now() + DEFAULT_TIMEOUT; - let backoff = 50; - - while (Date.now() < deadline) { - try { - await fetch(`http://localhost:${AUTH_CALLBACK_PORT}/callback?code=test-auth-code`); - return; - } catch { - await new Promise((resolve) => setTimeout(resolve, backoff)); - backoff = Math.min(backoff * 2, 500); - } - } - throw new Error(`Auth callback server not ready after ${DEFAULT_TIMEOUT / 1000}s`); -} - -export async function navigatePastWelcome(session: TerminalSession): Promise { - await session.waitForText('Start setup'); - await session.sendKey(ENTER); - await session.waitForText('All checks passed'); -} - -export async function navigatePastAuth(session: TerminalSession): Promise { - await session.waitForText('Sign in to Confidence'); - await session.waitForText('Sign in to a Confidence account'); - await session.sendKey(ENTER); - await session.waitForText('Waiting for browser'); - await simulateAuthCallback(); - await session.waitForText('Authenticated'); -} - -export async function navigateToPlugins(session: TerminalSession): Promise { - await navigatePastWelcome(session); - await navigatePastAuth(session); - session.checkpoint(); - await session.waitForText('Which agent tool are you using?'); -} - -export async function navigateToConnectTools(session: TerminalSession): Promise { - await navigateToPlugins(session); - await session.waitForText('Skip (install manually later)'); - session.checkpoint(); - await session.sendKey(ENTER); - await session.waitForText('Connect Confidence tools?'); -} - -export async function navigateToOnboarding(session: TerminalSession): Promise { - await navigateToConnectTools(session); - session.checkpoint(); - await session.sendKey(ENTER); - await session.waitForText('Start onboarding?'); -} - -export type Invocation = { - command: string; - args: string[]; - prompt: string; -}; - -export async function selectIdeAndOnboard( - session: TerminalSession, - downPresses: number, -): Promise { - await session.sendKeyRepeat(ARROW_DOWN, downPresses); - await session.sendKey(ENTER); - - // ConnectTools may auto-advance when MCP servers are already registered globally - const matched = await session.waitForText(['Start onboarding?', 'Connect Confidence tools?']); - - if (matched === 'Connect Confidence tools?') { - await session.sendKey(ENTER); - await session.waitForText('Connected successfully'); - } - - await session.waitForText('Start onboarding?'); - await session.sendKey(ENTER); - await session.waitForText('onboarding complete'); -} - -export function readInvocation(cwd: string): Invocation { - return JSON.parse(readFileSync(join(cwd, ONBOARDING_INVOCATION_FILE), 'utf-8')) as Invocation; -} diff --git a/__tests__/e2e/helpers/jwt.ts b/__tests__/e2e/helpers/jwt.ts deleted file mode 100644 index 406bbf9..0000000 --- a/__tests__/e2e/helpers/jwt.ts +++ /dev/null @@ -1,16 +0,0 @@ -function base64url(str: string): string { - return Buffer.from(str, 'utf-8').toString('base64url'); -} - -export function buildTestJwt(claims: Record = {}): string { - const header = base64url(JSON.stringify({ alg: 'none', typ: 'JWT' })); - const payload = base64url( - JSON.stringify({ - exp: Math.floor(Date.now() / 1000) + 86400, - 'https://confidence.dev/region': 'EU', - email: 'test@example.com', - ...claims, - }), - ); - return `${header}.${payload}.`; -} diff --git a/__tests__/e2e/helpers/keys.ts b/__tests__/e2e/helpers/keys.ts deleted file mode 100644 index 1da1c67..0000000 --- a/__tests__/e2e/helpers/keys.ts +++ /dev/null @@ -1,5 +0,0 @@ -const ESC = String.fromCharCode(27); -export const ARROW_DOWN = ESC + '[B'; -export const ARROW_UP = ESC + '[A'; -export const ENTER = '\r'; -export const ESCAPE = ESC; diff --git a/__tests__/e2e/helpers/pty.ts b/__tests__/e2e/helpers/pty.ts deleted file mode 100644 index 013ab93..0000000 --- a/__tests__/e2e/helpers/pty.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { spawn as ptySpawn, type IPty } from 'node-pty'; -import { resolve } from 'node:path'; -import { mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { stripAnsi } from './strip-ansi.js'; -import { renderScreen, normalizeSnapshot } from './screen-buffer.js'; -import { E2E_BASE_ENV } from './env.js'; - -const CLI_PATH = resolve(import.meta.dirname, '../../../dist/bin/cli.js'); -const DEFAULT_COLS = 100; -const DEFAULT_ROWS = 40; -const DEFAULT_TIMEOUT = 15_000; - -type SessionOptions = { - args?: string[]; - env?: Record; - cols?: number; - rows?: number; - cwd?: string; -}; - -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -export class TerminalSession { - private pty: IPty; - private rawOutput = ''; - private cachedScreen = ''; - private cachedRawLength = 0; - private markPosition = 0; - private rawMarkPosition = 0; - private exitCode: number | null = null; - private exitPromise: Promise; - private tempDirs: string[] = []; - - readonly cwd: string; - readonly cols: number; - readonly rows: number; - - constructor(options: SessionOptions = {}) { - const { args = ['--debug'], env = {}, cols = DEFAULT_COLS, rows = DEFAULT_ROWS, cwd } = options; - - this.cols = cols; - this.rows = rows; - - const isolatedTmpDir = env.TMPDIR ?? mkdtempSync(join(tmpdir(), 'e2e-')); - this.cwd = cwd ?? process.cwd(); - this.tempDirs.push(isolatedTmpDir); - - this.pty = ptySpawn(process.execPath, [CLI_PATH, ...args], { - name: 'xterm-256color', - cols, - rows, - cwd: this.cwd, - env: { - ...process.env, - ...E2E_BASE_ENV, - ...env, - HOME: isolatedTmpDir, - TMPDIR: isolatedTmpDir, - }, - }); - - this.pty.onData((data) => { - this.rawOutput += data; - }); - - this.exitPromise = new Promise((resolve) => { - this.pty.onExit(({ exitCode }) => { - this.exitCode = exitCode; - resolve(exitCode); - }); - }); - } - - get screen(): string { - if (this.rawOutput.length !== this.cachedRawLength) { - this.cachedScreen = stripAnsi(this.rawOutput); - this.cachedRawLength = this.rawOutput.length; - } - return this.cachedScreen; - } - - get screenSinceCheckpoint(): string { - return this.screen.slice(this.markPosition); - } - - addTempDir(dir: string): void { - this.tempDirs.push(dir); - } - - checkpoint(): void { - this.markPosition = this.screen.length; - this.rawMarkPosition = this.rawOutput.length; - } - - snapshot(): string { - const raw = this.rawOutput.slice(this.rawMarkPosition); - const rendered = renderScreen(raw, this.cols, this.rows); - return normalizeSnapshot(rendered, this.cwd); - } - - send(data: string): void { - this.pty.write(data); - } - - async sendKey(key: string, settleMs = 100): Promise { - this.pty.write(key); - await delay(settleMs); - } - - async sendKeyRepeat(key: string, count: number, settleMs = 100): Promise { - for (let i = 0; i < count; i++) { - await this.sendKey(key, settleMs); - } - } - - async waitForText( - text: string | string[], - { timeout = DEFAULT_TIMEOUT, sinceCheckpoint = true } = {}, - ): Promise { - const targets = Array.isArray(text) ? text : [text]; - const deadline = Date.now() + timeout; - let poll = 25; - - while (Date.now() < deadline) { - const haystack = sinceCheckpoint ? this.screenSinceCheckpoint : this.screen; - const match = targets.find((t) => haystack.includes(t)); - if (match) return match; - - await delay(poll); - poll = Math.min(poll * 2, 200); - } - - const label = - targets.length === 1 - ? `"${targets[0]}"` - : `any of [${targets.map((t) => `"${t}"`).join(', ')}]`; - - throw new Error( - `Timed out waiting for ${label} after ${timeout}ms.\n\nLast output:\n${this.screen.slice(-2000)}`, - ); - } - - async waitForExit(timeout = DEFAULT_TIMEOUT): Promise { - const timer = setTimeout(() => { - this.pty.kill(); - }, timeout); - - const code = await this.exitPromise; - clearTimeout(timer); - return code; - } - - kill(): void { - if (this.exitCode === null) { - this.pty.kill(); - } - } - - [Symbol.dispose](): void { - this.kill(); - this.cleanup(); - } - - private cleanup() { - for (const dir of this.tempDirs) { - rmSync(dir, { recursive: true, force: true }); - } - } -} diff --git a/__tests__/e2e/helpers/strip-ansi.ts b/__tests__/e2e/helpers/strip-ansi.ts deleted file mode 100644 index 33febce..0000000 --- a/__tests__/e2e/helpers/strip-ansi.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** @reason ANSI escape sequences are control characters by definition. */ -/* eslint-disable no-control-regex */ -const ANSI_REGEX = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\].*?(?:\x07|\x1B\\))/g; - -export function stripAnsi(str: string): string { - return str.replace(ANSI_REGEX, ''); -} diff --git a/__tests__/e2e/onboarding-invocation.e2e.ts b/__tests__/e2e/onboarding-invocation.e2e.ts index f5ea059..321ac89 100644 --- a/__tests__/e2e/onboarding-invocation.e2e.ts +++ b/__tests__/e2e/onboarding-invocation.e2e.ts @@ -3,7 +3,7 @@ import { navigateToPlugins, selectIdeAndOnboard, readInvocation, -} from './helpers/index.js'; +} from './testing-framework/index.js'; const IDE_CASES = [ { diff --git a/__tests__/e2e/skip-onboarding.e2e.ts b/__tests__/e2e/skip-onboarding.e2e.ts index a506b23..1ac1188 100644 --- a/__tests__/e2e/skip-onboarding.e2e.ts +++ b/__tests__/e2e/skip-onboarding.e2e.ts @@ -1,4 +1,4 @@ -import { createSession, navigateToOnboarding, ARROW_DOWN, ENTER } from './helpers/index.js'; +import { createSession, navigateToOnboarding } from './testing-framework/index.js'; describe('when the user skips onboarding', () => { it('shows Done screen without report file or code changes', async () => { @@ -7,8 +7,8 @@ describe('when the user skips onboarding', () => { await navigateToOnboarding(session); // Select "Skip for now" — 2nd option - await session.sendKey(ARROW_DOWN); - await session.sendKey(ENTER); + await session.press('ArrowDown'); + await session.press('Enter'); // Done — no onboarding ran await session.waitForText('Onboarding skipped'); @@ -25,8 +25,8 @@ describe('when the user skips onboarding', () => { await navigateToOnboarding(session); - await session.sendKey(ARROW_DOWN); - await session.sendKey(ENTER); + await session.press('ArrowDown'); + await session.press('Enter'); await session.waitForText('Continue work with Claude Code'); expect(session.snapshot()).toMatchSnapshot('done-skipped-with-ide'); diff --git a/__tests__/e2e/skip-plugins.e2e.ts b/__tests__/e2e/skip-plugins.e2e.ts index ad26041..af426de 100644 --- a/__tests__/e2e/skip-plugins.e2e.ts +++ b/__tests__/e2e/skip-plugins.e2e.ts @@ -1,4 +1,4 @@ -import { createSession, navigateToPlugins, ENTER, ARROW_DOWN } from './helpers/index.js'; +import { createSession, navigateToPlugins } from './testing-framework/index.js'; describe('when the user skips installing AI plugin', () => { it('does not show "Continue work with" on the Done screen', async () => { @@ -7,17 +7,17 @@ describe('when the user skips installing AI plugin', () => { await navigateToPlugins(session); // Select "Skip (install manually later)" — 4th option - await session.sendKeyRepeat(ARROW_DOWN, 3); - await session.sendKey(ENTER); + await session.pressRepeat('ArrowDown', 3); + await session.press('Enter'); // ConnectTools await session.waitForText('Connect Confidence tools?'); - await session.sendKey(ENTER); + await session.press('Enter'); await session.waitForText('Connected successfully'); // OnboardProject await session.waitForText('Start onboarding?'); - await session.sendKey(ENTER); + await session.press('Enter'); await session.waitForText('onboarding complete', { timeout: 30_000 }); // Done — no IDE set, so only "Exit" option (no "Continue work with") @@ -26,7 +26,7 @@ describe('when the user skips installing AI plugin', () => { await session.waitForText('Exit'); expect(session.snapshot()).toMatchSnapshot('done-no-ide'); - await session.sendKey(ENTER); + await session.press('Enter'); const exitCode = await session.waitForExit(); expect(exitCode).toBe(0); }); @@ -37,16 +37,16 @@ describe('when the user skips installing AI plugin', () => { await navigateToPlugins(session); // Skip plugins - await session.sendKeyRepeat(ARROW_DOWN, 3); - await session.sendKey(ENTER); + await session.pressRepeat('ArrowDown', 3); + await session.press('Enter'); // Connect + onboard await session.waitForText('Connect Confidence tools?'); - await session.sendKey(ENTER); + await session.press('Enter'); await session.waitForText('Connected successfully'); await session.waitForText('Start onboarding?'); - await session.sendKey(ENTER); + await session.press('Enter'); await session.waitForText('onboarding complete', { timeout: 30_000 }); // Done — onboarding ran so report file and code changes appear diff --git a/__tests__/e2e/skip-tools.e2e.ts b/__tests__/e2e/skip-tools.e2e.ts index efbb020..f181295 100644 --- a/__tests__/e2e/skip-tools.e2e.ts +++ b/__tests__/e2e/skip-tools.e2e.ts @@ -1,4 +1,4 @@ -import { createSession, navigateToConnectTools, ENTER, ARROW_DOWN } from './helpers/index.js'; +import { createSession, navigateToConnectTools } from './testing-framework/index.js'; describe('when the user skips connecting tools', () => { it('shows skip message and proceeds to onboarding', async () => { @@ -7,15 +7,15 @@ describe('when the user skips connecting tools', () => { await navigateToConnectTools(session); // Select "Skip for now" — 4th option (after "Connect all tools", 2 individual tools) - await session.sendKeyRepeat(ARROW_DOWN, 3); - await session.sendKey(ENTER); + await session.pressRepeat('ArrowDown', 3); + await session.press('Enter'); // Skip confirmation text await session.waitForText('Skipped'); // Still proceeds to OnboardProject await session.waitForText('Start onboarding?'); - await session.sendKey(ENTER); + await session.press('Enter'); await session.waitForText('onboarding complete', { timeout: 30_000 }); await session.waitForText('Confidence is ready'); diff --git a/__tests__/e2e/stale-auth.e2e.ts b/__tests__/e2e/stale-auth.e2e.ts index 11c9811..ea3a5b6 100644 --- a/__tests__/e2e/stale-auth.e2e.ts +++ b/__tests__/e2e/stale-auth.e2e.ts @@ -3,8 +3,7 @@ import { navigatePastWelcome, simulateAuthCallback, buildTestJwt, - ENTER, -} from './helpers/index.js'; +} from './testing-framework/index.js'; function buildExpiredJwt(): string { return buildTestJwt({ exp: Math.floor(Date.now() / 1000) - 3600 }); @@ -38,7 +37,7 @@ describe('when auth token is stale', () => { // Authenticate — sign in fresh await session.waitForText('Sign in to a Confidence account'); - await session.sendKey(ENTER); + await session.press('Enter'); await session.waitForText('Waiting for browser'); await simulateAuthCallback(); await session.waitForText('Authenticated'); diff --git a/__tests__/e2e/stale-mcp-auth.e2e.ts b/__tests__/e2e/stale-mcp-auth.e2e.ts index 67c3714..0abb8e5 100644 --- a/__tests__/e2e/stale-mcp-auth.e2e.ts +++ b/__tests__/e2e/stale-mcp-auth.e2e.ts @@ -5,9 +5,7 @@ import { navigatePastWelcome, navigatePastAuth, buildTestJwt, - ENTER, - ARROW_DOWN, -} from './helpers/index.js'; +} from './testing-framework/index.js'; function buildExpiredJwt(): string { return buildTestJwt({ exp: Math.floor(Date.now() / 1000) - 3600 }); @@ -39,7 +37,7 @@ describe('when MCP config has expired auth tokens', () => { // Welcome await session.waitForText('Start setup'); - await session.sendKey(ENTER); + await session.press('Enter'); // SystemCheck await session.waitForText('All checks passed'); @@ -49,7 +47,7 @@ describe('when MCP config has expired auth tokens', () => { // InstallPlugins await session.waitForText('Which agent tool are you using?'); - await session.sendKey(ENTER); + await session.press('Enter'); // ConnectTools — should detect expired auth await session.waitForText('auth expired'); @@ -58,7 +56,7 @@ describe('when MCP config has expired auth tokens', () => { expect(session.snapshot()).toMatchSnapshot('connect-tools-expired'); // Select "Reconnect all tools" - await session.sendKey(ENTER); + await session.press('Enter'); await session.waitForText('Connected successfully'); expect(session.snapshot()).toMatchSnapshot('connect-tools-reconnected'); }); @@ -72,14 +70,14 @@ describe('when MCP config has expired auth tokens', () => { // InstallPlugins await session.waitForText('Which agent tool are you using?'); - await session.sendKey(ENTER); + await session.press('Enter'); // ConnectTools — skip instead of reconnecting await session.waitForText('Reconnect all tools'); // Select "Skip for now" — 4th option (Reconnect all, 2 individual, Skip) - await session.sendKeyRepeat(ARROW_DOWN, 3); - await session.sendKey(ENTER); + await session.pressRepeat('ArrowDown', 3); + await session.press('Enter'); await session.waitForText('Skipped'); // Proceeds to OnboardProject diff --git a/__tests__/e2e/system-check-failure.e2e.ts b/__tests__/e2e/system-check-failure.e2e.ts index e076794..9defccf 100644 --- a/__tests__/e2e/system-check-failure.e2e.ts +++ b/__tests__/e2e/system-check-failure.e2e.ts @@ -1,10 +1,6 @@ -import { createSession, ENTER, ARROW_DOWN } from './helpers/index.js'; +import { createSession } from './testing-framework/index.js'; import { dirname } from 'node:path'; -// The CLI is spawned via process.execPath (absolute node path), so it runs -// regardless of PATH. But the system check uses execFile('node'/'git'), which -// resolves from PATH — so stripping a binary from PATH makes that check fail. - describe('when system check fails', () => { it('shows error when git is missing', async () => { // PATH with node but no git @@ -12,7 +8,7 @@ describe('when system check fails', () => { // Welcome await session.waitForText('Start setup'); - await session.sendKey(ENTER); + await session.press('Enter'); // SystemCheck — git not found await session.waitForText('System Check'); @@ -27,7 +23,7 @@ describe('when system check fails', () => { using session = createSession({ systemPath: '/usr/bin' }); await session.waitForText('Start setup'); - await session.sendKey(ENTER); + await session.press('Enter'); // SystemCheck — node not found on PATH await session.waitForText('System Check'); @@ -39,12 +35,12 @@ describe('when system check fails', () => { using session = createSession({ systemPath: dirname(process.execPath) }); await session.waitForText('Start setup'); - await session.sendKey(ENTER); + await session.press('Enter'); // SystemCheck — select Quit (2nd option) await session.waitForText('Required tools are missing'); - await session.sendKey(ARROW_DOWN); - await session.sendKey(ENTER); + await session.press('ArrowDown'); + await session.press('Enter'); const exitCode = await session.waitForExit(); expect(exitCode).toBe(1); diff --git a/__tests__/e2e/helpers/env.ts b/__tests__/e2e/testing-framework/env.ts similarity index 52% rename from __tests__/e2e/helpers/env.ts rename to __tests__/e2e/testing-framework/env.ts index 4f9bad9..4055bff 100644 --- a/__tests__/e2e/helpers/env.ts +++ b/__tests__/e2e/testing-framework/env.ts @@ -1,5 +1,11 @@ export { AUTH_CALLBACK_PORT } from '@lib/auth.js'; +/** + * Baseline environment variables injected into every e2e terminal session. + * + * Forces a consistent terminal environment regardless of the host machine's + * locale, CI mode, or color support settings. + */ export const E2E_BASE_ENV: Record = { CI: '0', TERM: 'xterm-256color', @@ -7,6 +13,13 @@ export const E2E_BASE_ENV: Record = { NODE_ENV: 'test', }; +/** + * Builds environment variables that point Confidence service URLs + * at the mock HTTP server. + * + * @param baseUrl - The mock server's base URL (e.g. `http://127.0.0.1:12345`). + * @returns A record of `CONFIDENCE_*` env vars ready to merge into the session env. + */ export function buildMockEnv(baseUrl: string): Record { return { CONFIDENCE_AUTH_URL: baseUrl, diff --git a/__tests__/e2e/testing-framework/index.ts b/__tests__/e2e/testing-framework/index.ts new file mode 100644 index 0000000..18b34fd --- /dev/null +++ b/__tests__/e2e/testing-framework/index.ts @@ -0,0 +1,13 @@ +export { TerminalSession } from './terminal/index.js'; +export { createSession } from './session-factory.js'; +export { buildTestJwt, CHAT_PROMPT_FILE, ONBOARDING_INVOCATION_FILE } from './mocks/index.js'; +export { AUTH_CALLBACK_PORT } from './env.js'; +export { simulateAuthCallback, readInvocation, type Invocation } from './utils.js'; +export { + navigatePastWelcome, + navigatePastAuth, + navigateToPlugins, + navigateToConnectTools, + navigateToOnboarding, + selectIdeAndOnboard, +} from './navigation.js'; diff --git a/__tests__/e2e/testing-framework/mocks/auth.ts b/__tests__/e2e/testing-framework/mocks/auth.ts new file mode 100644 index 0000000..0d817ad --- /dev/null +++ b/__tests__/e2e/testing-framework/mocks/auth.ts @@ -0,0 +1,33 @@ +function base64url(str: string): string { + return Buffer.from(str, 'utf-8').toString('base64url'); +} + +/** + * Builds an unsigned JWT with sensible test defaults. + * + * The token is structurally valid (base64url header + payload + empty + * signature) but uses `alg: 'none'` — sufficient for the wizard's + * client-side expiry check without needing a signing key. + * + * @param claims - Custom claims merged into the payload. Use `exp` to + * control token expiry (epoch seconds). + * @returns A JWT string like `eyJ...eyJ...`. + * + * @example + * ```ts + * buildTestJwt() // valid for 24h + * buildTestJwt({ exp: Math.floor(Date.now() / 1000) - 60 }) // expired 1 min ago + * ``` + */ +export function buildTestJwt(claims: Record = {}): string { + const header = base64url(JSON.stringify({ alg: 'none', typ: 'JWT' })); + const payload = base64url( + JSON.stringify({ + exp: Math.floor(Date.now() / 1000) + 86400, + 'https://confidence.dev/region': 'EU', + email: 'test@example.com', + ...claims, + }), + ); + return `${header}.${payload}.`; +} diff --git a/__tests__/e2e/helpers/mock-claude.ts b/__tests__/e2e/testing-framework/mocks/binaries/claude.ts similarity index 92% rename from __tests__/e2e/helpers/mock-claude.ts rename to __tests__/e2e/testing-framework/mocks/binaries/claude.ts index 97430c3..5d93b1a 100644 --- a/__tests__/e2e/helpers/mock-claude.ts +++ b/__tests__/e2e/testing-framework/mocks/binaries/claude.ts @@ -1,4 +1,4 @@ -import { streamEventsSnippet } from './mock-streaming.js'; +import { streamEventsSnippet } from './streaming.js'; export const CLAUDE_SCRIPT = `#!/usr/bin/env node const fs = require('fs'); diff --git a/__tests__/e2e/helpers/mock-codex.ts b/__tests__/e2e/testing-framework/mocks/binaries/codex.ts similarity index 93% rename from __tests__/e2e/helpers/mock-codex.ts rename to __tests__/e2e/testing-framework/mocks/binaries/codex.ts index 707f829..c92cd3a 100644 --- a/__tests__/e2e/helpers/mock-codex.ts +++ b/__tests__/e2e/testing-framework/mocks/binaries/codex.ts @@ -1,4 +1,4 @@ -import { streamEventsSnippet } from './mock-streaming.js'; +import { streamEventsSnippet } from './streaming.js'; export const CODEX_SCRIPT = `#!/usr/bin/env node const fs = require('fs'); diff --git a/__tests__/e2e/helpers/mock-cursor.ts b/__tests__/e2e/testing-framework/mocks/binaries/cursor.ts similarity index 93% rename from __tests__/e2e/helpers/mock-cursor.ts rename to __tests__/e2e/testing-framework/mocks/binaries/cursor.ts index a841e4a..01181ab 100644 --- a/__tests__/e2e/helpers/mock-cursor.ts +++ b/__tests__/e2e/testing-framework/mocks/binaries/cursor.ts @@ -1,4 +1,4 @@ -import { streamEventsSnippet } from './mock-streaming.js'; +import { streamEventsSnippet } from './streaming.js'; export const CURSOR_SCRIPT = `#!/usr/bin/env node const fs = require('fs'); diff --git a/__tests__/e2e/helpers/mock-binaries.ts b/__tests__/e2e/testing-framework/mocks/binaries/index.ts similarity index 50% rename from __tests__/e2e/helpers/mock-binaries.ts rename to __tests__/e2e/testing-framework/mocks/binaries/index.ts index 8ad92ec..0a6a082 100644 --- a/__tests__/e2e/helpers/mock-binaries.ts +++ b/__tests__/e2e/testing-framework/mocks/binaries/index.ts @@ -1,10 +1,13 @@ import { writeFileSync, mkdirSync, chmodSync } from 'node:fs'; import { join } from 'node:path'; -import { CLAUDE_SCRIPT } from './mock-claude.js'; -import { CURSOR_SCRIPT } from './mock-cursor.js'; -import { CODEX_SCRIPT } from './mock-codex.js'; +import { CLAUDE_SCRIPT } from './claude.js'; +import { CURSOR_SCRIPT } from './cursor.js'; +import { CODEX_SCRIPT } from './codex.js'; +/** Filename the mock IDE binary writes the chat prompt to. */ export const CHAT_PROMPT_FILE = '.e2e-chat-prompt'; + +/** Filename the mock IDE binary writes the onboarding invocation JSON to. */ export const ONBOARDING_INVOCATION_FILE = '.e2e-onboarding-invocation'; function writeMockBinary(dir: string, name: string, script: string): void { @@ -13,6 +16,17 @@ function writeMockBinary(dir: string, name: string, script: string): void { chmodSync(filePath, 0o755); } +/** + * Creates a directory of executable mock IDE binaries (`claude`, `cursor`, + * `codex`, `open`) that the wizard will find on `PATH` during e2e tests. + * + * Each binary is a self-contained Node.js script that simulates the real + * CLI's interface just enough for the wizard's plugin installation and + * onboarding flows to complete. + * + * @param dir - Parent directory in which to create the `bin/` subdirectory. + * @returns The absolute path to the created `bin/` directory. + */ export function createMockBinDir(dir: string): string { const binDir = join(dir, 'bin'); mkdirSync(binDir, { recursive: true }); diff --git a/__tests__/e2e/helpers/mock-streaming.ts b/__tests__/e2e/testing-framework/mocks/binaries/streaming.ts similarity index 71% rename from __tests__/e2e/helpers/mock-streaming.ts rename to __tests__/e2e/testing-framework/mocks/binaries/streaming.ts index bc65339..e0462f0 100644 --- a/__tests__/e2e/helpers/mock-streaming.ts +++ b/__tests__/e2e/testing-framework/mocks/binaries/streaming.ts @@ -26,6 +26,19 @@ function buildEvent(format: EventFormat, message: string, isLast: boolean): stri }); } +/** + * Generates an inline JavaScript snippet that streams mock onboarding + * events to stdout as JSON lines at 200 ms intervals. + * + * The snippet is embedded directly into the mock IDE binary scripts + * so they can simulate the real CLI's streaming output without any + * external dependencies. + * + * @param format - Event JSON structure: `'claude'` for the Claude/Cursor + * streaming format, `'codex'` for the Codex format. + * @returns A string of JavaScript code ready to be interpolated into + * a mock binary template. + */ export function streamEventsSnippet(format: EventFormat): string { const events = STATUS_MESSAGES.map((msg, i) => buildEvent(format, msg, i === STATUS_MESSAGES.length - 1), diff --git a/__tests__/e2e/testing-framework/mocks/index.ts b/__tests__/e2e/testing-framework/mocks/index.ts new file mode 100644 index 0000000..37fabd9 --- /dev/null +++ b/__tests__/e2e/testing-framework/mocks/index.ts @@ -0,0 +1,7 @@ +export { startMockServer, type MockServer } from './server.js'; +export { + createMockBinDir, + CHAT_PROMPT_FILE, + ONBOARDING_INVOCATION_FILE, +} from './binaries/index.js'; +export { buildTestJwt } from './auth.js'; diff --git a/__tests__/e2e/helpers/mock-server.ts b/__tests__/e2e/testing-framework/mocks/server.ts similarity index 65% rename from __tests__/e2e/helpers/mock-server.ts rename to __tests__/e2e/testing-framework/mocks/server.ts index ad0a487..bfec5cd 100644 --- a/__tests__/e2e/helpers/mock-server.ts +++ b/__tests__/e2e/testing-framework/mocks/server.ts @@ -1,15 +1,48 @@ import { createServer, type Server } from 'node:http'; -import { buildTestJwt } from './jwt.js'; -import { buildMockEnv } from './env.js'; - +import { buildTestJwt } from './auth.js'; +import { buildMockEnv } from '../env.js'; + +/** + * A running mock HTTP server that stubs every Confidence backend + * endpoint the wizard calls during e2e tests. + * + * Supports `Symbol.dispose` for use with `using` in global setup. + */ export type MockServer = { + /** The port the server is listening on. */ port: number; + /** Full base URL including port (e.g. `http://127.0.0.1:12345`). */ url: string; + /** The underlying Node.js HTTP server instance. */ server: Server; + /** Environment variables pointing `CONFIDENCE_*` URLs at this server. */ envVars: Record; + /** Shuts down the server. */ [Symbol.dispose](): void; }; +/** + * Starts a mock HTTP server on a random available port that stubs + * the Confidence backend endpoints used during e2e tests. + * + * Endpoints handled: + * - `POST /oauth/token` — returns a test JWT + refresh token + * - `POST /mcp/flags`, `POST /mcp/docs` — returns `{ status: 'ok' }` + * - `GET /skills/:skill/SKILL.md` — returns placeholder skill content + * - `POST .../agentTelemetryKey` — returns a test telemetry key + * - `POST .../events:publish` — accepts and discards telemetry events + * + * @returns A promise that resolves with a disposable {@link MockServer}. + * + * @example + * ```ts + * // In global-setup.ts + * const server = await startMockServer(); + * Object.assign(process.env, server.envVars); + * // teardown: + * server[Symbol.dispose](); + * ``` + */ export function startMockServer(): Promise { return new Promise((resolve, reject) => { const testJwt = buildTestJwt(); diff --git a/__tests__/e2e/testing-framework/navigation.ts b/__tests__/e2e/testing-framework/navigation.ts new file mode 100644 index 0000000..05f1192 --- /dev/null +++ b/__tests__/e2e/testing-framework/navigation.ts @@ -0,0 +1,111 @@ +import { TerminalSession } from './terminal/index.js'; +import { simulateAuthCallback } from './utils.js'; + +/** + * Advances the wizard past the Welcome and SystemCheck screens. + * + * Waits for the welcome CTA, presses Enter, then waits for the system + * check to pass. The session is left at the Authenticate screen. + * + * @param session - An active terminal session showing the Welcome screen. + */ +export async function navigatePastWelcome(session: TerminalSession): Promise { + await session.waitForText('Start setup'); + await session.press('Enter'); + await session.waitForText('All checks passed'); +} + +/** + * Completes the full authentication flow via browser-simulated OAuth. + * + * Initiates sign-in, triggers {@link simulateAuthCallback}, and waits + * for the "Authenticated" confirmation. The session is left at the + * InstallPlugins screen. + * + * @param session - An active terminal session showing the Authenticate screen. + */ +export async function navigatePastAuth(session: TerminalSession): Promise { + await session.waitForText('Sign in to Confidence'); + await session.waitForText('Sign in to a Confidence account'); + await session.press('Enter'); + await session.waitForText('Waiting for browser'); + await simulateAuthCallback(); + await session.waitForText('Authenticated'); +} + +/** + * Navigates from the start through Welcome, SystemCheck, and Auth, + * landing on the InstallPlugins screen with a fresh checkpoint. + * + * @param session - An active terminal session at the Welcome screen. + */ +export async function navigateToPlugins(session: TerminalSession): Promise { + await navigatePastWelcome(session); + await navigatePastAuth(session); + session.checkpoint(); + await session.waitForText('Which agent tool are you using?'); +} + +/** + * Navigates from the start through to the ConnectTools screen, + * selecting the default (first) IDE plugin and setting a checkpoint. + * + * @param session - An active terminal session at the Welcome screen. + */ +export async function navigateToConnectTools(session: TerminalSession): Promise { + await navigateToPlugins(session); + await session.waitForText('Skip (install manually later)'); + session.checkpoint(); + await session.press('Enter'); + await session.waitForText('Connect Confidence tools?'); +} + +/** + * Navigates from the start through to the OnboardProject screen, + * accepting default tools connection and setting a checkpoint. + * + * @param session - An active terminal session at the Welcome screen. + */ +export async function navigateToOnboarding(session: TerminalSession): Promise { + await navigateToConnectTools(session); + session.checkpoint(); + await session.press('Enter'); + await session.waitForText('Start onboarding?'); +} + +/** + * Selects an IDE from the InstallPlugins list and runs through the + * remaining wizard screens (ConnectTools + OnboardProject) until + * onboarding completes. + * + * Handles the case where ConnectTools may auto-advance when MCP + * servers are already registered globally. + * + * @param session - An active terminal session at the InstallPlugins screen. + * @param downPresses - How many times to press ArrowDown to reach the + * desired IDE option (0 = first item, 1 = second, etc.). + * + * @example + * ```ts + * await navigateToPlugins(session); + * await selectIdeAndOnboard(session, 1); // select Cursor (2nd item) + * ``` + */ +export async function selectIdeAndOnboard( + session: TerminalSession, + downPresses: number, +): Promise { + await session.pressRepeat('ArrowDown', downPresses); + await session.press('Enter'); + + const matched = await session.waitForText(['Start onboarding?', 'Connect Confidence tools?']); + + if (matched === 'Connect Confidence tools?') { + await session.press('Enter'); + await session.waitForText('Connected successfully'); + } + + await session.waitForText('Start onboarding?'); + await session.press('Enter'); + await session.waitForText('onboarding complete'); +} diff --git a/__tests__/e2e/testing-framework/session-factory.ts b/__tests__/e2e/testing-framework/session-factory.ts new file mode 100644 index 0000000..b6dd11a --- /dev/null +++ b/__tests__/e2e/testing-framework/session-factory.ts @@ -0,0 +1,91 @@ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { TerminalSession } from './terminal/index.js'; + +/** Determines which project scaffold is written to the temp directory. */ +type ProjectType = 'react' | 'empty'; + +/** + * Creates an isolated {@link TerminalSession} pre-configured for e2e testing. + * + * Each call sets up a fresh temporary project directory with mock IDE + * binaries on `PATH`, optional pre-seeded auth tokens, and the standard + * e2e environment. The temp directory is registered for automatic cleanup + * when the session is disposed (via `using`). + * + * @param options - Session configuration. + * @param options.project - Project scaffold type. `'react'` writes a + * `package.json` with a React dependency so the wizard can auto-detect + * the framework. `'empty'` creates a bare directory. @defaultValue `'react'` + * @param options.extraArgs - Additional CLI arguments. + * @param options.env - Extra environment variables. + * @param options.token - Pre-seed a Confidence auth token (JWT string). + * When set, the session writes the token to the temp directory so the + * wizard finds it on startup. + * @param options.refreshToken - Refresh token written alongside the auth + * token. Pass `null` to simulate a missing refresh token. + * @defaultValue `'e2e-refresh-token'` + * @param options.systemPath - Override `PATH` to control which system + * binaries the wizard's system check can find. + * @returns A disposable {@link TerminalSession} ready for interaction. + * + * @example + * ```ts + * using session = createSession(); + * await session.waitForText('Welcome'); + * + * // With a pre-seeded expired token + * using session = createSession({ token: buildTestJwt({ exp: 0 }) }); + * ``` + */ +export function createSession({ + project = 'react', + extraArgs = [], + env = {}, + token, + refreshToken = 'e2e-refresh-token', + systemPath, +}: { + project?: ProjectType; + extraArgs?: string[]; + env?: Record; + token?: string; + refreshToken?: string | null; + systemPath?: string; +} = {}): TerminalSession { + const mockBinDir = process.env.E2E_MOCK_BIN_DIR!; + const projectDir = mkdtempSync('/tmp/e2e-project-'); + + if (project === 'react') { + writeFileSync( + join(projectDir, 'package.json'), + JSON.stringify({ dependencies: { react: '^19.0.0' } }), + ); + } + + const sessionEnv: Record = { + PATH: `${mockBinDir}:${systemPath ?? process.env.PATH}`, + ...env, + }; + + if (token) { + const tokenDir = mkdtempSync(join(tmpdir(), 'e2e-tmp-')); + + writeFileSync(join(tokenDir, 'confidence_token'), token, 'utf-8'); + if (refreshToken) { + writeFileSync(join(tokenDir, 'confidence_refresh_token'), refreshToken, 'utf-8'); + } + + sessionEnv.TMPDIR = tokenDir; + } + + const session = new TerminalSession({ + args: ['--debug', '--dir', projectDir, ...extraArgs], + env: sessionEnv, + cwd: projectDir, + }); + + session.addTempDir(projectDir); + return session; +} diff --git a/__tests__/e2e/testing-framework/terminal/index.ts b/__tests__/e2e/testing-framework/terminal/index.ts new file mode 100644 index 0000000..3f775d3 --- /dev/null +++ b/__tests__/e2e/testing-framework/terminal/index.ts @@ -0,0 +1,2 @@ +export { TerminalSession } from './session.js'; +export type { KeyName, Modifiers } from './key-map.js'; diff --git a/__tests__/e2e/testing-framework/terminal/key-map.ts b/__tests__/e2e/testing-framework/terminal/key-map.ts new file mode 100644 index 0000000..43dd20f --- /dev/null +++ b/__tests__/e2e/testing-framework/terminal/key-map.ts @@ -0,0 +1,93 @@ +const ESC = '\x1b'; + +/** + * Maps human-readable key names to their terminal escape sequences. + * + * Key names follow the DOM {@link https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key KeyboardEvent.key} + * convention so they feel familiar to web developers. + * + * @example + * ```ts + * KEY_MAP.Enter // '\r' + * KEY_MAP.ArrowDown // '\x1b[B' + * ``` + */ +const KEY_MAP = { + Enter: '\r', + ArrowDown: `${ESC}[B`, + ArrowUp: `${ESC}[A`, + ArrowLeft: `${ESC}[D`, + ArrowRight: `${ESC}[C`, + Escape: ESC, + Backspace: '\x7f', + Tab: '\t', + Delete: `${ESC}[3~`, + Home: `${ESC}[H`, + End: `${ESC}[F`, + Space: ' ', +} as const; + +/** A key name recognized by {@link KEY_MAP}. */ +type KeyName = keyof typeof KEY_MAP; + +/** + * Optional keyboard modifiers applied when resolving a key. + * + * Only meaningful for single-character keys — named keys like `'ArrowDown'` + * have no distinct modified escape sequence in xterm. + * + * @see {@link resolveKey} + */ +type Modifiers = { + ctrl?: boolean; + alt?: boolean; + shift?: boolean; +}; + +/** + * Resolves a key name (or single character) into the raw escape sequence + * that the terminal expects, optionally applying modifier keys. + * + * Named keys are looked up in {@link KEY_MAP}. Single characters pass through + * as-is. Multi-character strings not in the map throw to catch typos like + * `'enter'` (should be `'Enter'`). + * + * @param key - A {@link KeyName} or a single character (e.g. `'a'`). + * @param modifiers - Optional {@link Modifiers} (`ctrl`, `alt`, `shift`). + * @returns The raw bytes to write to the PTY. + * @throws {Error} If `key` is a multi-character string not in {@link KEY_MAP}. + * + * @example + * ```ts + * resolveKey('Enter') // '\r' + * resolveKey('c', { ctrl: true }) // '\x03' (SIGINT) + * resolveKey('x', { alt: true }) // '\x1bx' (ESC-prefixed) + * ``` + */ +function resolveKey(key: string, modifiers?: Modifiers): string { + const mapped = KEY_MAP[key as KeyName] as string | undefined; + + if (!mapped && key.length > 1) { + throw new Error( + `Unknown key name: "${key}". Use a named key (${Object.keys(KEY_MAP).join(', ')}) or a single character.`, + ); + } + + const base = mapped ?? key; + + if (modifiers?.ctrl && base.length === 1) { + const code = base.toLowerCase().charCodeAt(0) - 96; + if (code >= 1 && code <= 26) { + return String.fromCharCode(code); + } + } + + if (modifiers?.alt && base.length === 1) { + return `${ESC}${base}`; + } + + return base; +} + +export { KEY_MAP, resolveKey }; +export type { KeyName, Modifiers }; diff --git a/__tests__/e2e/helpers/screen-buffer.ts b/__tests__/e2e/testing-framework/terminal/screen-buffer.ts similarity index 82% rename from __tests__/e2e/helpers/screen-buffer.ts rename to __tests__/e2e/testing-framework/terminal/screen-buffer.ts index 8cc7e11..f64296a 100644 --- a/__tests__/e2e/helpers/screen-buffer.ts +++ b/__tests__/e2e/testing-framework/terminal/screen-buffer.ts @@ -186,12 +186,38 @@ class ScreenBuffer { } } +/** + * Renders a raw ANSI byte stream into plain text as it would appear on a + * physical terminal of the given dimensions. + * + * Internally feeds the data through a VT100-compatible {@link ScreenBuffer} + * that handles cursor movement, line wrapping, scrolling, and erase commands, + * then serializes the resulting character grid. + * + * @param raw - Raw terminal output including ANSI escape sequences. + * @param cols - Terminal width in columns. + * @param rows - Terminal height in rows. + * @returns Plain text with trailing whitespace trimmed per line. + */ export function renderScreen(raw: string, cols: number, rows: number): string { const buf = new ScreenBuffer(rows, cols); buf.write(raw); return buf.toText(); } +/** + * Replaces environment-specific values in rendered terminal output with + * stable placeholders so Vitest snapshots don't break across machines. + * + * Normalizations applied: + * - Project directory path to `` + * - Semantic version numbers (`v1.2.3`, `1.2.3`) to `vX.Y.Z` / `X.Y.Z` + * - Runs of 4+ consecutive newlines collapsed to 3 + * + * @param text - Plain-text terminal output (from {@link renderScreen}). + * @param cwd - The project directory path to replace. + * @returns Normalized text safe for snapshot comparison. + */ export function normalizeSnapshot(text: string, cwd: string): string { let result = text.replaceAll(cwd, ''); result = result.replace(/v\d+\.\d+\.\d+(-[\w.]+)?/g, (m) => 'vX.Y.Z'.padEnd(m.length)); diff --git a/__tests__/e2e/testing-framework/terminal/session.ts b/__tests__/e2e/testing-framework/terminal/session.ts new file mode 100644 index 0000000..c11c964 --- /dev/null +++ b/__tests__/e2e/testing-framework/terminal/session.ts @@ -0,0 +1,427 @@ +import { spawn as ptySpawn, type IPty } from 'node-pty'; +import { resolve } from 'node:path'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { stripAnsi } from './strip-ansi.js'; +import { renderScreen, normalizeSnapshot } from './screen-buffer.js'; +import { E2E_BASE_ENV } from '../env.js'; +import { resolveKey, type Modifiers } from './key-map.js'; + +const CLI_PATH = resolve(import.meta.dirname, '../../../../dist/bin/cli.js'); +const DEFAULT_COLS = 100; +const DEFAULT_ROWS = 40; +const DEFAULT_TIMEOUT = 15_000; + +/** + * Options for constructing a {@link TerminalSession}. + * + * @see {@link TerminalSession} + */ +type SessionOptions = { + /** CLI arguments appended after the binary path. @defaultValue `['--debug']` */ + args?: string[]; + /** Extra environment variables merged on top of the base e2e env. */ + env?: Record; + /** Terminal width in columns. @defaultValue `100` */ + cols?: number; + /** Terminal height in rows. @defaultValue `40` */ + rows?: number; + /** Working directory for the spawned process. @defaultValue `process.cwd()` */ + cwd?: string; +}; + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Drives a CLI process inside a pseudo-terminal for end-to-end testing. + * + * Wraps `node-pty` to spawn the wizard binary in an isolated environment, + * then exposes high-level methods for sending input, waiting for output, + * and capturing snapshots. Supports the TC39 Explicit Resource Management + * protocol (`using`) for automatic cleanup. + * + * @example + * ```ts + * using session = new TerminalSession({ args: ['--debug'] }); + * await session.waitForText('Welcome'); + * await session.press('Enter'); + * expect(session.snapshot()).toMatchSnapshot('welcome'); + * ``` + */ +export class TerminalSession { + private pty: IPty; + private rawOutput = ''; + private cachedScreen = ''; + private cachedRawLength = 0; + private markPosition = 0; + private rawMarkPosition = 0; + private exitCode: number | null = null; + private exitPromise: Promise; + private tempDirs: string[] = []; + + readonly cwd: string; + readonly cols: number; + readonly rows: number; + + constructor(options: SessionOptions = {}) { + const { args = ['--debug'], env = {}, cols = DEFAULT_COLS, rows = DEFAULT_ROWS, cwd } = options; + + this.cols = cols; + this.rows = rows; + + const isolatedTmpDir = env.TMPDIR ?? mkdtempSync(join(tmpdir(), 'e2e-')); + this.cwd = cwd ?? process.cwd(); + this.tempDirs.push(isolatedTmpDir); + + this.pty = ptySpawn(process.execPath, [CLI_PATH, ...args], { + name: 'xterm-256color', + cols, + rows, + cwd: this.cwd, + env: { + ...process.env, + ...E2E_BASE_ENV, + ...env, + HOME: isolatedTmpDir, + TMPDIR: isolatedTmpDir, + }, + }); + + this.pty.onData((data) => { + this.rawOutput += data; + }); + + this.exitPromise = new Promise((resolve) => { + this.pty.onExit(({ exitCode }) => { + this.exitCode = exitCode; + resolve(exitCode); + }); + }); + } + + /** + * The full terminal output with ANSI escape codes stripped. + * + * Lazily re-computed only when new data arrives from the PTY. + * Use {@link screenSinceCheckpoint} for assertions scoped to the current screen. + */ + get screen(): string { + if (this.rawOutput.length !== this.cachedRawLength) { + this.cachedScreen = stripAnsi(this.rawOutput); + this.cachedRawLength = this.rawOutput.length; + } + return this.cachedScreen; + } + + /** + * Terminal output accumulated since the last {@link checkpoint} call. + * + * This is the default haystack for {@link waitForText} and + * {@link waitForPattern}, preventing stale text from earlier screens + * from producing false-positive matches. + */ + get screenSinceCheckpoint(): string { + return this.screen.slice(this.markPosition); + } + + /** + * Registers a temporary directory for cleanup when the session is disposed. + * + * @param dir - Absolute path to remove recursively on disposal. + */ + addTempDir(dir: string): void { + this.tempDirs.push(dir); + } + + /** + * Marks the current output position so that subsequent calls to + * {@link waitForText}, {@link waitForPattern}, and {@link snapshot} + * only consider output produced after this point. + * + * Call this between wizard screens to scope assertions to the + * active screen and avoid matching leftover text from previous ones. + * + * @example + * ```ts + * await session.waitForText('System Check'); + * session.checkpoint(); + * // From here, waitForText only searches new output + * await session.waitForText('All checks passed'); + * ``` + */ + checkpoint(): void { + this.markPosition = this.screen.length; + this.rawMarkPosition = this.rawOutput.length; + } + + /** + * Renders the terminal output since the last {@link checkpoint} into a + * normalized plain-text grid suitable for Vitest snapshot assertions. + * + * The raw ANSI stream is fed through a VT100 screen buffer emulator, + * then project-specific values (paths, version numbers) are replaced + * with stable placeholders so snapshots don't break on unrelated changes. + * + * @returns A deterministic string ready for `toMatchSnapshot()`. + * + * @example + * ```ts + * session.checkpoint(); + * await session.press('Enter'); + * await session.waitForText('All checks passed'); + * expect(session.snapshot()).toMatchSnapshot('system-check'); + * ``` + */ + snapshot(): string { + const raw = this.rawOutput.slice(this.rawMarkPosition); + const rendered = renderScreen(raw, this.cols, this.rows); + return normalizeSnapshot(rendered, this.cwd); + } + + /** + * Writes raw bytes to the PTY with no delay or key resolution. + * + * Prefer {@link press} for keyboard input. Use this only when you + * need to send an exact byte sequence that `press` cannot produce. + * + * @param data - Raw string to write to the PTY. + */ + write(data: string): void { + this.pty.write(data); + } + + /** + * Sends a single key press to the terminal and waits for it to settle. + * + * Accepts named keys (`'Enter'`, `'ArrowDown'`, etc.) following the DOM + * `KeyboardEvent.key` convention, or single characters (`'a'`, `'1'`). + * Named keys are resolved to their xterm escape sequences via the + * internal key map. + * + * The second parameter is overloaded: pass a {@link Modifiers} object + * for modified keys, or a `number` to override the settle delay. + * + * @param key - A named key or single character. + * @param modifiersOrSettleMs - Modifier keys (`{ ctrl: true }`) or settle delay in ms. + * @param settleMs - Settle delay in ms when the second param is a {@link Modifiers} object. + * @defaultValue `100` + * + * @example + * ```ts + * await session.press('Enter'); + * await session.press('ArrowDown'); + * await session.press('c', { ctrl: true }); // sends Ctrl+C + * await session.press('Enter', 200); // 200ms settle + * ``` + */ + async press( + key: string, + modifiersOrSettleMs?: Modifiers | number, + settleMs = 100, + ): Promise { + let modifiers: Modifiers | undefined; + let settle: number; + + if (typeof modifiersOrSettleMs === 'number') { + settle = modifiersOrSettleMs; + } else { + modifiers = modifiersOrSettleMs; + settle = settleMs; + } + + this.pty.write(resolveKey(key, modifiers)); + await delay(settle); + } + + /** + * Sends a key press multiple times in sequence, settling between each press. + * + * Useful for navigating lists where the target item is N positions away. + * + * @param key - A named key or single character (same as {@link press}). + * @param count - How many times to press the key. + * @param modifiersOrSettleMs - Modifier keys or settle delay (same as {@link press}). + * @param settleMs - Settle delay in ms when the third param is a {@link Modifiers} object. + * @defaultValue `100` + * + * @example + * ```ts + * // Select the 4th item in a list (skip 3 down from the first) + * await session.pressRepeat('ArrowDown', 3); + * await session.press('Enter'); + * ``` + */ + async pressRepeat( + key: string, + count: number, + modifiersOrSettleMs?: Modifiers | number, + settleMs = 100, + ): Promise { + for (let i = 0; i < count; i++) { + await this.press(key, modifiersOrSettleMs, settleMs); + } + } + + /** + * Polls the terminal output until the given text appears, or throws on timeout. + * + * By default searches only output produced since the last {@link checkpoint}, + * which prevents false positives from earlier screens. Pass + * `{ sinceCheckpoint: false }` to search the entire output history. + * + * When an array is passed, resolves as soon as any of the strings is found + * and returns the matched string — useful when the screen may show one of + * several possible states. + * + * Uses exponential backoff (25 ms to 200 ms) to balance responsiveness + * with CPU usage. + * + * @param text - A string or array of candidate strings to search for. + * @param options - Optional overrides. + * @param options.timeout - Maximum time to wait in ms. @defaultValue `15_000` + * @param options.sinceCheckpoint - Scope the search to post-checkpoint output. + * @defaultValue `true` + * @returns The matched string (useful when `text` is an array). + * @throws {Error} On timeout, with the last 2 000 characters of output in the message. + * + * @example + * ```ts + * await session.waitForText('All checks passed'); + * + * // Branch on which screen appeared + * const matched = await session.waitForText(['Start onboarding?', 'Connect tools?']); + * if (matched === 'Connect tools?') { ... } + * ``` + */ + async waitForText( + text: string | string[], + { timeout = DEFAULT_TIMEOUT, sinceCheckpoint = true } = {}, + ): Promise { + const targets = Array.isArray(text) ? text : [text]; + const deadline = Date.now() + timeout; + let poll = 25; + + while (Date.now() < deadline) { + const haystack = sinceCheckpoint ? this.screenSinceCheckpoint : this.screen; + const match = targets.find((t) => haystack.includes(t)); + if (match) return match; + + await delay(poll); + poll = Math.min(poll * 2, 200); + } + + const label = + targets.length === 1 + ? `"${targets[0]}"` + : `any of [${targets.map((t) => `"${t}"`).join(', ')}]`; + + throw new Error( + `Timed out waiting for ${label} after ${timeout}ms.\n\nLast output:\n${this.screen.slice(-2000)}`, + ); + } + + /** + * Polls the terminal output until a regex pattern matches, or throws on timeout. + * + * Behaves like {@link waitForText} but accepts a `RegExp` and returns the + * full `RegExpMatchArray`, giving access to capture groups. + * + * @param pattern - The regular expression to test against terminal output. + * @param options - Optional overrides. + * @param options.timeout - Maximum time to wait in ms. @defaultValue `15_000` + * @param options.sinceCheckpoint - Scope the search to post-checkpoint output. + * @defaultValue `true` + * @returns The `RegExpMatchArray` from the first successful match. + * @throws {Error} On timeout, with the last 2 000 characters of output in the message. + * + * @example + * ```ts + * const match = await session.waitForPattern(/v(\d+\.\d+\.\d+)/); + * const version = match[1]; // e.g. '1.2.3' + * ``` + */ + async waitForPattern( + pattern: RegExp, + { timeout = DEFAULT_TIMEOUT, sinceCheckpoint = true } = {}, + ): Promise { + const deadline = Date.now() + timeout; + let poll = 25; + + while (Date.now() < deadline) { + const haystack = sinceCheckpoint ? this.screenSinceCheckpoint : this.screen; + const match = haystack.match(pattern); + if (match) return match; + + await delay(poll); + poll = Math.min(poll * 2, 200); + } + + throw new Error( + `Timed out waiting for pattern ${pattern} after ${timeout}ms.\n\nLast output:\n${this.screen.slice(-2000)}`, + ); + } + + /** + * Waits for the CLI process to exit and returns its exit code. + * + * If the process hasn't exited within the timeout it is killed with + * `SIGKILL` and the promise resolves with whatever code the OS assigns. + * + * @param timeout - Maximum time to wait in ms. @defaultValue `15_000` + * @returns The process exit code (0 = success). + * + * @example + * ```ts + * await session.press('Enter'); // trigger quit + * const exitCode = await session.waitForExit(); + * expect(exitCode).toBe(0); + * ``` + */ + async waitForExit(timeout = DEFAULT_TIMEOUT): Promise { + const timer = setTimeout(() => { + this.pty.kill(); + }, timeout); + + const code = await this.exitPromise; + clearTimeout(timer); + return code; + } + + /** + * Immediately kills the PTY process if it is still running. + * + * Prefer `using` (which calls this via {@link [Symbol.dispose]}) over + * manual `kill()` calls so cleanup is guaranteed even on test failure. + */ + kill(): void { + if (this.exitCode === null) { + this.pty.kill(); + } + } + + /** + * Disposes the session: kills the PTY and removes all registered temp dirs. + * + * Called automatically by the `using` statement (TC39 Explicit Resource + * Management), ensuring cleanup even when a test throws. + * + * @example + * ```ts + * using session = createSession(); + * // session is disposed when the block exits + * ``` + */ + [Symbol.dispose](): void { + this.kill(); + this.cleanup(); + } + + private cleanup() { + for (const dir of this.tempDirs) { + rmSync(dir, { recursive: true, force: true }); + } + } +} diff --git a/__tests__/e2e/testing-framework/terminal/strip-ansi.ts b/__tests__/e2e/testing-framework/terminal/strip-ansi.ts new file mode 100644 index 0000000..a2a87e6 --- /dev/null +++ b/__tests__/e2e/testing-framework/terminal/strip-ansi.ts @@ -0,0 +1,16 @@ +/** @reason ANSI escape sequences are control characters by definition. */ +/* eslint-disable no-control-regex */ +const ANSI_REGEX = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\].*?(?:\x07|\x1B\\))/g; + +/** + * Removes all ANSI escape sequences (colors, cursor control, OSC) from a string. + * + * Used by {@link TerminalSession.screen} to produce a plain-text view + * of the terminal output for substring matching in assertions. + * + * @param str - Raw string potentially containing ANSI escape codes. + * @returns The string with all escape sequences removed. + */ +export function stripAnsi(str: string): string { + return str.replace(ANSI_REGEX, ''); +} diff --git a/__tests__/e2e/testing-framework/utils.ts b/__tests__/e2e/testing-framework/utils.ts new file mode 100644 index 0000000..1823cfb --- /dev/null +++ b/__tests__/e2e/testing-framework/utils.ts @@ -0,0 +1,72 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { AUTH_CALLBACK_PORT } from './env.js'; +import { ONBOARDING_INVOCATION_FILE } from './mocks/index.js'; + +const DEFAULT_TIMEOUT = 15_000; + +/** + * Simulates the browser-side OAuth callback that the wizard waits for + * during authentication. + * + * Polls the wizard's local callback server until it accepts a request, + * using exponential backoff. The wizard must already be displaying + * "Waiting for browser" before this is called. + * + * @throws {Error} If the callback server is not ready within 15 seconds. + * + * @example + * ```ts + * await session.press('Enter'); // initiate sign-in + * await session.waitForText('Waiting for browser'); + * await simulateAuthCallback(); + * await session.waitForText('Authenticated'); + * ``` + */ +export async function simulateAuthCallback(): Promise { + const deadline = Date.now() + DEFAULT_TIMEOUT; + let backoff = 50; + + while (Date.now() < deadline) { + try { + await fetch(`http://localhost:${AUTH_CALLBACK_PORT}/callback?code=test-auth-code`); + return; + } catch { + await new Promise((resolve) => setTimeout(resolve, backoff)); + backoff = Math.min(backoff * 2, 500); + } + } + throw new Error(`Auth callback server not ready after ${DEFAULT_TIMEOUT / 1000}s`); +} + +/** + * The recorded invocation of an IDE CLI during onboarding, + * written to disk by the mock binary so tests can assert on the + * exact command, arguments, and prompt that were passed. + */ +export type Invocation = { + /** The IDE binary name (e.g. `'claude'`, `'cursor'`, `'codex'`). */ + command: string; + /** The full argument list passed to the binary. */ + args: string[]; + /** The onboarding prompt sent to the IDE. */ + prompt: string; +}; + +/** + * Reads the onboarding invocation JSON that the mock IDE binary wrote + * to the project directory during onboarding. + * + * @param cwd - The project directory (typically `session.cwd`). + * @returns The parsed {@link Invocation} containing the command, args, and prompt. + * + * @example + * ```ts + * const invocation = readInvocation(session.cwd); + * expect(invocation.command).toBe('claude'); + * expect(invocation.prompt).toContain('Confidence SDK'); + * ``` + */ +export function readInvocation(cwd: string): Invocation { + return JSON.parse(readFileSync(join(cwd, ONBOARDING_INVOCATION_FILE), 'utf-8')) as Invocation; +} diff --git a/__tests__/e2e/welcome-navigation.e2e.ts b/__tests__/e2e/welcome-navigation.e2e.ts index fac6754..55dcb8f 100644 --- a/__tests__/e2e/welcome-navigation.e2e.ts +++ b/__tests__/e2e/welcome-navigation.e2e.ts @@ -1,4 +1,4 @@ -import { createSession, ENTER, ARROW_DOWN } from './helpers/index.js'; +import { createSession } from './testing-framework/index.js'; describe('welcome screen navigation', () => { it('navigates to About screen', async () => { @@ -7,8 +7,8 @@ describe('welcome screen navigation', () => { await session.waitForText('Confidence Quickstart'); await session.waitForText('Start setup'); - await session.sendKeyRepeat(ARROW_DOWN, 2); - await session.sendKey(ENTER); + await session.pressRepeat('ArrowDown', 2); + await session.press('Enter'); await session.waitForText('About Confidence'); expect(session.snapshot()).toMatchSnapshot('about'); @@ -20,8 +20,8 @@ describe('welcome screen navigation', () => { await session.waitForText('Confidence Quickstart'); await session.waitForText('Start setup'); - await session.sendKey(ARROW_DOWN); - await session.sendKey(ENTER); + await session.press('ArrowDown'); + await session.press('Enter'); await session.waitForText('Select Framework'); expect(session.snapshot()).toMatchSnapshot('select-framework'); @@ -33,8 +33,8 @@ describe('welcome screen navigation', () => { await session.waitForText('Confidence Quickstart'); await session.waitForText('Start setup'); - await session.sendKeyRepeat(ARROW_DOWN, 3); - await session.sendKey(ENTER); + await session.pressRepeat('ArrowDown', 3); + await session.press('Enter'); expect(session.snapshot()).toMatchSnapshot('welcome-quit-selected'); const exitCode = await session.waitForExit(); From 7f7f7f130d496ff7b30e4a5cde38f4109142f654 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 31 Jul 2026 11:50:55 +0200 Subject: [PATCH 2/9] refactor: split project scaffold --- .../e2e/testing-framework/project-scaffold.ts | 49 +++++++++++++++++++ .../e2e/testing-framework/session-factory.ts | 13 +---- 2 files changed, 51 insertions(+), 11 deletions(-) create mode 100644 __tests__/e2e/testing-framework/project-scaffold.ts diff --git a/__tests__/e2e/testing-framework/project-scaffold.ts b/__tests__/e2e/testing-framework/project-scaffold.ts new file mode 100644 index 0000000..5e99f15 --- /dev/null +++ b/__tests__/e2e/testing-framework/project-scaffold.ts @@ -0,0 +1,49 @@ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * Identifies a pre-built project scaffold. + * + * Each variant produces a minimal project directory that triggers a + * specific code path in the wizard's framework-detection logic. + * + * - `'react'` — contains a `package.json` with a React 19 dependency + * so the wizard auto-detects the React framework. + * - `'empty'` — bare directory with no files, forcing the wizard to + * prompt the user for framework selection. + */ +type ProjectType = 'react' | 'empty'; + +const SCAFFOLDS: Record void> = { + react(dir) { + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ dependencies: { react: '^19.0.0' } }), + ); + }, + empty() {}, +}; + +/** + * Creates an isolated temporary directory populated with the requested + * project scaffold. + * + * @param type - Which scaffold to use. + * @returns Absolute path to the created project directory. + * + * @example + * ```ts + * const dir = createProjectDir('react'); + * // dir contains package.json with { dependencies: { react: '^19.0.0' } } + * + * const dir = createProjectDir('empty'); + * // dir is an empty temporary directory + * ``` + */ +export function createProjectDir(type: ProjectType): string { + const dir = mkdtempSync('/tmp/e2e-project-'); + SCAFFOLDS[type](dir); + return dir; +} + +export type { ProjectType }; diff --git a/__tests__/e2e/testing-framework/session-factory.ts b/__tests__/e2e/testing-framework/session-factory.ts index b6dd11a..daff07c 100644 --- a/__tests__/e2e/testing-framework/session-factory.ts +++ b/__tests__/e2e/testing-framework/session-factory.ts @@ -2,9 +2,7 @@ import { mkdtempSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { TerminalSession } from './terminal/index.js'; - -/** Determines which project scaffold is written to the temp directory. */ -type ProjectType = 'react' | 'empty'; +import { createProjectDir, type ProjectType } from './project-scaffold.js'; /** * Creates an isolated {@link TerminalSession} pre-configured for e2e testing. @@ -55,14 +53,7 @@ export function createSession({ systemPath?: string; } = {}): TerminalSession { const mockBinDir = process.env.E2E_MOCK_BIN_DIR!; - const projectDir = mkdtempSync('/tmp/e2e-project-'); - - if (project === 'react') { - writeFileSync( - join(projectDir, 'package.json'), - JSON.stringify({ dependencies: { react: '^19.0.0' } }), - ); - } + const projectDir = createProjectDir(project); const sessionEnv: Record = { PATH: `${mockBinDir}:${systemPath ?? process.env.PATH}`, From d7def8dd806ee510ae470b24cd7a3ba290342283 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 31 Jul 2026 11:59:26 +0200 Subject: [PATCH 3/9] test: refactor scaffolds for integration tests --- .vscode/settings.json | 3 ++ __tests__/providers/detect.test.ts | 2 +- __tests__/providers/helpers/project.ts | 18 ++++++++ __tests__/ui/helpers/index.ts | 2 +- __tests__/ui/helpers/project.ts | 28 ++++++++++--- __tests__/ui/screens/OnboardingFlow.test.tsx | 44 +++++++------------- __tests__/ui/screens/WelcomeScreen.test.tsx | 6 +-- 7 files changed, 63 insertions(+), 40 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 __tests__/providers/helpers/project.ts diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..033fcf2 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "cSpell.words": ["posthog", "statsig"] +} diff --git a/__tests__/providers/detect.test.ts b/__tests__/providers/detect.test.ts index 94332b1..a058492 100644 --- a/__tests__/providers/detect.test.ts +++ b/__tests__/providers/detect.test.ts @@ -1,7 +1,7 @@ import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { detectProviders } from '@providers/index.js'; -import { createProjectDir } from '../ui/helpers/project.js'; +import { createProjectDir } from './helpers/project.js'; describe('detectProviders', () => { describe('when project has no manifest files', () => { diff --git a/__tests__/providers/helpers/project.ts b/__tests__/providers/helpers/project.ts new file mode 100644 index 0000000..22953c9 --- /dev/null +++ b/__tests__/providers/helpers/project.ts @@ -0,0 +1,18 @@ +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +export function createProjectDir(deps: Record | null = null) { + const dir = mkdtempSync(join(tmpdir(), 'provider-test-')); + + if (deps) { + writeFileSync(join(dir, 'package.json'), JSON.stringify({ dependencies: deps })); + } + + return { + path: dir, + [Symbol.dispose]() { + rmSync(dir, { recursive: true, force: true }); + }, + }; +} diff --git a/__tests__/ui/helpers/index.ts b/__tests__/ui/helpers/index.ts index c8f571a..b089b38 100644 --- a/__tests__/ui/helpers/index.ts +++ b/__tests__/ui/helpers/index.ts @@ -3,6 +3,6 @@ export { renderScreen, renderApp } from './render.js'; export { ARROW_DOWN, ARROW_UP, ENTER, ESCAPE } from './keys.js'; export { delay } from './delay.js'; export { waitFor } from './waitFor.js'; -export { createProjectDir } from './project.js'; +export { createProjectDir, type ProjectType } from './project.js'; export { createFakeChild, mockNextSpawn } from './spawn.js'; export { buildTestJwt, buildExpiredJwt, buildAuthState } from './auth.js'; diff --git a/__tests__/ui/helpers/project.ts b/__tests__/ui/helpers/project.ts index 908acf6..2924be8 100644 --- a/__tests__/ui/helpers/project.ts +++ b/__tests__/ui/helpers/project.ts @@ -1,13 +1,29 @@ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { noop } from '@lib/noop.js'; -export function createProjectDir(deps: Record | null = { react: '^19.0.0' }) { - const dir = mkdtempSync(join(tmpdir(), 'wizard-test-')); +type ProjectType = 'react' | 'empty' | 'react-statsig' | 'react-posthog-statsig'; + +function writeDeps(dir: string, dependencies: Record): void { + writeFileSync(join(dir, 'package.json'), JSON.stringify({ dependencies })); +} - if (deps) { - writeFileSync(join(dir, 'package.json'), JSON.stringify({ dependencies: deps })); - } +const SCAFFOLDS: Record void> = { + empty: noop, + react: (dir) => writeDeps(dir, { react: '^19.0.0' }), + 'react-statsig': (dir) => writeDeps(dir, { react: '^19.0.0', '@statsig/js-client': '^1.0.0' }), + 'react-posthog-statsig': (dir) => + writeDeps(dir, { + react: '^19.0.0', + 'posthog-js': '^1.0.0', + '@statsig/js-client': '^1.0.0', + }), +}; + +export function createProjectDir(type: ProjectType = 'react') { + const dir = mkdtempSync(join(tmpdir(), 'wizard-test-')); + SCAFFOLDS[type](dir); return { path: dir, @@ -16,3 +32,5 @@ export function createProjectDir(deps: Record | null = { react: }, }; } + +export type { ProjectType }; diff --git a/__tests__/ui/screens/OnboardingFlow.test.tsx b/__tests__/ui/screens/OnboardingFlow.test.tsx index fe609c8..fde411f 100644 --- a/__tests__/ui/screens/OnboardingFlow.test.tsx +++ b/__tests__/ui/screens/OnboardingFlow.test.tsx @@ -23,7 +23,7 @@ describe('Onboarding flow', () => { describe('confirmation prompt', () => { it('shows confirmation prompt on mount', async () => { - using project = createProjectDir({ react: '^19.0.0' }); + using project = createProjectDir(); using sut = renderApp({ screen: ScreenId.OnboardProject, @@ -40,7 +40,7 @@ describe('Onboarding flow', () => { }); it('advances to Done on skip', async () => { - using project = createProjectDir({ react: '^19.0.0' }); + using project = createProjectDir(); using sut = renderApp({ screen: ScreenId.OnboardProject, @@ -61,7 +61,7 @@ describe('Onboarding flow', () => { describe('when onboarding is confirmed', () => { it('shows Feature Flags heading on progress screen', async () => { - using project = createProjectDir({ react: '^19.0.0' }); + using project = createProjectDir(); mockNextSpawn({ hang: true }); using sut = renderApp({ @@ -82,7 +82,7 @@ describe('Onboarding flow', () => { }); it('shows status updates from spawned process', async () => { - using project = createProjectDir({ react: '^19.0.0' }); + using project = createProjectDir(); mockNextSpawn({ lines: ['STATUS: Creating feature flag example...', 'other output without STATUS prefix'], hang: true, @@ -106,7 +106,7 @@ describe('Onboarding flow', () => { }); it('advances to Done after successful onboarding', async () => { - using project = createProjectDir({ react: '^19.0.0' }); + using project = createProjectDir(); mockNextSpawn({ lines: [ 'STATUS: Installing SDK...', @@ -132,7 +132,7 @@ describe('Onboarding flow', () => { }); it('shows error when process exits with non-zero code', async () => { - using project = createProjectDir({ react: '^19.0.0' }); + using project = createProjectDir(); mockNextSpawn({ exitCode: 1, stderrOutput: 'Something went wrong', @@ -156,7 +156,7 @@ describe('Onboarding flow', () => { }); it('shows error when process fails to start', async () => { - using project = createProjectDir({ react: '^19.0.0' }); + using project = createProjectDir(); mockNextSpawn({ error: new Error('spawn claude ENOENT'), }); @@ -178,7 +178,7 @@ describe('Onboarding flow', () => { }); it('advances to Done on cancel from progress screen', async () => { - using project = createProjectDir({ react: '^19.0.0' }); + using project = createProjectDir(); mockNextSpawn({ lines: ['STATUS: Working...'], hang: true }); using sut = renderApp({ @@ -204,7 +204,7 @@ describe('Onboarding flow', () => { }); it('shows choose-sdk prompt for empty project', async () => { - using project = createProjectDir(null); + using project = createProjectDir('empty'); mockNextSpawn({ hang: true }); using sut = renderApp({ @@ -226,10 +226,7 @@ describe('Onboarding flow', () => { describe('when competitor is detected', () => { it('shows migration option instead of plain Start if AI plugin is installed', async () => { - using project = createProjectDir({ - react: '^19.0.0', - '@statsig/js-client': '^1.0.0', - }); + using project = createProjectDir('react-statsig'); using sut = renderApp({ screen: ScreenId.OnboardProject, @@ -246,10 +243,7 @@ describe('Onboarding flow', () => { }); it('shows standard options when no AI plugin is installed', async () => { - using project = createProjectDir({ - react: '^19.0.0', - '@statsig/js-client': '^1.0.0', - }); + using project = createProjectDir('react-statsig'); using sut = renderApp({ screen: ScreenId.OnboardProject, @@ -265,10 +259,7 @@ describe('Onboarding flow', () => { }); it('starts onboarding with migration when migration option is selected', async () => { - using project = createProjectDir({ - react: '^19.0.0', - '@statsig/js-client': '^1.0.0', - }); + using project = createProjectDir('react-statsig'); mockNextSpawn({ hang: true }); using sut = renderApp({ @@ -289,11 +280,7 @@ describe('Onboarding flow', () => { }); it('shows migrate-all option and per-competitor options when multiple detected', async () => { - using project = createProjectDir({ - react: '^19.0.0', - 'posthog-js': '^1.0.0', - '@statsig/js-client': '^1.0.0', - }); + using project = createProjectDir('react-posthog-statsig'); using sut = renderApp({ screen: ScreenId.OnboardProject, @@ -313,10 +300,7 @@ describe('Onboarding flow', () => { }); it('does not show migrate-all when only one competitor detected', async () => { - using project = createProjectDir({ - react: '^19.0.0', - '@statsig/js-client': '^1.0.0', - }); + using project = createProjectDir('react-statsig'); using sut = renderApp({ screen: ScreenId.OnboardProject, diff --git a/__tests__/ui/screens/WelcomeScreen.test.tsx b/__tests__/ui/screens/WelcomeScreen.test.tsx index f846eb6..4bee33f 100644 --- a/__tests__/ui/screens/WelcomeScreen.test.tsx +++ b/__tests__/ui/screens/WelcomeScreen.test.tsx @@ -100,7 +100,7 @@ describe('WelcomeScreen', () => { describe('when no known framework is detected', () => { it('hides "Start setup" and shows "Select framework"', async () => { - using project = createProjectDir(null); + using project = createProjectDir('empty'); using sut = renderScreen(, { dir: project.path }); await waitFor(() => { @@ -111,7 +111,7 @@ describe('WelcomeScreen', () => { }); it('does not show "Change framework"', async () => { - using project = createProjectDir(null); + using project = createProjectDir('empty'); using sut = renderScreen(, { dir: project.path }); await waitFor(() => { @@ -120,7 +120,7 @@ describe('WelcomeScreen', () => { }); it('navigates to SelectFramework on "Select framework"', async () => { - using project = createProjectDir(null); + using project = createProjectDir('empty'); using sut = renderApp({ dir: project.path }); await waitFor(() => { From 0a1bde011bccd1c6ef3ad5a64c175bb4cbe10bd9 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 31 Jul 2026 13:07:51 +0200 Subject: [PATCH 4/9] refactor: modularize integration test framework, reduce duplication --- .../__snapshots__/empty-project.e2e.ts.snap | 12 ++--- .../e2e/__snapshots__/happy-path.e2e.ts.snap | 6 +-- .../welcome-navigation.e2e.ts.snap | 6 +-- .../e2e/testing-framework/mocks/index.ts | 2 +- .../e2e/testing-framework/mocks/server.ts | 2 +- .../e2e/testing-framework/project-scaffold.ts | 49 ------------------- .../e2e/testing-framework/session-factory.ts | 4 +- .../e2e/testing-framework/terminal/index.ts | 2 +- .../e2e/testing-framework/terminal/session.ts | 2 +- .../mocks => shared}/auth.ts | 25 ++++++++++ .../terminal => shared}/key-map.ts | 0 .../project.ts => shared/project-scaffold.ts} | 27 ++++++++++ __tests__/ui/helpers/auth.ts | 30 ------------ __tests__/ui/helpers/delay.ts | 3 -- __tests__/ui/helpers/index.ts | 8 --- __tests__/ui/helpers/keys.ts | 5 -- __tests__/ui/screens/AboutScreen.test.tsx | 2 +- .../ui/screens/AuthenticateScreen.test.tsx | 8 ++- .../screens/ConnectToolsScreen.auth.test.tsx | 2 +- .../ui/screens/ConnectToolsScreen.test.tsx | 2 +- __tests__/ui/screens/DoneScreen.test.tsx | 2 +- .../ui/screens/InstallPluginsScreen.test.tsx | 2 +- __tests__/ui/screens/OnboardingFlow.test.tsx | 2 +- .../ui/screens/SelectFrameworkScreen.test.tsx | 2 +- .../ui/screens/SystemCheckScreen.test.tsx | 2 +- __tests__/ui/screens/WelcomeScreen.test.tsx | 2 +- .../waitFor.ts => testing-framework/async.ts} | 4 +- __tests__/ui/testing-framework/index.ts | 18 +++++++ .../{helpers => testing-framework/ink}/act.ts | 0 __tests__/ui/testing-framework/ink/index.ts | 2 + .../ink}/render.tsx | 0 __tests__/ui/testing-framework/mocks/index.ts | 1 + .../mocks}/spawn.ts | 0 33 files changed, 110 insertions(+), 124 deletions(-) delete mode 100644 __tests__/e2e/testing-framework/project-scaffold.ts rename __tests__/{e2e/testing-framework/mocks => shared}/auth.ts (62%) rename __tests__/{e2e/testing-framework/terminal => shared}/key-map.ts (100%) rename __tests__/{ui/helpers/project.ts => shared/project-scaffold.ts} (51%) delete mode 100644 __tests__/ui/helpers/auth.ts delete mode 100644 __tests__/ui/helpers/delay.ts delete mode 100644 __tests__/ui/helpers/index.ts delete mode 100644 __tests__/ui/helpers/keys.ts rename __tests__/ui/{helpers/waitFor.ts => testing-framework/async.ts} (79%) create mode 100644 __tests__/ui/testing-framework/index.ts rename __tests__/ui/{helpers => testing-framework/ink}/act.ts (100%) create mode 100644 __tests__/ui/testing-framework/ink/index.ts rename __tests__/ui/{helpers => testing-framework/ink}/render.tsx (100%) create mode 100644 __tests__/ui/testing-framework/mocks/index.ts rename __tests__/ui/{helpers => testing-framework/mocks}/spawn.ts (100%) diff --git a/__tests__/e2e/__snapshots__/empty-project.e2e.ts.snap b/__tests__/e2e/__snapshots__/empty-project.e2e.ts.snap index 03ad0bd..730b17b 100644 --- a/__tests__/e2e/__snapshots__/empty-project.e2e.ts.snap +++ b/__tests__/e2e/__snapshots__/empty-project.e2e.ts.snap @@ -16,9 +16,9 @@ exports[`when the project is empty > shows "Select framework" instead of "Start 5. Show a working feature flag example - Directory ✔ - Framework ✘ Could not auto-detect - Telemetry ✘ off + Directory ✔ + Framework ✘ Could not auto-detect + Telemetry ✘ off ────────────────────────────────────────────────────────────────────────────────────────────────── @@ -47,9 +47,9 @@ exports[`when the project is empty > shows "Start setup" after selecting a frame 5. Show a working feature flag example - Directory ✔ - Framework ✔ React (selected) - Telemetry ✘ off + Directory ✔ + Framework ✔ React (selected) + Telemetry ✘ off ────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/__tests__/e2e/__snapshots__/happy-path.e2e.ts.snap b/__tests__/e2e/__snapshots__/happy-path.e2e.ts.snap index 91fa0d9..3de3c1f 100644 --- a/__tests__/e2e/__snapshots__/happy-path.e2e.ts.snap +++ b/__tests__/e2e/__snapshots__/happy-path.e2e.ts.snap @@ -139,9 +139,9 @@ exports[`happy-path flow > navigates Welcome → SystemCheck → Authenticate 5. Show a working feature flag example - Directory ✔ - Framework ✔ React (detected) - Telemetry ✘ off + Directory ✔ + Framework ✔ React (detected) + Telemetry ✘ off ────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/__tests__/e2e/__snapshots__/welcome-navigation.e2e.ts.snap b/__tests__/e2e/__snapshots__/welcome-navigation.e2e.ts.snap index 5ed7105..09b8fb8 100644 --- a/__tests__/e2e/__snapshots__/welcome-navigation.e2e.ts.snap +++ b/__tests__/e2e/__snapshots__/welcome-navigation.e2e.ts.snap @@ -16,9 +16,9 @@ exports[`welcome screen navigation > exits cleanly on Quit > welcome-quit-select 5. Show a working feature flag example - Directory ✔ - Framework ✔ React (detected) - Telemetry ✘ off + Directory ✔ + Framework ✔ React (detected) + Telemetry ✘ off ────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/__tests__/e2e/testing-framework/mocks/index.ts b/__tests__/e2e/testing-framework/mocks/index.ts index 37fabd9..0b6ffc6 100644 --- a/__tests__/e2e/testing-framework/mocks/index.ts +++ b/__tests__/e2e/testing-framework/mocks/index.ts @@ -4,4 +4,4 @@ export { CHAT_PROMPT_FILE, ONBOARDING_INVOCATION_FILE, } from './binaries/index.js'; -export { buildTestJwt } from './auth.js'; +export { buildTestJwt } from '../../../shared/auth.js'; diff --git a/__tests__/e2e/testing-framework/mocks/server.ts b/__tests__/e2e/testing-framework/mocks/server.ts index bfec5cd..4e8b9c9 100644 --- a/__tests__/e2e/testing-framework/mocks/server.ts +++ b/__tests__/e2e/testing-framework/mocks/server.ts @@ -1,5 +1,5 @@ import { createServer, type Server } from 'node:http'; -import { buildTestJwt } from './auth.js'; +import { buildTestJwt } from '../../../shared/auth.js'; import { buildMockEnv } from '../env.js'; /** diff --git a/__tests__/e2e/testing-framework/project-scaffold.ts b/__tests__/e2e/testing-framework/project-scaffold.ts deleted file mode 100644 index 5e99f15..0000000 --- a/__tests__/e2e/testing-framework/project-scaffold.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { mkdtempSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; - -/** - * Identifies a pre-built project scaffold. - * - * Each variant produces a minimal project directory that triggers a - * specific code path in the wizard's framework-detection logic. - * - * - `'react'` — contains a `package.json` with a React 19 dependency - * so the wizard auto-detects the React framework. - * - `'empty'` — bare directory with no files, forcing the wizard to - * prompt the user for framework selection. - */ -type ProjectType = 'react' | 'empty'; - -const SCAFFOLDS: Record void> = { - react(dir) { - writeFileSync( - join(dir, 'package.json'), - JSON.stringify({ dependencies: { react: '^19.0.0' } }), - ); - }, - empty() {}, -}; - -/** - * Creates an isolated temporary directory populated with the requested - * project scaffold. - * - * @param type - Which scaffold to use. - * @returns Absolute path to the created project directory. - * - * @example - * ```ts - * const dir = createProjectDir('react'); - * // dir contains package.json with { dependencies: { react: '^19.0.0' } } - * - * const dir = createProjectDir('empty'); - * // dir is an empty temporary directory - * ``` - */ -export function createProjectDir(type: ProjectType): string { - const dir = mkdtempSync('/tmp/e2e-project-'); - SCAFFOLDS[type](dir); - return dir; -} - -export type { ProjectType }; diff --git a/__tests__/e2e/testing-framework/session-factory.ts b/__tests__/e2e/testing-framework/session-factory.ts index daff07c..50e005d 100644 --- a/__tests__/e2e/testing-framework/session-factory.ts +++ b/__tests__/e2e/testing-framework/session-factory.ts @@ -2,7 +2,7 @@ import { mkdtempSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { TerminalSession } from './terminal/index.js'; -import { createProjectDir, type ProjectType } from './project-scaffold.js'; +import { createProjectDir, type ProjectType } from '../../shared/project-scaffold.js'; /** * Creates an isolated {@link TerminalSession} pre-configured for e2e testing. @@ -53,7 +53,7 @@ export function createSession({ systemPath?: string; } = {}): TerminalSession { const mockBinDir = process.env.E2E_MOCK_BIN_DIR!; - const projectDir = createProjectDir(project); + const { path: projectDir } = createProjectDir(project); const sessionEnv: Record = { PATH: `${mockBinDir}:${systemPath ?? process.env.PATH}`, diff --git a/__tests__/e2e/testing-framework/terminal/index.ts b/__tests__/e2e/testing-framework/terminal/index.ts index 3f775d3..f262f2a 100644 --- a/__tests__/e2e/testing-framework/terminal/index.ts +++ b/__tests__/e2e/testing-framework/terminal/index.ts @@ -1,2 +1,2 @@ export { TerminalSession } from './session.js'; -export type { KeyName, Modifiers } from './key-map.js'; +export type { KeyName, Modifiers } from '../../../shared/key-map.js'; diff --git a/__tests__/e2e/testing-framework/terminal/session.ts b/__tests__/e2e/testing-framework/terminal/session.ts index c11c964..50d12fa 100644 --- a/__tests__/e2e/testing-framework/terminal/session.ts +++ b/__tests__/e2e/testing-framework/terminal/session.ts @@ -6,7 +6,7 @@ import { join } from 'node:path'; import { stripAnsi } from './strip-ansi.js'; import { renderScreen, normalizeSnapshot } from './screen-buffer.js'; import { E2E_BASE_ENV } from '../env.js'; -import { resolveKey, type Modifiers } from './key-map.js'; +import { resolveKey, type Modifiers } from '../../../shared/key-map.js'; const CLI_PATH = resolve(import.meta.dirname, '../../../../dist/bin/cli.js'); const DEFAULT_COLS = 100; diff --git a/__tests__/e2e/testing-framework/mocks/auth.ts b/__tests__/shared/auth.ts similarity index 62% rename from __tests__/e2e/testing-framework/mocks/auth.ts rename to __tests__/shared/auth.ts index 0d817ad..99c574a 100644 --- a/__tests__/e2e/testing-framework/mocks/auth.ts +++ b/__tests__/shared/auth.ts @@ -1,3 +1,5 @@ +import type { AuthState } from '@lib/session.js'; + function base64url(str: string): string { return Buffer.from(str, 'utf-8').toString('base64url'); } @@ -31,3 +33,26 @@ export function buildTestJwt(claims: Record = {}): string { ); return `${header}.${payload}.`; } + +/** + * Builds a JWT that expired one hour ago. + * + * @returns An expired JWT string. + */ +export function buildExpiredJwt(): string { + return buildTestJwt({ exp: Math.floor(Date.now() / 1000) - 3600 }); +} + +/** + * Builds a complete {@link AuthState} object with a valid (or custom) token. + * + * @param token - Optional JWT string. Defaults to a fresh test JWT. + * @returns An `AuthState` with `status: 'authenticated'` and `region: 'EU'`. + */ +export function buildAuthState(token?: string): AuthState { + return { + status: 'authenticated', + token: token ?? buildTestJwt(), + region: 'EU', + }; +} diff --git a/__tests__/e2e/testing-framework/terminal/key-map.ts b/__tests__/shared/key-map.ts similarity index 100% rename from __tests__/e2e/testing-framework/terminal/key-map.ts rename to __tests__/shared/key-map.ts diff --git a/__tests__/ui/helpers/project.ts b/__tests__/shared/project-scaffold.ts similarity index 51% rename from __tests__/ui/helpers/project.ts rename to __tests__/shared/project-scaffold.ts index 2924be8..7d6f76e 100644 --- a/__tests__/ui/helpers/project.ts +++ b/__tests__/shared/project-scaffold.ts @@ -3,6 +3,17 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { noop } from '@lib/noop.js'; +/** + * Identifies a pre-built project scaffold. + * + * Each variant produces a minimal project directory that triggers a + * specific code path in the wizard's framework-detection logic. + * + * - `'react'` — `package.json` with React 19 (auto-detects React framework) + * - `'empty'` — bare directory (forces manual framework selection) + * - `'react-statsig'` — React + Statsig SDK (triggers competitor detection) + * - `'react-posthog-statsig'` — React + PostHog + Statsig (multi-competitor) + */ type ProjectType = 'react' | 'empty' | 'react-statsig' | 'react-posthog-statsig'; function writeDeps(dir: string, dependencies: Record): void { @@ -21,6 +32,22 @@ const SCAFFOLDS: Record void> = { }), }; +/** + * Creates an isolated temporary directory populated with the requested + * project scaffold. Supports `Symbol.dispose` for automatic cleanup. + * + * @param type - Which scaffold to use. @defaultValue `'react'` + * @returns An object with `path` and a disposer that removes the directory. + * + * @example + * ```ts + * using project = createProjectDir('react'); + * // project.path contains package.json with { dependencies: { react: '^19.0.0' } } + * + * using project = createProjectDir('empty'); + * // project.path is an empty temporary directory + * ``` + */ export function createProjectDir(type: ProjectType = 'react') { const dir = mkdtempSync(join(tmpdir(), 'wizard-test-')); SCAFFOLDS[type](dir); diff --git a/__tests__/ui/helpers/auth.ts b/__tests__/ui/helpers/auth.ts deleted file mode 100644 index c3574ce..0000000 --- a/__tests__/ui/helpers/auth.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { AuthState } from '@lib/session.js'; - -function base64url(str: string): string { - return Buffer.from(str, 'utf-8').toString('base64url'); -} - -export function buildTestJwt(claims: Record = {}): string { - const header = base64url(JSON.stringify({ alg: 'RS256' })); - const payload = base64url( - JSON.stringify({ - exp: Math.floor(Date.now() / 1000) + 86400, - 'https://confidence.dev/region': 'EU', - email: 'test@example.com', - ...claims, - }), - ); - return `${header}.${payload}.fake-signature`; -} - -export function buildExpiredJwt(): string { - return buildTestJwt({ exp: Math.floor(Date.now() / 1000) - 3600 }); -} - -export function buildAuthState(token?: string): AuthState { - return { - status: 'authenticated', - token: token ?? buildTestJwt(), - region: 'EU', - }; -} diff --git a/__tests__/ui/helpers/delay.ts b/__tests__/ui/helpers/delay.ts deleted file mode 100644 index 59d8019..0000000 --- a/__tests__/ui/helpers/delay.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} diff --git a/__tests__/ui/helpers/index.ts b/__tests__/ui/helpers/index.ts deleted file mode 100644 index b089b38..0000000 --- a/__tests__/ui/helpers/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { act } from './act.js'; -export { renderScreen, renderApp } from './render.js'; -export { ARROW_DOWN, ARROW_UP, ENTER, ESCAPE } from './keys.js'; -export { delay } from './delay.js'; -export { waitFor } from './waitFor.js'; -export { createProjectDir, type ProjectType } from './project.js'; -export { createFakeChild, mockNextSpawn } from './spawn.js'; -export { buildTestJwt, buildExpiredJwt, buildAuthState } from './auth.js'; diff --git a/__tests__/ui/helpers/keys.ts b/__tests__/ui/helpers/keys.ts deleted file mode 100644 index 1da1c67..0000000 --- a/__tests__/ui/helpers/keys.ts +++ /dev/null @@ -1,5 +0,0 @@ -const ESC = String.fromCharCode(27); -export const ARROW_DOWN = ESC + '[B'; -export const ARROW_UP = ESC + '[A'; -export const ENTER = '\r'; -export const ESCAPE = ESC; diff --git a/__tests__/ui/screens/AboutScreen.test.tsx b/__tests__/ui/screens/AboutScreen.test.tsx index 566b7fc..628e07d 100644 --- a/__tests__/ui/screens/AboutScreen.test.tsx +++ b/__tests__/ui/screens/AboutScreen.test.tsx @@ -5,7 +5,7 @@ import { ENTER, ESCAPE, waitFor, -} from '../helpers/index.js'; +} from '../testing-framework/index.js'; import { AboutScreen } from '@ui/tui/screens/about/index.js'; import { ScreenId } from '@lib/session.js'; diff --git a/__tests__/ui/screens/AuthenticateScreen.test.tsx b/__tests__/ui/screens/AuthenticateScreen.test.tsx index e735608..325abb0 100644 --- a/__tests__/ui/screens/AuthenticateScreen.test.tsx +++ b/__tests__/ui/screens/AuthenticateScreen.test.tsx @@ -1,4 +1,10 @@ -import { renderScreen, renderApp, createProjectDir, ENTER, waitFor } from '../helpers/index.js'; +import { + renderScreen, + renderApp, + createProjectDir, + ENTER, + waitFor, +} from '../testing-framework/index.js'; import { AuthenticateScreen } from '@ui/tui/screens/authenticate/index.js'; import { ScreenId } from '@lib/session.js'; diff --git a/__tests__/ui/screens/ConnectToolsScreen.auth.test.tsx b/__tests__/ui/screens/ConnectToolsScreen.auth.test.tsx index 31a3271..182e5ed 100644 --- a/__tests__/ui/screens/ConnectToolsScreen.auth.test.tsx +++ b/__tests__/ui/screens/ConnectToolsScreen.auth.test.tsx @@ -9,7 +9,7 @@ import { buildExpiredJwt, buildAuthState, ENTER, -} from '../helpers/index.js'; +} from '../testing-framework/index.js'; import { ConnectToolsScreen } from '@ui/tui/screens/connect-tools/index.js'; import { ScreenId } from '@lib/session.js'; import { persistMcpPreference, clearMcpPreference, MCP_SERVERS } from '@integrations/index.js'; diff --git a/__tests__/ui/screens/ConnectToolsScreen.test.tsx b/__tests__/ui/screens/ConnectToolsScreen.test.tsx index ca46ba8..98d30bc 100644 --- a/__tests__/ui/screens/ConnectToolsScreen.test.tsx +++ b/__tests__/ui/screens/ConnectToolsScreen.test.tsx @@ -7,7 +7,7 @@ import { ENTER, ARROW_DOWN, waitFor, -} from '../helpers/index.js'; +} from '../testing-framework/index.js'; import { ConnectToolsScreen } from '@ui/tui/screens/connect-tools/index.js'; import { ScreenId } from '@lib/session.js'; import { server } from '../../msw/server.js'; diff --git a/__tests__/ui/screens/DoneScreen.test.tsx b/__tests__/ui/screens/DoneScreen.test.tsx index c994e43..c4ce09d 100644 --- a/__tests__/ui/screens/DoneScreen.test.tsx +++ b/__tests__/ui/screens/DoneScreen.test.tsx @@ -1,4 +1,4 @@ -import { renderScreen, waitFor } from '../helpers/index.js'; +import { renderScreen, waitFor } from '../testing-framework/index.js'; import { DoneScreen } from '@ui/tui/screens/done/index.js'; import { ScreenId } from '@lib/session.js'; import { store } from '@ui/tui/store.js'; diff --git a/__tests__/ui/screens/InstallPluginsScreen.test.tsx b/__tests__/ui/screens/InstallPluginsScreen.test.tsx index 0ca79bb..16357eb 100644 --- a/__tests__/ui/screens/InstallPluginsScreen.test.tsx +++ b/__tests__/ui/screens/InstallPluginsScreen.test.tsx @@ -6,7 +6,7 @@ import { ENTER, ARROW_DOWN, waitFor, -} from '../helpers/index.js'; +} from '../testing-framework/index.js'; import { InstallPluginsScreen } from '@ui/tui/screens/install-plugins/index.js'; import { ScreenId } from '@lib/session.js'; import { server } from '../../msw/server.js'; diff --git a/__tests__/ui/screens/OnboardingFlow.test.tsx b/__tests__/ui/screens/OnboardingFlow.test.tsx index fde411f..484de37 100644 --- a/__tests__/ui/screens/OnboardingFlow.test.tsx +++ b/__tests__/ui/screens/OnboardingFlow.test.tsx @@ -8,7 +8,7 @@ import { ARROW_DOWN, ESCAPE, waitFor, -} from '../helpers/index.js'; +} from '../testing-framework/index.js'; import { ScreenId } from '@lib/session.js'; vi.mock('node:child_process', async (importOriginal) => { diff --git a/__tests__/ui/screens/SelectFrameworkScreen.test.tsx b/__tests__/ui/screens/SelectFrameworkScreen.test.tsx index ad910e8..59f5b29 100644 --- a/__tests__/ui/screens/SelectFrameworkScreen.test.tsx +++ b/__tests__/ui/screens/SelectFrameworkScreen.test.tsx @@ -6,7 +6,7 @@ import { ARROW_DOWN, ESCAPE, waitFor, -} from '../helpers/index.js'; +} from '../testing-framework/index.js'; import { SelectFrameworkScreen } from '@ui/tui/screens/select-framework/index.js'; import { ScreenId } from '@lib/session.js'; diff --git a/__tests__/ui/screens/SystemCheckScreen.test.tsx b/__tests__/ui/screens/SystemCheckScreen.test.tsx index 9295613..4549d0e 100644 --- a/__tests__/ui/screens/SystemCheckScreen.test.tsx +++ b/__tests__/ui/screens/SystemCheckScreen.test.tsx @@ -1,4 +1,4 @@ -import { renderScreen, renderApp, ENTER, waitFor } from '../helpers/index.js'; +import { renderScreen, renderApp, ENTER, waitFor } from '../testing-framework/index.js'; import { SystemCheckScreen } from '@ui/tui/screens/system-check/index.js'; import { ScreenId } from '@lib/session.js'; diff --git a/__tests__/ui/screens/WelcomeScreen.test.tsx b/__tests__/ui/screens/WelcomeScreen.test.tsx index 4bee33f..e81a4c1 100644 --- a/__tests__/ui/screens/WelcomeScreen.test.tsx +++ b/__tests__/ui/screens/WelcomeScreen.test.tsx @@ -6,7 +6,7 @@ import { ENTER, waitFor, act, -} from '../helpers/index.js'; +} from '../testing-framework/index.js'; import { WelcomeScreen } from '@ui/tui/screens/welcome/index.js'; vi.mock('../../../src/lib/system-check.js', () => ({ diff --git a/__tests__/ui/helpers/waitFor.ts b/__tests__/ui/testing-framework/async.ts similarity index 79% rename from __tests__/ui/helpers/waitFor.ts rename to __tests__/ui/testing-framework/async.ts index 234ce8a..c550695 100644 --- a/__tests__/ui/helpers/waitFor.ts +++ b/__tests__/ui/testing-framework/async.ts @@ -1,4 +1,6 @@ -import { delay } from './delay.js'; +export function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} export async function waitFor( fn: () => void, diff --git a/__tests__/ui/testing-framework/index.ts b/__tests__/ui/testing-framework/index.ts new file mode 100644 index 0000000..06f6b2b --- /dev/null +++ b/__tests__/ui/testing-framework/index.ts @@ -0,0 +1,18 @@ +export { act, renderScreen, renderApp } from './ink/index.js'; +export { createFakeChild, mockNextSpawn } from './mocks/index.js'; +export { delay, waitFor } from './async.js'; + +export { KEY_MAP, resolveKey, type KeyName, type Modifiers } from '../../shared/key-map.js'; +export { buildTestJwt, buildExpiredJwt, buildAuthState } from '../../shared/auth.js'; +export { createProjectDir, type ProjectType } from '../../shared/project-scaffold.js'; + +import { KEY_MAP } from '../../shared/key-map.js'; + +/** @see {@link KEY_MAP.ArrowDown} */ +export const ARROW_DOWN = KEY_MAP.ArrowDown; +/** @see {@link KEY_MAP.ArrowUp} */ +export const ARROW_UP = KEY_MAP.ArrowUp; +/** @see {@link KEY_MAP.Enter} */ +export const ENTER = KEY_MAP.Enter; +/** @see {@link KEY_MAP.Escape} */ +export const ESCAPE = KEY_MAP.Escape; diff --git a/__tests__/ui/helpers/act.ts b/__tests__/ui/testing-framework/ink/act.ts similarity index 100% rename from __tests__/ui/helpers/act.ts rename to __tests__/ui/testing-framework/ink/act.ts diff --git a/__tests__/ui/testing-framework/ink/index.ts b/__tests__/ui/testing-framework/ink/index.ts new file mode 100644 index 0000000..95618eb --- /dev/null +++ b/__tests__/ui/testing-framework/ink/index.ts @@ -0,0 +1,2 @@ +export { act } from './act.js'; +export { renderScreen, renderApp } from './render.js'; diff --git a/__tests__/ui/helpers/render.tsx b/__tests__/ui/testing-framework/ink/render.tsx similarity index 100% rename from __tests__/ui/helpers/render.tsx rename to __tests__/ui/testing-framework/ink/render.tsx diff --git a/__tests__/ui/testing-framework/mocks/index.ts b/__tests__/ui/testing-framework/mocks/index.ts new file mode 100644 index 0000000..25dc206 --- /dev/null +++ b/__tests__/ui/testing-framework/mocks/index.ts @@ -0,0 +1 @@ +export { createFakeChild, mockNextSpawn } from './spawn.js'; diff --git a/__tests__/ui/helpers/spawn.ts b/__tests__/ui/testing-framework/mocks/spawn.ts similarity index 100% rename from __tests__/ui/helpers/spawn.ts rename to __tests__/ui/testing-framework/mocks/spawn.ts From ff34d7749267b2ea789a8a08bb76a8b82a858fde Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 31 Jul 2026 13:11:02 +0200 Subject: [PATCH 5/9] chore: update testing skill --- .claude/skills/wizard-testing/SKILL.md | 50 ++++++++++++++++++-------- AGENTS.md | 4 +-- 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/.claude/skills/wizard-testing/SKILL.md b/.claude/skills/wizard-testing/SKILL.md index 94615c0..c8595d6 100644 --- a/.claude/skills/wizard-testing/SKILL.md +++ b/.claude/skills/wizard-testing/SKILL.md @@ -1,7 +1,12 @@ --- name: testing -description: Testing guidelines and conventions for the Confidence Wizard CLI project -version: '0.1' +description: > + Load before writing, modifying, or adding any test file (unit, + integration, or e2e). Covers testing philosophy, conventions, shared + test scaffolds (__tests__/shared/), test framework structure + (__tests__/e2e/testing-framework/, __tests__/ui/testing-framework/), + and the named-key press() API for e2e tests. +version: '0.2' --- # Testing Guidelines @@ -65,13 +70,27 @@ Tests live in `__tests__/` mirroring the `src/` directory structure: ``` __tests__/ + shared/ # Utilities shared between e2e and integration tests + key-map.ts # Named terminal key mapping (Enter, ArrowDown, etc.) + auth.ts # JWT builders (buildTestJwt, buildExpiredJwt, buildAuthState) + project-scaffold.ts # Project directory factory (react, empty, react-statsig, etc.) + e2e/ # End-to-end tests (node-pty) + testing-framework/ + terminal/ # PTY infrastructure (TerminalSession, screen buffer, ANSI strip) + mocks/ # Mock HTTP server + mock IDE binaries (binaries/ subdirectory) + navigation.ts # Screen navigation shortcuts + session-factory.ts # createSession() factory + utils.ts # simulateAuthCallback, readInvocation + *.e2e.ts # E2E test files + ui/ # Integration tests (ink-testing-library) + testing-framework/ + ink/ # Ink rendering (renderScreen, renderApp, act) + mocks/ # Mock child process (createFakeChild, mockNextSpawn) + async.ts # delay, waitFor + screens/ # Screen test files commands/ - ui/ lib/ frameworks/ - e2e/ # End-to-end tests (node-pty) - helpers/ # TerminalSession, mock server, navigation utils - *.e2e.ts # E2E test files ``` Unit/integration tests are colocated as `src/**/__tests__/**/*.test.{ts,tsx}`. @@ -97,14 +116,15 @@ E2E tests use a dedicated vitest config (`vitest.config.e2e.ts`) with: - No MSW setup (HTTP is mocked via a real local server) - Global setup in `__tests__/e2e/global-setup.ts` -### Helpers (`__tests__/e2e/helpers/`) +### Testing Framework (`__tests__/e2e/testing-framework/`) -- **`createSession(opts?)`** — spawns the CLI in a pty with an isolated temp project dir. Pass `{ project: 'empty' }` for an empty project (no `package.json`). Returns a `TerminalSession` with `[Symbol.dispose]`. -- **`TerminalSession`** — wraps node-pty. Key methods: `waitForText(text)` (polls accumulated buffer), `sendKey(key)`, `waitForExit()`, `screen` (full ANSI-stripped output). -- **`simulateAuthCallback()`** — hits the CLI's local OAuth callback server to simulate browser auth. -- **`navigateToPlugins/ConnectTools/Onboarding(session)`** — navigation shortcuts that advance through earlier screens. -- **Mock HTTP server** — started in global setup, mimics all Confidence APIs (auth, MCP, skills, telemetry). The CLI's API URLs are configurable via env vars (e.g. `CONFIDENCE_AUTH_URL`), which the global setup points at the local server. -- **Mock `claude` binary** — placed on PATH, handles `mcp` subcommands and `--print` onboarding by emitting stream-json events. +- **`createSession(opts?)`** (`session-factory.ts`) — spawns the CLI in a pty with an isolated temp project dir. Pass `{ project: 'empty' }` for an empty project (no `package.json`). Returns a `TerminalSession` with `[Symbol.dispose]`. +- **`TerminalSession`** (`terminal/session.ts`) — wraps node-pty. Key methods: `press(key)` (named keys like `'Enter'`, `'ArrowDown'`), `pressRepeat(key, count)`, `waitForText(text)`, `waitForPattern(regex)`, `waitForExit()`, `checkpoint()`, `snapshot()`, `screen` (full ANSI-stripped output). +- **`simulateAuthCallback()`** (`utils.ts`) — hits the CLI's local OAuth callback server to simulate browser auth. +- **`navigateToPlugins/ConnectTools/Onboarding(session)`** (`navigation.ts`) — navigation shortcuts that advance through earlier screens. +- **Mock HTTP server** (`mocks/server.ts`) — started in global setup, mimics all Confidence APIs (auth, MCP, skills, telemetry). The CLI's API URLs are configurable via env vars (e.g. `CONFIDENCE_AUTH_URL`), which the global setup points at the local server. +- **Mock IDE binaries** (`mocks/binaries/`) — `claude`, `cursor`, `codex` mock scripts placed on PATH, handle subcommands and `--print` onboarding by emitting stream-json events. +- **Shared utilities** (`__tests__/shared/`) — `key-map.ts` (key escape sequences), `auth.ts` (JWT builders), `project-scaffold.ts` (temp project directory factory). Shared with integration tests. ### Writing E2E Tests @@ -112,7 +132,9 @@ E2E tests use a dedicated vitest config (`vitest.config.e2e.ts`) with: - **One concern per file**: group related scenarios (e.g. `skip-plugins.e2e.ts` covers all skip-plugin variations). - **Use `createSession()` per test** — each call creates a fresh project dir for full isolation. No shared state between tests. - **Use `using`** for automatic cleanup: `using session = createSession()`. +- **Use named keys** with `session.press('Enter')`, `session.press('ArrowDown')`, `session.pressRepeat('ArrowDown', 3)` — not raw escape code constants. - **Assert positively** — the accumulated buffer contains ALL output ever rendered (including text from previous screens). Prefer `waitForText('expected')` over `not.toContain('unexpected')`. +- **Use `checkpoint()`** between screens to scope `waitForText` and `snapshot()` to the current screen, avoiding false positives from earlier output. - **Use navigation helpers** to skip past earlier screens when testing later ones (e.g. `navigateToOnboarding(session)` advances through Welcome, SystemCheck, Auth, Plugins, and ConnectTools). - **Add comments** before each interaction block to identify the screen and the intent of the action (e.g. `// Welcome`, `// Select "Skip for now"`, `// Done — no IDE set, only Exit option`). @@ -195,7 +217,7 @@ describe('resolve method internals', () => { - One assertion concern per test — multiple `expect` calls are fine if they assert the same behavior. - No snapshot tests unless explicitly requested. -- **Prefer `createProjectDir()` for setting up project context** (framework, dependencies, project structure) in TUI screen tests. Pass dependencies to control framework detection (e.g., `createProjectDir({ react: '^19.0.0' })` for React, `createProjectDir({ express: '^4.0.0' })` for Node.js, `createProjectDir(null)` for an empty project). Only pre-build a `WizardStore` directly when the test needs store state that `createProjectDir` cannot provide (e.g., a framework already set from an earlier screen). +- **Prefer `createProjectDir()` for setting up project context** (framework, dependencies, project structure) in TUI screen tests. Use scaffold types to control framework detection (e.g., `createProjectDir('react')`, `createProjectDir('empty')`, `createProjectDir('react-statsig')`). The function lives in `__tests__/shared/project-scaffold.ts` and is shared between integration and e2e tests. Only pre-build a `WizardStore` directly when the test needs store state that `createProjectDir` cannot provide (e.g., a framework already set from an earlier screen). - **Prefer `using` for disposable resources.** When a helper returns an object with `[Symbol.dispose]` (e.g., `createProjectDir()`, `renderScreen()`, `renderApp()`), declare it with `using` inside each test rather than sharing it via `beforeAll`/`afterAll`. This keeps each test self-contained and guarantees cleanup even if the test throws. ```ts diff --git a/AGENTS.md b/AGENTS.md index d72c8a3..dc5638c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,7 +80,7 @@ The stable `node-pty` release (v1.1.0) doesn't ship prebuilt binaries for Node.j - All commits must follow Conventional Commits. The `commit-msg` hook enforces this via commitlint. - Run `pnpm qa` before pushing to ensure CI will pass. - When writing or modifying code, always use the `wizard-architecture` skill first to load the project's architecture and coding conventions. -- When writing or changing tests, always use the `wizard-testing` skill first to load the project's testing guidelines and conventions. +- When writing, modifying, or adding any test file (unit, integration, or e2e), always use the `wizard-testing` skill first to load the project's testing guidelines, conventions, and test framework structure. - When making commits or working with the CI/release pipeline, use the `wizard-development-harness` skill for guidelines. ## Skills (Mandatory) @@ -90,7 +90,7 @@ Before making any changes, agents MUST load the relevant skill(s) from `.claude/ | Skill | When to load | Key rules | | ---------------------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `wizard-architecture` | Any code change | Path aliases for cross-domain imports, dependency direction, dry-run separation, initialization hooks, TypeScript style (`type` over `interface`, `satisfies never` in switch defaults, object params for 4+ args), module exports | -| `wizard-testing` | Any test change | Observable behavior only, AAA pattern, `sut` naming, `using` for disposables, `waitFor` over `delay`, MSW for HTTP mocks, `describe` blocks use consumer-perspective naming (`when…`/`given…`) | +| `wizard-testing` | Any test change or addition | Observable behavior only, AAA pattern, `sut` naming, `using` for disposables, `waitFor` over `delay`, MSW for HTTP mocks, shared test scaffolds (`__tests__/shared/`), `press('Enter')` for e2e keys | | `wizard-ink-tui` | Any TUI/screen change | Ink rendering model, `@inkjs/ui` over standalone packages, `Colors`/`Icons`/`HAlign`/`VAlign` from `styles.ts`, named functions in `useEffect` | | `wizard-integrations` | IDE integration changes | Strategy pattern, self-contained IDE subdirs, adding new IDEs, MCP/chat/plugin flows | | `wizard-development-harness` | Commits, CI, releases | Conventional Commits, `pnpm qa` before push, pre-commit hooks, release-please | From 69b266962832fd62302e0516d05d7f8d6df6a7a3 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 31 Jul 2026 13:14:29 +0200 Subject: [PATCH 6/9] chore: please linter --- AGENTS.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dc5638c..5de40a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,11 +87,11 @@ The stable `node-pty` release (v1.1.0) doesn't ship prebuilt binaries for Node.j Before making any changes, agents MUST load the relevant skill(s) from `.claude/skills/`. These skills contain the authoritative guidelines for this project — architecture constraints, coding conventions, testing philosophy, and development harness rules. Skipping them leads to guideline violations. -| Skill | When to load | Key rules | -| ---------------------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `wizard-architecture` | Any code change | Path aliases for cross-domain imports, dependency direction, dry-run separation, initialization hooks, TypeScript style (`type` over `interface`, `satisfies never` in switch defaults, object params for 4+ args), module exports | -| `wizard-testing` | Any test change or addition | Observable behavior only, AAA pattern, `sut` naming, `using` for disposables, `waitFor` over `delay`, MSW for HTTP mocks, shared test scaffolds (`__tests__/shared/`), `press('Enter')` for e2e keys | -| `wizard-ink-tui` | Any TUI/screen change | Ink rendering model, `@inkjs/ui` over standalone packages, `Colors`/`Icons`/`HAlign`/`VAlign` from `styles.ts`, named functions in `useEffect` | -| `wizard-integrations` | IDE integration changes | Strategy pattern, self-contained IDE subdirs, adding new IDEs, MCP/chat/plugin flows | -| `wizard-development-harness` | Commits, CI, releases | Conventional Commits, `pnpm qa` before push, pre-commit hooks, release-please | -| `wizard-workflows` | Workflow changes | Hash-pinned actions with version comments, minimal permissions, per-secret references | +| Skill | When to load | Key rules | +| ---------------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `wizard-architecture` | Any code change | Path aliases for cross-domain imports, dependency direction, dry-run separation, initialization hooks, TypeScript style (`type` over `interface`, `satisfies never` in switch defaults, object params for 4+ args), module exports | +| `wizard-testing` | Any test change or addition | Observable behavior only, AAA pattern, `sut` naming, `using` for disposables, `waitFor` over `delay`, MSW for HTTP mocks, shared test scaffolds (`__tests__/shared/`), `press('Enter')` for e2e keys | +| `wizard-ink-tui` | Any TUI/screen change | Ink rendering model, `@inkjs/ui` over standalone packages, `Colors`/`Icons`/`HAlign`/`VAlign` from `styles.ts`, named functions in `useEffect` | +| `wizard-integrations` | IDE integration changes | Strategy pattern, self-contained IDE subdirs, adding new IDEs, MCP/chat/plugin flows | +| `wizard-development-harness` | Commits, CI, releases | Conventional Commits, `pnpm qa` before push, pre-commit hooks, release-please | +| `wizard-workflows` | Workflow changes | Hash-pinned actions with version comments, minimal permissions, per-secret references | From dd674aec2e4f21e5756e133f899dc691003d0f3e Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 31 Jul 2026 13:23:33 +0200 Subject: [PATCH 7/9] fix: CI temp directory regression during E2E runs --- .../e2e/__snapshots__/empty-project.e2e.ts.snap | 12 ++++++------ __tests__/e2e/__snapshots__/happy-path.e2e.ts.snap | 6 +++--- .../e2e/__snapshots__/welcome-navigation.e2e.ts.snap | 6 +++--- __tests__/shared/project-scaffold.ts | 9 +++++++-- 4 files changed, 19 insertions(+), 14 deletions(-) diff --git a/__tests__/e2e/__snapshots__/empty-project.e2e.ts.snap b/__tests__/e2e/__snapshots__/empty-project.e2e.ts.snap index 730b17b..03ad0bd 100644 --- a/__tests__/e2e/__snapshots__/empty-project.e2e.ts.snap +++ b/__tests__/e2e/__snapshots__/empty-project.e2e.ts.snap @@ -16,9 +16,9 @@ exports[`when the project is empty > shows "Select framework" instead of "Start 5. Show a working feature flag example - Directory ✔ - Framework ✘ Could not auto-detect - Telemetry ✘ off + Directory ✔ + Framework ✘ Could not auto-detect + Telemetry ✘ off ────────────────────────────────────────────────────────────────────────────────────────────────── @@ -47,9 +47,9 @@ exports[`when the project is empty > shows "Start setup" after selecting a frame 5. Show a working feature flag example - Directory ✔ - Framework ✔ React (selected) - Telemetry ✘ off + Directory ✔ + Framework ✔ React (selected) + Telemetry ✘ off ────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/__tests__/e2e/__snapshots__/happy-path.e2e.ts.snap b/__tests__/e2e/__snapshots__/happy-path.e2e.ts.snap index 3de3c1f..91fa0d9 100644 --- a/__tests__/e2e/__snapshots__/happy-path.e2e.ts.snap +++ b/__tests__/e2e/__snapshots__/happy-path.e2e.ts.snap @@ -139,9 +139,9 @@ exports[`happy-path flow > navigates Welcome → SystemCheck → Authenticate 5. Show a working feature flag example - Directory ✔ - Framework ✔ React (detected) - Telemetry ✘ off + Directory ✔ + Framework ✔ React (detected) + Telemetry ✘ off ────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/__tests__/e2e/__snapshots__/welcome-navigation.e2e.ts.snap b/__tests__/e2e/__snapshots__/welcome-navigation.e2e.ts.snap index 09b8fb8..5ed7105 100644 --- a/__tests__/e2e/__snapshots__/welcome-navigation.e2e.ts.snap +++ b/__tests__/e2e/__snapshots__/welcome-navigation.e2e.ts.snap @@ -16,9 +16,9 @@ exports[`welcome screen navigation > exits cleanly on Quit > welcome-quit-select 5. Show a working feature flag example - Directory ✔ - Framework ✔ React (detected) - Telemetry ✘ off + Directory ✔ + Framework ✔ React (detected) + Telemetry ✘ off ────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/__tests__/shared/project-scaffold.ts b/__tests__/shared/project-scaffold.ts index 7d6f76e..1299b00 100644 --- a/__tests__/shared/project-scaffold.ts +++ b/__tests__/shared/project-scaffold.ts @@ -1,6 +1,5 @@ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; import { join } from 'node:path'; -import { tmpdir } from 'node:os'; import { noop } from '@lib/noop.js'; /** @@ -36,6 +35,12 @@ const SCAFFOLDS: Record void> = { * Creates an isolated temporary directory populated with the requested * project scaffold. Supports `Symbol.dispose` for automatic cleanup. * + * @remarks + * Uses a hardcoded `/tmp/` prefix instead of `os.tmpdir()`. On macOS + * `tmpdir()` returns `/var/folders/…` which is longer than Linux's `/tmp/`, + * shifting column alignment in the VT100 screen buffer and breaking e2e + * snapshot assertions across platforms. + * * @param type - Which scaffold to use. @defaultValue `'react'` * @returns An object with `path` and a disposer that removes the directory. * @@ -49,7 +54,7 @@ const SCAFFOLDS: Record void> = { * ``` */ export function createProjectDir(type: ProjectType = 'react') { - const dir = mkdtempSync(join(tmpdir(), 'wizard-test-')); + const dir = mkdtempSync('/tmp/wizard-test-'); SCAFFOLDS[type](dir); return { From 1439149e8e22cb52740d3cff0cea6253b8e03bdc Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 31 Jul 2026 13:38:35 +0200 Subject: [PATCH 8/9] test: encapsulate project scaffolding for all project kinds --- .../e2e/testing-framework/session-factory.ts | 2 +- __tests__/providers/detect.test.ts | 40 +++-------- __tests__/providers/helpers/project.ts | 18 ----- __tests__/shared/project-scaffold.ts | 68 ------------------- __tests__/shared/project-scaffold/index.ts | 38 +++++++++++ .../shared/project-scaffold/scaffolds.ts | 45 ++++++++++++ __tests__/shared/project-scaffold/types.ts | 38 +++++++++++ __tests__/ui/testing-framework/index.ts | 2 +- 8 files changed, 134 insertions(+), 117 deletions(-) delete mode 100644 __tests__/providers/helpers/project.ts delete mode 100644 __tests__/shared/project-scaffold.ts create mode 100644 __tests__/shared/project-scaffold/index.ts create mode 100644 __tests__/shared/project-scaffold/scaffolds.ts create mode 100644 __tests__/shared/project-scaffold/types.ts diff --git a/__tests__/e2e/testing-framework/session-factory.ts b/__tests__/e2e/testing-framework/session-factory.ts index 50e005d..d2c706f 100644 --- a/__tests__/e2e/testing-framework/session-factory.ts +++ b/__tests__/e2e/testing-framework/session-factory.ts @@ -2,7 +2,7 @@ import { mkdtempSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { TerminalSession } from './terminal/index.js'; -import { createProjectDir, type ProjectType } from '../../shared/project-scaffold.js'; +import { createProjectDir, type ProjectType } from '../../shared/project-scaffold/index.js'; /** * Creates an isolated {@link TerminalSession} pre-configured for e2e testing. diff --git a/__tests__/providers/detect.test.ts b/__tests__/providers/detect.test.ts index a058492..2996bec 100644 --- a/__tests__/providers/detect.test.ts +++ b/__tests__/providers/detect.test.ts @@ -1,12 +1,10 @@ -import { writeFileSync } from 'node:fs'; -import { join } from 'node:path'; import { detectProviders } from '@providers/index.js'; -import { createProjectDir } from './helpers/project.js'; +import { createProjectDir } from '../shared/project-scaffold/index.js'; describe('detectProviders', () => { describe('when project has no manifest files', () => { it('returns empty array for empty directory', () => { - using project = createProjectDir(null); + using project = createProjectDir('empty'); const sut = detectProviders(project.path); expect(sut).toEqual([]); }); @@ -14,13 +12,13 @@ describe('detectProviders', () => { describe('when project has npm dependencies', () => { it('detects Eppo SDK', () => { - using project = createProjectDir({ '@eppo/js-client-sdk': '^1.0.0', react: '^19.0.0' }); + using project = createProjectDir('react-eppo'); const sut = detectProviders(project.path); expect(sut).toEqual([{ id: 'eppo', name: 'Eppo', skillName: 'migrate-eppo' }]); }); it('detects Optimizely SDK', () => { - using project = createProjectDir({ '@optimizely/optimizely-sdk': '^5.0.0' }); + using project = createProjectDir('optimizely'); const sut = detectProviders(project.path); expect(sut).toEqual([ { id: 'optimizely', name: 'Optimizely', skillName: 'migrate-optimizely' }, @@ -28,23 +26,19 @@ describe('detectProviders', () => { }); it('detects PostHog SDK', () => { - using project = createProjectDir({ 'posthog-js': '^1.0.0' }); + using project = createProjectDir('posthog'); const sut = detectProviders(project.path); expect(sut).toEqual([{ id: 'posthog', name: 'PostHog', skillName: 'migrate-posthog' }]); }); it('detects Statsig SDK', () => { - using project = createProjectDir({ '@statsig/js-client': '^1.0.0' }); + using project = createProjectDir('statsig'); const sut = detectProviders(project.path); expect(sut).toEqual([{ id: 'statsig', name: 'Statsig', skillName: 'migrate-statsig' }]); }); it('detects multiple providers', () => { - using project = createProjectDir({ - 'posthog-js': '^1.0.0', - '@statsig/react-sdk': '^2.0.0', - react: '^19.0.0', - }); + using project = createProjectDir('react-posthog-statsig'); const sut = detectProviders(project.path); @@ -53,18 +47,13 @@ describe('detectProviders', () => { }); it('detects provider in devDependencies', () => { - using project = createProjectDir(null); - writeFileSync( - join(project.path, 'package.json'), - JSON.stringify({ devDependencies: { 'statsig-node': '^1.0.0' } }), - ); - + using project = createProjectDir('statsig-node'); const sut = detectProviders(project.path); expect(sut).toEqual([{ id: 'statsig', name: 'Statsig', skillName: 'migrate-statsig' }]); }); it('returns empty array when no providers found', () => { - using project = createProjectDir({ react: '^19.0.0', next: '^15.0.0' }); + using project = createProjectDir('nextjs'); const sut = detectProviders(project.path); expect(sut).toEqual([]); }); @@ -72,20 +61,13 @@ describe('detectProviders', () => { describe('when project has Python dependencies', () => { it('detects provider from requirements.txt', () => { - using project = createProjectDir(null); - writeFileSync(join(project.path, 'requirements.txt'), 'posthog>=3.0.0\nflask==2.0.0\n'); - + using project = createProjectDir('python-posthog'); const sut = detectProviders(project.path); expect(sut).toEqual([{ id: 'posthog', name: 'PostHog', skillName: 'migrate-posthog' }]); }); it('detects provider from pyproject.toml', () => { - using project = createProjectDir(null); - writeFileSync( - join(project.path, 'pyproject.toml'), - `[project]\nname = "myapp"\ndependencies = [\n "statsig>=1.0.0",\n "flask"\n]\n`, - ); - + using project = createProjectDir('python-statsig'); const sut = detectProviders(project.path); expect(sut).toEqual([{ id: 'statsig', name: 'Statsig', skillName: 'migrate-statsig' }]); }); diff --git a/__tests__/providers/helpers/project.ts b/__tests__/providers/helpers/project.ts deleted file mode 100644 index 22953c9..0000000 --- a/__tests__/providers/helpers/project.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; - -export function createProjectDir(deps: Record | null = null) { - const dir = mkdtempSync(join(tmpdir(), 'provider-test-')); - - if (deps) { - writeFileSync(join(dir, 'package.json'), JSON.stringify({ dependencies: deps })); - } - - return { - path: dir, - [Symbol.dispose]() { - rmSync(dir, { recursive: true, force: true }); - }, - }; -} diff --git a/__tests__/shared/project-scaffold.ts b/__tests__/shared/project-scaffold.ts deleted file mode 100644 index 1299b00..0000000 --- a/__tests__/shared/project-scaffold.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; -import { noop } from '@lib/noop.js'; - -/** - * Identifies a pre-built project scaffold. - * - * Each variant produces a minimal project directory that triggers a - * specific code path in the wizard's framework-detection logic. - * - * - `'react'` — `package.json` with React 19 (auto-detects React framework) - * - `'empty'` — bare directory (forces manual framework selection) - * - `'react-statsig'` — React + Statsig SDK (triggers competitor detection) - * - `'react-posthog-statsig'` — React + PostHog + Statsig (multi-competitor) - */ -type ProjectType = 'react' | 'empty' | 'react-statsig' | 'react-posthog-statsig'; - -function writeDeps(dir: string, dependencies: Record): void { - writeFileSync(join(dir, 'package.json'), JSON.stringify({ dependencies })); -} - -const SCAFFOLDS: Record void> = { - empty: noop, - react: (dir) => writeDeps(dir, { react: '^19.0.0' }), - 'react-statsig': (dir) => writeDeps(dir, { react: '^19.0.0', '@statsig/js-client': '^1.0.0' }), - 'react-posthog-statsig': (dir) => - writeDeps(dir, { - react: '^19.0.0', - 'posthog-js': '^1.0.0', - '@statsig/js-client': '^1.0.0', - }), -}; - -/** - * Creates an isolated temporary directory populated with the requested - * project scaffold. Supports `Symbol.dispose` for automatic cleanup. - * - * @remarks - * Uses a hardcoded `/tmp/` prefix instead of `os.tmpdir()`. On macOS - * `tmpdir()` returns `/var/folders/…` which is longer than Linux's `/tmp/`, - * shifting column alignment in the VT100 screen buffer and breaking e2e - * snapshot assertions across platforms. - * - * @param type - Which scaffold to use. @defaultValue `'react'` - * @returns An object with `path` and a disposer that removes the directory. - * - * @example - * ```ts - * using project = createProjectDir('react'); - * // project.path contains package.json with { dependencies: { react: '^19.0.0' } } - * - * using project = createProjectDir('empty'); - * // project.path is an empty temporary directory - * ``` - */ -export function createProjectDir(type: ProjectType = 'react') { - const dir = mkdtempSync('/tmp/wizard-test-'); - SCAFFOLDS[type](dir); - - return { - path: dir, - [Symbol.dispose]() { - rmSync(dir, { recursive: true, force: true }); - }, - }; -} - -export type { ProjectType }; diff --git a/__tests__/shared/project-scaffold/index.ts b/__tests__/shared/project-scaffold/index.ts new file mode 100644 index 0000000..a170981 --- /dev/null +++ b/__tests__/shared/project-scaffold/index.ts @@ -0,0 +1,38 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { SCAFFOLDS } from './scaffolds.js'; +import type { ProjectType } from './types.js'; + +export type { ProjectType }; + +/** + * Creates an isolated temporary directory populated with the requested + * project scaffold. Supports `Symbol.dispose` for automatic cleanup. + * + * @remarks + * Uses a hardcoded `/tmp/` prefix instead of `os.tmpdir()`. On macOS + * `tmpdir()` returns `/var/folders/…` which is longer than Linux's `/tmp/`, + * shifting column alignment in the VT100 screen buffer and breaking e2e + * snapshot assertions across platforms. + * + * @param type - A named scaffold, or `null` for an empty directory. + * @defaultValue `'react'` + * @returns An object with `path` and a disposer that removes the directory. + * + * @example + * ```ts + * using project = createProjectDir('react'); + * using project = createProjectDir('empty'); + * using project = createProjectDir('python-posthog'); + * ``` + */ +export function createProjectDir(type: ProjectType = 'react') { + const dir = mkdtempSync('/tmp/wizard-test-'); + SCAFFOLDS[type](dir); + + return { + path: dir, + [Symbol.dispose]() { + rmSync(dir, { recursive: true, force: true }); + }, + }; +} diff --git a/__tests__/shared/project-scaffold/scaffolds.ts b/__tests__/shared/project-scaffold/scaffolds.ts new file mode 100644 index 0000000..de55be1 --- /dev/null +++ b/__tests__/shared/project-scaffold/scaffolds.ts @@ -0,0 +1,45 @@ +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { noop } from '@lib/noop.js'; +import type { ProjectType } from './types.js'; + +function writeDeps(dir: string, dependencies: Record): void { + writeFileSync(join(dir, 'package.json'), JSON.stringify({ dependencies })); +} + +function writeDevDeps(dir: string, devDependencies: Record): void { + writeFileSync(join(dir, 'package.json'), JSON.stringify({ devDependencies })); +} + +function writeFile(dir: string, filename: string, content: string): void { + writeFileSync(join(dir, filename), content); +} + +export const SCAFFOLDS: Record void> = { + empty: noop, + + react: (dir) => writeDeps(dir, { react: '^19.0.0' }), + nextjs: (dir) => writeDeps(dir, { react: '^19.0.0', next: '^15.0.0' }), + + 'react-eppo': (dir) => writeDeps(dir, { react: '^19.0.0', '@eppo/js-client-sdk': '^1.0.0' }), + 'react-statsig': (dir) => writeDeps(dir, { react: '^19.0.0', '@statsig/js-client': '^1.0.0' }), + 'react-posthog-statsig': (dir) => + writeDeps(dir, { + react: '^19.0.0', + 'posthog-js': '^1.0.0', + '@statsig/react-sdk': '^2.0.0', + }), + + optimizely: (dir) => writeDeps(dir, { '@optimizely/optimizely-sdk': '^5.0.0' }), + posthog: (dir) => writeDeps(dir, { 'posthog-js': '^1.0.0' }), + statsig: (dir) => writeDeps(dir, { '@statsig/js-client': '^1.0.0' }), + 'statsig-node': (dir) => writeDevDeps(dir, { 'statsig-node': '^1.0.0' }), + + 'python-posthog': (dir) => writeFile(dir, 'requirements.txt', 'posthog>=3.0.0\nflask==2.0.0\n'), + 'python-statsig': (dir) => + writeFile( + dir, + 'pyproject.toml', + `[project]\nname = "myapp"\ndependencies = [\n "statsig>=1.0.0",\n "flask"\n]\n`, + ), +}; diff --git a/__tests__/shared/project-scaffold/types.ts b/__tests__/shared/project-scaffold/types.ts new file mode 100644 index 0000000..cc0372b --- /dev/null +++ b/__tests__/shared/project-scaffold/types.ts @@ -0,0 +1,38 @@ +/** + * Identifies a pre-built project scaffold. + * + * Each variant produces a minimal project directory that triggers a + * specific code path in the wizard's framework-detection or + * provider-detection logic. + * + * **Framework scaffolds:** + * - `'empty'` — bare directory (forces manual framework selection) + * - `'react'` — `package.json` with React 19 + * - `'nextjs'` — React + Next.js 15 + * + * **Provider scaffolds (npm):** + * - `'react-eppo'` — React + Eppo SDK + * - `'react-statsig'` — React + Statsig SDK + * - `'react-posthog-statsig'` — React + PostHog + Statsig + * - `'optimizely'` — Optimizely SDK only + * - `'posthog'` — PostHog SDK only + * - `'statsig'` — Statsig SDK only + * - `'statsig-node'` — Statsig Node SDK in dev dependencies + * + * **Provider scaffolds (Python):** + * - `'python-posthog'` — `requirements.txt` with PostHog + * - `'python-statsig'` — `pyproject.toml` with Statsig + */ +export type ProjectType = + | 'empty' + | 'react' + | 'nextjs' + | 'react-eppo' + | 'react-statsig' + | 'react-posthog-statsig' + | 'optimizely' + | 'posthog' + | 'statsig' + | 'statsig-node' + | 'python-posthog' + | 'python-statsig'; diff --git a/__tests__/ui/testing-framework/index.ts b/__tests__/ui/testing-framework/index.ts index 06f6b2b..eff92cc 100644 --- a/__tests__/ui/testing-framework/index.ts +++ b/__tests__/ui/testing-framework/index.ts @@ -4,7 +4,7 @@ export { delay, waitFor } from './async.js'; export { KEY_MAP, resolveKey, type KeyName, type Modifiers } from '../../shared/key-map.js'; export { buildTestJwt, buildExpiredJwt, buildAuthState } from '../../shared/auth.js'; -export { createProjectDir, type ProjectType } from '../../shared/project-scaffold.js'; +export { createProjectDir, type ProjectType } from '../../shared/project-scaffold/index.js'; import { KEY_MAP } from '../../shared/key-map.js'; From 47eeee734c96990e85954f3584e8deec6cac5719 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Fri, 31 Jul 2026 14:58:51 +0200 Subject: [PATCH 9/9] test: fix post-merge artifacts --- __tests__/e2e/__snapshots__/stale-auth.e2e.ts.snap | 4 ++-- __tests__/e2e/stale-auth.e2e.ts | 4 ++-- __tests__/e2e/testing-framework/navigation.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/__tests__/e2e/__snapshots__/stale-auth.e2e.ts.snap b/__tests__/e2e/__snapshots__/stale-auth.e2e.ts.snap index bf18044..60b6e73 100644 --- a/__tests__/e2e/__snapshots__/stale-auth.e2e.ts.snap +++ b/__tests__/e2e/__snapshots__/stale-auth.e2e.ts.snap @@ -36,7 +36,7 @@ exports[`when auth token is stale > allows signing in after failed refresh and p ────────────────────────────────────────────────────────────────────────────────────────────────── - Which agent tool are you using? + Which CLI agent would you like to use? ❯ Claude Code Cursor @@ -99,7 +99,7 @@ exports[`when auth token is stale > refreshes and authenticates when choosing ex ────────────────────────────────────────────────────────────────────────────────────────────────── - Which agent tool are you using? + Which CLI agent would you like to use? ❯ Claude Code Cursor diff --git a/__tests__/e2e/stale-auth.e2e.ts b/__tests__/e2e/stale-auth.e2e.ts index 43b740a..0509052 100644 --- a/__tests__/e2e/stale-auth.e2e.ts +++ b/__tests__/e2e/stale-auth.e2e.ts @@ -41,7 +41,7 @@ describe('when auth token is stale', () => { await session.waitForText('Authenticated'); // Continues to InstallPlugins - await session.waitForText('Which agent tool are you using?'); + await session.waitForText('Which CLI agent would you like to use?'); expect(session.snapshot()).toMatchSnapshot('auth-refreshed'); }); @@ -93,7 +93,7 @@ describe('when auth token is stale', () => { await session.waitForText('Authenticated'); // Continues to InstallPlugins - await session.waitForText('Which agent tool are you using?'); + await session.waitForText('Which CLI agent would you like to use?'); expect(session.snapshot()).toMatchSnapshot('auth-refresh-failed-then-signed-in'); }); diff --git a/__tests__/e2e/testing-framework/navigation.ts b/__tests__/e2e/testing-framework/navigation.ts index 05f1192..63f5721 100644 --- a/__tests__/e2e/testing-framework/navigation.ts +++ b/__tests__/e2e/testing-framework/navigation.ts @@ -43,7 +43,7 @@ export async function navigateToPlugins(session: TerminalSession): Promise await navigatePastWelcome(session); await navigatePastAuth(session); session.checkpoint(); - await session.waitForText('Which agent tool are you using?'); + await session.waitForText('Which CLI agent would you like to use?'); } /**