diff --git a/config/scripts/ssh-windows-hosts-workflow.test.mjs b/config/scripts/ssh-windows-hosts-workflow.test.mjs deleted file mode 100644 index 80ae3c055af..00000000000 --- a/config/scripts/ssh-windows-hosts-workflow.test.mjs +++ /dev/null @@ -1,230 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join, resolve } from 'node:path' -import { describe, expect, it } from 'vitest' -import { parse } from 'yaml' -import { - WINDOWS_FORBIDDEN_TOOLS, - WINDOWS_HOST_CELL_IDS, - WINDOWS_CLI_MATRIX_CELL_IDS, - WINDOWS_CONVERT_CELL_ID, - WINDOWS_ORCAD_CELL_IDS -} from '../../src/main/ssh/ssh-windows-host-cells.ts' - -const projectDir = resolve(import.meta.dirname, '../..') -const workflow = parse( - readFileSync(join(projectDir, '.github/workflows/ssh-windows-hosts.yml'), 'utf8') -) -const job = workflow.jobs.hosts -const runStep = job.steps.find((step) => step.name?.startsWith('Run the Windows host cells')) -const provisioning = readFileSync( - join(projectDir, 'config/ci/windows-ssh-provider/preview-ssh/prove-preview-openssh.ps1'), - 'utf8' -) -const capability = readFileSync( - join(projectDir, 'config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1'), - 'utf8' -) -const manifest = (arch) => - JSON.parse( - readFileSync( - join( - projectDir, - `config/ci/windows-ssh-provider/preview-ssh/preview-native-inputs-${arch}.json` - ), - 'utf8' - ) - ) - -describe('SSH Windows-host workflow', () => { - it('runs on demand and on path-filtered, non-draft pull requests only', () => { - expect(Object.keys(workflow.on).sort()).toEqual(['pull_request', 'workflow_dispatch']) - const paths = workflow.on.pull_request.paths - expect(paths).toContain('src/main/ssh/ssh-relay-*') - expect(paths).toContain('config/ci/windows-ssh-provider/**') - expect(paths.indexOf('src/main/ssh/ssh-relay-windows-host-lane.test.ts')).toBeGreaterThan( - paths.indexOf('!src/**/*.test.ts') - ) - // A later glob would re-include unit tests the Windows lane never runs. - for (const glob of paths.slice(paths.indexOf('!src/**/*.test.ts') + 1)) { - expect(glob.startsWith('src/') ? glob.endsWith('.test.ts') : true, glob).toBe(true) - } - expect(job.if).toContain('github.event.pull_request.draft != true') - }) - - it('covers inbox and preview OpenSSH on both Windows architectures', () => { - const cells = job.strategy.matrix.include.map(({ arch, runner, server }) => - [arch, runner, server].join('/') - ) - expect(cells.sort()).toEqual([ - 'arm64/windows-11-arm/inbox', - 'arm64/windows-11-arm/preview', - 'x64/windows-2022/inbox', - 'x64/windows-2022/preview' - ]) - expect(job.env).toMatchObject({ - ORCA_BACKGROUND_LAUNCH: '1', - ORCA_ISOLATED_SSH_CI: '1' - }) - expect(job.strategy['fail-fast']).toBe(false) - // The CLI matrix cells, dispatched by name only, get a longer budget. - expect(job['timeout-minutes']).toBe( - "${{ contains(github.event.inputs.cells || '', 'orcad-cli') && 160 || 75 }}" - ) - expect(runStep['timeout-minutes']).toBe( - "${{ contains(github.event.inputs.cells || '', 'orcad-cli') && 130 || 50 }}" - ) - }) - - it('overlaps only guarded ARM inbox capability preparation with the existing builds', () => { - const selfTestIndex = job.steps.findIndex((step) => step.name?.startsWith('Self-test')) - const prepareIndex = job.steps.findIndex((step) => step.id === 'inbox-capability') - const installIndex = job.steps.findIndex( - (step) => step.uses === './.github/actions/install-node-dependencies' - ) - const buildIndex = job.steps.findIndex((step) => step.name?.startsWith('Build this runner')) - const prebuildIndex = job.steps.findIndex( - (step) => step.uses === './.github/actions/prepare-orcad-prebuilds' - ) - const templateIndex = job.steps.findIndex((step) => step.name?.startsWith('Build the win32')) - const waitIndex = job.steps.findIndex((step) => step.wait === 'inbox-capability') - const runIndex = job.steps.indexOf(runStep) - expect([ - selfTestIndex, - prepareIndex, - installIndex, - buildIndex, - prebuildIndex, - templateIndex, - waitIndex, - runIndex - ]).toEqual([1, 2, 3, 4, 5, 6, 7, 8]) - expect(job.steps[prepareIndex]).toMatchObject({ - background: true, - shell: 'pwsh' - }) - expect(job.steps[prepareIndex].if).toBeUndefined() - expect(job.steps[waitIndex].if).toBeUndefined() - expect(job.steps[prepareIndex].run.trim()).toMatch( - /^if\('\$\{\{ matrix\.server }}' -eq 'inbox' -and '\$\{\{ matrix\.arch }}' -eq 'arm64'\)\{[\s\S]+\}$/ - ) - expect(job.steps[prepareIndex].run).toContain('Initialize-WindowsInboxSshCapability') - expect(job.steps[prepareIndex].run).toContain('inbox-capability-preparation.json') - expect(runStep.run).toContain('-InboxPreparationReceipt $preparation') - expect(runStep.run).toContain( - "$preparation=Join-Path $receipts 'inbox-capability-preparation.json'" - ) - expect(runStep.run).toContain( - "if('${{ matrix.server }}' -eq 'inbox' -and '${{ matrix.arch }}' -eq 'arm64'){$preparation=" - ) - expect(runStep.background).toBeUndefined() - expect(job.steps.at(-1)).toMatchObject({ - if: 'always()', - uses: 'actions/upload-artifact@v7' - }) - }) - - it('shares one capability installer without bypassing native verification or private cleanup', () => { - expect(capability.match(/Add-WindowsCapability -Online/g)).toHaveLength(1) - expect(provisioning).not.toContain('Add-WindowsCapability') - expect(provisioning).toContain(". (Join-Path $PSScriptRoot 'windows-ssh-capability.ps1')") - expect(provisioning).toContain('Install-WindowsInboxSshCapability $Arch $report') - const install = capability.slice( - capability.indexOf('function Install-WindowsInboxSshCapability'), - capability.indexOf('function Initialize-WindowsInboxSshCapability') - ) - const mutation = install.indexOf('Add-WindowsCapability') - expect(install.indexOf('Assert-IsolatedWindowsSshCi')).toBeLessThan(mutation) - expect(install.indexOf('Assert-WindowsSshGlobalServerDormant')).toBeLessThan(mutation) - expect(install.lastIndexOf('Assert-WindowsSshGlobalServerDormant')).toBeGreaterThan(mutation) - expect(install.indexOf('Assert-WindowsSshStockShell')).toBeLessThan(mutation) - expect(install.lastIndexOf('Assert-WindowsSshStockShell')).toBeGreaterThan(mutation) - for (const check of [ - '(Machine $path) -ne $target.machine', - 'Get-AuthenticodeSignature -LiteralPath $path', - 'Inbox native input Microsoft signature invalid', - "Write-Stage 'host-cell-probe-start'", - "Write-Stage 'cleanup-default-shell-start'", - "Write-Stage 'cleanup-service-stop-delete-start'" - ]) { - expect(provisioning).toContain(check) - } - }) - - it('keeps preparation provenance separate from the oracle current capability state', () => { - for (const [field, environment] of [ - ['sourceSha', 'GITHUB_SHA'], - ['runId', 'GITHUB_RUN_ID'], - ['runAttempt', 'GITHUB_RUN_ATTEMPT'], - ['runnerName', 'RUNNER_NAME'], - ['imageVersion', 'ImageVersion'] - ]) { - expect(capability).toContain(`${field}=$env:${environment}`) - expect(provisioning).toContain(`$preparation.${field} -ne $env:${environment}`) - } - expect(provisioning).toContain("$preparation.status -ne 'passed'") - expect(provisioning).toContain('$preparation.arch -ne $Arch') - expect(provisioning).toContain('$report.inboxCapabilityPreparation=$preparation') - expect(capability).toContain('$Report.inboxCapabilityInitialState=[string]$capability.State') - expect(capability).toContain("$report.status='failed';$report.error=$_.Exception.Message;throw") - }) - - it('fetches the preview release its hash manifests pin', () => { - for (const arch of ['x64', 'arm64']) { - expect(runStep.run).toContain( - `https://github.com/PowerShell/Win32-OpenSSH/releases/download/${manifest(arch).release}/` - ) - } - }) - - it('builds the template for this runner only and shims exactly the cell toolchain list', () => { - const build = job.steps.map((step) => step.run ?? '').join('\n') - expect(build).toContain('--targets "win32-${{ matrix.arch }}"') - expect(build).toContain('pnpm run build:relay') - const shimmed = /-HiddenTools @\(([^)]*)\)/.exec(runStep.run)?.[1] - expect(shimmed?.split(',').map((tool) => tool.trim().replaceAll("'", ''))).toEqual([ - ...WINDOWS_FORBIDDEN_TOOLS - ]) - }) - - it('defaults to every cell the TypeScript lane knows', () => { - const defaults = /\{\$cells=@\(([^)]*)\)\}/.exec(runStep.run)?.[1] - expect(defaults?.split(',').map((id) => id.trim().replaceAll("'", ''))).toEqual([ - ...WINDOWS_HOST_CELL_IDS, - ...WINDOWS_ORCAD_CELL_IDS - ]) - const invoker = readFileSync( - join(projectDir, 'config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1'), - 'utf8' - ) - for (const id of [...WINDOWS_HOST_CELL_IDS, ...WINDOWS_ORCAD_CELL_IDS]) { - expect(invoker).toContain(`'${id}'`) - } - expect(invoker).toContain('src/main/ssh/orcad-windows-host-lane.test.ts') - }) - - it('provisions one private account for every cell, convert cell included', () => { - expect(runStep.run).toContain(`$cells+='${WINDOWS_CONVERT_CELL_ID}'`) - // Only app cells reach a managed server, through an SSH local forward; they run last. - const appCells = /\$appCellIds=@\(([^)]*)\)/.exec(runStep.run)?.[1] - expect(appCells?.split(',').map((id) => id.trim().replaceAll("'", ''))).toEqual([ - WINDOWS_CONVERT_CELL_ID, - ...WINDOWS_CLI_MATRIX_CELL_IDS - ]) - expect(runStep.run).toContain( - '$forwarding=@($cells | Where-Object {$appCellIds -contains $_}).Count' - ) - expect(runStep.run).toContain('-ForwardingAccounts $forwarding') - // A dispatched list may name them anywhere; they still run last. - expect(runStep.run).toContain( - '$cells=@($cells | Where-Object {$appCellIds -notcontains $_})+@($cells | Where-Object {$appCellIds -contains $_})' - ) - const provisioner = readFileSync( - join(projectDir, 'config/ci/windows-ssh-provider/preview-ssh/prove-preview-openssh.ps1'), - 'utf8' - ) - const max = Number(/\[ValidateRange\(1,(\d+)\)\]\[int\]\$Accounts/.exec(provisioner)?.[1]) - expect(max).toBeGreaterThanOrEqual( - WINDOWS_HOST_CELL_IDS.length + WINDOWS_ORCAD_CELL_IDS.length + 1 - ) - }) -}) diff --git a/config/scripts/verify-localization-extraction.mjs b/config/scripts/verify-localization-extraction.mjs index d3334a1d304..5cb92480397 100644 --- a/config/scripts/verify-localization-extraction.mjs +++ b/config/scripts/verify-localization-extraction.mjs @@ -1,12 +1,9 @@ -import { execFile } from 'node:child_process' import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import process from 'node:process' -import { promisify } from 'node:util' import { pathToFileURL } from 'node:url' -const execFileAsync = promisify(execFile) const EN_CATALOG_PATH = path.join('src', 'renderer', 'src', 'i18n', 'locales', 'en.json') const PLACEHOLDER_RE = /\{\{[^}]+\}\}/g @@ -82,17 +79,50 @@ async function extractToTemporaryCatalog(root, tempDir) { const outputPattern = path.join(tempDir, '{{language}}.json') // Why: extraction output is evidence for this check, not another committed // catalog that feature authors must keep synchronized. - await execFileAsync( - process.execPath, - [cliPath, '--config', 'config/i18next.config.ts', 'extract', '--sync-primary', '--quiet'], - { + const { runProcessSync, describeProcessFailure } = await import('./script-child-process.mjs') + let program = process.execPath + try { + const version = (await fs.readFile(path.join(root, 'config', '.bun-version'), 'utf8')).trim() + const probe = runProcessSync({ + program: 'bun', + args: [ + '-e', + 'console.log(JSON.stringify({ version: process.versions.bun, executable: process.execPath }))' + ], cwd: root, - env: { - ...process.env, - ORCA_I18N_EXTRACTION_OUTPUT: outputPattern.split(path.sep).join('/') - } + env: process.env, + timeoutMs: 2000, + maxOutputBytes: 4096 + }) + const identity = JSON.parse(probe.stdout) + if ( + probe.code === 0 && + !probe.signal && + !probe.timedOut && + !probe.outputTruncated && + identity.version === version && + typeof identity.executable === 'string' && + path.isAbsolute(identity.executable) + ) { + program = identity.executable } - ) + } catch { + // Missing or unusable optional Bun keeps the existing parent runtime. + } + const result = runProcessSync({ + program, + args: [cliPath, '--config', 'config/i18next.config.ts', 'extract', '--sync-primary', '--quiet'], + cwd: root, + env: { + ...process.env, + ORCA_I18N_EXTRACTION_OUTPUT: outputPattern.split(path.sep).join('/') + }, + timeoutMs: null, + maxOutputBytes: 1024 * 1024 + }) + if (result.code !== 0 || result.signal || result.timedOut || result.outputTruncated) { + throw new Error(`Localization extraction failed: ${describeProcessFailure(result)}`) + } return JSON.parse(await fs.readFile(path.join(tempDir, 'en.json'), 'utf8')) } diff --git a/mobile/src/reanimated-web-mapper-deps.test.ts b/mobile/src/reanimated-web-mapper-deps.test.ts deleted file mode 100644 index ed8e0a6e6b2..00000000000 --- a/mobile/src/reanimated-web-mapper-deps.test.ts +++ /dev/null @@ -1,316 +0,0 @@ -import { readFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' -import { extname, join, relative } from 'node:path' -import ts from 'typescript' -import { describe, expect, it } from 'vitest' -import { censusSourceFiles } from './test-support/census-source-files' - -const mobileDirectory = fileURLToPath(new URL('..', import.meta.url)) -const scanned = ['src', 'app'] -const sourceExtensions = new Set(['.ts', '.tsx']) - -/** - * Reanimated hooks whose updater becomes a mapper, and which therefore need to know which shared - * values the updater reads. - * - * On native the Babel plugin writes `updater.__closure` and Reanimated reads the inputs off it. - * The mobile web bundle is built by esbuild (config/scripts/build-mobile-web-app-bundle.mjs), which - * runs no Babel, so `__closure` is undefined and `inputs` falls back to the dependency array — - * and with neither, `startMapper` registers a mapper that listens to nothing. It runs once and - * never again, freezing whatever the first frame wrote. That is silent: the throw Reanimated has - * for this case is behind `__DEV__`, which the bundle builds out. - */ -const MAPPER_HOOKS = new Map([ - ['useAnimatedStyle', { updaters: [0], dependencies: 1 }], - ['useAnimatedProps', { updaters: [0], dependencies: 1 }], - ['useDerivedValue', { updaters: [0], dependencies: 1 }], - // Third argument, not second: `useAnimatedReaction(prepare, react, dependencies)`. Both - // callbacks run inside the one mapper it starts (hook/useAnimatedReaction.js:38-50), so both - // are updaters. - ['useAnimatedReaction', { updaters: [0, 1], dependencies: 2 }] -]) - -/** Whether this `X.value` is being written rather than read. A write is an output, not an input. */ -function isWriteTarget(node: ts.PropertyAccessExpression): boolean { - const parent = node.parent - if (ts.isBinaryExpression(parent) && parent.left === node) { - // `=` through `??=`: every assignment operator sits in this one contiguous token range. - return ( - parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && - parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment - ) - } - if (ts.isPrefixUnaryExpression(parent)) { - // Only `++x` and `--x` mutate. `!x.value`, `-x.value`, `+x.value` and `~x.value` are reads, - // and taking every prefix operator for a write dropped those from the array's requirement. - return ( - parent.operator === ts.SyntaxKind.PlusPlusToken || - parent.operator === ts.SyntaxKind.MinusMinusToken - ) - } - // Postfix has no other operators: `x.value++` and `x.value--` are the whole set. - return ts.isPostfixUnaryExpression(parent) -} - -/** - * What each local name in this file means, for the hooks above, resolved through its imports. - * - * Matching on the callee's spelling would both miss and invent: `useAnimatedStyle as useAS` and - * `Reanimated.useAnimatedStyle` are the same hook under another name, and a local helper that - * happens to be called `useDerivedValue` is not this hook at all. Returns the local identifiers - * bound to each hook, plus the namespace names a member access has to go through. - */ -function reanimatedBindings(sourceFile: ts.SourceFile): { - byLocalName: Map - namespaces: Set -} { - const byLocalName = new Map() - const namespaces = new Set() - for (const statement of sourceFile.statements) { - if ( - !ts.isImportDeclaration(statement) || - !ts.isStringLiteral(statement.moduleSpecifier) || - statement.moduleSpecifier.text !== 'react-native-reanimated' - ) { - continue - } - const bindings = statement.importClause?.namedBindings - if (bindings && ts.isNamespaceImport(bindings)) { - namespaces.add(bindings.name.text) - } - if (bindings && ts.isNamedImports(bindings)) { - for (const element of bindings.elements) { - const imported = element.propertyName?.text ?? element.name.text - if (MAPPER_HOOKS.has(imported)) { - byLocalName.set(element.name.text, imported) - } - } - } - // The default export is the `Animated` namespace object, which carries no hooks. - } - return { byLocalName, namespaces } -} - -/** The hook this callee names, or null when it is not one of ours. */ -function resolveHook( - callee: ts.Expression, - bindings: ReturnType -): string | null { - if (ts.isIdentifier(callee)) { - return bindings.byLocalName.get(callee.text) ?? null - } - if ( - ts.isPropertyAccessExpression(callee) && - ts.isIdentifier(callee.expression) && - bindings.namespaces.has(callee.expression.text) && - MAPPER_HOOKS.has(callee.name.text) - ) { - return callee.name.text - } - return null -} - -/** Every `X` in an `X.value` read under this node, which is what the mapper has to listen to. */ -function sharedValuesRead(updater: ts.Node): Set { - const names = new Set() - const visit = (node: ts.Node): void => { - if ( - ts.isPropertyAccessExpression(node) && - node.name.text === 'value' && - ts.isIdentifier(node.expression) && - !isWriteTarget(node) - ) { - names.add(node.expression.text) - } - ts.forEachChild(node, visit) - } - visit(updater) - return names -} - -/** The identifiers a dependency array lists, ignoring entries that are not plain names. */ -function namesListed(dependencies: ts.ArrayLiteralExpression): Set { - return new Set(dependencies.elements.filter(ts.isIdentifier).map((element) => element.text)) -} - -/** - * Every mapper-hook call that was not handed a dependency array, or was handed one that leaves a - * shared value out. - * - * The second half is the one an array alone does not give: `inputs` becomes exactly the array - * (hook/useAnimatedStyle.js:338-341), so a value the updater reads but the array omits is a value - * the mapper never listens to. That updater then stops re-running when only that value changes, - * which is the same freeze as having no array at all, in one prop instead of all of them. - */ -function callsMissingDependencies(path: string, source: string, found: string[] = []): string[] { - const sourceFile = ts.createSourceFile( - path, - source, - ts.ScriptTarget.Latest, - true, - extname(path) === '.tsx' ? ts.ScriptKind.TSX : ts.ScriptKind.TS - ) - const bindings = reanimatedBindings(sourceFile) - const missing: string[] = [] - const visit = (node: ts.Node): void => { - if (ts.isCallExpression(node)) { - const name = resolveHook(node.expression, bindings) - const hook = name === null ? undefined : MAPPER_HOOKS.get(name) - if (name !== null && hook) { - found.push(name) - const dependencies = node.arguments[hook.dependencies] - const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)) - const where = `${relative(mobileDirectory, path)}:${String(line + 1)} ${name}` - if (!dependencies) { - missing.push(where) - } else if (!ts.isArrayLiteralExpression(dependencies)) { - // An array built elsewhere counts as present: the hook only needs one to exist, and - // this file cannot see what a hoisted `const deps = [...]` holds. Completeness below - // therefore covers literal arrays only. - } else { - const listed = namesListed(dependencies) - const read = hook.updaters.flatMap((index) => { - const updater = node.arguments[index] - return updater ? [...sharedValuesRead(updater)] : [] - }) - for (const value of [...new Set(read)].sort()) { - if (!listed.has(value)) { - missing.push(`${where} omits ${value}`) - } - } - } - } - } - ts.forEachChild(node, visit) - } - visit(sourceFile) - return missing -} - -describe('reanimated mapper hooks in the web bundle', () => { - it('are all given a dependency array, because esbuild writes no worklet closure', () => { - const found: string[] = [] - const missing = scanned.flatMap((directory) => - censusSourceFiles(join(mobileDirectory, directory)) - .filter((path) => sourceExtensions.has(extname(path))) - .flatMap((path) => - path.endsWith('.test.ts') || path.endsWith('.test.tsx') - ? [] - : callsMissingDependencies(path, readFileSync(path, 'utf8'), found) - ) - ) - // The precondition the empty list above rests on. Binding resolution means a broken resolver - // reports nothing at all, which would read exactly like a clean tree. - expect(found.length).toBeGreaterThanOrEqual(5) - expect(missing).toEqual([]) - }) - - const FROM = "import { useAnimatedStyle, useAnimatedReaction } from 'react-native-reanimated'\n" - - it('finds a call with no dependency array, which is what makes the census above real', () => { - const found = callsMissingDependencies( - 'fixture.tsx', - `${FROM}const s = useAnimatedStyle(() => ({ opacity: progress.value }))\n` - ) - expect(found).toEqual(['fixture.tsx:2 useAnimatedStyle']) - }) - - it('reads useAnimatedReaction dependencies from its third argument, not its second', () => { - expect( - callsMissingDependencies( - 'fixture.tsx', - `${FROM}useAnimatedReaction(() => progress.value, (v) => { opacity.value = v })\n` - ) - ).toEqual(['fixture.tsx:2 useAnimatedReaction']) - expect( - callsMissingDependencies( - 'fixture.tsx', - `${FROM}useAnimatedReaction(() => progress.value, (v) => { opacity.value = v }, [progress])\n` - ) - ).toEqual([]) - }) - - it('names a shared value the updater reads but the array leaves out', () => { - const found = callsMissingDependencies( - 'fixture.tsx', - `${FROM}const s = useAnimatedStyle(() => ({ opacity: progress.value * fade.value }), [progress])\n` - ) - expect(found).toEqual(['fixture.tsx:2 useAnimatedStyle omits fade']) - }) - - it('still asks for one that is read and written', () => { - const found = callsMissingDependencies( - 'fixture.tsx', - `${FROM}const s = useAnimatedStyle(() => { offset.value = offset.value + 1; return {} }, [])\n` - ) - expect(found).toEqual(['fixture.tsx:2 useAnimatedStyle omits offset']) - }) - - it('still asks for a value read under a negation, which is not a write', () => { - const found = callsMissingDependencies( - 'fixture.tsx', - `${FROM}const s = useAnimatedStyle(() => ({ opacity: !hidden.value ? 1 : 0 }), [])\n` - ) - expect(found).toEqual(['fixture.tsx:2 useAnimatedStyle omits hidden']) - }) - - it('and one read under a unary minus', () => { - const found = callsMissingDependencies( - 'fixture.tsx', - `${FROM}const s = useAnimatedStyle(() => ({ top: -offset.value }), [])\n` - ) - expect(found).toEqual(['fixture.tsx:2 useAnimatedStyle omits offset']) - }) - - it('does not ask for one that is only incremented', () => { - const found = callsMissingDependencies( - 'fixture.tsx', - `${FROM}const s = useAnimatedStyle(() => { count.value++; return {} }, [])\n` - ) - expect(found).toEqual([]) - }) - - it('accepts one that has a dependency array', () => { - const found = callsMissingDependencies( - 'fixture.tsx', - `${FROM}const s = useAnimatedStyle(() => ({ opacity: progress.value }), [progress])\n` - ) - expect(found).toEqual([]) - }) - - it('sees the hook through an alias, which spelling alone would miss', () => { - const found = callsMissingDependencies( - 'fixture.tsx', - "import { useAnimatedStyle as useAS } from 'react-native-reanimated'\n" + - 'const s = useAS(() => ({ opacity: progress.value }))\n' - ) - expect(found).toEqual(['fixture.tsx:2 useAnimatedStyle']) - }) - - it('sees it through a namespace import too', () => { - const found = callsMissingDependencies( - 'fixture.tsx', - "import * as Reanimated from 'react-native-reanimated'\n" + - 'const s = Reanimated.useAnimatedStyle(() => ({ opacity: progress.value }))\n' - ) - expect(found).toEqual(['fixture.tsx:2 useAnimatedStyle']) - }) - - it('leaves a local function of the same name alone', () => { - const found = callsMissingDependencies( - 'fixture.tsx', - 'function useDerivedValue(fn: () => number) { return fn() }\n' + - 'const v = useDerivedValue(() => progress.value)\n' - ) - expect(found).toEqual([]) - }) - - it('takes an array built elsewhere as present rather than missing', () => { - const found = callsMissingDependencies( - 'fixture.tsx', - `${FROM}const deps = [progress]\n` + - 'const s = useAnimatedStyle(() => ({ opacity: progress.value }), deps)\n' - ) - expect(found).toEqual([]) - }) -}) diff --git a/mobile/src/rpc-params-contract-type-only-boundary.test.ts b/mobile/src/rpc-params-contract-type-only-boundary.test.ts deleted file mode 100644 index a6d9f917e8a..00000000000 --- a/mobile/src/rpc-params-contract-type-only-boundary.test.ts +++ /dev/null @@ -1,316 +0,0 @@ -import { readFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' -import { extname, join, relative, resolve } from 'node:path' -import ts from 'typescript' -import { describe, expect, it } from 'vitest' -import { censusSourceFiles } from './test-support/census-source-files' - -// Why: src/shared/rpc-contract/*-params.ts hold the host's zod schemas. Bundling one -// into the app would let client code call parse(), and requiredString is -// z.unknown().transform(...) — it coerces a non-string to '' instead of rejecting it, -// silently changing the bytes the phone puts on the wire. Types only, never values. -const mobileRoot = fileURLToPath(new URL('..', import.meta.url)) -const contractRoot = resolve(mobileRoot, '..', 'src', 'shared', 'rpc-contract') -const scannedRoots = ['app', 'src'].map((directory) => join(mobileRoot, directory)) -const sourceExtensions = new Set(['.js', '.jsx', '.ts', '.tsx']) - -function targetsContract(path: string, specifier: string): boolean { - if (!specifier.startsWith('.')) { - return false - } - const resolved = resolve(path, '..', specifier) - return resolved === contractRoot || resolved.startsWith(`${contractRoot}/`) -} - -function parse(path: string, source: string): ts.SourceFile { - const extension = extname(path) - return ts.createSourceFile( - path, - source, - ts.ScriptTarget.Latest, - true, - extension === '.tsx' || extension === '.jsx' ? ts.ScriptKind.TSX : ts.ScriptKind.TS - ) -} - -// Returns the specifiers that would pull contract *values* into the bundle. -export function contractValueImports(path: string, source: string): string[] { - const sourceFile = parse(path, source) - const offenders: string[] = [] - const visit = (node: ts.Node): void => { - if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) { - const specifier = node.moduleSpecifier.text - if (targetsContract(path, specifier)) { - const clause = node.importClause - const everyNamedIsType = - clause?.isTypeOnly === true || - (clause?.namedBindings !== undefined && - ts.isNamedImports(clause.namedBindings) && - clause.namedBindings.elements.every((element) => element.isTypeOnly)) - // A bare `import './x'` has no clause at all and still emits a require. - if (!everyNamedIsType) { - offenders.push(specifier) - } - } - } - if ( - ts.isExportDeclaration(node) && - node.moduleSpecifier && - ts.isStringLiteral(node.moduleSpecifier) - ) { - const specifier = node.moduleSpecifier.text - if (targetsContract(path, specifier)) { - const everyNamedIsType = - node.isTypeOnly || - (node.exportClause !== undefined && - ts.isNamedExports(node.exportClause) && - node.exportClause.elements.every((element) => element.isTypeOnly)) - if (!everyNamedIsType) { - offenders.push(specifier) - } - } - } - if (ts.isCallExpression(node)) { - const callee = node.expression - const isDynamic = callee.kind === ts.SyntaxKind.ImportKeyword - const isRequire = ts.isIdentifier(callee) && callee.text === 'require' - const argument = node.arguments[0] - if ( - (isDynamic || isRequire) && - argument && - ts.isStringLiteral(argument) && - targetsContract(path, argument.text) - ) { - offenders.push(argument.text) - } - } - ts.forEachChild(node, visit) - } - visit(sourceFile) - return offenders -} - -/** - * Whether this file names the contract at all, through the compiler's own module scanner. - * - * The census walks every mobile source file, and on this tree that is 2,235 files and 11.3 MB; - * exactly one of them reaches the contract. Parsing and walking the AST of the other 2,234 to learn - * that is what put this case over its 5 s timeout under full-suite load, where it has the machine to - * itself for none of the time it did alone. - * - * `preProcessFile` is the scanner behind `tsc`'s own dependency discovery: it reports every module - * reference — `import`, `export ... from`, `require()` and dynamic `import()`, which is the same four - * shapes the analyser below inspects — without building a tree. It is a sound filter and not a - * substring search over the text: it decodes string escapes, so a specifier spelled - * `'...\u002Dcontract/...'` is reported as the path it resolves to rather than missed. The case - * below pins that, on the same shapes the analyser is pinned against. - * - * Over-approximating on purpose: a type-only import names the contract too, so it reaches the - * analyser, which is the thing that decides. - * - * One shape the scanner does not report, so the filter cannot rest on it alone: a namespace - * re-export. `export * as ns from '...'` and its `export type * as ns` form are absent from - * `importedFiles`, while `export *`, `export { X } from`, and `import ns = require()` are all - * there — so a file re-exporting the contract under a name was dropped before the analyser saw it, - * which is the one shape the soundness case exists to catch. Anything holding that shape is handed - * on as well. Counted over the 2,236 files this census reads: 1,005 carry an asterisk and so reach - * the token scan, which costs 173 ms for all of them together, and none matches — including this - * one, whose spellings below live inside template literals and are therefore template text rather - * than tokens. No file in the tree holds the shape, so the widening hands the analyser nothing new. - * - * A text match for the contract path would be the other way to widen, and is the wrong one: the - * escaped-specifier case is a real import whose text does not contain the directory name, so a - * substring fence would miss it while looking thorough. - */ -/** - * Whether the source holds `export * as`, with or without `type`: the shape `preProcessFile` omits. - * - * By token, because the tokens can be separated by anything. A block comment between `*` and `as`, - * or a line comment splitting them across two lines, still leaves one namespace re-export — and a - * text rule reads those characters while the language does not. The scanner skips trivia, which is - * exactly the difference. - * - * The `*` pre-check is sound for this shape — the token is that literal character, so a source - * without one cannot hold the sequence — and it keeps the scan off the 1,231 files that have no - * asterisk anywhere. It is not a rarity filter: a block comment carries an asterisk, so most files - * with any comment at all still reach the scan. - */ -function hasNamespaceReExport(source: string): boolean { - if (!source.includes('*')) { - return false - } - const scanner = ts.createScanner( - ts.ScriptTarget.Latest, - true, - ts.LanguageVariant.Standard, - source - ) - // The scanner is not a standalone tokenizer: after a `${...}` substitution the closing brace has - // to be re-scanned as a template token or everything after it is read as ordinary source, and the - // rest of the file is tokenised wrongly. `preProcessFile` keeps this same stack for that reason. - const openTemplates: ts.SyntaxKind[] = [] - // `export` -> optional `type` -> `*` -> `as`, restarting from any `export` that breaks it. - let matched = 0 - for (let token = scanner.scan(); token !== ts.SyntaxKind.EndOfFileToken; token = scanner.scan()) { - if (token === ts.SyntaxKind.TemplateHead) { - openTemplates.push(ts.SyntaxKind.TemplateHead) - matched = 0 - continue - } - if (openTemplates.length > 0) { - // Inside a substitution, which cannot hold this declaration; the work here is staying in step. - if (token === ts.SyntaxKind.OpenBraceToken) { - openTemplates.push(ts.SyntaxKind.OpenBraceToken) - } else if (token === ts.SyntaxKind.CloseBraceToken) { - if (openTemplates.at(-1) === ts.SyntaxKind.TemplateHead) { - // A tail ends the template; a middle means another substitution follows. - if (scanner.reScanTemplateToken(false) === ts.SyntaxKind.TemplateTail) { - openTemplates.pop() - } - } else { - openTemplates.pop() - } - } - matched = 0 - continue - } - if (matched === 2 && token === ts.SyntaxKind.AsKeyword) { - return true - } - if (matched === 1 && token === ts.SyntaxKind.AsteriskToken) { - matched = 2 - continue - } - if (matched === 1 && token === ts.SyntaxKind.TypeKeyword) { - continue - } - matched = token === ts.SyntaxKind.ExportKeyword ? 1 : 0 - } - return false -} - -function referencesContract(path: string, source: string): boolean { - if (hasNamespaceReExport(source)) { - return true - } - return ts - .preProcessFile(source, true, true) - .importedFiles.some((reference) => targetsContract(path, reference.fileName)) -} - -/** - * The contract imported under a specifier whose text does not spell it: `-` written `\u002D`. - * - * Built from character codes rather than a raw string literal, because a source file carrying the - * escape is a source file whose own bytes the formatter, the linter and the next reader are all - * entitled to normalise — and normalising it would quietly turn this into an ordinary specifier. - */ -const ESCAPED_SPECIFIER = `import { RepoSelector } from '../../src/shared/rpc${String.fromCharCode( - 92 -)}u002Dcontract/repo-params'` - -describe('RPC params contract boundary', () => { - it('flags every shape that would emit a runtime require', () => { - const path = join(mobileRoot, 'src', 'probe.ts') - const contract = '../../src/shared/rpc-contract/repo-params' - expect(contractValueImports(path, `import { RepoSelector } from '${contract}'`)).toEqual([ - contract - ]) - expect(contractValueImports(path, `import '${contract}'`)).toEqual([contract]) - expect(contractValueImports(path, `export { RepoSelector } from '${contract}'`)).toEqual([ - contract - ]) - expect(contractValueImports(path, `const s = require('${contract}')`)).toEqual([contract]) - expect(contractValueImports(path, `const s = await import('${contract}')`)).toEqual([contract]) - expect(contractValueImports(path, `import type { RepoSelector } from '${contract}'`)).toEqual( - [] - ) - expect(contractValueImports(path, `import { type RepoSelector } from '${contract}'`)).toEqual( - [] - ) - expect(contractValueImports(path, `export type { RepoSelector } from '${contract}'`)).toEqual( - [] - ) - expect( - contractValueImports( - path, - `import type { GitHubWorkItem } from '../../src/shared/github/work-item-types'` - ) - ).toEqual([]) - }) - - it('lets every contract import spelling the analyser flags reach it', () => { - // The soundness half of the two-stage read below. The analyser only ever sees what this - // predicate admits, so a narrowing here is a fence that stops fencing while staying green — - // and it would stay green, because the tree has no offender to miss. - const path = join(mobileRoot, 'src', 'probe.ts') - const contract = '../../src/shared/rpc-contract/repo-params' - const contractImportSpellings = [ - `import { RepoSelector } from '${contract}'`, - `import '${contract}'`, - `export { RepoSelector } from '${contract}'`, - `const s = require('${contract}')`, - `const s = await import('${contract}')`, - // The type-only spellings too: this stage does not decide, it only hands work to the one - // that does, and a filter that pre-judged them would hide a later edit that dropped `type`. - `import type { RepoSelector } from '${contract}'`, - `import { type RepoSelector } from '${contract}'`, - `export type { RepoSelector } from '${contract}'`, - // Namespace re-exports: the shape `ts.preProcessFile` does not report, so the pre-filter - // dropped the file before the analyser ever saw it. - `export * as ns from '${contract}'`, - `export type * as ns from '${contract}'`, - // And with trivia between the tokens, which is still one namespace re-export: a text rule - // reads the characters between `*` and `as` and a token rule does not see them at all. - `export * /* note */ as ns from '${contract}'`, - `export *\n// note\nas ns from '${contract}'`, - // And after an interpolated template, which is where a bare scanner loop loses its place. - 'const t = `a${1}b`\n' + `export * as ns from '${contract}'`, - // A brace inside the substitution, and a template inside it: the two shapes that exercise - // the stack rather than a single flag, which a lone substitution would leave unpinned. - 'const t = `a${ { k: 1 } }b`\n' + `export * as ns from '${contract}'`, - 'const t = `a${ `x${2}y` }b`\n' + `export * as ns from '${contract}'`, - ESCAPED_SPECIFIER - ] - expect(contractImportSpellings.filter((source) => !referencesContract(path, source))).toEqual( - [] - ) - // The escaped spelling's own premise, asserted rather than described: its text really does not - // contain the directory name, so admitting it is the scanner decoding the specifier and not a - // substring happening to match. A plain `includes` filter over the file would miss this one. - expect(ESCAPED_SPECIFIER).not.toContain('rpc-contract') - expect(referencesContract(path, ESCAPED_SPECIFIER)).toBe(true) - // And it is not simply true of everything: an unrelated import is not handed on. - expect( - referencesContract( - path, - `import type { GitHubWorkItem } from '../../src/shared/github/work-item-types'` - ) - ).toBe(false) - }) - - it('keeps every mobile import of the params contract type-only', () => { - const sources = scannedRoots - .flatMap(censusSourceFiles) - .filter((path) => sourceExtensions.has(extname(path))) - .map((path) => ({ path, source: readFileSync(path, 'utf8') })) - // The walk found the tree it is written against, rather than a directory that moved. - expect(sources.length).toBeGreaterThan(1000) - - const reaching = sources.filter(({ path, source }) => referencesContract(path, source)) - // The presence precondition. `[]` below is the verdict "every reach is type-only", and without - // this it is also what a census reaching nothing at all prints — a renamed contract directory, - // a scanner that stopped reporting, a walk over the wrong root. - expect(reaching.length).toBeGreaterThan(0) - - // A test file is never bundled, and the recorded-request check has to run the host's schemas. - const offenders = reaching - .filter(({ path }) => !/\.test\.tsx?$/.test(path)) - .flatMap(({ path, source }) => - contractValueImports(path, source).map( - (specifier) => `${relative(mobileRoot, path)} -> ${specifier}` - ) - ) - expect(offenders).toEqual([]) - }) -}) diff --git a/mobile/src/session/clipboard-write-failure-branch-census.test.ts b/mobile/src/session/clipboard-write-failure-branch-census.test.ts deleted file mode 100644 index 633a4fff4aa..00000000000 --- a/mobile/src/session/clipboard-write-failure-branch-census.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { readFileSync } from 'node:fs' -import ts from 'typescript-api' -import { describe, expect, it } from 'vitest' -import { parse, productFiles } from '../navigation/router-seam-census.test-support' - -const SESSION_ROOT = import.meta.dirname - -/** - * Every clipboard write on this screen has somewhere for its failure to go. - * - * The seam rejects when the pasteboard refused the text, which is the whole reason it exists: a - * caller that showed "Copied" over a write that did not land was the failure it replaced. But a - * rejection needs a reader. Seven of these sites are fire-and-forget — a sheet row's `onPress`, a - * `void copy(...)` in a render tree — so a site without a failure branch does not merely stay - * quiet, it raises an unhandled rejection and still leaves "Copied" on screen. - * - * A census rather than seven tests, because the eighth site is the one that will be written by - * somebody who never read this file. Structural rather than behavioural on purpose: what each site - * does about a failure is its own business, and what this holds is that it does something. - */ - -/** The seam's own name, so a local helper called `writeText` is not mistaken for it. */ -const SEAM_METHOD = 'writeText' -const CATCH_METHOD = 'catch' -const SEAM_HOOK = 'useClipboardWriter' - -/** - * Whether a call is answered for, by either shape this tree uses. - * - * `await` inside the block of a `try` that catches, or a `.catch(...)` on the promise itself. - * Walked upward from the call rather than matched on text: both shapes put the handler somewhere - * other than the line the write is on, and a regex over the file would pass a `catch` that belongs - * to a different statement entirely. - * - * The `await` is not a formality. A promise nobody waits for settles after the block that started - * it has returned, so a `void clipboard.writeText(...)` inside a `try` is the unhandled rejection - * this rule exists to stop, with a `catch` above it that can never run. `return` is not enough - * either, for the same reason: only `return await` keeps the call inside the block. - */ -function hasFailureBranch(call: ts.CallExpression): boolean { - let node: ts.Node = call - let awaited = false - while (node.parent !== undefined) { - const parent: ts.Node = node.parent - if (ts.isAwaitExpression(parent)) { - awaited = true - } - // `clipboard.writeText(x).then(...).catch(...)`: the handler is further along this same chain, - // and `parent.expression === node` is what keeps it to this chain rather than any nearby catch. - // Called, with something to call: `.catch` read as a property registers nothing, and `.catch()` - // with no argument swallows the rejection while the caller goes on to say the write landed. - if ( - ts.isPropertyAccessExpression(parent) && - parent.expression === node && - parent.name.text === CATCH_METHOD && - parent.parent !== undefined && - ts.isCallExpression(parent.parent) && - parent.parent.expression === parent && - parent.parent.arguments.length > 0 - ) { - return true - } - // `tryBlock === node` because a write inside the catch or finally clause is not answered for by - // the try it is written in: that handler has already run. - if (ts.isTryStatement(parent) && parent.catchClause !== undefined && parent.tryBlock === node) { - return awaited - } - // Every function-like node ends the search, arrows and function expressions included: a `catch` - // outside one answers for whoever called it, not for the call left running inside. - if (ts.isFunctionLike(parent)) { - return false - } - node = parent - } - return false -} - -/** Every `x.writeText(...)` in a parsed module, as `file:line`, with whether its failure is handled. */ -function clipboardWritesIn( - source: ts.SourceFile, - name: string -): { at: string; handled: boolean }[] { - const found: { at: string; handled: boolean }[] = [] - const visit = (node: ts.Node): void => { - if ( - ts.isCallExpression(node) && - ts.isPropertyAccessExpression(node.expression) && - node.expression.name.text === SEAM_METHOD - ) { - const line = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1 - found.push({ at: `${name}:${line}`, handled: hasFailureBranch(node) }) - } - ts.forEachChild(node, visit) - } - ts.forEachChild(source, visit) - return found -} - -function clipboardWrites(root: string, name: string): { at: string; handled: boolean }[] { - return clipboardWritesIn(parse(root, name), name) -} - -/** A snippet read the way the census reads a module, for shapes no product file holds today. */ -function writesInSnippet(source: string): { at: string; handled: boolean }[] { - return clipboardWritesIn( - ts.createSourceFile('snippet.ts', source, ts.ScriptTarget.Latest, true), - 'snippet.ts' - ) -} - -/** Whether the census would report the single write in a snippet. */ -function reports(source: string): boolean { - const writes = writesInSnippet(source) - if (writes.length !== 1) { - throw new Error(`the snippet holds ${writes.length} writes, not one`) - } - return !writes[0].handled -} - -describe('every clipboard write in the session domain answers for its failure', () => { - const files = productFiles(SESSION_ROOT) - const writes = files.flatMap((name) => clipboardWrites(SESSION_ROOT, name)) - - it('finds the writes it is written against, so an empty list is not a pass', () => { - // The completeness half: a rule over nothing is a rule that cannot fail. The count is a floor - // rather than an equality, because a new copy button is not this census's business to approve. - expect(writes.length).toBeGreaterThanOrEqual(8) - expect( - files.filter((name) => readFileSync(`${SESSION_ROOT}/${name}`, 'utf8').includes(SEAM_HOOK)) - .length - ).toBeGreaterThan(0) - }) - - it('leaves none of them without one', () => { - expect(writes.filter((write) => !write.handled).map((write) => write.at)).toEqual([]) - }) -}) - -/** - * What the rule counts as somewhere for a failure to go, in the shapes a product file does not hold. - * - * The census walks real modules, so the shapes it must refuse cannot be planted in one: a `void` - * write is exactly the mistake it exists to catch, and it would have to be committed to be tested. - * Snippets instead, read through the same reader, so a rule that stops matching is a red here - * rather than a site that quietly passes for years. - */ -describe('the failure branch the census will accept', () => { - it('refuses a write nobody waits for, however well the block around it is guarded', () => { - // The whole point of the rule. `void` detaches the promise from the block: the `try` has - // returned long before the rejection settles, and the process gets an unhandled rejection with - // a `catch` sitting three lines above it that never runs. - expect( - reports(` - async function copy(clipboard: Clipboard, text: string) { - try { - void clipboard.writeText(text) - } catch { - showToast("Couldn't copy") - } - } - `) - ).toBe(true) - }) - - it('refuses a write whose only catch is outside the function it sits in', () => { - // An arrow ends the search as surely as a declaration does. The `catch` here answers for - // `forEach`, which returns before the write it started has settled. - expect( - reports(` - async function copyAll(clipboard: Clipboard, rows: string[]) { - try { - rows.forEach((row) => { - clipboard.writeText(row) - }) - } catch { - showToast("Couldn't copy") - } - } - `) - ).toBe(true) - }) - - it('refuses a write in the catch block, which its own try does not answer for', () => { - expect( - reports(` - async function copy(clipboard: Clipboard, text: string) { - try { - await save(text) - } catch { - await clipboard.writeText(text) - } - } - `) - ).toBe(true) - }) - - it('refuses a `.catch` that is read rather than called', () => { - // `clipboard.writeText(text).catch` is the handler's name, not a handler. It registers nothing, - // and the rejection goes exactly where it would have gone with no `catch` written at all. - expect( - reports(` - function copy(clipboard: Clipboard, text: string) { - const retry = clipboard.writeText(text).catch - return retry - } - `) - ).toBe(true) - }) - - it('refuses a `.catch()` with nothing to handle the rejection', () => { - // Called, so the promise is handled in the sense that nothing is reported — and the user is - // told a write landed when it did not, which is the failure this rule is written against. - expect( - reports(` - function copy(clipboard: Clipboard, text: string) { - clipboard.writeText(text).catch() - showToast('Copied') - } - `) - ).toBe(true) - }) - - it('accepts an awaited write inside a try that catches', () => { - expect( - reports(` - async function copy(clipboard: Clipboard, text: string) { - try { - await clipboard.writeText(text) - } catch { - showToast("Couldn't copy") - } - } - `) - ).toBe(false) - }) - - it('accepts a write that carries its own catch along the chain', () => { - expect( - reports(` - function copy(clipboard: Clipboard, text: string) { - clipboard - .writeText(text) - .then(() => showToast('Copied')) - .catch(() => showToast("Couldn't copy")) - } - `) - ).toBe(false) - }) -}) diff --git a/mobile/src/transport/rpc-operation-cast-fence.test.ts b/mobile/src/transport/rpc-operation-cast-fence.test.ts deleted file mode 100644 index df692012952..00000000000 --- a/mobile/src/transport/rpc-operation-cast-fence.test.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { readFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' -import { extname, join, relative, resolve } from 'node:path' -import ts from 'typescript' -import { describe, expect, it } from 'vitest' -import { censusSourceFiles } from '../test-support/census-source-files' - -/** - * Bans the escapes that would make the typed boundary decorative. - * - * An operation's whole claim is that a reply arrives as a declared type because a reader - * decoded it. `as`, `any` and a `@ts-` suppression each produce the same declared type without - * the decode, so one of them anywhere in an operation implementation buys back exactly the - * drift the contract removed — and it buys it silently, since the code still compiles and the - * types still read as validated. - * - * The fenced region includes the operation API, contract and result-reader factory, plus - * non-test files importing them and files that re-export a - * file that is (transitively). Step 4's operation modules therefore land inside the fence the - * moment they are written, with nothing to remember. - * - * What this does NOT catch, all accepted: - * - A lying reader. `z.unknown()` or a schema looser than the reply decodes anything, and no - * syntax check can tell a permissive schema from a wrong one. - * - Structural laundering: a helper in an unfenced module that returns the wrong type - * honestly, which the operation then consumes without a cast. - * - `!` non-null assertions, and the widening that an untyped intermediate variable gives - * you for free. - * - A screen. Screens are outside the region by design until they hold an operation; the - * raw-port inventory is what governs them. - */ - -const mobileRoot = fileURLToPath(new URL('../..', import.meta.url)) -const scannedRoots = ['app', 'src'].map((directory) => join(mobileRoot, directory)) -const sourceExtensions = new Set(['.js', '.jsx', '.ts', '.tsx']) -const transportRoot = join(mobileRoot, 'src', 'transport') - -/** Importing any of these is what makes a file an operation implementation. */ -const REGION_SEEDS = new Set( - ['rpc-operation', 'rpc-operation-contract', 'rpc-operation-result-reader'].map((name) => - join(transportRoot, name) - ) -) - -export type RpcOperationEscape = 'assertion' | 'any' | 'suppression' - -type CastFenceException = { - readonly file: string - readonly allows: readonly RpcOperationEscape[] -} - -/** - * The modules that own the `unknown` → declared-type transition, so the erasure has to land - * somewhere. Held as data, per escape kind, so an exception cannot quietly widen into the - * others. Every entry is also checked for staleness. - */ -const CAST_FENCE_EXCEPTIONS: readonly CastFenceException[] = [ - // The interpreter. Its casts re-apply type parameters that `AnyRpcOperation` erased on the - // way in; none of them invents a shape the reader did not already produce. - { file: 'src/transport/rpc-operation.ts', allows: ['assertion'] }, - // The reader factory. `safeParse` returns the schema's own output type as `unknown`. - { file: 'src/transport/rpc-operation-result-reader.ts', allows: ['assertion'] }, - // Nothing but suppressions: every directive in it is an assertion that tsc still rejects - // the thing above it, which is the compile fence's entire mechanism. - { file: 'src/transport/rpc-operation-compile-fence.ts', allows: ['suppression'] } -] - -// Text, not AST: a suppression is a comment, and comments are not nodes. A directive spelled -// inside a string literal therefore reads as one — which fails closed. -const SUPPRESSION = /@ts-(?:expect-error|ignore|nocheck)\b/ - -function parse(path: string, source: string): ts.SourceFile { - const extension = extname(path) - return ts.createSourceFile( - path, - source, - ts.ScriptTarget.Latest, - true, - extension === '.tsx' || extension === '.jsx' ? ts.ScriptKind.TSX : ts.ScriptKind.TS - ) -} - -function resolvedSpecifier(path: string, node: ts.Node | undefined): string | null { - if (!node || !ts.isStringLiteral(node) || !node.text.startsWith('.')) { - return null - } - return resolve(path, '..', node.text) -} - -/** `as const` narrows a literal; it declares nothing the value was not already. */ -function isConstAssertion(node: ts.AsExpression): boolean { - return ( - ts.isTypeReferenceNode(node.type) && - ts.isIdentifier(node.type.typeName) && - node.type.typeName.text === 'const' - ) -} - -export function rpcOperationEscapes(path: string, source: string): RpcOperationEscape[] { - const found: RpcOperationEscape[] = [] - const visit = (node: ts.Node): void => { - if ( - (ts.isAsExpression(node) && !isConstAssertion(node)) || - ts.isTypeAssertionExpression(node) - ) { - found.push('assertion') - } - if (node.kind === ts.SyntaxKind.AnyKeyword) { - found.push('any') - } - ts.forEachChild(node, visit) - } - visit(parse(path, source)) - if (SUPPRESSION.test(source)) { - found.push('suppression') - } - return [...new Set(found)].sort() -} - -/** Imports and re-exports that make the importer part of the operation region. */ -function moduleEdges(path: string, source: string): { imports: string[]; reExports: string[] } { - const imports: string[] = [] - const reExports: string[] = [] - for (const statement of parse(path, source).statements) { - if (ts.isImportDeclaration(statement)) { - const target = resolvedSpecifier(path, statement.moduleSpecifier) - if (target) { - imports.push(target) - } - continue - } - if (ts.isExportDeclaration(statement) && statement.moduleSpecifier) { - const target = resolvedSpecifier(path, statement.moduleSpecifier) - if (target) { - imports.push(target) - reExports.push(target) - } - } - } - return { imports, reExports } -} - -const scanned = scannedRoots - .flatMap(censusSourceFiles) - .filter((path) => sourceExtensions.has(extname(path))) - .filter((path) => !/\.test\.tsx?$/.test(path)) - -const sources = new Map(scanned.map((path) => [path, readFileSync(path, 'utf8')] as const)) -const edges = new Map([...sources].map(([path, source]) => [path, moduleEdges(path, source)])) - -/** Modules are keyed without their extension, the way a relative specifier resolves. */ -function moduleKey(path: string): string { - return path.replace(/\.[jt]sx?$/, '') -} - -const region = new Set(scanned.filter((path) => REGION_SEEDS.has(moduleKey(path)))) -for (const [path, { imports }] of edges) { - if (imports.some((target) => REGION_SEEDS.has(target))) { - region.add(path) - } -} -// Fixpoint over re-export edges: a barrel that re-exports an operation module is in the fence -// too, which is where a cast would otherwise sit unwatched between definition and screen. -for (let changed = true; changed;) { - changed = false - const members = new Set([...region].map(moduleKey)) - for (const [path, { reExports }] of edges) { - if (!region.has(path) && reExports.some((target) => members.has(target))) { - region.add(path) - changed = true - } - } -} - -const relativeRegion = [...region].map((path) => - relative(mobileRoot, path).split(/[/\\]/).join('/') -) - -describe('RPC operation cast fence', () => { - const probe = join(mobileRoot, 'src', 'transport', 'probe.ts') - - it('recognizes each escape and leaves honest code alone', () => { - expect(rpcOperationEscapes(probe, 'const v = raw as WorkspaceRows')).toEqual(['assertion']) - expect(rpcOperationEscapes(probe, 'const v = raw as unknown as WorkspaceRows')).toEqual([ - 'assertion' - ]) - expect(rpcOperationEscapes(probe, 'const v: any = raw')).toEqual(['any']) - expect(rpcOperationEscapes(probe, 'function f(raw: any) {}')).toEqual(['any']) - expect(rpcOperationEscapes(probe, 'const v = raw as any')).toEqual(['any', 'assertion']) - expect(rpcOperationEscapes(probe, '// @ts-expect-error\nconst v = raw')).toEqual([ - 'suppression' - ]) - expect(rpcOperationEscapes(probe, '// @ts-ignore\nconst v = raw')).toEqual(['suppression']) - expect(rpcOperationEscapes(probe, "const v = ['a'] as const")).toEqual([]) - expect(rpcOperationEscapes(probe, 'const v = read(raw)')).toEqual([]) - expect(rpcOperationEscapes(probe, 'const v = raw satisfies WorkspaceRows')).toEqual([]) - expect(rpcOperationEscapes(probe, 'const v = value!')).toEqual([]) - }) - - it('puts every operation module in the fenced region', () => { - for (const file of [ - 'src/transport/rpc-operation.ts', - 'src/transport/rpc-operation-contract.ts', - 'src/transport/rpc-operation-test-families.ts', - 'src/transport/rpc-operation-compile-fence.ts', - 'src/transport/rpc-operation-result-reader.ts', - 'src/transport/rpc-incompatible-reply-error.ts' - ]) { - expect(relativeRegion, `${file} must be fenced`).toContain(file) - } - // A screen that only holds a client is governed by the raw-port inventory, not by this. - expect(relativeRegion).not.toContain('src/transport/rpc-client.ts') - }) - - it('has no operation module casting, widening or suppressing its way to a type', () => { - const allowed = new Map(CAST_FENCE_EXCEPTIONS.map((entry) => [entry.file, entry.allows])) - const offenders = [...region] - .map((path) => { - const file = relative(mobileRoot, path).split(/[/\\]/).join('/') - const escapes = rpcOperationEscapes(path, sources.get(path) ?? '') - const permitted = allowed.get(file) ?? [] - return { file, escapes: escapes.filter((escape) => !permitted.includes(escape)) } - }) - .filter((entry) => entry.escapes.length > 0) - .map((entry) => `${entry.file}: ${entry.escapes.join(', ')}`) - .sort() - - expect( - offenders, - 'Decode the reply with a reader instead. An operation that asserts its own result type is not typed.' - ).toEqual([]) - }) - - it('has no stale cast-fence exception', () => { - const stale = CAST_FENCE_EXCEPTIONS.flatMap((entry) => { - const path = join(mobileRoot, entry.file) - if (!region.has(path)) { - return [`${entry.file}: no longer in the fenced region`] - } - const escapes = rpcOperationEscapes(path, sources.get(path) ?? '') - return entry.allows - .filter((escape) => !escapes.includes(escape)) - .map((escape) => `${entry.file}: no longer uses '${escape}'`) - }) - expect(stale, 'Narrow or delete the exception in rpc-operation-cast-fence.test.ts.').toEqual([]) - }) -}) diff --git a/mobile/src/transport/unchecked-rpc-reader-boundary.test.ts b/mobile/src/transport/unchecked-rpc-reader-boundary.test.ts deleted file mode 100644 index 463ba943cb5..00000000000 --- a/mobile/src/transport/unchecked-rpc-reader-boundary.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { readFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' -import { extname, join, relative } from 'node:path' -import ts from 'typescript' -import { describe, expect, it } from 'vitest' -import { censusSourceFiles } from '../test-support/census-source-files' -import { - UNCHECKED_RPC_READERS, - type UncheckedRpcReaderEntry -} from './unchecked-rpc-reader-inventory' - -/** - * Ratchet for unchecked reply readers. - * - * `rpcUncheckedPayloadReader('x')` says nothing about the payload: it answers `compatible: true` - * for a string, a null, an error envelope and the shape the consumer expects alike. The operation - * then hands that value to a consumer typed as if it had been checked. This list is what the - * count-down to zero is measured against, and this test is what makes it bind. - * - * Three failures, all of which mean "edit the list": - * - a file holds an unchecked reader and is on the list of none, - * - a listed file no longer holds one (stale entry — how allow-lists rot), - * - a listed file's reader count went up. - * - * What this does NOT catch, all accepted: - * - A hand-written `{ compatible: true, value: raw as T }` reader. Same hole, different bytes; - * `unchecked-rpc-reader-inventory.ts` says so in prose because no AST rule separates a - * projecting reader that validated its input from one that asserted. - * - Whether a *checked* schema is any good. A `z.unknown()` reader counts as checked here and is - * the right answer for a payload the consumer forwards opaquely; it is the wrong answer for one - * it destructures, and only the consumer trace tells the two apart. - * - Test files. `*.test.ts(x)` is not scanned: a suite asserting on an unchecked reader is - * testing the helper, and a test does not ship. - */ - -const mobileRoot = fileURLToPath(new URL('../..', import.meta.url)) -const scannedRoots = ['app', 'src'].map((directory) => join(mobileRoot, directory)) -const sourceExtensions = new Set(['.js', '.jsx', '.ts', '.tsx']) - -/** The three helpers, and the module that defines them — it is not an offender. */ -const UNCHECKED_READER_NAMES = new Set([ - 'rpcUncheckedPayloadReader', - 'rpcUncheckedMemberReader', - 'rpcReadUnchecked' -]) -// Only the module that defines the readers: the AST counter counts calls, and the inventory names -// them in prose alone. -const SELF_FILES = new Set(['src/transport/rpc-reader-payload.ts']) - -function parse(path: string, source: string): ts.SourceFile { - const extension = extname(path) - return ts.createSourceFile( - path, - source, - ts.ScriptTarget.Latest, - true, - extension === '.tsx' || extension === '.jsx' ? ts.ScriptKind.TSX : ts.ScriptKind.TS - ) -} - -/** How many unchecked readers this file builds. Only a call counts: an import is not a reader. */ -function uncheckedReaderCount(path: string, source: string): number { - let readers = 0 - const visit = (node: ts.Node): void => { - if ( - ts.isCallExpression(node) && - ts.isIdentifier(node.expression) && - UNCHECKED_READER_NAMES.has(node.expression.text) - ) { - readers += 1 - } - ts.forEachChild(node, visit) - } - visit(parse(path, source)) - return readers -} - -const scanned = scannedRoots - .flatMap(censusSourceFiles) - .filter((path) => sourceExtensions.has(extname(path))) - .filter((path) => !/\.test\.tsx?$/.test(path)) - .map((path) => relative(mobileRoot, path).split(/[/\\]/).join('/')) - .filter((file) => !SELF_FILES.has(file)) - -const observed = new Map( - scanned - .map( - (file) => - [ - file, - uncheckedReaderCount(join(mobileRoot, file), readFileSync(join(mobileRoot, file), 'utf8')) - ] as const - ) - .filter(([, readers]) => readers > 0) -) - -const inventory: readonly UncheckedRpcReaderEntry[] = UNCHECKED_RPC_READERS - -describe('unchecked RPC reader boundary', () => { - const probe = join(mobileRoot, 'src', 'transport', 'probe.ts') - - it('counts every shape that builds an unchecked reader', () => { - expect(uncheckedReaderCount(probe, "read: rpcUncheckedPayloadReader('x')")).toBe(1) - expect(uncheckedReaderCount(probe, "read: rpcUncheckedMemberReader('x', 'entries')")).toBe(1) - expect(uncheckedReaderCount(probe, "return rpcReadUnchecked('x', raw)")).toBe(1) - expect(uncheckedReaderCount(probe, "a(rpcReadUnchecked('x', rpcReadUnchecked('y', 1)))")).toBe( - 2 - ) - }) - - it('does not count prose, an import or a checked reader', () => { - expect(uncheckedReaderCount(probe, '// rpcUncheckedPayloadReader is the old shape')).toBe(0) - expect(uncheckedReaderCount(probe, '/* rpcReadUnchecked */ export const x = 1')).toBe(0) - expect( - uncheckedReaderCount( - probe, - "import { rpcUncheckedPayloadReader } from './rpc-reader-payload'" - ) - ).toBe(0) - expect(uncheckedReaderCount(probe, "read: rpcResultVariant('x', schema)")).toBe(0) - }) - - it('scans a plausible number of files', () => { - // A broken root or extension filter would make every check below vacuously pass. The list has - // reached zero, so the equality now asserts "no unchecked reader ships" — which a scan of - // nothing would also satisfy. The file floor is what rules that out, and it stays a constant. - expect(scanned.length).toBeGreaterThan(400) - expect(observed.size).toBe(inventory.length) - }) - - it('has no unlisted file holding an unchecked reader', () => { - const listed = new Set(inventory.map((entry) => entry.file)) - const unlisted = [...observed.keys()].filter((file) => !listed.has(file)) - expect( - unlisted, - 'A new operation validates its reply with rpcResultVariant. Nothing may be added to unchecked-rpc-reader-inventory.ts.' - ).toEqual([]) - }) - - it('has no stale inventory entry', () => { - const stale = inventory.filter((entry) => !observed.has(entry.file)) - expect( - stale.map((entry) => entry.file), - 'File holds no unchecked reader — delete its line from unchecked-rpc-reader-inventory.ts.' - ).toEqual([]) - }) - - it('has no inventory entry whose file gained readers', () => { - const grown = inventory - .filter((entry) => (observed.get(entry.file) ?? 0) > entry.readers) - .map((entry) => `${entry.file}: listed ${entry.readers}, found ${observed.get(entry.file)}`) - expect(grown, 'The counts are a ceiling. Validate the new reply with a schema.').toEqual([]) - }) - - it('reports a count that has fallen so the entry can be lowered', () => { - const overstated = inventory - .filter( - (entry) => observed.has(entry.file) && (observed.get(entry.file) ?? 0) < entry.readers - ) - .map((entry) => `${entry.file}: listed ${entry.readers}, found ${observed.get(entry.file)}`) - expect( - overstated, - 'Fewer unchecked readers than listed — lower the count so the ratchet holds.' - ).toEqual([]) - }) -}) diff --git a/mobile/src/transport/unvalidated-rpc-request-port-boundary.test.ts b/mobile/src/transport/unvalidated-rpc-request-port-boundary.test.ts deleted file mode 100644 index 5f8db2935a6..00000000000 --- a/mobile/src/transport/unvalidated-rpc-request-port-boundary.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { readFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' -import { extname, join, relative, resolve } from 'node:path' -import ts from 'typescript' -import { describe, expect, it } from 'vitest' -import { censusSourceFiles } from '../test-support/census-source-files' -import { - UNVALIDATED_RPC_REQUEST_PORT_OWNERS, - UNVALIDATED_RPC_REQUEST_PORT_PENDING, - type UnvalidatedRpcRequestPortEntry -} from './unvalidated-rpc-request-port-inventory' - -/** - * Ratchet for the raw RPC request port. - * - * `sendRequest` takes an unchecked method string and returns an envelope whose `result` is - * `unknown`. Every screen that reaches it re-decides acceptance and decoding for itself, which - * is the drift the RpcOperation contract exists to end. The port cannot be made unreachable by - * the type system today: `RpcClient` structurally carries it, and ~190 files hold a client. So - * the boundary is held as an inventory instead, and this test is what makes the inventory bind. - * - * Three failures, all of which mean "edit the list": - * - a file reaches the port and is on neither list, - * - a listed file no longer reaches it (stale entry — how allow-lists rot), - * - a listed file's reference count went up. - * - * What this does NOT catch, all accepted: - * - Reach laundered through a function type. A listed file can hand `client.sendRequest` to an - * unlisted one as a bare `(method: string) => Promise` and the receiver never - * names the port. Only two senders are named here; a third wrapper needs adding by hand. - * - Computed access — `client['send' + 'Request']` is not a literal in the AST. - * - Which method a listed file sends, or what it does with the reply. The count is a ceiling - * on how many times it reaches, nothing more. - * - Test files. `*.test.ts(x)` is not scanned: faking the port is how these suites work, and a - * test does not ship. A non-test file that fakes it (tsconfig excludes tests, so some do) is - * scanned and listed. - * - Build output. `censusSourceFiles` leaves every `*.generated.ts` out, and one of them is a - * bundled vendor engine whose own dependencies contain the token `sendRequest` — minified - * third-party code, not a call site anybody in this repo wrote or can move onto an - * RpcOperation. The script that emits each of them is ordinary source and is walked. - * A compile-time fence would catch the first two. That needs `RpcClient` to stop carrying the - * port, which needs the call sites migrated first — the thing this list is counting down. - */ - -const mobileRoot = fileURLToPath(new URL('../..', import.meta.url)) -const scannedRoots = ['app', 'src'].map((directory) => join(mobileRoot, directory)) -const sourceExtensions = new Set(['.js', '.jsx', '.ts', '.tsx']) -const portModule = join(mobileRoot, 'src', 'transport', 'unvalidated-rpc-request-port') - -/** The port and its own inventory are not offenders; the ratchet does not police itself. */ -const SELF_FILES = new Set([ - 'src/transport/unvalidated-rpc-request-port.ts', - 'src/transport/unvalidated-rpc-request-port-inventory.ts' -]) - -/** The coalescing second sender: same unchecked string in, same unread envelope out. */ -const SECOND_SENDER = 'sendSingleFlightRequest' - -function parse(path: string, source: string): ts.SourceFile { - const extension = extname(path) - return ts.createSourceFile( - path, - source, - ts.ScriptTarget.Latest, - true, - extension === '.tsx' || extension === '.jsx' ? ts.ScriptKind.TSX : ts.ScriptKind.TS - ) -} - -function targetsPortModule(path: string, node: ts.Node | undefined): boolean { - if (!node || !ts.isStringLiteral(node) || !node.text.startsWith('.')) { - return false - } - return resolve(path, '..', node.text) === portModule -} - -/** `client['sendRequest']` is one reach, not two: the element access already counted it. */ -function isCountedElementAccessArgument(node: ts.Node): boolean { - const parent: ts.Node | undefined = node.parent - return ( - parent !== undefined && - ts.isElementAccessExpression(parent) && - parent.argumentExpression === node - ) -} - -function declaresPortMember(node: ts.Node): boolean { - if ( - !ts.isPropertySignature(node) && - !ts.isMethodSignature(node) && - !ts.isMethodDeclaration(node) && - !ts.isPropertyDeclaration(node) && - !ts.isPropertyAssignment(node) && - !ts.isShorthandPropertyAssignment(node) - ) { - return false - } - const name = node.name - return (ts.isIdentifier(name) || ts.isStringLiteral(name)) && name.text === 'sendRequest' -} - -/** How many times this file reaches the raw port directly. Comments never count: this is AST. */ -export function rawRequestPortReferences(path: string, source: string): number { - let references = 0 - const visit = (node: ts.Node): void => { - if ( - (ts.isPropertyAccessExpression(node) && node.name.text === 'sendRequest') || - (ts.isElementAccessExpression(node) && - ts.isStringLiteral(node.argumentExpression) && - node.argumentExpression.text === 'sendRequest') || - declaresPortMember(node) || - (ts.isStringLiteral(node) && - node.text === 'sendRequest' && - !isCountedElementAccessArgument(node)) || - (ts.isIdentifier(node) && node.text === SECOND_SENDER) - ) { - references += 1 - } - if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) { - references += targetsPortModule(path, node.moduleSpecifier) ? 1 : 0 - } - if ( - ts.isCallExpression(node) && - (node.expression.kind === ts.SyntaxKind.ImportKeyword || - (ts.isIdentifier(node.expression) && node.expression.text === 'require')) && - targetsPortModule(path, node.arguments[0]) - ) { - references += 1 - } - ts.forEachChild(node, visit) - } - visit(parse(path, source)) - return references -} - -const inventory: readonly UnvalidatedRpcRequestPortEntry[] = [ - ...UNVALIDATED_RPC_REQUEST_PORT_OWNERS, - ...UNVALIDATED_RPC_REQUEST_PORT_PENDING -] - -const scanned = scannedRoots - .flatMap(censusSourceFiles) - .filter((path) => sourceExtensions.has(extname(path))) - .filter((path) => !/\.test\.tsx?$/.test(path)) - .map((path) => relative(mobileRoot, path).split(/[/\\]/).join('/')) - .filter((file) => !SELF_FILES.has(file)) - -const observed = new Map( - scanned - .map( - (file) => - [ - file, - rawRequestPortReferences( - join(mobileRoot, file), - readFileSync(join(mobileRoot, file), 'utf8') - ) - ] as const - ) - .filter(([, references]) => references > 0) -) - -describe('unvalidated RPC request port boundary', () => { - const probe = join(mobileRoot, 'src', 'transport', 'probe.ts') - - it('counts every shape that reaches the port', () => { - expect(rawRequestPortReferences(probe, 'await client.sendRequest("worktree.ps", {})')).toBe(1) - expect(rawRequestPortReferences(probe, 'const send = client.sendRequest')).toBe(1) - expect(rawRequestPortReferences(probe, 'client["sendRequest"]("x")')).toBe(1) - expect(rawRequestPortReferences(probe, "type A = Pick")).toBe(1) - expect(rawRequestPortReferences(probe, "type A = RpcClient['sendRequest']")).toBe(1) - expect(rawRequestPortReferences(probe, 'const c = { sendRequest: async () => reply }')).toBe(1) - expect(rawRequestPortReferences(probe, 'interface C { sendRequest(m: string): void }')).toBe(1) - expect(rawRequestPortReferences(probe, "if (name === 'sendRequest') { }")).toBe(1) - expect( - rawRequestPortReferences(probe, 'await sendSingleFlightRequest(c, h, "worktree.ps")') - ).toBe(1) - expect( - rawRequestPortReferences( - probe, - "import { sendSingleFlightRequest } from './request-single-flight'" - ) - ).toBe(1) - expect( - rawRequestPortReferences( - probe, - "import type { UnvalidatedRpcRequestPort } from './unvalidated-rpc-request-port'" - ) - ).toBe(1) - expect( - rawRequestPortReferences( - probe, - "export type { SendRequestOptions } from './unvalidated-rpc-request-port'" - ) - ).toBe(1) - expect( - rawRequestPortReferences(probe, "const m = await import('./unvalidated-rpc-request-port')") - ).toBe(1) - expect(rawRequestPortReferences(probe, 'a.sendRequest(1); b.sendRequest(2)')).toBe(2) - }) - - it('does not count prose or an unrelated sender', () => { - expect(rawRequestPortReferences(probe, '// calls sendRequest under the hood')).toBe(0) - expect(rawRequestPortReferences(probe, '/* sendRequest */ export const x = 1')).toBe(0) - expect(rawRequestPortReferences(probe, 'await client.subscribe("terminal.stream", {})')).toBe(0) - expect(rawRequestPortReferences(probe, "import type { RpcClient } from './rpc-client'")).toBe(0) - expect(rawRequestPortReferences(probe, 'await runRpcOperation(client, op, {})')).toBe(0) - }) - - it('scans a plausible number of files', () => { - // A broken root or extension filter would make every check below vacuously pass. The scan - // width is the load-bearing half: the offender count is what the migration is driving to zero, - // so its floor has to come down as the list does rather than fail on a successful step. - expect(scanned.length).toBeGreaterThan(400) - expect(observed.size).toBeGreaterThan(20) - }) - - it('lists each file once', () => { - const seen = inventory.map((entry) => entry.file) - expect(seen.filter((file, index) => seen.indexOf(file) !== index)).toEqual([]) - }) - - it('has no unlisted file reaching the raw request port', () => { - const listed = new Set(inventory.map((entry) => entry.file)) - const unlisted = [...observed.keys()].filter((file) => !listed.has(file)) - expect( - unlisted, - 'New code must send through an RpcOperation. Nothing may be added to unvalidated-rpc-request-port-inventory.ts.' - ).toEqual([]) - }) - - it('has no stale inventory entry', () => { - const stale = inventory.filter((entry) => !observed.has(entry.file)) - expect( - stale.map((entry) => entry.file), - 'File no longer reaches the raw port — delete its line from unvalidated-rpc-request-port-inventory.ts.' - ).toEqual([]) - }) - - it('has no inventory entry whose file gained references', () => { - const grown = inventory - .filter((entry) => (observed.get(entry.file) ?? 0) > entry.references) - .map( - (entry) => `${entry.file}: listed ${entry.references}, found ${observed.get(entry.file)}` - ) - expect(grown, 'The counts are a ceiling. Send the new call through an RpcOperation.').toEqual( - [] - ) - }) - - it('reports a count that has fallen so the entry can be lowered', () => { - const overstated = inventory - .filter( - (entry) => observed.has(entry.file) && (observed.get(entry.file) ?? 0) < entry.references - ) - .map( - (entry) => `${entry.file}: listed ${entry.references}, found ${observed.get(entry.file)}` - ) - expect( - overstated, - 'Fewer references than listed — lower the count so the ratchet holds.' - ).toEqual([]) - }) -}) diff --git a/src/cli/handler-group-manifest.test.ts b/src/cli/handler-group-manifest.test.ts index 147305d72a5..6f7476630dd 100644 --- a/src/cli/handler-group-manifest.test.ts +++ b/src/cli/handler-group-manifest.test.ts @@ -1,5 +1,5 @@ import { readdirSync } from 'node:fs' -import { join, relative, sep } from 'node:path' +import { join, relative } from 'node:path' import { describe, expect, it } from 'vitest' import { buildHandlerRoutes, dispatch, type HandlerContext } from './dispatch' @@ -71,15 +71,6 @@ describe('handler group manifest', () => { } expect(unroutable).toEqual([]) }) - - it('finds the modules it is meant to guard', () => { - // Why: a walk that missed the tree would make the guard above vacuously pass. - const modules = listHandlerModules(HANDLERS_DIR) - expect(modules.length).toBeGreaterThanOrEqual(40) - expect( - modules.filter((file) => relative(HANDLERS_DIR, file).includes(sep)).length - ).toBeGreaterThanOrEqual(7) - }) }) describe('duplicate command keys', () => { diff --git a/src/main/claude/claude-cli-flag-support.test.ts b/src/main/claude/claude-cli-flag-support.test.ts index f61dda478f3..76d6a8ee7fa 100644 --- a/src/main/claude/claude-cli-flag-support.test.ts +++ b/src/main/claude/claude-cli-flag-support.test.ts @@ -161,22 +161,29 @@ describe('whether a launch passes a Claude CLI flag', () => { it('spends the budget finding the binary too, and caches nothing when that runs out', async () => { let slow = true + let slowKeyTimer: ReturnType | undefined const { support, calls } = supportWith( async () => '2.1.280', 30, async (command, cwd) => { if (slow) { - await new Promise((resolve) => setTimeout(resolve, 1_000)) + await new Promise((resolve) => { + slowKeyTimer = setTimeout(resolve, 1_000) + }) } return `${command}\n${cwd}` } ) - const started = performance.now() - await expect(thinking(support, LAUNCH)).resolves.toBe(false) - expect(performance.now() - started).toBeLessThan(500) - expect(calls).not.toHaveBeenCalled() - slow = false - await expect(thinking(support, LAUNCH)).resolves.toBe(true) + try { + const started = performance.now() + await expect(thinking(support, LAUNCH)).resolves.toBe(false) + expect(performance.now() - started).toBeLessThan(500) + expect(calls).not.toHaveBeenCalled() + slow = false + await expect(thinking(support, LAUNCH)).resolves.toBe(true) + } finally { + clearTimeout(slowKeyTimer) + } }) it('forgets the binaries launched least recently, not the ones in use', async () => { diff --git a/src/main/persistence/terminal-topology/terminal-leaf-move.test.ts b/src/main/persistence/terminal-topology/terminal-leaf-move.test.ts index 14ff2d7b764..92fffc4cd94 100644 --- a/src/main/persistence/terminal-topology/terminal-leaf-move.test.ts +++ b/src/main/persistence/terminal-topology/terminal-leaf-move.test.ts @@ -165,7 +165,16 @@ describe('moving a pane to a new tab', () => { }) it('records a persistence.terminal-topology span without pane keys or PTY ids', async () => { - const records: { name: string; attributes: Record }[] = [] + const records: { + name: string + attributes: Record + traceId: string + spanId: string + parentSpanId?: string + startTimeUnixNano: string + endTimeUnixNano: string + durationMs: number + }[] = [] setActiveSink({ push: (record) => { records.push(JSON.parse(JSON.stringify(record))) @@ -198,7 +207,30 @@ describe('moving a pane to a new tab', () => { 'topology.refusal': 'leaf_in_other_tab' } ]) - expect(JSON.stringify(spans)).not.toMatch(/pty-|tab-source|tab-target|2222/) + const transportFields = new Set([ + 'traceId', + 'spanId', + 'parentSpanId', + 'startTimeUnixNano', + 'endTimeUnixNano', + 'durationMs' + ]) + for (const span of spans) { + expect(span.traceId).toMatch(/^[a-f0-9]{32}$/) + expect(span.spanId).toMatch(/^[a-f0-9]{16}$/) + if (span.parentSpanId !== undefined) { + expect(span.parentSpanId).toMatch(/^[a-f0-9]{16}$/) + } + expect(span.startTimeUnixNano).toMatch(/^\d+$/) + expect(span.endTimeUnixNano).toMatch(/^\d+$/) + expect(Number.isFinite(span.durationMs)).toBe(true) + expect(span.durationMs).toBeGreaterThanOrEqual(0) + } + // Random IDs and clocks can coincidentally contain the leaf's numeric prefix. + const payloads = spans.map((span) => + Object.fromEntries(Object.entries(span).filter(([key]) => !transportFields.has(key))) + ) + expect(JSON.stringify(payloads)).not.toMatch(/pty-|tab-source|tab-target|2222/) } finally { _resetTracerForTests() } diff --git a/src/main/runtime/headless-runtime-graph.test.ts b/src/main/runtime/headless-runtime-graph.test.ts index a33778eb35f..e0d227162c5 100644 --- a/src/main/runtime/headless-runtime-graph.test.ts +++ b/src/main/runtime/headless-runtime-graph.test.ts @@ -1,5 +1,3 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from './orca-runtime' import { publishHeadlessRuntimeGraph } from './headless-runtime-graph' @@ -16,12 +14,4 @@ describe('headless runtime graph', () => { authoritative: true }) }) - - it('is published by orcad before its RPC server accepts a client', () => { - // Why a source check: orcad's startup needs a real profile store and pty host to run. - const entry = readFileSync(join(import.meta.dirname, '../orcad/orcad-entry.ts'), 'utf8') - const published = entry.indexOf('publishHeadlessRuntimeGraph(runtime)') - expect(published).toBeGreaterThan(-1) - expect(published).toBeLessThan(entry.indexOf('await rpc.start()')) - }) }) diff --git a/src/main/runtime/orca-runtime-tests/agent-status-and-waits-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/agent-status-and-waits-part-03.spec.ts index a1bcdf4b287..6bd6400b61e 100644 --- a/src/main/runtime/orca-runtime-tests/agent-status-and-waits-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/agent-status-and-waits-part-03.spec.ts @@ -81,15 +81,24 @@ describe('OrcaRuntimeService', () => { const [terminal] = (await runtime.listTerminals()).terminals - await expect( - runtime.sendTerminal( - terminal.handle, - { text: 'notes', enter: true }, - { inputKind: 'driving', beforeWrite, afterWrite } - ) - ).rejects.toThrow('terminal_not_writable') - expect(writes).toEqual(['notes', '\r']) - expect(beforeWrite).toHaveBeenCalledTimes(2) - expect(afterWrite).toHaveBeenCalledOnce() + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'], shouldClearNativeTimers: true }) + try { + await Promise.all([ + expect( + runtime.sendTerminal( + terminal.handle, + { text: 'notes', enter: true }, + { inputKind: 'driving', beforeWrite, afterWrite } + ) + ).rejects.toThrow('terminal_not_writable'), + vi.runAllTimersAsync() + ]) + expect(writes).toEqual(['notes', '\r']) + expect(beforeWrite).toHaveBeenCalledTimes(2) + expect(afterWrite).toHaveBeenCalledOnce() + } finally { + vi.clearAllTimers() + vi.useRealTimers() + } }) }) diff --git a/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts b/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts index 310a1e12c81..053b43fce6a 100644 --- a/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts +++ b/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts @@ -150,19 +150,28 @@ describe('OrcaRuntimeService', () => { nextCursor: expect.any(String) }) - const send = await runtime.sendTerminal( - terminal.handle, - { - text: 'continue', - enter: true - }, - { inputKind: 'driving' } - ) - expect(send).toMatchObject({ - handle: terminal.handle, - accepted: true - }) - expect(writes).toEqual(['continue', '\r']) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'], shouldClearNativeTimers: true }) + try { + const [send] = await Promise.all([ + runtime.sendTerminal( + terminal.handle, + { + text: 'continue', + enter: true + }, + { inputKind: 'driving' } + ), + vi.runAllTimersAsync() + ]) + expect(send).toMatchObject({ + handle: terminal.handle, + accepted: true + }) + expect(writes).toEqual(['continue', '\r']) + } finally { + vi.clearAllTimers() + vi.useRealTimers() + } }) it('reports permission from blocked terminal wait text', async () => { diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts index 4848177403a..3d6c24c5a86 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts @@ -464,9 +464,18 @@ describe('OrcaRuntimeService', () => { }) const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) - await runtime.sendTerminal(handle, { text: 'continue', enter: true }, { inputKind: 'driving' }) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'], shouldClearNativeTimers: true }) + try { + await Promise.all([ + runtime.sendTerminal(handle, { text: 'continue', enter: true }, { inputKind: 'driving' }), + vi.runAllTimersAsync() + ]) - expect(writes).toEqual(['continue', '\r']) + expect(writes).toEqual(['continue', '\r']) + } finally { + vi.clearAllTimers() + vi.useRealTimers() + } }) it('sends agent prompts as bracketed paste before submit', async () => { diff --git a/src/main/runtime/orchestration/send-agent-turn-boundary.test.ts b/src/main/runtime/orchestration/send-agent-turn-boundary.test.ts deleted file mode 100644 index 1c47ab967e8..00000000000 --- a/src/main/runtime/orchestration/send-agent-turn-boundary.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { resolve } from 'node:path' -import { describe, expect, it } from 'vitest' -import { scanSourceTree, stripComments } from '../../../shared/source-scan/source-tree-scan' - -/** - * Orca sends a message into an agent on another agent's behalf through `sendAgentTurn`, so there - * is one place where queue-or-now, and later the sender, are decided. These pins list who still - * reaches the send primitives directly, and why. Paths are relative to `src/main`. - */ -const MAIN_ROOT = resolve(__dirname, '..', '..') -const shipped = scanSourceTree(MAIN_ROOT).map((file) => ({ - ...file, - code: stripComments(file.source) -})) - -/** `.name`, `?.name` or `['name']` off `receiver`: a call, an optional call, a bind, or the method - * passed on as a value. An assignment to the member is its definition, not a use. Not caught: an - * alias of the receiver (`const h = host`) or a destructured method (`const { send } = host`). */ -function memberUse(name: string, receiver = ''): RegExp { - const from = receiver && String.raw`${receiver}\s*` - return new RegExp( - String.raw`${from}(?:\?\.|\.)\s*${name}\b(?!\s*=[^=>])` + - String.raw`|${from}(?:\?\.)?\[\s*['"\x60]${name}['"\x60]\s*\]` - ) -} - -const TERMINAL_PROMPT = memberUse('sendTerminalAgentPrompt') -// The raw terminal write: text, and Enter if asked, with no agent prompt handling. -const TERMINAL_WRITE = memberUse('sendTerminal') -const SEND_SETTLEMENT_WAIT = memberUse('waitForSendSettlement') -// The session host's own send, off anything named as a host: `host`, `requireInstalledHost()`, -// `(await runtime.ensureStructuredAgentSessionHost())`, `host!`, `(host as X)`. -const HOST_RECEIVER = String.raw`[Hh]ost(?:\([^()]*\))?!?(?:\s+as\s+[\w.]+)?\)?!?` -const STRUCTURED_SEND = new RegExp( - [ - memberUse('send', HOST_RECEIVER).source, - String.raw`(? pattern.test(file.code)) - .map((file) => file.relativePath) - .sort() -} - -describe('agent turn send boundary', () => { - it('finds a send primitive however it is reached', () => { - const found = (pattern: RegExp, line: string) => pattern.test(stripComments(line)) - for (const line of [ - 'await runtime.sendTerminalAgentPrompt(handle, prompt, options)', - 'await runtime.sendTerminalAgentPrompt?.(handle, prompt, options)', - 'await runtime\n .sendTerminalAgentPrompt(handle, prompt, options)', - 'const send = runtime.sendTerminalAgentPrompt.bind(runtime)', - "await runtime['sendTerminalAgentPrompt'](handle, prompt, options)" - ]) { - expect(found(TERMINAL_PROMPT, line), line).toBe(true) - } - for (const line of [ - 'await host.waitForSendSettlement?.(sessionId, id, { budgetMs })', - "await host?.['waitForSendSettlement'](sessionId, id, { budgetMs })" - ]) { - expect(found(SEND_SETTLEMENT_WAIT, line), line).toBe(true) - } - for (const line of [ - 'await host.send(caller, params)', - 'await args.host?.send(caller, params)', - 'await getStructuredAgentSessionHost()?.send(caller, params)', - 'await getStructuredAgentSessionHost()!.send(caller, params)', - 'await requireInstalledHost().send(caller, params)', - 'await (await runtime.ensureStructuredAgentSessionHost()).send(caller, params)', - 'await host!.send(caller, params)', - 'await (host as X).send(caller, params)', - 'await structuredHost.send?.(caller, params)', - "await host['send'](caller, params)", - 'return sendStructuredAgentSessionTurn(context, caller, params)' - ]) { - expect(found(STRUCTURED_SEND, line), line).toBe(true) - } - for (const line of [ - 'await runtime.sendTerminal(handle, { text, enter: true }, options)', - "await api?.['sendTerminal'](handle, { text }, options)" - ]) { - expect(found(TERMINAL_WRITE, line), line).toBe(true) - } - for (const [pattern, line] of [ - [TERMINAL_PROMPT, 'async sendTerminalAgentPrompt(handle, prompt, options) {'], - [TERMINAL_PROMPT, '// runtime.sendTerminalAgentPrompt(handle, prompt, options)'], - [SEND_SETTLEMENT_WAIT, 'this.waitForSendSettlement = this.sendSettlement.wait'], - [STRUCTURED_SEND, 'await host.sendTerminal(handle, action, options)'], - [TERMINAL_WRITE, 'await runtime.sendTerminalAgentPrompt(handle, prompt, options)'], - [ - STRUCTURED_SEND, - 'export function sendStructuredAgentSessionTurn(context, caller, params) {' - ], - [STRUCTURED_SEND, 'await sendAgentTurn({ kind, host, sessionId, callerKey, turn })'] - ] as const) { - expect(found(pattern, line), line).toBe(false) - } - }) - - it('lists the agent senders already moved onto sendAgentTurn', () => { - expect(filesMatching(/\bsendAgentTurn\s*\(/)).toEqual( - [ - 'runtime/orchestration/send-agent-turn.ts', - // The structured mail-pointer lane. - 'runtime/orchestration/structured-mailbox-pointer-host.ts', - // Dispatch preambles: structured worker, PTY worker, `dispatch --inject`, the - // coordinator loop, and a federated worker host. - 'runtime/rpc/methods/orchestration-structured-worker-session.ts', - 'runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts', - 'runtime/rpc/methods/orchestration/runs/dispatch-methods.ts', - 'runtime/orchestration/coordinator-task-dispatch.ts', - 'runtime/rpc/methods/orchestration/federation/federation.ts', - // A chat assignee's task, from `dispatch --inject` and `worker-start --terminal`. - 'runtime/rpc/methods/orchestration/chat-task-delivery.ts' - ].sort() - ) - }) - - it('types an agent prompt into a terminal only from the listed paths', () => { - expect( - filesMatching(TERMINAL_PROMPT), - 'A new direct sendTerminalAgentPrompt use. Send an agent message through sendAgentTurn.' - ).toEqual( - [ - // `agent.launch`: the prompt an agent is started with, by a user or another agent. With - // `reuseTerminal` it is typed into an agent already running. Temporary: it needs its own - // terminal purpose, and the sender in step B. - 'runtime/rpc/methods/agent-launch-terminal-prompt.ts', - // `terminal.send --enter` (agentPrompt): a user's or an agent's prompt to a named terminal, - // so it is an agent-to-agent send too. Not moved yet: it carries its own write guard, - // submit wait and receipts, and no task lead line. - 'runtime/rpc/methods/terminal/terminal-send-method.ts', - 'runtime/orchestration/send-agent-turn.ts' - ].sort() - ) - }) - - it('sends into a structured session directly only from the listed paths', () => { - expect( - filesMatching(STRUCTURED_SEND), - 'A new direct structured send. Send an agent message through sendAgentTurn.' - ).toEqual( - [ - 'runtime/orchestration/send-agent-turn.ts', - // The composer's `agentSession.send` RPC: the user's own message. - 'runtime/rpc/methods/structured-agent-session-send-compatibility.ts', - // `agent.launch` into a structured chat: the first turn of the chat that launch just - // created, so nothing can be queued ahead of it. The sender comes in step B. - 'runtime/rpc/methods/agent-launch-structured-prompt.ts', - // Orca's own restart continuation. - 'native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts', - 'native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts', - // The host's own `send`, which every path above reaches. - 'native-chat/agent-session-wire/structured-conversation-command-controller.ts', - // The pointer lane's port, whose `send` is sendAgentTurn in structured-mailbox-pointer-host. - 'runtime/orchestration/structured-mailbox-pointer-delivery.ts', - // Real-host test rigs the shared scan does not count as test files. - 'native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts', - 'acp/acp-structured-host.test-support.ts', - // An Electron WebContents IPC send, not a chat. - 'browser/doc-preview-guest-policy.ts' - ].sort() - ) - }) - - it('waits for a structured send to settle only in sendAgentTurn or the host itself', () => { - expect( - filesMatching(SEND_SETTLEMENT_WAIT), - 'A new send-then-wait block. Send an agent message through sendAgentTurn.' - ).toEqual( - [ - // The host exposing its waiter. - 'native-chat/agent-session-wire/structured-agent-session-host.ts', - // A /compact command, not a message. - 'native-chat/agent-session-wire/structured-conversation-compaction.ts', - // Orca's own restart continuation, which waits for hand-over and settlement separately. - 'native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts', - 'runtime/orchestration/send-agent-turn.ts', - // The composer's RPC, which waits only for clients that predate pending replies. - 'runtime/rpc/methods/structured-agent-session-send-compatibility.ts' - ].sort() - ) - }) - - it('types raw terminal text only from the listed paths', () => { - expect( - filesMatching(TERMINAL_WRITE), - 'A new direct sendTerminal use. Send an agent message through sendAgentTurn.' - ).toEqual( - [ - // Intended: a plugin's own terminal input, not a message on an agent's behalf. - 'plugins/plugin-host-service-bindings.ts', - // Intended: Claude's agent-teams tmux `send-keys`, keystrokes the Claude CLI itself - // issues; Orca relays them and is not the sender. - 'runtime/claude-agent-teams-tmux-dispatcher.ts', - // Intended: the runtime lending its own write to that agent-teams relay. - 'runtime/orca-runtime-resolve-terminal-split-source-authority.ts', - // Intended: a client's live keystroke stream, typed without Enter. - 'runtime/rpc/methods/terminal/terminal-input-delivery.ts', - // Temporary: `terminal.send` into a terminal with no settled agent prompt, which may still - // be an agent's message to another agent; moves with the `terminal.send` prompt above. - 'runtime/rpc/methods/terminal/terminal-send-method.ts' - ].sort() - ) - }) - - it('launches with a prompt only from agent.launch', () => { - // The launch-prompt helpers send any text into a chat or terminal; a new caller is a new sender. - expect( - filesMatching( - /\b(?:commitStructuredAgentSessionLaunchPrompt|deliverTerminalAgentLaunchPrompt)\b/ - ) - ).toEqual( - [ - 'runtime/rpc/methods/agent-launch-structured-prompt.ts', - 'runtime/rpc/methods/agent-launch-terminal-prompt.ts', - 'runtime/rpc/methods/agent-launch-surfaces.ts' - ].sort() - ) - }) - - it('pins the agent senders that use another primitive and are not moved yet', () => { - // The terminal mail pointer: it types the text, then a separately gated Enter with durable - // attempt states, which the prompt write does not do. - expect(filesMatching(/\bwriteOrchestrationPointer(?:Pty|WithSettlement)\b/)).toEqual( - [ - 'runtime/orchestration/mailbox-pointer-pty-write.ts', - 'runtime/orca-runtime-write-orchestration-pointer-pty.ts', - 'runtime/orca-runtime-stop-requested-pty-ids.ts' - ].sort() - ) - }) -}) diff --git a/src/main/runtime/terminal-input-kind-unchecked-call-sites.test.ts b/src/main/runtime/terminal-input-kind-unchecked-call-sites.test.ts deleted file mode 100644 index e806e88320c..00000000000 --- a/src/main/runtime/terminal-input-kind-unchecked-call-sites.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { resolve } from 'node:path' -import { describe, expect, it } from 'vitest' -import { scanSourceTree } from '../../shared/source-scan/source-tree-scan' -import { - findCallsMissingArgument, - type RequiredCallArgument -} from '../../shared/source-scan/call-argument-scan' - -/** - * Every PTY write names its input kind, and the compiler enforces that everywhere except the - * runtime files split out with `@ts-nocheck`. There a missing kind would compile and silently - * record nothing, so this scan is the ratchet for them. - */ -const MAIN_ROOT = resolve(__dirname, '..') - -const namesInputKind = (literal: string): boolean => /\binputKind\b|\.\.\./.test(literal) -const kindAt = (index: number, receiver?: RegExp): RequiredCallArgument => ({ - index, - ...(receiver ? { receiver } : {}), - acceptsObjectLiteral: namesInputKind -}) -const CONTROLLER = /[Cc]ontroller\??\s*$/ - -const KIND_ARGUMENT_BY_METHOD: Record = { - write: kindAt(2, CONTROLLER), - writeWithSettlement: kindAt(2, CONTROLLER), - sendTerminal: kindAt(2), - sendTerminalAgentPrompt: kindAt(2), - writeTerminalAction: kindAt(3), - writeTerminalInputChunks: kindAt(2), - writeTerminalAgentPrompt: kindAt(4), - writeAction: kindAt(3), - writeChunks: kindAt(2) -} - -// Why multiline: some unchecked files open with a lint directive before `@ts-nocheck`. -const uncheckedSources = scanSourceTree(MAIN_ROOT).filter((file) => - /^\/\/ @ts-nocheck\b/m.test(file.source) -) - -describe('PTY write call sites the compiler cannot check', () => { - it('scans the unchecked runtime files that write to a PTY', () => { - expect(uncheckedSources.map((file) => file.relativePath)).toEqual( - expect.arrayContaining([ - 'runtime/orca-runtime-deliver-pending-messages.ts', - 'runtime/orca-runtime-create-pty-headless-terminal-state.ts', - 'runtime/orca-runtime-write-terminal-agent-prompt.ts', - 'runtime/orca-runtime-sync-window-graph.ts' - ]) - ) - }) - - it('finds a write, prompt or send that leaves out its kind', () => { - const planted = [ - '', - 'this.ptyController?.write(ptyId, reply)', - "this.ptyController.write(ptyId, '\\r', 'launch')", - "await this.sendTerminal(handle, { text: 'a, b' }, { beforeWrite })", - 'await this.sendTerminalAgentPrompt(handle, prompt, { ...options })', - 'other.write(ptyId, data)', - 'const controller = this.ptyController; controller.write(ptyId, data)' - ].join('\n') - - expect(findCallsMissingArgument(planted, KIND_ARGUMENT_BY_METHOD)).toEqual([ - '2: .write(ptyId, reply)', - "4: .sendTerminal(handle, { text: 'a, b' }, { beforeWrite })", - '7: .write(ptyId, data)' - ]) - }) - - it('passes an input kind at every write', () => { - const missing = uncheckedSources.flatMap((file) => - findCallsMissingArgument(file.source, KIND_ARGUMENT_BY_METHOD).map( - (site) => `${file.relativePath}:${site}` - ) - ) - - expect(missing).toEqual([]) - }) -}) diff --git a/src/main/runtime/terminal-run-facts-input.test.ts b/src/main/runtime/terminal-run-facts-input.test.ts index a1f26d9b686..9e634b3b0cc 100644 --- a/src/main/runtime/terminal-run-facts-input.test.ts +++ b/src/main/runtime/terminal-run-facts-input.test.ts @@ -93,14 +93,23 @@ describe('run facts: the controller write funnel', () => { it('records terminal.send input before the write that could end the process', async () => { const run = await createFreshRun() - await run.runtime.sendTerminal( - run.handle, - { text: 'exit', enter: true }, - { inputKind: 'driving' } - ) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'], shouldClearNativeTimers: true }) + try { + await Promise.all([ + run.runtime.sendTerminal( + run.handle, + { text: 'exit', enter: true }, + { inputKind: 'driving' } + ), + vi.runAllTimersAsync() + ]) - expect(run.firstUserInputAt()).not.toBeNull() - expect(run.writes.map((write) => write.inputRecorded)).toEqual([true, true]) + expect(run.firstUserInputAt()).not.toBeNull() + expect(run.writes.map((write) => write.inputRecorded)).toEqual([true, true]) + } finally { + vi.clearAllTimers() + vi.useRealTimers() + } }) it('records stream input from a client', async () => { diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index bd37b20307c..5aa48ec60aa 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -1,5 +1,6 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' +import { cancelHistoryGc } from '../terminal-history-gc' import type { registerSshHandlers } from '../ipc/ssh' import type { registerRemoteWorkspaceHandlers } from '../ipc/remote-workspace' import type { registerDaemonManagementHandlers } from '../ipc/pty-management' @@ -206,6 +207,10 @@ async function fireReadyToShow(mainWindow: MainWindowStub): Promise { } describe('attachMainWindowServices', () => { + afterEach(() => { + cancelHistoryGc() + }) + beforeEach(() => { vi.resetAllMocks() systemPreferencesAskForMediaAccessMock.mockResolvedValue(true) diff --git a/src/renderer/src/components/editor/closed-editor-model-reentrancy.test.ts b/src/renderer/src/components/editor/closed-editor-model-reentrancy.test.ts deleted file mode 100644 index ad32f6fd0f0..00000000000 --- a/src/renderer/src/components/editor/closed-editor-model-reentrancy.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -// @vitest-environment happy-dom -import { afterEach, expect, it, vi } from 'vitest' -import { - createModelLifetimeFixture, - modelLifetimeFile, - modelLifetimeTextModel, - resetModelLifetimeFixtures -} from './editor-model-lifetime-fixture' -import { getDiffViewerMonacoModelPaths } from './diff-monaco-model-disposal' -import { - scrollTopCache, - editorSelectionCache, - diffViewStateCache, - pdfViewPositionCache -} from '@/lib/scroll-cache' -import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js' - -vi.mock('sonner', () => ({ toast: { info: vi.fn(), error: vi.fn(), success: vi.fn() } })) -afterEach(resetModelLifetimeFixtures) - -it('protects a later file reopened by an earlier model disposal in the same batch', async () => { - const { store, attach, add } = createModelLifetimeFixture() - const first = add('first') - const second = add('second') - attach() - const reopen = first.model.onWillDispose(() => store.setState({ openFiles: [second.file] })) - try { - store.getState().closeFile(first.file.id) - store.getState().closeFile(second.file.id) - await Promise.resolve() - expect(first.model.isDisposed()).toBe(true) - expect(second.model.isDisposed()).toBe(false) - } finally { - reopen.dispose() - } -}) - -it('preserves new same-URI cache entries installed by a model disposal callback', async () => { - const { store, attach, add } = createModelLifetimeFixture() - const { file, model } = add('cache-owner') - attach() - const selection = [new monaco.Selection(2, 1, 2, 3)] - const pdf = { pageNumber: 3, top: 4, left: 5 } - const reopen = model.onWillDispose(() => { - store.setState({ openFiles: [{ ...file, id: 'new-cache-owner' }] }) - scrollTopCache.set(file.filePath, 41) - scrollTopCache.set(`${file.filePath}::pane`, 42) - editorSelectionCache.set(file.filePath, selection) - editorSelectionCache.set(`${file.filePath}::pane`, selection) - pdfViewPositionCache.set(`${file.filePath}:pdf`, pdf) - pdfViewPositionCache.set(`${file.filePath}::pane:pdf`, pdf) - }) - try { - store.getState().closeFile(file.id) - await Promise.resolve() - expect(scrollTopCache.get(file.filePath)).toBe(41) - expect(scrollTopCache.get(`${file.filePath}::pane`)).toBe(42) - expect(editorSelectionCache.get(file.filePath)).toBe(selection) - expect(editorSelectionCache.get(`${file.filePath}::pane`)).toBe(selection) - expect(pdfViewPositionCache.get(`${file.filePath}:pdf`)).toBe(pdf) - expect(pdfViewPositionCache.get(`${file.filePath}::pane:pdf`)).toBe(pdf) - } finally { - reopen.dispose() - } -}) - -it('checks model and cache ownership after a diff callback reopens its owner and an earlier edit', async () => { - const { store, attach, add } = createModelLifetimeFixture() - const { file: editFile } = add('earlier-edit') - const diffFile = modelLifetimeFile('diff-reentrant', 'diff') - const paths = getDiffViewerMonacoModelPaths({ modelKey: diffFile.id, generationSuffix: '' }) - const original = modelLifetimeTextModel(paths.originalModelPath) - const modified = modelLifetimeTextModel(paths.modifiedModelPath) - store.setState({ openFiles: [editFile, diffFile] }) - attach() - const scroll = { scrollTop: 1, scrollLeft: 2 } - const reopen = original.onWillDispose(() => { - store.setState({ openFiles: [{ ...editFile, id: 'edit-successor' }, diffFile] }) - scrollTopCache.set(`${editFile.filePath}::pane`, 71) - scrollTopCache.set(`${diffFile.id}:preview`, 72) - scrollTopCache.set(`${diffFile.id}::pane`, 73) - diffViewStateCache.set(diffFile.id, { original: null, modified: null, modelState: scroll }) - diffViewStateCache.set(`${diffFile.id}::pane`, { - original: null, - modified: null, - modelState: scroll - }) - }) - try { - store.getState().closeFile(editFile.id) - store.getState().closeFile(diffFile.id) - await Promise.resolve() - expect(original.isDisposed()).toBe(true) - expect(modified.isDisposed()).toBe(false) - expect(scrollTopCache.get(`${editFile.filePath}::pane`)).toBe(71) - expect(scrollTopCache.get(`${diffFile.id}:preview`)).toBe(72) - expect(scrollTopCache.get(`${diffFile.id}::pane`)).toBe(73) - expect(diffViewStateCache.get(diffFile.id)?.modelState).toBe(scroll) - expect(diffViewStateCache.get(`${diffFile.id}::pane`)?.modelState).toBe(scroll) - } finally { - reopen.dispose() - } -}) diff --git a/src/renderer/src/components/editor/closed-editor-model-registry.test.ts b/src/renderer/src/components/editor/closed-editor-model-registry.test.ts index 91818243edf..5ddfde59d86 100644 --- a/src/renderer/src/components/editor/closed-editor-model-registry.test.ts +++ b/src/renderer/src/components/editor/closed-editor-model-registry.test.ts @@ -10,7 +10,12 @@ import { resetModelLifetimeFixtures } from './editor-model-lifetime-fixture' import { getDiffViewerMonacoModelPaths } from './diff-monaco-model-disposal' -import { scrollTopCache, pdfViewPositionCache } from '@/lib/scroll-cache' +import { + scrollTopCache, + editorSelectionCache, + diffViewStateCache, + pdfViewPositionCache +} from '@/lib/scroll-cache' vi.mock('sonner', () => ({ toast: { info: vi.fn(), error: vi.fn(), success: vi.fn() } })) afterEach(resetModelLifetimeFixtures) @@ -192,3 +197,87 @@ it('retries the remaining captured batch after reentrant registry replacement', unregisterNext?.() } }) + +it('protects a later file reopened by an earlier model disposal in the same batch', async () => { + const { store, attach, add } = createModelLifetimeFixture() + const first = add('first') + const second = add('second') + attach() + const reopen = first.model.onWillDispose(() => store.setState({ openFiles: [second.file] })) + try { + store.getState().closeFile(first.file.id) + store.getState().closeFile(second.file.id) + await Promise.resolve() + expect(first.model.isDisposed()).toBe(true) + expect(second.model.isDisposed()).toBe(false) + } finally { + reopen.dispose() + } +}) + +it('preserves new same-URI cache entries installed by a model disposal callback', async () => { + const { store, attach, add } = createModelLifetimeFixture() + const { file, model } = add('cache-owner') + attach() + const selection = [new monaco.Selection(2, 1, 2, 3)] + const pdf = { pageNumber: 3, top: 4, left: 5 } + const reopen = model.onWillDispose(() => { + store.setState({ openFiles: [{ ...file, id: 'new-cache-owner' }] }) + scrollTopCache.set(file.filePath, 41) + scrollTopCache.set(`${file.filePath}::pane`, 42) + editorSelectionCache.set(file.filePath, selection) + editorSelectionCache.set(`${file.filePath}::pane`, selection) + pdfViewPositionCache.set(`${file.filePath}:pdf`, pdf) + pdfViewPositionCache.set(`${file.filePath}::pane:pdf`, pdf) + }) + try { + store.getState().closeFile(file.id) + await Promise.resolve() + expect(scrollTopCache.get(file.filePath)).toBe(41) + expect(scrollTopCache.get(`${file.filePath}::pane`)).toBe(42) + expect(editorSelectionCache.get(file.filePath)).toBe(selection) + expect(editorSelectionCache.get(`${file.filePath}::pane`)).toBe(selection) + expect(pdfViewPositionCache.get(`${file.filePath}:pdf`)).toBe(pdf) + expect(pdfViewPositionCache.get(`${file.filePath}::pane:pdf`)).toBe(pdf) + } finally { + reopen.dispose() + } +}) + +it('checks model and cache ownership after a diff callback reopens its owner and an earlier edit', async () => { + const { store, attach, add } = createModelLifetimeFixture() + const { file: editFile } = add('earlier-edit') + const diffFile = modelLifetimeFile('diff-reentrant', 'diff') + const paths = getDiffViewerMonacoModelPaths({ modelKey: diffFile.id, generationSuffix: '' }) + const original = modelLifetimeTextModel(paths.originalModelPath) + const modified = modelLifetimeTextModel(paths.modifiedModelPath) + store.setState({ openFiles: [editFile, diffFile] }) + attach() + const scroll = { scrollTop: 1, scrollLeft: 2 } + const reopen = original.onWillDispose(() => { + store.setState({ openFiles: [{ ...editFile, id: 'edit-successor' }, diffFile] }) + scrollTopCache.set(`${editFile.filePath}::pane`, 71) + scrollTopCache.set(`${diffFile.id}:preview`, 72) + scrollTopCache.set(`${diffFile.id}::pane`, 73) + diffViewStateCache.set(diffFile.id, { original: null, modified: null, modelState: scroll }) + diffViewStateCache.set(`${diffFile.id}::pane`, { + original: null, + modified: null, + modelState: scroll + }) + }) + try { + store.getState().closeFile(editFile.id) + store.getState().closeFile(diffFile.id) + await Promise.resolve() + expect(original.isDisposed()).toBe(true) + expect(modified.isDisposed()).toBe(false) + expect(scrollTopCache.get(`${editFile.filePath}::pane`)).toBe(71) + expect(scrollTopCache.get(`${diffFile.id}:preview`)).toBe(72) + expect(scrollTopCache.get(`${diffFile.id}::pane`)).toBe(73) + expect(diffViewStateCache.get(diffFile.id)?.modelState).toBe(scroll) + expect(diffViewStateCache.get(`${diffFile.id}::pane`)?.modelState).toBe(scroll) + } finally { + reopen.dispose() + } +}) diff --git a/src/renderer/src/components/editor/markdown-preview-table-chunks.test.ts b/src/renderer/src/components/editor/markdown-preview-table-chunks.test.ts index 4a0e50ac922..bdf4eec542c 100644 --- a/src/renderer/src/components/editor/markdown-preview-table-chunks.test.ts +++ b/src/renderer/src/components/editor/markdown-preview-table-chunks.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from 'vitest' +import { unified } from 'unified' +import remarkParse from 'remark-parse' +import remarkGfm from 'remark-gfm' import type { Nodes } from 'hast' +import { + markdownPreviewMinimumRowHeight, + markdownPreviewRequestIndices, + markdownPreviewViewportIndices +} from './markdown-preview-viewport-budget' import { MarkdownPreviewDocumentEngine } from './markdown-preview-document-engine' import { parseMarkdownPreviewDocument, @@ -150,3 +158,107 @@ describe('large preview table row groups', () => { expect(renderMarkdownPreviewBlock(spanning.children[0], 0).oversized).toBe(true) }) }) + +describe('large preview viewport budgets', () => { + it('keeps visible rows and a composer below the viewport ahead of overscan', () => { + const indices = markdownPreviewViewportIndices( + { startIndex: 10, endIndex: 44, overscan: 3, count: 100 }, + 90 + ) + expect(indices).toHaveLength(40) + expect(indices).toContain(90) + for (let index = 10; index <= 44; index += 1) { + expect(indices).toContain(index) + } + expect(indices).toEqual([...indices].sort((a, b) => a - b)) + }) + + it('reserves enough row height for partial rows, overscan and a pinned note at any viewport size', () => { + for (const height of [500, 1500, 8000]) { + const minimum = markdownPreviewMinimumRowHeight(height) + const visible = Math.ceil(height / minimum) + 2 + expect(visible + 6 + 1).toBeLessThanOrEqual(40) + } + }) + + it('makes Find targets revealable even when surrounding blocks exhaust the node budget', async () => { + const engine = new MarkdownPreviewDocumentEngine() + engine.load( + Array.from( + { length: 20 }, + (_, index) => `| a | b |\n| --- | --- |\n${`| Target${index} | b |\n`.repeat(100)}` + ).join('\n\n') + ) + const matches = await engine.search('Target19') + expect(matches?.matches.length).toBe(100) + const indices = Array.from({ length: 20 }, (_, index) => index) + expect(engine.blocks(indices).find((block) => block.index === 19)?.oversized).toBe(true) + const prioritized = markdownPreviewRequestIndices(indices, [19, 0]) + expect(prioritized[0]).toBe(19) + expect(engine.blocks(prioritized).find((block) => block.index === 19)?.oversized).toBe(false) + }) + + it('preserves descendant source ranges for generated footnote sections', () => { + const engine = new MarkdownPreviewDocumentEngine() + const document = engine.load('# Start\n\nNote[^n].\n\n[^n]: Footnote note\n') + const footnotes = document.blocks.at(-1) + expect(footnotes?.sourceLine).toBe(5) + expect(footnotes?.sourceEndLine).toBe(5) + }) +}) + +const parser = unified().use(remarkParse).use(remarkGfm) + +describe('Markdown table parsing', () => { + it('preserves alignment, escaped pipes, inline syntax, and source positions', () => { + const source = + '| left | right |\n| :- | -: |\n| escaped \\| pipe | `code` |\n| ~~deleted~~ | [link][r] |\n\n[r]: https://example.com\n' + const table = parser.parse(source).children[0] + expect(table.type).toBe('table') + if (table.type !== 'table') { + throw new Error('Expected a table') + } + expect(table.align).toEqual(['left', 'right']) + expect(table.position).toMatchObject({ + start: { line: 1, column: 1, offset: 0 }, + end: { line: 4, column: 28 } + }) + expect(table.children[1].children.map((cell) => cell.children)).toMatchObject([ + [{ type: 'text', value: 'escaped | pipe' }], + [{ type: 'inlineCode', value: 'code' }] + ]) + expect(table.children[2].children.map((cell) => cell.children)).toMatchObject([ + [{ type: 'delete', children: [{ type: 'text', value: 'deleted' }] }], + [{ type: 'linkReference', identifier: 'r' }] + ]) + }) + + it('keeps tables inside block quotes and list items', () => { + const quote = parser.parse('> | a | b |\n> | --- | --- |\n> | x | y |\n').children[0] + expect(quote).toMatchObject({ type: 'blockquote', children: [{ type: 'table' }] }) + const list = parser.parse('- item\n\n | a | b |\n | --- | --- |\n | x | y |\n').children[0] + expect(list).toMatchObject({ + type: 'list', + children: [{ type: 'listItem', children: [{ type: 'paragraph' }, { type: 'table' }] }] + }) + }) + + it('parses thousands of tables with global references and original line ranges', () => { + const section = '## Table\n\n[Reference][later]\n\n| a | b |\n| --- | --- |\n| x | y |\n\n' + const { tree, document } = parseMarkdownPreviewDocument( + `${section.repeat(2000)}[later]: https://example.com\n` + ) + expect(document.blocks).toHaveLength(6000) + expect(document.toc).toHaveLength(2000) + expect(document.blocks.at(-1)).toMatchObject({ + sourceLine: 1999 * 8 + 5, + sourceEndLine: 1999 * 8 + 7 + }) + expect(tree.children.at(-1)).toMatchObject({ type: 'element', tagName: 'table' }) + expect(tree.children[5998]).toMatchObject({ + type: 'element', + tagName: 'p', + children: [{ type: 'element', tagName: 'a', properties: { href: 'https://example.com' } }] + }) + }) +}) diff --git a/src/renderer/src/components/editor/markdown-preview-table-parsing.test.ts b/src/renderer/src/components/editor/markdown-preview-table-parsing.test.ts deleted file mode 100644 index d120e607b5a..00000000000 --- a/src/renderer/src/components/editor/markdown-preview-table-parsing.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { unified } from 'unified' -import remarkParse from 'remark-parse' -import remarkGfm from 'remark-gfm' -import { parseMarkdownPreviewDocument } from './markdown-preview-document-tree' - -const parser = unified().use(remarkParse).use(remarkGfm) - -describe('Markdown table parsing', () => { - it('preserves alignment, escaped pipes, inline syntax, and source positions', () => { - const source = - '| left | right |\n| :- | -: |\n| escaped \\| pipe | `code` |\n| ~~deleted~~ | [link][r] |\n\n[r]: https://example.com\n' - const table = parser.parse(source).children[0] - expect(table.type).toBe('table') - if (table.type !== 'table') { - throw new Error('Expected a table') - } - expect(table.align).toEqual(['left', 'right']) - expect(table.position).toMatchObject({ - start: { line: 1, column: 1, offset: 0 }, - end: { line: 4, column: 28 } - }) - expect(table.children[1].children.map((cell) => cell.children)).toMatchObject([ - [{ type: 'text', value: 'escaped | pipe' }], - [{ type: 'inlineCode', value: 'code' }] - ]) - expect(table.children[2].children.map((cell) => cell.children)).toMatchObject([ - [{ type: 'delete', children: [{ type: 'text', value: 'deleted' }] }], - [{ type: 'linkReference', identifier: 'r' }] - ]) - }) - - it('keeps tables inside block quotes and list items', () => { - const quote = parser.parse('> | a | b |\n> | --- | --- |\n> | x | y |\n').children[0] - expect(quote).toMatchObject({ type: 'blockquote', children: [{ type: 'table' }] }) - const list = parser.parse('- item\n\n | a | b |\n | --- | --- |\n | x | y |\n').children[0] - expect(list).toMatchObject({ - type: 'list', - children: [{ type: 'listItem', children: [{ type: 'paragraph' }, { type: 'table' }] }] - }) - }) - - it('parses thousands of tables with global references and original line ranges', () => { - const section = '## Table\n\n[Reference][later]\n\n| a | b |\n| --- | --- |\n| x | y |\n\n' - const { tree, document } = parseMarkdownPreviewDocument( - `${section.repeat(2000)}[later]: https://example.com\n` - ) - expect(document.blocks).toHaveLength(6000) - expect(document.toc).toHaveLength(2000) - expect(document.blocks.at(-1)).toMatchObject({ - sourceLine: 1999 * 8 + 5, - sourceEndLine: 1999 * 8 + 7 - }) - expect(tree.children.at(-1)).toMatchObject({ type: 'element', tagName: 'table' }) - expect(tree.children[5998]).toMatchObject({ - type: 'element', - tagName: 'p', - children: [{ type: 'element', tagName: 'a', properties: { href: 'https://example.com' } }] - }) - }) -}) diff --git a/src/renderer/src/components/editor/markdown-preview-viewport-budget.test.ts b/src/renderer/src/components/editor/markdown-preview-viewport-budget.test.ts deleted file mode 100644 index b0d3b37a240..00000000000 --- a/src/renderer/src/components/editor/markdown-preview-viewport-budget.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - markdownPreviewMinimumRowHeight, - markdownPreviewRequestIndices, - markdownPreviewViewportIndices -} from './markdown-preview-viewport-budget' -import { MarkdownPreviewDocumentEngine } from './markdown-preview-document-engine' - -describe('large preview viewport budgets', () => { - it('keeps visible rows and a composer below the viewport ahead of overscan', () => { - const indices = markdownPreviewViewportIndices( - { startIndex: 10, endIndex: 44, overscan: 3, count: 100 }, - 90 - ) - expect(indices).toHaveLength(40) - expect(indices).toContain(90) - for (let index = 10; index <= 44; index += 1) { - expect(indices).toContain(index) - } - expect(indices).toEqual([...indices].sort((a, b) => a - b)) - }) - - it('reserves enough row height for partial rows, overscan and a pinned note at any viewport size', () => { - for (const height of [500, 1500, 8000]) { - const minimum = markdownPreviewMinimumRowHeight(height) - const visible = Math.ceil(height / minimum) + 2 - expect(visible + 6 + 1).toBeLessThanOrEqual(40) - } - }) - - it('makes Find targets revealable even when surrounding blocks exhaust the node budget', async () => { - const engine = new MarkdownPreviewDocumentEngine() - engine.load( - Array.from( - { length: 20 }, - (_, index) => `| a | b |\n| --- | --- |\n${`| Target${index} | b |\n`.repeat(100)}` - ).join('\n\n') - ) - const matches = await engine.search('Target19') - expect(matches?.matches.length).toBe(100) - const indices = Array.from({ length: 20 }, (_, index) => index) - expect(engine.blocks(indices).find((block) => block.index === 19)?.oversized).toBe(true) - const prioritized = markdownPreviewRequestIndices(indices, [19, 0]) - expect(prioritized[0]).toBe(19) - expect(engine.blocks(prioritized).find((block) => block.index === 19)?.oversized).toBe(false) - }) - - it('preserves descendant source ranges for generated footnote sections', () => { - const engine = new MarkdownPreviewDocumentEngine() - const document = engine.load('# Start\n\nNote[^n].\n\n[^n]: Footnote note\n') - const footnotes = document.blocks.at(-1) - expect(footnotes?.sourceLine).toBe(5) - expect(footnotes?.sourceEndLine).toBe(5) - }) -}) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.bounds.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.bounds.test.tsx index f17ff0a7f63..bb1ba9561bc 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.bounds.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.bounds.test.tsx @@ -11,7 +11,11 @@ import { requestFloatingTerminalOpenMaximized } from '@/lib/floating-terminal' import { FLOATING_TERMINAL_PANEL_VIEW_STATE_STORAGE_KEY } from './floating-terminal-panel-view-state' -import { setupFloatingTerminalPanelTest } from './floating-terminal-panel-test-harness' +import { + setupFloatingTerminalPanelTest, + mocks, + hookRuntime +} from './floating-terminal-panel-test-harness' import { findByProp, findByTypeName, @@ -20,150 +24,144 @@ import { getPanelStyleBounds, renderPanel, runEffects, - setViewport + setViewport, + collectPropValues, + flushAsyncWork, + type ReactElementLike } from './floating-terminal-panel-render-probe' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { + makeTab, + setFloatingTabs, + storeBox, + type FloatingPanelStoreState, + makeFile, + setFloatingEditorTabs +} from './floating-terminal-panel-test-fixtures' +import { createUntitledMarkdownFileWithTemplateSelection } from '@/lib/create-untitled-markdown' +import { ORCA_EDITOR_REQUEST_FILE_CLOSE_EVENT } from '@/components/editor/editor-autosave' vi.mock('zustand/react/shallow', () => ({ // Why: zustand resolves the real react (unmocked in node_modules); the memo wrapper is inert here. useShallow: (selector: unknown) => selector })) - vi.mock('react', async () => { const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() const { createReactHookOverrides } = await import('./floating-terminal-panel-test-module-mocks') return { ...actual, ...createReactHookOverrides() } }) - vi.mock('@/store', async () => { return (await import('./floating-terminal-panel-test-module-mocks')).createAppStoreModule() }) - vi.mock('@/components/tab-bar/TabBar', async () => { return (await import('./floating-terminal-panel-component-stubs')).createTabBarModule() }) - vi.mock('@/components/terminal-pane/TerminalPane', async () => { return (await import('./floating-terminal-panel-component-stubs')).createTerminalPaneModule() }) - vi.mock('@/components/terminal-pane/use-terminal-tab-cold-parking', async () => { return (await import('./floating-terminal-panel-test-module-mocks')).createColdParkingModule() }) - vi.mock('@/components/terminal-pane/terminal-parked-tab-watchers', async () => { return ( await import('./floating-terminal-panel-test-module-mocks') ).createParkedTabWatchersModule() }) - vi.mock('@/components/terminal-pane/terminal-ime-input-context-refresh', async () => { return ( await import('./floating-terminal-panel-test-module-mocks') ).createImeInputContextRefreshModule() }) - vi.mock('@/components/terminal/terminal-tab-actions', async () => { return ( await import('./floating-terminal-panel-test-module-mocks') ).createTerminalTabActionsModule() }) - vi.mock('@/store/pinned-tab-close-guard', async () => { return ( await import('./floating-terminal-panel-test-module-mocks') ).createPinnedTabCloseGuardModule() }) - vi.mock('@/components/browser-pane/BrowserPane', async () => { return (await import('./floating-terminal-panel-component-stubs')).createBrowserPaneModule() }) - vi.mock('@/components/emulator-pane/EmulatorPane', async () => { return (await import('./floating-terminal-panel-component-stubs')).createEmulatorPaneModule() }) - vi.mock('@/components/editor/EditorPanel', async () => { return (await import('./floating-terminal-panel-component-stubs')).createEditorPanelModule() }) - vi.mock('@/components/ui/button', async () => { return (await import('./floating-terminal-panel-component-stubs')).createButtonModule() }) - vi.mock('@/components/contextual-tours/use-contextual-tour', async () => { return (await import('./floating-terminal-panel-test-module-mocks')).createContextualTourModule() }) - vi.mock('@/components/ui/dialog', async () => { return (await import('./floating-terminal-panel-component-stubs')).createDialogModule() }) - vi.mock('@/components/terminal/useTerminalSaveDialog', async () => { return ( await import('./floating-terminal-panel-test-module-mocks') ).createTerminalSaveDialogModule() }) - vi.mock('@/runtime/web-runtime-session', async () => { return ( await import('./floating-terminal-panel-test-module-mocks') ).createWebRuntimeSessionModule() }) - vi.mock('@/lib/connection-context', async () => { return ( await import('./floating-terminal-panel-test-module-mocks') ).createConnectionContextModule() }) - -// Why inline and sync: this module is imported by the test file itself, so an async factory -// resolves too late and the panel ends up calling a second copy of the mock. vi.mock('@/lib/create-untitled-markdown', () => ({ createUntitledMarkdownFileWithTemplateSelection: vi.fn() })) - vi.mock('@/lib/ipc-error', async () => { return (await import('./floating-terminal-panel-test-module-mocks')).createIpcErrorModule() }) - vi.mock('sonner', async () => { return (await import('./floating-terminal-panel-test-module-mocks')).createSonnerModule() }) - vi.mock('@/lib/focus-terminal-tab-surface', async () => { return ( await import('./floating-terminal-panel-test-module-mocks') ).createFocusTerminalTabSurfaceModule() }) - vi.mock('@/lib/orchestration-setup-state', async () => { return ( await import('./floating-terminal-panel-test-module-mocks') ).createOrchestrationSetupStateModule() }) - vi.mock('./FloatingTerminalOrchestrationDialog', async () => { return ( await import('./floating-terminal-panel-component-stubs') ).createOrchestrationDialogModule() }) - vi.mock('./FloatingTerminalResizeHandles', async () => { return (await import('./floating-terminal-panel-component-stubs')).createResizeHandlesModule() }) - vi.mock('./FloatingTerminalToggleButton', async () => { return (await import('./floating-terminal-panel-component-stubs')).createToggleButtonModule() }) - vi.mock('./FloatingTerminalWindowControls', async () => { return (await import('./floating-terminal-panel-component-stubs')).createWindowControlsModule() }) - vi.mock('@/components/ShortcutKeyCombo', async () => { return (await import('./floating-terminal-panel-component-stubs')).createShortcutKeyComboModule() }) +function requestedEditorCloses(): string[] { + return vi + .mocked(window.dispatchEvent) + .mock.calls.flatMap(([event]) => + event.type === ORCA_EDITOR_REQUEST_FILE_CLOSE_EVENT && event instanceof CustomEvent + ? [event.detail.fileId] + : [] + ) +} + describe('FloatingTerminalPanel close behavior', () => { beforeEach(setupFloatingTerminalPanelTest) @@ -487,3 +485,299 @@ describe('FloatingTerminalPanel close behavior', () => { expect(getPanelStyleBounds(element)).toEqual(savedBounds) }) }) + +describe('FloatingTerminalPanel empty state', () => { + beforeEach(setupFloatingTerminalPanelTest) + + afterEach(() => { + vi.unstubAllGlobals() + }) + it('does not bootstrap a terminal tab when the panel opens empty', async () => { + await renderPanel(false) + runEffects() + await flushAsyncWork() + expect(mocks.createTab).not.toHaveBeenCalled() + + await renderPanel(true) + runEffects() + await flushAsyncWork() + expect(mocks.createTab).not.toHaveBeenCalled() + + await renderPanel(true) + runEffects() + await flushAsyncWork() + expect(mocks.createTab).not.toHaveBeenCalled() + + await renderPanel(false) + runEffects() + await renderPanel(true) + runEffects() + await flushAsyncWork() + expect(mocks.createTab).not.toHaveBeenCalled() + }) + + it('requests the floating workspace tour only when the panel is open', async () => { + const persisted = Promise.resolve() + + await renderPanel(false, vi.fn(), { + wasPreviouslyInteracted: false, + persisted, + recordFeatureInteractionForTour: false + }) + + expect(mocks.useContextualTour).toHaveBeenLastCalledWith( + 'floating-workspace', + false, + 'floating_workspace_visible', + { + recordFeatureInteraction: false, + featureInteractionPersisted: persisted, + wasFeaturePreviouslyInteracted: false + } + ) + + await renderPanel(true, vi.fn(), { + wasPreviouslyInteracted: true, + persisted, + recordFeatureInteractionForTour: false + }) + + expect(mocks.useContextualTour).toHaveBeenLastCalledWith( + 'floating-workspace', + true, + 'floating_workspace_visible', + { + recordFeatureInteraction: false, + featureInteractionPersisted: persisted, + wasFeaturePreviouslyInteracted: true + } + ) + }) + + it('records the floating workspace tour interaction when the open snapshot deferred persistence', async () => { + await renderPanel(true, vi.fn(), { + wasPreviouslyInteracted: false, + recordFeatureInteractionForTour: true + }) + + expect(mocks.useContextualTour).toHaveBeenLastCalledWith( + 'floating-workspace', + true, + 'floating_workspace_visible', + { + recordFeatureInteraction: true, + featureInteractionPersisted: undefined, + wasFeaturePreviouslyInteracted: false + } + ) + }) + + it('targets the empty-state actions without co-mounting the surface fallback', async () => { + const element = await renderPanel(true) + const emptyState = findByTypeName(element, 'FloatingTerminalEmptyState') + const renderedEmptyState = ( + emptyState.type as (props: Record) => ReactElementLike + )(emptyState.props) + + expect(collectPropValues(element, 'data-contextual-tour-target')).not.toContain( + 'floating-workspace-surface' + ) + expect(collectPropValues(renderedEmptyState, 'data-contextual-tour-target')).toEqual([ + 'floating-workspace-new-terminal', + 'floating-workspace-new-markdown' + ]) + }) + + it('targets the non-empty panel surface when the empty-state actions are absent', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' })]) + + const element = await renderPanel(true) + + expect(() => findByTypeName(element, 'FloatingTerminalEmptyState')).toThrow( + 'FloatingTerminalEmptyState not found' + ) + expect(collectPropValues(element, 'data-contextual-tour-target')).toContain( + 'floating-workspace-surface' + ) + expect(collectPropValues(element, 'data-contextual-tour-target')).not.toContain( + 'floating-workspace-new-terminal' + ) + expect(collectPropValues(element, 'data-contextual-tour-target')).not.toContain( + 'floating-workspace-new-markdown' + ) + }) + + it('keeps the split surface mounted when its focused group has no tabs', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' })]) + const state = storeBox.state as FloatingPanelStoreState + state.groupsByWorktree[FLOATING_TERMINAL_WORKTREE_ID].push({ + id: 'empty-group', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + activeTabId: null, + tabOrder: [] + }) + state.activeGroupIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID] = 'empty-group' + state.layoutByWorktree[FLOATING_TERMINAL_WORKTREE_ID] = { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'floating-group' }, + second: { type: 'leaf', groupId: 'empty-group' } + } + + const element = await renderPanel(true) + + expect(() => findByTypeName(element, 'FloatingTerminalEmptyState')).toThrow( + 'FloatingTerminalEmptyState not found' + ) + expect(findByTypeName(element, 'TabGroupSplitNodeTree').props.focusedGroupId).toBe( + 'empty-group' + ) + }) + + it('minimizes the empty floating workspace from the empty state', async () => { + const onOpenChange = vi.fn() + const element = await renderPanel(true, onOpenChange) + + const emptyState = findByTypeName(element, 'FloatingTerminalEmptyState') + ;(emptyState.props.onClose as () => void)() + + expect(onOpenChange).toHaveBeenCalledWith(false) + expect(mocks.closeTab).not.toHaveBeenCalled() + expect(mocks.closeFile).not.toHaveBeenCalled() + expect(mocks.closeBrowserTab).not.toHaveBeenCalled() + }) + + it('shows the empty state when only stale unified tabs remain', async () => { + const state = storeBox.state as FloatingPanelStoreState + const staleTab = makeTab({ id: 'stale-tab' }) + setFloatingTabs([staleTab]) + state.tabsByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: [] } + state.activeTabIdByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: null } + + const element = await renderPanel(true) + const emptyState = findByTypeName(element, 'FloatingTerminalEmptyState') + + expect(emptyState).toBeTruthy() + }) +}) + +describe('FloatingTerminalPanel markdown editor', () => { + beforeEach(setupFloatingTerminalPanelTest) + + afterEach(() => { + vi.unstubAllGlobals() + }) + it('creates floating markdown files in local filesystem mode', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' })]) + vi.mocked(createUntitledMarkdownFileWithTemplateSelection).mockResolvedValue({ + filePath: '/tmp/orca/floating-notes/untitled.md', + relativePath: 'untitled.md', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + language: 'markdown', + isUntitled: true, + mode: 'edit' + }) + + let element = await renderPanel(true) + runEffects() + await flushAsyncWork() + element = await renderPanel(true) + const tabBar = findByTypeName(element, 'TabBar') + ;(tabBar.props.onNewFileTab as () => void)() + await flushAsyncWork() + + expect(createUntitledMarkdownFileWithTemplateSelection).toHaveBeenCalledWith( + '/tmp/orca/floating-notes', + FLOATING_TERMINAL_WORKTREE_ID, + undefined, + { activeRuntimeEnvironmentId: null } + ) + expect(mocks.openFile).toHaveBeenCalledWith( + expect.objectContaining({ filePath: '/tmp/orca/floating-notes/untitled.md' }), + expect.objectContaining({ suppressActiveRuntimeFallback: true }) + ) + }) + + it('opens existing markdown documents through the floating picker', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' })]) + mocks.pickFloatingMarkdownDocument.mockResolvedValue({ + filePath: '/tmp/orca/notes.md', + relativePath: 'notes.md', + basename: 'notes.md', + name: 'notes' + }) + + const element = await renderPanel(true) + const tabBar = findByTypeName(element, 'TabBar') + ;(tabBar.props.onOpenFileTab as () => void)() + await flushAsyncWork() + + expect(mocks.pickFloatingMarkdownDocument).toHaveBeenCalledWith() + expect(mocks.openFile).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: '/tmp/orca/notes.md', + relativePath: 'notes.md', + runtimeEnvironmentId: null, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID + }), + expect.objectContaining({ suppressActiveRuntimeFallback: true }) + ) + }) + + it('disables markdown annotations in floating editor tabs', async () => { + setFloatingEditorTabs([makeFile({ id: 'notes' })]) + + const element = await renderPanel(true) + // The editor renders inside the shared group tree; the panel's contract is the policy + // it passes down (scratch markdown exposes no agent annotations). + const tree = findByTypeName(element, 'TabGroupSplitNodeTree') + + expect(tree.props.markdownAnnotationsEnabled).toBe(false) + expect(tree.props.isWorktreeActive).toBe(true) + }) + + it('marks the retained floating editor hidden when the panel is closed', async () => { + setFloatingEditorTabs([makeFile({ id: 'notes' })]) + + const element = await renderPanel(false) + + expect(findByTypeName(element, 'TabGroupSplitNodeTree').props.isWorktreeActive).toBe(false) + }) + + it('routes every dirty editor from close-all-files to the central save queue', async () => { + setFloatingEditorTabs([ + makeFile({ id: 'file-a', isDirty: true }), + makeFile({ id: 'file-b', isDirty: true }) + ]) + + const element = await renderPanel(true) + const tabBar = findByTypeName(element, 'TabBar') + ;(tabBar.props.onCloseAllFiles as () => void)() + + expect(requestedEditorCloses()).toEqual(['file-a', 'file-b']) + expect(mocks.closeFile).not.toHaveBeenCalledWith('file-a') + expect(mocks.closeFile).not.toHaveBeenCalledWith('file-b') + }) + + it('routes close-others and close-to-right through the central save queue', async () => { + setFloatingEditorTabs([ + makeFile({ id: 'file-a', isDirty: true }), + makeFile({ id: 'file-b', isDirty: true }), + makeFile({ id: 'file-c', isDirty: true }) + ]) + + const element = await renderPanel(true) + const tabBar = findByTypeName(element, 'TabBar') + ;(tabBar.props.onCloseOthers as (tabId: string) => void)('tab-file-b') + expect(requestedEditorCloses()).toEqual(['file-a', 'file-c']) + + vi.mocked(window.dispatchEvent).mockClear() + mocks.closeFile.mockClear() + hookRuntime.values = [] + const nextElement = await renderPanel(true) + const nextTabBar = findByTypeName(nextElement, 'TabBar') + ;(nextTabBar.props.onCloseToRight as (tabId: string) => void)('tab-file-a') + expect(requestedEditorCloses()).toEqual(['file-b', 'file-c']) + expect(mocks.closeFile).not.toHaveBeenCalledWith('file-c') + }) +}) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.empty-state.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.empty-state.test.tsx deleted file mode 100644 index 4c249d473f7..00000000000 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.empty-state.test.tsx +++ /dev/null @@ -1,333 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import { - makeTab, - setFloatingTabs, - storeBox, - type FloatingPanelStoreState -} from './floating-terminal-panel-test-fixtures' -import { mocks, setupFloatingTerminalPanelTest } from './floating-terminal-panel-test-harness' -import { - collectPropValues, - findByTypeName, - flushAsyncWork, - renderPanel, - runEffects, - type ReactElementLike -} from './floating-terminal-panel-render-probe' - -vi.mock('zustand/react/shallow', () => ({ - // Why: zustand resolves the real react (unmocked in node_modules); the memo wrapper is inert here. - useShallow: (selector: unknown) => selector -})) - -vi.mock('react', async () => { - const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - const { createReactHookOverrides } = await import('./floating-terminal-panel-test-module-mocks') - return { ...actual, ...createReactHookOverrides() } -}) - -vi.mock('@/store', async () => { - return (await import('./floating-terminal-panel-test-module-mocks')).createAppStoreModule() -}) - -vi.mock('@/components/tab-bar/TabBar', async () => { - return (await import('./floating-terminal-panel-component-stubs')).createTabBarModule() -}) - -vi.mock('@/components/terminal-pane/TerminalPane', async () => { - return (await import('./floating-terminal-panel-component-stubs')).createTerminalPaneModule() -}) - -vi.mock('@/components/terminal-pane/use-terminal-tab-cold-parking', async () => { - return (await import('./floating-terminal-panel-test-module-mocks')).createColdParkingModule() -}) - -vi.mock('@/components/terminal-pane/terminal-parked-tab-watchers', async () => { - return ( - await import('./floating-terminal-panel-test-module-mocks') - ).createParkedTabWatchersModule() -}) - -vi.mock('@/components/terminal-pane/terminal-ime-input-context-refresh', async () => { - return ( - await import('./floating-terminal-panel-test-module-mocks') - ).createImeInputContextRefreshModule() -}) - -vi.mock('@/components/terminal/terminal-tab-actions', async () => { - return ( - await import('./floating-terminal-panel-test-module-mocks') - ).createTerminalTabActionsModule() -}) - -vi.mock('@/store/pinned-tab-close-guard', async () => { - return ( - await import('./floating-terminal-panel-test-module-mocks') - ).createPinnedTabCloseGuardModule() -}) - -vi.mock('@/components/browser-pane/BrowserPane', async () => { - return (await import('./floating-terminal-panel-component-stubs')).createBrowserPaneModule() -}) - -vi.mock('@/components/emulator-pane/EmulatorPane', async () => { - return (await import('./floating-terminal-panel-component-stubs')).createEmulatorPaneModule() -}) - -vi.mock('@/components/editor/EditorPanel', async () => { - return (await import('./floating-terminal-panel-component-stubs')).createEditorPanelModule() -}) - -vi.mock('@/components/ui/button', async () => { - return (await import('./floating-terminal-panel-component-stubs')).createButtonModule() -}) - -vi.mock('@/components/contextual-tours/use-contextual-tour', async () => { - return (await import('./floating-terminal-panel-test-module-mocks')).createContextualTourModule() -}) - -vi.mock('@/components/ui/dialog', async () => { - return (await import('./floating-terminal-panel-component-stubs')).createDialogModule() -}) - -vi.mock('@/components/terminal/useTerminalSaveDialog', async () => { - return ( - await import('./floating-terminal-panel-test-module-mocks') - ).createTerminalSaveDialogModule() -}) - -vi.mock('@/runtime/web-runtime-session', async () => { - return ( - await import('./floating-terminal-panel-test-module-mocks') - ).createWebRuntimeSessionModule() -}) - -vi.mock('@/lib/connection-context', async () => { - return ( - await import('./floating-terminal-panel-test-module-mocks') - ).createConnectionContextModule() -}) - -// Why inline and sync: this module is imported by the test file itself, so an async factory -// resolves too late and the panel ends up calling a second copy of the mock. -vi.mock('@/lib/create-untitled-markdown', () => ({ - createUntitledMarkdownFileWithTemplateSelection: vi.fn() -})) - -vi.mock('@/lib/ipc-error', async () => { - return (await import('./floating-terminal-panel-test-module-mocks')).createIpcErrorModule() -}) - -vi.mock('sonner', async () => { - return (await import('./floating-terminal-panel-test-module-mocks')).createSonnerModule() -}) - -vi.mock('@/lib/focus-terminal-tab-surface', async () => { - return ( - await import('./floating-terminal-panel-test-module-mocks') - ).createFocusTerminalTabSurfaceModule() -}) - -vi.mock('@/lib/orchestration-setup-state', async () => { - return ( - await import('./floating-terminal-panel-test-module-mocks') - ).createOrchestrationSetupStateModule() -}) - -vi.mock('./FloatingTerminalOrchestrationDialog', async () => { - return ( - await import('./floating-terminal-panel-component-stubs') - ).createOrchestrationDialogModule() -}) - -vi.mock('./FloatingTerminalResizeHandles', async () => { - return (await import('./floating-terminal-panel-component-stubs')).createResizeHandlesModule() -}) - -vi.mock('./FloatingTerminalToggleButton', async () => { - return (await import('./floating-terminal-panel-component-stubs')).createToggleButtonModule() -}) - -vi.mock('./FloatingTerminalWindowControls', async () => { - return (await import('./floating-terminal-panel-component-stubs')).createWindowControlsModule() -}) - -vi.mock('@/components/ShortcutKeyCombo', async () => { - return (await import('./floating-terminal-panel-component-stubs')).createShortcutKeyComboModule() -}) - -describe('FloatingTerminalPanel close behavior', () => { - beforeEach(setupFloatingTerminalPanelTest) - - afterEach(() => { - vi.unstubAllGlobals() - }) - it('does not bootstrap a terminal tab when the panel opens empty', async () => { - await renderPanel(false) - runEffects() - await flushAsyncWork() - expect(mocks.createTab).not.toHaveBeenCalled() - - await renderPanel(true) - runEffects() - await flushAsyncWork() - expect(mocks.createTab).not.toHaveBeenCalled() - - await renderPanel(true) - runEffects() - await flushAsyncWork() - expect(mocks.createTab).not.toHaveBeenCalled() - - await renderPanel(false) - runEffects() - await renderPanel(true) - runEffects() - await flushAsyncWork() - expect(mocks.createTab).not.toHaveBeenCalled() - }) - - it('requests the floating workspace tour only when the panel is open', async () => { - const persisted = Promise.resolve() - - await renderPanel(false, vi.fn(), { - wasPreviouslyInteracted: false, - persisted, - recordFeatureInteractionForTour: false - }) - - expect(mocks.useContextualTour).toHaveBeenLastCalledWith( - 'floating-workspace', - false, - 'floating_workspace_visible', - { - recordFeatureInteraction: false, - featureInteractionPersisted: persisted, - wasFeaturePreviouslyInteracted: false - } - ) - - await renderPanel(true, vi.fn(), { - wasPreviouslyInteracted: true, - persisted, - recordFeatureInteractionForTour: false - }) - - expect(mocks.useContextualTour).toHaveBeenLastCalledWith( - 'floating-workspace', - true, - 'floating_workspace_visible', - { - recordFeatureInteraction: false, - featureInteractionPersisted: persisted, - wasFeaturePreviouslyInteracted: true - } - ) - }) - - it('records the floating workspace tour interaction when the open snapshot deferred persistence', async () => { - await renderPanel(true, vi.fn(), { - wasPreviouslyInteracted: false, - recordFeatureInteractionForTour: true - }) - - expect(mocks.useContextualTour).toHaveBeenLastCalledWith( - 'floating-workspace', - true, - 'floating_workspace_visible', - { - recordFeatureInteraction: true, - featureInteractionPersisted: undefined, - wasFeaturePreviouslyInteracted: false - } - ) - }) - - it('targets the empty-state actions without co-mounting the surface fallback', async () => { - const element = await renderPanel(true) - const emptyState = findByTypeName(element, 'FloatingTerminalEmptyState') - const renderedEmptyState = ( - emptyState.type as (props: Record) => ReactElementLike - )(emptyState.props) - - expect(collectPropValues(element, 'data-contextual-tour-target')).not.toContain( - 'floating-workspace-surface' - ) - expect(collectPropValues(renderedEmptyState, 'data-contextual-tour-target')).toEqual([ - 'floating-workspace-new-terminal', - 'floating-workspace-new-markdown' - ]) - }) - - it('targets the non-empty panel surface when the empty-state actions are absent', async () => { - setFloatingTabs([makeTab({ id: 'tab-1' })]) - - const element = await renderPanel(true) - - expect(() => findByTypeName(element, 'FloatingTerminalEmptyState')).toThrow( - 'FloatingTerminalEmptyState not found' - ) - expect(collectPropValues(element, 'data-contextual-tour-target')).toContain( - 'floating-workspace-surface' - ) - expect(collectPropValues(element, 'data-contextual-tour-target')).not.toContain( - 'floating-workspace-new-terminal' - ) - expect(collectPropValues(element, 'data-contextual-tour-target')).not.toContain( - 'floating-workspace-new-markdown' - ) - }) - - it('keeps the split surface mounted when its focused group has no tabs', async () => { - setFloatingTabs([makeTab({ id: 'tab-1' })]) - const state = storeBox.state as FloatingPanelStoreState - state.groupsByWorktree[FLOATING_TERMINAL_WORKTREE_ID].push({ - id: 'empty-group', - worktreeId: FLOATING_TERMINAL_WORKTREE_ID, - activeTabId: null, - tabOrder: [] - }) - state.activeGroupIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID] = 'empty-group' - state.layoutByWorktree[FLOATING_TERMINAL_WORKTREE_ID] = { - type: 'split', - direction: 'horizontal', - first: { type: 'leaf', groupId: 'floating-group' }, - second: { type: 'leaf', groupId: 'empty-group' } - } - - const element = await renderPanel(true) - - expect(() => findByTypeName(element, 'FloatingTerminalEmptyState')).toThrow( - 'FloatingTerminalEmptyState not found' - ) - expect(findByTypeName(element, 'TabGroupSplitNodeTree').props.focusedGroupId).toBe( - 'empty-group' - ) - }) - - it('minimizes the empty floating workspace from the empty state', async () => { - const onOpenChange = vi.fn() - const element = await renderPanel(true, onOpenChange) - - const emptyState = findByTypeName(element, 'FloatingTerminalEmptyState') - ;(emptyState.props.onClose as () => void)() - - expect(onOpenChange).toHaveBeenCalledWith(false) - expect(mocks.closeTab).not.toHaveBeenCalled() - expect(mocks.closeFile).not.toHaveBeenCalled() - expect(mocks.closeBrowserTab).not.toHaveBeenCalled() - }) - - it('shows the empty state when only stale unified tabs remain', async () => { - const state = storeBox.state as FloatingPanelStoreState - const staleTab = makeTab({ id: 'stale-tab' }) - setFloatingTabs([staleTab]) - state.tabsByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: [] } - state.activeTabIdByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: null } - - const element = await renderPanel(true) - const emptyState = findByTypeName(element, 'FloatingTerminalEmptyState') - - expect(emptyState).toBeTruthy() - }) -}) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.markdown-editor.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.markdown-editor.test.tsx deleted file mode 100644 index ad65720a025..00000000000 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.markdown-editor.test.tsx +++ /dev/null @@ -1,293 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import { createUntitledMarkdownFileWithTemplateSelection } from '@/lib/create-untitled-markdown' -import { ORCA_EDITOR_REQUEST_FILE_CLOSE_EVENT } from '@/components/editor/editor-autosave' -import { - makeFile, - makeTab, - setFloatingEditorTabs, - setFloatingTabs -} from './floating-terminal-panel-test-fixtures' -import { - hookRuntime, - mocks, - setupFloatingTerminalPanelTest -} from './floating-terminal-panel-test-harness' -import { - findByTypeName, - flushAsyncWork, - renderPanel, - runEffects -} from './floating-terminal-panel-render-probe' - -function requestedEditorCloses(): string[] { - return vi - .mocked(window.dispatchEvent) - .mock.calls.flatMap(([event]) => - event.type === ORCA_EDITOR_REQUEST_FILE_CLOSE_EVENT && event instanceof CustomEvent - ? [event.detail.fileId] - : [] - ) -} - -vi.mock('zustand/react/shallow', () => ({ - // Why: zustand resolves the real react (unmocked in node_modules); the memo wrapper is inert here. - useShallow: (selector: unknown) => selector -})) - -vi.mock('react', async () => { - const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - const { createReactHookOverrides } = await import('./floating-terminal-panel-test-module-mocks') - return { ...actual, ...createReactHookOverrides() } -}) - -vi.mock('@/store', async () => { - return (await import('./floating-terminal-panel-test-module-mocks')).createAppStoreModule() -}) - -vi.mock('@/components/tab-bar/TabBar', async () => { - return (await import('./floating-terminal-panel-component-stubs')).createTabBarModule() -}) - -vi.mock('@/components/terminal-pane/TerminalPane', async () => { - return (await import('./floating-terminal-panel-component-stubs')).createTerminalPaneModule() -}) - -vi.mock('@/components/terminal-pane/use-terminal-tab-cold-parking', async () => { - return (await import('./floating-terminal-panel-test-module-mocks')).createColdParkingModule() -}) - -vi.mock('@/components/terminal-pane/terminal-parked-tab-watchers', async () => { - return ( - await import('./floating-terminal-panel-test-module-mocks') - ).createParkedTabWatchersModule() -}) - -vi.mock('@/components/terminal-pane/terminal-ime-input-context-refresh', async () => { - return ( - await import('./floating-terminal-panel-test-module-mocks') - ).createImeInputContextRefreshModule() -}) - -vi.mock('@/components/terminal/terminal-tab-actions', async () => { - return ( - await import('./floating-terminal-panel-test-module-mocks') - ).createTerminalTabActionsModule() -}) - -vi.mock('@/store/pinned-tab-close-guard', async () => { - return ( - await import('./floating-terminal-panel-test-module-mocks') - ).createPinnedTabCloseGuardModule() -}) - -vi.mock('@/components/browser-pane/BrowserPane', async () => { - return (await import('./floating-terminal-panel-component-stubs')).createBrowserPaneModule() -}) - -vi.mock('@/components/emulator-pane/EmulatorPane', async () => { - return (await import('./floating-terminal-panel-component-stubs')).createEmulatorPaneModule() -}) - -vi.mock('@/components/editor/EditorPanel', async () => { - return (await import('./floating-terminal-panel-component-stubs')).createEditorPanelModule() -}) - -vi.mock('@/components/ui/button', async () => { - return (await import('./floating-terminal-panel-component-stubs')).createButtonModule() -}) - -vi.mock('@/components/contextual-tours/use-contextual-tour', async () => { - return (await import('./floating-terminal-panel-test-module-mocks')).createContextualTourModule() -}) - -vi.mock('@/components/ui/dialog', async () => { - return (await import('./floating-terminal-panel-component-stubs')).createDialogModule() -}) - -vi.mock('@/components/terminal/useTerminalSaveDialog', async () => { - return ( - await import('./floating-terminal-panel-test-module-mocks') - ).createTerminalSaveDialogModule() -}) - -vi.mock('@/runtime/web-runtime-session', async () => { - return ( - await import('./floating-terminal-panel-test-module-mocks') - ).createWebRuntimeSessionModule() -}) - -vi.mock('@/lib/connection-context', async () => { - return ( - await import('./floating-terminal-panel-test-module-mocks') - ).createConnectionContextModule() -}) - -// Why inline and sync: this module is imported by the test file itself, so an async factory -// resolves too late and the panel ends up calling a second copy of the mock. -vi.mock('@/lib/create-untitled-markdown', () => ({ - createUntitledMarkdownFileWithTemplateSelection: vi.fn() -})) - -vi.mock('@/lib/ipc-error', async () => { - return (await import('./floating-terminal-panel-test-module-mocks')).createIpcErrorModule() -}) - -vi.mock('sonner', async () => { - return (await import('./floating-terminal-panel-test-module-mocks')).createSonnerModule() -}) - -vi.mock('@/lib/focus-terminal-tab-surface', async () => { - return ( - await import('./floating-terminal-panel-test-module-mocks') - ).createFocusTerminalTabSurfaceModule() -}) - -vi.mock('@/lib/orchestration-setup-state', async () => { - return ( - await import('./floating-terminal-panel-test-module-mocks') - ).createOrchestrationSetupStateModule() -}) - -vi.mock('./FloatingTerminalOrchestrationDialog', async () => { - return ( - await import('./floating-terminal-panel-component-stubs') - ).createOrchestrationDialogModule() -}) - -vi.mock('./FloatingTerminalResizeHandles', async () => { - return (await import('./floating-terminal-panel-component-stubs')).createResizeHandlesModule() -}) - -vi.mock('./FloatingTerminalToggleButton', async () => { - return (await import('./floating-terminal-panel-component-stubs')).createToggleButtonModule() -}) - -vi.mock('./FloatingTerminalWindowControls', async () => { - return (await import('./floating-terminal-panel-component-stubs')).createWindowControlsModule() -}) - -vi.mock('@/components/ShortcutKeyCombo', async () => { - return (await import('./floating-terminal-panel-component-stubs')).createShortcutKeyComboModule() -}) - -describe('FloatingTerminalPanel close behavior', () => { - beforeEach(setupFloatingTerminalPanelTest) - - afterEach(() => { - vi.unstubAllGlobals() - }) - it('creates floating markdown files in local filesystem mode', async () => { - setFloatingTabs([makeTab({ id: 'tab-1' })]) - vi.mocked(createUntitledMarkdownFileWithTemplateSelection).mockResolvedValue({ - filePath: '/tmp/orca/floating-notes/untitled.md', - relativePath: 'untitled.md', - worktreeId: FLOATING_TERMINAL_WORKTREE_ID, - language: 'markdown', - isUntitled: true, - mode: 'edit' - }) - - let element = await renderPanel(true) - runEffects() - await flushAsyncWork() - element = await renderPanel(true) - const tabBar = findByTypeName(element, 'TabBar') - ;(tabBar.props.onNewFileTab as () => void)() - await flushAsyncWork() - - expect(createUntitledMarkdownFileWithTemplateSelection).toHaveBeenCalledWith( - '/tmp/orca/floating-notes', - FLOATING_TERMINAL_WORKTREE_ID, - undefined, - { activeRuntimeEnvironmentId: null } - ) - expect(mocks.openFile).toHaveBeenCalledWith( - expect.objectContaining({ filePath: '/tmp/orca/floating-notes/untitled.md' }), - expect.objectContaining({ suppressActiveRuntimeFallback: true }) - ) - }) - - it('opens existing markdown documents through the floating picker', async () => { - setFloatingTabs([makeTab({ id: 'tab-1' })]) - mocks.pickFloatingMarkdownDocument.mockResolvedValue({ - filePath: '/tmp/orca/notes.md', - relativePath: 'notes.md', - basename: 'notes.md', - name: 'notes' - }) - - const element = await renderPanel(true) - const tabBar = findByTypeName(element, 'TabBar') - ;(tabBar.props.onOpenFileTab as () => void)() - await flushAsyncWork() - - expect(mocks.pickFloatingMarkdownDocument).toHaveBeenCalledWith() - expect(mocks.openFile).toHaveBeenCalledWith( - expect.objectContaining({ - filePath: '/tmp/orca/notes.md', - relativePath: 'notes.md', - runtimeEnvironmentId: null, - worktreeId: FLOATING_TERMINAL_WORKTREE_ID - }), - expect.objectContaining({ suppressActiveRuntimeFallback: true }) - ) - }) - - it('disables markdown annotations in floating editor tabs', async () => { - setFloatingEditorTabs([makeFile({ id: 'notes' })]) - - const element = await renderPanel(true) - // The editor renders inside the shared group tree; the panel's contract is the policy - // it passes down (scratch markdown exposes no agent annotations). - const tree = findByTypeName(element, 'TabGroupSplitNodeTree') - - expect(tree.props.markdownAnnotationsEnabled).toBe(false) - expect(tree.props.isWorktreeActive).toBe(true) - }) - - it('marks the retained floating editor hidden when the panel is closed', async () => { - setFloatingEditorTabs([makeFile({ id: 'notes' })]) - - const element = await renderPanel(false) - - expect(findByTypeName(element, 'TabGroupSplitNodeTree').props.isWorktreeActive).toBe(false) - }) - - it('routes every dirty editor from close-all-files to the central save queue', async () => { - setFloatingEditorTabs([ - makeFile({ id: 'file-a', isDirty: true }), - makeFile({ id: 'file-b', isDirty: true }) - ]) - - const element = await renderPanel(true) - const tabBar = findByTypeName(element, 'TabBar') - ;(tabBar.props.onCloseAllFiles as () => void)() - - expect(requestedEditorCloses()).toEqual(['file-a', 'file-b']) - expect(mocks.closeFile).not.toHaveBeenCalledWith('file-a') - expect(mocks.closeFile).not.toHaveBeenCalledWith('file-b') - }) - - it('routes close-others and close-to-right through the central save queue', async () => { - setFloatingEditorTabs([ - makeFile({ id: 'file-a', isDirty: true }), - makeFile({ id: 'file-b', isDirty: true }), - makeFile({ id: 'file-c', isDirty: true }) - ]) - - const element = await renderPanel(true) - const tabBar = findByTypeName(element, 'TabBar') - ;(tabBar.props.onCloseOthers as (tabId: string) => void)('tab-file-b') - expect(requestedEditorCloses()).toEqual(['file-a', 'file-c']) - - vi.mocked(window.dispatchEvent).mockClear() - mocks.closeFile.mockClear() - hookRuntime.values = [] - const nextElement = await renderPanel(true) - const nextTabBar = findByTypeName(nextElement, 'TabBar') - ;(nextTabBar.props.onCloseToRight as (tabId: string) => void)('tab-file-a') - expect(requestedEditorCloses()).toEqual(['file-b', 'file-c']) - expect(mocks.closeFile).not.toHaveBeenCalledWith('file-c') - }) -}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.opening-send.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.opening-send.test.tsx index 84c3cc4e218..f95b9f8ae70 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.opening-send.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.opening-send.test.tsx @@ -22,146 +22,147 @@ import { import { selectStructuredAgentSettledTurns } from '../../../../shared/structured-agent-session-turn-timing' import { NativeChatMessageList } from './NativeChatMessageList' import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' - -let restoreViewport = (): void => {} -beforeAll(() => { - restoreViewport = installNativeChatMessageListTestViewport() -}) -afterAll(() => restoreViewport()) -afterEach(cleanup) - -const THREAD: AgentJournalTurnScope = { kind: 'thread' } -const NOW = 100_000 - -function item( - itemId: string, - sequence: number, - body: AgentJournalItemBody -): AgentJournalRenderItem { - return { itemId, revision: 0, sequence, observedAt: sequence, body, turnScope: THREAD } -} - -const userMessage = (id: string, sequence: number, text: string) => - item(agentJournalSubmissionKey(id), sequence, { - kind: 'message', - role: 'user', - blocks: [{ type: 'text', text }] - }) - -function submission( - clientMessageId: string, - overrides: Partial = {} -): AgentJournalSubmission { - return { - clientMessageId, - fence: 1, - payloadFingerprint: clientMessageId, - dispatchState: 'pending', - providerItemId: null, - reason: null, - submittedAt: NOW, - resolvedAt: null, - handoverRecorded: true, - ...overrides - } -} - -/** A finished warm-up, then "first" handed over (row 5) with no turn record yet: its turn opens. */ -function openingFirst(): { - items: AgentJournalRenderItem[] - submissions: AgentJournalSubmission[] -} { - return { - items: [ - userMessage('warm', 1, 'warm up'), - item('turn-warm', 2, { - kind: 'turn', - turnId: 'turn-warm', - state: 'completed', - outcome: 'success', - userItemId: agentJournalSubmissionKey('warm'), - startedAt: 1_000, - completedAt: 2_000 - }), - userMessage('first', 5, 'first') - ], - submissions: [ - submission('warm', { dispatchState: 'accepted', resolvedAt: 2_000 }), - submission('first', { acceptedSequence: 3, handedOverAt: NOW }) - ] - } -} - -/** The first send's turn record, written as its turn opens; Codex names its own provider key. */ -const firstTurnOpened = (sequence: number) => - item('turn-first', sequence, { - kind: 'turn', - turnId: 'turn-first', - state: 'running', - userItemId: FIRST_TURN_PROVIDER_KEY, - startedAt: NOW - }) -const FIRST_TURN_PROVIDER_KEY = 'codex:thread:turn-first:0' -const inFirstTurn: AgentJournalTurnScope = { kind: 'turn', turnItemId: 'turn-first' } - -/** This client's own send the host has not recorded yet: its lane still runs the first handover. */ -const unrecorded = ( - clientMessageId: string, - text: string -): StructuredAgentSessionOptimisticMessage => ({ - clientMessageId, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] }, - queuedAt: NOW + 100 -}) - -function frame( - items: AgentJournalRenderItem[], - submissions: AgentJournalSubmission[], - optimistic: StructuredAgentSessionOptimisticMessage[], - stopping = false -): React.JSX.Element { - return ( - - ) -} - -function follows(later: HTMLElement, earlier: HTMLElement): boolean { - return Boolean(earlier.compareDocumentPosition(later) & Node.DOCUMENT_POSITION_FOLLOWING) -} - -/** The live turn's status, which carries the first send's working clock. */ -const liveStatus = () => screen.getByText(/^Working for/) -/** The live turn's activity line, the last thing the live turn draws. */ -const liveActivity = () => screen.getByText(/^(Working|Stopping)…$/) - -/** `text` is drawn after the live turn: its status and its activity line. */ -function waitsAtTheTail(text: string): void { - expect(follows(screen.getByText(text), liveStatus())).toBe(true) - expect(follows(screen.getByText(text), liveActivity())).toBe(true) -} +import { DISPATCH_REJECTED_CANCELLED } from '../../../../shared/structured-agent-session-dispatch-rejection' describe('a message sent while the turn ahead is still opening', () => { + let restoreViewport = (): void => {} + beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() + }) + afterAll(() => restoreViewport()) + afterEach(cleanup) + + const THREAD: AgentJournalTurnScope = { kind: 'thread' } + const NOW = 100_000 + + function item( + itemId: string, + sequence: number, + body: AgentJournalItemBody + ): AgentJournalRenderItem { + return { itemId, revision: 0, sequence, observedAt: sequence, body, turnScope: THREAD } + } + + const userMessage = (id: string, sequence: number, text: string) => + item(agentJournalSubmissionKey(id), sequence, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text }] + }) + + function submission( + clientMessageId: string, + overrides: Partial = {} + ): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: NOW, + resolvedAt: null, + handoverRecorded: true, + ...overrides + } + } + + /** A finished warm-up, then "first" handed over (row 5) with no turn record yet: its turn opens. */ + function openingFirst(): { + items: AgentJournalRenderItem[] + submissions: AgentJournalSubmission[] + } { + return { + items: [ + userMessage('warm', 1, 'warm up'), + item('turn-warm', 2, { + kind: 'turn', + turnId: 'turn-warm', + state: 'completed', + outcome: 'success', + userItemId: agentJournalSubmissionKey('warm'), + startedAt: 1_000, + completedAt: 2_000 + }), + userMessage('first', 5, 'first') + ], + submissions: [ + submission('warm', { dispatchState: 'accepted', resolvedAt: 2_000 }), + submission('first', { acceptedSequence: 3, handedOverAt: NOW }) + ] + } + } + + /** The first send's turn record, written as its turn opens; Codex names its own provider key. */ + const firstTurnOpened = (sequence: number) => + item('turn-first', sequence, { + kind: 'turn', + turnId: 'turn-first', + state: 'running', + userItemId: FIRST_TURN_PROVIDER_KEY, + startedAt: NOW + }) + const FIRST_TURN_PROVIDER_KEY = 'codex:thread:turn-first:0' + const inFirstTurn: AgentJournalTurnScope = { kind: 'turn', turnItemId: 'turn-first' } + + /** This client's own send the host has not recorded yet: its lane still runs the first handover. */ + const unrecorded = ( + clientMessageId: string, + text: string + ): StructuredAgentSessionOptimisticMessage => ({ + clientMessageId, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] }, + queuedAt: NOW + 100 + }) + + function frame( + items: AgentJournalRenderItem[], + submissions: AgentJournalSubmission[], + optimistic: StructuredAgentSessionOptimisticMessage[], + stopping = false + ): React.JSX.Element { + return ( + + ) + } + + function follows(later: HTMLElement, earlier: HTMLElement): boolean { + return Boolean(earlier.compareDocumentPosition(later) & Node.DOCUMENT_POSITION_FOLLOWING) + } + + /** The live turn's status, which carries the first send's working clock. */ + const liveStatus = () => screen.getByText(/^Working for/) + /** The live turn's activity line, the last thing the live turn draws. */ + const liveActivity = () => screen.getByText(/^(Working|Stopping)…$/) + + /** `text` is drawn after the live turn: its status and its activity line. */ + function waitsAtTheTail(text: string): void { + expect(follows(screen.getByText(text), liveStatus())).toBe(true) + expect(follows(screen.getByText(text), liveActivity())).toBe(true) + } + it('waits at the tail after the live status while the host has not recorded it yet', () => { const { items, submissions } = openingFirst() @@ -312,3 +313,249 @@ describe('a message sent while the turn ahead is still opening', () => { ]) }) }) + +describe('a send a Stop took back before the agent started it', () => { + let restoreViewport = (): void => {} + beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() + }) + afterAll(() => restoreViewport()) + afterEach(cleanup) + + const STOP_ROW = 'Stopped manually' + const THREAD: AgentJournalTurnScope = { kind: 'thread' } + let sequence = 0 + + function item( + itemId: string, + body: AgentJournalItemBody, + turnScope: AgentJournalTurnScope = THREAD + ): AgentJournalRenderItem { + sequence += 1 + return { itemId, revision: 0, sequence, observedAt: sequence, body, turnScope } + } + + const sent = (id: string, text: string) => + item(agentJournalSubmissionKey(id), { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text }] + }) + + function turn( + itemId: string, + userId: string, + seconds: number, + end: { state: 'completed' | 'interrupted'; outcome: 'success' | 'cancellation' } + ) { + return item(itemId, { + kind: 'turn', + turnId: itemId, + ...end, + userItemId: agentJournalSubmissionKey(userId), + startedAt: 1_000, + completedAt: 1_000 + seconds * 1_000 + }) + } + + /** "warm up", answered and settled in 3s. */ + function warmUp(): AgentJournalRenderItem[] { + return [ + sent('warm-up', 'warm up'), + turn('t1', 'warm-up', 3, { state: 'completed', outcome: 'success' }), + item( + 't1-answer', + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Warmed up.' }] }, + { kind: 'turn', turnItemId: 't1' } + ) + ] + } + + function submission( + clientMessageId: string, + overrides: Partial = {} + ): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + dispatchState: 'accepted', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: 2, + ...overrides + } + } + + const stopped = (id: string, overrides: Partial = {}) => + submission(id, { dispatchState: 'rejected', reason: DISPATCH_REJECTED_CANCELLED, ...overrides }) + + function journalList( + items: AgentJournalRenderItem[], + submissions: AgentJournalSubmission[], + live: { isWorking: boolean; workingStartedAt: number | null } = { + isWorking: false, + workingStartedAt: null + } + ): React.JSX.Element { + return ( + + ) + } + + function renderJournal(items: AgentJournalRenderItem[], submissions: AgentJournalSubmission[]) { + render(journalList(items, submissions)) + } + + function follows(later: HTMLElement, earlier: HTMLElement): boolean { + return Boolean(earlier.compareDocumentPosition(later) & Node.DOCUMENT_POSITION_FOLLOWING) + } + + it('stays after the turn before it, with the stop row after it and no header of its own', () => { + renderJournal( + [...warmUp(), sent('never-ran', 'look around')], + [submission('warm-up'), stopped('never-ran')] + ) + + const message = screen.getByText('look around') + const row = screen.getByText(STOP_ROW) + expect(follows(message, screen.getByText('Warmed up.'))).toBe(true) + expect(follows(row, message)).toBe(true) + expect(screen.getAllByText(/^(Worked|Working) for/)).toHaveLength(1) + expect(screen.getByText('Worked for 3s')).toBeInTheDocument() + }) + + it('keeps no clock it showed while the send waited on its turn', () => { + const items = [...warmUp(), sent('never-ran', 'look around')] + const { rerender } = render( + journalList( + items, + [submission('warm-up'), submission('never-ran', { dispatchState: 'pending' })], + { + isWorking: true, + workingStartedAt: Date.now() - 5_000 + } + ) + ) + expect(screen.getByText('Working for 5s')).toBeInTheDocument() + + rerender(journalList(items, [submission('warm-up'), stopped('never-ran')])) + + expect(screen.getAllByText(/^(Worked|Working) for/).map((node) => node.textContent)).toEqual([ + 'Worked for 3s' + ]) + expect(follows(screen.getByText(STOP_ROW), screen.getByText('look around'))).toBe(true) + }) + + it('draws one stop row after sends taken back together', () => { + renderJournal( + [...warmUp(), sent('first', 'first send'), sent('second', 'second send')], + [submission('warm-up'), stopped('first'), stopped('second')] + ) + + expect(screen.getAllByText(STOP_ROW)).toHaveLength(1) + expect(follows(screen.getByText(STOP_ROW), screen.getByText('second send'))).toBe(true) + }) + + it('leaves a send whose turn opened to that turn, which says it was interrupted', () => { + renderJournal( + [ + ...warmUp(), + sent('opened', 'look around'), + turn('t2', 'opened', 2, { state: 'interrupted', outcome: 'cancellation' }) + ], + [submission('warm-up'), stopped('opened')] + ) + + expect(screen.getByText('look around')).toBeInTheDocument() + expect(screen.getByText('Interrupted after 2s')).toBeInTheDocument() + expect(screen.queryByText(STOP_ROW)).toBeNull() + }) + + // Codex reports the turn open before it echoes the send, so the record names the provider's key. + it("leaves it to that turn before the provider echoed it, with only that turn's stop rows", () => { + renderJournal( + [ + ...warmUp(), + sent('opened', 'look around'), + item('t2', { + kind: 'turn', + turnId: 't2', + state: 'interrupted', + outcome: 'cancellation', + userItemId: 'codex:thread-1:t2:0', + startedAt: 10_000, + completedAt: 12_000 + }), + item( + 'stop:t2', + { kind: 'status', text: 'Cancellation requested.' }, + { kind: 'turn', turnItemId: 't2' } + ) + ], + [submission('warm-up'), stopped('opened', { resolvedAt: 12_000 })] + ) + + const message = screen.getByText('look around') + expect(follows(screen.getByText('Interrupted after 2s'), message)).toBe(true) + expect(screen.queryByText(STOP_ROW)).toBeNull() + }) + + it("draws its row as the Stop's own status rows are drawn", () => { + renderJournal( + [ + ...warmUp(), + item('stop:t1', { kind: 'status', text: 'Cancellation requested.' }), + sent('never-ran', 'look around') + ], + [submission('warm-up'), stopped('never-ran')] + ) + + const rowOf = (text: string) => screen.getByText(text).closest('.group') + expect(rowOf(STOP_ROW)?.className).toBe(rowOf('Cancellation requested.')?.className) + expect(rowOf(STOP_ROW)?.className).toBeTruthy() + }) + + it('keeps its row on screen under a settled turn on a host that states no turn scopes', () => { + const unscoped = (entry: AgentJournalRenderItem): AgentJournalRenderItem => { + const { turnScope: _none, ...rest } = entry + return rest + } + renderJournal( + [ + ...warmUp(), + sent('steered', 'and check the tests'), + item('t1-more', { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'More warming up.' }] + }) + ].map(unscoped), + [submission('warm-up'), stopped('steered')] + ) + + expect(screen.getByText('and check the tests')).toBeInTheDocument() + expect(screen.getByText(STOP_ROW)).toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.stopped-before-start.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.stopped-before-start.test.tsx deleted file mode 100644 index b3a263bc8ef..00000000000 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.stopped-before-start.test.tsx +++ /dev/null @@ -1,267 +0,0 @@ -// @vitest-environment happy-dom - -// A send a Stop took back before the agent started it stays where it was sent, with one row after -// it, never under the turn before, and with no header of its own. - -import '@testing-library/jest-dom/vitest' - -import { cleanup, render, screen } from '@testing-library/react' -import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' -import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' -import type { - AgentJournalItemBody, - AgentJournalRenderItem, - AgentJournalSubmission, - AgentJournalTurnScope -} from '../../../../shared/agent-session-journal-types' -import { DISPATCH_REJECTED_CANCELLED } from '../../../../shared/structured-agent-session-dispatch-rejection' -import { projectStructuredAgentSessionMessages } from '../../../../shared/structured-agent-session-message-projection' -import { selectStructuredAgentSettledTurns } from '../../../../shared/structured-agent-session-turn-timing' -import { NativeChatMessageList } from './NativeChatMessageList' -import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' - -let restoreViewport = (): void => {} -beforeAll(() => { - restoreViewport = installNativeChatMessageListTestViewport() -}) -afterAll(() => restoreViewport()) -afterEach(cleanup) - -const STOP_ROW = 'Stopped manually' -const THREAD: AgentJournalTurnScope = { kind: 'thread' } -let sequence = 0 - -function item( - itemId: string, - body: AgentJournalItemBody, - turnScope: AgentJournalTurnScope = THREAD -): AgentJournalRenderItem { - sequence += 1 - return { itemId, revision: 0, sequence, observedAt: sequence, body, turnScope } -} - -const sent = (id: string, text: string) => - item(agentJournalSubmissionKey(id), { - kind: 'message', - role: 'user', - blocks: [{ type: 'text', text }] - }) - -function turn( - itemId: string, - userId: string, - seconds: number, - end: { state: 'completed' | 'interrupted'; outcome: 'success' | 'cancellation' } -) { - return item(itemId, { - kind: 'turn', - turnId: itemId, - ...end, - userItemId: agentJournalSubmissionKey(userId), - startedAt: 1_000, - completedAt: 1_000 + seconds * 1_000 - }) -} - -/** "warm up", answered and settled in 3s. */ -function warmUp(): AgentJournalRenderItem[] { - return [ - sent('warm-up', 'warm up'), - turn('t1', 'warm-up', 3, { state: 'completed', outcome: 'success' }), - item( - 't1-answer', - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Warmed up.' }] }, - { kind: 'turn', turnItemId: 't1' } - ) - ] -} - -function submission( - clientMessageId: string, - overrides: Partial = {} -): AgentJournalSubmission { - return { - clientMessageId, - fence: 1, - payloadFingerprint: clientMessageId, - dispatchState: 'accepted', - providerItemId: null, - reason: null, - submittedAt: 1, - resolvedAt: 2, - ...overrides - } -} - -const stopped = (id: string, overrides: Partial = {}) => - submission(id, { dispatchState: 'rejected', reason: DISPATCH_REJECTED_CANCELLED, ...overrides }) - -function journalList( - items: AgentJournalRenderItem[], - submissions: AgentJournalSubmission[], - live: { isWorking: boolean; workingStartedAt: number | null } = { - isWorking: false, - workingStartedAt: null - } -): React.JSX.Element { - return ( - - ) -} - -function renderJournal(items: AgentJournalRenderItem[], submissions: AgentJournalSubmission[]) { - render(journalList(items, submissions)) -} - -function follows(later: HTMLElement, earlier: HTMLElement): boolean { - return Boolean(earlier.compareDocumentPosition(later) & Node.DOCUMENT_POSITION_FOLLOWING) -} - -describe('a send a Stop took back before the agent started it', () => { - it('stays after the turn before it, with the stop row after it and no header of its own', () => { - renderJournal( - [...warmUp(), sent('never-ran', 'look around')], - [submission('warm-up'), stopped('never-ran')] - ) - - const message = screen.getByText('look around') - const row = screen.getByText(STOP_ROW) - expect(follows(message, screen.getByText('Warmed up.'))).toBe(true) - expect(follows(row, message)).toBe(true) - expect(screen.getAllByText(/^(Worked|Working) for/)).toHaveLength(1) - expect(screen.getByText('Worked for 3s')).toBeInTheDocument() - }) - - it('keeps no clock it showed while the send waited on its turn', () => { - const items = [...warmUp(), sent('never-ran', 'look around')] - const { rerender } = render( - journalList( - items, - [submission('warm-up'), submission('never-ran', { dispatchState: 'pending' })], - { - isWorking: true, - workingStartedAt: Date.now() - 5_000 - } - ) - ) - expect(screen.getByText('Working for 5s')).toBeInTheDocument() - - rerender(journalList(items, [submission('warm-up'), stopped('never-ran')])) - - expect(screen.getAllByText(/^(Worked|Working) for/).map((node) => node.textContent)).toEqual([ - 'Worked for 3s' - ]) - expect(follows(screen.getByText(STOP_ROW), screen.getByText('look around'))).toBe(true) - }) - - it('draws one stop row after sends taken back together', () => { - renderJournal( - [...warmUp(), sent('first', 'first send'), sent('second', 'second send')], - [submission('warm-up'), stopped('first'), stopped('second')] - ) - - expect(screen.getAllByText(STOP_ROW)).toHaveLength(1) - expect(follows(screen.getByText(STOP_ROW), screen.getByText('second send'))).toBe(true) - }) - - it('leaves a send whose turn opened to that turn, which says it was interrupted', () => { - renderJournal( - [ - ...warmUp(), - sent('opened', 'look around'), - turn('t2', 'opened', 2, { state: 'interrupted', outcome: 'cancellation' }) - ], - [submission('warm-up'), stopped('opened')] - ) - - expect(screen.getByText('look around')).toBeInTheDocument() - expect(screen.getByText('Interrupted after 2s')).toBeInTheDocument() - expect(screen.queryByText(STOP_ROW)).toBeNull() - }) - - // Codex reports the turn open before it echoes the send, so the record names the provider's key. - it("leaves it to that turn before the provider echoed it, with only that turn's stop rows", () => { - renderJournal( - [ - ...warmUp(), - sent('opened', 'look around'), - item('t2', { - kind: 'turn', - turnId: 't2', - state: 'interrupted', - outcome: 'cancellation', - userItemId: 'codex:thread-1:t2:0', - startedAt: 10_000, - completedAt: 12_000 - }), - item( - 'stop:t2', - { kind: 'status', text: 'Cancellation requested.' }, - { kind: 'turn', turnItemId: 't2' } - ) - ], - [submission('warm-up'), stopped('opened', { resolvedAt: 12_000 })] - ) - - const message = screen.getByText('look around') - expect(follows(screen.getByText('Interrupted after 2s'), message)).toBe(true) - expect(screen.queryByText(STOP_ROW)).toBeNull() - }) - - it("draws its row as the Stop's own status rows are drawn", () => { - renderJournal( - [ - ...warmUp(), - item('stop:t1', { kind: 'status', text: 'Cancellation requested.' }), - sent('never-ran', 'look around') - ], - [submission('warm-up'), stopped('never-ran')] - ) - - const rowOf = (text: string) => screen.getByText(text).closest('.group') - expect(rowOf(STOP_ROW)?.className).toBe(rowOf('Cancellation requested.')?.className) - expect(rowOf(STOP_ROW)?.className).toBeTruthy() - }) - - it('keeps its row on screen under a settled turn on a host that states no turn scopes', () => { - const unscoped = (entry: AgentJournalRenderItem): AgentJournalRenderItem => { - const { turnScope: _none, ...rest } = entry - return rest - } - renderJournal( - [ - ...warmUp(), - sent('steered', 'and check the tests'), - item('t1-more', { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text: 'More warming up.' }] - }) - ].map(unscoped), - [submission('warm-up'), stopped('steered')] - ) - - expect(screen.getByText('and check the tests')).toBeInTheDocument() - expect(screen.getByText(STOP_ROW)).toBeInTheDocument() - }) -}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-timing.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-timing.test.tsx index 5caedaded51..63fff5ee377 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-timing.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-timing.test.tsx @@ -1,53 +1,59 @@ // @vitest-environment happy-dom import '@testing-library/jest-dom/vitest' - -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import type { NativeChatLiveSession } from './use-native-chat-live-session' +import type { + AgentJournalRenderItem, + AgentJournalTurnScope +} from '../../../../shared/agent-session-journal-types' +import type { AgentSessionLatestTurn } from '../../../../shared/agent-session-wire' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { projectStructuredAgentSessionMessages } from '../../../../shared/structured-agent-session-message-projection' import { NativeChatMessageList } from './NativeChatMessageList' import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' -afterEach(cleanup) - -let restoreViewport = (): void => {} - -beforeAll(() => { - restoreViewport = installNativeChatMessageListTestViewport() -}) - -afterAll(() => restoreViewport()) - -const session: NativeChatLiveSession = { - messages: [ - { - id: 'user-settled', - role: 'user', - blocks: [{ type: 'text', text: 'Settled on the host' }], - timestamp: 1, - source: 'transcript' - }, - { - id: 'assistant-settled', - role: 'assistant', - blocks: [{ type: 'text', text: 'Done.' }], - timestamp: 2, - source: 'transcript' - } - ], - status: 'ready', - sessionId: 'session-1', - agent: 'codex', - hasMore: false, - loadingEarlier: false, - olderHistoryGeneration: 0, - loadEarlier: vi.fn(), - readPhase: 'ready' -} - -const settledTurns = new Map([['user-settled', { startedAt: 1, workedSeconds: 197 }]]) - describe('NativeChatMessageList host-settled turn timing', () => { + afterEach(cleanup) + + let restoreViewport = (): void => {} + + beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() + }) + + afterAll(() => restoreViewport()) + + const session: NativeChatLiveSession = { + messages: [ + { + id: 'user-settled', + role: 'user', + blocks: [{ type: 'text', text: 'Settled on the host' }], + timestamp: 1, + source: 'transcript' + }, + { + id: 'assistant-settled', + role: 'assistant', + blocks: [{ type: 'text', text: 'Done.' }], + timestamp: 2, + source: 'transcript' + } + ], + status: 'ready', + sessionId: 'session-1', + agent: 'codex', + hasMore: false, + loadingEarlier: false, + olderHistoryGeneration: 0, + loadEarlier: vi.fn(), + readPhase: 'ready' + } + + const settledTurns = new Map([['user-settled', { startedAt: 1, workedSeconds: 197 }]]) + it('does not render a local completed duration when the host cannot verify the end', () => { const now = vi.spyOn(Date, 'now').mockReturnValue(1_000) const unknownTurns = new Map([['user-settled', null]]) @@ -107,3 +113,250 @@ describe('NativeChatMessageList host-settled turn timing', () => { } }) }) + +describe('a live turn whose record and opening message are not loaded', () => { + let restoreViewport = (): void => {} + beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() + }) + afterAll(() => restoreViewport()) + afterEach(cleanup) + + const TURN_RECORD = 'turn-record-1' + const IN_TURN: AgentJournalTurnScope = { kind: 'turn', turnItemId: TURN_RECORD } + + /** The newest rows of the turn: everything from its 300th row on. */ + const loaded: AgentJournalRenderItem[] = [300, 301, 302].map((sequence) => ({ + itemId: `row-${sequence}`, + revision: 0, + sequence, + observedAt: sequence, + body: { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: `Step ${sequence}` }] + }, + turnScope: IN_TURN + })) + + const patch = '@@ -1 +1 @@\n-before\n+after' + /** An edit among the loaded rows; the turn's earlier edits are above them. */ + const edit: AgentJournalRenderItem = { + itemId: 'row-303', + revision: 0, + sequence: 303, + observedAt: 303, + body: { + kind: 'diff', + path: 'src/a.ts', + patch: { head: patch, truncated: false, digest: 'fixture', byteLength: patch.length } + }, + turnScope: IN_TURN + } + + const running: AgentSessionLatestTurn = { + itemId: TURN_RECORD, + observedAt: 1, + turn: { turnId: 'turn-1', state: 'running', startedAt: 1_000, userItemId: 'user-1' } + } + + function list( + latestTurn: AgentSessionLatestTurn | null | undefined, + items: AgentJournalRenderItem[] = loaded + ): React.JSX.Element { + return ( + + ) + } + + it('draws its Working bar with the running clock', () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(64_000) + try { + render(list(running)) + expect(screen.getByText('Step 302')).toBeInTheDocument() + expect(screen.getByText(/Working for 1m 3s/)).toBeInTheDocument() + } finally { + now.mockRestore() + } + }) + + it('totals no edits, since only the end of the turn is loaded', () => { + render(list(running, [...loaded, edit])) + expect(screen.getByText(/Working for/)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /changed file/ })).toBeNull() + }) + + it('had no bar to draw from the loaded rows alone', () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(64_000) + try { + render(list(undefined)) + expect(screen.getByText('Step 302')).toBeInTheDocument() + expect(screen.queryByText(/Working for/)).not.toBeInTheDocument() + } finally { + now.mockRestore() + } + }) +}) + +describe('a thought joining a work run', () => { + let restoreViewport = (): void => {} + beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() + }) + afterAll(() => restoreViewport()) + afterEach(cleanup) + + const STARTED = 1_000 + + const prompt: NativeChatMessage = { + id: 'user-1', + role: 'user', + blocks: [{ type: 'text', text: 'Start the task' }], + timestamp: STARTED - 500, + source: 'transcript' + } + + const command: NativeChatMessage = { + id: 'tool-1', + role: 'assistant', + blocks: [ + { type: 'tool-call', name: 'Bash', input: { command: 'ls logs' }, state: 'completed' } + ], + timestamp: STARTED, + source: 'transcript' + } + + function reasoning(state: 'running' | 'completed'): NativeChatMessage { + return { + id: 'r-1', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Weighing two approaches' }], + timestamp: STARTED + 50, + source: 'transcript', + state, + ...(state === 'completed' ? { completedAt: STARTED + 12_000 } : {}) + } + } + + /** The journal that says the turn runs and what its newest content is. */ + function journal(rows: readonly NativeChatMessage[]): AgentJournalRenderItem[] { + return [ + { + itemId: prompt.id, + revision: 1, + sequence: 1, + observedAt: 1, + body: { kind: 'message', role: 'user', blocks: prompt.blocks } + }, + { + itemId: 'turn-1', + revision: 1, + sequence: 2, + observedAt: 2, + body: { kind: 'turn', turnId: 'turn-1', state: 'running', userItemId: prompt.id } + }, + ...rows.map((row, index) => ({ + itemId: row.id, + revision: 1, + sequence: index + 3, + observedAt: index + 3, + body: { + kind: 'message' as const, + role: row.role, + blocks: row.blocks, + ...(row.state ? { state: row.state } : {}) + } + })) + ] + } + + function list( + rows: readonly NativeChatMessage[], + props: Partial> = {} + ): React.JSX.Element { + const session: NativeChatLiveSession = { + messages: [prompt, ...rows], + status: 'working', + sessionId: 'session-1', + agent: 'claude', + hasMore: false, + loadingEarlier: false, + olderHistoryGeneration: 0, + loadEarlier: vi.fn(), + readPhase: 'ready' + } + return ( + + ) + } + + const runHeader = (): HTMLElement => + document.querySelector('[data-native-chat-tool-run-state]')! + + it('joins collapsed when the reader never opened it', () => { + render(list([command, reasoning('completed')])) + expect(runHeader()).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByText('Weighing two approaches')).toBeNull() + }) + + // The run never opens for a thought; the thought keeps its own open state inside it. + it('keeps a thought the reader opened live open inside its collapsed run', () => { + const { rerender } = render(list([command, reasoning('running')])) + fireEvent.click(screen.getByRole('button', { name: 'Thinking' })) + expect(screen.getByText('Weighing two approaches')).toBeInTheDocument() + + rerender(list([command, reasoning('completed')])) + expect(runHeader()).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByText('Weighing two approaches')).toBeNull() + + fireEvent.click(runHeader()) + expect(screen.getByRole('button', { name: /Thought for/ })).toHaveAttribute( + 'aria-expanded', + 'true' + ) + expect(screen.getByText('Weighing two approaches')).toBeInTheDocument() + }) + + // While the live line is not showing it (a Stop in flight, a prompt the reader owes), the open + // thought sits in the run rather than flashing as its own row until the turn ends. + it.each([ + ['a Stop is in flight', { stopping: true }], + ['a prompt waits on the reader', { awaitingInput: 'unshown' as const }] + ])('keeps an open thought inside the run while %s', (_, props) => { + const second: NativeChatMessage = { + ...command, + id: 'tool-2', + blocks: [{ type: 'tool-call', name: 'Bash', input: { command: 'ls' }, state: 'completed' }], + timestamp: STARTED + 100 + } + const open = { ...reasoning('running'), id: 'r-2', timestamp: STARTED + 200 } + render(list([command, reasoning('completed'), second, open], props)) + expect(screen.queryByRole('button', { name: /Reasoning|Thought/ })).toBeNull() + fireEvent.click(runHeader()) + expect(screen.getAllByRole('button', { name: /Reasoning|Thought/ })).toHaveLength(2) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.unloaded-live-turn.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.unloaded-live-turn.test.tsx deleted file mode 100644 index 641916ee314..00000000000 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.unloaded-live-turn.test.tsx +++ /dev/null @@ -1,119 +0,0 @@ -// @vitest-environment happy-dom - -// A long turn's record and opening message sit above the loaded page. The host's newest turn record -// still names the turn, so its loaded rows draw under the live Working bar. - -import '@testing-library/jest-dom/vitest' - -import { cleanup, render, screen } from '@testing-library/react' -import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' -import type { - AgentJournalRenderItem, - AgentJournalTurnScope -} from '../../../../shared/agent-session-journal-types' -import type { AgentSessionLatestTurn } from '../../../../shared/agent-session-wire' -import { projectStructuredAgentSessionMessages } from '../../../../shared/structured-agent-session-message-projection' -import { NativeChatMessageList } from './NativeChatMessageList' -import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' - -let restoreViewport = (): void => {} -beforeAll(() => { - restoreViewport = installNativeChatMessageListTestViewport() -}) -afterAll(() => restoreViewport()) -afterEach(cleanup) - -const TURN_RECORD = 'turn-record-1' -const IN_TURN: AgentJournalTurnScope = { kind: 'turn', turnItemId: TURN_RECORD } - -/** The newest rows of the turn: everything from its 300th row on. */ -const loaded: AgentJournalRenderItem[] = [300, 301, 302].map((sequence) => ({ - itemId: `row-${sequence}`, - revision: 0, - sequence, - observedAt: sequence, - body: { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text: `Step ${sequence}` }] - }, - turnScope: IN_TURN -})) - -const patch = '@@ -1 +1 @@\n-before\n+after' -/** An edit among the loaded rows; the turn's earlier edits are above them. */ -const edit: AgentJournalRenderItem = { - itemId: 'row-303', - revision: 0, - sequence: 303, - observedAt: 303, - body: { - kind: 'diff', - path: 'src/a.ts', - patch: { head: patch, truncated: false, digest: 'fixture', byteLength: patch.length } - }, - turnScope: IN_TURN -} - -const running: AgentSessionLatestTurn = { - itemId: TURN_RECORD, - observedAt: 1, - turn: { turnId: 'turn-1', state: 'running', startedAt: 1_000, userItemId: 'user-1' } -} - -function list( - latestTurn: AgentSessionLatestTurn | null | undefined, - items: AgentJournalRenderItem[] = loaded -): React.JSX.Element { - return ( - - ) -} - -describe('a live turn whose record and opening message are not loaded', () => { - it('draws its Working bar with the running clock', () => { - const now = vi.spyOn(Date, 'now').mockReturnValue(64_000) - try { - render(list(running)) - expect(screen.getByText('Step 302')).toBeInTheDocument() - expect(screen.getByText(/Working for 1m 3s/)).toBeInTheDocument() - } finally { - now.mockRestore() - } - }) - - it('totals no edits, since only the end of the turn is loaded', () => { - render(list(running, [...loaded, edit])) - expect(screen.getByText(/Working for/)).toBeInTheDocument() - expect(screen.queryByRole('button', { name: /changed file/ })).toBeNull() - }) - - it('had no bar to draw from the loaded rows alone', () => { - const now = vi.spyOn(Date, 'now').mockReturnValue(64_000) - try { - render(list(undefined)) - expect(screen.getByText('Step 302')).toBeInTheDocument() - expect(screen.queryByText(/Working for/)).not.toBeInTheDocument() - } finally { - now.mockRestore() - } - }) -}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.work-runs.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.work-runs.test.tsx deleted file mode 100644 index bf5cfdb8bb4..00000000000 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.work-runs.test.tsx +++ /dev/null @@ -1,154 +0,0 @@ -// @vitest-environment happy-dom - -import '@testing-library/jest-dom/vitest' - -import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' -import type { NativeChatLiveSession } from './use-native-chat-live-session' -import { NativeChatMessageList } from './NativeChatMessageList' -import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' -import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' -import type { NativeChatMessage } from '../../../../shared/native-chat-types' - -let restoreViewport = (): void => {} -beforeAll(() => { - restoreViewport = installNativeChatMessageListTestViewport() -}) -afterAll(() => restoreViewport()) -afterEach(cleanup) - -const STARTED = 1_000 - -const prompt: NativeChatMessage = { - id: 'user-1', - role: 'user', - blocks: [{ type: 'text', text: 'Start the task' }], - timestamp: STARTED - 500, - source: 'transcript' -} - -const command: NativeChatMessage = { - id: 'tool-1', - role: 'assistant', - blocks: [{ type: 'tool-call', name: 'Bash', input: { command: 'ls logs' }, state: 'completed' }], - timestamp: STARTED, - source: 'transcript' -} - -function reasoning(state: 'running' | 'completed'): NativeChatMessage { - return { - id: 'r-1', - role: 'reasoning', - blocks: [{ type: 'text', text: 'Weighing two approaches' }], - timestamp: STARTED + 50, - source: 'transcript', - state, - ...(state === 'completed' ? { completedAt: STARTED + 12_000 } : {}) - } -} - -/** The journal that says the turn runs and what its newest content is. */ -function journal(rows: readonly NativeChatMessage[]): AgentJournalRenderItem[] { - return [ - { - itemId: prompt.id, - revision: 1, - sequence: 1, - observedAt: 1, - body: { kind: 'message', role: 'user', blocks: prompt.blocks } - }, - { - itemId: 'turn-1', - revision: 1, - sequence: 2, - observedAt: 2, - body: { kind: 'turn', turnId: 'turn-1', state: 'running', userItemId: prompt.id } - }, - ...rows.map((row, index) => ({ - itemId: row.id, - revision: 1, - sequence: index + 3, - observedAt: index + 3, - body: { - kind: 'message' as const, - role: row.role, - blocks: row.blocks, - ...(row.state ? { state: row.state } : {}) - } - })) - ] -} - -function list( - rows: readonly NativeChatMessage[], - props: Partial> = {} -): React.JSX.Element { - const session: NativeChatLiveSession = { - messages: [prompt, ...rows], - status: 'working', - sessionId: 'session-1', - agent: 'claude', - hasMore: false, - loadingEarlier: false, - olderHistoryGeneration: 0, - loadEarlier: vi.fn(), - readPhase: 'ready' - } - return ( - - ) -} - -const runHeader = (): HTMLElement => - document.querySelector('[data-native-chat-tool-run-state]')! - -describe('a thought joining a work run', () => { - it('joins collapsed when the reader never opened it', () => { - render(list([command, reasoning('completed')])) - expect(runHeader()).toHaveAttribute('aria-expanded', 'false') - expect(screen.queryByText('Weighing two approaches')).toBeNull() - }) - - // The run never opens for a thought; the thought keeps its own open state inside it. - it('keeps a thought the reader opened live open inside its collapsed run', () => { - const { rerender } = render(list([command, reasoning('running')])) - fireEvent.click(screen.getByRole('button', { name: 'Thinking' })) - expect(screen.getByText('Weighing two approaches')).toBeInTheDocument() - - rerender(list([command, reasoning('completed')])) - expect(runHeader()).toHaveAttribute('aria-expanded', 'false') - expect(screen.queryByText('Weighing two approaches')).toBeNull() - - fireEvent.click(runHeader()) - expect(screen.getByRole('button', { name: /Thought for/ })).toHaveAttribute( - 'aria-expanded', - 'true' - ) - expect(screen.getByText('Weighing two approaches')).toBeInTheDocument() - }) - - // While the live line is not showing it (a Stop in flight, a prompt the reader owes), the open - // thought sits in the run rather than flashing as its own row until the turn ends. - it.each([ - ['a Stop is in flight', { stopping: true }], - ['a prompt waits on the reader', { awaitingInput: 'unshown' as const }] - ])('keeps an open thought inside the run while %s', (_, props) => { - const second: NativeChatMessage = { - ...command, - id: 'tool-2', - blocks: [{ type: 'tool-call', name: 'Bash', input: { command: 'ls' }, state: 'completed' }], - timestamp: STARTED + 100 - } - const open = { ...reasoning('running'), id: 'r-2', timestamp: STARTED + 200 } - render(list([command, reasoning('completed'), second, open], props)) - expect(screen.queryByRole('button', { name: /Reasoning|Thought/ })).toBeNull() - fireEvent.click(runHeader()) - expect(screen.getAllByRole('button', { name: /Reasoning|Thought/ })).toHaveLength(2) - }) -}) diff --git a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts index d16d816c4ad..6f0a1a1273e 100644 --- a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts +++ b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts @@ -17,7 +17,7 @@ import { isRuntimeServerTransportConnected, isRuntimeEnvironmentRemovalBlocked, type RuntimeHostDetails -} from './RuntimeEnvironmentsPane' +} from './runtime-environment-host-details' function details(overrides: Partial): RuntimeHostDetails { return { diff --git a/src/renderer/src/components/terminal/activation-deferred-tab-admission.test.ts b/src/renderer/src/components/terminal/activation-deferred-tab-admission.test.ts index e84b98683aa..1bb0be78700 100644 --- a/src/renderer/src/components/terminal/activation-deferred-tab-admission.test.ts +++ b/src/renderer/src/components/terminal/activation-deferred-tab-admission.test.ts @@ -48,22 +48,6 @@ describe('activation-deferred tab admission', () => { expect(isActivationAdmissionEligible(ACTIVATION_DEFERRED_ADMISSION_LIMIT + 1)).toBe(false) }) - // The launch worktree is restored active before hydration opens the startup - // gate, so admission first sees an empty set and only later the real plan. - // Judging on the high-water mark is what lets that plan still be admitted, - // while an over-cap worktree stays ineligible as its set drains. - it('judges eligibility on the largest deferred set seen, not the latest', () => { - const highWaterMark = (counts: readonly number[]): number => - counts.reduce((seen, count) => Math.max(seen, count), 0) - - // Launch: empty reading before hydration, then the real 3-tab plan. - expect(isActivationAdmissionEligible(highWaterMark([0, 3]))).toBe(true) - // Draining 3 -> 2 -> 1 must not change the verdict. - expect(isActivationAdmissionEligible(highWaterMark([0, 3, 2, 1]))).toBe(true) - // An over-cap activation stays ineligible however far it drains. - expect(isActivationAdmissionEligible(highWaterMark([7, 4, 2]))).toBe(false) - }) - // The contract that makes deferral free: repeated admission ends with the // worktree exactly as fully mounted as it would have been without deferral. it('drains to a fully mounted worktree with no restriction left behind', () => { diff --git a/src/renderer/src/components/workspace-surface-projection.test.ts b/src/renderer/src/components/workspace-surface-projection.test.ts index 7f7c1709ca9..fc74ba66fa1 100644 --- a/src/renderer/src/components/workspace-surface-projection.test.ts +++ b/src/renderer/src/components/workspace-surface-projection.test.ts @@ -7,8 +7,6 @@ * SSH/paired-runtime host therefore reached the mount loops twice, mounting the * same tabIds under duplicate React keys with both trees marked visible. */ -import { readdirSync, readFileSync } from 'node:fs' -import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { projectWorkspaceSurfaces } from './workspace-surface-projection' import { getIndexedAllWorktrees, getIndexedWorktreeMap } from '../store/worktree-repo-index' @@ -367,32 +365,3 @@ describe('worktree surface feed subscription identity', () => { ) }) }) - -// Why source text: the per-id collapse lives in the store index, so the module -// tests above stay green even if the workbench goes back to flattening the -// host-qualified array itself. The feed is the half that has to be ratcheted. -const FOUNDATION_PATH = 'src/renderer/src/components/use-terminal-workspace-foundation.ts' - -describe('Terminal workbench surface feed', () => { - const source = readFileSync(join(process.cwd(), FOUNDATION_PATH), 'utf8') - - it('feeds the projection from the store per-id index, never the host-qualified array', () => { - expect(source).not.toContain('useAllWorktrees') - expect(source).toContain('const worktreesById = useWorktreeMap()') - }) - - it('has exactly one projection call site, so no second flatten can hide beside it', () => { - const componentsDir = join(process.cwd(), 'src/renderer/src/components') - const callSiteFiles = readdirSync(componentsDir) - .filter((name) => /\.tsx?$/.test(name) && !name.includes('.test.')) - .filter((name) => - readFileSync(join(componentsDir, name), 'utf8').includes('projectWorkspaceSurfaces(') - ) - expect(callSiteFiles.sort()).toEqual([ - 'use-terminal-workspace-foundation.ts', - 'workspace-surface-projection.ts' - ]) - expect(source.split('projectWorkspaceSurfaces(').length - 1).toBe(1) - expect(source).toContain('worktreesById,\n folderWorkspaces,') - }) -}) diff --git a/src/renderer/src/i18n/no-top-level-translate.test.ts b/src/renderer/src/i18n/no-top-level-translate.test.ts deleted file mode 100644 index 1709bfb35fd..00000000000 --- a/src/renderer/src/i18n/no-top-level-translate.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { readdirSync, readFileSync, statSync } from 'node:fs' -import { relative, resolve } from 'node:path' -// TypeScript 7 is a native CLI; AST tests still need the legacy JavaScript API. -import ts from 'typescript-api' -import { describe, expect, it } from 'vitest' - -const RENDERER_ROOT = resolve('src/renderer/src') - -const FUNCTION_KINDS = new Set([ - ts.SyntaxKind.FunctionDeclaration, - ts.SyntaxKind.FunctionExpression, - ts.SyntaxKind.ArrowFunction, - ts.SyntaxKind.MethodDeclaration, - ts.SyntaxKind.Constructor, - ts.SyntaxKind.GetAccessor, - ts.SyntaxKind.SetAccessor -]) - -function collectSourceFiles(dir: string, files: string[] = []): string[] { - for (const name of readdirSync(dir)) { - const filePath = resolve(dir, name) - const stat = statSync(filePath) - if (stat.isDirectory()) { - collectSourceFiles(filePath, files) - } else if ( - /\.(ts|tsx)$/.test(name) && - !/\.test\.(ts|tsx)$/.test(name) && - !filePath.includes('/i18n/locales/') - ) { - files.push(filePath) - } - } - return files -} - -function isInsideFunction(node: ts.Node): boolean { - let parent = node.parent - while (parent && parent.kind !== ts.SyntaxKind.SourceFile) { - if (FUNCTION_KINDS.has(parent.kind)) { - return true - } - parent = parent.parent - } - return false -} - -describe('i18n import-time safety', () => { - it('does not evaluate translate() at module load time', () => { - const violations: string[] = [] - - for (const filePath of collectSourceFiles(RENDERER_ROOT)) { - const source = readFileSync(filePath, 'utf8') - if (!source.includes('translate(')) { - continue - } - const sourceFile = ts.createSourceFile( - filePath, - source, - ts.ScriptTarget.Latest, - true, - filePath.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS - ) - - function visit(node: ts.Node): void { - if ( - ts.isCallExpression(node) && - node.expression.getText(sourceFile) === 'translate' && - !isInsideFunction(node) - ) { - const { line, character } = sourceFile.getLineAndCharacterOfPosition( - node.getStart(sourceFile) - ) - violations.push(`${relative(process.cwd(), filePath)}:${line + 1}:${character + 1}`) - } - ts.forEachChild(node, visit) - } - - visit(sourceFile) - } - - expect(violations).toEqual([]) - }, 15_000) -}) diff --git a/src/shared/agent-status-child-work-codec-boundary.test.ts b/src/shared/agent-status-child-work-codec-boundary.test.ts deleted file mode 100644 index aa4ac50c7ad..00000000000 --- a/src/shared/agent-status-child-work-codec-boundary.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { resolve } from 'node:path' -import { describe, expect, it } from 'vitest' -import { scanSourceTree } from './source-scan/source-tree-scan' - -const SOURCE_ROOT = resolve(__dirname, '..') -const CODEC_IMPORT = /from\s+['"][^'"]*\/agent-status-child-work-codec['"]/ - -// The host's own store and admission path. The codec rejects a whole record over one unknown key, -// so a reader of another build's records or views must use a permissive or negotiated decoder. -const HOST_INTERNAL_IMPORTERS = [ - 'shared/agent-status-child-work-admission-core.ts', - 'shared/agent-status-child-work-admission-operations.ts', - 'shared/agent-status-child-work-alias.ts', - 'shared/agent-status-child-work-resume.ts', - 'shared/agent-status-store-codec.ts', - 'shared/agent-status-store-mutation.ts', - 'shared/agent-status-store-state.ts' -] - -describe('child-work record codec boundary', () => { - it('is imported only by the host store and admission modules', () => { - const importers = scanSourceTree(SOURCE_ROOT) - .filter((file) => CODEC_IMPORT.test(file.source)) - .map((file) => file.relativePath) - .sort() - expect(importers).toEqual(HOST_INTERNAL_IMPORTERS) - }) -}) diff --git a/src/shared/remote-runtime-connect-bound.test.ts b/src/shared/remote-runtime-connect-bound.test.ts index df70cd13e11..2dea720808d 100644 --- a/src/shared/remote-runtime-connect-bound.test.ts +++ b/src/shared/remote-runtime-connect-bound.test.ts @@ -1,6 +1,4 @@ -import { readFileSync, readdirSync } from 'node:fs' import { createServer, type Server, type Socket } from 'node:net' -import { basename, join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { generateKeyPair, publicKeyToBase64 } from './e2ee-crypto' import type { RemoteRuntimeClientError } from './remote-runtime-client-error' @@ -20,21 +18,6 @@ const sockets = new Set() const handshakeTimeoutError = (): Error => new Error(WS_HANDSHAKE_TIMEOUT_MESSAGE) -/** - * Files whose WebSocket construction must carry the connect bound: the shared - * remote-runtime transports, swept by prefix. Other WebSocket sites (relay - * control and data transports, emulator control) carry their own bounds and are - * deliberately not covered here. - */ -function coveredSocketSources(): string[] { - return readdirSync(__dirname) - .filter( - (name) => - name.startsWith('remote-runtime-') && name.endsWith('.ts') && !name.includes('.test.') - ) - .map((name) => join(__dirname, name)) -} - afterEach(async () => { for (const socket of sockets) { socket.destroy() @@ -77,25 +60,6 @@ describe('remote runtime connect bound', () => { expect(options.maxPayload).toBe(1024) }) - // Why: the bound only helps if every Node-side remote-runtime socket carries - // it; a new transport that calls `new WebSocket` directly reintroduces #18191. - it('routes every covered WebSocket construction through the bounded options', () => { - const offenders: string[] = [] - let scannedConstructions = 0 - for (const path of coveredSocketSources()) { - const source = readFileSync(path, 'utf8') - const constructions = source.split('new WebSocket(').length - 1 - const bounded = source.split('remoteRuntimeConnectOptions(').length - 1 - scannedConstructions += constructions - if (constructions > bounded) { - offenders.push(`${basename(path)}: ${constructions} WebSocket(s), ${bounded} bounded`) - } - } - expect(offenders).toEqual([]) - // Guards against the scan silently matching nothing and passing vacuously. - expect(scannedConstructions).toBeGreaterThan(0) - }) - it('reports an unanswered host as unreachable rather than as an empty result', async () => { const endpoint = await listenSilentUpgradeServer() const keyPair = generateKeyPair() diff --git a/src/shared/remote-runtime-shared-control-boundary.test.ts b/src/shared/remote-runtime-shared-control-boundary.test.ts deleted file mode 100644 index 70811d7273c..00000000000 --- a/src/shared/remote-runtime-shared-control-boundary.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { readdirSync, readFileSync, statSync } from 'node:fs' -import { join } from 'node:path' -// TypeScript 7 is a native CLI; AST tests still need the legacy JavaScript API. -import ts from 'typescript-api' -import { describe, expect, it } from 'vitest' - -const FORBIDDEN_REMOTE_SERVER_IMPORTS = [ - 'remote-runtime-shared-control', - 'runtime-environment-request-connections' -] - -describe('remote runtime shared-control transport boundary', () => { - it.each(['src/main/ssh', 'src/relay'])( - 'does not couple %s to remote-server shared-control transport', - (root) => { - const offenders = collectTypeScriptFiles(root).filter((file) => { - const source = readFileSync(file, 'utf8') - return collectModuleSpecifiers(file, source).some((specifier) => - FORBIDDEN_REMOTE_SERVER_IMPORTS.some((pattern) => specifier.includes(pattern)) - ) - }) - - expect(offenders).toEqual([]) - } - ) -}) - -function collectTypeScriptFiles(root: string): string[] { - const entries = readdirSync(root) - const files: string[] = [] - for (const entry of entries) { - const path = join(root, entry) - const stat = statSync(path) - if (stat.isDirectory()) { - files.push(...collectTypeScriptFiles(path)) - continue - } - if (path.endsWith('.ts') || path.endsWith('.tsx')) { - files.push(path) - } - } - return files -} - -function collectModuleSpecifiers(fileName: string, source: string): string[] { - const file = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true) - const specifiers: string[] = [] - - const visit = (node: ts.Node): void => { - if ( - (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && - node.moduleSpecifier && - ts.isStringLiteral(node.moduleSpecifier) - ) { - specifiers.push(node.moduleSpecifier.text) - } - if ( - ts.isImportTypeNode(node) && - ts.isLiteralTypeNode(node.argument) && - ts.isStringLiteral(node.argument.literal) - ) { - specifiers.push(node.argument.literal.text) - } - if ( - ts.isCallExpression(node) && - isModuleLoader(node.expression) && - node.arguments.length === 1 && - ts.isStringLiteral(node.arguments[0]) - ) { - specifiers.push(node.arguments[0].text) - } - ts.forEachChild(node, visit) - } - - visit(file) - return specifiers -} - -function isModuleLoader(expression: ts.Expression): boolean { - return ( - expression.kind === ts.SyntaxKind.ImportKeyword || - (ts.isIdentifier(expression) && expression.text === 'require') - ) -} diff --git a/src/shared/source-scan/call-argument-scan.ts b/src/shared/source-scan/call-argument-scan.ts deleted file mode 100644 index 59a411aa2f0..00000000000 --- a/src/shared/source-scan/call-argument-scan.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { stripComments } from './source-tree-scan' - -const CLOSER_BY_OPENER: Record = { '(': ')', '[': ']', '{': '}' } - -/** Top-level argument texts of the call whose `(` ends at `start`; quote-aware, not a parser. */ -export function splitCallArguments(source: string, start: number): string[] { - const args: string[] = [] - const closers: string[] = [')'] - let current = '' - let quote: string | null = null - for (let index = start; index < source.length; index += 1) { - const char = source[index]! - if (quote) { - current += char - if (char === '\\') { - current += source[index + 1] ?? '' - index += 1 - } else if (char === quote) { - quote = null - } - continue - } - const closer = CLOSER_BY_OPENER[char] - if (char === "'" || char === '"' || char === '`') { - quote = char - } else if (closer) { - closers.push(closer) - } else if (char === closers.at(-1)) { - closers.pop() - if (closers.length === 0) { - break - } - } else if (char === ',' && closers.length === 1) { - args.push(current.trim()) - current = '' - continue - } - current += char - } - if (current.trim()) { - args.push(current.trim()) - } - return args -} - -export type RequiredCallArgument = { - /** Which positional argument must be present. */ - index: number - /** The call counts only when the text before `.method(` matches, e.g. its receiver. */ - receiver?: RegExp - /** When the argument is an object literal, whether it carries the required field. */ - acceptsObjectLiteral?: (literal: string) => boolean -} - -/** - * `line: .method(args)` for each call that leaves out a required argument. For code the compiler - * cannot check, such as `@ts-nocheck` files and `any`-typed receivers. - */ -export function findCallsMissingArgument( - source: string, - requiredByMethod: Record -): string[] { - const code = stripComments(source) - const callRe = new RegExp(`\\.\\s*(${Object.keys(requiredByMethod).join('|')})\\s*\\(`, 'g') - const missing: string[] = [] - for (const match of code.matchAll(callRe)) { - const method = match[1]! - const required = requiredByMethod[method]! - const before = code.slice(0, match.index) - if (required.receiver && !required.receiver.test(before)) { - continue - } - const args = splitCallArguments(code, match.index + match[0].length) - const argument = args[required.index] - const present = - argument !== undefined && - (!argument.startsWith('{') || (required.acceptsObjectLiteral?.(argument) ?? true)) - if (!present) { - missing.push(`${before.split('\n').length}: .${method}(${args.join(', ')})`) - } - } - return missing -}