diff --git a/cli/package.json b/cli/package.json index f9990ec36f..8660d21de7 100644 --- a/cli/package.json +++ b/cli/package.json @@ -94,11 +94,13 @@ "test:macos-signing": "bun test/test-macos-signing.mjs", "test:apple-api-import-helpers": "bun test/test-apple-api-import-helpers.mjs", "test:manifest-path-encoding": "bun test/test-manifest-path-encoding.mjs", - "test": "bun run build && bun run test:version-detection:setup && bun run test:bundle && bun run test:functional && bun run test:semver && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:credentials && bun run test:credentials-validation && bun run test:android-service-account-validation && bun run test:build-zip-filter && bun run test:checksum && bun run test:build-needed && bun run test:ci-prompts && bun run test:ci-secrets && bun run test:posthog-exception && bun run test:build-platform-selection && bun run test:onboarding-recovery && bun run test:onboarding-progress && bun run test:onboarding-run-targets && bun run test:run-device-command && bun run test:init-app-conflict && bun run test:init-guardrails && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:version-detection && bun run test:platform-paths && bun run test:payload-split && bun run test:manifest-path-encoding && bun run test:macos-signing && bun run test:apple-api-import-helpers && bun run test:ai-log-capture && bun run test:ai-analyze-flow && bun run test:ai-render-markdown", + "test": "bun run build && bun run test:version-detection:setup && bun run test:bundle && bun run test:functional && bun run test:semver && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:credentials && bun run test:credentials-validation && bun run test:android-service-account-validation && bun run test:build-zip-filter && bun run test:checksum && bun run test:build-needed && bun run test:ci-prompts && bun run test:ci-secrets && bun run test:posthog-exception && bun run test:build-platform-selection && bun run test:onboarding-recovery && bun run test:onboarding-progress && bun run test:onboarding-run-targets && bun run test:run-device-command && bun run test:init-app-conflict && bun run test:init-guardrails && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:version-detection && bun run test:platform-paths && bun run test:payload-split && bun run test:manifest-path-encoding && bun run test:macos-signing && bun run test:apple-api-import-helpers && bun run test:ai-log-capture && bun run test:ai-analyze-flow && bun run test:ai-render-markdown && bun run test:ai-onboarding-mode && bun run test:ai-fit", "test:build-platform-selection": "bun test/test-build-platform-selection.mjs", "test:ai-log-capture": "bun test/test-ai-log-capture.mjs", "test:ai-analyze-flow": "bun test/test-ai-analyze-flow.mjs", - "test:ai-render-markdown": "bun test/test-ai-render-markdown.mjs" + "test:ai-render-markdown": "bun test/test-ai-render-markdown.mjs", + "test:ai-onboarding-mode": "bun test/test-ai-onboarding-mode.mjs", + "test:ai-fit": "bun test/test-ai-fit.mjs" }, "dependencies": { "@inkjs/ui": "^2.0.0", diff --git a/cli/src/ai/analyze.ts b/cli/src/ai/analyze.ts index b25d2efbf8..8063eaa022 100644 --- a/cli/src/ai/analyze.ts +++ b/cli/src/ai/analyze.ts @@ -1,5 +1,5 @@ import { readFile, stat, writeFile } from 'node:fs/promises' -import { getAiPromptPath, getLogCapturePath } from './log-capture' +import { cleanupCapturedJobFiles, getAiPromptPath, getLogCapturePath } from './log-capture' import { SYSTEM_PROMPT } from './prompt' export type AnalyzeBehavior = 'show_menu' | 'ask_then_menu' | 'auto_upload' | 'skip' @@ -100,3 +100,43 @@ export async function isLogTooBig(jobId: string): Promise { return false } } + +export interface RunCapgoAiAnalysisInput { + apiHost: string + apikey: string + jobId: string + appId: string +} + +// Reads the captured log file for a failed job, then sends it to the Capgo AI +// edge function. Used by callers (e.g. the Ink onboarding TUI) that can't show +// the interactive clack menu in `requestBuildInternal`. +export async function runCapgoAiAnalysis(input: RunCapgoAiAnalysisInput): Promise { + // Check the byte limit before the read so a multi-MB log file doesn't get + // pulled into memory just to be rejected. + if (await isLogTooBig(input.jobId)) + return { kind: 'too_big' } + + let logs: string + try { + logs = await readFile(getLogCapturePath(input.jobId), 'utf8') + } + catch (err) { + return { kind: 'error', message: err instanceof Error ? err.message : 'log_unavailable' } + } + + return postAnalyzeRequest({ + apiHost: input.apiHost, + apikey: input.apikey, + jobId: input.jobId, + appId: input.appId, + logs, + }) +} + +// Best-effort cleanup of captured artifacts for a job. Callers in caller-handled +// mode use this once the user has either viewed the analysis or chosen to skip, +// since `requestBuildInternal` leaves the log file in place for them. +export async function releaseCapturedLogs(jobId: string): Promise { + await cleanupCapturedJobFiles(jobId, { keepAiPromptFile: false }) +} diff --git a/cli/src/ai/telemetry.ts b/cli/src/ai/telemetry.ts index 4113d79d6a..8f34c154cc 100644 --- a/cli/src/ai/telemetry.ts +++ b/cli/src/ai/telemetry.ts @@ -1,7 +1,7 @@ import { sendEvent } from '../utils.js' -export type AiAnalysisChoice = 'capgo_ai' | 'local_ai' | 'skip' | 'auto_upload' -export type AiAnalysisTriggeredBy = 'menu' | 'ci_flag' +export type AiAnalysisChoice = 'capgo_ai' | 'local_ai' | 'skip' | 'auto_upload' | 'retry' +export type AiAnalysisTriggeredBy = 'menu' | 'ci_flag' | 'onboarding' export type AiAnalysisResult = 'success' | 'already_analyzed' | 'too_big' | 'error' export interface TrackAiAnalysisChoiceInput { diff --git a/cli/src/build/onboarding/ai-fit.ts b/cli/src/build/onboarding/ai-fit.ts new file mode 100644 index 0000000000..5d88d8eed7 --- /dev/null +++ b/cli/src/build/onboarding/ai-fit.ts @@ -0,0 +1,159 @@ +/** + * Fit estimation for the AI analysis result step in the onboarding TUI. + * + * The on-failure AI flow can return a multi-screen markdown diagnosis. If + * that text doesn't fit in the user's current terminal viewport we MUST + * route it through the scrollable `FullscreenAiViewer` — otherwise the + * earlier lines scroll out of view and the onboarding wizard ends up in + * an unreadable state. + * + * The estimator deliberately errs on the side of "doesn't fit": a + * false-positive scroll is fine (just one more keystroke for the user), + * but a false-negative inline render is bad UX (text disappears off the + * top of the screen). + */ + +// Conservative chrome reserve: outer Header + AI title + safety warning + +// the retry/skip Select with up to 2 options + blank lines/margins. +// Sized for the worst case so a small terminal still feels safe. +export const AI_RESULT_CHROME_ROWS = 20 + +// ESC sequence used by `renderMarkdown` and `kleur`/`chalk` to color text. +// The escape byte (0x1B) lives in a private-use region so the regex below +// is exact even for input that includes literal '[' or 'm' bytes. +// eslint-disable-next-line no-control-regex +const ANSI_RE = /\x1B\[[0-9;]*m/g + +/** Strip ANSI SGR escape codes so length matches what the user actually sees. */ +export function stripAnsi(text: string): string { + return text.replace(ANSI_RE, '') +} + +/** + * Estimate how many terminal rows a multi-line, possibly ANSI-styled string + * will occupy when rendered by Ink at the given column width. + * + * Each logical line (split on '\n') becomes `ceil(visibleLen / cols)` rows, + * with a floor of 1 to account for empty lines that still consume a row. + */ +export function estimateRenderedRows(text: string, terminalCols: number): number { + if (!text) + return 0 + const cols = Math.max(1, Math.floor(terminalCols)) + const lines = text.split('\n') + let total = 0 + for (const line of lines) { + const visibleLen = stripAnsi(line).length + total += Math.max(1, Math.ceil(visibleLen / cols)) + } + return total +} + +/** + * Decide whether the AI analysis text should be routed through the + * scrollable fullscreen viewer. Conservative — prefers true (scroll) when + * the estimate is close to the available row budget. + * + * @param text The AI analysis markdown (already rendered to ANSI). + * @param terminalRows Total terminal rows from `useStdout().stdout?.rows`. + * @param terminalCols Total terminal cols from `useStdout().stdout?.columns`. + * @param chromeRows Reserved rows for the surrounding wizard chrome. + * Defaults to `AI_RESULT_CHROME_ROWS`. + */ +export function isAiAnalysisTooTall( + text: string, + terminalRows: number, + terminalCols: number, + chromeRows: number = AI_RESULT_CHROME_ROWS, +): boolean { + if (!text) + return false + const availableRows = Math.max(1, terminalRows - chromeRows) + const estimated = estimateRenderedRows(text, terminalCols) + return estimated > availableRows +} + +/** + * Wrap-aware rendered-row count for a single logical line. + * Treats blank/empty lines as one row (Ink still occupies a row for them). + */ +function renderedRowsForLine(line: string, terminalCols: number): number { + const cols = Math.max(1, Math.floor(terminalCols)) + const visibleLen = stripAnsi(line).length + return Math.max(1, Math.ceil(visibleLen / cols)) +} + +/** + * Sum of rendered rows for a list of logical lines. + * + * Used by the scrollable viewer to figure out how many padding rows to + * add below the visible content so the frame height stays constant across + * scroll positions (constant height = Ink renders in-place, no scrollback + * growth on every keystroke). + */ +export function totalRenderedRows(lines: string[], terminalCols: number): number { + let total = 0 + for (const line of lines) + total += renderedRowsForLine(line, terminalCols) + return total +} + +/** + * Pick the slice of `lines` starting at `scrollOffset` that fits within + * `viewportRows` *rendered* rows on a terminal `terminalCols` wide. Returns + * fewer lines than would otherwise be sliced when individual lines wrap. + * + * Always returns at least one line if the input is non-empty and the + * `scrollOffset` is in-range — even if that line wraps to more rows than the + * viewport. The user can still scroll past it; without this floor the viewer + * would render an empty body on hostile inputs. + */ +export function pickVisibleLines( + lines: string[], + scrollOffset: number, + viewportRows: number, + terminalCols: number, +): string[] { + if (lines.length === 0 || scrollOffset >= lines.length) + return [] + const result: string[] = [] + let rowsUsed = 0 + for (let i = scrollOffset; i < lines.length; i++) { + const rows = renderedRowsForLine(lines[i], terminalCols) + if (result.length > 0 && rowsUsed + rows > viewportRows) + break + result.push(lines[i]) + rowsUsed += rows + if (rowsUsed >= viewportRows) + break + } + return result +} + +/** + * Compute the largest `scrollOffset` that still keeps content visible at the + * bottom of the viewport — i.e. the offset where the LAST line is rendered + * within the viewport. Walks backwards from the end, packing as many tail + * lines as fit (accounting for wrap), and returns the offset of the first + * fully-visible tail line. + */ +export function computeMaxScrollOffset( + lines: string[], + viewportRows: number, + terminalCols: number, +): number { + if (lines.length === 0) + return 0 + let rowsUsed = 0 + let kFromEnd = 0 + for (let i = lines.length - 1; i >= 0; i--) { + const rows = renderedRowsForLine(lines[i], terminalCols) + if (kFromEnd > 0 && rowsUsed + rows > viewportRows) + break + rowsUsed += rows + kFromEnd += 1 + if (rowsUsed >= viewportRows) + break + } + return Math.max(0, lines.length - kFromEnd) +} diff --git a/cli/src/build/onboarding/android/types.ts b/cli/src/build/onboarding/android/types.ts index d4cd58a973..30b02c0f4c 100644 --- a/cli/src/build/onboarding/android/types.ts +++ b/cli/src/build/onboarding/android/types.ts @@ -55,6 +55,11 @@ export type AndroidOnboardingStep | 'ci-secrets-failed' | 'ask-build' | 'requesting-build' + // AI debug — only entered when the build fails and logs were captured + | 'ai-analysis-prompt' + | 'ai-analysis-running' + | 'ai-analysis-result' + | 'ai-analysis-result-scroll' | 'build-complete' | 'error' @@ -222,6 +227,10 @@ export const ANDROID_STEP_PROGRESS: Record = { 'ci-secrets-failed': 88, 'ask-build': 90, 'requesting-build': 95, + 'ai-analysis-prompt': 96, + 'ai-analysis-running': 98, + 'ai-analysis-result-scroll': 98, + 'ai-analysis-result': 99, 'build-complete': 100, 'error': 0, } @@ -279,6 +288,11 @@ export function getAndroidPhaseLabel(step: AndroidOnboardingStep): string { case 'ask-build': case 'requesting-build': return 'Step 4 of 4 · Save & Build' + case 'ai-analysis-prompt': + case 'ai-analysis-running': + case 'ai-analysis-result': + case 'ai-analysis-result-scroll': + return 'AI debug' case 'build-complete': return 'Complete' case 'error': diff --git a/cli/src/build/onboarding/android/ui/app.tsx b/cli/src/build/onboarding/android/ui/app.tsx index eafb3129c0..22ac8a84e3 100644 --- a/cli/src/build/onboarding/android/ui/app.tsx +++ b/cli/src/build/onboarding/android/ui/app.tsx @@ -25,13 +25,23 @@ import { Box, Newline, Text, useApp, useInput, useStdout } from 'ink' import React, { useCallback, useEffect, useRef, useState } from 'react' import { createSupabaseClient, findSavedKey, findSavedKeySilent, getOrganizationId } from '../../../../utils.js' import { loadSavedCredentials, updateSavedCredentials } from '../../../credentials.js' +import { releaseCapturedLogs, runCapgoAiAnalysis } from '../../../../ai/analyze.js' +import { renderMarkdown } from '../../../../ai/render-markdown.js' +import { trackAiAnalysisChoice, trackAiAnalysisResult } from '../../../../ai/telemetry.js' import { requestBuildInternal } from '../../../request.js' +import { isAiAnalysisTooTall } from '../../ai-fit.js' + +// Upper bound on "I fixed it, retry build" attempts after an AI diagnosis. +// Three total attempts (initial + two retries) caps the AI cost when a model +// suggestion doesn't actually fix the failure mode while still giving the user +// a couple of in-wizard chances to iterate. +const MAX_AI_RETRIES = 2 import type { CiSecretEntry, CiSecretSetupAdvice, CiSecretTarget } from '../../ci-secrets.js' import { createCiSecretEntries, detectCiSecretTargets, getCiSecretTargetLabel, listExistingCiSecretKeys, uploadCiSecrets } from '../../ci-secrets.js' import { mapAndroidOnboardingError, mapSaValidationKindToCategory } from '../../error-categories.js' import { canUseFilePicker, openKeystorePicker, openServiceAccountJsonPicker } from '../../file-picker.js' import { trackBuilderOnboardingStep } from '../../telemetry.js' -import { Divider, ErrorLine, FilteredTextInput, Header, SpinnerLine, SuccessLine } from '../../ui/components.js' +import { Divider, ErrorLine, FilteredTextInput, FullscreenAiViewer, Header, SpinnerLine, SuccessLine } from '../../ui/components.js' import { findAndroidApplicationIds } from '../gradle-parser.js' import { validateServiceAccountJson } from '../service-account-validation.js' import { @@ -377,6 +387,14 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir // Phase 6 — build output const [buildUrl, setBuildUrl] = useState('') const [buildOutput, setBuildOutput] = useState([]) + // ── AI-analysis sub-flow (see iOS sibling for full notes). Entered only when + // requestBuildInternal returns aiAnalysis.ready=true on a failed build. + const [aiJobId, setAiJobId] = useState(null) + const [aiAnalysisText, setAiAnalysisText] = useState(null) + const [aiResultMessage, setAiResultMessage] = useState(null) + const [aiRetryCount, setAiRetryCount] = useState(0) + // See iOS sibling for full notes on aiViewedFull. + const [aiViewedFull, setAiViewedFull] = useState(false) const [ciSecretEntries, setCiSecretEntries] = useState([]) const [ciSecretTargets, setCiSecretTargets] = useState([]) const [ciSecretTarget, setCiSecretTarget] = useState(null) @@ -387,6 +405,7 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir const { stdout } = useStdout() const terminalRows = stdout?.rows ?? 24 + const terminalCols = stdout?.columns ?? 80 const addLog = useCallback((text: string, color = 'green') => { setLogLines(prev => [...prev, { text, color }]) @@ -1401,6 +1420,11 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir const result = await requestBuildInternal(appId, { platform: 'android', apikey: capgoKey, + // The Ink TUI owns the terminal — @clack/prompts inside + // requestBuildInternal would corrupt rendering. Caller-handled mode + // surfaces the captured log path via result.aiAnalysis and lets us + // render the AI flow with Ink-native components. + aiAnalysisMode: 'caller-handled', }, true, buildLogger) if (cancelled) return @@ -1418,6 +1442,13 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir } else { setBuildOutput(prev => [...prev, `⚠ ${result.error || 'unknown error'}`]) + // Offer AI-assisted diagnosis when logs were captured. The log file + // stays on disk until releaseCapturedLogs runs in 'build-complete'. + if (result.aiAnalysis?.ready && result.aiAnalysis.jobId) { + setAiJobId(result.aiAnalysis.jobId) + setStep('ai-analysis-prompt') + return + } } setStep('build-complete') } @@ -1431,8 +1462,87 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir })() } + // AI analysis — entered only when requestBuildInternal returned with + // aiAnalysis.ready=true. See iOS sibling for full notes. + if (step === 'ai-analysis-running' && aiJobId) { + ;(async () => { + await trackAiAnalysisChoice({ + apikey: resolvedApiKeyRef.current ?? apikey ?? '', + orgId: resolvedOrgId ?? '', + appId, + platform: 'android', + jobId: aiJobId, + choice: 'capgo_ai', + triggeredBy: 'onboarding', + }).catch(() => { /* telemetry never breaks the wizard */ }) + + const result = await runCapgoAiAnalysis({ + apiHost: 'https://api.capgo.app', + apikey: resolvedApiKeyRef.current ?? apikey ?? '', + jobId: aiJobId, + appId, + }) + + if (cancelled) + return + + const resultTag: 'success' | 'already_analyzed' | 'too_big' | 'error' + = result.kind === 'ok' + ? 'success' + : result.kind === 'already_analyzed' + ? 'already_analyzed' + : result.kind === 'too_big' + ? 'too_big' + : 'error' + + await trackAiAnalysisResult({ + apikey: resolvedApiKeyRef.current ?? apikey ?? '', + orgId: resolvedOrgId ?? '', + appId, + platform: 'android', + jobId: aiJobId, + result: resultTag, + errorStatus: result.kind === 'error' ? result.status : undefined, + }).catch(() => { /* telemetry never breaks the wizard */ }) + + if (result.kind === 'ok') { + setAiAnalysisText(renderMarkdown(result.analysis, true)) + setAiResultMessage(null) + } + else if (result.kind === 'already_analyzed') { + setAiAnalysisText(null) + setAiResultMessage('AI analysis was already requested for this build (only one per job).') + } + else if (result.kind === 'too_big') { + setAiAnalysisText(null) + setAiResultMessage('Build log is too large for Capgo AI (>10 MB). Try a local AI tool with the captured log.') + } + else { + setAiAnalysisText(null) + const detail = [ + result.status ? `(status ${result.status})` : null, + result.message, + ].filter(Boolean).join(' ') + setAiResultMessage(`AI analysis failed${detail ? `: ${detail}` : ''}.`) + } + setStep('ai-analysis-result') + })() + } + + // See iOS sibling: route through fullscreen scroll viewer when the + // analysis is taller than the available viewport. + if (step === 'ai-analysis-result' && aiAnalysisText && !aiViewedFull) { + if (isAiAnalysisTooTall(aiAnalysisText, terminalRows, terminalCols)) { + setStep('ai-analysis-result-scroll') + } + } + if (step === 'build-complete') { setBuildOutput([]) + // Best-effort cleanup of any leftover captured log. + if (aiJobId) { + void releaseCapturedLogs(aiJobId).catch(() => { /* best-effort */ }) + } const timer = setTimeout(() => { if (!cancelled) exit() @@ -1457,14 +1567,18 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir const progressPct = ANDROID_STEP_PROGRESS[step] ?? 0 const phaseLabel = getAndroidPhaseLabel(step) - const showProgress = step !== 'welcome' && step !== 'error' && step !== 'build-complete' && step !== 'requesting-build' - const showHeader = step !== 'requesting-build' - const showLog = step !== 'requesting-build' && step !== 'build-complete' + // See iOS sibling: conditional Header, visible on every interactive step + // including the AI sub-flow, hidden on `requesting-build` and on the + // scrollable AI viewer so those get the full terminal height. + const isAiResultScroll = step === 'ai-analysis-result-scroll' + const isAiStep = step === 'ai-analysis-prompt' || step === 'ai-analysis-running' || step === 'ai-analysis-result' || isAiResultScroll + const showHeader = step !== 'requesting-build' && !isAiResultScroll + const showProgress = step !== 'welcome' && step !== 'error' && step !== 'build-complete' && step !== 'requesting-build' && step !== 'ai-analysis-result' && !isAiResultScroll + const showLog = step !== 'requesting-build' && step !== 'build-complete' && !isAiStep return ( {showHeader &&
} - {showProgress && ( {phaseLabel} @@ -2590,6 +2704,130 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir )} + {/* AI debug — ask the user whether to send the captured log */} + {step === 'ai-analysis-prompt' && ( + + + + We can analyze the build log with Capgo AI (Kimi K2.5) and suggest a fix. + + { + if (value === 'retry') { + if (aiJobId) { + await trackAiAnalysisChoice({ + apikey: resolvedApiKeyRef.current ?? apikey ?? '', + orgId: resolvedOrgId ?? '', + appId, + platform: 'android', + jobId: aiJobId, + choice: 'retry', + triggeredBy: 'onboarding', + }).catch(() => { /* telemetry never breaks the wizard */ }) + void releaseCapturedLogs(aiJobId).catch(() => { /* best-effort */ }) + } + setAiJobId(null) + setAiAnalysisText(null) + setAiResultMessage(null) + setAiViewedFull(false) + setAiRetryCount(prev => prev + 1) + setStep('requesting-build') + return + } + setStep('build-complete') + }} + /> + + ) + })()} + + {/* AI debug — scrollable viewer (see iOS sibling). */} + {step === 'ai-analysis-result-scroll' && aiAnalysisText && ( + { + setAiViewedFull(true) + setStep('ai-analysis-result') + }} + /> + )} + {step === 'error' && error && retryStep && ( diff --git a/cli/src/build/onboarding/types.ts b/cli/src/build/onboarding/types.ts index ae61aa9b33..fa2cf07361 100644 --- a/cli/src/build/onboarding/types.ts +++ b/cli/src/build/onboarding/types.ts @@ -45,6 +45,11 @@ export type OnboardingStep | 'ci-secrets-failed' | 'ask-build' | 'requesting-build' + // AI debug — only entered when the build fails and logs were captured + | 'ai-analysis-prompt' + | 'ai-analysis-running' + | 'ai-analysis-result' + | 'ai-analysis-result-scroll' | 'build-complete' | 'no-platform' | 'error' @@ -164,6 +169,10 @@ export const STEP_PROGRESS: Record = { 'ci-secrets-failed': 84, 'ask-build': 85, 'requesting-build': 90, + 'ai-analysis-prompt': 92, + 'ai-analysis-running': 95, + 'ai-analysis-result-scroll': 97, + 'ai-analysis-result': 98, 'build-complete': 100, 'no-platform': 0, 'error': 0, @@ -224,6 +233,11 @@ export function getPhaseLabel(step: OnboardingStep): string { case 'ask-build': case 'requesting-build': return 'Step 4 of 4 · Save & Build' + case 'ai-analysis-prompt': + case 'ai-analysis-running': + case 'ai-analysis-result': + case 'ai-analysis-result-scroll': + return 'AI debug' case 'build-complete': return 'Complete' case 'no-platform': diff --git a/cli/src/build/onboarding/ui/app.tsx b/cli/src/build/onboarding/ui/app.tsx index 7435e3a98d..4f6f6181a7 100644 --- a/cli/src/build/onboarding/ui/app.tsx +++ b/cli/src/build/onboarding/ui/app.tsx @@ -19,7 +19,17 @@ import { writeOnboardingSupportBundle } from '../../../onboarding-support.js' import { formatRunnerCommand, splitRunnerCommand } from '../../../runner-command.js' import { createSupabaseClient, findSavedKeySilent, getOrganizationId, getPMAndCommand } from '../../../utils.js' import { loadSavedCredentials, updateSavedCredentials } from '../../credentials.js' +import { releaseCapturedLogs, runCapgoAiAnalysis } from '../../../ai/analyze.js' +import { renderMarkdown } from '../../../ai/render-markdown.js' +import { trackAiAnalysisChoice, trackAiAnalysisResult } from '../../../ai/telemetry.js' import { requestBuildInternal } from '../../request.js' +import { isAiAnalysisTooTall } from '../ai-fit.js' + +// Upper bound on "I fixed it, retry build" attempts after an AI diagnosis. +// Three total attempts (initial + two retries) caps the AI cost when a model +// suggestion doesn't actually fix the failure mode while still giving the user +// a couple of in-wizard chances to iterate. +const MAX_AI_RETRIES = 2 import { CertificateLimitError, createCertificate, createProfile, deleteProfile, DuplicateProfileError, ensureBundleId, findCertIdBySha1, generateJwt, listProfilesForCert, revokeCertificate, verifyApiKey } from '../apple-api.js' import { createP12, DEFAULT_P12_PASSWORD, generateCsr } from '../csr.js' import { mapIosOnboardingError } from '../error-categories.js' @@ -35,7 +45,7 @@ import { STEP_PROGRESS, } from '../types.js' -import { Divider, ErrorLine, FilteredTextInput, Header, SpinnerLine, SuccessLine } from './components.js' +import { Divider, ErrorLine, FilteredTextInput, FullscreenAiViewer, Header, SpinnerLine, SuccessLine } from './components.js' const OUTPUT_LINE_SPLIT_RE = /\r?\n/ const CARRIAGE_RETURN_RE = /\r/g @@ -167,6 +177,7 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) // Get terminal height for build output sizing const { stdout } = useStdout() const terminalRows = stdout?.rows ?? 24 + const terminalCols = stdout?.columns ?? 80 // Refs to avoid stale closures in useEffect async handlers const p8ContentRef = useRef(p8Content) @@ -261,6 +272,20 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) const [buildUrl, setBuildUrl] = useState('') const [buildOutput, setBuildOutput] = useState([]) const [supportBundlePath, setSupportBundlePath] = useState(null) + // ── AI-analysis sub-flow (entered only when the build fails and logs were + // captured). `aiJobId` is set when entering 'ai-analysis-prompt'; the running + // step reads it to call runCapgoAiAnalysis; the result step renders one of + // these two state strings depending on the PostAnalyzeResult kind. + // `aiRetryCount` tracks how many "I fixed it, retry" attempts the user has + // used so we can cap them at MAX_AI_RETRIES. + // `aiViewedFull` flips true once the user has dismissed the scrollable + // FullscreenAiViewer for the current analysis — prevents 'ai-analysis-result' + // from immediately re-routing back into the scroll step on every render. + const [aiJobId, setAiJobId] = useState(null) + const [aiAnalysisText, setAiAnalysisText] = useState(null) + const [aiResultMessage, setAiResultMessage] = useState(null) + const [aiRetryCount, setAiRetryCount] = useState(0) + const [aiViewedFull, setAiViewedFull] = useState(false) const [ciSecretEntries, setCiSecretEntries] = useState([]) const [ciSecretTargets, setCiSecretTargets] = useState([]) const [ciSecretTarget, setCiSecretTarget] = useState(null) @@ -1237,6 +1262,11 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) const result = await requestBuildInternal(appId, { platform: 'ios', apikey: capgoKey, + // The Ink TUI owns the terminal — @clack/prompts inside + // requestBuildInternal would corrupt rendering. Caller-handled mode + // surfaces the captured log path via result.aiAnalysis and lets us + // render the AI flow with Ink-native components. + aiAnalysisMode: 'caller-handled', }, true, buildLogger) // silent=true, use our logger if (cancelled) return @@ -1254,6 +1284,14 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) } else { setBuildOutput(prev => [...prev, `⚠ ${result.error || 'unknown error'}`]) + // If logs were captured we can offer AI-assisted diagnosis. The + // captured log file stays on disk until the user views the result + // (or skips); 'ai-analysis-result' calls releaseCapturedLogs on exit. + if (result.aiAnalysis?.ready && result.aiAnalysis.jobId) { + setAiJobId(result.aiAnalysis.jobId) + setStep('ai-analysis-prompt') + return + } } setStep('build-complete') } @@ -1268,8 +1306,96 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) })() } + // AI analysis — entered only when requestBuildInternal returned with + // aiAnalysis.ready=true. The captured log file is on disk; we call the + // edge function, then transition to 'ai-analysis-result' which renders the + // diagnosis (or a friendly fallback message) and waits for Enter. + if (step === 'ai-analysis-running' && aiJobId) { + ;(async () => { + // Fire the Choice telemetry here (not in 'ai-analysis-prompt'): we only + // know the user picked "Debug with AI" because we landed in `running`. + await trackAiAnalysisChoice({ + apikey: resolvedApiKeyRef.current ?? apikey ?? '', + orgId: resolvedOrgId ?? '', + appId, + platform: 'ios', + jobId: aiJobId, + choice: 'capgo_ai', + triggeredBy: 'onboarding', + }).catch(() => { /* telemetry never breaks the wizard */ }) + + const result = await runCapgoAiAnalysis({ + apiHost: 'https://api.capgo.app', + apikey: resolvedApiKeyRef.current ?? apikey ?? '', + jobId: aiJobId, + appId, + }) + + if (cancelled) + return + + const resultTag: 'success' | 'already_analyzed' | 'too_big' | 'error' + = result.kind === 'ok' + ? 'success' + : result.kind === 'already_analyzed' + ? 'already_analyzed' + : result.kind === 'too_big' + ? 'too_big' + : 'error' + + await trackAiAnalysisResult({ + apikey: resolvedApiKeyRef.current ?? apikey ?? '', + orgId: resolvedOrgId ?? '', + appId, + platform: 'ios', + jobId: aiJobId, + result: resultTag, + errorStatus: result.kind === 'error' ? result.status : undefined, + }).catch(() => { /* telemetry never breaks the wizard */ }) + + if (result.kind === 'ok') { + // Render markdown to ANSI escapes; Ink passes them through. + // Fall back to raw text if a future Ink version stops doing so. + setAiAnalysisText(renderMarkdown(result.analysis, true)) + setAiResultMessage(null) + } + else if (result.kind === 'already_analyzed') { + setAiAnalysisText(null) + setAiResultMessage('AI analysis was already requested for this build (only one per job).') + } + else if (result.kind === 'too_big') { + setAiAnalysisText(null) + setAiResultMessage('Build log is too large for Capgo AI (>10 MB). Try a local AI tool with the captured log.') + } + else { + setAiAnalysisText(null) + const detail = [ + result.status ? `(status ${result.status})` : null, + result.message, + ].filter(Boolean).join(' ') + setAiResultMessage(`AI analysis failed${detail ? `: ${detail}` : ''}.`) + } + setStep('ai-analysis-result') + })() + } + + // When entering 'ai-analysis-result' with text the user hasn't yet seen, + // estimate fit and route through the fullscreen scroll viewer if the + // analysis is taller than the available viewport. The check is + // deliberately conservative — see ai-fit.ts for the heuristic. + if (step === 'ai-analysis-result' && aiAnalysisText && !aiViewedFull) { + if (isAiAnalysisTooTall(aiAnalysisText, terminalRows, terminalCols)) { + setStep('ai-analysis-result-scroll') + } + } + if (step === 'build-complete') { setBuildOutput([]) + // Best-effort cleanup of any leftover captured log file. Safe to call + // even if we never entered the AI flow (operates only on jobs we know). + if (aiJobId) { + void releaseCapturedLogs(aiJobId).catch(() => { /* best-effort */ }) + } // Exit immediately after rendering the final screen const timer = setTimeout(() => { if (!cancelled) @@ -1290,9 +1416,16 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) const progress = STEP_PROGRESS[step] ?? 0 const phaseLabel = getPhaseLabel(step) - const showProgress = step !== 'welcome' && step !== 'platform-select' && step !== 'adding-platform' && step !== 'no-platform' && step !== 'error' && step !== 'build-complete' && step !== 'requesting-build' - const showHeader = step !== 'requesting-build' - const showLog = step !== 'requesting-build' && step !== 'build-complete' + // Header is rendered as a normal conditional. Visible on every interactive + // step including the AI sub-flow; hidden on `requesting-build` (build + // output needs the full viewport) and on the scrollable AI viewer (same + // reason). The Header itself is now a compact two-row banner so even when + // visible it only costs a couple of rows. + const isAiResultScroll = step === 'ai-analysis-result-scroll' + const isAiStep = step === 'ai-analysis-prompt' || step === 'ai-analysis-running' || step === 'ai-analysis-result' || isAiResultScroll + const showHeader = step !== 'requesting-build' && !isAiResultScroll + const showProgress = step !== 'welcome' && step !== 'platform-select' && step !== 'adding-platform' && step !== 'no-platform' && step !== 'error' && step !== 'build-complete' && step !== 'requesting-build' && step !== 'ai-analysis-result' && !isAiResultScroll + const showLog = step !== 'requesting-build' && step !== 'build-complete' && !isAiStep const recoveryAdvice = error ? getBuildOnboardingRecoveryAdvice(error, retryStep, pm.runner, appId) : null @@ -1300,8 +1433,7 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) return ( {showHeader &&
} - - {/* Progress bar */} + {/* Progress bar */} {showProgress && ( {phaseLabel} @@ -2431,6 +2563,148 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) ) })()} + {/* AI debug — ask the user whether to send the captured log */} + {step === 'ai-analysis-prompt' && ( + + + + We can analyze the build log with Capgo AI (Kimi K2.5) and suggest a fix. + + { + if (value === 'retry') { + // Track the retry intent before we tear down the AI state so + // the choice event carries the per-attempt context. + if (aiJobId) { + await trackAiAnalysisChoice({ + apikey: resolvedApiKeyRef.current ?? apikey ?? '', + orgId: resolvedOrgId ?? '', + appId, + platform: 'ios', + jobId: aiJobId, + choice: 'retry', + triggeredBy: 'onboarding', + }).catch(() => { /* telemetry never breaks the wizard */ }) + // Free the captured log for the previous attempt; the next + // attempt's `requestBuildInternal` will create a new file + // tied to a new builder_job_id. + void releaseCapturedLogs(aiJobId).catch(() => { /* best-effort */ }) + } + // Reset AI state so the next failure starts clean. The fit + // check (and possible scroll-viewer route) will re-evaluate + // against the new analysis text. + setAiJobId(null) + setAiAnalysisText(null) + setAiResultMessage(null) + setAiViewedFull(false) + setAiRetryCount(prev => prev + 1) + setStep('requesting-build') + return + } + // 'skip' (with retries available) or 'continue' (none left). + setStep('build-complete') + }} + /> + + ) + })()} + + {/* AI debug — scrollable viewer for analyses too tall for the viewport. + The outer Header / progress bar are hidden during this step so the + viewer gets the full terminal height. On exit, we mark the analysis + as "viewed" and return to 'ai-analysis-result' (which now shows a + compact "Analysis above" indicator + the retry/skip picker). */} + {step === 'ai-analysis-result-scroll' && aiAnalysisText && ( + { + setAiViewedFull(true) + setStep('ai-analysis-result') + }} + /> + )} + {/* Error with retry */} {step === 'error' && error && ( diff --git a/cli/src/build/onboarding/ui/components.tsx b/cli/src/build/onboarding/ui/components.tsx index a303bc6d76..918df00ea1 100644 --- a/cli/src/build/onboarding/ui/components.tsx +++ b/cli/src/build/onboarding/ui/components.tsx @@ -1,8 +1,9 @@ import type { FC } from 'react' -import { Box, Text, useInput } from 'ink' +import { Box, Text, useInput, useStdout } from 'ink' import Spinner from 'ink-spinner' // src/build/onboarding/ui/components.tsx -import React, { useState } from 'react' +import React, { useEffect, useState } from 'react' +import { computeMaxScrollOffset, pickVisibleLines, totalRenderedRows } from '../ai-fit.js' export const Divider: FC<{ width?: number }> = ({ width = 60 }) => ( {'─'.repeat(width)} @@ -98,3 +99,156 @@ export const Header: FC = () => ( ) + +/** + * Scrollable, fullscreen viewer for the AI build-analysis markdown when it + * is taller than the user's terminal viewport. Mirrors the shape of the + * workflow-file diff viewer on main, but for pre-rendered ANSI lines (no + * `add`/`del` colouring — the markdown renderer already styled them). + * + * Keybindings: + * ↑/k scroll one line up + * ↓/j scroll one line down + * PgUp/u jump up one viewport + * PgDn/d/␣ jump down one viewport + * Home/g jump to top + * End/G jump to bottom + * Esc/Enter dismiss the viewer (returns control to the parent step) + */ +export const FullscreenAiViewer: FC<{ + title: string + subtitle?: string + lines: string[] + terminalRows: number + onExit: () => void +}> = ({ title, subtitle, lines, terminalRows, onExit }) => { + // Track terminal dimensions in state so the component re-renders on resize. + // Without this, the viewport was computed at mount and the body could + // overflow the live screen if the user enlarged or shrank the terminal — + // forcing the user to scroll their terminal emulator to see content the + // viewer should have paginated. + const { stdout } = useStdout() + const initialRows = stdout?.rows ?? terminalRows + const initialCols = stdout?.columns ?? 80 + const [dims, setDims] = useState<{ rows: number, cols: number }>({ + rows: initialRows, + cols: initialCols, + }) + + useEffect(() => { + if (!stdout) + return + const handler = (): void => { + setDims({ + rows: stdout.rows ?? 24, + cols: stdout.columns ?? 80, + }) + } + stdout.on('resize', handler) + return () => { + stdout.off('resize', handler) + } + }, [stdout]) + + // Reserve 10 rows for the viewer's own chrome: title + optional subtitle + + // two dividers + position line + exit hint + a margin to absorb chrome + // lines that themselves wrap on narrow terminals. The parent wizard has + // already hidden its outer Header for this step so the viewer owns the + // whole screen. + const VIEWER_CHROME_ROWS = 10 + const viewportRows = Math.max(1, dims.rows - VIEWER_CHROME_ROWS) + const total = lines.length + // Wrap-aware bound: maximum offset that still places the last logical line + // inside the viewport. Without per-line wrap accounting the user could + // scroll past the end on narrow terminals. + const maxScrollOffset = computeMaxScrollOffset(lines, viewportRows, dims.cols) + const [scrollOffset, setScrollOffset] = useState(0) + + // Clamp the scroll if the viewport grew past the bottom (e.g. terminal + // resized larger after the user scrolled to the bottom). + useEffect(() => { + setScrollOffset(prev => Math.min(prev, maxScrollOffset)) + }, [maxScrollOffset]) + + useInput((input, key) => { + if (key.escape || key.return) { + onExit() + return + } + if (key.downArrow || input === 'j') { + setScrollOffset(prev => Math.min(prev + 1, maxScrollOffset)) + return + } + if (key.upArrow || input === 'k') { + setScrollOffset(prev => Math.max(prev - 1, 0)) + return + } + if (key.pageDown || input === 'd' || input === ' ') { + setScrollOffset(prev => Math.min(prev + viewportRows, maxScrollOffset)) + return + } + if (key.pageUp || input === 'u') { + setScrollOffset(prev => Math.max(prev - viewportRows, 0)) + return + } + if (input === 'g') { + setScrollOffset(0) + return + } + if (input === 'G') { + setScrollOffset(maxScrollOffset) + } + }) + + // Wrap-aware visible slice. `pickVisibleLines` stops adding logical lines + // once their cumulative wrapped row count would overflow `viewportRows`, + // so we never render past the bottom of the live terminal. + const visibleLines = pickVisibleLines(lines, scrollOffset, viewportRows, dims.cols) + const firstVisibleLine = total === 0 ? 0 : scrollOffset + 1 + const lastVisibleLine = Math.min(total, scrollOffset + visibleLines.length) + const atBottom = scrollOffset >= maxScrollOffset + // Divider widths scale to the terminal so the cosmetic border doesn't + // wrap on narrow terminals (which would silently eat a viewport row). + const dividerWidth = Math.max(10, Math.min(60, dims.cols - 1)) + + // Pad the content area with empty rows so the viewer's total frame height + // is CONSTANT across scroll positions. Without this, scrolling can change + // the frame height by ±1 row when lines with different wrap counts move in + // and out of view — Ink then writes the new (taller) frame BELOW the old + // one and the user perceives "scrolling just added an extra line". + const visibleRowsUsed = totalRenderedRows(visibleLines, dims.cols) + const padRows = Math.max(0, viewportRows - visibleRowsUsed) + + // Suppress every scroll-related hint when the analysis fits the viewport + // outright. The conservative `isAiAnalysisTooTall` estimator in the parent + // sometimes routes us here even though `pickVisibleLines` ends up showing + // every logical line — telling the user to "↑/↓ to scroll" when scrolling + // is a no-op is just noise. The subtitle is also suppressed in that case + // because its only job is to advertise "this is scrollable". + const hasMoreToScroll = maxScrollOffset > 0 + + return ( + + {title} + {subtitle && hasMoreToScroll && {subtitle}} + {'─'.repeat(dividerWidth)} + {visibleLines.map((line, index) => ( + {line} + ))} + {Array.from({ length: padRows }).map((_, i) => ( + {' '} + ))} + {'─'.repeat(dividerWidth)} + + {hasMoreToScroll + ? `Showing ${firstVisibleLine}-${lastVisibleLine} of ${total} lines. ↑/↓ or PgUp/PgDn to scroll.` + : `Showing all ${total} lines.`} + + + {hasMoreToScroll && !atBottom + ? 'Press Esc or Enter when done to continue.' + : 'Press Esc or Enter to continue to the retry/skip prompt.'} + + + ) +} diff --git a/cli/src/build/request.ts b/cli/src/build/request.ts index f975c72502..cd885e0859 100644 --- a/cli/src/build/request.ts +++ b/cli/src/build/request.ts @@ -1631,9 +1631,17 @@ export async function requestBuildInternal(appId: string, options: BuildRequestO // --ai-analytics is set in CI (so auto-upload has logs to send). Without the // flag-OR, the CI auto_upload branch from decideAnalyzeBehavior would never // have a log file to read. - const captureEnabled = shouldCaptureLogs() || options.aiAnalytics === true + const aiAnalysisMode: 'auto-prompt' | 'caller-handled' | 'skip' = options.aiAnalysisMode ?? 'auto-prompt' + // Capture when interactive, when the CI flag is set, OR when the caller asked + // to drive the AI flow themselves (e.g. Ink onboarding) so the captured log + // is available for runCapgoAiAnalysis. + const captureEnabled = (shouldCaptureLogs() || options.aiAnalytics === true || aiAnalysisMode === 'caller-handled') + && aiAnalysisMode !== 'skip' let capturedJobId: string | null = null let keepPromptFile = false // mutable so local-AI flow can set it true + // Populated only in caller-handled mode on a failed build. Returned to the + // caller so it can render its own AI prompt UI and later release the log. + let aiAnalysisInfo: BuildRequestResult['aiAnalysis'] if (captureEnabled && buildRequest.job_id) { capturedJobId = buildRequest.job_id @@ -1977,8 +1985,30 @@ export async function requestBuildInternal(appId: string, options: BuildRequestO } } - // On failure, offer the AI analysis flow (interactive menu or auto-upload). - if (finalStatus === 'failed' && captureEnabled && capturedJobId) { + // On failure, offer the AI analysis flow. + // + // - 'skip' → no-op (caller wants nothing to do with AI here). + // - 'caller-handled' → leave the captured log on disk and surface it via + // `result.aiAnalysis` so the caller (e.g. the Ink + // onboarding wizard) can run `runCapgoAiAnalysis` + // and render its own UI without clack corrupting + // its terminal renderer. + // - 'auto-prompt' → existing interactive / CI matrix via + // decideAnalyzeBehavior + clack prompts. + if (finalStatus === 'failed' && captureEnabled && capturedJobId && aiAnalysisMode === 'caller-handled') { + // Preserve the captured log until the caller calls + // releaseCapturedLogs(jobId) explicitly. Without this, the cleanup + // handlers registered above would remove it on process exit before + // the caller had a chance to read it. + keepPromptFile = true + const logsPath = `${process.env.CAPGO_AI_LOG_BASE_DIR || '/tmp/capgo-builds'}/${capturedJobId}.log` + aiAnalysisInfo = { + jobId: capturedJobId, + capturedLogPath: logsPath, + ready: true, + } + } + else if (finalStatus === 'failed' && captureEnabled && capturedJobId && aiAnalysisMode === 'auto-prompt') { const behavior = decideAnalyzeBehavior({ isTTY: process.stdout.isTTY === true, aiAnalyticsFlag: options.aiAnalytics === true, @@ -2180,6 +2210,7 @@ export async function requestBuildInternal(appId: string, options: BuildRequestO jobId: buildRequest.job_id, uploadUrl: buildRequest.upload_url, status: finalStatus || startResult.status || buildRequest.status, + aiAnalysis: aiAnalysisInfo, } } finally { diff --git a/cli/src/schemas/build.ts b/cli/src/schemas/build.ts index 2d7d855f7a..4cb7cce534 100644 --- a/cli/src/schemas/build.ts +++ b/cli/src/schemas/build.ts @@ -66,6 +66,16 @@ export const buildRequestOptionsSchema = optionsBaseSchema.extend({ playstoreUpload: z.boolean().optional(), verbose: z.boolean().optional(), aiAnalytics: z.boolean().optional(), + // Controls the on-failure AI-analysis flow inside requestBuildInternal: + // - 'auto-prompt' (default) — current behavior: clack-driven menu when + // interactive, decideAnalyzeBehavior matrix in CI. + // - 'caller-handled' — skip the clack block entirely and surface + // `aiAnalysis` on the result so the caller (e.g. the Ink onboarding + // wizard) drives the UX. The captured log file is preserved so the + // caller can read it before calling `releaseCapturedLogs`. + // - 'skip' — skip the AI block entirely; normal cleanup + // runs (log file deleted on exit). + aiAnalysisMode: z.enum(['auto-prompt', 'caller-handled', 'skip']).optional(), }) export type BuildRequestOptions = z.infer @@ -89,6 +99,16 @@ export const buildRequestResultSchema = z.object({ uploadUrl: z.string().optional(), status: z.string().optional(), error: z.string().optional(), + // Populated only when `aiAnalysisMode === 'caller-handled'` AND the build + // failed AND log capture was active. `ready: true` means the captured log + // file is on disk at `capturedLogPath` and a caller can run `runCapgoAiAnalysis` + // immediately. Callers must invoke `releaseCapturedLogs(jobId)` when they're + // done viewing the analysis (or chose to skip) so the file gets cleaned up. + aiAnalysis: z.object({ + jobId: z.string(), + capturedLogPath: z.string(), + ready: z.boolean(), + }).optional(), }) export type BuildRequestResult = z.infer diff --git a/cli/test/test-ai-fit.mjs b/cli/test/test-ai-fit.mjs new file mode 100644 index 0000000000..094d4d786c --- /dev/null +++ b/cli/test/test-ai-fit.mjs @@ -0,0 +1,201 @@ +#!/usr/bin/env node +// Tests for the AI-result fit estimator used by the onboarding TUI to decide +// whether to render the analysis inline or route it through the scrollable +// FullscreenAiViewer. +// +// The heuristic is deliberately conservative — prefers "scroll" when in doubt +// — so the tests focus on: +// 1. Empty text → never overflows. +// 2. Text that fits comfortably → returns false. +// 3. Text that obviously overflows → returns true. +// 4. ANSI escape codes don't inflate the estimate. +// 5. Long single line that would wrap → counted as multiple rows. +import { + AI_RESULT_CHROME_ROWS, + computeMaxScrollOffset, + estimateRenderedRows, + isAiAnalysisTooTall, + pickVisibleLines, + stripAnsi, +} from '../src/build/onboarding/ai-fit.ts' + +let passed = 0 +let failed = 0 +function test(name, fn) { + try { + fn() + console.log(`✅ ${name}`) + passed++ + } + catch (err) { + console.error(`❌ ${name}\n ${err.message}`) + failed++ + } +} + +test('AI_RESULT_CHROME_ROWS is conservative (≥ 15)', () => { + if (AI_RESULT_CHROME_ROWS < 15) + throw new Error(`chrome reserve too small: ${AI_RESULT_CHROME_ROWS}`) +}) + +test('stripAnsi removes SGR escape codes', () => { + const styled = `\x1b[1;36mhello\x1b[0m world` + if (stripAnsi(styled) !== 'hello world') + throw new Error(`got ${JSON.stringify(stripAnsi(styled))}`) +}) + +test('estimateRenderedRows returns 0 for empty text', () => { + if (estimateRenderedRows('', 80) !== 0) + throw new Error('empty should be 0') +}) + +test('estimateRenderedRows counts each newline as a row floor of 1', () => { + // 5 short lines → 5 rows + const txt = ['a', 'b', '', 'c', 'd'].join('\n') + const rows = estimateRenderedRows(txt, 80) + if (rows !== 5) + throw new Error(`expected 5, got ${rows}`) +}) + +test('estimateRenderedRows accounts for line wrap', () => { + // 160 chars on a 40-col terminal → 4 rows + const txt = 'a'.repeat(160) + const rows = estimateRenderedRows(txt, 40) + if (rows !== 4) + throw new Error(`expected 4, got ${rows}`) +}) + +test('estimateRenderedRows ignores ANSI codes for length', () => { + // 'hello' with red color = 5 visible chars, should be 1 row at width 80 + const txt = `\x1b[31mhello\x1b[0m` + const rows = estimateRenderedRows(txt, 80) + if (rows !== 1) + throw new Error(`expected 1, got ${rows}`) +}) + +test('isAiAnalysisTooTall: empty text → false', () => { + if (isAiAnalysisTooTall('', 30, 80) !== false) + throw new Error('empty should fit') +}) + +test('isAiAnalysisTooTall: short text on a tall terminal → false (fits)', () => { + const txt = ['### Likely cause', '', 'Missing entitlement.'].join('\n') + if (isAiAnalysisTooTall(txt, 40, 80) !== false) + throw new Error('3 short lines on 40-row terminal should fit') +}) + +test('isAiAnalysisTooTall: many lines on a small terminal → true (overflows)', () => { + // 50 lines on a 24-row terminal — definitely doesn't fit + const txt = Array.from({ length: 50 }, (_, i) => `line ${i}`).join('\n') + if (isAiAnalysisTooTall(txt, 24, 80) !== true) + throw new Error('50 lines on 24-row terminal should NOT fit') +}) + +test('isAiAnalysisTooTall: borderline case errs on the side of scroll (conservative)', () => { + // 20 rows of text, 24-row terminal, 20-row chrome reserve → 4 rows budget. + // 20 > 4, must return true. + const txt = Array.from({ length: 20 }, (_, i) => `line ${i}`).join('\n') + if (isAiAnalysisTooTall(txt, 24, 80) !== true) + throw new Error('borderline should err toward true') +}) + +test('isAiAnalysisTooTall: very tall terminal accepts moderate analyses', () => { + // 10 rows of text, 60-row terminal — fits easily even with 20-row chrome + const txt = Array.from({ length: 10 }, (_, i) => `line ${i}`).join('\n') + if (isAiAnalysisTooTall(txt, 60, 80) !== false) + throw new Error('10 lines on 60-row terminal should fit') +}) + +test('isAiAnalysisTooTall: one very long wrapping line on narrow terminal → true', () => { + // One logical line, 800 chars, 40-col terminal → 20 wrapped rows + // 20 rows on 24-row terminal with 20-row chrome → 4 budget → overflows. + const txt = 'a'.repeat(800) + if (isAiAnalysisTooTall(txt, 24, 40) !== true) + throw new Error('long wrapping line should overflow') +}) + +// ── pickVisibleLines ───────────────────────────────────────────────────────── + +test('pickVisibleLines: empty input returns []', () => { + const out = pickVisibleLines([], 0, 10, 80) + if (out.length !== 0) + throw new Error('expected []') +}) + +test('pickVisibleLines: scrollOffset past end returns []', () => { + const out = pickVisibleLines(['a', 'b'], 5, 10, 80) + if (out.length !== 0) + throw new Error('expected []') +}) + +test('pickVisibleLines: returns at most viewportRows simple lines', () => { + const lines = ['a', 'b', 'c', 'd', 'e'] + const out = pickVisibleLines(lines, 0, 3, 80) + if (out.join(',') !== 'a,b,c') + throw new Error(`got ${out.join(',')}`) +}) + +test('pickVisibleLines: stops early when wrapped lines would overflow', () => { + // 80-char line wraps to 4 rows on a 20-col terminal. Viewport 5 rows → + // 4 (first line) + 1 (second short) = 5 fits exactly, then we stop. + // The third short line is dropped because rowsUsed already hit viewportRows. + const lines = ['x'.repeat(80), 'short', 'short'] + const out = pickVisibleLines(lines, 0, 5, 20) + if (out.length !== 2) + throw new Error(`expected 2 lines, got ${out.length}`) +}) + +test('pickVisibleLines: floor at one line even if it overflows by itself', () => { + // 200-char line wraps to 10 rows on 20-col terminal, viewport is 5 rows. + // We still include the line so the viewer isn't blank. + const lines = ['x'.repeat(200)] + const out = pickVisibleLines(lines, 0, 5, 20) + if (out.length !== 1) + throw new Error('expected 1 line as floor') +}) + +test('pickVisibleLines: starts from scrollOffset', () => { + const lines = ['a', 'b', 'c', 'd', 'e'] + const out = pickVisibleLines(lines, 2, 2, 80) + if (out.join(',') !== 'c,d') + throw new Error(`got ${out.join(',')}`) +}) + +// ── computeMaxScrollOffset ─────────────────────────────────────────────────── + +test('computeMaxScrollOffset: empty input is 0', () => { + if (computeMaxScrollOffset([], 5, 80) !== 0) + throw new Error('expected 0') +}) + +test('computeMaxScrollOffset: simple lines, viewport ≥ count → 0', () => { + // 5 short lines, viewport 10 — everything fits, max offset is 0 + const lines = ['a', 'b', 'c', 'd', 'e'] + if (computeMaxScrollOffset(lines, 10, 80) !== 0) + throw new Error('expected 0 when everything fits') +}) + +test('computeMaxScrollOffset: simple lines, viewport < count → packs from end', () => { + // 10 short lines, viewport 3 → max offset = 7 (lines 7,8,9 visible) + const lines = Array.from({ length: 10 }, (_, i) => `line ${i}`) + if (computeMaxScrollOffset(lines, 3, 80) !== 7) + throw new Error(`expected 7, got ${computeMaxScrollOffset(lines, 3, 80)}`) +}) + +test('computeMaxScrollOffset: long wrapping tail line counts for wrap', () => { + // Last line wraps to 3 rows on a 20-col terminal, viewport 5 → only 2 short + // tail lines + the wrapping line fit. Actually: tail line takes 3 rows, + // then 2 more short lines (1 row each) = 5 rows total. So 3 lines fit at + // the end → max offset = 10 - 3 = 7. + const lines = [ + ...Array.from({ length: 9 }, (_, i) => `line ${i}`), + 'y'.repeat(60), + ] + const got = computeMaxScrollOffset(lines, 5, 20) + if (got !== 7) + throw new Error(`expected 7, got ${got}`) +}) + +console.log(`\n${passed} passed, ${failed} failed`) +if (failed > 0) + process.exit(1) diff --git a/cli/test/test-ai-onboarding-mode.mjs b/cli/test/test-ai-onboarding-mode.mjs new file mode 100644 index 0000000000..f94e3ab460 --- /dev/null +++ b/cli/test/test-ai-onboarding-mode.mjs @@ -0,0 +1,167 @@ +#!/usr/bin/env node +// Tests for the caller-handled AI flow used by the Ink onboarding wizard: +// - runCapgoAiAnalysis (reads /tmp log, delegates to postAnalyzeRequest) +// - releaseCapturedLogs (best-effort cleanup wrapper) +// Plus a regression check that decideAnalyzeBehavior still returns the same +// matrix when the new aiAnalysisMode lives elsewhere — direct CLI invocation +// must not change behavior. +import { existsSync } from 'node:fs' +import { mkdir, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { + decideAnalyzeBehavior, + HARD_LOG_SIZE_LIMIT, + releaseCapturedLogs, + runCapgoAiAnalysis, +} from '../src/ai/analyze.ts' + +let passed = 0 +let failed = 0 + +function test(name, fn) { + return Promise.resolve() + .then(() => fn()) + .then(() => { console.log(`✅ ${name}`); passed++ }) + .catch((err) => { console.error(`❌ ${name}\n ${err.message}`); failed++ }) +} + +const TEST_DIR = join(tmpdir(), `capgo-ai-onboarding-test-${Date.now()}`) +await mkdir(TEST_DIR, { recursive: true }) +process.env.CAPGO_AI_LOG_BASE_DIR = TEST_DIR + +// ---- decideAnalyzeBehavior unchanged ---- +await test('decideAnalyzeBehavior matrix unchanged: interactive+flag → show_menu', () => { + if (decideAnalyzeBehavior({ isTTY: true, aiAnalyticsFlag: true }) !== 'show_menu') + throw new Error('regression') +}) +await test('decideAnalyzeBehavior matrix unchanged: interactive only → ask_then_menu', () => { + if (decideAnalyzeBehavior({ isTTY: true, aiAnalyticsFlag: false }) !== 'ask_then_menu') + throw new Error('regression') +}) +await test('decideAnalyzeBehavior matrix unchanged: CI+flag → auto_upload', () => { + if (decideAnalyzeBehavior({ isTTY: false, aiAnalyticsFlag: true }) !== 'auto_upload') + throw new Error('regression') +}) +await test('decideAnalyzeBehavior matrix unchanged: CI alone → skip', () => { + if (decideAnalyzeBehavior({ isTTY: false, aiAnalyticsFlag: false }) !== 'skip') + throw new Error('regression') +}) + +// ---- runCapgoAiAnalysis ---- +await test('runCapgoAiAnalysis reads the captured log and posts to /build/ai_analyze', async () => { + const jobId = `job-ok-${Date.now()}` + const logPath = join(TEST_DIR, `${jobId}.log`) + await writeFile(logPath, 'pretend xcode log line 1\npretend xcode log line 2\n') + + let captured = null + const origFetch = globalThis.fetch + globalThis.fetch = async (url, init) => { + captured = { url, init } + return new Response(JSON.stringify({ analysis: '### Likely cause\nsigning' }), { + status: 200, headers: { 'content-type': 'application/json' }, + }) + } + try { + const result = await runCapgoAiAnalysis({ + apiHost: 'https://api.test', + apikey: 'key', + jobId, + appId: 'com.test.app', + }) + if (result.kind !== 'ok') + throw new Error(`expected ok, got ${result.kind}`) + if (!result.analysis.includes('signing')) + throw new Error('analysis text not propagated') + if (!captured) + throw new Error('fetch was not called') + if (!captured.url.endsWith('/build/ai_analyze')) + throw new Error(`unexpected url ${captured.url}`) + const body = JSON.parse(captured.init.body) + if (body.jobId !== jobId) + throw new Error('jobId not forwarded') + if (!body.logs.includes('pretend xcode')) + throw new Error('log content not forwarded') + } + finally { + globalThis.fetch = origFetch + } +}) + +await test('runCapgoAiAnalysis returns too_big when log exceeds HARD_LOG_SIZE_LIMIT', async () => { + const jobId = `job-big-${Date.now()}` + const logPath = join(TEST_DIR, `${jobId}.log`) + // Write 1 byte over the limit. Using a Buffer keeps the test fast. + const buf = Buffer.alloc(HARD_LOG_SIZE_LIMIT + 1, 'x') + await writeFile(logPath, buf) + + let fetchCalled = false + const origFetch = globalThis.fetch + globalThis.fetch = async () => { + fetchCalled = true + return new Response('', { status: 500 }) + } + try { + const result = await runCapgoAiAnalysis({ + apiHost: 'https://api.test', + apikey: 'key', + jobId, + appId: 'com.test.app', + }) + if (result.kind !== 'too_big') + throw new Error(`expected too_big, got ${result.kind}`) + if (fetchCalled) + throw new Error('fetch should not be called for too-big logs') + } + finally { + globalThis.fetch = origFetch + } +}) + +await test('runCapgoAiAnalysis returns error when the captured log is missing', async () => { + let fetchCalled = false + const origFetch = globalThis.fetch + globalThis.fetch = async () => { + fetchCalled = true + return new Response('', { status: 500 }) + } + try { + const result = await runCapgoAiAnalysis({ + apiHost: 'https://api.test', + apikey: 'key', + jobId: `job-missing-${Date.now()}`, + appId: 'com.test.app', + }) + if (result.kind !== 'error') + throw new Error(`expected error, got ${result.kind}`) + if (fetchCalled) + throw new Error('fetch should not be called when log file is missing') + } + finally { + globalThis.fetch = origFetch + } +}) + +// ---- releaseCapturedLogs ---- +await test('releaseCapturedLogs deletes the captured log file', async () => { + const jobId = `job-release-${Date.now()}` + const logPath = join(TEST_DIR, `${jobId}.log`) + await writeFile(logPath, 'some content') + if (!existsSync(logPath)) + throw new Error('precondition: log file should exist') + + await releaseCapturedLogs(jobId) + + if (existsSync(logPath)) + throw new Error('log file should be deleted after releaseCapturedLogs') +}) + +await test('releaseCapturedLogs is best-effort when nothing exists', async () => { + // Should not throw on a missing file. + await releaseCapturedLogs(`job-noop-${Date.now()}`) +}) + +console.log(`\n${passed} passed, ${failed} failed`) +if (failed > 0) + process.exit(1) diff --git a/cli/tsconfig.json b/cli/tsconfig.json index 308023f0cb..af46250a33 100644 --- a/cli/tsconfig.json +++ b/cli/tsconfig.json @@ -4,6 +4,7 @@ "lib": [ "es2023" ], + "ignoreDeprecations": "6.0", "baseUrl": "src", "module": "ESNext", "moduleResolution": "Bundler",