[CI] Reduce repeated test work and use pinned Bun for localization extraction (#26806)

* [CI] Reduce repeated test setup and retire source inventories

* [CI] Give consolidated floating panel suites distinct titles
This commit is contained in:
Neil
2026-10-09 02:37:53 -07:00
committed by GitHub
parent 1dab0d076e
commit 7b1242ba94
40 changed files with 1373 additions and 4137 deletions
@@ -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
)
})
})
@@ -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'))
}
@@ -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<string, string>
namespaces: Set<string>
} {
const byLocalName = new Map<string, string>()
const namespaces = new Set<string>()
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<typeof reanimatedBindings>
): 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<string> {
const names = new Set<string>()
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<string> {
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([])
})
})
@@ -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([])
})
})
@@ -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)
})
})
@@ -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([])
})
})
@@ -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([])
})
})
@@ -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<RpcResponse>` 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<RpcClient, 'sendRequest'>")).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([])
})
})
+1 -10
View File
@@ -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', () => {
@@ -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<typeof setTimeout> | 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 () => {
@@ -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<string, unknown> }[] = []
const records: {
name: string
attributes: Record<string, unknown>
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()
}
@@ -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()'))
})
})
@@ -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()
}
})
})
@@ -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 () => {
@@ -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 () => {
@@ -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`(?<!\bfunction\s+)\bsendStructuredAgentSessionTurn\s*\(`
].join('|')
)
function filesMatching(pattern: RegExp): string[] {
return shipped
.filter((file) => 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()
)
})
})
@@ -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<string, RequiredCallArgument> = {
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([])
})
})
@@ -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 () => {
@@ -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<void> {
}
describe('attachMainWindowServices', () => {
afterEach(() => {
cancelHistoryGc()
})
beforeEach(() => {
vi.resetAllMocks()
systemPreferencesAskForMediaAccessMock.mockResolvedValue(true)
@@ -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()
}
})
@@ -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()
}
})
@@ -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' } }]
})
})
})
@@ -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' } }]
})
})
})
@@ -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)
})
})
@@ -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<typeof import('react')>('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<string, unknown>) => 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')
})
})
@@ -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<typeof import('react')>('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<string, unknown>) => 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()
})
})
@@ -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<typeof import('react')>('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')
})
})
@@ -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> = {}
): 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 (
<NativeChatMessageList
session={{
messages: projectStructuredAgentSessionMessages(items, optimistic, submissions, {
rejectedInPlace: true
}),
status: 'ready',
sessionId: 'session-1',
agent: 'codex',
hasMore: false,
loadingEarlier: false,
olderHistoryGeneration: 0,
loadEarlier: vi.fn(),
readPhase: 'ready'
}}
journalItems={items}
journalSubmissions={submissions}
settledTurns={selectStructuredAgentSettledTurns(items, submissions)}
isWorking
workingStartedAt={NOW}
stopping={stopping}
expandSignal={false}
/>
)
}
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> = {}
): 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 (
<NativeChatMessageList
session={{
messages: projectStructuredAgentSessionMessages(items, optimistic, submissions, {
rejectedInPlace: true
}),
status: 'ready',
sessionId: 'session-1',
agent: 'codex',
hasMore: false,
loadingEarlier: false,
olderHistoryGeneration: 0,
loadEarlier: vi.fn(),
readPhase: 'ready'
}}
journalItems={items}
journalSubmissions={submissions}
settledTurns={selectStructuredAgentSettledTurns(items, submissions)}
isWorking
workingStartedAt={NOW}
stopping={stopping}
expandSignal={false}
/>
)
}
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> = {}
): AgentJournalSubmission {
return {
clientMessageId,
fence: 1,
payloadFingerprint: clientMessageId,
dispatchState: 'accepted',
providerItemId: null,
reason: null,
submittedAt: 1,
resolvedAt: 2,
...overrides
}
}
const stopped = (id: string, overrides: Partial<AgentJournalSubmission> = {}) =>
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 (
<NativeChatMessageList
session={{
messages: projectStructuredAgentSessionMessages(items, [], submissions, {
rejectedInPlace: true
}),
status: 'ready',
sessionId: 'session-1',
agent: 'codex',
hasMore: false,
loadingEarlier: false,
olderHistoryGeneration: 0,
loadEarlier: vi.fn(),
readPhase: 'ready'
}}
journalItems={items}
journalSubmissions={submissions}
settledTurns={selectStructuredAgentSettledTurns(items, submissions)}
isWorking={live.isWorking}
workingStartedAt={live.workingStartedAt}
expandSignal={false}
/>
)
}
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()
})
})
@@ -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> = {}
): AgentJournalSubmission {
return {
clientMessageId,
fence: 1,
payloadFingerprint: clientMessageId,
dispatchState: 'accepted',
providerItemId: null,
reason: null,
submittedAt: 1,
resolvedAt: 2,
...overrides
}
}
const stopped = (id: string, overrides: Partial<AgentJournalSubmission> = {}) =>
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 (
<NativeChatMessageList
session={{
messages: projectStructuredAgentSessionMessages(items, [], submissions, {
rejectedInPlace: true
}),
status: 'ready',
sessionId: 'session-1',
agent: 'codex',
hasMore: false,
loadingEarlier: false,
olderHistoryGeneration: 0,
loadEarlier: vi.fn(),
readPhase: 'ready'
}}
journalItems={items}
journalSubmissions={submissions}
settledTurns={selectStructuredAgentSettledTurns(items, submissions)}
isWorking={live.isWorking}
workingStartedAt={live.workingStartedAt}
expandSignal={false}
/>
)
}
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()
})
})
@@ -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 (
<NativeChatMessageList
session={{
messages: projectStructuredAgentSessionMessages(items, [], [], { rejectedInPlace: true }),
status: 'ready',
sessionId: 'session-1',
agent: 'codex',
hasMore: true,
loadingEarlier: false,
olderHistoryGeneration: 0,
loadEarlier: vi.fn(),
readPhase: 'ready'
}}
journalItems={items}
journalSubmissions={[]}
journalLatestTurn={latestTurn}
isWorking
workingStartedAt={1_000}
expandSignal={false}
/>
)
}
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.ComponentProps<typeof NativeChatMessageList>> = {}
): 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 (
<NativeChatMessageList
session={session}
journalItems={journal(rows)}
isWorking
expandSignal={false}
{...props}
/>
)
}
const runHeader = (): HTMLElement =>
document.querySelector<HTMLElement>('[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)
})
})
@@ -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 (
<NativeChatMessageList
session={{
messages: projectStructuredAgentSessionMessages(items, [], [], { rejectedInPlace: true }),
status: 'ready',
sessionId: 'session-1',
agent: 'codex',
hasMore: true,
loadingEarlier: false,
olderHistoryGeneration: 0,
loadEarlier: vi.fn(),
readPhase: 'ready'
}}
journalItems={items}
journalSubmissions={[]}
journalLatestTurn={latestTurn}
isWorking
workingStartedAt={1_000}
expandSignal={false}
/>
)
}
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()
}
})
})
@@ -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.ComponentProps<typeof NativeChatMessageList>> = {}
): 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 (
<NativeChatMessageList
session={session}
journalItems={journal(rows)}
isWorking
expandSignal={false}
{...props}
/>
)
}
const runHeader = (): HTMLElement =>
document.querySelector<HTMLElement>('[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)
})
})
@@ -17,7 +17,7 @@ import {
isRuntimeServerTransportConnected,
isRuntimeEnvironmentRemovalBlocked,
type RuntimeHostDetails
} from './RuntimeEnvironmentsPane'
} from './runtime-environment-host-details'
function details(overrides: Partial<RuntimeHostDetails>): RuntimeHostDetails {
return {
@@ -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', () => {
@@ -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,')
})
})
@@ -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>([
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)
})
@@ -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)
})
})
@@ -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<Socket>()
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()
@@ -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')
)
}
@@ -1,83 +0,0 @@
import { stripComments } from './source-tree-scan'
const CLOSER_BY_OPENER: Record<string, string> = { '(': ')', '[': ']', '{': '}' }
/** 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, RequiredCallArgument>
): 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
}