diff --git a/package.json b/package.json index fc54a60..ce927be 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "profile:repl:scroll:cpu": "bun build/replProfile.mjs cpu scroll", "profile:repl:long-history:cpu": "bun build/replProfile.mjs cpu long-history", "profile:repl:long-history:heap": "bun build/replProfile.mjs heap long-history", - "test:contracts": "bun test src/QueryEngine.test.ts src/utils/sessionLifecycle.test.ts src/utils/startupPrefetch.test.ts src/utils/startupPrefetchPolicy.test.ts src/utils/citcWorkspaceSource.test.ts src/utils/crypto.test.ts src/utils/envUtils.test.ts src/utils/debug.test.ts src/utils/json.test.ts src/utils/glob.test.ts src/utils/glob-defaults.test.ts src/utils/themeOnboardingOutput.test.ts src/utils/startupPromptOutput.test.ts src/utils/claudemdRepoBoundary.test.ts src/utils/fullscreen.tmux.test.ts src/constants/repoInspectionPrompt.test.ts src/constants/repoExposureAudit.test.ts src/constants/product.test.ts src/constants/productIdentityAudit.test.ts src/services/api/openAICompatWsV2Native.test.ts src/services/api/inferenceRequestShapeModeContract.test.ts src/services/api/claude.verifyApiKey.test.ts src/remote/runtimeMatrix.test.ts src/ink/events/input-event.test.ts src/keybindings/resolver.test.ts src/tools/REPLTool/constants.test.ts src/tools/BashTool/BashTool.test.ts src/tools/GrepTool/GrepTool.budget-contract.test.ts src/tools/toolPolicy.test.ts src/tools/toolPromptRouting.test.ts src/tools/FileWriteTool/UI.test.tsx src/components/permissions/FileWritePermissionRequest/FileWriteToolDiff.test.tsx src/ink/useTerminalViewport.test.ts src/commands/repaint/repaint.test.ts", + "test:contracts": "bun test src/QueryEngine.test.ts src/utils/sessionLifecycle.test.ts src/utils/startupPrefetch.test.ts src/utils/startupPrefetchPolicy.test.ts src/utils/citcWorkspaceSource.test.ts src/utils/crypto.test.ts src/utils/envUtils.test.ts src/utils/debug.test.ts src/utils/json.test.ts src/utils/glob.test.ts src/utils/glob-defaults.test.ts src/utils/themeOnboardingOutput.test.ts src/utils/startupPromptOutput.test.ts src/utils/claudemdRepoBoundary.test.ts src/utils/fullscreen.tmux.test.ts src/utils/staticRender.test.tsx src/components/Feedback.submit.test.ts src/constants/repoInspectionPrompt.test.ts src/constants/repoExposureAudit.test.ts src/constants/product.test.ts src/constants/productIdentityAudit.test.ts src/services/api/openAICompatWsV2Native.test.ts src/services/api/inferenceRequestShapeModeContract.test.ts src/services/api/claude.verifyApiKey.test.ts src/remote/runtimeMatrix.test.ts src/ink/events/input-event.test.ts src/keybindings/resolver.test.ts src/tools/REPLTool/constants.test.ts src/tools/BashTool/BashTool.test.ts src/tools/GrepTool/GrepTool.budget-contract.test.ts src/tools/toolPolicy.test.ts src/tools/toolPromptRouting.test.ts src/tools/FileWriteTool/UI.test.tsx src/components/permissions/FileWritePermissionRequest/FileWriteToolDiff.test.tsx src/ink/useTerminalViewport.test.ts src/commands/repaint/repaint.test.ts src/commands/export/export.test.tsx", "test:inference-contracts": "bun test src/services/api/openAICompatInferenceClient.test.ts src/services/api/openAICompatInferenceReceiptReplay.test.ts", "test:snapshots": "bun test src/ink/replVisibleScreenContract.test.tsx src/ink/replTranscriptScreenContract.test.tsx src/ink/replToolTranscriptScreenContract.test.tsx src/ink/replToolResultMountedContract.test.tsx src/components/messages/messagesToolResultTranscriptContract.test.tsx src/components/ThemePicker.renderSnapshot.test.tsx src/components/LogoV2.renderSnapshot.test.tsx src/components/TeleportProgress.renderSnapshot.test.tsx src/components/ResumeTask.renderSnapshot.test.tsx src/components/TeleportResumeWrapper.renderSnapshot.test.tsx src/ink/replSubmitAssistantTurn.test.tsx", "replay:openai-compat-receipts": "bun build/openAICompatReceiptReplay.mjs", diff --git a/src/commands/export/export.test.tsx b/src/commands/export/export.test.tsx new file mode 100644 index 0000000..e6f0f9f --- /dev/null +++ b/src/commands/export/export.test.tsx @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { runWithCwdOverride } from '../../utils/cwd.js' +import { createUserMessage } from '../../utils/messages.js' +import { call } from './export.js' + +async function withTimeout(promise: Promise, ms: number): Promise { + let timer: ReturnType | undefined + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`Timed out after ${ms}ms`)), + ms, + ) + }), + ]) + } finally { + if (timer) clearTimeout(timer) + } +} + +describe('/export command', () => { + const tempDirs: string[] = [] + + afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop() + if (dir) rmSync(dir, { recursive: true, force: true }) + } + }) + + test('writes filename exports without hanging', async () => { + const cwd = mkdtempSync(join(tmpdir(), 'ncode-export-')) + tempDirs.push(cwd) + const doneMessages: string[] = [] + const targetPath = join(cwd, 'conversation.txt') + + const node = await runWithCwdOverride(cwd, () => + withTimeout( + call( + message => { + if (message) doneMessages.push(message) + }, + { + messages: [createUserMessage({ content: 'hello export' })], + options: { tools: [] }, + } as never, + 'conversation.txt', + ), + 1000, + ), + ) + + expect(node).toBeNull() + expect(doneMessages).toEqual([`Conversation exported to: ${targetPath}`]) + expect(existsSync(targetPath)).toBe(true) + expect(readFileSync(targetPath, 'utf8')).toContain('hello export') + }) +}) diff --git a/src/commands/export/export.tsx b/src/commands/export/export.tsx index c47f5cf..040d9ef 100644 --- a/src/commands/export/export.tsx +++ b/src/commands/export/export.tsx @@ -1,6 +1,5 @@ import { join } from 'path'; import React from 'react'; -import { ExportDialog } from '../../components/ExportDialog.js'; import type { ToolUseContext } from '../../Tool.js'; import type { LocalJSXCommandOnDone } from '../../types/command.js'; import type { Message } from '../../types/message.js'; @@ -84,6 +83,7 @@ export async function call(onDone: LocalJSXCommandOnDone, context: ToolUseContex } // Return the dialog component when no args provided + const { ExportDialog } = await import('../../components/ExportDialog.js'); return { onDone(result.message); }} />; diff --git a/src/components/Feedback.submit.test.ts b/src/components/Feedback.submit.test.ts new file mode 100644 index 0000000..36c73be --- /dev/null +++ b/src/components/Feedback.submit.test.ts @@ -0,0 +1,152 @@ +import axios from 'axios' +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { getAuthRuntime } from '../auth/runtime/AuthRuntime.js' +import { OAUTH_BETA_HEADER } from '../constants/oauth.js' +import type { ResolvedAuthSession } from '../auth/runtime/types.js' +import { submitFeedback, type FeedbackData } from './Feedback.js' + +const originalAxiosPost = axios.post +const originalMacro = globalThis.MACRO +const originalPlatformBaseUrl = process.env.NOUMENA_PLATFORM_BASE_URL +const originalDisableNonessentialTraffic = process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC + +function makeSession( + overrides: Partial = {}, +): ResolvedAuthSession { + return { + principalKind: 'noumena_account', + principalSource: 'managed_oauth', + sessionState: 'usable', + headersKind: 'bearer', + providerAuthKind: 'noumena_first_party', + providerPlan: { + mode: 'noumena_managed', + source: 'managed_principal', + staticKeyEnvVarName: null, + }, + isInteractive: true, + canRefresh: true, + canReauthenticateInteractively: true, + identity: { + email: 'user@noumena.net', + accountUuid: 'acct-1', + organizationUuid: 'org-1', + organizationName: 'Noumena', + }, + subscription: { + subscriptionName: 'Noumena Max', + subscriptionType: 'max', + rateLimitTier: 'tier-1', + }, + scopes: ['user:inference'], + hasUsableToken: true, + hasUsableApiKey: false, + accessToken: 'managed-token', + accessTokenExpiresAt: Date.now() + 60_000, + refreshTokenPresent: true, + apiKey: null, + rawAuthTokenSource: 'noumena.com', + rawApiKeySource: null, + recoveryAction: 'none', + recoveryMessage: null, + sourceDetails: { + usedLegacyCompat: false, + usedEnvVar: false, + usedFileDescriptor: false, + usedHelper: false, + }, + ...overrides, + } +} + +function makeFeedbackData(): FeedbackData { + return { + latestAssistantMessageId: null, + message_count: 0, + datetime: '2026-07-01T00:00:00.000Z', + description: 'export hangs and feedback failed', + platform: 'linux', + gitRepo: false, + version: '0.0.0-test', + transcript: [], + } +} + +describe('submitFeedback', () => { + const runtime = getAuthRuntime() as { + resolveSession: (options?: { allowRefresh?: boolean }) => Promise + getCurrentSession: () => ResolvedAuthSession + } + const originalResolveSession = runtime.resolveSession + const originalGetCurrentSession = runtime.getCurrentSession + + beforeEach(() => { + process.env.NOUMENA_PLATFORM_BASE_URL = 'https://api.noumena.test' + delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC + ;(globalThis as { MACRO?: Record }).MACRO = { + VERSION: '0.0.0-test', + } + runtime.resolveSession = mock(async () => makeSession()) + runtime.getCurrentSession = mock(() => makeSession()) + axios.post = originalAxiosPost + }) + + afterEach(() => { + if (originalPlatformBaseUrl === undefined) { + delete process.env.NOUMENA_PLATFORM_BASE_URL + } else { + process.env.NOUMENA_PLATFORM_BASE_URL = originalPlatformBaseUrl + } + if (originalDisableNonessentialTraffic === undefined) { + delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC + } else { + process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = originalDisableNonessentialTraffic + } + ;(globalThis as { MACRO?: unknown }).MACRO = originalMacro + runtime.resolveSession = originalResolveSession + runtime.getCurrentSession = originalGetCurrentSession + axios.post = originalAxiosPost + mock.restore() + }) + + test('submits to the platform feedback collector with managed auth headers', async () => { + let capturedRequest: + | { + url: string + body: unknown + options?: { headers?: Record } + } + | null = null + const feedbackData = makeFeedbackData() + + axios.post = (async (url: string, body: unknown, options?: { headers?: Record }) => { + capturedRequest = { url, body, options } + return { status: 200, data: { feedback_id: 'fb-1' } } + }) as typeof axios.post + + expect(await submitFeedback(feedbackData)).toEqual({ + success: true, + feedbackId: 'fb-1', + }) + expect(capturedRequest?.url).toBe('https://api.noumena.test/api/ncode_feedback') + expect(capturedRequest?.body).toEqual({ content: JSON.stringify(feedbackData) }) + expect(capturedRequest?.options?.headers).toMatchObject({ + Authorization: 'Bearer managed-token', + 'anthropic-beta': OAUTH_BETA_HEADER, + }) + expect(runtime.resolveSession).toHaveBeenCalledWith({ allowRefresh: true }) + }) + + test('fails closed when the collector route is missing', async () => { + axios.post = (async () => { + throw { + isAxiosError: true, + response: { status: 404, data: 'not found' }, + } + }) as typeof axios.post + + expect(await submitFeedback(makeFeedbackData())).toEqual({ + success: false, + }) + }) +}) diff --git a/src/components/Feedback.tsx b/src/components/Feedback.tsx index a8fc123..ca1c54c 100644 --- a/src/components/Feedback.tsx +++ b/src/components/Feedback.tsx @@ -52,7 +52,7 @@ type Props = { }; }; type Step = 'userInput' | 'consent' | 'submitting' | 'done'; -type FeedbackData = { +export type FeedbackData = { // latestAssistantMessageId is the message ID from the latest main model call latestAssistantMessageId: string | null; message_count: number; @@ -248,7 +248,7 @@ export function Feedback({ // Stay on userInput step so user can retry with their content preserved setStep('userInput'); } - }, [description, envInfo.isGit, messages]); + }, [backgroundTasks, description, envInfo.isGit, messages]); // Handle cancel - this will be called by Dialog's automatic Esc handling const handleCancel = useCallback(() => { @@ -258,6 +258,7 @@ export function Feedback({ onDone('Error submitting feedback / bug report', { display: 'system' }); + } else { onDone('Feedback / bug report submitted', { display: 'system' @@ -288,6 +289,7 @@ export function Feedback({ onDone('Error submitting feedback / bug report', { display: 'system' }); + } else { onDone('Feedback / bug report submitted', { display: 'system' @@ -516,7 +518,7 @@ function sanitizeAndLogError(err: unknown): void { logError(new Error(errorString)); } } -async function submitFeedback(data: FeedbackData, signal?: AbortSignal): Promise<{ +export async function submitFeedback(data: FeedbackData, signal?: AbortSignal): Promise<{ success: boolean; feedbackId?: string; isZdrOrg?: boolean; @@ -538,7 +540,7 @@ async function submitFeedback(data: FeedbackData, signal?: AbortSignal): Promise 'User-Agent': getUserAgent(), ...authResult.headers }; - const response = await axios.post(buildNoumenaPlatformUrl('/api/claude_cli_feedback'), { + const response = await axios.post(buildNoumenaPlatformUrl('/api/ncode_feedback'), { content: jsonStringify(data) }, { headers, diff --git a/src/ink/ink.tsx b/src/ink/ink.tsx index 4230bb8..d9baf21 100644 --- a/src/ink/ink.tsx +++ b/src/ink/ink.tsx @@ -98,7 +98,7 @@ export default class Ink { private readonly stylePool: StylePool; private charPool: CharPool; private hyperlinkPool: HyperlinkPool; - private exitPromise?: Promise; + private exitPromise: Promise; private restoreConsole?: () => void; private restoreStderr?: () => void; private readonly unsubscribeTTYHandlers?: () => void; @@ -202,6 +202,10 @@ export default class Ink { } | null = null; constructor(private readonly options: Options) { autoBind(this); + this.exitPromise = new Promise((resolve, reject) => { + this.resolveExitPromise = resolve; + this.rejectExitPromise = reject; + }); if (this.options.patchConsole) { this.restoreConsole = this.patchConsole(); this.restoreStderr = this.patchStderr(); @@ -1685,10 +1689,6 @@ export default class Ink { } } async waitUntilExit(): Promise { - this.exitPromise ||= new Promise((resolve, reject) => { - this.resolveExitPromise = resolve; - this.rejectExitPromise = reject; - }); return this.exitPromise; } resetLineCount(): void { diff --git a/src/ink/log-update.ts b/src/ink/log-update.ts index e2b9875..638a804 100644 --- a/src/ink/log-update.ts +++ b/src/ink/log-update.ts @@ -23,6 +23,7 @@ import { import { analyzeFillableSpaceGap, type Cell, + cellAt, CellWidth, diffEach, createScreen, diff --git a/src/tools/toolPromptRouting.test.ts b/src/tools/toolPromptRouting.test.ts index 5f96615..fa08205 100644 --- a/src/tools/toolPromptRouting.test.ts +++ b/src/tools/toolPromptRouting.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it, mock } from 'bun:test' process.env.NOUMENA_API_KEY ??= 'test-key-for-hermetic-contracts' +const actualModel = await import(import.meta.resolve('../utils/model/model.ts')) + mock.module(import.meta.resolve('../utils/model/model.js'), () => ({ + ...actualModel, getMainLoopModel: () => 'claude-sonnet-4-6', getSmallFastModel: () => 'claude-haiku-4-5', })) diff --git a/src/utils/staticRender.test.tsx b/src/utils/staticRender.test.tsx new file mode 100644 index 0000000..a694d54 --- /dev/null +++ b/src/utils/staticRender.test.tsx @@ -0,0 +1,32 @@ +import { describe, expect, test } from 'bun:test' +import React from 'react' +import { Text } from '../ink.js' +import { renderToString } from './staticRender.js' + +async function withTimeout(promise: Promise, ms: number): Promise { + let timer: ReturnType | undefined + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`Timed out after ${ms}ms`)), + ms, + ) + }), + ]) + } finally { + if (timer) clearTimeout(timer) + } +} + +describe('static renderer', () => { + test('resolves after the render tree exits', async () => { + const output = await withTimeout( + renderToString(static render complete, 80), + 1000, + ) + + expect(output).toContain('static render complete') + }) +})