mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
[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:
@@ -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,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 () => {
|
||||
|
||||
+11
-2
@@ -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)
|
||||
})
|
||||
})
|
||||
+326
-32
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
-333
@@ -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()
|
||||
})
|
||||
})
|
||||
-293
@@ -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')
|
||||
})
|
||||
})
|
||||
+385
-138
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
-267
@@ -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()
|
||||
})
|
||||
})
|
||||
+294
-41
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
-119
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user