diff --git a/e2e-harness/__tests__/e2e-flow-snapshot.test.ts b/e2e-harness/__tests__/e2e-flow-snapshot.test.ts index 2a525cfb0..243d4f830 100644 --- a/e2e-harness/__tests__/e2e-flow-snapshot.test.ts +++ b/e2e-harness/__tests__/e2e-flow-snapshot.test.ts @@ -18,6 +18,7 @@ import { InkUI } from '@ui/tui/ink-ui'; import { setUI } from '@ui/index'; import { buildSession, RunPhase } from '@lib/wizard-session'; import { Integration } from '@lib/constants'; +import { HostResolution } from '@lib/host-resolution'; import { FRAMEWORK_REGISTRY } from '@lib/registry'; import { WizardReadiness } from '@lib/health-checks/readiness'; import { @@ -89,7 +90,7 @@ function traceFlow( store.setCredentials({ accessToken: 'phx_x', projectApiKey: 'phc_x', - host: 'https://us.posthog.com', + host: HostResolution.fromApiHost('https://us.posthog.com'), projectId: 1, }); } else if (screen === ScreenId.SelfDrivingGithub) { diff --git a/e2e-harness/__tests__/wizard-ci-driver.test.ts b/e2e-harness/__tests__/wizard-ci-driver.test.ts index 45d4af832..2edf4553e 100644 --- a/e2e-harness/__tests__/wizard-ci-driver.test.ts +++ b/e2e-harness/__tests__/wizard-ci-driver.test.ts @@ -251,7 +251,7 @@ describe('WizardCiDriver — source-maps project pick', () => { store.setCredentials({ accessToken: 'phx_x', projectApiKey: 'phc_x', - host: 'https://us.posthog.com', + host: HostResolution.fromApiHost('https://us.posthog.com'), projectId: 1, }); } diff --git a/package.json b/package.json index 617175ae8..61cf60883 100644 --- a/package.json +++ b/package.json @@ -132,6 +132,13 @@ "fix:prettier": "prettier --write \"{lib,src,test}/**/*.ts\"", "fix:eslint": "eslint . --cache --format stylish --fix", "test": "pnpm build && vitest run", + "test:legacy": "vitest run --project legacy", + "test:agent": "vitest run --project agent", + "test:tui": "vitest run --project tui", + "test:cli": "vitest run --project cli", + "test:harness": "vitest run --project harness", + "test:arch": "vitest run --project architecture", + "test:goldens:update": "vitest run -u", "test:coverage": "pnpm build && vitest run --coverage", "test:e2e": "pnpm build && ./e2e-tests/run.sh", "test:e2e-record": "export RECORD_FIXTURES=true && pnpm build && ./e2e-tests/run.sh", diff --git a/scripts/README.md b/scripts/README.md index a136ce8f5..4fb836878 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -12,17 +12,18 @@ real ink render) and is driven purely by store state manipulation; a PTY parent ([`e2e-harness/tui-capture.ts`](../e2e-harness/tui-capture.ts), node-pty + `@xterm/headless`) captures the real rendered screen. -| Script | What it does | Needs | -| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | -| **`tui-host.no-jest.ts`** | Real TUI host: `MODE=fixed` follows a profile; `MODE=serve` accepts socket commands. | `APP_DIR`, `PROJECT_ID`, key for a full run, `SNAP_CTRL`; run under a PTY, with `CONTROL_SOCK` in serve mode | -| **`tui-snapshots.no-jest.ts`** | Runs the fixed host and saves colored `SNAP_OUT/NN-.ans` frames, including within-screen progress. | `SNAP_OUT`, `APP_DIR`, `PROJECT_ID`, `POSTHOG_KEY_FILE` or `POSTHOG_PERSONAL_API_KEY` | -| **`wizard-ci-mcp.no-jest.ts`** | Stdio MCP server: `open_app`, `read_state`, `perform_action`, `render_screen`, `run_agent`. Screen output is plain text. | Spawns the host; `open_app` requires `appDir` and `projectId`, with optional `keyFile`, `apiKey`, `region` | -| **`wizard-ci-explore.no-jest.ts`** | `pnpm wizard-ci-explore`: opens an app, confirms setup, reads state, prints one frame, and exits. It does not run the agent. | `APP_DIR`, `PROJECT_ID`; optional `POSTHOG_KEY_FILE` | +| Script | What it does | Needs | +| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| **`tui-host.no-jest.ts`** | Real TUI host: `MODE=fixed` follows a profile; `MODE=serve` accepts socket commands. | `APP_DIR`, `PROJECT_ID`, key for a full run, `SNAP_CTRL`; run under a PTY, with `CONTROL_SOCK` in serve mode | +| **`tui-snapshots.no-jest.ts`** | Runs the fixed host and saves colored `SNAP_OUT/NN-.ans` frames, including within-screen progress. | `SNAP_OUT`, `APP_DIR`, `PROJECT_ID`, `POSTHOG_KEY_FILE` or `POSTHOG_PERSONAL_API_KEY` | +| **`wizard-ci-mcp.no-jest.ts`** | Stdio MCP server: `open_app`, `read_state`, `perform_action`, `render_screen`, `run_agent`. Screen output is plain text. | Spawns the host; `open_app` requires `appDir` and `projectId`, with optional `keyFile`, `apiKey`, `region` | +| **`chunk-manifest.no-jest.ts`** | Prints a structural manifest of `dist/`: per chunk, the source files it contains and the chunks it imports, hash suffixes stripped. `--summary` prints chunk names plus the sorted source set. A reading tool, nothing diffs its output. | A built `dist/` | +| **`wizard-ci-explore.no-jest.ts`** | `pnpm wizard-ci-explore`: opens an app, confirms setup, reads state, prints one frame, and exits. It does not run the agent. | `APP_DIR`, `PROJECT_ID`; optional `POSTHOG_KEY_FILE` | > You usually don't call these directly — `pnpm wizard-ci-snapshots` (in > [wizard-workbench](https://github.com/PostHog/wizard-workbench)) orchestrates -> the snapshot route; the MCP server is registered in this repo's `.mcp.json` and -> used via the `exploring-the-wizard` skill. +> the snapshot route; the MCP server is registered in this repo's `.mcp.json` +> and used via the `exploring-the-wizard` skill. `PROGRAM`, `SNAP_HARNESS`, `SNAP_SEQUENCE`, `SNAP_MODEL`, and `E2E_ASK` are host environment inputs, inherited from the launcher; they are not MCP tool @@ -34,11 +35,11 @@ before choosing credentials or an EU project. ## Credentials for full agent runs Full TUI host and snapshot runs require both the personal API key (or -`POSTHOG_KEY_FILE`) and `WIZARD_CI_GATEWAY_TOKEN_FILE`, plus `PROJECT_ID`. -For MCP runs, pass the personal key through `open_app` and set the gateway -token file path in the server environment before launch. Restart the server -after changing it; the gateway path is not an MCP tool argument. -Detection-only exploration does not need either secret. See +`POSTHOG_KEY_FILE`) and `WIZARD_CI_GATEWAY_TOKEN_FILE`, plus `PROJECT_ID`. For +MCP runs, pass the personal key through `open_app` and set the gateway token +file path in the server environment before launch. Restart the server after +changing it; the gateway path is not an MCP tool argument. Detection-only +exploration does not need either secret. See [local credential setup](../docs/local-dev.md#credentials-for-local-ci-and-headless-runs). ## Background @@ -46,5 +47,5 @@ Detection-only exploration does not need either secret. See The control plane lives in [`e2e-harness/`](../e2e-harness/) — out of `src/`, so none of it ships in prod. `WizardCiDriver` (read/act over the store), the screen→action registry, the e2e profiles, and `tui-capture` (real-TUI PTY -capture). See [`ARCHITECTURE.md`](../e2e-harness/ARCHITECTURE.md) for how the two -routes drive these (env strip, scoped project id, gotchas). +capture). See [`ARCHITECTURE.md`](../e2e-harness/ARCHITECTURE.md) for how the +two routes drive these (env strip, scoped project id, gotchas). diff --git a/scripts/chunk-manifest.no-jest.ts b/scripts/chunk-manifest.no-jest.ts new file mode 100644 index 000000000..51d5b731d --- /dev/null +++ b/scripts/chunk-manifest.no-jest.ts @@ -0,0 +1,55 @@ +/** + * Structural manifest of the built bundle: for every chunk in dist/, the + * source files it contains (from its sourcemap) and the chunks it imports. + * Hash suffixes are stripped so the output is stable across builds. + * + * tsx scripts/chunk-manifest.no-jest.ts [distDir] > manifest.json + * tsx scripts/chunk-manifest.no-jest.ts [distDir] --summary > summary.json + * + * `--summary` prints the chunk names and the sorted set of every bundled + * source. Output differs between macOS and Linux (chunk assignment and + * sourcemap sources), so this is a reading tool, not a golden. + */ +import fs from 'fs'; +import path from 'path'; + +const args = process.argv.slice(2); +const summary = args.includes('--summary'); +const dist = path.resolve(args.find((a) => !a.startsWith('--')) ?? 'dist'); +const root = path.resolve(dist, '..'); + +const stripHash = (file: string): string => + file.replace(/-[A-Za-z0-9_-]{8}\.js$/, '.js'); + +const importRe = /(?:from\s*|import\s*\(\s*)["']\.\/([^"']+\.js)["']/g; + +const manifest: Record = {}; + +for (const file of fs + .readdirSync(dist) + .filter((f) => f.endsWith('.js')) + .sort()) { + const code = fs.readFileSync(path.join(dist, file), 'utf8'); + const mapPath = path.join(dist, `${file}.map`); + const sources = fs.existsSync(mapPath) + ? ( + JSON.parse(fs.readFileSync(mapPath, 'utf8')) as { sources: string[] } + ).sources + .map((s) => path.relative(root, path.resolve(dist, s))) + .filter((s) => !s.includes('node_modules')) + .sort() + : []; + const imports = new Set(); + for (const m of code.matchAll(importRe)) imports.add(stripHash(m[1])); + manifest[stripHash(file)] = { sources, imports: [...imports].sort() }; +} + +const output = summary + ? { + chunks: Object.keys(manifest).sort(), + sources: [ + ...new Set(Object.values(manifest).flatMap((c) => c.sources)), + ].sort(), + } + : manifest; +process.stdout.write(JSON.stringify(output, null, 2) + '\n'); diff --git a/src/__tests__/architecture/import-boundaries.test.ts b/src/__tests__/architecture/import-boundaries.test.ts new file mode 100644 index 000000000..8220182ae --- /dev/null +++ b/src/__tests__/architecture/import-boundaries.test.ts @@ -0,0 +1,403 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; + +export type Surface = 'env' | 'legacy' | 'agent' | 'tui' | 'cli'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(HERE, '../../..'); + +const SURFACE_RULES: ReadonlyArray boolean]> = + [ + ['env', (p) => p === 'src/env.ts'], + [ + 'agent', + (p) => + p.startsWith('src/lib/agent/') || + p.startsWith('src/lib/middleware/') || + p === 'src/lib/gateway-session.ts' || + p === 'src/lib/yara-hooks.ts' || + p === 'src/lib/yara-policy.ts' || + p === 'src/lib/wizard-tools/mcp.ts', + ], + [ + 'tui', + (p) => + p.startsWith('src/ui/tui/') || + p === 'src/ui/logging-ui.ts' || + p === 'src/ui/headless-ui.ts' || + p === 'src/commands/factories/family-picker.tsx' || + /^src\/lib\/programs\/[^/]+\/content\//.test(p) || + /^src\/lib\/programs\/[^/]+\/tips\.ts$/.test(p), + ], + [ + 'cli', + (p) => + p === 'bin.ts' || + p === 'src/wizard.ts' || + p === 'src/telemetry.ts' || + p.startsWith('src/commands/') || + p.startsWith('src/lib/runners/'), + ], + ]; + +export function classifySurface(relPath: string): Surface { + const p = relPath.split(path.sep).join('/'); + for (const [surface, matches] of SURFACE_RULES) { + if (matches(p)) return surface; + } + return 'legacy'; +} + +export const ALLOWED_IMPORTS: Record = { + env: [], + legacy: ['env', 'legacy'], + agent: ['env', 'legacy', 'agent'], + tui: ['env', 'legacy', 'tui'], + cli: ['env', 'legacy', 'agent', 'tui', 'cli'], +}; + +const TUI_ONLY_PACKAGES = ['ink', 'react', '@inkjs/ui', 'ink-testing-library']; + +const SKIP_DIRS = new Set([ + '__tests__', + '__mocks__', + '__fixtures__', + '__snapshots__', + 'node_modules', + 'dist', + 'coverage', +]); + +const SPECIFIER_PATTERNS = [ + /import\s+(?:type\s+)?[^'"]*?from\s*['"]([^'"]+)['"]/g, + /import\s*['"]([^'"]+)['"]/g, + /export\s+(?:type\s+)?[^'"]*?from\s*['"]([^'"]+)['"]/g, + /import\(\s*['"]([^'"]+)['"]\s*\)/g, + /require\(\s*['"]([^'"]+)['"]\s*\)/g, +]; + +const REGEX_PRECEDERS = new Set([ + '\n', + '(', + ')', + ',', + '=', + ':', + '[', + '!', + '&', + '|', + '?', + '{', + '}', + ';', + '+', + '-', + '*', + '%', + '^', + '~', + '<', + '>', +]); + +function endOfString(source: string, start: number, quote: string): number { + let i = start + 1; + while (i < source.length) { + if (source[i] === '\\') { + i += 2; + continue; + } + if (source[i] === quote) return i + 1; + i++; + } + return source.length; +} + +function endOfRegex(source: string, start: number): number { + let i = start + 1; + let inClass = false; + while (i < source.length) { + const c = source[i]; + if (c === '\\') { + i += 2; + continue; + } + if (c === '\n') return start + 1; + if (c === '[') inClass = true; + else if (c === ']') inClass = false; + else if (c === '/' && !inClass) return i + 1; + i++; + } + return source.length; +} + +function stripComments(source: string): string { + let out = ''; + let prev = '\n'; + let i = 0; + while (i < source.length) { + const c = source[i]; + const next = source[i + 1]; + if (c === '/' && next === '/') { + while (i < source.length && source[i] !== '\n') i++; + continue; + } + if (c === '/' && next === '*') { + i += 2; + while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) + i++; + i += 2; + out += ' '; + continue; + } + if (c === '"' || c === "'" || c === '`') { + const end = endOfString(source, i, c); + out += source.slice(i, end); + prev = c; + i = end; + continue; + } + if (c === '/' && REGEX_PRECEDERS.has(prev)) { + const end = endOfRegex(source, i); + out += source.slice(i, end); + prev = '/'; + i = end; + continue; + } + out += c; + if (c === '\n' || !/\s/.test(c)) prev = c; + i++; + } + return out; +} + +function toRepoRelative(abs: string): string { + return path.relative(REPO_ROOT, abs).split(path.sep).join('/'); +} + +function isFile(abs: string): boolean { + return fs.statSync(abs, { throwIfNoEntry: false })?.isFile() ?? false; +} + +function collectFiles(absDir: string, into: string[]): void { + for (const entry of fs.readdirSync(absDir, { withFileTypes: true })) { + const abs = path.join(absDir, entry.name); + if (entry.isDirectory()) { + if (!SKIP_DIRS.has(entry.name)) collectFiles(abs, into); + continue; + } + if (!entry.isFile()) continue; + if (!/\.tsx?$/.test(entry.name)) continue; + if (/\.d\.ts$/.test(entry.name) || /\.test\.tsx?$/.test(entry.name)) + continue; + into.push(toRepoRelative(abs)); + } +} + +function loadAliases(): ReadonlyArray { + const tsconfig = JSON.parse( + fs.readFileSync(path.join(REPO_ROOT, 'tsconfig.build.json'), 'utf8'), + ) as { compilerOptions?: { paths?: Record } }; + return Object.entries(tsconfig.compilerOptions?.paths ?? {}).map( + ([pattern, targets]) => [pattern, targets[0]] as const, + ); +} + +function aliasTarget( + spec: string, + aliases: ReadonlyArray, +): string | null { + for (const [pattern, target] of aliases) { + if (pattern.endsWith('*')) { + const prefix = pattern.slice(0, -1); + if (spec.startsWith(prefix)) { + return path.resolve( + REPO_ROOT, + target.slice(0, -1) + spec.slice(prefix.length), + ); + } + } else if (spec === pattern) { + return path.resolve(REPO_ROOT, target); + } + } + return null; +} + +function probe(base: string): string | null { + const candidates: string[] = []; + if (base.endsWith('.js')) { + const stem = base.slice(0, -3); + candidates.push(`${stem}.ts`, `${stem}.tsx`); + } + candidates.push( + `${base}.ts`, + `${base}.tsx`, + path.join(base, 'index.ts'), + path.join(base, 'index.tsx'), + base, + ); + return candidates.find(isFile) ?? null; +} + +function specifiersIn(text: string): string[] { + const found = new Set(); + for (const pattern of SPECIFIER_PATTERNS) { + pattern.lastIndex = 0; + let match = pattern.exec(text); + while (match !== null) { + found.add(match[1]); + match = pattern.exec(text); + } + } + return [...found]; +} + +type Analysis = { + files: string[]; + edges: string[]; + violations: ReadonlyArray<{ key: string; rule: string }>; + unresolved: string[]; +}; + +function analyze(): Analysis { + const files: string[] = ['bin.ts']; + collectFiles(path.join(REPO_ROOT, 'src'), files); + files.sort(); + + const aliases = loadAliases(); + const edges = new Set(); + const violations = new Map(); + const unresolved = new Set(); + + for (const file of files) { + const text = stripComments( + fs.readFileSync(path.join(REPO_ROOT, file), 'utf8'), + ); + const from = classifySurface(file); + const allowed = ALLOWED_IMPORTS[from]; + + for (const spec of specifiersIn(text)) { + const base = spec.startsWith('.') + ? path.resolve(REPO_ROOT, path.dirname(file), spec) + : aliasTarget(spec, aliases); + + if (base === null) { + const tuiOnly = + TUI_ONLY_PACKAGES.includes(spec) || spec.startsWith('react/'); + if (tuiOnly && from !== 'tui') { + violations.set(`${file} -> pkg:${spec}`, 'ink-outside-tui'); + } + continue; + } + + const resolved = probe(base); + if (resolved === null) { + unresolved.add(`${file} -> ${spec}`); + continue; + } + + const target = toRepoRelative(resolved); + const key = `${file} -> ${target}`; + edges.add(key); + + const to = classifySurface(target); + if (!allowed.includes(to)) violations.set(key, `matrix:${from}->${to}`); + } + } + + return { + files, + edges: [...edges].sort(), + violations: [...violations] + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([key, rule]) => ({ key, rule })), + unresolved: [...unresolved].sort(), + }; +} + +const analysis = analyze(); + +const known = ( + JSON.parse( + fs.readFileSync(path.join(HERE, 'known-violations.json'), 'utf8'), + ) as { violations: string[] } +).violations; + +if (process.env.PRINT_VIOLATIONS) { + const byRule = new Map(); + const byImporter = new Map(); + for (const { key, rule } of analysis.violations) { + byRule.set(rule, (byRule.get(rule) ?? 0) + 1); + const file = key.split(' -> ')[0]; + byImporter.set(file, (byImporter.get(file) ?? 0) + 1); + } + const lines = [ + `files scanned: ${analysis.files.length}`, + `edges: ${analysis.edges.length}`, + `violations: ${analysis.violations.length}`, + `unresolved: ${analysis.unresolved.length}`, + ...analysis.unresolved.map((u) => ` unresolved: ${u}`), + ...[...byRule] + .sort((a, b) => b[1] - a[1]) + .map(([rule, count]) => ` ${rule}: ${count}`), + 'top importers:', + ...[...byImporter] + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([file, count]) => ` ${file}: ${count}`), + ]; + process.stderr.write(`${lines.join('\n')}\n`); + process.stderr.write( + `${JSON.stringify( + { violations: analysis.violations.map((v) => v.key) }, + null, + 2, + )}\n`, + ); +} + +describe('import boundaries', () => { + it('resolves every internal specifier', () => { + expect(analysis.unresolved).toEqual([]); + }); + + it('introduces no violation outside known-violations.json', () => { + const knownSet = new Set(known); + const added = analysis.violations + .filter(({ key }) => !knownSet.has(key)) + .map(({ key, rule }) => `${key} [${rule}]`); + expect(added).toEqual([]); + }); + + it('keeps known-violations.json free of stale entries', () => { + const current = new Set(analysis.violations.map((v) => v.key)); + const stale = known.filter((key) => !current.has(key)); + expect( + stale, + 'stale entries, delete them from known-violations.json', + ).toEqual([]); + }); +}); + +describe('surface classification', () => { + it('maps representative paths to their surface', () => { + expect(classifySurface('src/env.ts')).toBe('env'); + expect(classifySurface('src/utils/analytics.ts')).toBe('legacy'); + expect(classifySurface('src/lib/agent/agent-runner.ts')).toBe('agent'); + expect(classifySurface('src/ui/tui/App.tsx')).toBe('tui'); + expect(classifySurface('bin.ts')).toBe('cli'); + expect(classifySurface('src/lib/wizard-tools/mcp.ts')).toBe('agent'); + expect(classifySurface('src/lib/wizard-tools/tools.ts')).toBe('legacy'); + expect(classifySurface('src/commands/factories/family-picker.tsx')).toBe( + 'tui', + ); + expect( + classifySurface('src/lib/programs/posthog-integration/content/index.tsx'), + ).toBe('tui'); + expect( + classifySurface('src/lib/programs/posthog-integration/index.ts'), + ).toBe('legacy'); + }); +}); diff --git a/src/__tests__/architecture/known-violations.json b/src/__tests__/architecture/known-violations.json new file mode 100644 index 000000000..d523b2a73 --- /dev/null +++ b/src/__tests__/architecture/known-violations.json @@ -0,0 +1,76 @@ +{ + "violations": [ + "src/commands/factories/family-picker.tsx -> src/commands/command.ts", + "src/env.ts -> src/lib/headless-mode.ts", + "src/lib/agent/mcp-prompt-streaming.ts -> src/ui/tui/services/mcp-suggested-prompts-services.ts", + "src/lib/detection/agentic.ts -> src/lib/agent/agent-interface.ts", + "src/lib/detection/project-scope.ts -> src/lib/agent/runner/shared/authenticate.ts", + "src/lib/errors/agent-map.ts -> src/lib/agent/signals.ts", + "src/lib/programs/agent-skill/index.ts -> src/lib/agent/agent-runner.ts", + "src/lib/programs/agent-skill/index.ts -> src/lib/programs/agent-skill/content/index.tsx", + "src/lib/programs/ai-observability/index.ts -> src/lib/programs/agent-skill/content/index.tsx", + "src/lib/programs/audit/detect.ts -> src/lib/agent/agent-runner.ts", + "src/lib/programs/audit/index.ts -> src/lib/agent/agent-runner.ts", + "src/lib/programs/dispatch-family.ts -> src/commands/command.ts", + "src/lib/programs/dispatch-family.ts -> src/commands/factories/shared.ts", + "src/lib/programs/error-tracking-upload-source-maps/detect.ts -> src/lib/agent/agent-runner.ts", + "src/lib/programs/error-tracking-upload-source-maps/index.ts -> src/lib/agent/agent-runner.ts", + "src/lib/programs/error-tracking-upload-source-maps/index.ts -> src/lib/programs/error-tracking-upload-source-maps/content/index.tsx", + "src/lib/programs/error-tracking-upload-source-maps/prompt.ts -> src/lib/agent/agent-interface.ts", + "src/lib/programs/error-tracking/index.ts -> src/lib/agent/runner/shared/types.ts", + "src/lib/programs/error-tracking/index.ts -> src/lib/programs/error-tracking/content/index.tsx", + "src/lib/programs/error-tracking/index.ts -> src/lib/programs/error-tracking/content/tips.ts", + "src/lib/programs/events-audit/index.ts -> src/lib/agent/agent-runner.ts", + "src/lib/programs/mcp-analytics/index.ts -> src/lib/agent/agent-runner.ts", + "src/lib/programs/metrics/index.ts -> src/lib/programs/agent-skill/content/index.tsx", + "src/lib/programs/migration/index.ts -> src/lib/agent/agent-runner.ts", + "src/lib/programs/migration/index.ts -> src/lib/programs/migration/content/index.tsx", + "src/lib/programs/posthog-integration/index.ts -> src/lib/agent/agent-interface.ts", + "src/lib/programs/posthog-integration/index.ts -> src/lib/agent/agent-runner.ts", + "src/lib/programs/posthog-integration/index.ts -> src/lib/agent/runner/shared/bootstrap.ts", + "src/lib/programs/posthog-integration/index.ts -> src/lib/programs/posthog-integration/content/index.tsx", + "src/lib/programs/program-registry.ts -> src/lib/programs/agent-skill/content/index.tsx", + "src/lib/programs/program-step.ts -> src/lib/agent/agent-runner.ts", + "src/lib/programs/program-step.ts -> src/ui/tui/components/TipsCard.tsx", + "src/lib/programs/program-step.ts -> src/ui/tui/primitives/index.ts", + "src/lib/programs/program-step.ts -> src/ui/tui/store.ts", + "src/lib/programs/replay-vision/index.ts -> src/lib/agent/agent-runner.ts", + "src/lib/programs/revenue-analytics/detect.ts -> src/lib/agent/agent-runner.ts", + "src/lib/programs/revenue-analytics/index.ts -> src/lib/programs/revenue-analytics/content/index.tsx", + "src/lib/programs/self-driving/detect.ts -> src/lib/agent/agent-runner.ts", + "src/lib/programs/self-driving/index.ts -> src/lib/agent/agent-runner.ts", + "src/lib/programs/self-driving/index.ts -> src/lib/programs/self-driving/content/index.tsx", + "src/lib/programs/self-driving/index.ts -> src/lib/programs/self-driving/content/pricing.ts", + "src/lib/programs/self-driving/index.ts -> src/lib/programs/self-driving/content/tips.ts", + "src/lib/programs/self-driving/prompt.ts -> src/lib/agent/agent-interface.ts", + "src/lib/programs/self-driving/prompt.ts -> src/lib/agent/agent-runner.ts", + "src/lib/programs/warehouse-source/detect.ts -> src/lib/agent/agent-runner.ts", + "src/lib/programs/warehouse-source/index.ts -> src/lib/agent/agent-runner.ts", + "src/lib/programs/warehouse-source/index.ts -> src/lib/programs/warehouse-source/content/index.tsx", + "src/lib/programs/web-analytics-doctor/detect.ts -> src/lib/agent/agent-runner.ts", + "src/lib/task-stream/event-plan-watcher.ts -> src/ui/tui/store.ts", + "src/lib/task-stream/task-stream-push.ts -> src/ui/tui/store.ts", + "src/lib/wizard-session.ts -> src/lib/agent/claude-settings.ts", + "src/lib/wizard-tools/index.ts -> src/lib/wizard-tools/mcp.ts", + "src/lib/wizard-tools/tools.ts -> src/lib/yara-hooks.ts", + "src/steps/add-mcp-server-to-clients/index.ts -> src/telemetry.ts", + "src/steps/add-or-update-environment-variables.ts -> src/telemetry.ts", + "src/steps/run-prettier.ts -> src/telemetry.ts", + "src/steps/upload-environment-variables/index.ts -> src/telemetry.ts", + "src/ui/index.ts -> src/ui/logging-ui.ts", + "src/ui/logging-ui.ts -> src/lib/agent/claude-settings.ts", + "src/ui/tui/components/PhaseVisuals.tsx -> src/lib/agent/agent-phase.ts", + "src/ui/tui/components/TokenCostHud.tsx -> src/lib/agent/token-pricing.ts", + "src/ui/tui/exit-line.ts -> src/lib/agent/token-pricing.ts", + "src/ui/tui/ink-ui.ts -> src/lib/agent/claude-settings.ts", + "src/ui/tui/playground/demos/RunScreenDemo.tsx -> src/lib/agent/agent-phase.ts", + "src/ui/tui/screens/ManagedSettingsScreen.tsx -> src/lib/agent/claude-settings.ts", + "src/ui/tui/services/mcp-suggested-prompts-services.ts -> src/lib/agent/mcp-prompt-streaming.ts", + "src/ui/tui/store.ts -> src/lib/agent/claude-settings.ts", + "src/ui/tui/store.ts -> src/lib/agent/token-pricing.ts", + "src/ui/wizard-ui.ts -> src/lib/agent/claude-settings.ts", + "src/utils/package-manager.ts -> src/telemetry.ts", + "src/utils/setup-utils.ts -> src/telemetry.ts", + "src/utils/wizard-abort.ts -> src/ui/logging-ui.ts" + ] +} diff --git a/src/lib/__tests__/wizard-tools.test.ts b/src/lib/__tests__/wizard-tools.test.ts index 771751234..5ddeff913 100644 --- a/src/lib/__tests__/wizard-tools.test.ts +++ b/src/lib/__tests__/wizard-tools.test.ts @@ -920,7 +920,12 @@ describe('resolveAskQuestionKinds', () => { it('reads a kind-less question as free text, which is what a credential is', () => { expect( resolveAskQuestionKinds([ - { id: 'password', prompt: 'Database password', sensitive: true }, + { id: 'password', prompt: 'Database password', sensitive: true } as { + kind?: undefined; + id: string; + prompt: string; + sensitive: boolean; + }, ]), ).toEqual([ { diff --git a/src/lib/agent/runner/harness/pi/__tests__/tools.test.ts b/src/lib/agent/runner/harness/pi/__tests__/tools.test.ts index 018e85adf..c3ef60dc1 100644 --- a/src/lib/agent/runner/harness/pi/__tests__/tools.test.ts +++ b/src/lib/agent/runner/harness/pi/__tests__/tools.test.ts @@ -66,6 +66,8 @@ const call = (tool: { execute: unknown }, args: unknown): Promise => args, ); +type ExecuteLoosely = (id: string, params: unknown) => Promise; + describe('pi wizard_ask — sensitive answers are vaulted', () => { it('returns {secretRef}, never the raw value', async () => { const { wizardAsk } = makeTools({ token: SECRET, tracker: 'linear' }); @@ -687,13 +689,13 @@ describe('audit ledger tools', () => { status: 'pending' as const, }); - await tool('audit_seed_checks').execute('1', { + await (tool('audit_seed_checks').execute as ExecuteLoosely)('1', { checks: [check('sdk-installed'), check('init-correct')], } as never); - await tool('audit_resolve_checks').execute('2', { + await (tool('audit_resolve_checks').execute as ExecuteLoosely)('2', { updates: [{ id: 'sdk-installed', status: 'pass' }], } as never); - await tool('audit_add_checks').execute('3', { + await (tool('audit_add_checks').execute as ExecuteLoosely)('3', { checks: [check('live-data-source-maps')], } as never); diff --git a/src/lib/agent/runner/harness/pi/mcp.ts b/src/lib/agent/runner/harness/pi/mcp.ts index 4e662178f..eeaa3607a 100644 --- a/src/lib/agent/runner/harness/pi/mcp.ts +++ b/src/lib/agent/runner/harness/pi/mcp.ts @@ -59,7 +59,9 @@ export async function setupPostHogMcp(opts: { // The adapter ships raw TypeScript; loading through jiti is its documented requirement. const jiti = createJiti(import.meta.url); - const mod = await jiti.import('pi-mcp-adapter'); + const mod = await jiti.import<{ + createMcpAdapter: (options: unknown) => PostHogMcpSetup['extensionFactory']; + }>('pi-mcp-adapter'); const extensionFactory = mod.createMcpAdapter({ config: { mcpServers: { diff --git a/src/lib/agent/runner/sequence/orchestrator/__tests__/task-notice-timeout.test.ts b/src/lib/agent/runner/sequence/orchestrator/__tests__/task-notice-timeout.test.ts index c7d35a034..c6acf1014 100644 --- a/src/lib/agent/runner/sequence/orchestrator/__tests__/task-notice-timeout.test.ts +++ b/src/lib/agent/runner/sequence/orchestrator/__tests__/task-notice-timeout.test.ts @@ -13,7 +13,7 @@ import type { TaskNotice } from '@lib/wizard-session'; // factory would otherwise read these before they exist. const { showTaskNotice, cancelTaskNotice, wizardCapture, captureException } = vi.hoisted(() => ({ - showTaskNotice: vi.fn<[TaskNotice], Promise>(), + showTaskNotice: vi.fn<(notice: TaskNotice) => Promise>(), cancelTaskNotice: vi.fn(), wizardCapture: vi.fn(), captureException: vi.fn(), diff --git a/src/lib/agent/runner/shared/bootstrap.ts b/src/lib/agent/runner/shared/bootstrap.ts index 6c2bd794e..dc12a2b60 100644 --- a/src/lib/agent/runner/shared/bootstrap.ts +++ b/src/lib/agent/runner/shared/bootstrap.ts @@ -36,7 +36,10 @@ import { CallType, getSkillsBaseUrl, IS_DEV } from '@lib/constants'; import { VERSION } from '@lib/version'; import { mcpUrlFor } from '@lib/host-resolution'; import type { WizardRunOptions } from '@utils/types'; -import type { ProgramConfig } from '@lib/programs/program-step'; +import { + postAuthGateSteps, + type ProgramConfig, +} from '@lib/programs/program-step'; import type { ProgramRun, BootstrapResult } from './types'; // ── Helpers ────────────────────────────────────────────────────────── @@ -270,16 +273,10 @@ export async function bootstrapProgram( // but BEFORE the agent runs — e.g. the source-maps project picker, which // needs credentials to scan and writes its choice to frameworkContext that // the run prompt reads. Generic: await every gated step between auth and run. - const authIndex = programConfig.steps.findIndex((s) => s.screenId === 'auth'); - const runIndex = programConfig.steps.findIndex((s) => s.screenId === 'run'); - if (authIndex !== -1 && runIndex > authIndex) { - for (const step of programConfig.steps.slice(authIndex + 1, runIndex)) { - if (step.gate) { - logToFile(`[agent-runner] awaiting post-auth gate: ${step.id}`); - await getUI().waitForGate(step.id); - logToFile(`[agent-runner] post-auth gate cleared: ${step.id}`); - } - } + for (const step of postAuthGateSteps(programConfig.steps)) { + logToFile(`[agent-runner] awaiting post-auth gate: ${step.id}`); + await getUI().waitForGate(step.id); + logToFile(`[agent-runner] post-auth gate cleared: ${step.id}`); } // Feature flags. Both arms need these, and the fork decision reads the flags. diff --git a/src/lib/programs/__tests__/__snapshots__/flow-traces.test.ts.snap b/src/lib/programs/__tests__/__snapshots__/flow-traces.test.ts.snap new file mode 100644 index 000000000..cc4a39428 --- /dev/null +++ b/src/lib/programs/__tests__/__snapshots__/flow-traces.test.ts.snap @@ -0,0 +1,1104 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`flow traces per program > agent-skill (node) 1`] = ` +{ + "events": [ + { + "event": "screen health-check", + "from": "agent-skill-intro", + "program": "agent-skill", + }, + { + "event": "screen auth", + "from": "health-check", + "program": "agent-skill", + }, + { + "event": "screen run", + "from": "auth", + "program": "agent-skill", + }, + { + "event": "screen ai-opt-in", + "from": "run", + "program": "agent-skill", + }, + { + "event": "screen run", + "from": "ai-opt-in", + "program": "agent-skill", + }, + { + "event": "screen outro", + "from": "run", + "program": "agent-skill", + }, + { + "event": "screen keep-skills", + "from": "outro", + "program": "agent-skill", + }, + ], + "program": "agent-skill", + "screens": [ + "agent-skill-intro", + "health-check", + "auth", + "ai-opt-in", + "run", + "outro", + "keep-skills", + ], + "stoppedOn": null, +} +`; + +exports[`flow traces per program > ai-observability (node) 1`] = ` +{ + "events": [ + { + "event": "screen health-check", + "from": "ai-observability-intro", + "program": "ai-observability", + }, + { + "event": "screen auth", + "from": "health-check", + "program": "ai-observability", + }, + { + "event": "screen run", + "from": "auth", + "program": "ai-observability", + }, + { + "event": "screen ai-opt-in", + "from": "run", + "program": "ai-observability", + }, + { + "event": "screen run", + "from": "ai-opt-in", + "program": "ai-observability", + }, + { + "event": "screen outro", + "from": "run", + "program": "ai-observability", + }, + { + "event": "screen keep-skills", + "from": "outro", + "program": "ai-observability", + }, + ], + "program": "ai-observability", + "screens": [ + "ai-observability-intro", + "health-check", + "auth", + "ai-opt-in", + "run", + "outro", + "keep-skills", + ], + "stoppedOn": null, +} +`; + +exports[`flow traces per program > audit (node) 1`] = ` +{ + "events": [ + { + "event": "screen health-check", + "from": "audit-intro", + "program": "audit", + }, + { + "event": "screen auth", + "from": "health-check", + "program": "audit", + }, + { + "event": "screen audit-run", + "from": "auth", + "program": "audit", + }, + { + "event": "screen ai-opt-in", + "from": "audit-run", + "program": "audit", + }, + { + "event": "screen audit-run", + "from": "ai-opt-in", + "program": "audit", + }, + { + "event": "screen audit-outro", + "from": "audit-run", + "program": "audit", + }, + { + "event": "screen keep-skills", + "from": "audit-outro", + "program": "audit", + }, + ], + "program": "audit", + "screens": [ + "audit-intro", + "health-check", + "auth", + "ai-opt-in", + "audit-run", + "audit-outro", + "keep-skills", + ], + "stoppedOn": null, +} +`; + +exports[`flow traces per program > error-tracking (node) 1`] = ` +{ + "events": [ + { + "event": "screen health-check", + "from": "error-tracking-intro", + "program": "error-tracking", + }, + { + "event": "screen auth", + "from": "health-check", + "program": "error-tracking", + }, + { + "event": "screen run", + "from": "auth", + "program": "error-tracking", + }, + { + "event": "screen ai-opt-in", + "from": "run", + "program": "error-tracking", + }, + { + "event": "screen run", + "from": "ai-opt-in", + "program": "error-tracking", + }, + { + "event": "screen outro", + "from": "run", + "program": "error-tracking", + }, + { + "event": "screen keep-skills", + "from": "outro", + "program": "error-tracking", + }, + ], + "program": "error-tracking", + "screens": [ + "error-tracking-intro", + "health-check", + "auth", + "ai-opt-in", + "run", + "outro", + "keep-skills", + ], + "stoppedOn": null, +} +`; + +exports[`flow traces per program > error-tracking-upload-source-maps (node) 1`] = ` +{ + "events": [ + { + "event": "screen auth", + "from": "source-maps-intro", + "program": "error-tracking-upload-source-maps", + }, + { + "event": "screen source-maps-detect", + "from": "auth", + "program": "error-tracking-upload-source-maps", + }, + { + "event": "screen ai-opt-in", + "from": "source-maps-detect", + "program": "error-tracking-upload-source-maps", + }, + { + "event": "screen source-maps-detect", + "from": "ai-opt-in", + "program": "error-tracking-upload-source-maps", + }, + { + "event": "screen run", + "from": "source-maps-detect", + "program": "error-tracking-upload-source-maps", + }, + { + "event": "screen source-maps-outro", + "from": "run", + "program": "error-tracking-upload-source-maps", + }, + { + "event": "screen keep-skills", + "from": "source-maps-outro", + "program": "error-tracking-upload-source-maps", + }, + ], + "program": "error-tracking-upload-source-maps", + "screens": [ + "source-maps-intro", + "auth", + "ai-opt-in", + "source-maps-detect", + "run", + "source-maps-outro", + "keep-skills", + ], + "stoppedOn": null, +} +`; + +exports[`flow traces per program > events-audit (node) 1`] = ` +{ + "events": [ + { + "event": "screen health-check", + "from": "audit-intro", + "program": "events-audit", + }, + { + "event": "screen auth", + "from": "health-check", + "program": "events-audit", + }, + { + "event": "screen audit-run", + "from": "auth", + "program": "events-audit", + }, + { + "event": "screen ai-opt-in", + "from": "audit-run", + "program": "events-audit", + }, + { + "event": "screen audit-run", + "from": "ai-opt-in", + "program": "events-audit", + }, + { + "event": "screen mcp", + "from": "audit-run", + "program": "events-audit", + }, + { + "event": "screen audit-outro", + "from": "mcp", + "program": "events-audit", + }, + { + "event": "screen keep-skills", + "from": "audit-outro", + "program": "events-audit", + }, + ], + "program": "events-audit", + "screens": [ + "audit-intro", + "health-check", + "auth", + "ai-opt-in", + "audit-run", + "mcp", + "audit-outro", + "keep-skills", + ], + "stoppedOn": null, +} +`; + +exports[`flow traces per program > mcp-add (node) 1`] = ` +{ + "events": [ + { + "event": "screen exit", + "from": "mcp-add", + "program": "mcp-add", + }, + ], + "program": "mcp-add", + "screens": [ + "mcp-add", + "exit", + ], + "stoppedOn": null, +} +`; + +exports[`flow traces per program > mcp-analytics (node) 1`] = ` +{ + "events": [ + { + "event": "screen health-check", + "from": "agent-skill-intro", + "program": "mcp-analytics", + }, + { + "event": "screen auth", + "from": "health-check", + "program": "mcp-analytics", + }, + { + "event": "screen run", + "from": "auth", + "program": "mcp-analytics", + }, + { + "event": "screen ai-opt-in", + "from": "run", + "program": "mcp-analytics", + }, + { + "event": "screen run", + "from": "ai-opt-in", + "program": "mcp-analytics", + }, + { + "event": "screen outro", + "from": "run", + "program": "mcp-analytics", + }, + { + "event": "screen keep-skills", + "from": "outro", + "program": "mcp-analytics", + }, + ], + "program": "mcp-analytics", + "screens": [ + "agent-skill-intro", + "health-check", + "auth", + "ai-opt-in", + "run", + "outro", + "keep-skills", + ], + "stoppedOn": null, +} +`; + +exports[`flow traces per program > mcp-remove (node) 1`] = ` +{ + "events": [ + { + "event": "screen exit", + "from": "mcp-remove", + "program": "mcp-remove", + }, + ], + "program": "mcp-remove", + "screens": [ + "mcp-remove", + "exit", + ], + "stoppedOn": null, +} +`; + +exports[`flow traces per program > mcp-tutorial (node) 1`] = ` +{ + "events": [ + { + "event": "screen slack-connect", + "from": "mcp-suggested-prompts", + "program": "mcp-tutorial", + }, + { + "event": "screen exit", + "from": "slack-connect", + "program": "mcp-tutorial", + }, + ], + "program": "mcp-tutorial", + "screens": [ + "mcp-suggested-prompts", + "slack-connect", + "exit", + ], + "stoppedOn": null, +} +`; + +exports[`flow traces per program > metrics (node) 1`] = ` +{ + "events": [ + { + "event": "screen health-check", + "from": "metrics-intro", + "program": "metrics", + }, + { + "event": "screen auth", + "from": "health-check", + "program": "metrics", + }, + { + "event": "screen run", + "from": "auth", + "program": "metrics", + }, + { + "event": "screen ai-opt-in", + "from": "run", + "program": "metrics", + }, + { + "event": "screen run", + "from": "ai-opt-in", + "program": "metrics", + }, + { + "event": "screen outro", + "from": "run", + "program": "metrics", + }, + { + "event": "screen keep-skills", + "from": "outro", + "program": "metrics", + }, + ], + "program": "metrics", + "screens": [ + "metrics-intro", + "health-check", + "auth", + "ai-opt-in", + "run", + "outro", + "keep-skills", + ], + "stoppedOn": null, +} +`; + +exports[`flow traces per program > migration (node) 1`] = ` +{ + "events": [ + { + "event": "screen health-check", + "from": "migration-intro", + "program": "migration", + }, + { + "event": "screen auth", + "from": "health-check", + "program": "migration", + }, + { + "event": "screen run", + "from": "auth", + "program": "migration", + }, + { + "event": "screen ai-opt-in", + "from": "run", + "program": "migration", + }, + { + "event": "screen run", + "from": "ai-opt-in", + "program": "migration", + }, + { + "event": "screen outro", + "from": "run", + "program": "migration", + }, + { + "event": "screen keep-skills", + "from": "outro", + "program": "migration", + }, + ], + "program": "migration", + "screens": [ + "migration-intro", + "health-check", + "auth", + "ai-opt-in", + "run", + "outro", + "keep-skills", + ], + "stoppedOn": null, +} +`; + +exports[`flow traces per program > posthog-doctor (node) 1`] = ` +{ + "events": [ + { + "event": "screen health-check", + "from": "doctor-intro", + "program": "posthog-doctor", + }, + { + "event": "screen auth", + "from": "health-check", + "program": "posthog-doctor", + }, + { + "event": "screen doctor-report", + "from": "auth", + "program": "posthog-doctor", + }, + { + "event": "screen outro", + "from": "doctor-report", + "program": "posthog-doctor", + }, + { + "event": "screen exit", + "from": "outro", + "program": "posthog-doctor", + }, + ], + "program": "posthog-doctor", + "screens": [ + "doctor-intro", + "health-check", + "auth", + "doctor-report", + "outro", + "exit", + ], + "stoppedOn": null, +} +`; + +exports[`flow traces per program > posthog-integration (nextjs, with a setup question) 1`] = ` +{ + "events": [ + { + "event": "screen health-check", + "from": "intro", + "program": "posthog-integration", + }, + { + "event": "screen setup", + "from": "health-check", + "program": "posthog-integration", + }, + { + "event": "screen auth", + "from": "setup", + "program": "posthog-integration", + }, + { + "event": "screen run", + "from": "auth", + "program": "posthog-integration", + }, + { + "event": "screen ai-opt-in", + "from": "run", + "program": "posthog-integration", + }, + { + "event": "screen run", + "from": "ai-opt-in", + "program": "posthog-integration", + }, + { + "event": "screen outro", + "from": "run", + "program": "posthog-integration", + }, + { + "event": "screen mcp", + "from": "outro", + "program": "posthog-integration", + }, + { + "event": "screen slack-connect", + "from": "mcp", + "program": "posthog-integration", + }, + { + "event": "screen keep-skills", + "from": "slack-connect", + "program": "posthog-integration", + }, + ], + "program": "posthog-integration", + "screens": [ + "intro", + "health-check", + "setup", + "auth", + "ai-opt-in", + "run", + "outro", + "mcp", + "slack-connect", + "keep-skills", + ], + "stoppedOn": null, +} +`; + +exports[`flow traces per program > posthog-integration (no framework detected) 1`] = ` +{ + "events": [ + { + "event": "screen health-check", + "from": "intro", + "program": "posthog-integration", + }, + { + "event": "screen auth", + "from": "health-check", + "program": "posthog-integration", + }, + { + "event": "screen run", + "from": "auth", + "program": "posthog-integration", + }, + { + "event": "screen ai-opt-in", + "from": "run", + "program": "posthog-integration", + }, + { + "event": "screen run", + "from": "ai-opt-in", + "program": "posthog-integration", + }, + { + "event": "screen outro", + "from": "run", + "program": "posthog-integration", + }, + { + "event": "screen mcp", + "from": "outro", + "program": "posthog-integration", + }, + { + "event": "screen slack-connect", + "from": "mcp", + "program": "posthog-integration", + }, + { + "event": "screen keep-skills", + "from": "slack-connect", + "program": "posthog-integration", + }, + ], + "program": "posthog-integration", + "screens": [ + "intro", + "health-check", + "auth", + "ai-opt-in", + "run", + "outro", + "mcp", + "slack-connect", + "keep-skills", + ], + "stoppedOn": null, +} +`; + +exports[`flow traces per program > posthog-integration (node) 1`] = ` +{ + "events": [ + { + "event": "screen health-check", + "from": "intro", + "program": "posthog-integration", + }, + { + "event": "screen auth", + "from": "health-check", + "program": "posthog-integration", + }, + { + "event": "screen run", + "from": "auth", + "program": "posthog-integration", + }, + { + "event": "screen ai-opt-in", + "from": "run", + "program": "posthog-integration", + }, + { + "event": "screen run", + "from": "ai-opt-in", + "program": "posthog-integration", + }, + { + "event": "screen outro", + "from": "run", + "program": "posthog-integration", + }, + { + "event": "screen mcp", + "from": "outro", + "program": "posthog-integration", + }, + { + "event": "screen slack-connect", + "from": "mcp", + "program": "posthog-integration", + }, + { + "event": "screen keep-skills", + "from": "slack-connect", + "program": "posthog-integration", + }, + ], + "program": "posthog-integration", + "screens": [ + "intro", + "health-check", + "auth", + "ai-opt-in", + "run", + "outro", + "mcp", + "slack-connect", + "keep-skills", + ], + "stoppedOn": null, +} +`; + +exports[`flow traces per program > replay-vision (node) 1`] = ` +{ + "events": [ + { + "event": "screen health-check", + "from": "agent-skill-intro", + "program": "replay-vision", + }, + { + "event": "screen auth", + "from": "health-check", + "program": "replay-vision", + }, + { + "event": "screen run", + "from": "auth", + "program": "replay-vision", + }, + { + "event": "screen ai-opt-in", + "from": "run", + "program": "replay-vision", + }, + { + "event": "screen run", + "from": "ai-opt-in", + "program": "replay-vision", + }, + { + "event": "screen outro", + "from": "run", + "program": "replay-vision", + }, + { + "event": "screen keep-skills", + "from": "outro", + "program": "replay-vision", + }, + ], + "program": "replay-vision", + "screens": [ + "agent-skill-intro", + "health-check", + "auth", + "ai-opt-in", + "run", + "outro", + "keep-skills", + ], + "stoppedOn": null, +} +`; + +exports[`flow traces per program > revenue-analytics-setup (node) 1`] = ` +{ + "events": [ + { + "event": "screen health-check", + "from": "revenue-intro", + "program": "revenue-analytics-setup", + }, + { + "event": "screen auth", + "from": "health-check", + "program": "revenue-analytics-setup", + }, + { + "event": "screen run", + "from": "auth", + "program": "revenue-analytics-setup", + }, + { + "event": "screen ai-opt-in", + "from": "run", + "program": "revenue-analytics-setup", + }, + { + "event": "screen run", + "from": "ai-opt-in", + "program": "revenue-analytics-setup", + }, + { + "event": "screen outro", + "from": "run", + "program": "revenue-analytics-setup", + }, + { + "event": "screen keep-skills", + "from": "outro", + "program": "revenue-analytics-setup", + }, + ], + "program": "revenue-analytics-setup", + "screens": [ + "revenue-intro", + "health-check", + "auth", + "ai-opt-in", + "run", + "outro", + "keep-skills", + ], + "stoppedOn": null, +} +`; + +exports[`flow traces per program > self-driving (node) 1`] = ` +{ + "events": [ + { + "event": "screen self-driving-integration-check", + "from": "self-driving-intro", + "program": "self-driving", + }, + { + "event": "screen health-check", + "from": "self-driving-integration-check", + "program": "self-driving", + }, + { + "event": "screen auth", + "from": "health-check", + "program": "self-driving", + }, + { + "event": "screen run", + "from": "auth", + "program": "self-driving", + }, + { + "event": "screen ai-opt-in", + "from": "run", + "program": "self-driving", + }, + { + "event": "screen run", + "from": "ai-opt-in", + "program": "self-driving", + }, + { + "event": "screen self-driving-handoff", + "from": "run", + "program": "self-driving", + }, + { + "event": "screen self-driving-github", + "from": "self-driving-handoff", + "program": "self-driving", + }, + { + "event": "screen run", + "from": "self-driving-github", + "program": "self-driving", + }, + { + "event": "screen outro", + "from": "run", + "program": "self-driving", + }, + { + "event": "screen exit", + "from": "outro", + "program": "self-driving", + }, + ], + "program": "self-driving", + "screens": [ + "self-driving-intro", + "self-driving-integration-check", + "health-check", + "auth", + "ai-opt-in", + "run", + "self-driving-handoff", + "self-driving-github", + "run", + "outro", + "exit", + ], + "stoppedOn": null, +} +`; + +exports[`flow traces per program > slack (node) 1`] = ` +{ + "events": [ + { + "event": "screen exit", + "from": "slack-connect", + "program": "slack", + }, + ], + "program": "slack", + "screens": [ + "slack-connect", + "exit", + ], + "stoppedOn": null, +} +`; + +exports[`flow traces per program > warehouse-source (node) 1`] = ` +{ + "events": [ + { + "event": "screen auth", + "from": "warehouse-intro", + "program": "warehouse-source", + }, + { + "event": "screen run", + "from": "auth", + "program": "warehouse-source", + }, + { + "event": "screen ai-opt-in", + "from": "run", + "program": "warehouse-source", + }, + { + "event": "screen run", + "from": "ai-opt-in", + "program": "warehouse-source", + }, + { + "event": "screen outro", + "from": "run", + "program": "warehouse-source", + }, + { + "event": "screen keep-skills", + "from": "outro", + "program": "warehouse-source", + }, + ], + "program": "warehouse-source", + "screens": [ + "warehouse-intro", + "auth", + "ai-opt-in", + "run", + "outro", + "keep-skills", + ], + "stoppedOn": null, +} +`; + +exports[`flow traces per program > web-analytics-doctor (node) 1`] = ` +{ + "events": [ + { + "event": "screen health-check", + "from": "agent-skill-intro", + "program": "web-analytics-doctor", + }, + { + "event": "screen auth", + "from": "health-check", + "program": "web-analytics-doctor", + }, + { + "event": "screen run", + "from": "auth", + "program": "web-analytics-doctor", + }, + { + "event": "screen ai-opt-in", + "from": "run", + "program": "web-analytics-doctor", + }, + { + "event": "screen run", + "from": "ai-opt-in", + "program": "web-analytics-doctor", + }, + { + "event": "screen outro", + "from": "run", + "program": "web-analytics-doctor", + }, + { + "event": "screen keep-skills", + "from": "outro", + "program": "web-analytics-doctor", + }, + ], + "program": "web-analytics-doctor", + "screens": [ + "agent-skill-intro", + "health-check", + "auth", + "ai-opt-in", + "run", + "outro", + "keep-skills", + ], + "stoppedOn": null, +} +`; + +exports[`headless walk analytics > audit: run phases without a TUI 1`] = ` +{ + "events": [], + "program": "audit", + "screen": "audit-intro", +} +`; + +exports[`headless walk analytics > posthog-integration: run phases without a TUI 1`] = ` +{ + "events": [], + "program": "posthog-integration", + "screen": "intro", +} +`; diff --git a/src/lib/programs/__tests__/__snapshots__/post-auth-gates.test.ts.snap b/src/lib/programs/__tests__/__snapshots__/post-auth-gates.test.ts.snap new file mode 100644 index 000000000..764f5263a --- /dev/null +++ b/src/lib/programs/__tests__/__snapshots__/post-auth-gates.test.ts.snap @@ -0,0 +1,28 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`post-auth gate ids per program > match the golden 1`] = ` +{ + "agent-skill": [], + "ai-observability": [], + "audit": [], + "error-tracking": [], + "error-tracking-upload-source-maps": [ + "detect", + ], + "events-audit": [], + "mcp-add": [], + "mcp-analytics": [], + "mcp-remove": [], + "mcp-tutorial": [], + "metrics": [], + "migration": [], + "posthog-doctor": [], + "posthog-integration": [], + "replay-vision": [], + "revenue-analytics-setup": [], + "self-driving": [], + "slack": [], + "warehouse-source": [], + "web-analytics-doctor": [], +} +`; diff --git a/src/lib/programs/__tests__/flow-traces.test.ts b/src/lib/programs/__tests__/flow-traces.test.ts new file mode 100644 index 000000000..43b532b89 --- /dev/null +++ b/src/lib/programs/__tests__/flow-traces.test.ts @@ -0,0 +1,238 @@ +/** + * Golden screen sequences and `screen ` analytics per program, produced + * by walking each program's steps through the store with a generic advance per + * screen. Baseline for the surface split: must stay byte identical. + * + * Known defect recorded as-is: gated programs show `screen run`, + * `screen ai-opt-in`, `screen run`. authenticate.ts sets credentials before + * apiUser and the AI opt-in gate hides itself while apiUser is null, so the + * router visits `run` twice. Fixing the ordering is a production analytics + * change and belongs in its own PR; when it lands, re-record and add an + * assertion that no trace visits `run` twice. + */ +import { WizardStore, ScreenId, RunPhase, McpOutcome } from '@ui/tui/store'; +import { InkUI } from '@ui/tui/ink-ui'; +import { setUI } from '@ui/index'; +import { + buildSession, + OutroKind, + type WizardSession, +} from '@lib/wizard-session'; +import { Integration } from '@lib/constants'; +import { FRAMEWORK_REGISTRY } from '@lib/registry'; +import { HostResolution } from '@lib/host-resolution'; +import { WizardReadiness } from '@lib/health-checks/readiness'; +import { analytics } from '@utils/analytics'; +import { + PROGRAM_REGISTRY, + getProgramConfig, + type ProgramId, +} from '../program-registry'; +import { SELF_DRIVING_INTEGRATE_PATH_KEY } from '../self-driving/detect'; +import { ERROR_TRACKING_PROJECT_PATH_KEY } from '../error-tracking/detect-agentic'; +import { SOURCE_MAPS_CONTEXT_KEYS } from '../error-tracking-upload-source-maps/detect'; + +vi.mock('@utils/analytics', () => ({ + analytics: { + capture: vi.fn(), + wizardCapture: vi.fn(), + setTag: vi.fn(), + captureException: vi.fn(), + shutdown: vi.fn().mockResolvedValue(undefined), + }, + sessionProperties: vi.fn(() => ({})), +})); + +const wizardCapture = analytics.wizardCapture as Mock; + +interface ScreenEvent { + event: string; + from: unknown; + program: unknown; +} + +function screenEvents(): ScreenEvent[] { + return wizardCapture.mock.calls + .filter(([name]) => typeof name === 'string' && name.startsWith('screen ')) + .map(([name, props]) => ({ + event: name as string, + from: (props as Record)?.from_screen, + program: (props as Record)?.program_id, + })); +} + +const NODE = FRAMEWORK_REGISTRY[Integration.javascriptNode]; + +function createStore(program: ProgramId, integration: Integration | null) { + const store = new WizardStore(program); + setUI(new InkUI(store)); + const session = buildSession({ installDir: '/app', ci: false }); + if (integration) { + session.integration = integration; + session.frameworkConfig = FRAMEWORK_REGISTRY[integration]; + } + store.session = session; + return store; +} + +const approved = (ok: boolean) => + ({ + organization: { is_ai_data_processing_approved: ok }, + } as unknown as WizardSession['apiUser']); + +/** Commit what a user, the runner, or the agent would commit on this screen. */ +function advance(store: WizardStore, screen: string): boolean { + const s = store.session; + if (screen === ScreenId.Intro || screen.endsWith('-intro')) { + store.completeSetup(); + return true; + } + switch (screen) { + case ScreenId.HealthCheck: + store.setReadinessResult({ + decision: WizardReadiness.Yes, + health: {} as never, + reasons: [], + }); + return true; + case ScreenId.Setup: { + const questions = s.frameworkConfig?.metadata.setup?.questions ?? []; + for (const q of questions) { + if (!(q.key in s.frameworkContext)) { + store.setFrameworkContext(q.key, q.options[0].value); + } + } + return true; + } + case ScreenId.Auth: + store.setCredentials({ + accessToken: 'phx_test', + projectApiKey: 'phc_test', + host: HostResolution.fromApiHost('https://us.posthog.com'), + projectId: 1, + }); + store.setApiUser(approved(false)); + return true; + case ScreenId.AiOptIn: + store.setApiUser(approved(true)); + return true; + case ScreenId.Run: + case ScreenId.AuditRun: { + const steps = getProgramConfig(store.router.activeProgram).steps; + const runStep = steps.find( + (st) => + st.screenId === screen && + (!st.show || st.show(s)) && + (!st.isComplete || !st.isComplete(s)), + ); + if (runStep?.run) { + store.completeRunStep(runStep.id); + } else { + store.setRunPhase(RunPhase.Running); + store.setRunPhase(RunPhase.Completed); + } + return true; + } + case ScreenId.Outro: + case ScreenId.AuditOutro: + case ScreenId.SourceMapsOutro: + store.setOutroDismissed(); + return true; + case ScreenId.DoctorReport: + store.setOutroData({ kind: OutroKind.Success, message: 'done' }); + return true; + case ScreenId.Mcp: + case ScreenId.McpAdd: + case ScreenId.McpRemove: + store.setMcpComplete(McpOutcome.Skipped); + return true; + case ScreenId.McpSuggestedPrompts: + store.setMcpSuggestedPromptsDismissed(); + return true; + case ScreenId.SlackConnect: + store.setSlackStepDismissed(); + return true; + case ScreenId.KeepSkills: + store.setSkillsComplete(true); + return true; + case ScreenId.SelfDrivingIntegrationCheck: + store.setIntegrate(true); + return true; + case ScreenId.SelfDrivingIntegrationDetect: + store.setFrameworkContext(SELF_DRIVING_INTEGRATE_PATH_KEY, '.'); + store.setFrameworkConfig(Integration.javascriptNode, NODE); + return true; + case ScreenId.SelfDrivingHandoff: + store.confirmSelfDrivingHandoff(); + return true; + case ScreenId.SelfDrivingGithub: + store.setGithubConnected(true); + return true; + case ScreenId.ErrorTrackingDetect: + store.setFrameworkContext(ERROR_TRACKING_PROJECT_PATH_KEY, '.'); + store.setFrameworkConfig(Integration.javascriptNode, NODE); + return true; + case ScreenId.SourceMapsDetect: + store.setFrameworkContext( + SOURCE_MAPS_CONTEXT_KEYS.selectedVariant, + 'node', + ); + store.setFrameworkContext(SOURCE_MAPS_CONTEXT_KEYS.selectedPath, '.'); + return true; + default: + return false; + } +} + +function trace(program: ProgramId, integration: Integration | null) { + wizardCapture.mockClear(); + const store = createStore(program, integration); + const screens: string[] = []; + let stoppedOn: string | null = null; + for (let guard = 0; guard < 40; guard++) { + const screen = store.router.resolve(store.session); + screens.push(screen); + if (screen === ScreenId.Exit) break; + if (!advance(store, screen)) { + stoppedOn = screen; + break; + } + if (store.session.skillsComplete) break; + } + return { program, screens, stoppedOn, events: screenEvents() }; +} + +describe('flow traces per program', () => { + for (const config of PROGRAM_REGISTRY) { + it(`${config.id} (node)`, () => { + expect(trace(config.id, Integration.javascriptNode)).toMatchSnapshot(); + }); + } + + it('posthog-integration (nextjs, with a setup question)', () => { + expect(trace('posthog-integration', Integration.nextjs)).toMatchSnapshot(); + }); + + it('posthog-integration (no framework detected)', () => { + expect(trace('posthog-integration', null)).toMatchSnapshot(); + }); +}); + +describe('headless walk analytics', () => { + for (const program of ['posthog-integration', 'audit'] as ProgramId[]) { + it(`${program}: run phases without a TUI`, () => { + wizardCapture.mockClear(); + const store = new WizardStore(program); + setUI(new InkUI(store)); + store.session = buildSession({ installDir: '/app', ci: true }); + store.setRunPhase(RunPhase.Running); + store.setOutroData({ kind: OutroKind.Success, message: 'done' }); + store.setRunPhase(RunPhase.Completed); + expect({ + program, + screen: store.router.resolve(store.session), + events: screenEvents(), + }).toMatchSnapshot(); + }); + } +}); diff --git a/src/lib/programs/__tests__/metrics-program.test.ts b/src/lib/programs/__tests__/metrics-program.test.ts index 27dbd8989..ae5936408 100644 --- a/src/lib/programs/__tests__/metrics-program.test.ts +++ b/src/lib/programs/__tests__/metrics-program.test.ts @@ -2,7 +2,6 @@ import { AGENT_SKILL_STEPS } from '@lib/programs/agent-skill/index'; import { getProgramConfig, Program } from '@lib/programs/program-registry'; import { metricsConfig } from '@lib/programs/metrics/index'; import type { ProgramRun } from '@lib/agent/agent-runner'; -import type { WizardSession } from '@lib/wizard-session'; import { metricsCommand } from '../../../commands/metrics'; @@ -38,7 +37,7 @@ describe('metrics program', () => { const run = staticRun(metricsConfig); expect(run.skillId).toBeUndefined(); - const prompt = run.customPrompt?.({} as WizardSession); + const prompt = run.customPrompt?.({} as never); expect(prompt).toContain('load_skill_menu'); expect(prompt).toContain('"metrics"'); // Every published variant the prompt teaches the agent to choose from. diff --git a/src/lib/programs/__tests__/post-auth-gates.test.ts b/src/lib/programs/__tests__/post-auth-gates.test.ts new file mode 100644 index 000000000..3dd2052bc --- /dev/null +++ b/src/lib/programs/__tests__/post-auth-gates.test.ts @@ -0,0 +1,18 @@ +/** + * Golden of the post-auth gate ids the agent runner awaits per program. Reads + * the same `postAuthGateSteps` walk that runner/shared/bootstrap.ts awaits. + */ +import { PROGRAM_REGISTRY } from '../program-registry'; +import { postAuthGateSteps } from '../program-step'; + +describe('post-auth gate ids per program', () => { + it('match the golden', () => { + const gates = Object.fromEntries( + PROGRAM_REGISTRY.map((c) => [ + c.id, + postAuthGateSteps(c.steps).map((s) => s.id), + ]), + ); + expect(gates).toMatchSnapshot(); + }); +}); diff --git a/src/lib/programs/__tests__/warehouse-suggestion.test.ts b/src/lib/programs/__tests__/warehouse-suggestion.test.ts index de7a39b9c..0f6023f28 100644 --- a/src/lib/programs/__tests__/warehouse-suggestion.test.ts +++ b/src/lib/programs/__tests__/warehouse-suggestion.test.ts @@ -78,7 +78,7 @@ describe('outro suggestion', () => { const s = sessionWith([POSTGRES, STRIPE]); const runDef = await resolveRun(s); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const outro = runDef.buildOutroData!(s, CREDENTIALS as any); + const outro = runDef.buildOutroData!(s, CREDENTIALS as any)!; const text = outro.nextSteps!.items.join('\n'); // The project segment and the kind are what land the user on the source's @@ -100,7 +100,7 @@ describe('outro suggestion', () => { const s = sessionWith(many); const runDef = await resolveRun(s); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const outro = runDef.buildOutroData!(s, CREDENTIALS as any); + const outro = runDef.buildOutroData!(s, CREDENTIALS as any)!; const links = outro.nextSteps!.items.filter((i) => i.includes('new-source'), ); @@ -113,7 +113,7 @@ describe('outro suggestion', () => { const s = sessionWith([]); const runDef = await resolveRun(s); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const outro = runDef.buildOutroData!(s, CREDENTIALS as any); + const outro = runDef.buildOutroData!(s, CREDENTIALS as any)!; expect(outro.nextSteps).toBeUndefined(); }); @@ -123,7 +123,7 @@ describe('outro suggestion', () => { const s = sessionWith(sources); const runDef = await resolveRun(s); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const outro = runDef.buildOutroData!(s, CREDENTIALS as any); + const outro = runDef.buildOutroData!(s, CREDENTIALS as any)!; expect(outro.message).toBe('Successfully installed PostHog!'); expect(outro.changes).toContain('Added PostHog provider'); diff --git a/src/lib/programs/program-step.ts b/src/lib/programs/program-step.ts index 9388bee79..81e55a655 100644 --- a/src/lib/programs/program-step.ts +++ b/src/lib/programs/program-step.ts @@ -351,6 +351,17 @@ export interface ProgramConfig { * This intentional separation keeps the router focused on one question: * "Which screen should be rendered right now?" */ +/** + * The gated steps the agent runner awaits after `auth` and before `run`, in + * step order. Empty when a program has no auth step or runs before it. + */ +export function postAuthGateSteps(steps: ProgramStep[]): ProgramStep[] { + const authIndex = steps.findIndex((s) => s.screenId === 'auth'); + const runIndex = steps.findIndex((s) => s.screenId === 'run'); + if (authIndex === -1 || runIndex <= authIndex) return []; + return steps.slice(authIndex + 1, runIndex).filter((s) => s.gate); +} + export function createProgramSequence(steps: ProgramStep[]): Array<{ id: string; show?: (session: WizardSession) => boolean; diff --git a/src/ui/tui/__tests__/__snapshots__/frames/agent-skill-intro-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/agent-skill-intro-120x40.txt new file mode 100644 index 000000000..a507eb1ba --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/agent-skill-intro-120x40.txt @@ -0,0 +1,39 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + + + + + + + + + ███ PostHog Wizard 🦔 + + We'll use AI to analyze your project and complete work. + .env* file contents will not leave your machine. + + Let's run the unknown skill. + + Directory ✔ /app + + ▸ Continue + More info + Privacy & data + Cancel + + + + + + + + + + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/agent-skill-intro-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/agent-skill-intro-80x28.txt new file mode 100644 index 000000000..5b1884fae --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/agent-skill-intro-80x28.txt @@ -0,0 +1,27 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + + + ███ PostHog Wizard 🦔 + + We'll use AI to analyze your project and complete work. + .env* file contents will not leave your machine. + + Let's run the unknown skill. + + Directory ✔ /app + + ▸ Continue + More info + Privacy & data + Cancel + + + + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/ai-observability-intro-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/ai-observability-intro-120x40.txt new file mode 100644 index 000000000..dee366aea --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/ai-observability-intro-120x40.txt @@ -0,0 +1,39 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + + + + + + + + + ███ PostHog Wizard 🦔 + + We'll use AI to analyze your project and complete work. + .env* file contents will not leave your machine. + + Let's instrument your LLM calls with PostHog AI Observability. + + Directory ✔ /app + + ▸ Continue + More info + Privacy & data + Cancel + + + + + + + + + + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/ai-observability-intro-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/ai-observability-intro-80x28.txt new file mode 100644 index 000000000..6d3fce7b2 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/ai-observability-intro-80x28.txt @@ -0,0 +1,27 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + + + ███ PostHog Wizard 🦔 + + We'll use AI to analyze your project and complete work. + .env* file contents will not leave your machine. + + Let's instrument your LLM calls with PostHog AI Observability. + + Directory ✔ /app + + ▸ Continue + More info + Privacy & data + Cancel + + + + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/ai-opt-in-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/ai-opt-in-120x40.txt new file mode 100644 index 000000000..9cb0e0106 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/ai-opt-in-120x40.txt @@ -0,0 +1,39 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + PostHog Setup Wizard + + ⚠ PostHog AI services are disabled for your organization + + The wizard uses Anthropic Claude. To proceed, enable "Enable PostHog + features that use third-party AI services" in your organization + settings. + + http://localhost:8010/project/42/settings/organization-details#organization-ai-consent + + + [O] Open settings in browser + [S] Show how to use your own AI + [R] Retry (after the toggle is enabled) + [E] Exit + + + + + + + + + + + + + + + + + + + + O open settings S show skill R retry E exit diff --git a/src/ui/tui/__tests__/__snapshots__/frames/ai-opt-in-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/ai-opt-in-80x28.txt new file mode 100644 index 000000000..022a8a31c --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/ai-opt-in-80x28.txt @@ -0,0 +1,27 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + PostHog Setup Wizard + + ⚠ PostHog AI services are disabled for your organization + + The wizard uses Anthropic Claude. To proceed, enable "Enable PostHog + features that use third-party AI services" in your organization + settings. + + http://localhost:8010/project/42/settings/organization-details#organization-ai + -consent + + + [O] Open settings in browser + [S] Show how to use your own AI + [R] Retry (after the toggle is enabled) + [E] Exit + + + + + + + O open settings S show skill R retry E exit diff --git a/src/ui/tui/__tests__/__snapshots__/frames/audit-intro-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/audit-intro-120x40.txt new file mode 100644 index 000000000..8132aed5d --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/audit-intro-120x40.txt @@ -0,0 +1,39 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + + + + + + + + + ███ PostHog Wizard 🦔 + + We'll use AI to analyze your project and complete work. + .env* file contents will not leave your machine. + + Let's review your existing PostHog setup for best practices. + + Directory ✔ /app + + ▸ Continue + More info + Privacy & data + Cancel + + + + + + + + + + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/audit-intro-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/audit-intro-80x28.txt new file mode 100644 index 000000000..11b7c3c20 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/audit-intro-80x28.txt @@ -0,0 +1,27 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + + + ███ PostHog Wizard 🦔 + + We'll use AI to analyze your project and complete work. + .env* file contents will not leave your machine. + + Let's review your existing PostHog setup for best practices. + + Directory ✔ /app + + ▸ Continue + More info + Privacy & data + Cancel + + + + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/audit-outro-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/audit-outro-120x40.txt new file mode 100644 index 000000000..9727f363f --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/audit-outro-120x40.txt @@ -0,0 +1,17 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + ✔ PostHog is set up! + + Report saved to: + /app/posthog-setup-report.md + A markdown file in your project folder. Open it in any editor to read the full audit. + + Items audited: + 14 checks · 0 errors · 0 warnings · 0 suggestions + • No issues found. + + Learn more: https://posthog.com/docs + + Press any key to continue diff --git a/src/ui/tui/__tests__/__snapshots__/frames/audit-outro-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/audit-outro-80x28.txt new file mode 100644 index 000000000..a56f15cee --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/audit-outro-80x28.txt @@ -0,0 +1,18 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + ✔ PostHog is set up! + + Report saved to: + /app/posthog-setup-report.md + A markdown file in your project folder. Open it in any editor to read the full + audit. + + Items audited: + 14 checks · 0 errors · 0 warnings · 0 suggestions + • No issues found. + + Learn more: https://posthog.com/docs + + Press any key to continue diff --git a/src/ui/tui/__tests__/__snapshots__/frames/audit-run-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/audit-run-120x40.txt new file mode 100644 index 000000000..12c5a9ca5 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/audit-run-120x40.txt @@ -0,0 +1,39 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + Verifying installation Checks + + ┌──────────────────────────────────────────────────────┐ ⠋ Installation (0/4) + │ app boot │ ◌ PostHog SDK installed + │ ▼ posthog.init(...) once │ ◌ SDK version up to date + │ │ │ ◌ Initialization is correct + │ ▼ posthog.capture('pageview') │ ◌ One initialization per runtime + │ posthog.capture('signup') │ + │ posthog.capture('purchase') │ Identification (0/4) + └──────────────────────────────────────────────────────┘ ◌ Stable distinct_id (not session UUID) + ◌ identify() called before captures / flag evals + PostHog releases frequent SDK updates to fix bugs and ◌ Same distinct_id across client and server + add new features. We're checking your project's SDK ◌ reset() called on logout / account switch + version and making sure it's up to date. + Event Capture (0/3) + We're also checking that your SDK is initialized ◌ Event names are static and consistent + correctly and in the right part of your app's lifecycle. ◌ Captures route through a reverse proxy + ◌ Key activation events captured + This ensures you won't miss any autocaptured events. + Live Data (0/1) + [O] Learn more ◌ Open findings in PostHog + + Write report (0/1) + ◌ Create posthog-audit-report.md + + Upload notebook (0/1) + ◌ Write the report into a PostHog notebook + + + ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + ◆ Reviewing autocapture coverage + + Status Audit plan Tail logs HN + + ←→ switch tab s toggle status diff --git a/src/ui/tui/__tests__/__snapshots__/frames/audit-run-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/audit-run-80x28.txt new file mode 100644 index 000000000..f042274c3 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/audit-run-80x28.txt @@ -0,0 +1,27 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + Verifying installation hecks + ┌──────────────────────────────────┐ ◌ SDK version up to date + │ ▼ posthog.init(...) once │ ◌ One initialization per runtime + │ ▼ posthog.capture('pageview') │ + │ posthog.capture('signup') │ ◌ Stable distinct_id (not session + │ posthog.capture('purchase') │ ◌ Same distinct_id across client and + └──────────────────────────────────┘ ◌ reset() called on logout / account + switch + PostHog releases frequent SDK + updates to fix bugs and add new + features. We're checking your ◌ Event names are static and + project's SDK version and making ◌ Key activation events capturedse + We're also checking that your SDK is proxy + initialized correctly and in the ◌ Open findings in PostHog + right part of your app's lifecycle. + This ensures you won't miss any ◌ Create posthog-audit-report.md + autocaptured events. + [O] Learn more ◌ Write the report into a PostHog + notebook + ────────────────────────────────────────────────────────────────────────────── + + Status Audit plan Tail logs HN + ←→ switch tab s toggle status diff --git a/src/ui/tui/__tests__/__snapshots__/frames/auth-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/auth-120x40.txt new file mode 100644 index 000000000..694cc94f1 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/auth-120x40.txt @@ -0,0 +1,39 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + PostHog Setup Wizard + + Privacy & data + • Source files are read by Claude for AI context + • .env* and secrets stay on your machine + • Press [I] for full privacy & usage info + + ⠋ Waiting for authentication... + + If the browser didn't open, copy and paste this URL: + + https://us.posthog.com/login?next=/oauth/authorize + + Press [C] to copy the link · on a remote machine or devbox? Press [P] to paste the callback URL. + + + + + + + + + + + + + + + + + + + + + P paste auth code C copy link I privacy & data diff --git a/src/ui/tui/__tests__/__snapshots__/frames/auth-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/auth-80x28.txt new file mode 100644 index 000000000..26da42381 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/auth-80x28.txt @@ -0,0 +1,27 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + PostHog Setup Wizard + + Privacy & data + • Source files are read by Claude for AI context + • .env* and secrets stay on your machine + • Press [I] for full privacy & usage info + + ⠋ Waiting for authentication... + + If the browser didn't open, copy and paste this URL: + + https://us.posthog.com/login?next=/oauth/authorize + + Press [C] to copy the link · on a remote machine or devbox? Press [P] to paste + the callback URL. + + + + + + + + P paste auth code C copy link I privacy & data diff --git a/src/ui/tui/__tests__/__snapshots__/frames/auth-error-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/auth-error-120x40.txt new file mode 100644 index 000000000..5629b373b --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/auth-error-120x40.txt @@ -0,0 +1,18 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + ✘ Authentication error + + The Wizard couldn't connect to the PostHog LLM Gateway. Claude Code settings on this machine override the Wizard's + credentials. + + • /app/.claude/settings.json sets ANTHROPIC_BASE_URL + + Remove those keys from the file(s) above, or log out of Claude Code, then re-run the Wizard: + + claude auth logout + + Verbose log: /tmp/posthog-wizard.log + + Press any key to exit diff --git a/src/ui/tui/__tests__/__snapshots__/frames/auth-error-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/auth-error-80x28.txt new file mode 100644 index 000000000..03d097d56 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/auth-error-80x28.txt @@ -0,0 +1,19 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + ✘ Authentication error + + The Wizard couldn't connect to the PostHog LLM Gateway. Claude Code settings + on this machine override the Wizard's credentials. + + • /app/.claude/settings.json sets ANTHROPIC_BASE_URL + + Remove those keys from the file(s) above, or log out of Claude Code, then + re-run the Wizard: + + claude auth logout + + Verbose log: /tmp/posthog-wizard.log + + Press any key to exit diff --git a/src/ui/tui/__tests__/__snapshots__/frames/doctor-intro-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/doctor-intro-120x40.txt new file mode 100644 index 000000000..0ec73cc4e --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/doctor-intro-120x40.txt @@ -0,0 +1,39 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + PostHog Doctor + Scan your project configuration for issues that may need attention. + + The wizard will: + • Sign you in to PostHog + • Fetch active health issues for your project + • Show you what needs to be resolved, with docs links + + ▸ Continue + Cancel + + + + + + + + + + + + + + + + + + + + + + + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/doctor-intro-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/doctor-intro-80x28.txt new file mode 100644 index 000000000..cc0c1852a --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/doctor-intro-80x28.txt @@ -0,0 +1,27 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + PostHog Doctor + Scan your project configuration for issues that may need attention. + + The wizard will: + • Sign you in to PostHog + • Fetch active health issues for your project + • Show you what needs to be resolved, with docs links + + ▸ Continue + Cancel + + + + + + + + + + + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/doctor-report-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/doctor-report-120x40.txt new file mode 100644 index 000000000..8b2c637da --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/doctor-report-120x40.txt @@ -0,0 +1,16 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + PostHog Doctor Report + Project 42 • https://us.posthog.com + + 2 active issues: 1 critical, 1 warning + + Critical (1) + ◼ Ingestion is delayed https://posthog.com/docs/support/troubleshooting + + Warning (1) + ◼ SDK version is out of date https://posthog.com/docs/libraries + + ▸ Continue diff --git a/src/ui/tui/__tests__/__snapshots__/frames/doctor-report-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/doctor-report-80x28.txt new file mode 100644 index 000000000..1a365db89 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/doctor-report-80x28.txt @@ -0,0 +1,18 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + PostHog Doctor Report + Project 42 • https://us.posthog.com + + 2 active issues: 1 critical, 1 warning + + Critical (1) + ◼ Ingestion is delayed + https://posthog.com/docs/support/troubleshooting + + Warning (1) + ◼ SDK version is out of date + https://posthog.com/docs/libraries + + ▸ Continue diff --git a/src/ui/tui/__tests__/__snapshots__/frames/error-tracking-detect-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/error-tracking-detect-120x40.txt new file mode 100644 index 000000000..66d32065c --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/error-tracking-detect-120x40.txt @@ -0,0 +1,9 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + Detecting your project... + + ⠋ Scanning the repo for frameworks and PostHog SDKs... + + Starting up the detection agent… diff --git a/src/ui/tui/__tests__/__snapshots__/frames/error-tracking-detect-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/error-tracking-detect-80x28.txt new file mode 100644 index 000000000..0adcafd67 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/error-tracking-detect-80x28.txt @@ -0,0 +1,9 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + Detecting your project... + + ⠋ Scanning the repo for frameworks and PostHog SDKs... + + Starting up the detection agent… diff --git a/src/ui/tui/__tests__/__snapshots__/frames/error-tracking-intro-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/error-tracking-intro-120x40.txt new file mode 100644 index 000000000..545a8bb2b --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/error-tracking-intro-120x40.txt @@ -0,0 +1,39 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + + + + + + + + + ███ PostHog Wizard 🦔 + + We'll use AI to analyze your project and complete work. + .env* file contents will not leave your machine. + + Let's make uncaught errors reach PostHog with readable stack traces. + + Directory ✔ /app + + ▸ Continue + More info + Privacy & data + Cancel + + + + + + + + + + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/error-tracking-intro-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/error-tracking-intro-80x28.txt new file mode 100644 index 000000000..4cd8b5634 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/error-tracking-intro-80x28.txt @@ -0,0 +1,27 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + + + ███ PostHog Wizard 🦔 + + We'll use AI to analyze your project and complete work. + .env* file contents will not leave your machine. + + Let's make uncaught errors reach PostHog with readable stack traces. + + Directory ✔ /app + + ▸ Continue + More info + Privacy & data + Cancel + + + + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/exit-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/exit-120x40.txt new file mode 100644 index 000000000..48456b7b9 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/exit-120x40.txt @@ -0,0 +1,3 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide diff --git a/src/ui/tui/__tests__/__snapshots__/frames/exit-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/exit-80x28.txt new file mode 100644 index 000000000..78f797570 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/exit-80x28.txt @@ -0,0 +1,3 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide diff --git a/src/ui/tui/__tests__/__snapshots__/frames/health-check-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/health-check-120x40.txt new file mode 100644 index 000000000..c8a2f4974 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/health-check-120x40.txt @@ -0,0 +1,39 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + + + + + + + ╭──────────────────────────────────────────────────────────────────────╮ + │ │ + │ Ongoing service disruptions │ + │ │ + │ ◼ Down ◼ Degraded ◼ No connection │ + │ │ + │ ◼ Skills download │ + │ │ + │ The Wizard can't download the skills it needs — neither GitHub │ + │ Releases nor PostHog's mirror is reachable right now. │ + │ │ + │ ──────────────────────────────────────────────────────────────── │ + │ │ + │ │ + │ ▸ Exit [Esc] │ + │ │ + ╰──────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + ←→ switch esc cancel enter confirm diff --git a/src/ui/tui/__tests__/__snapshots__/frames/health-check-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/health-check-80x28.txt new file mode 100644 index 000000000..f7e5e84f9 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/health-check-80x28.txt @@ -0,0 +1,27 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + ╭──────────────────────────────────────────────────────────────────────╮ + │ │ + │ Ongoing service disruptions │ + │ │ + │ ◼ Down ◼ Degraded ◼ No connection │ + │ │ + │ ◼ Skills download │ + │ │ + │ The Wizard can't download the skills it needs — neither GitHub │ + │ Releases nor PostHog's mirror is reachable right now. │ + │ │ + │ ──────────────────────────────────────────────────────────────── │ + │ │ + │ │ + │ ▸ Exit [Esc] │ + │ │ + ╰──────────────────────────────────────────────────────────────────────╯ + + + + ←→ switch esc cancel enter confirm diff --git a/src/ui/tui/__tests__/__snapshots__/frames/intro-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/intro-120x40.txt new file mode 100644 index 000000000..724976efa --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/intro-120x40.txt @@ -0,0 +1,39 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + + + + + + + + ███ PostHog Wizard 🦔 + + We'll use AI to analyze your project and complete work. + Review what data is shared in "Privacy & data." + .env* values stay on your machine. + + Let's do two hours of work in eight minutes. + + Directory ✔ /app + Framework ✔ Next.js (detected) + + ▸ Continue + Change framework + More info + Privacy & data + Cancel + + + + + + + + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/intro-60x15.txt b/src/ui/tui/__tests__/__snapshots__/frames/intro-60x15.txt new file mode 100644 index 000000000..cebaf5c47 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/intro-60x15.txt @@ -0,0 +1,9 @@ + + + + + + The Wizard needs more room to display required text. + Please make this terminal bigger. + + Currently 60×15, needs at least 80×28. diff --git a/src/ui/tui/__tests__/__snapshots__/frames/intro-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/intro-80x28.txt new file mode 100644 index 000000000..957bf2cfd --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/intro-80x28.txt @@ -0,0 +1,27 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + + ███ PostHog Wizard 🦔 + + We'll use AI to analyze your project and complete work. + Review what data is shared in "Privacy & data." + .env* values stay on your machine. + + Let's do two hours of work in eight minutes. + + Directory ✔ /app + Framework ✔ Next.js (detected) + + ▸ Continue + Change framework + More info + Privacy & data + Cancel + + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/keep-skills-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/keep-skills-120x40.txt new file mode 100644 index 000000000..56e20ea52 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/keep-skills-120x40.txt @@ -0,0 +1,7 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + Keep the skills? + + Checking installed skills... diff --git a/src/ui/tui/__tests__/__snapshots__/frames/keep-skills-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/keep-skills-80x28.txt new file mode 100644 index 000000000..1e8d16bb3 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/keep-skills-80x28.txt @@ -0,0 +1,7 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + Keep the skills? + + Checking installed skills... diff --git a/src/ui/tui/__tests__/__snapshots__/frames/managed-settings-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/managed-settings-120x40.txt new file mode 100644 index 000000000..0d95de9a7 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/managed-settings-120x40.txt @@ -0,0 +1,39 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + + + + + ╭──────────────────────────────────────────────────────────────────────╮ + │ │ + │ ⚠ Settings conflict │ + │ │ + │ These Claude Code settings override credentials and prevent the │ + │ Wizard from reaching the PostHog LLM Gateway. │ + │ │ + │ Organization-managed settings │ + │ /Library/Application Support/ClaudeCode/managed-settings.json │ + │ • ANTHROPIC_BASE_URL │ + │ │ + │ Remove these keys (or run "claude auth logout"). Managed files │ + │ are root-owned — ask your IT administrator. │ + │ │ + │ ──────────────────────────────────────────────────────────────── │ + │ │ + │ Fix the file(s) above, then re-run the Wizard. │ + │ │ + │ ▸ Exit [Esc] │ + │ │ + ╰──────────────────────────────────────────────────────────────────────╯ + + + + + + + + ←→ switch esc cancel enter confirm diff --git a/src/ui/tui/__tests__/__snapshots__/frames/managed-settings-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/managed-settings-80x28.txt new file mode 100644 index 000000000..d99506077 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/managed-settings-80x28.txt @@ -0,0 +1,27 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + ╭──────────────────────────────────────────────────────────────────────╮ + │ │ + │ ⚠ Settings conflict │ + │ │ + │ These Claude Code settings override credentials and prevent the │ + │ Wizard from reaching the PostHog LLM Gateway. │ + │ │ + │ Organization-managed settings │ + │ /Library/Application Support/ClaudeCode/managed-settings.json │ + │ • ANTHROPIC_BASE_URL │ + │ │ + │ Remove these keys (or run "claude auth logout"). Managed files │ + │ are root-owned — ask your IT administrator. │ + │ │ + │ ──────────────────────────────────────────────────────────────── │ + │ │ + │ Fix the file(s) above, then re-run the Wizard. │ + │ │ + │ ▸ Exit [Esc] │ + │ │ + ╰──────────────────────────────────────────────────────────────────────╯ + + ←→ switch esc cancel enter confirm diff --git a/src/ui/tui/__tests__/__snapshots__/frames/manual-auth-code-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/manual-auth-code-120x40.txt new file mode 100644 index 000000000..41a786da6 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/manual-auth-code-120x40.txt @@ -0,0 +1,11 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + ◆ Paste authorization code + + After authorizing, paste the callback URL it lands on — or just the code from it — here: + + http://localhost:8239/callback?code=… or the code + + ENTER submit · ESC cancel diff --git a/src/ui/tui/__tests__/__snapshots__/frames/manual-auth-code-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/manual-auth-code-80x28.txt new file mode 100644 index 000000000..75ec9891d --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/manual-auth-code-80x28.txt @@ -0,0 +1,12 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + ◆ Paste authorization code + + After authorizing, paste the callback URL it lands on — or just the code from + it — here: + + http://localhost:8239/callback?code=… or the code + + ENTER submit · ESC cancel diff --git a/src/ui/tui/__tests__/__snapshots__/frames/mcp-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/mcp-120x40.txt new file mode 100644 index 000000000..782547112 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/mcp-120x40.txt @@ -0,0 +1,15 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + Install the MCP so you can chat to your data + + • Ask your agent: "List my feature flags" — and it does. + • Run SQL, build dashboards, ship flags, all from your IDE. + • No copy-pasting tokens or context. Your agent has the keys. + + Detected: Claude Code, Cursor + + Install the PostHog MCP server and plugin? + + ▸ Install No thanks diff --git a/src/ui/tui/__tests__/__snapshots__/frames/mcp-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/mcp-80x28.txt new file mode 100644 index 000000000..7abbafc8a --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/mcp-80x28.txt @@ -0,0 +1,15 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + Install the MCP so you can chat to your data + + • Ask your agent: "List my feature flags" — and it does. + • Run SQL, build dashboards, ship flags, all from your IDE. + • No copy-pasting tokens or context. Your agent has the keys. + + Detected: Claude Code, Cursor + + Install the PostHog MCP server and plugin? + + ▸ Install No thanks diff --git a/src/ui/tui/__tests__/__snapshots__/frames/mcp-add-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/mcp-add-120x40.txt new file mode 100644 index 000000000..782547112 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/mcp-add-120x40.txt @@ -0,0 +1,15 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + Install the MCP so you can chat to your data + + • Ask your agent: "List my feature flags" — and it does. + • Run SQL, build dashboards, ship flags, all from your IDE. + • No copy-pasting tokens or context. Your agent has the keys. + + Detected: Claude Code, Cursor + + Install the PostHog MCP server and plugin? + + ▸ Install No thanks diff --git a/src/ui/tui/__tests__/__snapshots__/frames/mcp-add-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/mcp-add-80x28.txt new file mode 100644 index 000000000..7abbafc8a --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/mcp-add-80x28.txt @@ -0,0 +1,15 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + Install the MCP so you can chat to your data + + • Ask your agent: "List my feature flags" — and it does. + • Run SQL, build dashboards, ship flags, all from your IDE. + • No copy-pasting tokens or context. Your agent has the keys. + + Detected: Claude Code, Cursor + + Install the PostHog MCP server and plugin? + + ▸ Install No thanks diff --git a/src/ui/tui/__tests__/__snapshots__/frames/mcp-remove-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/mcp-remove-120x40.txt new file mode 100644 index 000000000..e725c3cc8 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/mcp-remove-120x40.txt @@ -0,0 +1,11 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + Remove the PostHog MCP + + Detected: Claude Code, Cursor + + Remove the PostHog MCP server and plugin? + + ▸ Remove No thanks diff --git a/src/ui/tui/__tests__/__snapshots__/frames/mcp-remove-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/mcp-remove-80x28.txt new file mode 100644 index 000000000..37de4b0bc --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/mcp-remove-80x28.txt @@ -0,0 +1,11 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + Remove the PostHog MCP + + Detected: Claude Code, Cursor + + Remove the PostHog MCP server and plugin? + + ▸ Remove No thanks diff --git a/src/ui/tui/__tests__/__snapshots__/frames/mcp-suggested-prompts-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/mcp-suggested-prompts-120x40.txt new file mode 100644 index 000000000..89e26e46e --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/mcp-suggested-prompts-120x40.txt @@ -0,0 +1,39 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + PostHog MCP + + With MCP your agent works directly with the PostHog platform. You can prompt it to: + + ◆ Build dashboards + ◆ Run SQL queries + ◆ Deploy feature flags + ◆ Debug exceptions and errors + ◆ And lots more... + + Want a live demo using real data from your project? + ▸ Start MCP tutorial + Exit + + + + + + + + + + + + + + + + + + + + + ↑↓ navigate p pick new prompt esc exit enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/mcp-suggested-prompts-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/mcp-suggested-prompts-80x28.txt new file mode 100644 index 000000000..8d28a8678 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/mcp-suggested-prompts-80x28.txt @@ -0,0 +1,27 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + PostHog MCP + + With MCP your agent works directly with the PostHog platform. You can prompt + it to: + + ◆ Build dashboards + ◆ Run SQL queries + ◆ Deploy feature flags + ◆ Debug exceptions and errors + ◆ And lots more... + + Want a live demo using real data from your project? + ▸ Start MCP tutorial + Exit + + + + + + + + ↑↓ navigate p pick new prompt esc exit enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/metrics-intro-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/metrics-intro-120x40.txt new file mode 100644 index 000000000..ac846908d --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/metrics-intro-120x40.txt @@ -0,0 +1,39 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + + + + + + + + + ███ PostHog Wizard 🦔 + + We'll use AI to analyze your project and complete work. + .env* file contents will not leave your machine. + + Let's instrument your service with PostHog application metrics. + + Directory ✔ /app + + ▸ Continue + More info + Privacy & data + Cancel + + + + + + + + + + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/metrics-intro-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/metrics-intro-80x28.txt new file mode 100644 index 000000000..af16a1fa7 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/metrics-intro-80x28.txt @@ -0,0 +1,27 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + + + ███ PostHog Wizard 🦔 + + We'll use AI to analyze your project and complete work. + .env* file contents will not leave your machine. + + Let's instrument your service with PostHog application metrics. + + Directory ✔ /app + + ▸ Continue + More info + Privacy & data + Cancel + + + + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/migration-intro-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/migration-intro-120x40.txt new file mode 100644 index 000000000..70e454685 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/migration-intro-120x40.txt @@ -0,0 +1,39 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + + + + + + + + + + ███ PostHog Wizard 🦔 + + We'll use AI to analyze your project and complete work. + .env* file contents will not leave your machine. + + Let's migrate this project to PostHog. + + Directory ✔ /app + + ▸ Continue + Privacy & data + Cancel + + + + + + + + + + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/migration-intro-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/migration-intro-80x28.txt new file mode 100644 index 000000000..e4b3127ab --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/migration-intro-80x28.txt @@ -0,0 +1,27 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + + + + ███ PostHog Wizard 🦔 + + We'll use AI to analyze your project and complete work. + .env* file contents will not leave your machine. + + Let's migrate this project to PostHog. + + Directory ✔ /app + + ▸ Continue + Privacy & data + Cancel + + + + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/mint-failure-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/mint-failure-120x40.txt new file mode 100644 index 000000000..8a6356d08 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/mint-failure-120x40.txt @@ -0,0 +1,39 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + + + + + + + + + + + ██ The Wizard's a little busy 🦔 + + The wizard can still share spells. Grab a skill and let your agent take over. + + ▸ Save skill + Open in Claude Code + Open in Codex + Report this issue + Exit + + + + + + + + + + + + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/mint-failure-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/mint-failure-80x28.txt new file mode 100644 index 000000000..4a6e9c44f --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/mint-failure-80x28.txt @@ -0,0 +1,27 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + + + + + ██ The Wizard's a little busy 🦔 + + The wizard can still share spells. Grab a skill and let your agent take + over. + + ▸ Save skill + Open in Claude Code + Open in Codex + Report this issue + Exit + + + + + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/outro-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/outro-120x40.txt new file mode 100644 index 000000000..acc954cc4 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/outro-120x40.txt @@ -0,0 +1,20 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + ✔ PostHog is set up! + + Events will start flowing once you run the app. + + Check ./posthog-setup-report.md for details + + What the agent did: + • Added posthog-js + • Wired the provider + + Learn more: https://posthog.com/docs?utm_source=wizard&utm_medium=cli&utm_content=outro-docs + + Note: This wizard uses an LLM agent to analyze and modify your project. Please review the changes made. + How did this work for you? Drop us a line: wizard@posthog.com + + Press any key to continue diff --git a/src/ui/tui/__tests__/__snapshots__/frames/outro-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/outro-80x28.txt new file mode 100644 index 000000000..30d0e4480 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/outro-80x28.txt @@ -0,0 +1,22 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + ✔ PostHog is set up! + + Events will start flowing once you run the app. + + Check ./posthog-setup-report.md for details + + What the agent did: + • Added posthog-js + • Wired the provider + + Learn more: https://posthog.com/docs?utm_source=wizard&utm_medium=cli&utm_cont + ent=outro-docs + + Note: This wizard uses an LLM agent to analyze and modify your project. Please + review the changes made. + How did this work for you? Drop us a line: wizard@posthog.com + + Press any key to continue diff --git a/src/ui/tui/__tests__/__snapshots__/frames/port-conflict-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/port-conflict-120x40.txt new file mode 100644 index 000000000..8de35ce48 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/port-conflict-120x40.txt @@ -0,0 +1,39 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + + + + ╭──────────────────────────────────────────────────────────────────────╮ + │ │ + │ OAuth ports in use │ + │ │ + │ The wizard needs a local port for OAuth. We tried these ports │ + │ which are all in use: │ + │ │ + │ Port 8239 │ + │ Port 8238 │ + │ Port 8240 │ + │ Port 8237 │ + │ Port 8236 │ + │ Port 8235 │ + │ │ + │ Please free one of these ports and retry. │ + │ │ + │ ──────────────────────────────────────────────────────────────── │ + │ │ + │ Retry after freeing ports? │ + │ │ + │ ▸ Retry [Enter] Exit [Esc] │ + │ │ + ╰──────────────────────────────────────────────────────────────────────╯ + + + + + + + ←→ switch esc cancel enter confirm diff --git a/src/ui/tui/__tests__/__snapshots__/frames/port-conflict-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/port-conflict-80x28.txt new file mode 100644 index 000000000..5be86f33e --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/port-conflict-80x28.txt @@ -0,0 +1,27 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + ╭──────────────────────────────────────────────────────────────────────╮ + │ │ + │ OAuth ports in use │ + │ The wizard needs a local port for OAuth. We tried these ports │ + │ which are all in use: │ + │ │ + │ │ + │ Port 8238 │ + │ Port 8237 │ + │ Port 8236 │ + │ Port 8235 │ + │ │ + │ Please free one of these ports and retry. │ + │ │ + │ ──────────────────────────────────────────────────────────────── │ + │ │ + │ Retry after freeing ports? │ + │ │ + │ ▸ Retry [Enter] Exit [Esc] │ + │ │ + ╰──────────────────────────────────────────────────────────────────────╯ + + ←→ switch esc cancel enter confirm diff --git a/src/ui/tui/__tests__/__snapshots__/frames/revenue-intro-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/revenue-intro-120x40.txt new file mode 100644 index 000000000..18b366381 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/revenue-intro-120x40.txt @@ -0,0 +1,39 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + + + + + + + + ███ PostHog Wizard 🦔 + + We'll use AI to analyze your project and complete work. + .env* file contents will not leave your machine. + + Let's create revenue analytics with Stripe and PostHog. + + Link purchases to product data. + + Directory ✔ /app + + ▸ Continue + More info + Privacy & data + Cancel + + + + + + + + + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/revenue-intro-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/revenue-intro-80x28.txt new file mode 100644 index 000000000..9c0b3bb65 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/revenue-intro-80x28.txt @@ -0,0 +1,27 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + + ███ PostHog Wizard 🦔 + + We'll use AI to analyze your project and complete work. + .env* file contents will not leave your machine. + + Let's create revenue analytics with Stripe and PostHog. + + Link purchases to product data. + + Directory ✔ /app + + ▸ Continue + More info + Privacy & data + Cancel + + + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/revenue-intro-detect-error-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/revenue-intro-detect-error-120x40.txt new file mode 100644 index 000000000..1ab72510e --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/revenue-intro-detect-error-120x40.txt @@ -0,0 +1,39 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + + + + + + + + + ███ PostHog Wizard 🦔 + + ✘ Cannot set up revenue analytics + + Neither PostHog nor Stripe SDKs detected (scanned 2 package.json files). + + Revenue analytics requires: + • A PostHog SDK (posthog-js, posthog-node, posthog-react-native, …) + • A Stripe SDK (stripe, @stripe/stripe-js, @stripe/react-stripe-js) + + Install Stripe and run npx @posthog/wizard to set up PostHog. + + ▸ Exit + + + + + + + + + + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/revenue-intro-detect-error-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/revenue-intro-detect-error-80x28.txt new file mode 100644 index 000000000..32d138c27 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/revenue-intro-detect-error-80x28.txt @@ -0,0 +1,27 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + + + ███ PostHog Wizard 🦔 + + ✘ Cannot set up revenue analytics + + Neither PostHog nor Stripe SDKs detected (scanned 2 package.json files). + + Revenue analytics requires: + • A PostHog SDK (posthog-js, posthog-node, posthog-react-native, …) + • A Stripe SDK (stripe, @stripe/stripe-js, @stripe/react-stripe-js) + + Install Stripe and run npx @posthog/wizard to set up PostHog. + + ▸ Exit + + + + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/run-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/run-120x40.txt new file mode 100644 index 000000000..4fa1590f9 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/run-120x40.txt @@ -0,0 +1,39 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + Learn Tasks + + ◼ Install posthog-js + ▶ Wiring the provider + ◻ Capture a test event + + ⠋ Progress: 1/3 completed + + + + + + + + + + + + + + + + + + + + + + + ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + ◆ Editing app/layout.tsx + + Status Event plan Tail logs Visualizer HN + + ←→ switch tab s toggle status diff --git a/src/ui/tui/__tests__/__snapshots__/frames/run-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/run-80x28.txt new file mode 100644 index 000000000..796530ae6 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/run-80x28.txt @@ -0,0 +1,27 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + Learn Tasks + + ◼ Install posthog-js + ▶ Wiring the provider + ◻ Capture a test event + + ⠋ Progress: 1/3 completed + + + + + + + + + + + ────────────────────────────────────────────────────────────────────────────── + ◆ Editing app/layout.tsx + + Status Event plan Tail logs Visualizer HN + + ←→ switch tab s toggle status diff --git a/src/ui/tui/__tests__/__snapshots__/frames/self-driving-github-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/self-driving-github-120x40.txt new file mode 100644 index 000000000..dac2e9023 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/self-driving-github-120x40.txt @@ -0,0 +1,39 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + Connect GitHub + + Self-driving needs GitHub access to research findings in your code and open fixes, so setup cannot finish without it. + + ◆ Grant it the repos you want Self-driving to work with — include this project's repo so it can also watch its issues. + + Install it: https://us.posthog.com/api/environments/42/integrations/authorize?kind=github + + ▸ Open GitHub App install + I can't connect right now + + + + + + + + + + + + + + + + + + + + + + + + ↑↓ navigate esc end setup enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/self-driving-github-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/self-driving-github-80x28.txt new file mode 100644 index 000000000..6e24aa30a --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/self-driving-github-80x28.txt @@ -0,0 +1,27 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + Connect GitHub + + Self-driving needs GitHub access to research findings in your code and open + fixes, so setup cannot finish without it. + + ◆ Grant it the repos you want Self-driving to work with — include this + project's repo so it can also watch its issues. + + Install it: + https://us.posthog.com/api/environments/42/integrations/authorize?kind=github + + ▸ Open GitHub App install + I can't connect right now + + + + + + + + + ↑↓ navigate esc end setup enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/self-driving-handoff-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/self-driving-handoff-120x40.txt new file mode 100644 index 000000000..5fba8e669 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/self-driving-handoff-120x40.txt @@ -0,0 +1,39 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + ✔ PostHog is installed + + Now let's make your product self-driving. + + Next, the agent connects GitHub, turns on signal sources, and tunes the scouts that watch your product data. + + About 10 more minutes, and it needs your input a few times. Keep this terminal open. + + ▸ Set up Self-driving [Enter] + + You can find your PostHog integration report at ./posthog-setup-report.md + + + + + + + + + + + + + + + + + + + + + + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/self-driving-handoff-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/self-driving-handoff-80x28.txt new file mode 100644 index 000000000..7ab3602fe --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/self-driving-handoff-80x28.txt @@ -0,0 +1,27 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + ✔ PostHog is installed + + Now let's make your product self-driving. + + Next, the agent connects GitHub, turns on signal sources, and tunes the scouts + that watch your product data. + + About 10 more minutes, and it needs your input a few times. Keep this terminal + open. + + ▸ Set up Self-driving [Enter] + + You can find your PostHog integration report at ./posthog-setup-report.md + + + + + + + + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/self-driving-integration-check-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/self-driving-integration-check-120x40.txt new file mode 100644 index 000000000..402292cfb --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/self-driving-integration-check-120x40.txt @@ -0,0 +1,39 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + No PostHog integration found + + This will kick off an agent to explore your project and find existing PostHog integrations. Self-driving reads PostHog + data, so we'll set that up first: it gives your signal sources something to watch. To do that we need to connect to + PostHog — do you already have an account? + + ▸ Yes, log me in (opens PostHog to authorize) + No, create one for me (we'll email you a login link) + + [I] Privacy & data + + + + + + + + + + + + + + + + + + + + + + + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/self-driving-integration-check-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/self-driving-integration-check-80x28.txt new file mode 100644 index 000000000..6a3833e0e --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/self-driving-integration-check-80x28.txt @@ -0,0 +1,27 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + No PostHog integration found + + This will kick off an agent to explore your project and find existing PostHog + integrations. Self-driving reads PostHog data, so we'll set that up first: it + gives your signal sources something to watch. To do that we need to connect to + PostHog — do you already have an account? + + ▸ Yes, log me in (opens PostHog to authorize) + No, create one for me (we'll email you a login link) + + [I] Privacy & data + + + + + + + + + + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/self-driving-integration-detect-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/self-driving-integration-detect-120x40.txt new file mode 100644 index 000000000..ad8b12c10 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/self-driving-integration-detect-120x40.txt @@ -0,0 +1,9 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + Detecting your project. Stick around, this will take a few seconds... + + ⠋ Scanning the repo for frameworks and PostHog SDKs... + + Starting up the detection agent… diff --git a/src/ui/tui/__tests__/__snapshots__/frames/self-driving-integration-detect-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/self-driving-integration-detect-80x28.txt new file mode 100644 index 000000000..ecc9d4fcc --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/self-driving-integration-detect-80x28.txt @@ -0,0 +1,9 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + Detecting your project. Stick around, this will take a few seconds... + + ⠋ Scanning the repo for frameworks and PostHog SDKs... + + Starting up the detection agent… diff --git a/src/ui/tui/__tests__/__snapshots__/frames/self-driving-intro-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/self-driving-intro-120x40.txt new file mode 100644 index 000000000..47914a5eb --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/self-driving-intro-120x40.txt @@ -0,0 +1,39 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + + + + + + + ███ PostHog Wizard 🦔 + + We'll use .env* file contents will not leave your machine.lf-driving. + + + Let's set up PostHog Self-driving. + + PostHog watches how people really use your product, finds issues, and proposes fixes. + + An agent wAbout 10 minutes, with a few questions along the way.our scouts. + Agents charge a flat $15 per pull request they ship. + Directory ✔ /app + + ▸ Continue + More info + Privacy & data + Cancel + + + + + + + + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/self-driving-intro-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/self-driving-intro-80x28.txt new file mode 100644 index 000000000..a05d591a2 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/self-driving-intro-80x28.txt @@ -0,0 +1,27 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + ███ PostHog Wizard 🦔 + + We'll use .env* file contents will not leave your machine.lf-driving. + + + Let's set up PostHog Self-driving. + + PostHog watches how people really use your product, finds issues, and proposes + fixes. + + An agent wAbout 10 minutes, with a few questions along the way.our scouts. + Agents charge a flat $15 per pull request they ship. + + Directory ✔ /app + + ▸ Continue + More info + Privacy & data + Cancel + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/session-timeout-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/session-timeout-120x40.txt new file mode 100644 index 000000000..ad1add63d --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/session-timeout-120x40.txt @@ -0,0 +1,11 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + ✘ Login timed out + + The OAuth link timed out after 30 minutes. + + Re-run the wizard to get a fresh link and try again. + + Press any key to exit diff --git a/src/ui/tui/__tests__/__snapshots__/frames/session-timeout-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/session-timeout-80x28.txt new file mode 100644 index 000000000..8951ba29f --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/session-timeout-80x28.txt @@ -0,0 +1,11 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + ✘ Login timed out + + The OAuth link timed out after 30 minutes. + + Re-run the wizard to get a fresh link and try again. + + Press any key to exit diff --git a/src/ui/tui/__tests__/__snapshots__/frames/settings-override-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/settings-override-120x40.txt new file mode 100644 index 000000000..67fa2fb3e --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/settings-override-120x40.txt @@ -0,0 +1,39 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + + + + + + ╭──────────────────────────────────────────────────────────────╮ + │ │ + │ ⚠ Settings conflict │ + │ │ + │ Your settings file at /app/.claude/settings.json sets: │ + │ • ANTHROPIC_BASE_URL │ + │ • apiKeyHelper │ + │ │ + │ These settings override credentials and prevent the │ + │ Wizard from reaching the PostHog LLM Gateway. We can │ + │ back up the file and continue. │ + │ │ + │ ──────────────────────────────────────────────────────── │ + │ │ + │ Back up to .wizard-backup and continue? │ + │ │ + │ ▸ Backup & continue [Enter] Exit [Esc] │ + │ │ + ╰──────────────────────────────────────────────────────────────╯ + + + + + + + + + ←→ switch esc cancel enter confirm diff --git a/src/ui/tui/__tests__/__snapshots__/frames/settings-override-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/settings-override-80x28.txt new file mode 100644 index 000000000..95bcc3d9b --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/settings-override-80x28.txt @@ -0,0 +1,27 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + ╭──────────────────────────────────────────────────────────────╮ + │ │ + │ ⚠ Settings conflict │ + │ │ + │ Your settings file at /app/.claude/settings.json sets: │ + │ • ANTHROPIC_BASE_URL │ + │ • apiKeyHelper │ + │ │ + │ These settings override credentials and prevent the │ + │ Wizard from reaching the PostHog LLM Gateway. We can │ + │ back up the file and continue. │ + │ │ + │ ──────────────────────────────────────────────────────── │ + │ │ + │ Back up to .wizard-backup and continue? │ + │ │ + │ ▸ Backup & continue [Enter] Exit [Esc] │ + │ │ + ╰──────────────────────────────────────────────────────────────╯ + + + ←→ switch esc cancel enter confirm diff --git a/src/ui/tui/__tests__/__snapshots__/frames/setup-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/setup-120x40.txt new file mode 100644 index 000000000..59b1eb3f8 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/setup-120x40.txt @@ -0,0 +1,10 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + Project Setup + Configuring Next.js integration + + Which Next.js router are you using? + ▸ App Router + Pages Router diff --git a/src/ui/tui/__tests__/__snapshots__/frames/setup-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/setup-80x28.txt new file mode 100644 index 000000000..ca0bc32f2 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/setup-80x28.txt @@ -0,0 +1,10 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + Project Setup + Configuring Next.js integration + + Which Next.js router are you using? + ▸ App Router + Pages Router diff --git a/src/ui/tui/__tests__/__snapshots__/frames/slack-connect-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/slack-connect-120x40.txt new file mode 100644 index 000000000..4f524734d --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/slack-connect-120x40.txt @@ -0,0 +1,39 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + @PostHog in Slack + + Ask about your product data, debug issues, and generate PRs without leaving the thread. + + ◆ Tag @PostHog with a bug, edit, or a feature idea. It will spin up a sandboxed environment, plan, edit files, run + tests, and open a draft PR. + + ◆ Tag @PostHog with any data question. It's the same SQL-writing, statistically-minded assistant as PostHog AI, but it + responds where you send work memes. + + Connect it: + https://app.posthog.com/integrations/slack?utm_source=wizard&utm_medium=cli&utm_content=slack-connect-setup + Learn more: https://posthog.com/slack?utm_source=wizard&utm_medium=cli&utm_content=slack-connect-learn-more + + ▸ Open Slack setup + Skip / Continue + + + + + + + + + + + + + + + + + + ↑↓ navigate esc skip enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/slack-connect-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/slack-connect-80x28.txt new file mode 100644 index 000000000..e5008626e --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/slack-connect-80x28.txt @@ -0,0 +1,27 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + @PostHog in Slack + + Ask about your product data, debug issues, and generate PRs without leaving + the thread. + + ◆ Tag @PostHog with a bug, edit, or a feature idea. It will spin up a + sandboxed environment, plan, edit files, run tests, and open a draft PR. + + ◆ Tag @PostHog with any data question. It's the same SQL-writing, + statistically-minded assistant as PostHog AI, but it responds where you send + work memes. + + Connect it: https://app.posthog.com/integrations/slack?utm_source=wizard&utm_m + edium=cli&utm_content=slack-connect-setup + Learn more: https://posthog.com/slack?utm_source=wizard&utm_medium=cli&utm_con + tent=slack-connect-learn-more + + ▸ Open Slack setup + Skip / Continue + + + ↑↓ navigate esc skip enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/source-maps-detect-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/source-maps-detect-120x40.txt new file mode 100644 index 000000000..66d32065c --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/source-maps-detect-120x40.txt @@ -0,0 +1,9 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + Detecting your project... + + ⠋ Scanning the repo for frameworks and PostHog SDKs... + + Starting up the detection agent… diff --git a/src/ui/tui/__tests__/__snapshots__/frames/source-maps-detect-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/source-maps-detect-80x28.txt new file mode 100644 index 000000000..0adcafd67 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/source-maps-detect-80x28.txt @@ -0,0 +1,9 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + Detecting your project... + + ⠋ Scanning the repo for frameworks and PostHog SDKs... + + Starting up the detection agent… diff --git a/src/ui/tui/__tests__/__snapshots__/frames/source-maps-intro-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/source-maps-intro-120x40.txt new file mode 100644 index 000000000..c65253abf --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/source-maps-intro-120x40.txt @@ -0,0 +1,39 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + + + + + + + ███ PostHog Wizard 🦔 + + We'll use AI to analyze your project and complete work. + .env* file contents will not leave your machine. + + The Wizard will run an agent to detect your project's + framework(s), download the relevant docs, and implement + source-map uploads for you. + + Ready? + + Directory ✔ /app + + ▸ Continue + More info + Privacy & data + Cancel + + + + + + + + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/source-maps-intro-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/source-maps-intro-80x28.txt new file mode 100644 index 000000000..90fe762f7 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/source-maps-intro-80x28.txt @@ -0,0 +1,27 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + ███ PostHog Wizard 🦔 + + We'll use AI to analyze your project and complete work. + .env* file contents will not leave your machine. + + The Wizard will run an agent to detect your project's + framework(s), download the relevant docs, and implement + source-map uploads for you. + + Ready? + + Directory ✔ /app + + ▸ Continue + More info + Privacy & data + Cancel + + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/source-maps-outro-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/source-maps-outro-120x40.txt new file mode 100644 index 000000000..e899fff37 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/source-maps-outro-120x40.txt @@ -0,0 +1,24 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + ✔ PostHog is set up! + + What the wizard did: + • Installed the packages needed for source map upload. + • Wrote the PostHog upload credentials to your .env file. + + How uploads work now: + • Every build now uploads source maps to PostHog automatically — no extra command to remember. + • Run your app from the built output. Source maps only resolve for errors thrown by the build that was uploaded. + • In CI, the build job needs the same upload credentials. If the wizard wired your pipeline, add the referenced + secrets to your CI provider (e.g. GitHub repo secrets) before your next deploy. + + Details in /app/posthog-setup-report.md + + Learn more: https://posthog.com/docs + + Note: This wizard uses an LLM agent to analyze and modify your project. Please review the changes made. + How did this work for you? Drop us a line: wizard@posthog.com + + Press any key to continue diff --git a/src/ui/tui/__tests__/__snapshots__/frames/source-maps-outro-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/source-maps-outro-80x28.txt new file mode 100644 index 000000000..75cb08e2e --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/source-maps-outro-80x28.txt @@ -0,0 +1,25 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + ✔ PostHog is set up! + + • Installed the packages needed for source map upload. + • Wrote the PostHog upload credentials to your .env file. + + • Every build now uploads source maps to PostHog automatically — no extra + command to remember. + • Run your app from the built output. Source maps only resolve for errors + thrown by the build that was uploaded. + • In CI, the build job needs the same upload credentials. If the wizard wired + your pipeline, add the referenced secrets to your CI provider (e.g. GitHub + repo secrets) before your next deploy. + + Details in /app/posthog-setup-report.md + + Learn more: https://posthog.com/docs + + Note: This wizard uses an LLM agent to analyze and modify your project. Please + How did this work for you? Drop us a line: wizard@posthog.com + + Press any key to continue diff --git a/src/ui/tui/__tests__/__snapshots__/frames/task-notice-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/task-notice-120x40.txt new file mode 100644 index 000000000..367dd053c --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/task-notice-120x40.txt @@ -0,0 +1,39 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + + + + + + + + ╭──────────────────────────────────────────────────────────────────────────╮ + │ │ + │ Connect your data sources │ + │ │ + │ We detected warehouse sources in this project. │ + │ │ + │ Postgres, Stripe │ + │ │ + │ ──────────────────────────────────────────────────────────────────── │ + │ │ + │ Connect these during setup? │ + │ │ + │ ▸ Continue [Enter] Skip [Esc] │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + ←→ switch esc cancel enter confirm diff --git a/src/ui/tui/__tests__/__snapshots__/frames/task-notice-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/task-notice-80x28.txt new file mode 100644 index 000000000..90cf62fbb --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/task-notice-80x28.txt @@ -0,0 +1,27 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + + ╭──────────────────────────────────────────────────────────────────────────╮ + │ │ + │ Connect your data sources │ + │ │ + │ We detected warehouse sources in this project. │ + │ │ + │ Postgres, Stripe │ + │ │ + │ ──────────────────────────────────────────────────────────────────── │ + │ │ + │ Connect these during setup? │ + │ │ + │ ▸ Continue [Enter] Skip [Esc] │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + ←→ switch esc cancel enter confirm diff --git a/src/ui/tui/__tests__/__snapshots__/frames/warehouse-intro-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/warehouse-intro-120x40.txt new file mode 100644 index 000000000..4d35faa52 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/warehouse-intro-120x40.txt @@ -0,0 +1,39 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + + + + + + + + + ███ PostHog Wizard 🦔 + + We'll use AI to analyze your project and complete work. + .env* file contents will not leave your machine. + + Let's connect your data to PostHog's data warehouse. + + Directory ✔ /app + + ▸ Continue + More info + Privacy & data + Cancel + + + + + + + + + + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/warehouse-intro-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/warehouse-intro-80x28.txt new file mode 100644 index 000000000..6adeab826 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/warehouse-intro-80x28.txt @@ -0,0 +1,27 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + + + ███ PostHog Wizard 🦔 + + We'll use AI to analyze your project and complete work. + .env* file contents will not leave your machine. + + Let's connect your data to PostHog's data warehouse. + + Directory ✔ /app + + ▸ Continue + More info + Privacy & data + Cancel + + + + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/wizard-ask-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/wizard-ask-120x40.txt new file mode 100644 index 000000000..a176b216c --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/wizard-ask-120x40.txt @@ -0,0 +1,39 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + + + + + + + + + + ╭──────────────────────────────────────────────────────────────────────╮ + │ │ + │ ◆ integration-nextjs │ + │ │ + │ Which router does this app use? │ + │ │ + │ ▸ App Router │ + │ Pages Router │ + │ │ + │ ESC skip │ + │ │ + ╰──────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/wizard-ask-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/wizard-ask-80x28.txt new file mode 100644 index 000000000..0c76cd16b --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/wizard-ask-80x28.txt @@ -0,0 +1,27 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + + + + ╭──────────────────────────────────────────────────────────────────────╮ + │ │ + │ ◆ integration-nextjs │ + │ │ + │ Which router does this app use? │ + │ │ + │ ▸ App Router │ + │ Pages Router │ + │ │ + │ ESC skip │ + │ │ + ╰──────────────────────────────────────────────────────────────────────╯ + + + + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/__snapshots__/keyboard-equivalence.test.tsx.snap b/src/ui/tui/__tests__/__snapshots__/keyboard-equivalence.test.tsx.snap new file mode 100644 index 000000000..e5355cb55 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/keyboard-equivalence.test.tsx.snap @@ -0,0 +1,225 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`keyboard commit vs control action commit > audit-outro: any key vs dismiss_outro 1`] = ` +{ + "action": { + "outroDismissed": true, + "screen": "keep-skills", + }, + "keyboard": { + "outroDismissed": true, + "screen": "keep-skills", + "skillsComplete": true, + }, +} +`; + +exports[`keyboard commit vs control action commit > intro: enter on Continue vs confirm_setup 1`] = ` +{ + "action": { + "screen": "health-check", + "setupConfirmed": true, + }, + "keyboard": { + "scanConsent": "granted", + "screen": "health-check", + "setupConfirmed": true, + "warehouseSourcesReported": true, + }, +} +`; + +exports[`keyboard commit vs control action commit > keep-skills: mount with no skills dir vs keep_skills 1`] = ` +{ + "action": { + "skillsComplete": true, + }, + "keyboard": {}, +} +`; + +exports[`keyboard commit vs control action commit > manual-auth-code: escape vs dismiss_auth_code 1`] = ` +{ + "action": { + "overlay": false, + "screen": "auth", + }, + "keyboard": { + "overlay": false, + "screen": "auth", + }, +} +`; + +exports[`keyboard commit vs control action commit > manual-auth-code: paste code and enter vs submit_auth_code 1`] = ` +{ + "action": { + "overlay": false, + "screen": "auth", + }, + "keyboard": { + "overlay": false, + "screen": "auth", + }, +} +`; + +exports[`keyboard commit vs control action commit > mcp: decline install vs set_mcp_outcome skipped 1`] = ` +{ + "action": { + "mcpComplete": true, + "mcpOutcome": "skipped", + "screen": "slack-connect", + }, + "keyboard": { + "mcpComplete": true, + "mcpOutcome": "skipped", + "screen": "slack-connect", + "slackConnected": false, + }, +} +`; + +exports[`keyboard commit vs control action commit > outro: any key vs dismiss_outro 1`] = ` +{ + "action": { + "outroDismissed": true, + "screen": "mcp", + }, + "keyboard": { + "outroDismissed": true, + "screen": "mcp", + }, +} +`; + +exports[`keyboard commit vs control action commit > port-conflict: enter vs resolve_port_conflict 1`] = ` +{ + "action": { + "overlay": false, + "portConflictProcess": null, + "screen": "auth", + }, + "keyboard": { + "overlay": false, + "portConflictProcess": null, + "screen": "auth", + }, +} +`; + +exports[`keyboard commit vs control action commit > self-driving-handoff: enter vs confirm_self_driving_handoff 1`] = ` +{ + "action": { + "screen": "self-driving-github", + "selfDrivingHandoffConfirmed": true, + }, + "keyboard": { + "screen": "self-driving-github", + "selfDrivingHandoffConfirmed": true, + }, +} +`; + +exports[`keyboard commit vs control action commit > self-driving-integration-check: log me in vs set_integrate true 1`] = ` +{ + "action": { + "integrate": true, + "screen": "health-check", + }, + "keyboard": { + "integrate": true, + "screen": "health-check", + }, +} +`; + +exports[`keyboard commit vs control action commit > setup: enter on first router option vs choose 1`] = ` +{ + "action": { + "frameworkContext": { + "router": "app-router", + }, + "screen": "auth", + }, + "keyboard": { + "frameworkContext": { + "router": "app-router", + }, + "screen": "auth", + }, +} +`; + +exports[`keyboard commit vs control action commit > slack-connect: skip vs dismiss_slack 1`] = ` +{ + "action": { + "screen": "keep-skills", + "slackStepDismissed": true, + }, + "keyboard": { + "screen": "keep-skills", + "skillsComplete": true, + "slackStepDismissed": true, + }, +} +`; + +exports[`keyboard commit vs control action commit > source-maps-outro: any key vs dismiss_outro 1`] = ` +{ + "action": { + "outroDismissed": true, + "screen": "keep-skills", + }, + "keyboard": { + "outroDismissed": true, + "screen": "keep-skills", + "skillsComplete": true, + }, +} +`; + +exports[`keyboard commit vs control action commit > task-notice: enter vs resolve_notice keep 1`] = ` +{ + "action": { + "overlay": false, + "screen": "intro", + "taskNotice": null, + }, + "keyboard": { + "overlay": false, + "screen": "intro", + "taskNotice": null, + }, +} +`; + +exports[`keyboard commit vs control action commit > task-notice: escape vs resolve_notice decline 1`] = ` +{ + "action": { + "overlay": false, + "screen": "intro", + "taskNotice": null, + }, + "keyboard": { + "overlay": false, + "screen": "intro", + "taskNotice": null, + }, +} +`; + +exports[`keyboard commit vs control action commit > wizard-ask: type answer and enter vs answer_question 1`] = ` +{ + "action": { + "overlay": false, + "pendingQuestion": null, + "screen": "intro", + }, + "keyboard": { + "overlay": false, + "pendingQuestion": null, + "screen": "intro", + }, +} +`; diff --git a/src/ui/tui/__tests__/frames.test.tsx b/src/ui/tui/__tests__/frames.test.tsx new file mode 100644 index 000000000..2a2b805f6 --- /dev/null +++ b/src/ui/tui/__tests__/frames.test.tsx @@ -0,0 +1,627 @@ +// Behaviour baseline: every ScreenId and Overlay through real Ink at two sizes. +// Every fixture arranges session state until the router resolves to its target, +// so each frame carries the full ScreenContainer chrome. No screen needs a +// bare, router-bypassing render. + +import { vi, it, expect, describe, beforeAll, afterEach } from 'vitest'; +import { cleanup } from 'ink-testing-library'; + +vi.mock('ink', () => + vi.importActual('../../../../node_modules/ink/build/index.js'), +); + +const { pending } = vi.hoisted(() => ({ + pending: () => new Promise(() => undefined), +})); + +vi.mock('opn', () => ({ default: vi.fn(pending) })); +vi.mock('@utils/analytics', () => ({ + analytics: { + capture: vi.fn(), + wizardCapture: vi.fn(), + captureException: vi.fn(), + setTag: vi.fn(), + shutdown: vi.fn().mockResolvedValue(undefined), + }, + sessionProperties: vi.fn(() => ({})), +})); +vi.mock('@utils/clipboard', () => ({ + copyToClipboard: vi.fn().mockResolvedValue(true), + openInBrowser: vi.fn().mockResolvedValue(true), + browserOpenCommands: vi.fn(() => []), +})); +vi.mock('@utils/links', async (actual) => ({ + ...(await actual>()), + openTrackedLink: vi.fn(), +})); +vi.mock('@utils/debug', async (actual) => ({ + ...(await actual>()), + getLogFilePath: () => '/tmp/posthog-wizard.log', + logToFile: vi.fn(), + debug: vi.fn(), +})); +vi.mock('@utils/setup-utils', async (actual) => ({ + ...(await actual>()), + getOrAskForProjectData: vi.fn(pending), +})); +vi.mock('@lib/api', async (actual) => ({ + ...(await actual>()), + fetchUserData: vi.fn(pending), + fetchSlackConnected: vi.fn(pending), + fetchGithubConnected: vi.fn(pending), +})); +vi.mock('@lib/wizard-tools', async (actual) => ({ + ...(await actual>()), + fetchSkillMenu: vi.fn(pending), + downloadSkill: vi.fn(pending), +})); +vi.mock('@ui/tui/hooks/useGithubConnection', () => ({ + useGithubConnection: () => undefined, + fetchLoginUrl: vi.fn().mockResolvedValue(null), +})); +vi.mock('@lib/programs/self-driving/detect-agentic', async (actual) => ({ + ...(await actual>()), + detectSelfDrivingIntegrationProjects: vi.fn(pending), +})); +vi.mock('@lib/programs/error-tracking/detect-agentic', async (actual) => ({ + ...(await actual>()), + detectErrorTrackingProjects: vi.fn(pending), +})); +vi.mock( + '@lib/programs/error-tracking-upload-source-maps/detect-agentic', + async (actual) => ({ + ...(await actual>()), + detectSourceMapsProjects: vi.fn(pending), + }), +); +vi.mock('@lib/programs/posthog-doctor/fetch', () => ({ + fetchHealthIssues: vi.fn().mockResolvedValue([ + { + id: 'issue-1', + kind: 'ingestion_lag', + severity: 'critical', + status: 'active', + dismissed: false, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, + { + id: 'issue-2', + kind: 'sdk_outdated', + severity: 'warning', + status: 'active', + dismissed: false, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, + ]), +})); + +import { WizardStore, TaskStatus, type ScreenName } from '@ui/tui/store'; +import { InkUI } from '@ui/tui/ink-ui'; +import { setUI } from '@ui/index'; +import { ScreenId, Overlay } from '@ui/tui/router'; +import { createServices, type ScreenServices } from '@ui/tui/screen-registry'; +import { + buildSession, + OutroKind, + RunPhase, + McpOutcome, +} from '@lib/wizard-session'; +import { HostResolution } from '@lib/host-resolution'; +import { Integration } from '@lib/constants'; +import { FRAMEWORK_REGISTRY } from '@lib/registry'; +import type { FrameworkConfig } from '@lib/framework-config'; +import { Program, type ProgramId } from '@lib/programs/program-registry'; +import { + WizardReadiness, + type WizardReadinessResult, +} from '@lib/health-checks/readiness'; +import { ServiceHealthStatus } from '@lib/health-checks/types'; +import { SOURCE_MAPS_CONTEXT_KEYS } from '@lib/programs/error-tracking-upload-source-maps/detect'; +import { AUDIT_CHECKS_KEY } from '@lib/programs/audit/types'; +import { AUDIT_SEED_CHECKS } from '@lib/programs/audit/seed'; +import type { McpInstaller } from '@ui/tui/services/mcp-installer'; +import type { McpSuggestedPromptsServices } from '@ui/tui/services/mcp-suggested-prompts-services'; +import { + renderScreen, + screenShell, + type TerminalSize, +} from './helpers/render-screen.no-jest'; + +// 80x28 is the ScreenContainer minimum. Anything smaller renders the +// viewport guard instead of the screen, which the last describe pins once. +const SIZES: TerminalSize[] = [ + { columns: 120, rows: 40 }, + { columns: 80, rows: 28 }, +]; +const TOO_SMALL: TerminalSize = { columns: 60, rows: 15 }; + +const CREDENTIALS = { + accessToken: 'phx_test_token', + projectApiKey: 'phc_test_key', + host: HostResolution.fromApiHost('https://us.posthog.com'), + projectId: 42, +}; + +const HEALTHY: WizardReadinessResult = { + decision: WizardReadiness.Yes, + health: { skillsOrigin: { status: ServiceHealthStatus.Healthy } }, + reasons: [], +}; + +const OUTAGE: WizardReadinessResult = { + decision: WizardReadiness.No, + health: { skillsOrigin: { status: ServiceHealthStatus.Down } }, + reasons: ['Skill downloads are unavailable.'], +}; + +const SUCCESS_OUTRO = { + kind: OutroKind.Success, + message: 'PostHog is set up!', + body: 'Events will start flowing once you run the app.', + changes: ['Added posthog-js', 'Wired the provider'], + reportFile: 'posthog-setup-report.md', + docsUrl: 'https://posthog.com/docs', +}; + +/** Next.js config with its setup question stubbed so detection never touches disk. */ +function staticFrameworkConfig(): FrameworkConfig { + const base = FRAMEWORK_REGISTRY[Integration.nextjs]; + const setup = base.metadata.setup; + if (!setup) return base; + return { + ...base, + metadata: { + ...base.metadata, + setup: { + ...setup, + questions: setup.questions.map((q) => ({ + ...q, + detect: () => Promise.resolve(null), + })), + }, + }, + }; +} + +const fakeInstaller: McpInstaller = { + detectClients: () => + Promise.resolve([ + { name: 'Claude Code', supportsPlugin: true, pluginBundlesMcp: false }, + { name: 'Cursor', supportsPlugin: false, pluginBundlesMcp: false }, + ]), + install: pending, + remove: pending, + installPlugins: pending, +}; + +const inertPromptsServices = { + performLogin: pending, + runPromptStreaming: () => ({ + [Symbol.asyncIterator]: () => ({ next: pending }), + }), + probeProjectData: pending, + seedDemoEvents: pending, +} as unknown as McpSuggestedPromptsServices; + +function makeStore(program: ProgramId): WizardStore { + const store = new WizardStore(program); + setUI(new InkUI(store)); + store.version = '0.0.0-test'; + store.session = buildSession({ installDir: '/app' }); + return store; +} + +function makeServices(store: WizardStore): ScreenServices { + return { + ...createServices(store), + mcpInstaller: fakeInstaller, + mcpSuggestedPromptsServices: inertPromptsServices, + }; +} + +function authed(store: WizardStore): void { + store.completeSetup(); + store.setReadinessResult(HEALTHY); + store.setCredentials(CREDENTIALS); +} + +function ranSuccessfully(store: WizardStore): void { + store.setRunPhase(RunPhase.Completed); + store.setOutroData(SUCCESS_OUTRO); +} + +interface Fixture { + program: ProgramId; + arrange?: (store: WizardStore) => void; +} + +const FIXTURES: Record = { + // ── Overlays ─────────────────────────────────────────────────── + [Overlay.SettingsOverride]: { + program: Program.PostHogIntegration, + arrange: (s) => + void s.showSettingsOverride( + [ + { + source: 'project', + path: '/app/.claude/settings.json', + keys: ['ANTHROPIC_BASE_URL', 'apiKeyHelper'], + writable: true, + }, + ], + () => true, + ), + }, + [Overlay.ManagedSettings]: { + program: Program.PostHogIntegration, + arrange: (s) => + void s.showSettingsOverride( + [ + { + source: 'managed', + path: '/Library/Application Support/ClaudeCode/managed-settings.json', + keys: ['ANTHROPIC_BASE_URL'], + writable: false, + }, + ], + () => true, + ), + }, + [Overlay.PortConflict]: { + program: Program.PostHogIntegration, + arrange: (s) => + void s.showPortConflict({ + command: 'node', + pid: '4242', + port: 8010, + user: 'wizard', + }), + }, + [Overlay.TaskNotice]: { + program: Program.PostHogIntegration, + arrange: (s) => + void s.showTaskNotice({ + title: 'Connect your data sources', + body: ['We detected warehouse sources in this project.'], + items: ['Postgres', 'Stripe'], + confirmLabel: 'Continue [Enter]', + cancelLabel: 'Skip [Esc]', + prompt: 'Connect these during setup?', + }), + }, + [Overlay.ManualAuthCode]: { + program: Program.PostHogIntegration, + arrange: (s) => { + s.completeSetup(); + s.setReadinessResult(HEALTHY); + s.showManualAuthCode(); + }, + }, + [Overlay.AuthError]: { + program: Program.PostHogIntegration, + arrange: (s) => + s.showAuthError({ + hasSettingsConflict: true, + conflicts: [ + { + source: 'project', + path: '/app/.claude/settings.json', + keys: ['ANTHROPIC_BASE_URL'], + writable: true, + }, + ], + credentialPlaces: ['ANTHROPIC_API_KEY in your shell profile'], + logFilePath: '/tmp/posthog-wizard.log', + }), + }, + [Overlay.SessionTimeout]: { + program: Program.PostHogIntegration, + arrange: (s) => s.showSessionTimeout(), + }, + [Overlay.WizardAsk]: { + program: Program.PostHogIntegration, + arrange: (s) => + void s.requestQuestion({ + id: 'q1', + source: 'integration-nextjs', + questions: [ + { + id: 'router', + prompt: 'Which router does this app use?', + kind: 'single', + options: [ + { label: 'App Router', value: 'app' }, + { label: 'Pages Router', value: 'pages' }, + ], + }, + ], + }), + }, + + // ── Program screens ──────────────────────────────────────────── + [ScreenId.Intro]: { + program: Program.PostHogIntegration, + arrange: (s) => { + s.setFrameworkConfig(Integration.nextjs, staticFrameworkConfig()); + s.setDetectedFramework('Next.js'); + s.setSkillId('nextjs'); + s.setDetectionComplete(); + }, + }, + [ScreenId.RevenueIntro]: { program: Program.RevenueAnalyticsSetup }, + [ScreenId.WarehouseIntro]: { program: Program.WarehouseSource }, + [ScreenId.SourceMapsIntro]: { + program: Program.ErrorTrackingUploadSourceMaps, + }, + [ScreenId.SourceMapsDetect]: { + program: Program.ErrorTrackingUploadSourceMaps, + arrange: (s) => { + s.completeSetup(); + s.setCredentials(CREDENTIALS); + }, + }, + [ScreenId.SourceMapsOutro]: { + program: Program.ErrorTrackingUploadSourceMaps, + arrange: (s) => { + s.completeSetup(); + s.setCredentials(CREDENTIALS); + s.setFrameworkContext(SOURCE_MAPS_CONTEXT_KEYS.selectedVariant, 'node'); + s.setFrameworkContext(SOURCE_MAPS_CONTEXT_KEYS.selectedPath, '.'); + ranSuccessfully(s); + }, + }, + [ScreenId.MigrationIntro]: { program: Program.Migration }, + [ScreenId.AgentSkillIntro]: { program: Program.AgentSkill }, + [ScreenId.AiObservabilityIntro]: { program: Program.AiObservability }, + [ScreenId.MetricsIntro]: { program: Program.Metrics }, + [ScreenId.ErrorTrackingIntro]: { program: Program.ErrorTracking }, + [ScreenId.ErrorTrackingDetect]: { + program: Program.ErrorTracking, + arrange: authed, + }, + [ScreenId.SelfDrivingIntro]: { program: Program.SelfDriving }, + [ScreenId.SelfDrivingIntegrationCheck]: { + program: Program.SelfDriving, + arrange: (s) => s.completeSetup(), + }, + [ScreenId.SelfDrivingIntegrationDetect]: { + program: Program.SelfDriving, + arrange: (s) => { + s.setIntegrate(true); + authed(s); + }, + }, + [ScreenId.SelfDrivingHandoff]: { + program: Program.SelfDriving, + arrange: (s) => { + s.setIntegrate(true); + authed(s); + s.setFrameworkConfig(Integration.nextjs, staticFrameworkConfig()); + s.completeRunStep('integrate-run'); + }, + }, + [ScreenId.SelfDrivingGithub]: { + program: Program.SelfDriving, + arrange: (s) => { + s.setIntegrate(true); + authed(s); + s.setFrameworkConfig(Integration.nextjs, staticFrameworkConfig()); + s.completeRunStep('integrate-run'); + s.confirmSelfDrivingHandoff(); + s.setGithubConnected(false); + }, + }, + [ScreenId.AuditIntro]: { program: Program.Audit }, + [ScreenId.AuditRun]: { + program: Program.Audit, + arrange: (s) => { + authed(s); + s.setFrameworkContext(AUDIT_CHECKS_KEY, AUDIT_SEED_CHECKS); + s.pushStatus('Reviewing autocapture coverage'); + }, + }, + [ScreenId.AuditOutro]: { + program: Program.Audit, + arrange: (s) => { + authed(s); + s.setFrameworkContext(AUDIT_CHECKS_KEY, AUDIT_SEED_CHECKS); + ranSuccessfully(s); + }, + }, + [ScreenId.HealthCheck]: { + program: Program.PostHogIntegration, + arrange: (s) => { + s.completeSetup(); + s.setReadinessResult(OUTAGE); + }, + }, + [ScreenId.DoctorIntro]: { program: Program.PosthogDoctor }, + [ScreenId.DoctorReport]: { + program: Program.PosthogDoctor, + arrange: authed, + }, + [ScreenId.Setup]: { + program: Program.PostHogIntegration, + arrange: (s) => { + s.setFrameworkConfig(Integration.nextjs, staticFrameworkConfig()); + s.completeSetup(); + s.setReadinessResult(HEALTHY); + }, + }, + [ScreenId.Auth]: { + program: Program.PostHogIntegration, + arrange: (s) => { + s.completeSetup(); + s.setReadinessResult(HEALTHY); + s.setLoginUrl('https://us.posthog.com/login?next=/oauth/authorize'); + }, + }, + [ScreenId.AiOptIn]: { + program: Program.PostHogIntegration, + arrange: (s) => { + authed(s); + s.setApiUser(apiUser(false)); + }, + }, + [ScreenId.Run]: { + program: Program.PostHogIntegration, + arrange: (s) => { + authed(s); + s.setRunPhase(RunPhase.Running); + s.syncTodos([ + { content: 'Install posthog-js', status: TaskStatus.Completed }, + { + content: 'Wire the provider', + status: TaskStatus.InProgress, + activeForm: 'Wiring the provider', + }, + { content: 'Capture a test event', status: TaskStatus.Pending }, + ]); + s.setEventPlan([ + { name: 'signup_completed', description: 'A new account was created' }, + ]); + s.pushStatus('Editing app/layout.tsx'); + }, + }, + [ScreenId.Mcp]: { + program: Program.PostHogIntegration, + arrange: (s) => { + authed(s); + ranSuccessfully(s); + s.setOutroDismissed(); + }, + }, + [ScreenId.McpSuggestedPrompts]: { program: Program.McpTutorial }, + [ScreenId.SlackConnect]: { + program: Program.SlackConnect, + arrange: (s) => { + s.setCredentials(CREDENTIALS); + s.setSlackConnected(false); + }, + }, + [ScreenId.KeepSkills]: { + program: Program.PostHogIntegration, + arrange: (s) => { + authed(s); + ranSuccessfully(s); + s.setOutroDismissed(); + s.setMcpComplete(McpOutcome.Skipped); + s.setSlackStepDismissed(); + }, + }, + [ScreenId.Outro]: { + program: Program.PostHogIntegration, + arrange: (s) => { + authed(s); + ranSuccessfully(s); + s.setDashboardUrl('https://us.posthog.com/project/42/dashboard/7'); + }, + }, + [ScreenId.MintFailure]: { + program: Program.PostHogIntegration, + arrange: (s) => { + authed(s); + s.setRunPhase(RunPhase.Error); + s.setOutroData({ + kind: OutroKind.Error, + message: 'The agent run failed', + }); + }, + }, + [ScreenId.Exit]: { + program: Program.PostHogIntegration, + arrange: (s) => { + authed(s); + s.setRunPhase(RunPhase.Error); + s.setOutroData({ + kind: OutroKind.Error, + message: 'The agent run failed', + }); + s.setMintHandoff('exit'); + }, + }, + [ScreenId.McpAdd]: { program: Program.McpAdd }, + [ScreenId.McpRemove]: { program: Program.McpRemove }, +}; + +/** Only the org's AI consent and membership level drive the gate screen. */ +function apiUser(approved: boolean): WizardStore['session']['apiUser'] { + return { + distinct_id: 'user-1', + team: { id: 42, organization: '00000000-0000-0000-0000-000000000000' }, + organization: { + id: '00000000-0000-0000-0000-000000000000', + name: 'Hedgehogs Inc', + membership_level: 8, + is_ai_data_processing_approved: approved, + }, + } as unknown as WizardStore['session']['apiUser']; +} + +beforeAll(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + vi.spyOn(Math, 'random').mockReturnValue(0.42); +}); + +afterEach(cleanup); + +describe.each(Object.entries(FIXTURES))('%s', (name, fixture) => { + it.each(SIZES)(`at $columns x $rows`, async (size) => { + const screen = name as ScreenName; + const store = makeStore(fixture.program); + fixture.arrange?.(store); + expect(store.currentScreen).toBe(screen); + + const { frame } = await renderScreen( + store, + screenShell(store, makeServices(store)), + size, + ); + await expect(frame).toMatchFileSnapshot(snapshotPath(screen, size)); + }); +}); + +describe('viewport guard', () => { + it('replaces every screen below 80x28 with the too-small message', async () => { + const store = makeStore(FIXTURES[ScreenId.Intro].program); + FIXTURES[ScreenId.Intro].arrange?.(store); + const { frame } = await renderScreen( + store, + screenShell(store, makeServices(store)), + TOO_SMALL, + ); + expect(frame).toContain('needs at least 80×28'); + await expect(frame).toMatchFileSnapshot( + snapshotPath(ScreenId.Intro, TOO_SMALL), + ); + }); +}); + +describe('revenue-intro with a detect error', () => { + // Covers the detect-error branch of RevenueIntroScreen, which spreads the + // POSTHOG_SDKS / STRIPE_SDKS sets before calling array methods. + it.each(SIZES)(`at $columns x $rows`, async (size) => { + const store = makeStore(Program.RevenueAnalyticsSetup); + store.setFrameworkContext('detectError', { + kind: 'no-sdks', + scannedCount: 2, + }); + expect(store.currentScreen).toBe(ScreenId.RevenueIntro); + const { frame } = await renderScreen( + store, + screenShell(store, makeServices(store)), + size, + ); + await expect(frame).toMatchFileSnapshot( + `__snapshots__/frames/revenue-intro-detect-error-${size.columns}x${size.rows}.txt`, + ); + }); +}); + +function snapshotPath(screen: ScreenName, size: TerminalSize): string { + return `__snapshots__/frames/${screen}-${size.columns}x${size.rows}.txt`; +} diff --git a/src/ui/tui/__tests__/helpers/render-screen.no-jest.tsx b/src/ui/tui/__tests__/helpers/render-screen.no-jest.tsx new file mode 100644 index 000000000..60e16196b --- /dev/null +++ b/src/ui/tui/__tests__/helpers/render-screen.no-jest.tsx @@ -0,0 +1,60 @@ +import { render } from 'ink-testing-library'; +import type { ReactNode } from 'react'; +import { vi } from 'vitest'; +import { ScreenContainer } from '@ui/tui/primitives/ScreenContainer'; +import { createScreens, type ScreenServices } from '@ui/tui/screen-registry'; +import type { WizardStore } from '@ui/tui/store'; + +export interface TerminalSize { + columns: number; + rows: number; +} + +export interface RenderedScreen { + app: ReturnType; + frame: string; +} + +// OSC 8 hyperlinks (LinkText) as well as the usual CSI colour/cursor codes. +// eslint-disable-next-line no-control-regex +const OSC_RE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g; +// eslint-disable-next-line no-control-regex +const CSI_RE = /[\x1b\x9b][[()#;?]*[0-9;]*[A-Za-z]/g; + +export function toFrameText(raw: string | undefined): string { + return `${(raw ?? '').replace(OSC_RE, '').replace(CSI_RE, '')}\n` + .split('\n') + .map((line) => line.replace(/[ \t]+$/, '')) + .join('\n') + .replace(/\n+$/, '\n'); +} + +export async function flushInk(ms = 50): Promise { + await vi.advanceTimersByTimeAsync(ms); +} + +/** The shell App.tsx builds: router-resolved screen inside the full chrome. */ +export function screenShell( + store: WizardStore, + services: ScreenServices, +): ReactNode { + return ( + + ); +} + +export async function renderScreen( + store: WizardStore, + element: ReactNode, + { columns, rows }: TerminalSize, +): Promise { + const app = render(<>{element}); + const dimensions = { columns, rows, isTTY: true }; + for (const [key, value] of Object.entries(dimensions)) { + Object.defineProperty(app.stdout, key, { value, configurable: true }); + } + app.stdout.emit('resize'); + store.emitChange(); + await flushInk(); + return { app, frame: toFrameText(app.lastFrame()) }; +} diff --git a/src/ui/tui/__tests__/keyboard-equivalence.test.tsx b/src/ui/tui/__tests__/keyboard-equivalence.test.tsx new file mode 100644 index 000000000..790487197 --- /dev/null +++ b/src/ui/tui/__tests__/keyboard-equivalence.test.tsx @@ -0,0 +1,487 @@ +/** + * Baseline: for each screen a controller can act on, drive the real screen by + * keyboard on one store and apply the control action on another, then golden + * both session diffs. Pairs whose diffs differ today are recorded, not hidden. + */ +import { vi, describe, it, expect, afterEach, beforeAll } from 'vitest'; +import { render, cleanup } from 'ink-testing-library'; +import { mkdtempSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { + WizardStore, + Program, + ScreenId, + Overlay, + RunPhase, + McpOutcome, + type ProgramId, +} from '../store'; +import { InkUI } from '../ink-ui'; +import { setUI } from '@ui/index'; +import { + buildSession, + OutroKind, + type WizardSession, +} from '@lib/wizard-session'; +import { Integration } from '@lib/constants'; +import { FRAMEWORK_REGISTRY } from '@lib/registry'; +import { HostResolution } from '@lib/host-resolution'; +import { WizardReadiness } from '@lib/health-checks/readiness'; +import { SOURCE_MAPS_CONTEXT_KEYS } from '@lib/programs/error-tracking-upload-source-maps/detect'; +import { SELF_DRIVING_INTEGRATE_PATH_KEY } from '@lib/programs/self-driving/detect'; +import { ScreenContainer } from '../primitives/ScreenContainer'; +import { + createScreens, + createServices, + type ScreenServices, +} from '../screen-registry'; +import { ACTION_REGISTRY } from '@e2e-harness/action-registry'; + +vi.mock('ink', () => + vi.importActual('../../../../node_modules/ink/build/index.js'), +); +vi.mock('@utils/analytics', () => ({ + analytics: { + capture: vi.fn(), + wizardCapture: vi.fn(), + setTag: vi.fn(), + captureException: vi.fn(), + shutdown: vi.fn().mockResolvedValue(undefined), + }, + sessionProperties: vi.fn(() => ({})), +})); +vi.mock('@utils/links', async (importOriginal) => ({ + ...(await importOriginal()), + openTrackedLink: vi.fn(), +})); +vi.mock('@utils/clipboard', async (importOriginal) => ({ + ...(await importOriginal()), + copyToClipboard: vi.fn().mockResolvedValue(false), + openInBrowser: vi.fn().mockResolvedValue(false), +})); +vi.mock('opn', () => ({ default: vi.fn() })); +vi.mock('@lib/api', async (importOriginal) => ({ + ...(await importOriginal()), + fetchSlackConnected: vi.fn().mockResolvedValue(false), + fetchUserData: vi.fn(() => new Promise(() => undefined)), +})); +vi.mock('@lib/wizard-tools', async (importOriginal) => ({ + ...(await importOriginal()), + fetchSkillMenu: vi.fn(() => new Promise(() => undefined)), +})); +vi.mock('@utils/setup-utils', async (importOriginal) => ({ + ...(await importOriginal()), + getOrAskForProjectData: vi.fn(() => new Promise(() => undefined)), +})); +vi.mock('@utils/wizard-abort', async (importOriginal) => ({ + ...(await importOriginal()), + wizardAbort: vi.fn().mockResolvedValue(undefined), +})); + +// A temp dir so KeepSkillsScreen's readdir of `/.claude/skills` +// never sees a host directory. +const INSTALL_DIR = mkdtempSync(join(tmpdir(), 'wizard-kb-')); + +const ENTER = '\r'; +const ESC = '\u001B'; +const DOWN = '\u001B[B'; +// Real timers on purpose: Ink delivers stdin writes through the event loop, +// and under vi.useFakeTimers the key handlers never run (verified: every +// keyboard diff came back empty). The frames test can fake time since it +// never presses a key. +const tick = (ms = 40) => new Promise((r) => setTimeout(r, ms)); + +const clean = { + decision: WizardReadiness.Yes, + health: {} as never, + reasons: [] as string[], +}; +const approved = (ok: boolean) => + ({ + organization: { is_ai_data_processing_approved: ok }, + } as unknown as WizardSession['apiUser']); + +const confirmed = (s: WizardStore) => { + s.completeSetup(); + s.setReadinessResult(clean); +}; +const authed = (s: WizardStore) => { + s.setCredentials({ + accessToken: 'phx_test', + projectApiKey: 'phc_test', + host: HostResolution.fromApiHost('https://us.posthog.com'), + projectId: 1, + }); + s.setApiUser(approved(true)); +}; +const ran = (s: WizardStore) => { + s.setRunPhase(RunPhase.Running); + s.setOutroData({ kind: OutroKind.Success, message: 'done' }); + s.setRunPhase(RunPhase.Completed); +}; + +const fakeInstaller = { + detectClients: () => + Promise.resolve([ + { name: 'Cursor', supportsPlugin: false }, + { name: 'Claude Code', supportsPlugin: true }, + ]), + install: () => Promise.resolve([]), +} as unknown as ScreenServices['mcpInstaller']; + +interface Pair { + name: string; + /** + * Set when the keyboard path and the control action are known to commit + * different state today. The test asserts the divergence so a fix must + * clear this field rather than silently re-record. + */ + knownDivergence?: string; + program: ProgramId; + integration?: Integration; + screen: string; + arrange: (s: WizardStore) => void; + keys: string[]; + action: string; + params?: Record; +} + +const nextjsRouterFirst = () => { + const setup = FRAMEWORK_REGISTRY[Integration.nextjs].metadata.setup; + if (!setup) throw new Error('nextjs setup questions missing'); + return setup.questions[0].options[0].value; +}; + +const PAIRS: Pair[] = [ + { + name: 'intro: enter on Continue vs confirm_setup', + knownDivergence: + 'keyboard grants scan sharing before completeSetup, confirm_setup only completes setup', + program: Program.PostHogIntegration, + screen: ScreenId.Intro, + arrange: () => undefined, + keys: [ENTER], + action: 'confirm_setup', + }, + { + name: 'setup: enter on first router option vs choose', + program: Program.PostHogIntegration, + integration: Integration.nextjs, + screen: ScreenId.Setup, + arrange: confirmed, + keys: [ENTER], + action: 'choose', + params: { key: 'router', value: nextjsRouterFirst() }, + }, + { + name: 'outro: any key vs dismiss_outro', + program: Program.PostHogIntegration, + screen: ScreenId.Outro, + arrange: (s) => { + confirmed(s); + authed(s); + ran(s); + }, + keys: [ENTER], + action: 'dismiss_outro', + }, + { + name: 'mcp: decline install vs set_mcp_outcome skipped', + knownDivergence: + 'keyboard path records extra MCP state the action does not', + program: Program.PostHogIntegration, + screen: ScreenId.Mcp, + arrange: (s) => { + confirmed(s); + authed(s); + ran(s); + s.setOutroDismissed(); + }, + keys: [ESC], + action: 'set_mcp_outcome', + params: { outcome: 'skipped' }, + }, + { + name: 'slack-connect: skip vs dismiss_slack', + knownDivergence: + 'keyboard path and dismiss_slack commit different slack step state', + program: Program.PostHogIntegration, + screen: ScreenId.SlackConnect, + arrange: (s) => { + confirmed(s); + authed(s); + ran(s); + s.setOutroDismissed(); + s.setMcpComplete(McpOutcome.Skipped); + }, + keys: [DOWN, ENTER], + action: 'dismiss_slack', + }, + { + name: 'keep-skills: mount with no skills dir vs keep_skills', + knownDivergence: + 'keyboard path runs the skills-dir scan effect, the action only flips the flag', + program: Program.PostHogIntegration, + screen: ScreenId.KeepSkills, + arrange: (s) => { + confirmed(s); + authed(s); + ran(s); + s.setOutroDismissed(); + s.setMcpComplete(McpOutcome.Skipped); + s.setSlackStepDismissed(); + }, + keys: [], + action: 'keep_skills', + params: { kept: true }, + }, + { + name: 'audit-outro: any key vs dismiss_outro', + knownDivergence: + 'keyboard path commits mintHandoff alongside outroDismissed', + program: Program.Audit, + screen: ScreenId.AuditOutro, + arrange: (s) => { + confirmed(s); + authed(s); + ran(s); + }, + keys: [ENTER], + action: 'dismiss_outro', + }, + { + name: 'source-maps-outro: any key vs dismiss_outro', + knownDivergence: + 'keyboard path commits mintHandoff alongside outroDismissed', + program: Program.ErrorTrackingUploadSourceMaps, + screen: ScreenId.SourceMapsOutro, + arrange: (s) => { + s.completeSetup(); + authed(s); + s.setFrameworkContext(SOURCE_MAPS_CONTEXT_KEYS.selectedVariant, 'node'); + s.setFrameworkContext(SOURCE_MAPS_CONTEXT_KEYS.selectedPath, '.'); + ran(s); + }, + keys: [ENTER], + action: 'dismiss_outro', + }, + { + name: 'self-driving-integration-check: log me in vs set_integrate true', + program: Program.SelfDriving, + screen: ScreenId.SelfDrivingIntegrationCheck, + arrange: (s) => { + s.setFrameworkContext('postHogPresent', false); + s.completeSetup(); + }, + keys: [ENTER], + action: 'set_integrate', + params: { integrate: true }, + }, + { + name: 'self-driving-handoff: enter vs confirm_self_driving_handoff', + program: Program.SelfDriving, + screen: ScreenId.SelfDrivingHandoff, + arrange: (s) => { + s.setFrameworkContext('postHogPresent', false); + s.completeSetup(); + s.setIntegrate(true); + s.setReadinessResult(clean); + authed(s); + s.setFrameworkContext(SELF_DRIVING_INTEGRATE_PATH_KEY, '.'); + s.setFrameworkConfig( + Integration.javascriptNode, + FRAMEWORK_REGISTRY[Integration.javascriptNode], + ); + s.completeRunStep('integrate-run'); + }, + keys: [ENTER], + action: 'confirm_self_driving_handoff', + }, + { + name: 'wizard-ask: type answer and enter vs answer_question', + program: Program.PostHogIntegration, + screen: Overlay.WizardAsk, + arrange: (s) => { + void s.requestQuestion({ + id: 'ask-1', + source: 'test', + questions: [{ id: 'name', prompt: 'Name?', kind: 'text' }], + }); + }, + keys: ['yes', ENTER], + action: 'answer_question', + params: { answers: { name: 'yes' } }, + }, + { + name: 'task-notice: enter vs resolve_notice keep', + program: Program.PostHogIntegration, + screen: Overlay.TaskNotice, + arrange: (s) => { + void s.showTaskNotice({ + title: 'Optional step', + body: ['Body'], + prompt: 'Run it?', + confirmLabel: 'Yes', + cancelLabel: 'No', + }); + }, + keys: [ENTER], + action: 'resolve_notice', + params: { keep: true }, + }, + { + name: 'task-notice: escape vs resolve_notice decline', + program: Program.PostHogIntegration, + screen: Overlay.TaskNotice, + arrange: (s) => { + void s.showTaskNotice({ + title: 'Optional step', + body: ['Body'], + prompt: 'Run it?', + confirmLabel: 'Yes', + cancelLabel: 'No', + }); + }, + keys: [ESC], + action: 'resolve_notice', + params: { keep: false }, + }, + { + name: 'port-conflict: enter vs resolve_port_conflict', + program: Program.PostHogIntegration, + screen: Overlay.PortConflict, + arrange: (s) => { + confirmed(s); + void s.showPortConflict({ + command: 'node', + pid: '1', + port: 8000, + user: 'me', + }); + }, + keys: [ENTER], + action: 'resolve_port_conflict', + }, + { + name: 'manual-auth-code: paste code and enter vs submit_auth_code', + program: Program.PostHogIntegration, + screen: Overlay.ManualAuthCode, + arrange: (s) => { + confirmed(s); + s.showManualAuthCode(); + }, + keys: ['https://app.test/callback?code=abc123', ENTER], + action: 'submit_auth_code', + params: { code: 'abc123' }, + }, + { + name: 'manual-auth-code: escape vs dismiss_auth_code', + program: Program.PostHogIntegration, + screen: Overlay.ManualAuthCode, + arrange: (s) => { + confirmed(s); + s.showManualAuthCode(); + }, + keys: [ESC], + action: 'dismiss_auth_code', + }, +]; + +function makeStore(pair: Pair): WizardStore { + const store = new WizardStore(pair.program); + store.version = '0.0.0-test'; + setUI(new InkUI(store)); + const session = buildSession({ installDir: INSTALL_DIR, ci: false }); + const integration = pair.integration ?? Integration.javascriptNode; + session.integration = integration; + session.frameworkConfig = FRAMEWORK_REGISTRY[integration]; + store.session = session; + store.setDetectionComplete(); + pair.arrange(store); + return store; +} + +interface Snap { + screen: string; + overlay: boolean; + session: Record; +} + +function snap(store: WizardStore): Snap { + const session: Record = {}; + for (const [k, v] of Object.entries(store.session)) { + session[k] = k === 'frameworkConfig' ? (v ? '[config]' : null) : v; + } + return { + screen: store.currentScreen, + overlay: store.router.hasOverlay, + session, + }; +} + +function diff(before: Snap, after: Snap): Record { + const out: Record = {}; + if (before.screen !== after.screen) out.screen = after.screen; + if (before.overlay !== after.overlay) out.overlay = after.overlay; + for (const key of Object.keys(after.session)) { + if ( + JSON.stringify(before.session[key]) !== JSON.stringify(after.session[key]) + ) { + out[key] = after.session[key]; + } + } + return out; +} + +async function driveKeyboard(pair: Pair): Promise> { + const store = makeStore(pair); + const services = { ...createServices(store), mcpInstaller: fakeInstaller }; + const screens = createScreens(store, services); + const { stdin, unmount } = render( + , + ); + await tick(60); + expect(store.currentScreen).toBe(pair.screen); + const before = snap(store); + for (const key of pair.keys) { + stdin.write(key); + await tick(); + } + await tick(60); + const after = snap(store); + unmount(); + return diff(before, after); +} + +function applyAction(pair: Pair): Record { + const store = makeStore(pair); + expect(store.currentScreen).toBe(pair.screen); + const before = snap(store); + const action = ACTION_REGISTRY[pair.screen as ScreenId]?.find( + (a) => a.id === pair.action, + ); + if (!action) throw new Error(`no action ${pair.action} on ${pair.screen}`); + action.apply(store, pair.params ?? {}); + return diff(before, snap(store)); +} + +describe('keyboard commit vs control action commit', () => { + beforeAll(() => { + vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + }); + afterEach(() => cleanup()); + + for (const pair of PAIRS) { + it(pair.name, async () => { + const keyboard = await driveKeyboard(pair); + const action = applyAction(pair); + if (pair.knownDivergence) { + expect(keyboard, pair.knownDivergence).not.toEqual(action); + } else { + expect(keyboard).toEqual(action); + } + expect({ keyboard, action }).toMatchSnapshot(); + }); + } +}); diff --git a/src/ui/tui/__tests__/store-invariants.test.ts b/src/ui/tui/__tests__/store-invariants.test.ts new file mode 100644 index 000000000..8977738e7 --- /dev/null +++ b/src/ui/tui/__tests__/store-invariants.test.ts @@ -0,0 +1,873 @@ +/** + * Behaviour baseline for WizardStore + WizardRouter, taken before a refactor. + * Every expectation here pins what the code does today, exceptions included. + */ + +import { + WizardStore, + TaskStatus, + Program, + type ProgramId, + ScreenId, + Overlay, + RunPhase, + McpOutcome, + type ScreenName, +} from '@ui/tui/store'; +import { + buildSession, + AdditionalFeature, + DiscoveredFeature, + OutroKind, + type AskAnswers, + type PendingQuestion, + type TaskNotice, + type WizardSession, +} from '@lib/wizard-session'; +import { EXPANDED_COUNT } from '@ui/tui/constants'; +import { PROGRAM_SEQUENCES } from '@ui/tui/screen-sequences'; +import { WizardReadiness } from '@lib/health-checks/readiness'; +import { HostResolution } from '@lib/host-resolution'; +import { Integration } from '@lib/constants'; +import { FRAMEWORK_REGISTRY } from '@lib/registry'; +import { analytics } from '@utils/analytics'; +import { PROGRAM_REGISTRY } from '@lib/programs/program-registry'; +import type { SettingsConflict } from '@lib/agent/claude-settings'; + +vi.mock('../../../utils/analytics.js', () => ({ + analytics: { + capture: vi.fn(), + wizardCapture: vi.fn(), + captureException: vi.fn(), + setTag: vi.fn(), + shutdown: vi.fn().mockResolvedValue(undefined), + }, + sessionProperties: vi.fn(() => ({})), +})); + +vi.mock('../../../lib/health-checks/readiness.js', () => ({ + evaluateWizardReadiness: vi.fn().mockResolvedValue({ + decision: 'yes', + health: {}, + reasons: [], + }), + WizardReadiness: { + Yes: 'yes', + No: 'no', + YesWithWarnings: 'yes-with-warnings', + }, + SERVICE_LABELS: {}, + // Generated signup sessions reach the branch that reads this config. + SIGNUP_WIZARD_READINESS_CONFIG: {}, + getBlockingServiceKeys: vi.fn(() => []), +})); + +const wizardCaptureMock = analytics.wizardCapture as Mock; +const setTagMock = analytics.setTag as Mock; + +const CREDENTIALS = { + accessToken: 'tok', + projectApiKey: 'pk', + host: HostResolution.fromApiHost('https://app.posthog.com'), + projectId: 1, +}; + +const CLEAN_READINESS = { + decision: WizardReadiness.Yes, + health: {} as never, + reasons: [], +}; + +const SETTINGS_CONFLICT: SettingsConflict = { + source: 'project', + path: '/app/.claude/settings.json', + keys: ['ANTHROPIC_BASE_URL'], + writable: true, +}; + +const TASK_NOTICE: TaskNotice = { + title: 'Connect your sources', + body: ['body'], + confirmLabel: 'Continue', + cancelLabel: 'Skip', + prompt: 'Connect these?', +}; + +const PENDING_QUESTION: PendingQuestion = { + id: 'ask-1', + questions: [{ id: 'a', prompt: 'p', kind: 'text' }], + source: 'test-skill', +}; + +const ANSWERS: AskAnswers = { a: 'yes' }; + +const aiUser = (approved: boolean): WizardSession['apiUser'] => + ({ organization: { is_ai_data_processing_approved: approved } } as never); + +function createStore(program?: ProgramId): WizardStore { + return new WizardStore(program); +} + +async function flushMicrotasks(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +function tracked(promise: Promise): { resolved: boolean } { + const state = { resolved: false }; + void promise.then(() => { + state.resolved = true; + }); + return state; +} + +function countEmissions(store: WizardStore, act: () => void): number { + let count = 0; + const unsubscribe = store.subscribe(() => { + count += 1; + }); + act(); + unsubscribe(); + return count; +} + +interface MutationCase { + name: string; + /** Untracked setup — emissions it fires are excluded from the count. */ + prepare?: (store: WizardStore) => void; + invoke: (store: WizardStore) => void; + emits: number; +} + +const MUTATIONS: MutationCase[] = [ + { + name: 'setCurrentStage', + invoke: (s) => s.setCurrentStage('stage'), + emits: 1, + }, + { + name: 'toggleStatusExpanded', + invoke: (s) => s.toggleStatusExpanded(), + emits: 1, + }, + { + name: 'setStatusExpanded', + invoke: (s) => s.setStatusExpanded(true), + emits: 1, + }, + { name: 'completeSetup', invoke: (s) => s.completeSetup(), emits: 1 }, + { name: 'grantSharing', invoke: (s) => s.grantSharing(), emits: 1 }, + { name: 'declineSharing', invoke: (s) => s.declineSharing(), emits: 1 }, + { + name: 'setRunPhase', + invoke: (s) => s.setRunPhase(RunPhase.Running), + emits: 1, + }, + { + name: 'setCredentials', + invoke: (s) => s.setCredentials(CREDENTIALS), + emits: 1, + }, + { + name: 'setAccessToken', + invoke: (s) => s.setAccessToken(CREDENTIALS), + emits: 1, + }, + { + name: 'setRoleAtOrganization', + invoke: (s) => s.setRoleAtOrganization('engineer'), + emits: 1, + }, + { name: 'setApiUser', invoke: (s) => s.setApiUser(aiUser(true)), emits: 1 }, + { + name: 'setFrameworkConfig', + invoke: (s) => + s.setFrameworkConfig( + Integration.javascriptNode, + FRAMEWORK_REGISTRY[Integration.javascriptNode], + ), + emits: 1, + }, + { + name: 'setDetectionComplete', + invoke: (s) => s.setDetectionComplete(), + emits: 1, + }, + { + name: 'setDetectedFramework', + invoke: (s) => s.setDetectedFramework('Node.js'), + emits: 1, + }, + { + name: 'setPosthogSdkDetected', + invoke: (s) => s.setPosthogSdkDetected(true), + emits: 1, + }, + { + name: 'setSpellbook', + invoke: (s) => s.setSpellbook({ path: '/app/skill', skillsIncluded: true }), + emits: 1, + }, + { + name: 'setMintHandoff', + invoke: (s) => s.setMintHandoff('continue'), + emits: 1, + }, + { + name: 'setSkillId', + invoke: (s) => s.setSkillId('integration-nextjs'), + emits: 1, + }, + { + name: 'setUnsupportedVersion', + invoke: (s) => + s.setUnsupportedVersion({ current: '1', minimum: '2', docsUrl: 'u' }), + emits: 1, + }, + { + name: 'setLoginUrl', + invoke: (s) => s.setLoginUrl('http://localhost:8010'), + emits: 1, + }, + { + name: 'setAuthorizeUrl', + invoke: (s) => s.setAuthorizeUrl('https://app.posthog.com'), + emits: 1, + }, + { + name: 'setReadinessResult', + invoke: (s) => s.setReadinessResult(CLEAN_READINESS), + emits: 1, + }, + { name: 'dismissOutage', invoke: (s) => s.dismissOutage(), emits: 1 }, + { + name: 'showSettingsOverride', + invoke: (s) => void s.showSettingsOverride([SETTINGS_CONFLICT], () => true), + emits: 1, + }, + { + name: 'showPortConflict', + invoke: (s) => + void s.showPortConflict({ + command: 'node', + pid: '1', + port: 8010, + user: 'u', + }), + emits: 1, + }, + { + name: 'resolvePortConflict', + prepare: (s) => + void s.showPortConflict({ + command: 'node', + pid: '1', + port: 8010, + user: 'u', + }), + invoke: (s) => s.resolvePortConflict(), + emits: 1, + }, + { + name: 'showTaskNotice', + invoke: (s) => void s.showTaskNotice(TASK_NOTICE), + emits: 1, + }, + { + name: 'resolveTaskNotice', + prepare: (s) => void s.showTaskNotice(TASK_NOTICE), + invoke: (s) => s.resolveTaskNotice(true), + emits: 1, + }, + // No overlay push — the modal is opened separately by showManualAuthCode. + { + name: 'waitForManualAuthCode', + invoke: (s) => void s.waitForManualAuthCode(), + emits: 0, + }, + { + name: 'showManualAuthCode', + invoke: (s) => s.showManualAuthCode(), + emits: 1, + }, + { + name: 'dismissManualAuthCode', + prepare: (s) => s.showManualAuthCode(), + invoke: (s) => s.dismissManualAuthCode(), + emits: 1, + }, + { + name: 'submitManualAuthCode', + prepare: (s) => s.showManualAuthCode(), + invoke: (s) => s.submitManualAuthCode('code'), + emits: 1, + }, + { + name: 'requestQuestion', + invoke: (s) => void s.requestQuestion(PENDING_QUESTION), + emits: 1, + }, + { + name: 'resolvePendingQuestion', + prepare: (s) => void s.requestQuestion(PENDING_QUESTION), + invoke: (s) => s.resolvePendingQuestion(ANSWERS), + emits: 1, + }, + { + name: 'cancelPendingQuestion', + prepare: (s) => void s.requestQuestion(PENDING_QUESTION), + invoke: (s) => s.cancelPendingQuestion(), + emits: 1, + }, + { + name: 'backupAndFixSettingsOverride', + prepare: (s) => + void s.showSettingsOverride([SETTINGS_CONFLICT], () => true), + invoke: (s) => void s.backupAndFixSettingsOverride(), + emits: 1, + }, + { + name: 'showAuthError', + invoke: (s) => + s.showAuthError({ hasSettingsConflict: false, logFilePath: '/tmp/log' }), + emits: 1, + }, + { + name: 'showSessionTimeout', + invoke: (s) => s.showSessionTimeout(), + emits: 1, + }, + { + name: 'addDiscoveredFeature', + invoke: (s) => s.addDiscoveredFeature(DiscoveredFeature.Stripe), + emits: 1, + }, + { + name: 'enableFeature', + invoke: (s) => s.enableFeature(AdditionalFeature.LLM), + emits: 1, + }, + { + name: 'setMcpComplete', + invoke: (s) => s.setMcpComplete(McpOutcome.Installed, ['claude'], 'all'), + emits: 1, + }, + { + name: 'setSkillsComplete', + invoke: (s) => s.setSkillsComplete(true), + emits: 1, + }, + { + name: 'setMcpSuggestedPromptsDismissed', + invoke: (s) => s.setMcpSuggestedPromptsDismissed(), + emits: 1, + }, + { + name: 'setSlackStepDismissed', + invoke: (s) => s.setSlackStepDismissed(), + emits: 1, + }, + { + name: 'setSlackConnected', + invoke: (s) => s.setSlackConnected(true), + emits: 1, + }, + { + name: 'setGithubConnected', + invoke: (s) => s.setGithubConnected(true), + emits: 1, + }, + { + name: 'declineGithub', + invoke: (s) => + s.declineGithub({ kind: OutroKind.Cancel, message: 'declined' }), + emits: 1, + }, + { + name: 'setIntegrate', + invoke: (s) => s.setIntegrate(true, { via: 'screen' }), + emits: 1, + }, + { + name: 'chooseProvisionAccount', + invoke: (s) => s.chooseProvisionAccount('a@b.com', 'us'), + emits: 1, + }, + { + name: 'confirmSelfDrivingHandoff', + invoke: (s) => s.confirmSelfDrivingHandoff(), + emits: 1, + }, + { + name: 'completeRunStep', + invoke: (s) => s.completeRunStep('integrate-run'), + emits: 1, + }, + { name: 'setOutroDismissed', invoke: (s) => s.setOutroDismissed(), emits: 1 }, + { + name: 'setOutroData', + invoke: (s) => s.setOutroData({ kind: OutroKind.Success, message: 'done' }), + emits: 1, + }, + { + name: 'setDashboardUrl', + invoke: (s) => s.setDashboardUrl('https://d'), + emits: 1, + }, + { + name: 'setNotebookUrl', + invoke: (s) => s.setNotebookUrl('https://n'), + emits: 1, + }, + { + name: 'setFrameworkContext', + invoke: (s) => s.setFrameworkContext('k', 'v'), + emits: 1, + }, + { + name: 'switchProgram', + invoke: (s) => s.switchProgram(Program.Metrics), + emits: 1, + }, + { + name: 'pushOverlay', + invoke: (s) => s.pushOverlay(Overlay.WizardAsk), + emits: 1, + }, + { + name: 'popOverlay', + prepare: (s) => s.pushOverlay(Overlay.WizardAsk), + invoke: (s) => s.popOverlay(), + emits: 1, + }, + { name: 'pushStatus', invoke: (s) => s.pushStatus('working'), emits: 1 }, + { name: 'toggleTokenHud', invoke: (s) => s.toggleTokenHud(), emits: 1 }, + { + name: 'addTokenUsage', + invoke: (s) => + s.addTokenUsage({ + inputTokens: 10, + outputTokens: 5, + cacheReadTokens: 0, + cacheCreationTokens: 0, + cacheCreation5m: 0, + cacheCreation1h: 0, + }), + emits: 1, + }, + { + name: 'setFinalTokenCostUsd', + invoke: (s) => s.setFinalTokenCostUsd(1.25), + emits: 1, + }, + { + name: 'setTasks', + invoke: (s) => + s.setTasks([{ label: 'a', status: TaskStatus.Pending, done: false }]), + emits: 1, + }, + { + name: 'updateTask', + prepare: (s) => + s.setTasks([{ label: 'a', status: TaskStatus.Pending, done: false }]), + invoke: (s) => s.updateTask(0, true), + emits: 1, + }, + { + name: 'setEventPlan', + invoke: (s) => s.setEventPlan([{ name: 'signed_up', description: 'd' }]), + emits: 1, + }, + { + name: 'setHandoffText', + invoke: (s) => s.setHandoffText('handoff'), + emits: 1, + }, + // Render-only cursor: the learn card drives its own re-render. + { + name: 'setLearnCardBlockIdx', + invoke: (s) => s.setLearnCardBlockIdx(2), + emits: 0, + }, + { + name: 'setLearnCardComplete', + invoke: (s) => s.setLearnCardComplete(), + emits: 1, + }, + { + name: 'syncTodos', + invoke: (s) => s.syncTodos([{ content: 'a', status: 'pending' }]), + emits: 1, + }, +]; + +/** Read-only or notification-plumbing methods, excluded by the task brief. */ +const NON_MUTATING = [ + 'subscribe', + 'getSnapshot', + 'getVersion', + 'runInitHooks', + 'runReadyHooks', + 'getGate', + 'waitUntil', + 'onEnterScreen', + 'emitChange', +]; + +describe('store invariants', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('one notification per mutation', () => { + it('enumerates every public method on the class', () => { + const methods = Object.getOwnPropertyNames(WizardStore.prototype).filter( + (name) => { + const descriptor = Object.getOwnPropertyDescriptor( + WizardStore.prototype, + name, + ); + return ( + typeof descriptor?.value === 'function' && + name !== 'constructor' && + !name.startsWith('_') + ); + }, + ); + const covered = new Set([ + ...MUTATIONS.map((c) => c.name), + ...NON_MUTATING, + ]); + + expect(methods.filter((name) => !covered.has(name))).toEqual([]); + expect([...covered].filter((name) => !methods.includes(name))).toEqual( + [], + ); + }); + + it.each(MUTATIONS)( + '$name notifies $emits time(s)', + ({ prepare, invoke, emits }) => { + const store = createStore(); + prepare?.(store); + expect(countEmissions(store, () => invoke(store))).toBe(emits); + }, + ); + + it('cancelPendingQuestion with no question pending notifies nothing', () => { + const store = createStore(); + expect(countEmissions(store, () => store.cancelPendingQuestion())).toBe( + 0, + ); + }); + + it('backupAndFixSettingsOverride with no pending fix notifies nothing', () => { + const store = createStore(); + expect( + countEmissions(store, () => void store.backupAndFixSettingsOverride()), + ).toBe(0); + }); + + it('updateTask on a missing index notifies nothing', () => { + const store = createStore(); + expect(countEmissions(store, () => store.updateTask(3, true))).toBe(0); + }); + + it('switchProgram to the active program notifies nothing', () => { + const store = createStore(); + expect( + countEmissions(store, () => + store.switchProgram(Program.PostHogIntegration), + ), + ).toBe(0); + }); + + it('setStatusExpanded to the current value notifies nothing', () => { + const store = createStore(); + expect(countEmissions(store, () => store.setStatusExpanded(false))).toBe( + 0, + ); + }); + + it('setCurrentStage with the same stage notifies nothing', () => { + const store = createStore(); + store.setCurrentStage('stage'); + expect(countEmissions(store, () => store.setCurrentStage('stage'))).toBe( + 0, + ); + }); + + it('setHandoffText with identical text notifies nothing', () => { + const store = createStore(); + store.setHandoffText('handoff'); + expect(countEmissions(store, () => store.setHandoffText('handoff'))).toBe( + 0, + ); + }); + }); + + describe('gates latch once', () => { + it('resolves when the predicate turns true and stays resolved after it turns false', async () => { + const store = createStore(); + const gate = tracked(store.getGate('intro')); + await flushMicrotasks(); + expect(gate.resolved).toBe(false); + + store.completeSetup(); + await flushMicrotasks(); + expect(gate.resolved).toBe(true); + + store.session = buildSession({}); + await flushMicrotasks(); + expect(store.session.setupConfirmed).toBe(false); + + const relatched = tracked(store.getGate('intro')); + await flushMicrotasks(); + expect(relatched.resolved).toBe(true); + }); + + it('returns an already resolved promise for an unknown step', async () => { + const store = createStore(); + const gate = tracked(store.getGate('nonexistent')); + await flushMicrotasks(); + expect(gate.resolved).toBe(true); + }); + + it('waitUntil resolves on the next commit that matches', async () => { + const store = createStore(); + const waiter = tracked(store.waitUntil((s) => s.credentials !== null)); + await flushMicrotasks(); + expect(waiter.resolved).toBe(false); + + store.setDetectionComplete(); + await flushMicrotasks(); + expect(waiter.resolved).toBe(false); + + store.setCredentials(CREDENTIALS); + await flushMicrotasks(); + expect(waiter.resolved).toBe(true); + }); + + it('waitUntil evaluates live, so an already true predicate resolves immediately', async () => { + const store = createStore(); + store.completeSetup(); + const waiter = tracked(store.waitUntil((s) => s.setupConfirmed)); + await flushMicrotasks(); + expect(waiter.resolved).toBe(true); + }); + }); + + describe('overlays are LIFO', () => { + it('unwinds in reverse order back to the program screen', () => { + const store = createStore(); + expect(store.router.hasOverlay).toBe(false); + + store.pushOverlay(Overlay.AuthError); + store.pushOverlay(Overlay.SessionTimeout); + expect(store.router.resolve(store.session)).toBe(Overlay.SessionTimeout); + expect(store.router.hasOverlay).toBe(true); + + store.popOverlay(); + expect(store.router.resolve(store.session)).toBe(Overlay.AuthError); + expect(store.router.hasOverlay).toBe(true); + + store.popOverlay(); + expect(store.router.hasOverlay).toBe(false); + expect(store.router.resolve(store.session)).toBe(ScreenId.Intro); + }); + + it('tracks the nav direction across emits and overlay moves', () => { + const store = createStore(); + expect(store.lastNavDirection).toBeNull(); + + store.emitChange(); + expect(store.lastNavDirection).toBe('push'); + + store.pushOverlay(Overlay.AuthError); + expect(store.lastNavDirection).toBe('push'); + + store.popOverlay(); + expect(store.lastNavDirection).toBe('pop'); + + store.emitChange(); + expect(store.lastNavDirection).toBe('push'); + }); + }); + + describe('status ring', () => { + it('skips consecutive duplicates but keeps a repeat that is not adjacent', () => { + const store = createStore(); + store.pushStatus('a'); + store.pushStatus('a'); + store.pushStatus('b'); + store.pushStatus('a'); + expect(store.statusMessages).toEqual(['a', 'b', 'a']); + }); + + it('caps at the expanded window, dropping the oldest', () => { + const store = createStore(); + const total = EXPANDED_COUNT + 3; + for (let i = 0; i < total; i++) store.pushStatus(`m${i}`); + + expect(store.statusMessages).toHaveLength(EXPANDED_COUNT); + expect(store.statusMessages[0]).toBe(`m${total - EXPANDED_COUNT}`); + expect(store.statusMessages[EXPANDED_COUNT - 1]).toBe(`m${total - 1}`); + }); + }); + + describe('screen resolution is total', () => { + const PROGRAM_IDS = PROGRAM_REGISTRY.map((config) => config.id); + const SCREEN_NAMES = new Set([ + ...Object.values(ScreenId), + ...Object.values(Overlay), + ]); + const SEED = 0x5eed; + const SESSION_COUNT = 200; + + function mulberry32(seed: number): () => number { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + } + + function randomSession(rand: () => number): WizardSession { + const pick = (values: readonly T[]): T => + values[Math.floor(rand() * values.length)]; + const flip = (): boolean => rand() < 0.5; + + const session = buildSession({ installDir: '/app', ci: flip() }); + session.setupConfirmed = flip(); + session.credentials = flip() ? CREDENTIALS : null; + session.apiUser = pick([null, aiUser(true), aiUser(false)]); + session.runPhase = pick(Object.values(RunPhase)); + session.outroDismissed = flip(); + session.outroData = flip() + ? { kind: OutroKind.Error, message: 'x' } + : null; + session.mintHandoff = pick([null, 'exit', 'continue'] as const); + session.mcpComplete = flip(); + session.mcpOutcome = pick([null, ...Object.values(McpOutcome)]); + session.slackStepDismissed = flip(); + session.skillsComplete = flip(); + session.integrate = pick([null, true, false]); + if (flip()) { + session.integration = Integration.javascriptNode; + session.frameworkConfig = + FRAMEWORK_REGISTRY[Integration.javascriptNode]; + } + session.selfDrivingHandoffConfirmed = flip(); + session.githubConnected = pick([null, true, false]); + session.githubDeclined = flip(); + session.readinessResult = flip() ? CLEAN_READINESS : null; + session.outageDismissed = flip(); + if (flip()) session.frameworkContext = { postHogPresent: flip() }; + session.completedRuns = flip() ? ['integrate-run'] : []; + session.detectionComplete = flip(); + session.signup = flip(); + return session; + } + + function resolveAll(program: ProgramId): ScreenName[] { + const store = createStore(program); + const rand = mulberry32(SEED); + const screens: ScreenName[] = []; + for (let i = 0; i < SESSION_COUNT; i++) { + screens.push(store.router.resolve(randomSession(rand))); + } + return screens; + } + + it.each(PROGRAM_IDS)( + 'resolves a known screen for every session in %s', + (program) => { + const screens = resolveAll(program); + expect(screens).toHaveLength(SESSION_COUNT); + expect(screens.filter((screen) => !SCREEN_NAMES.has(screen))).toEqual( + [], + ); + }, + ); + + it.each(PROGRAM_IDS)('%s sequence ends on the exit screen', (program) => { + const sequence = PROGRAM_SEQUENCES[program]; + expect(sequence[sequence.length - 1].id).toBe(ScreenId.Exit); + }); + + it('generates the same sessions from the same seed', () => { + expect(resolveAll(Program.PostHogIntegration)).toEqual( + resolveAll(Program.PostHogIntegration), + ); + }); + }); + + describe('transition analytics shape', () => { + function driveIntegrationFlow(): void { + const store = createStore(); + store.completeSetup(); + store.setReadinessResult(CLEAN_READINESS); + store.setCredentials(CREDENTIALS); + store.setRunPhase(RunPhase.Running); + store.setRunPhase(RunPhase.Completed); + store.setOutroDismissed(); + } + + it('captures one screen event per transition', () => { + driveIntegrationFlow(); + + const screenEvents = wizardCaptureMock.mock.calls + .filter(([event]) => String(event).startsWith('screen ')) + .map(([event, props]) => ({ + event, + from_screen: props.from_screen, + program_id: props.program_id, + })); + + expect(screenEvents).toMatchInlineSnapshot(` + [ + { + "event": "screen auth", + "from_screen": "health-check", + "program_id": "posthog-integration", + }, + { + "event": "screen run", + "from_screen": "auth", + "program_id": "posthog-integration", + }, + { + "event": "screen outro", + "from_screen": "run", + "program_id": "posthog-integration", + }, + { + "event": "screen mcp", + "from_screen": "outro", + "program_id": "posthog-integration", + }, + ] + `); + }); + + it('tags $screen_name on every transition', () => { + driveIntegrationFlow(); + + const screenTags = setTagMock.mock.calls + .filter(([key]) => key === '$screen_name') + .map(([, value]) => value); + + expect(screenTags).toMatchInlineSnapshot(` + [ + "health-check", + "auth", + "run", + "outro", + "mcp", + ] + `); + }); + }); +}); diff --git a/src/ui/tui/components/visualizer/CrateStack.tsx b/src/ui/tui/components/visualizer/CrateStack.tsx index d002faa95..781502a4c 100644 --- a/src/ui/tui/components/visualizer/CrateStack.tsx +++ b/src/ui/tui/components/visualizer/CrateStack.tsx @@ -109,7 +109,7 @@ export const CrateStack = ({ width, height }: VisualProps) => { return ( ({ name, - status: - i === 1 ? McpClientStatus.Unchanged : McpClientStatus.Changed, + status: i === 1 ? McpClientStatus.Unchanged : McpClientStatus.Changed, })); }, async installPlugins(clientNames) { diff --git a/src/ui/tui/screens/MetricsIntroScreen.tsx b/src/ui/tui/screens/MetricsIntroScreen.tsx index 87875559a..cdda2be1e 100644 --- a/src/ui/tui/screens/MetricsIntroScreen.tsx +++ b/src/ui/tui/screens/MetricsIntroScreen.tsx @@ -23,7 +23,7 @@ export const MetricsIntroScreen = ({ store }: MetricsIntroScreenProps) => { // kubernetes, other), so there's no pre-seeded skillId here. Fall back to // the group id for the "more info" lookup. const skillId = session.skillId ?? 'metrics'; - const { skillEntry, fetchFailed } = useSkillEntry(skillId, session.localMcp); + const { skillEntry, fetchFailed } = useSkillEntry(skillId); const body = showingMoreInfo ? ( diff --git a/src/ui/tui/screens/RevenueIntroScreen.tsx b/src/ui/tui/screens/RevenueIntroScreen.tsx index bdfd0df32..3d791dd3b 100644 --- a/src/ui/tui/screens/RevenueIntroScreen.tsx +++ b/src/ui/tui/screens/RevenueIntroScreen.tsx @@ -205,11 +205,11 @@ const DetectErrorBody = ({ error }: { error: RevenueDetectError }) => { Revenue analytics requires: - {' \u2022'} A PostHog SDK ({POSTHOG_SDKS.slice(0, 3).join(', ')}, - …) + {' \u2022'} A PostHog SDK ( + {[...POSTHOG_SDKS].slice(0, 3).join(', ')}, …) - {' \u2022'} A Stripe SDK ({STRIPE_SDKS.join(', ')}) + {' \u2022'} A Stripe SDK ({[...STRIPE_SDKS].join(', ')}) @@ -247,7 +247,7 @@ const DetectErrorBody = ({ error }: { error: RevenueDetectError }) => { Install one of: - {STRIPE_SDKS.map((sdk) => ( + {[...STRIPE_SDKS].map((sdk) => ( {' \u2022'} {sdk} diff --git a/src/utils/__tests__/ci-region.test.ts b/src/utils/__tests__/ci-region.test.ts index 2f7cf9acd..d174852a5 100644 --- a/src/utils/__tests__/ci-region.test.ts +++ b/src/utils/__tests__/ci-region.test.ts @@ -55,6 +55,7 @@ describe('getOrAskForProjectData CI region', () => { it('uses the provided region and never probes @me for it', async () => { const result = await getOrAskForProjectData({ + signup: false, ci: true, apiKey: 'phx_test', projectId: 123, @@ -76,6 +77,7 @@ describe('getOrAskForProjectData CI region', () => { mockedDetect.mockResolvedValue('us'); await getOrAskForProjectData({ + signup: false, ci: true, apiKey: 'phx_test', projectId: 123, diff --git a/src/utils/bounded-fs.ts b/src/utils/bounded-fs.ts index 23c091a0f..35e6dd55c 100644 --- a/src/utils/bounded-fs.ts +++ b/src/utils/bounded-fs.ts @@ -143,7 +143,7 @@ export async function boundedGlob( ignore: [...PROJECT_IGNORE_GLOBS, ...(options.extraIgnore ?? [])], suppressErrors: true, followSymbolicLinks: false, - }); + }) as NodeJS.ReadableStream & { destroy(): void }; const matches: string[] = []; return new Promise((resolve) => { diff --git a/src/wizard.ts b/src/wizard.ts index 5072275b2..621e14699 100644 --- a/src/wizard.ts +++ b/src/wizard.ts @@ -81,7 +81,7 @@ export class Wizard { private cli: Argv; private constructor() { - let cli = yargs(hideBin(process.argv)) + let cli: Argv = yargs(hideBin(process.argv)) .env('POSTHOG_WIZARD') .options(GLOBAL_OPTIONS); diff --git a/vitest.config.ts b/vitest.config.ts index 817d2577d..7985ed29d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -29,6 +29,43 @@ function resolveTsForJs(): Plugin { }; } +// Per-surface Vitest projects keyed by today's directories. Each project runs +// alone with `vitest run --project `; `vitest run` runs them all. +const TESTS = '__tests__/**/*.{js,jsx,ts,tsx}'; +const AGENT_TESTS = [ + `src/lib/agent/**/${TESTS}`, + `src/lib/middleware/**/${TESTS}`, + 'src/lib/__tests__/agent-*.test.ts', + 'src/lib/__tests__/gateway-session.test.ts', + 'src/lib/__tests__/wizard-can-use-tool.test.ts', + 'src/lib/__tests__/yara-*.test.ts', +]; +const TUI_TESTS = [`src/ui/tui/**/${TESTS}`]; +const CLI_TESTS = [ + `src/commands/**/${TESTS}`, + `src/lib/runners/${TESTS}`, + 'src/__tests__/*cli*.test.ts', + 'src/__tests__/wizard.test.ts', + 'src/__tests__/headless-scope.test.ts', +]; +const HARNESS_TESTS = [ + `e2e-harness/${TESTS}`, + 'e2e-harness/**/*.{test,spec}.{js,jsx,ts,tsx}', +]; +const ARCH_TESTS = ['src/__tests__/architecture/**/*.{ts,tsx}']; +const EXCLUDE = [ + '**/node_modules/**', + '**/dist/**', + '**/e2e-tests/**', + '**/*.no-jest.*', + '**/*.d.ts', +]; + +const project = (name: string, include: string[], exclude: string[] = []) => ({ + extends: true as const, + test: { name, include, exclude: [...EXCLUDE, ...exclude] }, +}); + export default defineConfig({ plugins: [resolveTsForJs()], // The source targets the React 19 automatic JSX runtime (tsconfig @@ -63,17 +100,19 @@ export default defineConfig({ test: { globals: true, environment: 'node', - include: [ - '**/__tests__/**/*.{test,spec}.{js,jsx,ts,tsx}', - '**/__tests__/**/*.{js,jsx,ts,tsx}', - '**/*.{test,spec}.{js,jsx,ts,tsx}', - ], - exclude: [ - '**/node_modules/**', - '**/dist/**', - '**/e2e-tests/**', - '**/*.no-jest.*', - '**/*.d.ts', + projects: [ + project('agent', AGENT_TESTS), + project('tui', TUI_TESTS), + project('cli', CLI_TESTS), + project('harness', HARNESS_TESTS), + project('architecture', ARCH_TESTS), + project( + 'legacy', + // The second glob keeps the pre-split behavior: a test file outside + // a __tests__ directory still runs, here, rather than nowhere. + [`src/**/${TESTS}`, 'src/**/*.{test,spec}.{js,jsx,ts,tsx}'], + [...AGENT_TESTS, ...TUI_TESTS, ...CLI_TESTS, ...ARCH_TESTS], + ), ], coverage: { provider: 'v8',