[CI] Consolidate tests, retire static scans, and update evidence (#27109)

* [CI] Consolidate renderer test modules and retire static scans

* [CI] Keep consolidated test within the existing line limit

* [CI] Refresh evidence for the preserved turn-duration gate
This commit is contained in:
Neil
2026-10-09 21:16:00 -07:00
committed by GitHub
parent 8ba3cf46ab
commit 7c22f6f045
31 changed files with 3492 additions and 5089 deletions
+7 -7
View File
@@ -1616,7 +1616,7 @@
"invariant": "An observed completed turn retains the same host-recorded duration and initiating user-message attribution through client reload, settlement retry and loading older history; older clients receive the compatible item carrier, and a host-recorded unknown duration suppresses local estimates.",
"oracle": "Render a host-settled duration without observing completion locally; preserve an exit at 2000 through failed settlement at 60000 and later retry; retain a seven-second older turn beyond 256 submitted turns; prune aliases with removed user items and preserve alias identity during item-only streaming; render no completed duration for an unverifiable turn both before and after client reload.",
"commands": [
"ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/native-chat-unverifiable-turn-status.test.ts src/main/codex/codex-requested-close-turn-timing.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts src/shared/structured-agent-session-turn-timing-retention.test.ts src/shared/structured-agent-session-turn-timing.test.ts src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.test.ts src/renderer/src/components/native-chat/NativeChatMessageList.turn-timing.test.tsx"
"ORCA_BACKGROUND_LAUNCH=1 node config/scripts/run-vitest.mjs run --config config/vitest.config.ts src/shared/native-chat-unverifiable-turn-status.test.ts src/main/codex/codex-requested-close-turn-timing.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts src/shared/structured-agent-session-turn-timing-retention.test.ts src/shared/structured-agent-session-turn-timing.test.ts src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.test.ts src/renderer/src/components/native-chat/NativeChatMessageList.turn-membership.test.tsx --maxWorkers=4 --reporter=json --outputFile=notes/bun-migration/performance/ci-throughput-resume-20261009/next_capacity_wave_owner/reliability-path-correction/fresh-eight-root-stock-config.json"
],
"testFiles": [
"src/shared/native-chat-unverifiable-turn-status.test.ts",
@@ -1626,7 +1626,7 @@
"src/shared/structured-agent-session-turn-timing.test.ts",
"src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts",
"src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.test.ts",
"src/renderer/src/components/native-chat/NativeChatMessageList.turn-timing.test.tsx"
"src/renderer/src/components/native-chat/NativeChatMessageList.turn-membership.test.tsx"
],
"assertionRefs": [
{
@@ -1651,19 +1651,19 @@
]
},
{
"file": "src/renderer/src/components/native-chat/NativeChatMessageList.turn-timing.test.tsx",
"file": "src/renderer/src/components/native-chat/NativeChatMessageList.turn-membership.test.tsx",
"assertions": ["renders a host-settled duration without ever clocking the turn locally"]
}
],
"evidenceRuns": [
{
"date": "2026-09-09",
"date": "2026-10-10",
"runner": "local",
"platform": "macos",
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/native-chat-unverifiable-turn-status.test.ts src/main/codex/codex-requested-close-turn-timing.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts src/shared/structured-agent-session-turn-timing-retention.test.ts src/shared/structured-agent-session-turn-timing.test.ts src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.test.ts src/renderer/src/components/native-chat/NativeChatMessageList.turn-timing.test.tsx",
"command": "ORCA_BACKGROUND_LAUNCH=1 node config/scripts/run-vitest.mjs run --config config/vitest.config.ts src/shared/native-chat-unverifiable-turn-status.test.ts src/main/codex/codex-requested-close-turn-timing.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts src/shared/structured-agent-session-turn-timing-retention.test.ts src/shared/structured-agent-session-turn-timing.test.ts src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.test.ts src/renderer/src/components/native-chat/NativeChatMessageList.turn-membership.test.tsx --maxWorkers=4 --reporter=json --outputFile=notes/bun-migration/performance/ci-throughput-resume-20261009/next_capacity_wave_owner/reliability-path-correction/fresh-eight-root-stock-config.json",
"result": "passed",
"durationSeconds": 4.65,
"summary": "58 tests passed across eight files at source commit 5acb404bf244cdc0bd3643c4e79beb189ce9a0cd; the following manifest-only commit records this run without changing tested source."
"durationSeconds": 5.688707999999999,
"summary": "87 tests passed across exactly eight current files at source commit aea00c5c02c3dccaa83aee3207ba6fcb586ffa5c using stock config/vitest.config.ts, Node 24.21.0, Bun 1.4.2 and four workers; no failures, skips or todo. Natural CLI close took 5.688708 seconds. The prior 2026-09-09 record (58 tests, 4.65 seconds, source 5acb404bf244cdc0bd3643c4e79beb189ce9a0cd) is preserved verbatim in Git history and notes/bun-migration/performance/ci-throughput-resume-20261009/next_capacity_wave_owner/reliability-path-correction/before.jsonc. This manifest-only change records the new run without changing tested source."
}
],
"runtimeBudget": {
@@ -1,118 +0,0 @@
import { readFileSync } from 'node:fs'
import { expect, it } from 'vitest'
import { parse } from 'yaml'
import { NODE_SERVER_RUNNERS } from './node-server-qualification.mjs'
const readWorkflow = (name) =>
parse(readFileSync(new URL(`../../.github/workflows/${name}.yml`, import.meta.url), 'utf8'))
const workflow = readWorkflow('ci-cache-warmup')
const steps = workflow.jobs.warm.steps
it('seeds new native keys on main when any declared build or probe input changes', () => {
const native = parse(readFileSync('.github/actions/prepare-native-runtime/action.yml', 'utf8'))
const hash = native.runs.steps.find((step) => step.id === 'native-cache-scope').env
.NATIVE_SOURCE_HASH
const files = [...hash.matchAll(/'([^']+)'/g)].map((match) => match[1])
for (const file of files) {
expect(
workflow.on.push.paths.some((pattern) =>
pattern.endsWith('/**') ? file.startsWith(pattern.slice(0, -2)) : file === pattern
),
file
).toBe(true)
}
})
it('warms the same Linux Node runtime the PR shards restore', () => {
const arm = workflow.jobs['warm-linux-arm']
const install = arm.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
const primer = readWorkflow('pr').jobs.preflight
expect(arm['runs-on']).toBe(primer['runs-on'])
expect(arm.steps.at(-1).run).toBe('node config/scripts/ensure-native-runtime.mjs --check-only')
expect(install.with).toMatchObject(primer.steps.find((step) => step.uses === install.uses).with)
})
it('populates shared Electron archives on both Linux architectures without changing the Node ABI', () => {
for (const name of ['warm', 'warm-linux-arm']) {
const steps = workflow.jobs[name].steps
const install = steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
expect(install.with['native-runtime']).toBe('node')
expect(install.with['cache-electron-package']).toBe('true')
expect(install.with['cache-pnpm-store-lookup-only']).toBe('true')
const populate = steps.find((step) => step.name === 'Populate shared Electron archive')
expect(populate.run).toBe('node config/scripts/install-electron-package-binary.mjs')
expect(steps.indexOf(populate)).toBeGreaterThan(steps.indexOf(install))
}
})
it('publishes incremental state under a key and prefix that new PRs restore', () => {
const cache = steps.find((step) => step.id === 'typecheck-cache')
const prCache = readWorkflow('pr').jobs.preflight.steps.find((step) => step.name === cache.name)
expect(cache.with.path).toBe(prCache.with.path)
expect(cache.with['restore-keys']).toBe(prCache.with['restore-keys'])
expect(cache.with.key).toBe(
prCache.with.key.replace('github.event.pull_request.base.sha', 'github.sha')
)
const check = steps.find((step) => step.run === 'pnpm run typecheck')
expect(check.if).toBe("steps.typecheck-cache.outputs.cache-hit != 'true'")
expect(steps.indexOf(check)).toBeGreaterThan(steps.indexOf(cache))
})
it('bounds warming to the required platforms and validates changes without granting writes', () => {
expect(Object.keys(workflow.jobs)).toEqual([
'warm',
'warm-linux-arm',
'warm-windows',
'warm-linux-package-fixtures'
])
expect(workflow.jobs.warm['timeout-minutes']).toBeLessThanOrEqual(10)
expect(workflow.permissions).toEqual({ contents: 'read' })
expect(workflow.on.push.branches).toEqual(['main'])
expect(workflow.on.push.paths).toContain('.github/actions/prepare-native-runtime/**')
expect(workflow.on.schedule).toEqual([{ cron: '41 */6 * * *' }])
expect(workflow.on.pull_request.paths).toContain('.github/workflows/ci-cache-warmup.yml')
expect(steps[0].with['persist-credentials']).toBe(false)
})
it('lets scheduled warmers wait while pushes, PR updates, and manual runs can replace active work', () => {
expect(workflow.concurrency).toEqual({
group: 'ci-cache-warmup-${{ github.event.pull_request.number || github.ref }}',
'cancel-in-progress': "${{ github.event_name != 'schedule' }}"
})
})
it('keeps compiler changes validated without starting warmers for test-only edits', () => {
for (const event of ['push', 'pull_request']) {
const paths = workflow.on[event].paths
expect(paths).toContain('config/scripts/headless-detector-compiler-cache.mjs')
expect(paths).not.toContain('config/scripts/headless-detector-compiler-cache*')
expect(paths).not.toContain('config/scripts/headless-detector-compiler-cache.test.mjs')
expect(paths).not.toContain('config/scripts/ci-cache-warmup-workflow.test.mjs')
expect(paths).toContain('.github/workflows/ci-cache-warmup.yml')
expect(paths).toContain('.github/actions/prepare-headless-compiler/**')
}
})
it('warms and probes both Windows images with the persistence job runtime', () => {
const job = workflow.jobs['warm-windows']
expect(job.strategy.matrix.os).toEqual(
NODE_SERVER_RUNNERS.filter((os) => os.startsWith('windows-'))
)
expect(job['runs-on']).toBe('${{ matrix.os }}')
expect(job.strategy['fail-fast']).toBe(false)
expect(job['timeout-minutes']).toBeLessThanOrEqual(20)
expect(job.env.ORCA_BACKGROUND_LAUNCH).toBe('1')
expect(job.steps[0].with['persist-credentials']).toBe(false)
const install = job.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
expect(install.with).toEqual({
'native-runtime': 'node',
'cache-pnpm-store-lookup-only': 'true'
})
expect(job.steps.at(-1).run).toBe('node config/scripts/ensure-native-runtime.mjs --check-only')
})
@@ -10,16 +10,6 @@ const steps = parse(readFileSync('.github/actions/prepare-native-runtime/action.
const toolchain = steps.find((step) => step.name === 'Use external node-gyp')
describe('CI native toolchain preparation', () => {
it('probes only after both cache restore variants and before native rebuilding', () => {
const index = steps.indexOf(toolchain)
for (const id of ['native-cache-restore', 'native-cache-restore-only']) {
expect(index).toBeGreaterThan(steps.findIndex((step) => step.id === id))
expect(toolchain.env.NATIVE_CACHE_HIT).toContain(`steps.${id}.outputs.cache-hit`)
}
expect(index).toBeLessThan(steps.findIndex((step) => step.name === 'Prepare native runtime'))
expect(toolchain.if).toBe("runner.os == 'Linux' && inputs.native-runtime == 'node'")
})
// The action's toolchain workaround only runs in Linux Bash.
it.skipIf(process.platform === 'win32').each([
['node', 'true', '0', false],
@@ -64,13 +54,4 @@ describe('CI native toolchain preparation', () => {
rmSync(directory, { recursive: true, force: true })
}
})
it('prepares Electron native modules through the existing rebuild path without the global toolchain', () => {
const preparation = steps.find((step) => step.name === 'Prepare native runtime')
expect(preparation.if).toBe("inputs.native-runtime != 'none'")
expect(preparation.env.NATIVE_RUNTIME).toBe('${{ inputs.native-runtime }}')
expect(preparation.run).toBe(
'node config/scripts/ensure-native-runtime.mjs --runtime="$NATIVE_RUNTIME"'
)
})
})
@@ -1,34 +0,0 @@
import { existsSync, readFileSync } from 'node:fs'
import { dirname, matchesGlob, relative, resolve, sep } from 'node:path'
import { expect, it } from 'vitest'
import ts from 'typescript-api'
import { parse } from 'yaml'
const projectDir = resolve(import.meta.dirname, '../..')
const workflow = parse(readFileSync(resolve(projectDir, '.github/workflows/mobile.yml'), 'utf8'))
it.each(['agent-launch-mobile-replay', 'mobile-agent-launch-architecture'])(
'runs Mobile Checks when a direct root dependency of %s changes',
(name) => {
const suite = resolve(projectDir, `mobile/src/tasks/${name}.test.ts`)
// Parse source only: the root test project must never load the mobile dependency graph.
const imports = ts
.preProcessFile(readFileSync(suite, 'utf8'), true)
.importedFiles.filter((entry) => entry.fileName.startsWith('.'))
.map((entry) =>
relative(projectDir, resolve(dirname(suite), `${entry.fileName}.ts`))
.split(sep)
.join('/')
)
.filter((file) => file.startsWith('src/'))
expect(imports).not.toEqual([])
for (const file of imports) {
expect(existsSync(resolve(projectDir, file)), file).toBe(true)
expect(
workflow.on.pull_request.paths.some((pattern) => matchesGlob(file, pattern)),
file
).toBe(true)
}
}
)
@@ -1,131 +0,0 @@
import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const projectDir = fileURLToPath(new URL('../..', import.meta.url))
const mobileDir = join(projectDir, 'mobile')
const allowlistPath = join(mobileDir, 'web-entry', 'web-overrides.json')
// Every tree the app entry can resolve a .web.* sibling out of: src and web-entry and packages
// through the builder's resolveExtensions, app through the route manifest's own sibling
// preference. packages is in the list because the dictation hook imports the vendored
// @orca/expo-two-way-audio, whose web module then reaches the page.
const SCANNED = ['src', 'app', 'web-entry', 'packages']
const WEB_SIBLING = /\.web\.(tsx|ts|jsx|js)$/
async function listFiles(directory) {
const out = []
for (const entry of await readdir(directory, { withFileTypes: true })) {
if (entry.name === 'node_modules') {
continue
}
const entryPath = join(directory, entry.name)
if (entry.isDirectory()) {
out.push(...(await listFiles(entryPath)))
} else if (entry.isFile()) {
out.push(entryPath)
}
}
return out
}
/** Takes the root so the census can be run against a scratch tree and shown to fail. */
export async function findWebSiblings(rootDir) {
const found = []
for (const tree of SCANNED) {
const directory = join(rootDir, tree)
if (!existsSync(directory)) {
continue
}
for (const file of await listFiles(directory)) {
if (WEB_SIBLING.test(file)) {
found.push(relative(rootDir, file).split('\\').join('/'))
}
}
}
return found.sort()
}
async function readAllowlist() {
return JSON.parse(await readFile(allowlistPath, 'utf8'))
}
async function exists(path) {
return readFile(path).then(
() => true,
() => false
)
}
async function withScratch(run) {
const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-overrides-'))
try {
return await run(scratch)
} finally {
await rm(scratch, { recursive: true, force: true })
}
}
async function plant(scratch, file) {
await mkdir(join(scratch, file, '..'), { recursive: true })
await writeFile(join(scratch, file), 'export default null\n', 'utf8')
}
describe('mobile web app .web.* overrides', () => {
it('lists exactly the .web.* files on disk', async () => {
const { overrides } = await readAllowlist()
expect(overrides.map((entry) => entry.file).sort()).toEqual(await findWebSiblings(mobileDir))
})
it('gives every override a non-web sibling, so the native build still has a module', async () => {
const { overrides } = await readAllowlist()
for (const { file } of overrides) {
const native = join(mobileDir, file.replace('.web.', '.'))
// A .web.tsx may shadow a .tsx or a .ts; try both before failing.
const alternative = native.replace(/\.tsx$/, '.ts').replace(/\.jsx$/, '.js')
expect(
(await exists(native)) || (await exists(alternative)),
`${file} has no non-web sibling`
).toBe(true)
}
})
it('states a reason for every override', async () => {
const { overrides } = await readAllowlist()
for (const entry of overrides) {
expect(entry.reason.length, `${entry.file} has no reason`).toBeGreaterThan(20)
}
})
})
// A census that scans only trees which happen to hold no .web.* file passes for the wrong reason.
// These plant one in each scanned tree and show the first assertion above would report it.
describe('the census scan', () => {
it('reports an unlisted .web.* in every tree it claims to cover', async () => {
const planted = {
src: 'src/transport/planted.web.ts',
app: 'app/h/[hostId]/edit.web.tsx',
'web-entry': 'web-entry/planted.web.tsx',
packages: 'packages/expo-two-way-audio/src/Planted.web.ts'
}
for (const [tree, file] of Object.entries(planted)) {
await withScratch(async (scratch) => {
await plant(scratch, file)
expect(
await findWebSiblings(scratch),
`${tree} is scanned but ${file} went unseen`
).toEqual([file])
})
}
})
it('skips node_modules, which vendors thousands of unrelated .web.js files', async () => {
await withScratch(async (scratch) => {
await plant(scratch, 'packages/x/node_modules/dep/index.web.js')
expect(await findWebSiblings(scratch)).toEqual([])
})
})
})
-6
View File
@@ -5,15 +5,9 @@ import path from 'node:path'
import { describe, expect, it } from 'vitest'
const projectDir = path.resolve(import.meta.dirname, '../..')
const packageJson = JSON.parse(readFileSync(path.join(projectDir, 'package.json'), 'utf8'))
const wrapperPath = path.join(projectDir, 'config', 'scripts', 'orca-dev.mjs')
describe('orca-dev package bin', () => {
it('uses a Node entrypoint for cross-platform package installs', () => {
expect(packageJson.bin['orca-dev']).toBe('./config/scripts/orca-dev.mjs')
expect(readFileSync(wrapperPath, 'utf8')).toMatch(/^#!\/usr\/bin\/env node\n/)
})
it('runs the dev CLI through Node without requiring Bash', () => {
const root = mkdtempSync(path.join(tmpdir(), 'orca-dev-bin-'))
const cliEntry = path.join(root, 'cli-entry.cjs')
+1 -287
View File
@@ -1,57 +1,27 @@
import { DEDICATED_E2E_SPECS } from './ci-e2e-job-selection.mjs'
import { linuxInstallPackageList } from './pr-e2e-linux-packages.test-fixture.mjs'
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { existsSync, readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { parse as parseJsonc } from 'jsonc-parser'
import { describe, expect, it } from 'vitest'
import { parse as parseYaml } from 'yaml'
import {
hasNativeImeSourceChange,
hasSshSourceChange,
NATIVE_IME_SOURCE_ROUTE_IDS,
PR_E2E_SOURCE_ROUTES,
selectPrE2eSpecs,
SSH_SOURCE_ROUTE_IDS
} from './pr-e2e-source-routing.mjs'
import {
EXPECTED_NATIVE_IME_TESTS,
IME_ENGAGEMENT_RECEIPT_ENV
} from './terminal-ime-engagement-receipt.mjs'
const projectDir = resolve(import.meta.dirname, '../..')
const prWorkflow = parseYaml(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
const e2eWorkflow = parseYaml(readFileSync(join(projectDir, '.github/workflows/e2e.yml'), 'utf8'))
const reliabilityManifest = parseJsonc(
readFileSync(join(projectDir, 'config/reliability-gates.jsonc'), 'utf8')
)
const playwrightConfig = readFileSync(join(projectDir, 'tests/playwright.config.ts'), 'utf8')
const sshDockerRunner = readFileSync(
join(projectDir, 'config/scripts/run-ssh-docker-terminal-parking-e2e.mjs'),
'utf8'
)
const nativeImeWorkflow = parseYaml(
readFileSync(join(projectDir, '.github/workflows/terminal-ime-e2e.yml'), 'utf8')
)
const nativeImeRunner = readFileSync(
join(projectDir, 'config/scripts/run-terminal-ibus-hangul-e2e.mjs'),
'utf8'
)
const nativeImeSpec = readFileSync(
join(projectDir, 'tests/e2e/terminal-ibus-hangul-native.spec.ts'),
'utf8'
)
const filterStep = prWorkflow.jobs.code_paths.steps.find(
(step) => step.name === 'Filter changed E2E specs'
)
const rollbackStep = prWorkflow.jobs.preflight.steps.find(
(step) => step.name === 'Check VM runtime rollback compatibility'
)
const verifyStep = prWorkflow.jobs.verify.steps.find(
(step) => step.name === 'Require successful checks'
)
/** The route that sends a change to the two-Electron restart-survival spec. */
const restartSurvivalRoute = PR_E2E_SOURCE_ROUTES.find(
(route) => route.id === 'client-hosted-browser.restart-survival'
@@ -94,53 +64,6 @@ describe('restart-survival E2E routing', () => {
})
describe('PR E2E gate contract', () => {
it('keeps E2E advisory while the suite is red on main', () => {
// Why: pin the deliberate choice so it reads as intentional rather than as
// the "forgot to wire the gate" bug this file originally caught. Gating on a
// suite that fails every scheduled run would block the PRs that fix it.
// Flipping to blocking means updating this expectation too — see the comment
// on verify's Require-successful-checks step for the exact wiring.
expect(prWorkflow.jobs.verify.needs).not.toContain('e2e')
expect(verifyStep.env.E2E).toBeUndefined()
expect(verifyStep.run).not.toContain('$E2E')
})
it('passes only changed specs to the reusable E2E workflow', () => {
// Why: without this the job could lose its filter and run on every PR — the
// cost the path filter exists to avoid — while the gate assertions above
// stay green.
expect(prWorkflow.jobs.e2e.needs).toEqual(['code_paths', 'preflight'])
expect(prWorkflow.jobs.e2e.if).toContain("needs.code_paths.outputs.e2e_should_run == 'true'")
expect(prWorkflow.jobs.code_paths.outputs.e2e_should_run).toBe(
'${{ steps.e2e_filter.outputs.should_run }}'
)
expect(prWorkflow.jobs.code_paths.outputs.test_files).toBe(
'${{ steps.e2e_filter.outputs.test_files }}'
)
expect(prWorkflow.jobs.e2e.with.ref).toBe('${{ github.event.pull_request.head.sha }}')
expect(prWorkflow.jobs.e2e.with.test_files).toBe('${{ needs.code_paths.outputs.test_files }}')
})
it('enforces every job verify depends on', () => {
// Why: derive from verify.needs rather than hardcoding, so adding a required
// job without adding it to the strict loop fails here instead of silently
// leaving that job unenforced. This is what caught GIT_COMPATIBILITY and
// SHELL_CONTRACTS being absent from an earlier hardcoded list.
const successMarker = '# Require success when the PR has code-relevant changes'
const successLoop = verifyStep.run.slice(verifyStep.run.indexOf(successMarker))
expect(successLoop.length).toBeGreaterThan(0)
expect(verifyStep.run).toContain('"$CODE_PATHS" != "success"')
for (const job of prWorkflow.jobs.verify.needs) {
const envVar = job.replaceAll('-', '_').toUpperCase()
expect(verifyStep.env[envVar]).toBe(`\${{ needs.${job}.result }}`)
if (job === 'code_paths' || job === 'preflight') {
continue
}
expect(successLoop).toContain(`"$${envVar}"`)
expect(verifyStep.env[`${envVar}_SHOULD_RUN`]).toBe(`\${{ needs.code_paths.outputs.${job} }}`)
}
})
it('selects modified Playwright specs without running deleted tests', () => {
expect(filterStep.run).toContain('--diff-filter=AMCR')
expect(filterStep.run).toContain('config/scripts/pr-e2e-source-routing.mjs')
@@ -154,99 +77,6 @@ describe('PR E2E gate contract', () => {
).toEqual(['tests/e2e/active-view-restart-restore.spec.ts'])
})
it('uses one runner for changed specs and keeps full runs sharded', () => {
expect(e2eWorkflow.jobs.e2e.if).toBe("inputs.test_files == ''")
expect(e2eWorkflow.jobs['changed-e2e'].if).toBe(
"inputs.test_files != '' && (github.event_name != 'pull_request' || inputs.run_changed_e2e)"
)
expect(e2eWorkflow.jobs['changed-e2e'].strategy).toBeUndefined()
expect(e2eWorkflow.jobs.e2e.strategy.matrix.include).toEqual(
Array.from({ length: 14 }, (_, index) => ({
shard: `${index + 1}/14`,
shard_name: `${index + 1}-of-14`
}))
)
const changedRun = e2eWorkflow.jobs['changed-e2e'].steps.find(
(step) => step.name === 'Run changed E2E specs'
)
expect(changedRun.env.TEST_FILES_JSON).toBe('${{ inputs.test_files }}')
expect(DEDICATED_E2E_SPECS).toContain('tests/e2e/ssh-startup-exec-readiness.spec.ts')
expect(DEDICATED_E2E_SPECS).toContain('tests/e2e/paired-startup-exec-readiness.spec.ts')
expect(DEDICATED_E2E_SPECS).toContain(
'tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts'
)
expect(DEDICATED_E2E_SPECS).toContain('tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts')
expect(changedRun.run).toContain('if [ "${#TEST_FILES[@]}" -eq 0 ]')
expect(changedRun.run).toContain('grep -l \'@headful\' "${TEST_FILES[@]}"')
expect(changedRun.run).toContain('E2E_PROJECT_ARGS+=(--project=electron-headful)')
expect(changedRun.run).toContain(
'pnpm run test:e2e "${TEST_FILES[@]}" --workers=1 "${E2E_PROJECT_ARGS[@]}"'
)
expect(playwrightConfig).toContain('retries: 0')
const steps = e2eWorkflow.jobs.e2e.steps.filter((step) =>
step.run?.includes('tests/e2e/worktree-switch-first-paint.spec.ts')
)
expect(steps).toHaveLength(1)
expect(steps[0].if).toBe("matrix.shard == '1/14'")
expect(steps[0].run).toContain('xvfb-run --auto-servernum')
expect(steps[0].run).toContain('--project=electron-headful --workers=1')
})
it('keeps startup-exec live parity in the isolated SSH lane', () => {
const sshLaneCondition = e2eWorkflow.jobs['ssh-docker-watcher-isolation'].if
expect(sshLaneCondition).toContain("inputs.test_files == ''")
expect(sshLaneCondition).toContain('tests/e2e/ssh-startup-exec-readiness.spec.ts')
expect(sshLaneCondition).toContain('tests/e2e/paired-startup-exec-readiness.spec.ts')
expect(sshDockerRunner).toContain('tests/e2e/ssh-startup-exec-readiness.spec.ts')
expect(sshDockerRunner).toContain('tests/e2e/paired-startup-exec-readiness.spec.ts')
expect(sshDockerRunner).toContain("'electron-headless'")
expect(sshDockerRunner).toContain("'electron-headful'")
})
it('reuses the composite install action instead of duplicating pnpm setup', () => {
const installFor = (jobName) =>
e2eWorkflow.jobs[jobName].steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
expect(installFor('build').with['native-runtime']).toBe('node')
for (const jobName of ['e2e', 'changed-e2e', 'ssh-docker-watcher-isolation']) {
expect(installFor(jobName).with['native-runtime'], jobName).toBe('electron')
}
})
it('installs zsh in every Linux lane that can run paired startup readiness', () => {
for (const jobName of ['e2e', 'changed-e2e', 'ssh-docker-watcher-isolation']) {
const installStep = e2eWorkflow.jobs[jobName].steps.find((step) =>
step.name.startsWith('Install native build')
)
expect(linuxInstallPackageList(installStep, jobName), jobName).toMatch(/(^|\s)zsh(\s|$)/)
}
})
it('keeps dedicated E2E workflows from self-triggering on pull requests', () => {
// Why this still holds for terminal-ime-e2e.yml now that pr.yml runs it: pr.yml reaches it
// through workflow_call, behind the path filter. A pull_request trigger here would run a
// real ibus session on every PR, which is the cost the filter exists to avoid.
const dedicatedWorkflows = [
'golden-e2e-experiment.yml',
'linux-wayland-gpu-sandbox.yml',
'terminal-ime-e2e.yml',
'win-crash-survival-e2e.yml',
'windows-terminal-restart-e2e.yml'
]
for (const file of dedicatedWorkflows) {
const workflow = parseYaml(readFileSync(join(projectDir, '.github/workflows', file), 'utf8'))
expect(workflow.on.pull_request, file).toBeUndefined()
}
})
it('scopes detection to the PR range so base drift cannot false-trigger', () => {
expect(filterStep.run).toMatch(/diff-base\.mjs "\$BASE"[\s\S]*"\$DIFF_BASE" HEAD/)
expect(filterStep.run).toContain('set -euo pipefail')
})
it('maps SSH source edits onto the Docker-backed specs they can break', () => {
// Why: the Docker-SSH specs self-skip without ORCA_E2E_SSH_DOCKER, and the only
// trigger used to be "someone edited a spec" — four pane-restore regressions shipped
@@ -507,37 +337,6 @@ describe('PR E2E gate contract', () => {
)
})
it('puts the real-IME lane on the PR gate behind the IME source filter', () => {
// Why a whole lane and not a spec in changed-e2e: the harness is an ibus-daemon, an xfwm4
// session, and an X11 display; the generic lane has none of them and the spec would skip.
expect(nativeImeWorkflow.on.workflow_call).toBeDefined()
expect(prWorkflow.jobs.terminal_ime_native.uses).toBe(
'./.github/workflows/terminal-ime-e2e.yml'
)
expect(prWorkflow.jobs.terminal_ime_native.needs).toBe('code_paths')
expect(prWorkflow.jobs.terminal_ime_native.if).toBe(
"needs.code_paths.outputs.native_ime_source_changed == 'true'"
)
expect(prWorkflow.jobs.code_paths.outputs.native_ime_source_changed).toBe(
'${{ steps.e2e_filter.outputs.native_ime_source_changed }}'
)
expect(filterStep.run).toContain('pr-e2e-source-routing.mjs --native-ime-source')
expect(filterStep.run).toContain('native_ime_source_changed=$NATIVE_IME_SOURCE_CHANGED')
// Why: continue-on-error would report the lane green and hide every failure it exists to
// surface. Advisory here means "absent from verify.needs", not "always passes".
expect(prWorkflow.jobs.terminal_ime_native['continue-on-error']).toBeUndefined()
expect(prWorkflow.jobs.verify.needs).not.toContain('terminal_ime_native')
expect(verifyStep.env.TERMINAL_IME_NATIVE).toBeUndefined()
for (const id of NATIVE_IME_SOURCE_ROUTE_IDS) {
expect(
PR_E2E_SOURCE_ROUTES.map((route) => route.id),
id
).toContain(id)
}
})
it('triggers the real-IME lane from every surface an input method can judge', () => {
for (const file of [
'src/renderer/src/components/terminal-pane/terminal-ime-composition-route.ts',
@@ -574,68 +373,6 @@ describe('PR E2E gate contract', () => {
}
})
it('gives every input-method-gated spec a lane that runs it, or an honest exemption', () => {
// Why this shape: a spec gated on a native-IME env var that no runner sets is a skip that
// reports as a pass. This repo already carries such specs; the point is that they are named
// as gaps rather than counted as coverage.
const nativeGateExpression = /ORCA_E2E_NATIVE_(?:IBUS_HANGUL|MACOS_KOREAN)\s*[!=]==\s*['"]1['"]/
const nativeGatedSpecs = readdirSync(join(projectDir, 'tests/e2e'))
.filter((file) => file.endsWith('.spec.ts'))
.map((file) => `tests/e2e/${file}`)
.filter((spec) => nativeGateExpression.test(readFileSync(join(projectDir, spec), 'utf8')))
expect(nativeGatedSpecs.length).toBeGreaterThan(0)
// The macOS spec needs a native input source; PR and scheduled IME lanes use Linux.
const unreachableSpecs = new Set(['tests/e2e/terminal-macos-2set-korean-native.spec.ts'])
const unclaimed = nativeGatedSpecs.filter(
(spec) => !unreachableSpecs.has(spec) && !nativeImeRunner.includes(spec)
)
expect(
unclaimed,
`Native-IME-gated specs claimed by no lane runner: ${unclaimed.join(', ')}`
).toEqual([])
for (const spec of unreachableSpecs) {
expect(nativeGatedSpecs, spec).toContain(spec)
expect(nativeImeRunner.includes(spec), `${spec} is exempt but still invoked`).toBe(false)
}
})
it('requires proof an input method engaged before the lane may report success', () => {
// Why this is the assertion that matters: every other check in this file protects a job from
// not running. This one protects a job that ran from having exercised nothing.
expect(nativeImeRunner).toContain('verifyImeEngagementReceipts')
expect(nativeImeRunner).toContain(`[IME_ENGAGEMENT_RECEIPT_ENV]: receiptPath`)
expect(nativeImeSpec).toContain('appendImeEngagementReceipt(testInfo.title, trace)')
// Why: the synthetic CDP step runs first in the same job. Under the default success()
// condition its failure skipped the real-IME step, so the half that needs an input method
// reported nothing on exactly the changes that broke IME code.
const nativeStep = nativeImeWorkflow.jobs['linux-x11'].steps.find(
(step) => step.name === 'Run native IBus Hangul exact-byte tests'
)
expect(nativeStep.if).toBe('!cancelled()')
// Why a literal comparison: the spec cannot import the .mjs module, so the env var name is
// written twice and would otherwise drift into a receipt nobody reads.
const specSideReceipt = readFileSync(
join(projectDir, 'tests/e2e/terminal-ime-engagement-receipt.ts'),
'utf8'
)
expect(specSideReceipt).toContain(`'${IME_ENGAGEMENT_RECEIPT_ENV}'`)
// Why pin the titles: the runner requires one receipt per name, so a rename that nobody
// mirrored here would fail the lane loudly instead of quietly halving it.
const nativeDigitSpec = readFileSync(
join(projectDir, 'tests/e2e/terminal-hangul-terminating-digit-native.spec.ts'),
'utf8'
)
expect(nativeDigitSpec).toContain('appendImeEngagementReceipt(testInfo.title, trace)')
for (const title of EXPECTED_NATIVE_IME_TESTS) {
expect(nativeImeSpec + nativeDigitSpec, title).toContain(title)
}
})
it('keeps the native IME spec out of the lane that would silently skip it', () => {
expect(DEDICATED_E2E_SPECS).toContain('tests/e2e/terminal-ibus-hangul-native.spec.ts')
// Why it still has to be routed: the dedicated lane is selected by the same route, so the
@@ -644,27 +381,4 @@ describe('PR E2E gate contract', () => {
'tests/e2e/terminal-ibus-hangul-native.spec.ts'
)
})
it('keeps source-routed sentinels registered to their reliability gates', () => {
const routedGateIds = [
'terminal-startup.quick-command-pre-bind-recovery',
'quick-open.paired-host-path-search',
'terminal-session.host-cold-park-stream-continuity',
'terminal-provider.ssh-remote-reattach-contract',
'terminal-session.remote-pane-layout-retry'
]
for (const gateId of routedGateIds) {
const route = PR_E2E_SOURCE_ROUTES.find((candidate) => candidate.id === gateId)
const gate = reliabilityManifest.gates.find((candidate) => candidate.id === gateId)
expect(route, gateId).toBeDefined()
expect(gate, gateId).toMatchObject({ maturity: 'experimental', protection: 'partial' })
for (const spec of route.specs) {
expect(gate.testFiles, gateId).toContain(spec)
expect(
gate.commands.some((command) => command.includes(spec)),
gateId
).toBe(true)
}
}
})
})
+1 -575
View File
@@ -1,56 +1,10 @@
import { existsSync, globSync, readFileSync } from 'node:fs'
import { readFileSync } from 'node:fs'
import { runInNewContext } from 'node:vm'
import { parse } from 'yaml'
import { describe, expect, it } from 'vitest'
import { UNIT_EXCLUDE } from './ci-unit-files.mjs'
import { MOBILE_WEB_APP_DEPENDENCIES_REQUIRED_ENV } from './mobile-web-app-bundle-dependencies.mjs'
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
const prTestLocWorkflow = parse(readFileSync('.github/workflows/pr-test-loc.yml', 'utf8'))
const releasePolicyWorkflow = parse(readFileSync('.github/workflows/release-policy.yml', 'utf8'))
const issueLabelWorkflow = parse(readFileSync('.github/workflows/issue-os-labeler.yaml', 'utf8'))
const unitTestWorkflow = parse(readFileSync('.github/workflows/unit-tests.yml', 'utf8'))
const nodeNextWorkflow = parse(readFileSync('.github/workflows/node-next-compat.yml', 'utf8'))
const dependencyAction = parse(
readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8')
)
const nativeAction = parse(
readFileSync('.github/actions/prepare-native-runtime/action.yml', 'utf8')
)
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'))
const pnpmWorkspace = parse(readFileSync('pnpm-workspace.yaml', 'utf8'))
const shellContractFiles = [
'src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts',
'src/main/daemon/shell-ready.test.ts',
'src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts',
'src/main/providers/__tests__/shell-ready-framework-example.test.ts',
'src/main/pty/omp-shell-wrapper-alias-safety.test.ts',
'src/main/pty/omp-shell-wrapper.node-pty.test.ts',
'src/main/shell-startup-feature-channel.test.ts',
'src/main/zsh-deferred-startup-line-init.live-shell.test.ts',
'src/main/zsh-scoped-histfile.live-shell.test.ts',
'src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts',
'src/main/zsh-wrapper-version-mismatch.live-shell.test.ts',
'src/main/runtime/structured-session-cli-login-shell.live-shell.test.ts',
'src/shared/posix-command-path-lookup.test.ts'
]
const patchedNodePtyContractFiles = [
'src/main/daemon/node-pty-fd-leak.test.ts',
'src/shared/fish-query-reply-child-stdin.node-pty.test.ts'
]
const nativeShellContractFiles = [...shellContractFiles, ...patchedNodePtyContractFiles]
const testFilePatterns = [
'config/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}',
'src/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}',
'tests/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}',
'tests/tools/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}'
]
// Why the harness import counts: the zsh startup hook runs from a `precmd`, so
// its tests drive a real zsh through a PTY in zsh-startup-hook-pty-harness
// rather than calling spawnSync('zsh') themselves. Without this branch the rule
// silently stops noticing the very tests that need the lane's zsh install.
const realZshUsage =
/(?:spawnSync|execFileSync|spawn)\(\s*['"](?:\/(?:usr\/)?bin\/)?zsh['"]|program:\s*['"](?:\/(?:usr\/)?bin\/)?zsh['"]|spawnSync\(\s*['"]which['"]\s*,\s*\[\s*['"]zsh['"]|name:\s*['"]zsh['"]\s*,\s*path:\s*executablePath|from '[^']*zsh-startup-hook-pty-harness'/
describe('PR workflow parallelism', () => {
it('enforces process-host boundaries in the required static-analysis checks', () => {
@@ -61,489 +15,6 @@ describe('PR workflow parallelism', () => {
expect(guard?.['continue-on-error']).toBeUndefined()
})
it('keeps lightweight orchestration jobs on the free slim runner', () => {
expect(workflow.jobs.code_paths['runs-on']).toBe('ubuntu-slim')
expect(workflow.jobs.preflight['runs-on']).toBe('ubuntu-24.04-arm')
expect(workflow.jobs.verify['runs-on']).toBe('ubuntu-slim')
expect(prTestLocWorkflow.jobs.loc['runs-on']).toBe('ubuntu-slim')
expect(releasePolicyWorkflow.jobs.enforce['runs-on']).toBe('ubuntu-slim')
expect(issueLabelWorkflow.jobs['apply-os-label']['runs-on']).toBe('ubuntu-slim')
})
it('cancels superseded runs for the same pull request', () => {
expect(workflow.concurrency.group).toBe('pr-checks-${{ github.event.pull_request.number }}')
expect(workflow.concurrency['cancel-in-progress']).toBe(true)
expect(workflow.jobs.test.if).toContain('!cancelled()')
expect(workflow.jobs.test.if).not.toContain('always()')
expect(workflow.jobs.verify.if).toBe('${{ !cancelled() }}')
})
it('grants the PR workflow read-only repository access', () => {
expect(workflow.permissions).toEqual({ contents: 'read' })
})
it('runs PR shards on ARM and both Node versions on x86 daily', () => {
const sharedTest = unitTestWorkflow.jobs.test
const testStep = sharedTest.steps.find((step) => step.name === 'Test shard')
const installStep = sharedTest.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
const staticInstall = workflow.jobs.preflight.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
const nodeNextPrimerInstall = nodeNextWorkflow.jobs.test_native_cache.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
expect(workflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml')
expect(JSON.parse(workflow.jobs.test.with.node_versions)).toEqual(['24'])
expect(nodeNextWorkflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml')
expect(JSON.parse(nodeNextWorkflow.jobs.test.with.node_versions)).toEqual(['24', '26'])
expect(workflow.jobs.test.with.runner).toBe('ubuntu-24.04-arm')
expect(workflow.jobs.preflight['runs-on']).toBe('ubuntu-24.04-arm')
expect(sharedTest['runs-on']).toBe('${{ inputs.runner }}')
expect(unitTestWorkflow.on.workflow_call.inputs.runner.default).toBe('ubuntu-latest')
expect(nodeNextWorkflow.jobs.test.with.runner).toBeUndefined()
expect(nodeNextWorkflow.jobs.test_native_cache['runs-on']).toBe('ubuntu-latest')
expect(nodeNextWorkflow.jobs.test_native_cache.strategy.matrix.node).toEqual(['24', '26'])
const relay = unitTestWorkflow.jobs.relay_integration
expect(relay.strategy.matrix.node).toBe('${{ fromJSON(inputs.node_versions) }}')
expect(relay['runs-on']).toBe('ubuntu-latest')
expect(
relay.steps.find((step) => step.uses === './.github/actions/install-node-dependencies').with[
'node-version'
]
).toBe('${{ matrix.node }}')
expect(nodeNextWorkflow.on.schedule).toHaveLength(1)
expect(nodeNextWorkflow.on.workflow_dispatch).toBeNull()
expect(sharedTest.strategy.matrix.node).toBe('${{ fromJSON(inputs.node_versions) }}')
expect(sharedTest.strategy.matrix.shard).toBe('${{ fromJSON(inputs.shards) }}')
// PR planning overlaps typecheck; the daily suite keeps its separate planner.
expect(sharedTest.needs).toBeUndefined()
expect(unitTestWorkflow.jobs.plan).toBeUndefined()
expect(unitTestWorkflow.on.workflow_call.inputs.shards.required).toBe(true)
expect(installStep.with['node-version']).toBe('${{ matrix.node }}')
expect(installStep.with['cache-electron-package']).toBe('true')
expect(testStep.run).toContain('--shard=${{ matrix.shard.index }}/${{ matrix.shard.count }}')
for (const testFile of nativeShellContractFiles) {
expect(UNIT_EXCLUDE).toContain(testFile)
}
expect(staticInstall.with['native-runtime']).toBe('node')
expect(staticInstall.with['node-version']).toBe('24')
expect(workflow.jobs.test.needs).toContain('preflight')
expect(workflow.jobs.test_native_cache).toBeUndefined()
expect(nodeNextPrimerInstall.with['native-runtime']).toBe('node')
expect(nodeNextPrimerInstall.with['node-version']).toBe('${{ matrix.node }}')
expect(nodeNextWorkflow.jobs.test.needs).toEqual(['test_native_cache', 'unit_plan'])
})
it('runs real-shell coverage once outside the general shards', () => {
const shellStep = workflow.jobs.shell_contracts.steps.find(
(step) => step.name === 'Test real shell contracts'
)
const shellInstall = workflow.jobs.shell_contracts.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
// Why parsed rather than substring-matched: the step name changes as shells are
// added, and `includes('fish')` would also match a comment or a longer package.
const aptPackages = (step) =>
(step.run?.match(/apt-get install[^\n]*/)?.[0] ?? '')
.split(/\s+/)
.filter((token) => !['apt-get', 'install', 'sudo', ''].includes(token))
.filter((token) => !token.startsWith('-'))
const requiredShells = ['zsh', 'fish']
const jobsInstallingShells = Object.entries(workflow.jobs)
.filter(([, job]) =>
(job.steps ?? []).some((step) =>
aptPackages(step).some((packageName) => requiredShells.includes(packageName))
)
)
.map(([name]) => name)
expect(shellStep).toBeDefined()
expect(shellInstall).toBeDefined()
expect(shellStep.run.split(/\s+/)).toContain('--maxWorkers=1')
// Why the whole workflow, not just the general shards: any other lane installing
// these shells would silently start running the real-shell tests twice.
expect(jobsInstallingShells).toEqual(['shell_contracts'])
// Why each shell is asserted: the live tests skip themselves when the binary is
// missing, so a dropped package silently empties this lane instead of failing it.
const shellPackages = workflow.jobs.shell_contracts.steps.flatMap(aptPackages)
for (const shell of requiredShells) {
expect(shellPackages).toContain(shell)
}
expect(shellInstall.with['native-runtime']).toBe('node')
for (const testFile of nativeShellContractFiles) {
expect(shellStep.run).toContain(testFile)
}
})
it('refreshes the apt index once while adding the fish PPA', () => {
const installStep = workflow.jobs.shell_contracts.steps.find(
(step) => step.name === 'Install zsh and fish'
)
// Comment lines mention both commands by name, so count the executed ones only.
const commands = installStep.run
.split('\n')
.filter((line) => !line.trim().startsWith('#'))
.join('\n')
const updates = commands.match(/apt-get update/g) ?? []
// Anchored to the start of a line so the retry message that names the command in
// prose is not mistaken for an invocation of it.
const addRepoCalls = commands
.split('\n')
.map((line) => line.trim())
.filter((line) => /^(sudo\s+)?add-apt-repository\b/.test(line))
// add-apt-repository refreshes every configured repo unless told not to, so an
// update on each side of it made this step pay for three full passes.
expect(updates).toHaveLength(1)
expect(addRepoCalls.length).toBeGreaterThan(0)
for (const call of addRepoCalls) {
expect(call.split(/\s+/)).toContain('-n')
}
// The one remaining update has to come after the PPA is on the list, or the fish
// index it exists to fetch would not be there yet.
expect(commands.lastIndexOf('add-apt-repository')).toBeLessThan(
commands.indexOf('apt-get update')
)
})
it('bounds the shell lane so a stalled apt mirror cannot hold the run open', () => {
const job = workflow.jobs.shell_contracts
// A passing run of this job takes ~4.5 minutes, almost all of it package download.
// Without a job bound a stalled mirror runs to GitHub's 6h default, and because this
// is a required check it holds the whole run open and blocks `gh run rerun --failed`.
expect(job['timeout-minutes']).toBeGreaterThan(0)
expect(job['timeout-minutes']).toBeLessThanOrEqual(30)
const installStep = job.steps.find((step) => step.name === 'Install zsh and fish')
// apt applies no wall-clock bound to a stalled mirror on its own. These turn an
// unbounded hang into a bounded, retried, legible failure.
expect(installStep.run).toMatch(/Acquire::http::Timeout/)
expect(installStep.run).toMatch(/Acquire::https::Timeout/)
expect(installStep.run).toMatch(/Acquire::Retries/)
// Retries multiply: a first attempt at 30s x 3 retries across every index file turned
// a dead mirror into a ~15 minute stall. One attempt, then move on.
expect(installStep.run).toMatch(/Acquire::Retries "1"/)
// Acquire timeouts are per-connection, so they cannot bound the command as a whole.
// Only a wall-clock bound can, and both apt invocations need one.
expect(installStep.run).toMatch(/timeout \d+ sudo apt-get update/)
expect(installStep.run).toMatch(/timeout \d+ sudo apt-get install/)
})
it('bounds package tooling refresh and keeps required installation fatal', () => {
const steps = workflow.jobs.package.steps
const installStep = steps.find((step) => step.id === 'linux-package-tools')
const commands = installStep.run
.split('\n')
.filter((line) => !line.trim().startsWith('#'))
.join('\n')
expect(installStep.background).toBe(true)
expect(commands).toContain('Acquire::http::Timeout "15";')
expect(commands).toContain('Acquire::https::Timeout "15";')
expect(commands).toContain('Acquire::Retries "1";')
expect(commands).toMatch(/^timeout 120 sudo apt-get update \|\| true$/m)
expect(commands).toMatch(/^timeout 300 sudo apt-get install -y cpio rpm$/m)
expect(commands.trim().split('\n').at(-1)).toBe('timeout 300 sudo apt-get install -y cpio rpm')
expect(steps.find((step) => step.wait?.includes('linux-package-tools')).wait).toEqual([
'linux-package-tools',
'web-client'
])
})
it('keeps every real-zsh test in the dedicated shell lane', () => {
const discoveredFiles = globSync(testFilePatterns)
// Why this file is excluded: it carries the detector pattern as a literal
// and would otherwise match itself.
.filter((testFile) => testFile !== 'config/scripts/pr-workflow-parallelism.test.mjs')
// TypeScript builds can leave an ignored JavaScript companion beside a source
// test. Inspect the source file once so generated output cannot duplicate it.
.filter(
(testFile) =>
!testFile.endsWith('.js') ||
!['.ts', '.tsx', '.mjs', '.cjs'].some((extension) =>
existsSync(testFile.replace(/\.js$/, extension))
)
)
.filter((testFile) => realZshUsage.test(readFileSync(testFile, 'utf8')))
.sort()
expect(discoveredFiles).toEqual([...shellContractFiles].sort())
})
it('overlaps bundles with independent output directories', () => {
const buildStep = workflow.jobs.package.steps.find(
(step) => step.name === 'Build package inputs'
)
expect(buildStep.run).toContain('scripts=(build:relay build:electron-vite:parallel)')
expect(buildStep.run).toContain('pnpm run "$script" &')
expect(
workflow.jobs.package.steps.find(
(step) => step.name === 'Project web client from renderer build'
).run
).toBe('pnpm run build:web-from-renderer')
expect(packageJson.scripts['build:desktop']).toContain('pnpm run build:web-from-renderer')
expect(packageJson.scripts['build:release']).toContain('pnpm run build:web-from-renderer')
})
it('smokes managed-hook companions under their supported Node 18 runtime', () => {
const steps = workflow.jobs.managed_hook_node18.steps
const installIndex = steps.findIndex(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
const buildIndex = steps.findIndex((step) => step.run === 'pnpm run build:relay')
const node18Index = steps.findIndex(
(step) => step.uses === 'actions/setup-node@v6' && step.with['node-version'] === '18'
)
const smokeIndex = steps.findIndex(
(step) => step.run === 'node config/scripts/smoke-managed-hook-runtime-node18.mjs'
)
const processHostIndex = steps.findIndex(
(step) => step.run === 'node config/scripts/smoke-process-host-node18.mjs'
)
expect(installIndex).toBeLessThan(buildIndex)
expect(buildIndex).toBeLessThan(node18Index)
expect(node18Index).toBeLessThan(smokeIndex)
expect(node18Index).toBeLessThan(processHostIndex)
})
it('restores the pnpm store before dependency installation', () => {
const steps = dependencyAction.runs.steps
const pnpmIndex = steps.findIndex((step) => step.name === 'Setup pnpm')
const nodeIndex = steps.findIndex((step) => step.name === 'Setup Node.js')
const requestedNodeIndex = steps.findIndex((step) => step.name === 'Setup requested Node.js')
expect(pnpmIndex).toBeLessThan(nodeIndex)
expect(pnpmIndex).toBeLessThan(requestedNodeIndex)
const packageManagerVersion = /^pnpm@([^+]+)/.exec(packageJson.packageManager)?.[1]
expect(packageManagerVersion).toBe('12.8.1')
expect(steps[pnpmIndex].uses).toBe('pnpm/setup@v2')
expect(steps[pnpmIndex].with.version).toBeUndefined()
expect(steps[pnpmIndex].with.install).toBe(false)
const saveOutsidePrs =
"${{ github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && steps.pnpm-store-mode.outputs.lookup-only != 'true' && 'pnpm' || '' }}"
expect(steps[nodeIndex].with.cache).toBe(saveOutsidePrs)
expect(steps[nodeIndex].if).toBe("inputs.node-version == ''")
expect(steps[requestedNodeIndex].if).toBe("inputs.node-version != ''")
expect(steps[requestedNodeIndex].with['node-version']).toBe('${{ inputs.node-version }}')
expect(steps[requestedNodeIndex].with.cache).toBe(saveOutsidePrs)
const restoreIndex = steps.findIndex(
(step) => step.name === 'Restore pnpm download store without saving'
)
expect(restoreIndex).toBeGreaterThan(requestedNodeIndex)
expect(restoreIndex).toBeLessThan(
steps.findIndex((step) => step.name === 'Install dependencies')
)
expect(steps[restoreIndex].uses).toBe('actions/cache/restore@v5')
expect(steps[restoreIndex].if).toBe(
"github.event_name == 'pull_request' && inputs.cache-pnpm-store != 'false' && !((runner.os == 'Linux' || runner.os == 'macOS') && (runner.arch == 'X64' || runner.arch == 'ARM64') && inputs.cache-dependency-path == 'pnpm-lock.yaml') && (runner.os != 'Windows' || !(runner.arch == 'X64' && contains(inputs.cache-dependency-path, 'mobile/pnpm-lock.yaml')) && !((runner.arch == 'X64' || runner.arch == 'ARM64') && inputs.cache-dependency-path == 'pnpm-lock.yaml'))"
)
})
it('uses the repository package-manager version for every direct pnpm setup', () => {
const directSetups = globSync('.github/workflows/*.yml').flatMap((workflowPath) => {
const parsed = parse(readFileSync(workflowPath, 'utf8'))
return Object.values(parsed.jobs ?? {}).flatMap((job) =>
(job.steps ?? [])
.filter((step) => step.uses === 'pnpm/setup@v2')
.map((step) => ({ workflowPath, step }))
)
})
expect(directSetups.length).toBeGreaterThan(0)
for (const { workflowPath, step } of directSetups) {
expect(step.with?.version, workflowPath).toBeUndefined()
expect(step.with?.install, workflowPath).toBe(false)
}
})
it('restores Electron downloads before preparing the package runtime', () => {
const steps = workflow.jobs.package.steps
const cacheIndex = steps.findIndex((step) => step.name === 'Cache electron-builder downloads')
const installIndex = steps.findIndex(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
expect(cacheIndex).toBeGreaterThanOrEqual(0)
expect(installIndex).toBeGreaterThanOrEqual(0)
expect(cacheIndex).toBeLessThan(installIndex)
})
it('prepares each native runtime before its consumers start', () => {
const installFor = (jobName) =>
workflow.jobs[jobName].steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
const sharedTestInstall = unitTestWorkflow.jobs.test.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
for (const jobName of ['git_compatibility']) {
expect(installFor(jobName).with, jobName).toBeUndefined()
}
expect(installFor('xterm_patch_sync')).toBeUndefined()
expect(installFor('preflight').with['native-runtime']).toBe('node')
expect(installFor('shell_contracts').with['native-runtime']).toBe('node')
expect(sharedTestInstall.with['native-runtime']).toBe('node')
expect(installFor('package').with['native-runtime']).toBe('electron')
expect(installFor('package_windows').with['native-runtime']).toBe('node')
expect(installFor('package_windows').with['persist-native-cache']).toBe('false')
expect(
workflow.jobs.package_windows.steps.find(
(step) => step.name === 'Save compiled Node native modules'
).if
).toBe("steps.deps.outputs.native-cache-hit != 'true'")
expect(dependencyAction.inputs['persist-native-cache'].default).toBe('true')
expect(nativeAction.runs.steps.find((step) => step.name === 'Use external node-gyp').if).toBe(
"runner.os == 'Linux' && inputs.native-runtime == 'node'"
)
const dependencyInstall = dependencyAction.runs.steps.find(
(step) => step.name === 'Install dependencies'
)
// Why frozen: re-resolving the graph costs a minute per job and the `git diff`
// guard below already fails the run when the lockfile is stale, so the slow
// resolution can never legitimately change anything.
expect(dependencyInstall.run).toContain('--frozen-lockfile')
expect(dependencyInstall.run).not.toContain('--no-frozen-lockfile')
expect(dependencyInstall.run).toContain(
'git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml pnpm-workspace.yaml'
)
expect(dependencyInstall.run).toContain('--ignore-scripts')
expect(dependencyInstall.run).not.toContain('--os=')
expect(dependencyInstall.run).not.toContain('--cpu=')
expect(pnpmWorkspace.supportedArchitectures.os).toEqual(['current'])
expect(pnpmWorkspace.supportedArchitectures.cpu).toEqual(['current'])
const prepareRuntime = dependencyAction.runs.steps.find(
(step) => step.name === 'Prepare native runtime'
)
expect(prepareRuntime.if).toBe("inputs.native-runtime != 'none'")
expect(prepareRuntime.uses).toBe('./.github/actions/prepare-native-runtime')
expect(prepareRuntime.with).toEqual({
'native-runtime': '${{ inputs.native-runtime }}',
'node-version':
'${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }}',
'persist-native-cache': '${{ inputs.persist-native-cache }}'
})
})
it('reuses native preparation after the dependency action gate', () => {
const buildStep = workflow.jobs.package.steps.find(
(step) => step.name === 'Build package inputs'
)
const packageStep = workflow.jobs.package.steps.find(
(step) => step.name === 'Package unpacked app'
)
expect(buildStep.run).not.toContain('ensure:electron-runtime')
expect(packageStep.env.ORCA_REUSE_PREPARED_NATIVE_RUNTIME).toBe('1')
})
it('restores compiled native modules after the install that strips them', () => {
const installerSteps = dependencyAction.runs.steps
const installIndex = installerSteps.findIndex((step) => step.name === 'Install dependencies')
const nativeIndex = installerSteps.findIndex((step) => step.id === 'native-runtime')
expect(installIndex).toBeLessThan(nativeIndex)
const steps = nativeAction.runs.steps
const cacheIndex = steps.findIndex((step) => step.name === 'Restore compiled native modules')
const prepareIndex = steps.findIndex((step) => step.name === 'Prepare native runtime')
// `--ignore-scripts` leaves no build/, so a restore before the install would be
// overwritten and one after the rebuild would never save a hit.
expect(cacheIndex).toBeLessThan(prepareIndex)
expect(steps[cacheIndex].if).toBe(
"inputs.native-runtime != 'none' && inputs.persist-native-cache != 'false'"
)
const restoreOnly = steps.find(
(step) => step.name === 'Restore compiled native modules without saving'
)
expect(restoreOnly.if).toBe(
"inputs.native-runtime != 'none' && inputs.persist-native-cache == 'false'"
)
expect(restoreOnly.uses).toBe('actions/cache/restore@v5')
// Native artifacts are ABI-bound: a key missing either dimension serves a build
// that cannot load, and ensure-native-runtime would recompile it anyway.
for (const cacheStep of [steps[cacheIndex], restoreOnly]) {
expect(cacheStep.with.key).toBe('${{ steps.native-cache-scope.outputs.key }}')
expect(cacheStep.with.path).not.toContain('${{')
expect(cacheStep.with.path).toContain('node-pty@*/node_modules/node-pty/build')
expect(cacheStep.with.path).toContain('native/windows-registry/build')
expect(cacheStep.with.path).toContain('@vscode+windows-process-tre*')
expect(cacheStep.with['restore-keys']).toBeUndefined()
}
expect(steps[cacheIndex].id).toBe('native-cache-restore')
expect(restoreOnly.id).toBe('native-cache-restore-only')
const cacheScope = steps.find((step) => step.name === 'Resolve native cache scope')
expect(cacheScope.if).toBe("inputs.native-runtime != 'none'")
expect(cacheScope.run).toContain('/etc/os-release')
expect(nativeAction.outputs['cache-scope'].value).toBe(
'${{ steps.native-cache-scope.outputs.scope }}'
)
expect(nativeAction.outputs['cache-hit'].value).toContain(
'steps.native-cache-restore.outputs.cache-hit'
)
expect(nativeAction.outputs['cache-hit'].value).toContain(
'steps.native-cache-restore-only.outputs.cache-hit'
)
for (const output of ['scope', 'hit', 'key', 'path']) {
expect(dependencyAction.outputs[`native-cache-${output}`].value).toBe(
`\${{ steps.native-runtime.outputs.cache-${output} }}`
)
}
const electronCache = installerSteps.find(
(step) => step.name === 'Cache Electron package archive'
)
const electronCacheResolution = installerSteps.find(
(step) => step.name === 'Resolve Electron package cache'
)
expect(electronCacheResolution.if).toBe(
"inputs.native-runtime == 'electron' || inputs.cache-electron-package == 'true'"
)
expect(electronCache.if).toBe(
"(github.event_name != 'pull_request' || runner.os != 'Linux') && steps.electron-package-cache.outputs.version != ''"
)
expect(electronCache.uses).toBe('actions/cache@v5')
expect(electronCache.with.key).toContain('steps.electron-package-cache.outputs.version')
expect(dependencyAction.inputs['cache-electron-package'].default).toBe('false')
})
it('reuses TypeScript incremental state across typecheck runs', () => {
const steps = workflow.jobs.preflight.steps
const cacheIndex = steps.findIndex((step) => step.name === 'Cache TypeScript incremental state')
const checkIndex = steps.findIndex((step) => step.run === 'pnpm run typecheck')
expect(cacheIndex).toBeGreaterThanOrEqual(0)
expect(cacheIndex).toBeLessThan(checkIndex)
expect(steps[cacheIndex].with.path).toBe('config/*.tsbuildinfo')
// Why restore-keys matter here: the base SHA key is shared by every commit in a PR,
// but actions/cache keeps the first successful graph until the base or config changes.
expect(steps[cacheIndex].with['restore-keys']).toBeTruthy()
// The buildinfo is only reusable while the compiler options that produced it hold.
expect(steps[cacheIndex].with.key).toContain(
"hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json')"
)
expect(steps[cacheIndex].with.key).toContain('github.event.pull_request.base.sha')
expect(steps[cacheIndex].with['restore-keys']).not.toContain('tsbuildinfo-${{ runner.os }}-\n')
})
it('checks out full history without historical blobs', () => {
const fullHistoryCheckouts = Object.values(workflow.jobs)
.flatMap((job) => job.steps ?? [])
.filter(
(step) => step.uses?.startsWith('actions/checkout@') && step.with?.['fetch-depth'] === 0
)
expect(fullHistoryCheckouts.length).toBeGreaterThan(0)
for (const checkout of fullHistoryCheckouts) {
expect(checkout.with.filter).toBe('blob:none')
}
})
it('keeps advisory unit-selection evidence off the gate', () => {
// It is continue-on-error, so it can never fail a PR. Living inside unit-tests.yml made a
// caller's `needs: test` wait for it anyway, holding verify ~36s past the last shard. Pinned
@@ -574,49 +45,4 @@ describe('PR workflow parallelism', () => {
const job = evidenceWorkflow.jobs.selection_evidence
expect(job['continue-on-error']).toBe(true)
})
it('keeps verify as the aggregate required check', () => {
expect(workflow.jobs.verify.needs).toEqual([
'code_paths',
'preflight',
'git_compatibility',
'codex_index_heal_contract',
'xterm_patch_sync',
'shell_contracts',
'test',
'orcad_browser',
'mobile_web_app',
'cross-version-wire',
'managed_hook_node18',
'package',
'package_windows'
])
const verifyStep = workflow.jobs.verify.steps.find(
(step) => step.name === 'Require successful checks'
)
expect(verifyStep.env.MANAGED_HOOK_NODE18).toBe('${{ needs.managed_hook_node18.result }}')
expect(verifyStep.run).toContain('"$MANAGED_HOOK_NODE18"')
// Why assert this one too: the browser provider test skips itself without
// ORCA_BROWSER_EXECUTABLE, so it only guards anything if verify actually reads it.
expect(verifyStep.env.ORCAD_BROWSER).toBe('${{ needs.orcad_browser.result }}')
expect(verifyStep.run).toContain('"$ORCAD_BROWSER"')
expect(verifyStep.env.CROSS_VERSION_WIRE).toBe('${{ needs.cross-version-wire.result }}')
expect(verifyStep.run).toContain('"$CROSS_VERSION_WIRE"')
// Same reason as the browser provider: the render check fails loudly on a runner with no
// Chrome, which only guards the page if verify reads the job's result.
expect(verifyStep.env.MOBILE_WEB_APP).toBe('${{ needs.mobile_web_app.result }}')
expect(verifyStep.run).toContain('"$MOBILE_WEB_APP"')
})
it('makes the mobile_web_app job refuse to skip the tests it exists to run', () => {
// The bundling tests skip themselves without mobile/node_modules, which is what keeps the
// sharded `test` job green. Only this env var stops that skip from spreading to the one job
// that installs them, so a typo here would leave the whole job passing vacuously.
const step = workflow.jobs.mobile_web_app.steps.find(
(entry) => entry.name === 'Builder, override census and render checks'
)
expect(step.run).toContain('node config/scripts/run-mobile-web-app-checks.mjs')
expect(step.run).not.toContain('--prepare-route-snapshot')
expect(step.env[MOBILE_WEB_APP_DEPENDENCIES_REQUIRED_ENV]).toBe('1')
})
})
@@ -1,23 +1,11 @@
// Census: every release-triggered workflow and every script that lists this repo's releases
// classifies tags through release-tag-patterns.mjs, so a new tag family such as the agent state
// rules cannot reach a desktop-only path through one that forgot it.
import { readdirSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { parse } from 'yaml'
import {
DESKTOP_RC_TAG,
DESKTOP_STABLE_TAG,
DESKTOP_STABLE_TAG_SHELL_PATTERN,
agentStateRulesTag,
isAgentStateRulesTag
} from './release-tag-patterns.mjs'
const WORKFLOWS_DIR = '.github/workflows'
const SCRIPT_DIRS = ['config/scripts', '.github/scripts']
const SHARED_IMPORT = /from '[^']*release-tag-patterns\.mjs'/
const LISTS_RELEASES = /\/releases\?|listReleases|releases\.atom|'release',\s*'list'/
const RULES_TAGS = [agentStateRulesTag(1, 'next'), agentStateRulesTag(1, 'stable')]
const rulesRelease = (tag, extra = {}) => ({
tag_name: tag,
@@ -29,51 +17,7 @@ const rulesRelease = (tag, extra = {}) => ({
...extra
})
function read(path) {
return readFileSync(path, 'utf8')
}
function importsSharedPatterns(path) {
return SHARED_IMPORT.test(read(path))
}
function isReleaseTriggered(workflow) {
const on = workflow.on
if (typeof on === 'string') {
return on === 'release'
}
if (Array.isArray(on)) {
return on.includes('release')
}
return typeof on === 'object' && on !== null && 'release' in on
}
function releaseTriggeredWorkflows() {
return readdirSync(WORKFLOWS_DIR)
.filter((name) => /\.ya?ml$/.test(name))
.map((name) => join(WORKFLOWS_DIR, name))
.filter((path) => isReleaseTriggered(parse(read(path))))
}
function scriptsNamedIn(text) {
return [...text.matchAll(/((?:config|\.github)\/scripts\/[\w.-]+\.mjs)/g)].map(
(match) => match[1]
)
}
function releaseListingScripts() {
return SCRIPT_DIRS.flatMap((dir) =>
readdirSync(dir)
.filter((name) => name.endsWith('.mjs') && !name.includes('.test.'))
.map((name) => join(dir, name))
).filter((path) => LISTS_RELEASES.test(read(path)))
}
/**
* Scripts that list releases yet admit only desktop tags by their own shape. Each entry proves,
* by calling the script, that a rules release never passes; a new listing script must import the
* shared patterns or add a proof here.
*/
// Exercise the desktop consumers against rules releases.
const DESKTOP_ONLY_PROOFS = {
'config/scripts/create-draft-release.mjs': async () => {
const { latestPreviousPublishedDesktopReleaseTag } = await import('./create-draft-release.mjs')
@@ -123,43 +67,9 @@ afterEach(() => {
})
describe('release tag pattern census', () => {
it('finds the release-triggered workflows and release-listing scripts it guards', () => {
expect(releaseTriggeredWorkflows().length).toBeGreaterThan(0)
expect(releaseListingScripts().length).toBeGreaterThan(0)
})
it.each(releaseTriggeredWorkflows())('%s classifies tags through the shared patterns', (path) => {
const text = read(path)
const viaScript = scriptsNamedIn(text).some(importsSharedPatterns)
const viaShellPattern = text.includes(DESKTOP_STABLE_TAG_SHELL_PATTERN)
expect(
viaScript || viaShellPattern,
`${path} names no script importing release-tag-patterns.mjs and embeds no shared pattern`
).toBe(true)
})
it.each(releaseTriggeredWorkflows())(
'%s checks out the shared patterns beside its script',
(path) => {
const workflow = parse(read(path))
const sparse = Object.values(workflow.jobs)
.flatMap((job) => job.steps ?? [])
.map((step) => step.with?.['sparse-checkout'])
.filter((value) => typeof value === 'string')
for (const paths of sparse) {
if (scriptsNamedIn(paths).some(importsSharedPatterns)) {
expect(paths).toContain('config/scripts/release-tag-patterns.mjs')
}
}
}
)
it.each(releaseListingScripts())(
it.each(Object.keys(DESKTOP_ONLY_PROOFS))(
'%s imports the shared patterns or proves it admits only desktop tags',
async (path) => {
if (importsSharedPatterns(path)) {
return
}
const proof = DESKTOP_ONLY_PROOFS[path]
expect(
proof,
@@ -169,11 +79,6 @@ describe('release tag pattern census', () => {
}
)
it('keeps no proof for a script that no longer lists releases', () => {
const listing = new Set(releaseListingScripts())
expect(Object.keys(DESKTOP_ONLY_PROOFS).filter((path) => !listing.has(path))).toEqual([])
})
it('never classifies an agent state rules tag as a desktop release', () => {
for (const tag of RULES_TAGS) {
expect(isAgentStateRulesTag(tag)).toBe(true)
@@ -1,120 +0,0 @@
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
import { NODE_RUNTIME_PIN } from '../../src/shared/node-runtime-pin.ts'
import { HOSTILE_HOST_CELLS } from '../../src/main/ssh/ssh-hostile-host-cells.ts'
const projectDir = resolve(import.meta.dirname, '../..')
const readText = (name) => readFileSync(join(projectDir, '.github/workflows', name), 'utf8')
const workflow = parse(readText('ssh-hostile-hosts.yml'))
function imageRefs(text, pattern) {
return [...new Set(text.match(pattern) ?? [])]
}
describe('SSH hostile-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'])
expect(workflow.on.pull_request.paths).toContain('src/main/ssh/ssh-relay-*')
for (const lane of ['glibc_slot', 'musl_slot', 'glibc217_slot']) {
expect(workflow.jobs[lane].if).toContain('github.event.pull_request.draft != true')
expect(workflow.jobs[lane].needs).toBeUndefined()
expect(
workflow.jobs[lane].steps.some((step) =>
String(step.uses).startsWith('actions/download-artifact')
)
).toBe(false)
}
expect(workflow.jobs.hosts.needs).toEqual(['glibc_slot', 'musl_slot', 'glibc217_slot'])
})
// Why: the slots must come from the same builders the headless-server lanes qualify, so a
// NODE_RUNTIME_PIN or builder move cannot leave this matrix testing a stale runtime.
it('builds the slots on the headless-server lanes’ pinned builder images', () => {
const nodeServer = readText('node-server-tests.yml')
const hostile = readText('ssh-hostile-hosts.yml')
const alpine = /node:[0-9.]+-alpine@sha256:[0-9a-f]{64}/g
const manylinux = /quay\.io\/pypa\/manylinux_2_28_x86_64@sha256:[0-9a-f]{64}/g
expect(imageRefs(hostile, alpine)).toEqual(imageRefs(nodeServer, alpine))
expect(imageRefs(hostile, alpine)).toEqual([
expect.stringMatching(new RegExp(`^node:${NODE_RUNTIME_PIN.version.replaceAll('.', '\\.')}-`))
])
expect(imageRefs(hostile, manylinux)).toEqual(imageRefs(nodeServer, manylinux))
expect(imageRefs(hostile, manylinux)).toHaveLength(1)
const manylinux2014 = /quay\.io\/pypa\/manylinux2014_x86_64@sha256:[0-9a-f]{64}/g
expect(imageRefs(hostile, manylinux2014)).toEqual(imageRefs(nodeServer, manylinux2014))
expect(imageRefs(hostile, manylinux2014)).toHaveLength(1)
})
// Why: the CentOS 7 cell expects rung B, which needs the compat slot in the hosts' template.
it('builds the glibc 2.17 compat slot and hands it to the hosts job', () => {
const compat = workflow.jobs.glibc217_slot.steps.map((step) => step.run ?? '').join('\n')
expect(compat).toContain('--slot=linux-x64-glibc217 --print-runtime')
expect(compat).toContain('--slot=linux-x64-glibc217 --smoke')
const artifactNames = ['glibc_slot', 'musl_slot', 'glibc217_slot'].map(
(lane) =>
workflow.jobs[lane].steps.find((step) =>
String(step.uses).startsWith('actions/upload-artifact')
).with.name
)
expect(artifactNames).toEqual([
'hostile-hosts-glibc-slot',
'hostile-hosts-musl-slot',
'hostile-hosts-glibc217-slot'
])
const download = workflow.jobs.hosts.steps.find((step) =>
String(step.uses).startsWith('actions/download-artifact')
)
expect(download.with.pattern).toBe('hostile-hosts-*-slot')
expect(download.with['merge-multiple']).not.toBe(true)
const merge = workflow.jobs.hosts.steps.find(
(step) => step.name === 'Merge verified Linux slots'
)
expect(merge.run).toContain('node config/scripts/merge-orcad-prebuilds.mjs')
expect(merge.run).toContain('--require-slots linux-x64-glibc,linux-x64-musl,linux-x64-glibc217')
expect(workflow.jobs.hosts.steps.indexOf(merge)).toBeLessThan(
workflow.jobs.hosts.steps.findIndex(
(step) => step.name === 'Build the orcad template and relay'
)
)
})
it('opts the matrix in and runs it against both x64 Linux slots', () => {
const steps = workflow.jobs.hosts.steps
const matrix = steps.find((step) => step.name === 'Run the hostile-host matrix')
expect(matrix.env.ORCA_RUN_SSH_HOSTILE_HOSTS).toBe('1')
expect(matrix.run).toBe('pnpm test:node src/main/ssh/ssh-relay-hostile-hosts.docker.test.ts')
expect(steps.map((step) => step.run ?? '').join('\n')).toContain(
'--targets linux-x64-glibc,linux-x64-musl'
)
})
// Why: each macOS cell expects its runner's own slot, so the runner, template target and cell
// must agree or the cell would test a slot the template never packaged.
it('runs each macOS cell on the runner and template target it expects', () => {
const job = workflow.jobs.macos_hosts
expect(job.if).toContain('github.event.pull_request.draft != true')
expect(job.needs).toBeUndefined()
const runners = { 'darwin-arm64': 'macos-15', 'darwin-x64': 'macos-15-intel' }
const macCells = HOSTILE_HOST_CELLS.filter((cell) => cell.host === 'local-sshd')
expect(
job.strategy.matrix.include.map(({ os, target, cell }) => ({ os, target, cell }))
).toEqual(
macCells.map((cell) => ({
os: runners[cell.expect.target],
target: cell.expect.target,
cell: cell.id
}))
)
const run = job.steps.map((step) => step.run ?? '').join('\n')
expect(run).toContain('--targets ${{ matrix.target }}')
expect(run).toContain('--require-slots ${{ matrix.target }}')
const cellStep = job.steps.find((step) => step.name === 'Run the macOS hostile-host cell')
expect(cellStep.env).toEqual({
ORCA_RUN_SSH_HOSTILE_HOSTS: '1',
ORCA_SSH_HOSTILE_HOST_CELLS: '${{ matrix.cell }}'
})
expect(cellStep.run).toBe('pnpm test:node src/main/ssh/ssh-relay-hostile-hosts.docker.test.ts')
})
})
@@ -43,13 +43,6 @@ describe('verify packaged plugin resources', () => {
}
})
// The tree is hashed by raw bytes, so a CRLF checkout on Windows breaks the
// pinned hash. These two guard the `.gitattributes` eol=lf pin that prevents it.
it('pins the launch tree to LF so Windows checkouts hash identically', async () => {
const attributes = await readFile(join(process.cwd(), '.gitattributes'), 'utf8')
expect(attributes).toContain('/resources/plugins/** text eol=lf')
})
it('rejects a CRLF checkout of the launch tree', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-packaged-plugins-'))
try {
@@ -314,7 +314,13 @@ describe.runIf(process.platform !== 'win32')('POSIX provider supervisor processe
const signalledAt = Date.now()
supervisor.kill('SIGTERM')
const exited = await Promise.race([exit, new Promise((resolve) => setTimeout(resolve, 5_000))])
let exitTimeout: ReturnType<typeof setTimeout> | undefined
const exited = await Promise.race([
exit,
new Promise((resolve) => {
exitTimeout = setTimeout(resolve, 5_000)
})
]).finally(() => clearTimeout(exitTimeout))
expect(exited).toEqual({ code: null, signal: 'SIGTERM' })
expect(Date.now() - signalledAt).toBeGreaterThanOrEqual(graceMs)
File diff suppressed because it is too large Load Diff
@@ -1,203 +0,0 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type {
AgentJournalItemBody,
AgentJournalRenderItem
} from '../../../../shared/agent-session-journal-types'
import { projectStructuredItemsToNativeChat } from '../../../../shared/structured-agent-session-projection'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { NativeChatMessageList } from './NativeChatMessageList'
import {
TRANSCRIPT_LENGTH,
list,
marker,
scrollTranscript,
session,
stubLayout,
windowState
} from './NativeChatMessageList.windowing-test-support'
afterEach(cleanup)
describe('revealing a diff from a turn rollup', () => {
let restoreLayout = (): void => {}
beforeEach(() => {
restoreLayout = stubLayout({ scrollGeometry: true, offsetChain: true })
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (
this: HTMLElement
) {
const row = this.closest<HTMLElement>('[data-index]')
const scroller = this.closest<HTMLElement>('[data-native-chat-scroll]')
return new DOMRect(
0,
row ? row.offsetTop - (scroller?.scrollTop ?? 0) : 0,
100,
this.offsetHeight
)
})
})
afterEach(() => {
restoreLayout()
vi.restoreAllMocks()
})
function journalItem(itemId: string, body: AgentJournalItemBody, sequence: number) {
return { itemId, body, sequence, observedAt: sequence * 1000, revision: 1 }
}
const patch = '@@ -1 +1 @@\n-before\n+after'
const items: AgentJournalRenderItem[] = [
journalItem(
'user',
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Edit it' }] },
1
),
journalItem(
'diff',
{
kind: 'diff',
path: 'src/a.ts',
patch: { head: patch, truncated: false, digest: 'fixture', byteLength: patch.length }
},
2
),
...Array.from({ length: TRANSCRIPT_LENGTH }, (_, index) =>
journalItem(
`tail-${index}`,
{ kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: `marker-${index}` }] },
index + 3
)
)
]
it('lets a rail jump supersede a previously revealed diff', () => {
const withPrompts = [
...items.slice(0, 2),
journalItem(
'user-2',
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Second prompt' }] },
3
),
journalItem(
'user-3',
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Third prompt' }] },
4
),
...items.slice(2)
].map((item, index) => ({ ...item, sequence: index + 1 }))
const scrollTo = vi.fn()
vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(scrollTo)
const transcript = (): React.JSX.Element => (
<NativeChatMessageList
session={session(projectStructuredItemsToNativeChat(withPrompts))}
journalItems={withPrompts}
isWorking={false}
expandSignal={false}
/>
)
const { container, rerender } = render(transcript())
const selectedTop =
screen.getByText('Second prompt').closest<HTMLElement>('[data-index]')?.offsetTop ?? 0
expect(selectedTop).toBeGreaterThan(0)
fireEvent.click(screen.getByRole('button', { name: /1 changed file/ }))
fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ }))
scrollTranscript(container, 6000)
expect(screen.getByText('Edited')).toBeInTheDocument()
scrollTo.mockClear()
fireEvent.click(screen.getByRole('button', { name: 'Second prompt' }))
expect(scrollTo.mock.calls).toEqual([
[{ top: 6000, behavior: 'auto' }],
[{ top: selectedTop, behavior: 'smooth' }]
])
expect(screen.queryByRole('button', { name: /^Edited .*a\.ts(?:\s|$)/ })).toBeNull()
scrollTo.mockClear()
rerender(transcript())
rerender(transcript())
expect(scrollTo).not.toHaveBeenCalled()
scrollTranscript(container, 0)
scrollTo.mockClear()
fireEvent.click(screen.getByRole('button', { name: /1 changed file/ }))
fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ }))
const diffTop = screen.getByText('Edited').closest<HTMLElement>('[data-index]')?.offsetTop ?? 0
expect(Number.isFinite(diffTop)).toBe(true)
expect(scrollTo.mock.calls.filter(([options]) => options?.behavior === 'smooth')).toEqual([
[{ top: diffTop, behavior: 'smooth' }]
])
})
})
// The rail borrows the reveal's pin to reach a row the window has left behind.
// Borrowing the pin means it also has to give it back: the request is what
// outranks a later reveal, and slots is rebuilt every render, so an effect that
// merely watched it would re-scroll forever.
describe('jumping to a message from the rail', () => {
let restoreLayout = (): void => {}
beforeEach(() => {
restoreLayout = stubLayout()
})
afterEach(() => {
restoreLayout()
vi.useRealTimers()
vi.restoreAllMocks()
})
function userMarker(index: number): NativeChatMessage {
return {
id: `message-${index}`,
role: 'user',
blocks: [{ type: 'text', text: `prompt-${index}` }],
timestamp: index + 1,
source: 'transcript'
}
}
const conversation = Array.from({ length: TRANSCRIPT_LENGTH }, (_, index) =>
index % 10 === 0 ? userMarker(index) : marker(index)
)
function jumpToFirstPrompt(): void {
fireEvent.click(screen.getByRole('button', { name: 'prompt-0' }))
act(() => {
vi.advanceTimersByTime(300)
})
}
it('scrolls once for a selection, not again on every later render', () => {
vi.useFakeTimers()
const scrollTo = vi.fn()
vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(scrollTo)
const { container, rerender } = render(list(conversation))
scrollTranscript(container, 6000)
jumpToFirstPrompt()
expect(scrollTo).toHaveBeenCalled()
// A streaming turn re-renders constantly with the same messages. The jump is
// spent; nothing here may drag the reader back to the row they left.
scrollTo.mockClear()
rerender(list(conversation))
rerender(list(conversation))
expect(scrollTo).not.toHaveBeenCalled()
})
it('releases the pin once the jump is spent', () => {
vi.useFakeTimers()
const scrollTo = vi.fn()
vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(scrollTo)
const { container } = render(list(conversation))
scrollTranscript(container, 6000)
jumpToFirstPrompt()
expect(scrollTo).toHaveBeenCalled()
// The request is spent as soon as the scroll is issued, so the row it pinned
// is not held in the window afterwards. A pin still standing here would also
// still outrank a diff reveal, which shares the same slot.
expect(windowState(container).indexes).not.toContain(0)
})
})
@@ -2,363 +2,583 @@
import '@testing-library/jest-dom/vitest'
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { cleanup, fireEvent, render, screen, within, act } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type {
AgentJournalItemBody,
AgentJournalProducerLinkage,
AgentJournalRenderItem
} from '../../../../shared/agent-session-journal-types'
import type { NativeChatSubagentState } from '../../../../shared/native-chat-types'
import type {
NativeChatSubagentState,
NativeChatMessage
} from '../../../../shared/native-chat-types'
import { projectStructuredItemsToNativeChat } from '../../../../shared/structured-agent-session-projection'
import {
foldStructuredAgentSubagentRoster,
NO_STRUCTURED_AGENT_SUBAGENT_ROSTER
} from '../../../../shared/structured-agent-session-subagent-roster'
import { NativeChatMessageList } from './NativeChatMessageList'
import { session, stubLayout } from './native-chat-windowing-test-harness'
afterEach(cleanup)
import {
TRANSCRIPT_LENGTH,
list,
marker,
scrollTranscript,
session as materialRailSession,
stubLayout as materialRailStubLayout,
windowState
} from './NativeChatMessageList.windowing-test-support'
function journalItem(
itemId: string,
body: AgentJournalItemBody,
sequence: number,
linkage: AgentJournalProducerLinkage = {}
): AgentJournalRenderItem {
return { itemId, body, sequence, observedAt: sequence * 1000, revision: 1, ...linkage }
}
{
function journalItem(
itemId: string,
body: AgentJournalItemBody,
sequence: number,
linkage: AgentJournalProducerLinkage = {}
): AgentJournalRenderItem {
return { itemId, body, sequence, observedAt: sequence * 1000, revision: 1, ...linkage }
}
const child: AgentJournalProducerLinkage = { agentId: 'task-1', producerKind: 'agent' }
const patch = '@@ -1 +1 @@\n-before\n+after'
const itemsWith = (state: NativeChatSubagentState, answered: boolean): AgentJournalRenderItem[] => [
journalItem(
'ask',
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Review it' }] },
1
),
journalItem(
'spawn',
{
kind: 'message',
role: 'system',
blocks: [
{
type: 'subagent-group',
groupId: 'group-1',
agents: [{ id: 'task-1', label: 'explore the lane', state }]
}
]
},
2
),
journalItem(
'child-said',
{ kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'The PR is CLEAN.' }] },
3,
child
),
journalItem(
'child-edit',
{
kind: 'diff',
path: 'src/a.ts',
patch: { head: patch, truncated: false, digest: 'fixture', byteLength: patch.length }
},
4,
child
),
...(answered
? [
journalItem(
'answer',
const child: AgentJournalProducerLinkage = { agentId: 'task-1', producerKind: 'agent' }
const patch = '@@ -1 +1 @@\n-before\n+after'
const itemsWith = (
state: NativeChatSubagentState,
answered: boolean
): AgentJournalRenderItem[] => [
journalItem(
'ask',
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Review it' }] },
1
),
journalItem(
'spawn',
{
kind: 'message',
role: 'system',
blocks: [
{
kind: 'message',
role: 'assistant',
blocks: [{ type: 'text', text: 'Delegated; nothing to fix.' }]
},
5
)
]
: [])
]
type: 'subagent-group',
groupId: 'group-1',
agents: [{ id: 'task-1', label: 'explore the lane', state }]
}
]
},
2
),
journalItem(
'child-said',
{ kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'The PR is CLEAN.' }] },
3,
child
),
journalItem(
'child-edit',
{
kind: 'diff',
path: 'src/a.ts',
patch: { head: patch, truncated: false, digest: 'fixture', byteLength: patch.length }
},
4,
child
),
...(answered
? [
journalItem(
'answer',
{
kind: 'message',
role: 'assistant',
blocks: [{ type: 'text', text: 'Delegated; nothing to fix.' }]
},
5
)
]
: [])
]
/** `waiting`: the session runs and has produced nothing since the roster. */
function listOf(state: NativeChatSubagentState, waiting = false): React.JSX.Element {
const items = itemsWith(state, !waiting)
return (
<NativeChatMessageList
session={session(projectStructuredItemsToNativeChat(items))}
journalItems={items}
isWorking={waiting}
expandSignal={false}
/>
)
}
const renderList = () => render(listOf('completed'))
describe("a subagent's rows in the transcript", () => {
let restoreLayout = (): void => {}
beforeEach(() => {
restoreLayout = stubLayout()
})
afterEach(() => {
restoreLayout()
vi.restoreAllMocks()
})
it("shows the conversation and none of the subagent's words until its entry opens them", () => {
renderList()
expect(screen.getByText('Delegated; nothing to fix.')).toBeInTheDocument()
expect(screen.queryByText('The PR is CLEAN.')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /Ran 1 subagent/ }))
fireEvent.click(screen.getByRole('button', { name: /explore the lane/, expanded: false }))
// Named once: its entry heads its rows, which come before the parent's answer.
expect(screen.getAllByText('explore the lane')).toHaveLength(1)
const entry = screen.getByRole('button', { name: /explore the lane/, expanded: true })
const said = screen.getByText('The PR is CLEAN.')
expect(entry.compareDocumentPosition(said)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
expect(said.compareDocumentPosition(screen.getByText('Delegated; nothing to fix.'))).toBe(
Node.DOCUMENT_POSITION_FOLLOWING
)
fireEvent.click(entry)
expect(screen.queryByText('The PR is CLEAN.')).toBeNull()
expect(screen.getByRole('button', { name: /explore the lane/, expanded: false })).toBeVisible()
})
// happy-dom has no layout, so the geometry is pinned by the margin that decides it:
// outside a section a row's controls hang into the gap below it (`-mb-5`), and
// inside one that overhang would leave them below the section's border, touching
// the parent's next row.
it("keeps a section row's controls inside the row that carries the section's border", () => {
renderList()
fireEvent.click(screen.getByRole('button', { name: /Ran 1 subagent/ }))
fireEvent.click(screen.getByRole('button', { name: /explore the lane/, expanded: false }))
const controlsOf = (text: string): HTMLElement => {
const row = screen.getByText(text).closest<HTMLElement>('.group')
expect(row).not.toBeNull()
return within(row!).getByRole('button', { name: 'Scroll this message to top' }).parentElement!
}
const sectionControls = controlsOf('The PR is CLEAN.')
expect(sectionControls.closest('.border-l-2')).not.toBeNull()
expect(sectionControls).not.toHaveClass('-mb-5')
expect(controlsOf('Delegated; nothing to fix.')).toHaveClass('-mb-5')
})
it("draws an open agent's rows between its entry and the next one", () => {
const agents = [
{ id: 'task-a', label: 'lane a', state: 'completed' as const },
{ id: 'task-b', label: 'lane b', state: 'completed' as const },
{ id: 'task-c', label: 'lane c', state: 'completed' as const }
]
const items = [
itemsWith('completed', false)[0]!,
journalItem(
'spawn',
{
kind: 'message',
role: 'system',
blocks: [{ type: 'subagent-group', groupId: 'group-1', agents }]
},
2
),
...agents.map(({ id, label }, index) =>
journalItem(
`${id}-said`,
{
kind: 'message',
role: 'assistant',
blocks: [{ type: 'text', text: `Read ${label}.` }]
},
3 + index,
{ agentId: id, producerKind: 'agent' }
)
)
]
render(
/** `waiting`: the session runs and has produced nothing since the roster. */
function listOf(state: NativeChatSubagentState, waiting = false): React.JSX.Element {
const items = itemsWith(state, !waiting)
return (
<NativeChatMessageList
session={session(projectStructuredItemsToNativeChat(items))}
journalItems={items}
isWorking={false}
isWorking={waiting}
expandSignal={false}
/>
)
fireEvent.click(screen.getByRole('button', { name: /Ran 3 subagents/ }))
fireEvent.click(screen.getByRole('button', { name: /lane b/ }))
}
const order = [
screen.getByRole('button', { name: /lane a/, expanded: false }),
screen.getByRole('button', { name: /lane b/, expanded: true }),
screen.getByText('Read lane b.'),
screen.getByRole('button', { name: /lane c/, expanded: false })
]
for (const [index, node] of order.slice(1).entries()) {
expect(order[index]!.compareDocumentPosition(node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
}
expect(screen.queryByText('Read lane a.')).toBeNull()
expect(screen.getAllByText(/^lane [abc]$/)).toHaveLength(3)
})
const renderList = () => render(listOf('completed'))
it("still counts the subagent's edit in the turn, and reveals it inside its section", () => {
vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(() => {})
renderList()
expect(screen.queryByRole('button', { name: /^Edited .*a\.ts(?:\s|$)/ })).toBeNull()
describe("a subagent's rows in the transcript", () => {
afterEach(cleanup)
let restoreLayout = (): void => {}
beforeEach(() => {
restoreLayout = stubLayout()
})
afterEach(() => {
restoreLayout()
vi.restoreAllMocks()
})
fireEvent.click(screen.getByRole('button', { name: /1 changed file/ }))
fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ }))
it("shows the conversation and none of the subagent's words until its entry opens them", () => {
renderList()
expect(screen.getByText('Delegated; nothing to fix.')).toBeInTheDocument()
expect(screen.queryByText('The PR is CLEAN.')).toBeNull()
expect(screen.getByText('Edited')).toBeInTheDocument()
expect(screen.getByText('The PR is CLEAN.')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /explore the lane/, expanded: true })).toBeVisible()
})
fireEvent.click(screen.getByRole('button', { name: /Ran 1 subagent/ }))
fireEvent.click(screen.getByRole('button', { name: /explore the lane/, expanded: false }))
// Rows are grouped by the journal's turn records, so a subagent's edit counts in the
// turn that was running when it was made: not a send queued mid-turn, whose own turn
// had not opened, and in a turn keyed to its own record rather than the last prompt.
it("counts a subagent's edit in the turn the journal says it was made in", () => {
const say = (itemId: string, role: 'user' | 'assistant', text: string, sequence: number) =>
journalItem(itemId, { kind: 'message', role, blocks: [{ type: 'text', text }] }, sequence)
const turn = (turnId: string, userItemId: string, sequence: number) =>
journalItem(
`turn-${turnId}`,
{ kind: 'turn', turnId, state: 'completed', userItemId, startedAt: sequence * 1000 },
sequence
// Named once: its entry heads its rows, which come before the parent's answer.
expect(screen.getAllByText('explore the lane')).toHaveLength(1)
const entry = screen.getByRole('button', { name: /explore the lane/, expanded: true })
const said = screen.getByText('The PR is CLEAN.')
expect(entry.compareDocumentPosition(said)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
expect(said.compareDocumentPosition(screen.getByText('Delegated; nothing to fix.'))).toBe(
Node.DOCUMENT_POSITION_FOLLOWING
)
const childEdit = (itemId: string, path: string, sequence: number) =>
journalItem(
itemId,
{
kind: 'diff',
path,
patch: { head: patch, truncated: false, digest: path, byteLength: patch.length }
},
sequence,
child
)
const [ask, spawn] = itemsWith('completed', false)
const items = [
ask!,
turn('1', 'ask', 2),
{ ...spawn!, sequence: 3 },
say('ask-2', 'user', 'And the tests?', 4),
childEdit('child-edit', 'src/a.ts', 5),
say('answer-1', 'assistant', 'Edited a.', 6),
turn('2', 'ask-2', 7),
say('answer-2', 'assistant', 'Tests pass.', 8),
// Its opener is outside the loaded window, so the turn keys to its own record.
turn('3', 'older-send', 9),
childEdit('child-woke', 'src/b.ts', 10),
say('answer-3', 'assistant', 'Picked up the result.', 11)
]
render(
<NativeChatMessageList
session={session(projectStructuredItemsToNativeChat(items))}
journalItems={items}
isWorking={false}
expandSignal={false}
/>
)
const rollups = screen.getAllByRole('button', { name: /changed file/ })
expect(rollups.map((rollup) => rollup.textContent)).toEqual([
expect.stringMatching(/1 changed file/),
expect.stringMatching(/1 changed file/)
])
const inOrder = [
screen.getByText('Edited a.'),
rollups[0]!,
screen.getByText('Tests pass.'),
screen.getByText('Picked up the result.'),
rollups[1]!
]
for (const [index, node] of inOrder.slice(1).entries()) {
expect(inOrder[index]!.compareDocumentPosition(node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
}
})
it('reveals an edit under a roster list the reader closed', () => {
vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(() => {})
renderList()
fireEvent.click(screen.getByRole('button', { name: /Ran 1 subagent/ }))
fireEvent.click(screen.getByRole('button', { name: /Ran 1 subagent/ }))
fireEvent.click(entry)
expect(screen.queryByText('The PR is CLEAN.')).toBeNull()
expect(
screen.getByRole('button', { name: /explore the lane/, expanded: false })
).toBeVisible()
})
fireEvent.click(screen.getByRole('button', { name: /1 changed file/ }))
fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ }))
// happy-dom has no layout, so the geometry is pinned by the margin that decides it:
// outside a section a row's controls hang into the gap below it (`-mb-5`), and
// inside one that overhang would leave them below the section's border, touching
// the parent's next row.
it("keeps a section row's controls inside the row that carries the section's border", () => {
renderList()
fireEvent.click(screen.getByRole('button', { name: /Ran 1 subagent/ }))
fireEvent.click(screen.getByRole('button', { name: /explore the lane/, expanded: false }))
const controlsOf = (text: string): HTMLElement => {
const row = screen.getByText(text).closest<HTMLElement>('.group')
expect(row).not.toBeNull()
return within(row!).getByRole('button', { name: 'Scroll this message to top' })
.parentElement!
}
expect(screen.getByText('Edited')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Ran 1 subagent/, expanded: true })).toBeVisible()
})
const sectionControls = controlsOf('The PR is CLEAN.')
expect(sectionControls.closest('.border-l-2')).not.toBeNull()
expect(sectionControls).not.toHaveClass('-mb-5')
expect(controlsOf('Delegated; nothing to fix.')).toHaveClass('-mb-5')
})
it('keeps a roster list open or closed past its row unmounting, and hides its sections while closed', () => {
const items = itemsWith('completed', true)
const view = (withRoster: boolean) => {
const shown = withRoster ? items : items.filter((next) => next.itemId !== 'spawn')
return (
it("draws an open agent's rows between its entry and the next one", () => {
const agents = [
{ id: 'task-a', label: 'lane a', state: 'completed' as const },
{ id: 'task-b', label: 'lane b', state: 'completed' as const },
{ id: 'task-c', label: 'lane c', state: 'completed' as const }
]
const items = [
itemsWith('completed', false)[0]!,
journalItem(
'spawn',
{
kind: 'message',
role: 'system',
blocks: [{ type: 'subagent-group', groupId: 'group-1', agents }]
},
2
),
...agents.map(({ id, label }, index) =>
journalItem(
`${id}-said`,
{
kind: 'message',
role: 'assistant',
blocks: [{ type: 'text', text: `Read ${label}.` }]
},
3 + index,
{ agentId: id, producerKind: 'agent' }
)
)
]
render(
<NativeChatMessageList
session={session(projectStructuredItemsToNativeChat(shown))}
journalItems={shown}
session={session(projectStructuredItemsToNativeChat(items))}
journalItems={items}
isWorking={false}
expandSignal={false}
/>
)
fireEvent.click(screen.getByRole('button', { name: /Ran 3 subagents/ }))
fireEvent.click(screen.getByRole('button', { name: /lane b/ }))
const order = [
screen.getByRole('button', { name: /lane a/, expanded: false }),
screen.getByRole('button', { name: /lane b/, expanded: true }),
screen.getByText('Read lane b.'),
screen.getByRole('button', { name: /lane c/, expanded: false })
]
for (const [index, node] of order.slice(1).entries()) {
expect(order[index]!.compareDocumentPosition(node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
}
expect(screen.queryByText('Read lane a.')).toBeNull()
expect(screen.getAllByText(/^lane [abc]$/)).toHaveLength(3)
})
it("still counts the subagent's edit in the turn, and reveals it inside its section", () => {
vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(() => {})
renderList()
expect(screen.queryByRole('button', { name: /^Edited .*a\.ts(?:\s|$)/ })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /1 changed file/ }))
fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ }))
expect(screen.getByText('Edited')).toBeInTheDocument()
expect(screen.getByText('The PR is CLEAN.')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /explore the lane/, expanded: true })).toBeVisible()
})
// Rows are grouped by the journal's turn records, so a subagent's edit counts in the
// turn that was running when it was made: not a send queued mid-turn, whose own turn
// had not opened, and in a turn keyed to its own record rather than the last prompt.
it("counts a subagent's edit in the turn the journal says it was made in", () => {
const say = (itemId: string, role: 'user' | 'assistant', text: string, sequence: number) =>
journalItem(itemId, { kind: 'message', role, blocks: [{ type: 'text', text }] }, sequence)
const turn = (turnId: string, userItemId: string, sequence: number) =>
journalItem(
`turn-${turnId}`,
{ kind: 'turn', turnId, state: 'completed', userItemId, startedAt: sequence * 1000 },
sequence
)
const childEdit = (itemId: string, path: string, sequence: number) =>
journalItem(
itemId,
{
kind: 'diff',
path,
patch: { head: patch, truncated: false, digest: path, byteLength: patch.length }
},
sequence,
child
)
const [ask, spawn] = itemsWith('completed', false)
const items = [
ask!,
turn('1', 'ask', 2),
{ ...spawn!, sequence: 3 },
say('ask-2', 'user', 'And the tests?', 4),
childEdit('child-edit', 'src/a.ts', 5),
say('answer-1', 'assistant', 'Edited a.', 6),
turn('2', 'ask-2', 7),
say('answer-2', 'assistant', 'Tests pass.', 8),
// Its opener is outside the loaded window, so the turn keys to its own record.
turn('3', 'older-send', 9),
childEdit('child-woke', 'src/b.ts', 10),
say('answer-3', 'assistant', 'Picked up the result.', 11)
]
render(
<NativeChatMessageList
session={session(projectStructuredItemsToNativeChat(items))}
journalItems={items}
isWorking={false}
expandSignal={false}
/>
)
const rollups = screen.getAllByRole('button', { name: /changed file/ })
expect(rollups.map((rollup) => rollup.textContent)).toEqual([
expect.stringMatching(/1 changed file/),
expect.stringMatching(/1 changed file/)
])
const inOrder = [
screen.getByText('Edited a.'),
rollups[0]!,
screen.getByText('Tests pass.'),
screen.getByText('Picked up the result.'),
rollups[1]!
]
for (const [index, node] of inOrder.slice(1).entries()) {
expect(inOrder[index]!.compareDocumentPosition(node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
}
})
it('reveals an edit under a roster list the reader closed', () => {
vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(() => {})
renderList()
fireEvent.click(screen.getByRole('button', { name: /Ran 1 subagent/ }))
fireEvent.click(screen.getByRole('button', { name: /Ran 1 subagent/ }))
fireEvent.click(screen.getByRole('button', { name: /1 changed file/ }))
fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ }))
expect(screen.getByText('Edited')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Ran 1 subagent/, expanded: true })).toBeVisible()
})
it('keeps a roster list open or closed past its row unmounting, and hides its sections while closed', () => {
const items = itemsWith('completed', true)
const view = (withRoster: boolean) => {
const shown = withRoster ? items : items.filter((next) => next.itemId !== 'spawn')
return (
<NativeChatMessageList
session={session(projectStructuredItemsToNativeChat(shown))}
journalItems={shown}
isWorking={false}
expandSignal={false}
/>
)
}
const { rerender } = render(view(true))
fireEvent.click(screen.getByRole('button', { name: /Ran 1 subagent/ }))
fireEvent.click(screen.getByRole('button', { name: /explore the lane/, expanded: false }))
expect(screen.getByText('The PR is CLEAN.')).toBeInTheDocument()
rerender(view(false))
rerender(view(true))
expect(screen.getByRole('button', { name: /Ran 1 subagent/, expanded: true })).toBeVisible()
fireEvent.click(screen.getByRole('button', { name: /Ran 1 subagent/ }))
expect(screen.queryByText('The PR is CLEAN.')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /Ran 1 subagent/ }))
expect(screen.getByText('The PR is CLEAN.')).toBeInTheDocument()
})
it('opens a working subagent while the session waits on it, and closes it once the parent moves on', () => {
const { rerender } = render(listOf('working', true))
expect(screen.getByText('The PR is CLEAN.')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /explore the lane/, expanded: true })).toBeVisible()
expect(screen.getAllByText('explore the lane')).toHaveLength(1)
rerender(listOf('working'))
expect(screen.queryByText('The PR is CLEAN.')).toBeNull()
// The closed roster still says the agent works.
expect(screen.getByRole('button', { name: /Kicked off 1 subagent/ })).toBeInTheDocument()
})
it("keeps the reader's choice over the frontier, in either direction", () => {
const { rerender } = render(listOf('working', true))
fireEvent.click(screen.getByRole('button', { name: /explore the lane/, expanded: true }))
rerender(listOf('working', true))
expect(screen.queryByText('The PR is CLEAN.')).toBeNull()
// Closing the section from its entry left the list open.
fireEvent.click(screen.getByRole('button', { name: /explore the lane/, expanded: false }))
rerender(listOf('working'))
expect(screen.getByText('The PR is CLEAN.')).toBeInTheDocument()
})
it('names a section from the client roster when its roster row is not loaded', () => {
const items = itemsWith('completed', true)
const loaded = items.filter((next) => next.itemId !== 'spawn')
render(
<NativeChatMessageList
session={session(projectStructuredItemsToNativeChat(loaded))}
journalItems={loaded}
subagentRoster={foldStructuredAgentSubagentRoster(
NO_STRUCTURED_AGENT_SUBAGENT_ROSTER,
items
)}
isWorking={false}
expandSignal={false}
/>
)
expect(
screen.getByRole('button', { name: /explore the lane/, expanded: false })
).toBeVisible()
})
})
}
{
const session = materialRailSession
const stubLayout = materialRailStubLayout
describe('revealing a diff from a turn rollup', () => {
afterEach(cleanup)
let restoreLayout = (): void => {}
beforeEach(() => {
restoreLayout = stubLayout({ scrollGeometry: true, offsetChain: true })
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (
this: HTMLElement
) {
const row = this.closest<HTMLElement>('[data-index]')
const scroller = this.closest<HTMLElement>('[data-native-chat-scroll]')
return new DOMRect(
0,
row ? row.offsetTop - (scroller?.scrollTop ?? 0) : 0,
100,
this.offsetHeight
)
})
})
afterEach(() => {
restoreLayout()
vi.restoreAllMocks()
})
function journalItem(itemId: string, body: AgentJournalItemBody, sequence: number) {
return { itemId, body, sequence, observedAt: sequence * 1000, revision: 1 }
}
const { rerender } = render(view(true))
fireEvent.click(screen.getByRole('button', { name: /Ran 1 subagent/ }))
fireEvent.click(screen.getByRole('button', { name: /explore the lane/, expanded: false }))
expect(screen.getByText('The PR is CLEAN.')).toBeInTheDocument()
rerender(view(false))
rerender(view(true))
expect(screen.getByRole('button', { name: /Ran 1 subagent/, expanded: true })).toBeVisible()
const patch = '@@ -1 +1 @@\n-before\n+after'
const items: AgentJournalRenderItem[] = [
journalItem(
'user',
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Edit it' }] },
1
),
journalItem(
'diff',
{
kind: 'diff',
path: 'src/a.ts',
patch: { head: patch, truncated: false, digest: 'fixture', byteLength: patch.length }
},
2
),
...Array.from({ length: TRANSCRIPT_LENGTH }, (_, index) =>
journalItem(
`tail-${index}`,
{
kind: 'message',
role: 'assistant',
blocks: [{ type: 'text', text: `marker-${index}` }]
},
index + 3
)
)
]
fireEvent.click(screen.getByRole('button', { name: /Ran 1 subagent/ }))
expect(screen.queryByText('The PR is CLEAN.')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /Ran 1 subagent/ }))
expect(screen.getByText('The PR is CLEAN.')).toBeInTheDocument()
it('lets a rail jump supersede a previously revealed diff', () => {
const withPrompts = [
...items.slice(0, 2),
journalItem(
'user-2',
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Second prompt' }] },
3
),
journalItem(
'user-3',
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Third prompt' }] },
4
),
...items.slice(2)
].map((item, index) => ({ ...item, sequence: index + 1 }))
const scrollTo = vi.fn()
vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(scrollTo)
const transcript = (): React.JSX.Element => (
<NativeChatMessageList
session={session(projectStructuredItemsToNativeChat(withPrompts))}
journalItems={withPrompts}
isWorking={false}
expandSignal={false}
/>
)
const { container, rerender } = render(transcript())
const selectedTop =
screen.getByText('Second prompt').closest<HTMLElement>('[data-index]')?.offsetTop ?? 0
expect(selectedTop).toBeGreaterThan(0)
fireEvent.click(screen.getByRole('button', { name: /1 changed file/ }))
fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ }))
scrollTranscript(container, 6000)
expect(screen.getByText('Edited')).toBeInTheDocument()
scrollTo.mockClear()
fireEvent.click(screen.getByRole('button', { name: 'Second prompt' }))
expect(scrollTo.mock.calls).toEqual([
[{ top: 6000, behavior: 'auto' }],
[{ top: selectedTop, behavior: 'smooth' }]
])
expect(screen.queryByRole('button', { name: /^Edited .*a\.ts(?:\s|$)/ })).toBeNull()
scrollTo.mockClear()
rerender(transcript())
rerender(transcript())
expect(scrollTo).not.toHaveBeenCalled()
scrollTranscript(container, 0)
scrollTo.mockClear()
fireEvent.click(screen.getByRole('button', { name: /1 changed file/ }))
fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ }))
const diffTop =
screen.getByText('Edited').closest<HTMLElement>('[data-index]')?.offsetTop ?? 0
expect(Number.isFinite(diffTop)).toBe(true)
expect(scrollTo.mock.calls.filter(([options]) => options?.behavior === 'smooth')).toEqual([
[{ top: diffTop, behavior: 'smooth' }]
])
})
})
it('opens a working subagent while the session waits on it, and closes it once the parent moves on', () => {
const { rerender } = render(listOf('working', true))
expect(screen.getByText('The PR is CLEAN.')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /explore the lane/, expanded: true })).toBeVisible()
expect(screen.getAllByText('explore the lane')).toHaveLength(1)
rerender(listOf('working'))
expect(screen.queryByText('The PR is CLEAN.')).toBeNull()
// The closed roster still says the agent works.
expect(screen.getByRole('button', { name: /Kicked off 1 subagent/ })).toBeInTheDocument()
})
// The rail borrows the reveal's pin to reach a row the window has left behind.
// Borrowing the pin means it also has to give it back: the request is what
// outranks a later reveal, and slots is rebuilt every render, so an effect that
// merely watched it would re-scroll forever.
describe('jumping to a message from the rail', () => {
afterEach(cleanup)
let restoreLayout = (): void => {}
beforeEach(() => {
restoreLayout = stubLayout()
})
afterEach(() => {
restoreLayout()
vi.useRealTimers()
vi.restoreAllMocks()
})
it("keeps the reader's choice over the frontier, in either direction", () => {
const { rerender } = render(listOf('working', true))
fireEvent.click(screen.getByRole('button', { name: /explore the lane/, expanded: true }))
rerender(listOf('working', true))
expect(screen.queryByText('The PR is CLEAN.')).toBeNull()
function userMarker(index: number): NativeChatMessage {
return {
id: `message-${index}`,
role: 'user',
blocks: [{ type: 'text', text: `prompt-${index}` }],
timestamp: index + 1,
source: 'transcript'
}
}
// Closing the section from its entry left the list open.
fireEvent.click(screen.getByRole('button', { name: /explore the lane/, expanded: false }))
rerender(listOf('working'))
expect(screen.getByText('The PR is CLEAN.')).toBeInTheDocument()
})
it('names a section from the client roster when its roster row is not loaded', () => {
const items = itemsWith('completed', true)
const loaded = items.filter((next) => next.itemId !== 'spawn')
render(
<NativeChatMessageList
session={session(projectStructuredItemsToNativeChat(loaded))}
journalItems={loaded}
subagentRoster={foldStructuredAgentSubagentRoster(
NO_STRUCTURED_AGENT_SUBAGENT_ROSTER,
items
)}
isWorking={false}
expandSignal={false}
/>
const conversation = Array.from({ length: TRANSCRIPT_LENGTH }, (_, index) =>
index % 10 === 0 ? userMarker(index) : marker(index)
)
expect(screen.getByRole('button', { name: /explore the lane/, expanded: false })).toBeVisible()
function jumpToFirstPrompt(): void {
fireEvent.click(screen.getByRole('button', { name: 'prompt-0' }))
act(() => {
vi.advanceTimersByTime(300)
})
}
it('scrolls once for a selection, not again on every later render', () => {
vi.useFakeTimers()
const scrollTo = vi.fn()
vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(scrollTo)
const { container, rerender } = render(list(conversation))
scrollTranscript(container, 6000)
jumpToFirstPrompt()
expect(scrollTo).toHaveBeenCalled()
// A streaming turn re-renders constantly with the same messages. The jump is
// spent; nothing here may drag the reader back to the row they left.
scrollTo.mockClear()
rerender(list(conversation))
rerender(list(conversation))
expect(scrollTo).not.toHaveBeenCalled()
})
it('releases the pin once the jump is spent', () => {
vi.useFakeTimers()
const scrollTo = vi.fn()
vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(scrollTo)
const { container } = render(list(conversation))
scrollTranscript(container, 6000)
jumpToFirstPrompt()
expect(scrollTo).toHaveBeenCalled()
// The request is spent as soon as the scroll is issued, so the row it pinned
// is not held in the window afterwards. A pin still standing here would also
// still outrank a diff reveal, which shares the same slot.
expect(windowState(container).indexes).not.toContain(0)
})
})
})
}
@@ -1,249 +0,0 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import type { AgentJournalStatusItem } from '../../../../shared/agent-session-journal-types'
import { projectStructuredItemToNativeChat } from '../../../../shared/structured-agent-session-projection'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { NativeChatMessageList } from './NativeChatMessageList'
import { projectNativeChatTaskListFrames } from './native-chat-task-list-frames'
import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport'
import { openToolRunMembers } from './native-chat-tool-run-members-test-support'
let restoreViewport = (): void => {}
beforeAll(() => {
restoreViewport = installNativeChatMessageListTestViewport()
})
afterAll(() => restoreViewport())
afterEach(cleanup)
function frame(id: number, status: string, overrides: { kind?: string; truncated?: boolean } = {}) {
const kind = overrides.kind ?? 'notification:turn/plan/updated'
const head = JSON.stringify({
threadId: 'thread',
turnId: 'turn',
explanation: 'Keep verification visible',
plan: [{ step: 'Verify', status }]
})
const body: AgentJournalStatusItem = {
kind: 'status',
text: `codex · ${kind}`,
providerFrame: {
provider: 'codex',
kind,
payload: {
head,
byteLength: new TextEncoder().encode(head).byteLength,
digest: 'fixture-digest',
truncated: overrides.truncated ?? false
}
}
}
const message = projectStructuredItemToNativeChat({
itemId: `frame-${id}`,
revision: 1,
sequence: id,
observedAt: id,
body
})
if (!message) {
throw new Error('Expected a projected message')
}
return message
}
function transcript(messages: NativeChatMessage[], sessionId = 'live-codex') {
return (
<NativeChatMessageList
session={{
messages,
status: 'ready',
sessionId,
agent: 'codex',
hasMore: false,
loadingEarlier: false,
olderHistoryGeneration: 0,
loadEarlier: vi.fn(),
readPhase: 'ready'
}}
isWorking={false}
expandSignal
/>
)
}
describe('live Codex checklist frames', () => {
it('updates one pinned checklist from journal notifications without rewinding on pagination', () => {
const first = frame(1, 'pending')
const active = frame(2, 'inProgress')
const last = frame(3, 'completed')
const { rerender, container } = render(transcript([first]))
const toggle = screen.getByRole('button', { name: 'Tasks 0 of 1 tasks completed' })
const viewport = container.querySelector('.overflow-y-auto')!
expect(viewport.contains(toggle)).toBe(false)
fireEvent.click(toggle)
rerender(transcript([first, active]))
expect(within(toggle.parentElement!).getByText('Verify').closest('li')).toHaveClass(
'text-foreground'
)
expect(within(viewport as HTMLElement).getByText('Started Verify')).toBeInTheDocument()
rerender(transcript([last]))
expect(
within(
screen.getByRole('button', { name: 'Tasks 1 of 1 tasks completed' }).parentElement!
).getByText('Verify')
).toHaveClass('line-through')
expect(screen.getAllByText('Keep verification visible')).toHaveLength(2)
rerender(transcript([first, active, last]))
expect(screen.getAllByText('Verify')).toHaveLength(2)
expect(screen.getByRole('button', { name: 'Tasks 1 of 1 tasks completed' })).toBe(toggle)
expect(screen.getByText('Started Verify')).toBeInTheDocument()
expect(screen.queryByText('notification:turn/plan/updated')).toBeNull()
expect(projectNativeChatTaskListFrames([last])[0]).toBe(
projectNativeChatTaskListFrames([last])[0]
)
})
it('uses the latest complete snapshot across Codex tool calls and notifications', () => {
const tool: NativeChatMessage = {
id: 'tool',
role: 'assistant',
timestamp: 1,
source: 'transcript',
// Journalled like the frames it sits between.
journalPosition: { sequence: 1, index: 0 },
blocks: [
{
type: 'tool-call',
name: 'update_plan',
input: { plan: [{ step: 'Verify', status: 'pending' }] }
}
]
}
render(transcript([tool, frame(3, 'completed')]))
fireEvent.click(screen.getByRole('button', { name: 'Tasks 1 of 1 tasks completed' }))
expect(screen.getAllByText('Verify')).toHaveLength(2)
expect(
within(
screen.getByRole('button', { name: 'Tasks 1 of 1 tasks completed' }).parentElement!
).getByText('Verify')
).toHaveClass('line-through')
})
it('keeps malformed, truncated, other-provider, and plan-document frames unchanged', () => {
const truncated = frame(1, 'pending', { truncated: true })
const document = frame(2, 'pending', { kind: 'item:plan' })
const malformed = frame(3, 'pending')
const otherProvider = frame(4, 'pending')
const malformedBlock = malformed.blocks[0]
const otherBlock = otherProvider.blocks[0]
if (malformedBlock.type === 'text' && malformedBlock.providerFrame) {
malformedBlock.providerFrame.payload.head = '{"plan":null}'
}
if (otherBlock.type === 'text' && otherBlock.providerFrame) {
otherBlock.providerFrame.provider = 'claude'
}
const messages = [truncated, document, malformed, otherProvider]
const projected = projectNativeChatTaskListFrames(messages)
projected.forEach((message, index) => expect(message).toBe(messages[index]))
render(transcript([truncated]))
// Unreadable as a list and wordless, so it is neither a checklist nor a raw row.
expect(screen.queryByText('notification:turn/plan/updated')).toBeNull()
expect(screen.queryByText('Tasks')).toBeNull()
})
// Stored for readers like the task list, drawn only when it has words of its own.
it('hides a wordless unrecognised frame while a plan update still becomes the checklist', () => {
const unknown = frame(1, 'pending', { kind: 'notification:future/event' })
const failure = frame(2, 'pending', { kind: 'notification:future/failure' })
const failureBlock = failure.blocks[0]
if (failureBlock.type === 'text') {
failureBlock.tone = 'error'
}
render(transcript([unknown, failure, frame(3, 'inProgress')]))
expect(screen.queryByText('notification:future/event')).toBeNull()
expect(screen.getByText('codex · notification:future/failure')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Tasks 0 of 1 tasks completed' })).toBeInTheDocument()
})
it('does not consume a neighboring tool failure as a notification result', () => {
const command: NativeChatMessage = {
id: 'command',
role: 'assistant',
timestamp: 2,
source: 'transcript',
blocks: [
{ type: 'tool-call', name: 'shell', input: { command: 'verify' }, state: 'failed' },
{ type: 'tool-result', output: 'Verification failed', isError: true }
]
}
render(transcript([frame(1, 'pending'), command]))
expect(screen.getByRole('button', { name: 'Tasks 0 of 1 tasks completed' })).toBeInTheDocument()
openToolRunMembers()
expect(screen.getByText('Verification failed', { selector: 'pre' })).toHaveClass(
'text-destructive'
)
})
})
describe('NativeChatMessageList task list history', () => {
it('keeps the latest Claude state after pagination and resets disclosure between sessions', () => {
const first = {
id: 'first-list',
role: 'assistant' as const,
timestamp: 1,
source: 'transcript' as const,
blocks: [
{
type: 'tool-call' as const,
name: 'TodoWrite',
input: {
todos: [
{ content: 'Read', status: 'pending' },
{ content: 'Test', status: 'pending' }
]
}
}
]
}
const last = {
...first,
id: 'last-list',
timestamp: 3,
blocks: [
{ type: 'text' as const, text: 'Ready for verification' },
{
type: 'tool-call' as const,
name: 'TodoWrite',
input: {
todos: [
{ content: 'Read', status: 'completed' },
{ content: 'Test', status: 'pending' }
]
}
}
]
}
const { rerender } = render(transcript([last]))
fireEvent.click(screen.getByRole('button', { name: 'Tasks 1 of 2 tasks completed' }))
rerender(transcript([first, last]))
expect(screen.getAllByText('Read')).toHaveLength(2)
expect(screen.getByText('Completed Read')).toBeInTheDocument()
expect(
within(
screen.getByRole('button', { name: 'Tasks 1 of 2 tasks completed' }).parentElement!
).getByText('Read')
).toHaveClass('line-through')
expect(screen.getByText('Ready for verification')).toBeInTheDocument()
rerender(transcript([first], 'two'))
expect(screen.getByRole('button', { name: 'Tasks 0 of 2 tasks completed' })).toHaveAttribute(
'aria-expanded',
'false'
)
expect(screen.getAllByText('Read')).toHaveLength(1)
rerender(transcript([], 'three'))
expect(screen.queryByText('Tasks')).toBeNull()
})
})
File diff suppressed because it is too large Load Diff
@@ -1,259 +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 {
AgentJournalRenderItem,
AgentJournalTurnScope
} from '../../../../shared/agent-session-journal-types'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import type { NativeChatLiveSession } from './use-native-chat-live-session'
import { NativeChatMessageList } from './NativeChatMessageList'
import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport'
let restoreViewport = (): void => {}
beforeAll(() => {
restoreViewport = installNativeChatMessageListTestViewport()
})
afterAll(() => restoreViewport())
afterEach(cleanup)
const NARRATION = 'I am starting with the required readiness pass.'
const MORE_NARRATION = 'The branch is current and the reference corpus is refreshed.'
const ANSWER = 'Review complete: clean after fixes.'
function session(startedAt: number): NativeChatLiveSession {
return {
messages: [
{
id: 'user-1',
role: 'user',
blocks: [{ type: 'text', text: 'Please review this PR.' }],
timestamp: startedAt,
source: 'transcript'
},
{
id: 'narration-1',
role: 'assistant',
blocks: [{ type: 'text', text: NARRATION }],
timestamp: startedAt + 1,
source: 'transcript'
},
{
id: 'work-1',
role: 'assistant',
blocks: [
{ type: 'tool-call', name: 'shell', input: { command: 'pnpm test' }, state: 'completed' },
{ type: 'tool-result', output: 'ok' }
],
timestamp: startedAt + 2,
source: 'transcript'
},
{
id: 'narration-2',
role: 'assistant',
blocks: [{ type: 'text', text: MORE_NARRATION }],
timestamp: startedAt + 3,
source: 'transcript'
},
{
id: 'answer-1',
role: 'assistant',
blocks: [{ type: 'text', text: ANSWER }],
timestamp: startedAt + 4,
source: 'transcript'
}
],
status: 'ready',
sessionId: 'session-1',
agent: 'codex',
hasMore: false,
loadingEarlier: false,
olderHistoryGeneration: 0,
loadEarlier: vi.fn(),
readPhase: 'ready'
}
}
/** The journal behind `messages`: one turn `user-1` opened, holding every later row. With
* `statesScope` false, the rows carry no turn scope, as an older host writes them. */
function journalOf(
messages: readonly NativeChatMessage[],
statesScope: boolean
): AgentJournalRenderItem[] {
const scope = (turnScope: AgentJournalTurnScope) => (statesScope ? { turnScope } : {})
const [opener, ...rest] = messages
const entry = (message: NativeChatMessage, sequence: number): AgentJournalRenderItem => ({
itemId: message.id,
revision: 0,
sequence,
observedAt: sequence,
body: {
kind: 'message',
role: message.role === 'user' ? 'user' : 'assistant',
blocks: [{ type: 'text', text: message.id }]
},
...scope({ kind: 'turn', turnItemId: 'turn-1' })
})
return [
{ ...entry(opener!, 1), ...scope({ kind: 'thread' }) },
{
itemId: 'turn-1',
revision: 0,
sequence: 2,
observedAt: 2,
body: { kind: 'turn', turnId: 'turn-1', state: 'completed', userItemId: opener!.id },
...scope({ kind: 'thread' })
},
...rest.map((message, index) => entry(message, index + 3))
]
}
// A finished turn is the transcript's resting state, and it should read as the
// answer to the prompt — not as the transcript of the work that produced it.
describe('NativeChatMessageList settled turn fold', () => {
it('shows a settled turn as its prompt, its duration and its answer', () => {
const startedAt = Date.now() - 3000
render(
<NativeChatMessageList
session={session(startedAt)}
isWorking={false}
workingStartedAt={startedAt}
expandSignal={false}
/>
)
expect(screen.getByText('Please review this PR.')).toBeInTheDocument()
expect(screen.getByText(ANSWER)).toBeInTheDocument()
expect(screen.queryByText(NARRATION)).toBeNull()
expect(screen.queryByText(MORE_NARRATION)).toBeNull()
expect(screen.getByRole('button', { name: 'Toggle turn details' })).toHaveAttribute(
'aria-expanded',
'false'
)
})
it('returns the whole turn when the reader opens it', () => {
const startedAt = Date.now() - 3000
render(
<NativeChatMessageList
session={session(startedAt)}
isWorking={false}
workingStartedAt={startedAt}
expandSignal={false}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'Toggle turn details' }))
expect(screen.getByText(NARRATION)).toBeInTheDocument()
expect(screen.getByText(MORE_NARRATION)).toBeInTheDocument()
expect(screen.getByText(ANSWER)).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Toggle turn details' })).toHaveAttribute(
'aria-expanded',
'true'
)
})
// Nothing closes the disclosure when a turn ends; the fold arrives already
// closed. While the turn runs there is no fold at all, so the reader watches
// the work happen.
it('folds nothing while the turn is still running', () => {
const startedAt = Date.now() - 3000
render(
<NativeChatMessageList
session={{ ...session(startedAt), status: 'working' }}
isWorking
workingStartedAt={startedAt}
expandSignal={false}
/>
)
expect(screen.getByText(NARRATION)).toBeInTheDocument()
expect(screen.getByText(MORE_NARRATION)).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Toggle turn details' })).toBeNull()
})
// The #23621 shape: the host folded a mid-turn send into the running turn, so
// the rows after it are still the opener's. One bar under the opener folds
// them all; the steered bubble stays visible and never grows a bar or group.
it.each([
['states each row’s turn', true],
['states no scope', false]
])(
"folds the rows after a mid-turn send behind the opener's bar on a host that %s",
(_host, statesScope) => {
const startedAt = Date.now() - 3000
const base = session(startedAt)
const steer: (typeof base.messages)[number] = {
id: 'mid-b',
role: 'user',
blocks: [{ type: 'text', text: 'Also check the tests.' }],
timestamp: startedAt + 2,
source: 'transcript'
}
const messages = [...base.messages.slice(0, 3), steer, ...base.messages.slice(3)]
render(
<NativeChatMessageList
session={{ ...base, messages }}
journalItems={journalOf(messages, statesScope)}
journalSubmissions={[]}
isWorking={false}
workingStartedAt={null}
settledTurns={new Map([['user-1', { startedAt, workedSeconds: 70 }]])}
expandSignal={false}
/>
)
expect(screen.getByText('Also check the tests.')).toBeInTheDocument()
expect(screen.queryByText(NARRATION)).toBeNull()
expect(screen.queryByText(MORE_NARRATION)).toBeNull()
const toggles = screen.getAllByRole('button', { name: 'Toggle turn details' })
expect(toggles).toHaveLength(1)
fireEvent.click(toggles[0]!)
expect(screen.getByText(NARRATION)).toBeInTheDocument()
expect(screen.getByText(MORE_NARRATION)).toBeInTheDocument()
}
)
// The answer is the LAST prose the agent produced. A turn whose final output
// is a tool run still answers with the prose before it.
it('keeps the last prose row when the turn ends on tool activity', () => {
const startedAt = Date.now() - 3000
const base = session(startedAt)
render(
<NativeChatMessageList
session={{
...base,
messages: [
...base.messages,
{
id: 'work-2',
role: 'assistant',
blocks: [
{
type: 'tool-call',
name: 'shell',
input: { command: 'git push' },
state: 'completed'
},
{ type: 'tool-result', output: 'done' }
],
timestamp: startedAt + 5,
source: 'transcript'
}
]
}}
isWorking={false}
workingStartedAt={startedAt}
expandSignal={false}
/>
)
expect(screen.getByText(ANSWER)).toBeInTheDocument()
expect(screen.queryByText(NARRATION)).toBeNull()
})
})
@@ -1,268 +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 {
AgentJournalItemBody,
AgentJournalRenderItem
} from '../../../../shared/agent-session-journal-types'
import { projectStructuredQuestionMessages } from './structured-agent-question-projection'
import { NativeChatMessageList } from './NativeChatMessageList'
import type { NativeChatLiveSession } from './use-native-chat-live-session'
import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport'
const scrollTo = vi.fn()
let restoreViewport = (): void => {}
beforeAll(() => {
restoreViewport = installNativeChatMessageListTestViewport()
})
afterAll(() => restoreViewport())
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
function item(
itemId: string,
body: AgentJournalItemBody,
sequence: number
): AgentJournalRenderItem {
return { itemId, body, sequence, observedAt: sequence * 1000, revision: 1 }
}
const user = item(
'user',
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Make the change' }] },
1
)
const prose = item(
'prose',
{ kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Updating the files.' }] },
2
)
function diff(patch = '@@ -1 +1 @@\n-before\n+after'): AgentJournalRenderItem {
return item(
'diff',
{
kind: 'diff',
path: 'src/a.ts',
patch: { head: patch, truncated: false, digest: 'fixture', byteLength: patch.length }
},
3
)
}
function session(items: AgentJournalRenderItem[]): NativeChatLiveSession {
return {
messages: projectStructuredQuestionMessages(items),
status: 'ready',
sessionId: 'session',
agent: 'codex',
hasMore: false,
loadingEarlier: false,
olderHistoryGeneration: 0,
loadEarlier: vi.fn(),
readPhase: 'ready'
}
}
function view(items: AgentJournalRenderItem[], structured = true) {
return (
<NativeChatMessageList
session={session(items)}
journalItems={structured ? items : undefined}
isWorking={false}
expandSignal={false}
/>
)
}
describe('turn history presentation', () => {
it('renders canonical pending questions while idle and keeps resolved answers at their row', () => {
const question = item(
'question',
{
kind: 'question',
question: 'Which branch?',
options: [{ id: 'main', label: 'main' }],
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
},
3
)
const { rerender } = render(view([user, prose, question]))
expect(screen.getByText('Awaiting user input:')).toBeInTheDocument()
expect(screen.getByText('Which branch?')).toBeInTheDocument()
expect(screen.queryByText(/request_user_input/)).toBeNull()
const settled = item(
'question',
{
...question.body,
kind: 'question',
question: 'Which branch?',
options: [{ id: 'main', label: 'main' }],
resolution: {
state: 'resolved',
selectedOptionId: 'main',
resolvedBy: 'desktop',
resolvedAt: 4000
}
},
3
)
rerender(view([user, prose, settled]))
expect(screen.queryByText('Awaiting user input:')).toBeNull()
expect(screen.getByText('Asked:')).toBeInTheDocument()
expect(screen.getByText('main')).toBeInTheDocument()
})
it('renders one resolved row when Claude journals both the call and receipt', () => {
const call = item(
'ask-call',
{
kind: 'tool-call',
name: 'AskUserQuestion',
input: { questions: [{ question: 'Which branch?' }] },
state: 'completed',
output: { head: 'main', byteLength: 4, truncated: false, digest: 'answer' }
},
3
)
const question = item(
'question-receipt',
{
kind: 'question',
question: 'Which branch?',
options: [{ id: 'main', label: 'main' }],
resolution: {
state: 'resolved',
selectedOptionId: 'main',
resolvedBy: 'desktop',
resolvedAt: 4000
}
},
4
)
render(view([user, call, question]))
expect(screen.getAllByText('Asked:')).toHaveLength(1)
expect(screen.queryByText(/AskUserQuestion/)).toBeNull()
})
it('groups pending Codex questions then narrows the awaiting count after one answer', () => {
const first = item(
'q1',
{
kind: 'question',
question: 'Which branch?',
options: [{ id: 'main', label: 'main' }],
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
},
3
)
const second = item(
'q2',
{
kind: 'question',
question: 'Proceed?',
options: [],
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
},
4
)
const { rerender } = render(view([user, first, second]))
expect(screen.getByText('2 questions')).toBeInTheDocument()
expect(screen.getAllByText('Awaiting user input:')).toHaveLength(1)
const answered = item(
'q1',
{
kind: 'question',
question: 'Which branch?',
options: [{ id: 'main', label: 'main' }],
resolution: {
state: 'resolved',
selectedOptionId: 'main',
resolvedBy: 'phone',
resolvedAt: 5000
}
},
3
)
rerender(view([user, answered, second]))
expect(screen.queryByText('2 questions')).toBeNull()
expect(screen.getByText('Proceed?')).toBeInTheDocument()
expect(screen.getByText('main')).toBeInTheDocument()
expect(screen.getAllByText('Awaiting user input:')).toHaveLength(1)
})
it('reveals and scrolls to a folded diff card from a collapsed completed turn', () => {
vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(scrollTo)
render(view([user, prose, diff()]))
expect(screen.queryByRole('button', { name: /^Edited .*a\.ts(?:\s|$)/ })).toBeNull()
const header = screen.getByRole('button', { name: /1 changed file/ })
expect(header).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(header)
fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ }))
expect(screen.getByText('Edited')).toBeInTheDocument()
expect(screen.getByText('after')).toBeInTheDocument()
expect(screen.getByText('before')).toBeInTheDocument()
expect(scrollTo).toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: /Edited 1 file/ }))
expect(screen.queryByRole('button', { name: /^Edited .*a\.ts(?:\s|$)/ })).toBeNull()
fireEvent.click(header)
expect(header).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(header)
fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ }))
expect(scrollTo.mock.calls.length).toBeGreaterThanOrEqual(2)
})
it('updates journal revisions and replaces flat approval text with a passive receipt', () => {
const approval = item(
'approval',
{
kind: 'approval',
title: 'Run tests?',
detail: 'pnpm test',
options: [{ id: 'allow', label: 'Allow once' }],
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
},
4
)
const initial = [user, prose, diff(), approval]
const { rerender } = render(view(initial))
expect(screen.queryByText('Run tests?')).toBeNull()
if (approval.body.kind !== 'approval') {
throw new Error('fixture')
}
const resolved = {
...approval,
revision: 2,
body: {
...approval.body,
resolution: {
state: 'resolved' as const,
selectedOptionId: 'allow',
resolvedBy: 'desktop',
resolvedAt: 5000
}
}
}
rerender(view([user, prose, diff('@@ -0,0 +1,2 @@\n+first\n+second'), resolved]))
expect(screen.getByRole('button', { name: /1 changed file \+2/ })).toBeInTheDocument()
expect(screen.getByText('Run tests?')).toBeInTheDocument()
expect(screen.getByText('Allow once')).toBeInTheDocument()
expect(screen.getByText('Answered on desktop')).toBeInTheDocument()
expect(screen.getByText('Resolved').closest('[data-native-chat-receipt]')).not.toBeNull()
expect(screen.queryByText('resolved')).toBeNull()
})
it('keeps rollups turn-local and leaves legacy message lists unchanged', () => {
const secondUser = item(
'user-two',
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Again' }] },
5
)
const secondDiff = { ...diff(), itemId: 'second-diff', sequence: 6, observedAt: 6000 }
const items = [user, prose, diff(), secondUser, secondDiff]
const { rerender } = render(view(items))
expect(screen.getAllByRole('button', { name: /1 changed file/ })).toHaveLength(2)
rerender(view(items, false))
expect(screen.queryByRole('button', { name: /changed file/ })).toBeNull()
})
})
File diff suppressed because it is too large Load Diff
@@ -1,362 +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 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'
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]])
try {
const { rerender } = render(
<NativeChatMessageList
session={session}
isWorking
workingStartedAt={1_000}
settledTurns={unknownTurns}
expandSignal={false}
/>
)
now.mockReturnValue(60_000)
rerender(
<NativeChatMessageList
session={session}
isWorking={false}
workingStartedAt={null}
settledTurns={unknownTurns}
expandSignal={false}
/>
)
expect(screen.queryByText(/Worked for/)).not.toBeInTheDocument()
} finally {
now.mockRestore()
}
})
it('renders a host-settled duration without ever clocking the turn locally', () => {
// A local clock nowhere near the host's: the value must still be the host's.
const now = vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000)
try {
const { rerender } = render(
<NativeChatMessageList
session={session}
isWorking={false}
workingStartedAt={null}
settledTurns={settledTurns}
expandSignal={false}
/>
)
expect(screen.getByText('Worked for 3m 17s')).toBeInTheDocument()
now.mockReturnValue(1_700_000_099_000)
rerender(
<NativeChatMessageList
session={{ ...session }}
isWorking={false}
workingStartedAt={null}
settledTurns={settledTurns}
expandSignal={false}
/>
)
expect(screen.getByText('Worked for 3m 17s')).toBeInTheDocument()
} finally {
now.mockRestore()
}
})
})
describe('a live turn whose record and opening message are not loaded', () => {
let restoreViewport = (): void => {}
beforeAll(() => {
restoreViewport = installNativeChatMessageListTestViewport()
})
afterAll(() => restoreViewport())
afterEach(cleanup)
const TURN_RECORD = 'turn-record-1'
const IN_TURN: AgentJournalTurnScope = { kind: 'turn', turnItemId: TURN_RECORD }
/** The newest rows of the turn: everything from its 300th row on. */
const loaded: AgentJournalRenderItem[] = [300, 301, 302].map((sequence) => ({
itemId: `row-${sequence}`,
revision: 0,
sequence,
observedAt: sequence,
body: {
kind: 'message',
role: 'assistant',
blocks: [{ type: 'text', text: `Step ${sequence}` }]
},
turnScope: IN_TURN
}))
const patch = '@@ -1 +1 @@\n-before\n+after'
/** An edit among the loaded rows; the turn's earlier edits are above them. */
const edit: AgentJournalRenderItem = {
itemId: 'row-303',
revision: 0,
sequence: 303,
observedAt: 303,
body: {
kind: 'diff',
path: 'src/a.ts',
patch: { head: patch, truncated: false, digest: 'fixture', byteLength: patch.length }
},
turnScope: IN_TURN
}
const running: AgentSessionLatestTurn = {
itemId: TURN_RECORD,
observedAt: 1,
turn: { turnId: 'turn-1', state: 'running', startedAt: 1_000, userItemId: 'user-1' }
}
function list(
latestTurn: AgentSessionLatestTurn | null | undefined,
items: AgentJournalRenderItem[] = loaded
): React.JSX.Element {
return (
<NativeChatMessageList
session={{
messages: projectStructuredAgentSessionMessages(items, [], [], { rejectedInPlace: true }),
status: 'ready',
sessionId: 'session-1',
agent: 'codex',
hasMore: true,
loadingEarlier: false,
olderHistoryGeneration: 0,
loadEarlier: vi.fn(),
readPhase: 'ready'
}}
journalItems={items}
journalSubmissions={[]}
journalLatestTurn={latestTurn}
isWorking
workingStartedAt={1_000}
expandSignal={false}
/>
)
}
it('draws its Working bar with the running clock', () => {
const now = vi.spyOn(Date, 'now').mockReturnValue(64_000)
try {
render(list(running))
expect(screen.getByText('Step 302')).toBeInTheDocument()
expect(screen.getByText(/Working for 1m 3s/)).toBeInTheDocument()
} finally {
now.mockRestore()
}
})
it('totals no edits, since only the end of the turn is loaded', () => {
render(list(running, [...loaded, edit]))
expect(screen.getByText(/Working for/)).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /changed file/ })).toBeNull()
})
it('had no bar to draw from the loaded rows alone', () => {
const now = vi.spyOn(Date, 'now').mockReturnValue(64_000)
try {
render(list(undefined))
expect(screen.getByText('Step 302')).toBeInTheDocument()
expect(screen.queryByText(/Working for/)).not.toBeInTheDocument()
} finally {
now.mockRestore()
}
})
})
describe('a thought joining a work run', () => {
let restoreViewport = (): void => {}
beforeAll(() => {
restoreViewport = installNativeChatMessageListTestViewport()
})
afterAll(() => restoreViewport())
afterEach(cleanup)
const STARTED = 1_000
const prompt: NativeChatMessage = {
id: 'user-1',
role: 'user',
blocks: [{ type: 'text', text: 'Start the task' }],
timestamp: STARTED - 500,
source: 'transcript'
}
const command: NativeChatMessage = {
id: 'tool-1',
role: 'assistant',
blocks: [
{ type: 'tool-call', name: 'Bash', input: { command: 'ls logs' }, state: 'completed' }
],
timestamp: STARTED,
source: 'transcript'
}
function reasoning(state: 'running' | 'completed'): NativeChatMessage {
return {
id: 'r-1',
role: 'reasoning',
blocks: [{ type: 'text', text: 'Weighing two approaches' }],
timestamp: STARTED + 50,
source: 'transcript',
state,
...(state === 'completed' ? { completedAt: STARTED + 12_000 } : {})
}
}
/** The journal that says the turn runs and what its newest content is. */
function journal(rows: readonly NativeChatMessage[]): AgentJournalRenderItem[] {
return [
{
itemId: prompt.id,
revision: 1,
sequence: 1,
observedAt: 1,
body: { kind: 'message', role: 'user', blocks: prompt.blocks }
},
{
itemId: 'turn-1',
revision: 1,
sequence: 2,
observedAt: 2,
body: { kind: 'turn', turnId: 'turn-1', state: 'running', userItemId: prompt.id }
},
...rows.map((row, index) => ({
itemId: row.id,
revision: 1,
sequence: index + 3,
observedAt: index + 3,
body: {
kind: 'message' as const,
role: row.role,
blocks: row.blocks,
...(row.state ? { state: row.state } : {})
}
}))
]
}
function list(
rows: readonly NativeChatMessage[],
props: Partial<React.ComponentProps<typeof NativeChatMessageList>> = {}
): React.JSX.Element {
const session: NativeChatLiveSession = {
messages: [prompt, ...rows],
status: 'working',
sessionId: 'session-1',
agent: 'claude',
hasMore: false,
loadingEarlier: false,
olderHistoryGeneration: 0,
loadEarlier: vi.fn(),
readPhase: 'ready'
}
return (
<NativeChatMessageList
session={session}
journalItems={journal(rows)}
isWorking
expandSignal={false}
{...props}
/>
)
}
const runHeader = (): HTMLElement =>
document.querySelector<HTMLElement>('[data-native-chat-tool-run-state]')!
it('joins collapsed when the reader never opened it', () => {
render(list([command, reasoning('completed')]))
expect(runHeader()).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByText('Weighing two approaches')).toBeNull()
})
// The run never opens for a thought; the thought keeps its own open state inside it.
it('keeps a thought the reader opened live open inside its collapsed run', () => {
const { rerender } = render(list([command, reasoning('running')]))
fireEvent.click(screen.getByRole('button', { name: 'Thinking' }))
expect(screen.getByText('Weighing two approaches')).toBeInTheDocument()
rerender(list([command, reasoning('completed')]))
expect(runHeader()).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByText('Weighing two approaches')).toBeNull()
fireEvent.click(runHeader())
expect(screen.getByRole('button', { name: /Thought for/ })).toHaveAttribute(
'aria-expanded',
'true'
)
expect(screen.getByText('Weighing two approaches')).toBeInTheDocument()
})
// While the live line is not showing it (a Stop in flight, a prompt the reader owes), the open
// thought sits in the run rather than flashing as its own row until the turn ends.
it.each([
['a Stop is in flight', { stopping: true }],
['a prompt waits on the reader', { awaitingInput: 'unshown' as const }]
])('keeps an open thought inside the run while %s', (_, props) => {
const second: NativeChatMessage = {
...command,
id: 'tool-2',
blocks: [{ type: 'tool-call', name: 'Bash', input: { command: 'ls' }, state: 'completed' }],
timestamp: STARTED + 100
}
const open = { ...reasoning('running'), id: 'r-2', timestamp: STARTED + 200 }
render(list([command, reasoning('completed'), second, open], props))
expect(screen.queryByRole('button', { name: /Reasoning|Thought/ })).toBeNull()
fireEvent.click(runHeader())
expect(screen.getAllByRole('button', { name: /Reasoning|Thought/ })).toHaveLength(2)
})
})
@@ -1,109 +1,628 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, fireEvent, render, screen, act } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { NativeChatBlock } from '../../../../shared/native-chat-types'
import { NativeChatToolRun } from './NativeChatToolRun'
import { nativeChatAppearanceStyle } from './native-chat-appearance-style'
import { openToolRunMembers } from './native-chat-tool-run-members-test-support'
afterEach(cleanup)
import {
NativeChatDisclosureContext,
useNativeChatDisclosures
} from './native-chat-disclosure-store'
const blocks: NativeChatBlock[] = [
{ type: 'tool-call', name: 'Bash', input: { command: 'test' }, state: 'failed' },
{ type: 'tool-result', output: 'exit 1', isError: true },
{ type: 'tool-call', name: 'Read', input: { file_path: 'a.ts' }, state: 'completed' },
{ type: 'tool-call', name: 'Write', input: { file_path: 'a.ts' }, state: 'completed' },
{ type: 'tool-call', name: 'Grep', input: { pattern: 'todo' }, state: 'completed' },
{ type: 'tool-call', name: 'Task', input: { description: 'Review' }, state: 'completed' }
]
import { revealNativeChatToolRunMember } from './NativeChatToolRunMembers'
describe('tool-run summary in a matching chat', () => {
it.each([false, true])('caps the summary at two wrapped lines, live=%s', (live) => {
const style = nativeChatAppearanceStyle({
terminalFontFamily: 'Menlo',
nativeChatAppearance: { matchTerminalInterface: true }
})
const { container } = render(
<div className="native-chat-appearance" style={style}>
<div className="max-w-(--chat-content-max-width)">
<NativeChatToolRun blocks={blocks} expandSignal={false} activeTurnIsWorking={live} />
import { i18n } from '@/i18n/i18n'
{
const blocks: NativeChatBlock[] = [
{ type: 'tool-call', name: 'Bash', input: { command: 'test' }, state: 'failed' },
{ type: 'tool-result', output: 'exit 1', isError: true },
{ type: 'tool-call', name: 'Read', input: { file_path: 'a.ts' }, state: 'completed' },
{ type: 'tool-call', name: 'Write', input: { file_path: 'a.ts' }, state: 'completed' },
{ type: 'tool-call', name: 'Grep', input: { pattern: 'todo' }, state: 'completed' },
{ type: 'tool-call', name: 'Task', input: { description: 'Review' }, state: 'completed' }
]
describe('tool-run summary in a matching chat', () => {
afterEach(cleanup)
it.each([false, true])('caps the summary at two wrapped lines, live=%s', (live) => {
const style = nativeChatAppearanceStyle({
terminalFontFamily: 'Menlo',
nativeChatAppearance: { matchTerminalInterface: true }
})
const { container } = render(
<div className="native-chat-appearance" style={style}>
<div className="max-w-(--chat-content-max-width)">
<NativeChatToolRun blocks={blocks} expandSignal={false} activeTurnIsWorking={live} />
</div>
</div>
</div>
)
expect(style['--chat-content-max-width']).toBe('46rem')
expect(style['--chat-font-family']).toContain('Menlo')
const summary = container.querySelector('span.native-chat-message-text')
expect(summary).toHaveTextContent(live ? 'running 1 agent' : 'ran 1 agent')
expect(summary).toHaveClass('min-w-0', 'line-clamp-2', 'whitespace-normal', 'break-words')
expect(summary).not.toHaveClass('truncate', 'whitespace-nowrap', 'font-mono')
const failure = container.querySelector('[aria-label="Failed tool calls: 1"]')
expect(failure).toHaveTextContent('1 failed')
expect(failure).toHaveClass('shrink-0')
expect(failure?.parentElement).toHaveClass('flex', 'h-[1lh]', 'items-center')
})
it('keeps a long command available in the expanded detail', () => {
const command = `printf ${'x'.repeat(5000)}`
const style = nativeChatAppearanceStyle({
terminalFontFamily: 'Menlo',
nativeChatAppearance: { matchTerminalInterface: true }
)
expect(style['--chat-content-max-width']).toBe('46rem')
expect(style['--chat-font-family']).toContain('Menlo')
const summary = container.querySelector('span.native-chat-message-text')
expect(summary).toHaveTextContent(live ? 'running 1 agent' : 'ran 1 agent')
expect(summary).toHaveClass('min-w-0', 'line-clamp-2', 'whitespace-normal', 'break-words')
expect(summary).not.toHaveClass('truncate', 'whitespace-nowrap', 'font-mono')
const failure = container.querySelector('[aria-label="Failed tool calls: 1"]')
expect(failure).toHaveTextContent('1 failed')
expect(failure).toHaveClass('shrink-0')
expect(failure?.parentElement).toHaveClass('flex', 'h-[1lh]', 'items-center')
})
const { container } = render(
<div className="native-chat-appearance" style={style}>
it('keeps a long command available in the expanded detail', () => {
const command = `printf ${'x'.repeat(5000)}`
const style = nativeChatAppearanceStyle({
terminalFontFamily: 'Menlo',
nativeChatAppearance: { matchTerminalInterface: true }
})
const { container } = render(
<div className="native-chat-appearance" style={style}>
<NativeChatToolRun
blocks={[{ type: 'tool-call', name: 'shell', input: { command }, state: 'completed' }]}
expandSignal={false}
/>
</div>
)
const summary = container.querySelector('span.native-chat-message-text')
expect(summary).toHaveClass('line-clamp-2', 'break-words')
expect(summary?.textContent).toContain('printf')
expect(summary?.textContent?.length).toBeLessThan(command.length)
expect(container.querySelector('pre')).toBeNull()
fireEvent.click(screen.getByRole('button'))
openToolRunMembers()
expect(container.querySelector('pre')).toHaveTextContent(command)
})
it.each([
['wrapped', `QA inert command for display only: ${'x'.repeat(400)}`],
['single-line', 'pnpm test']
])('pins the marks to the first line of a %s summary', (_summaryLength, command) => {
const { container } = render(
<NativeChatToolRun
blocks={[{ type: 'tool-call', name: 'shell', input: { command }, state: 'completed' }]}
blocks={[
{ type: 'tool-call', name: 'shell', input: { command }, state: 'completed' },
{ type: 'tool-result', output: 'done' }
]}
expandSignal={false}
activeTurnIsWorking={false}
/>
</div>
)
)
const summary = container.querySelector('span.native-chat-message-text')
expect(summary).toHaveClass('line-clamp-2', 'break-words')
expect(summary?.textContent).toContain('printf')
expect(summary?.textContent?.length).toBeLessThan(command.length)
expect(container.querySelector('pre')).toBeNull()
const header = screen.getByRole('button')
// Top-aligned in the summary's own type, so `1lh` is one summary line.
expect(header).toHaveClass('items-start', 'text-sm', 'leading-relaxed')
expect(header).toHaveClass('native-chat-message-text')
expect(header).not.toHaveClass('items-center')
const summary = container.querySelector('span.native-chat-message-text')
expect(summary?.parentElement).toBe(header)
expect(summary).toHaveClass('min-w-0', 'line-clamp-2')
fireEvent.click(screen.getByRole('button'))
openToolRunMembers()
expect(container.querySelector('pre')).toHaveTextContent(command)
const slots = Array.from(header.children).filter((child) => child !== summary)
expect(slots.length).toBeGreaterThanOrEqual(2)
for (const slot of slots) {
expect(slot).toHaveClass('flex', 'h-[1lh]', 'items-center')
}
// The icon leads, so a wrapped second line starts under the text, not under it.
expect(header.firstElementChild?.querySelector('svg')).toBeInTheDocument()
expect(header.children[1]).toBe(summary)
expect(header.querySelector('.lucide-check')?.parentElement).toHaveClass('h-[1lh]')
// The header carries no caret: the whole line is the toggle.
expect(header.querySelector('.lucide-chevron-right')).toBeNull()
})
})
}
it.each([
['wrapped', `QA inert command for display only: ${'x'.repeat(400)}`],
['single-line', 'pnpm test']
])('pins the marks to the first line of a %s summary', (_summaryLength, command) => {
const { container } = render(
<NativeChatToolRun
blocks={[
{ type: 'tool-call', name: 'shell', input: { command }, state: 'completed' },
{ type: 'tool-result', output: 'done' }
]}
expandSignal={false}
activeTurnIsWorking={false}
/>
{
const QUESTION = 'What would you like me to do next in this repo?'
const ASK_INPUT = { questions: [{ question: QUESTION }] }
function askBlocks(state: 'running' | 'completed'): NativeChatBlock[] {
return [{ type: 'tool-call', name: 'AskUserQuestion', input: ASK_INPUT, state }]
}
/** Lays every element out wider than its box, as a long question is on its line. */
function clipEveryLine(): () => void {
const originals = ['scrollWidth', 'clientWidth'].map(
(name) => [name, Object.getOwnPropertyDescriptor(HTMLElement.prototype, name)] as const
)
const header = screen.getByRole('button')
// Top-aligned in the summary's own type, so `1lh` is one summary line.
expect(header).toHaveClass('items-start', 'text-sm', 'leading-relaxed')
expect(header).toHaveClass('native-chat-message-text')
expect(header).not.toHaveClass('items-center')
const summary = container.querySelector('span.native-chat-message-text')
expect(summary?.parentElement).toBe(header)
expect(summary).toHaveClass('min-w-0', 'line-clamp-2')
const slots = Array.from(header.children).filter((child) => child !== summary)
expect(slots.length).toBeGreaterThanOrEqual(2)
for (const slot of slots) {
expect(slot).toHaveClass('flex', 'h-[1lh]', 'items-center')
Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {
configurable: true,
get: () => 400
})
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
configurable: true,
get: () => 100
})
return () => {
for (const [name, original] of originals) {
if (original) {
Object.defineProperty(HTMLElement.prototype, name, original)
} else {
Reflect.deleteProperty(HTMLElement.prototype, name)
}
}
}
// The icon leads, so a wrapped second line starts under the text, not under it.
expect(header.firstElementChild?.querySelector('svg')).toBeInTheDocument()
expect(header.children[1]).toBe(summary)
expect(header.querySelector('.lucide-check')?.parentElement).toHaveClass('h-[1lh]')
// The header carries no caret: the whole line is the toggle.
expect(header.querySelector('.lucide-chevron-right')).toBeNull()
}
function DisclosureHarness({ mounted }: { mounted: boolean }): React.JSX.Element {
const disclosures = useNativeChatDisclosures()
return (
<NativeChatDisclosureContext.Provider value={disclosures}>
{mounted ? (
<NativeChatToolRun
blocks={askBlocks('completed')}
expandSignal
activeTurnIsWorking={false}
disclosureId="message-1"
/>
) : null}
</NativeChatDisclosureContext.Provider>
)
}
describe('NativeChatToolRun awaiting-input row', () => {
afterEach(cleanup)
it('does not revive stale tool state after a turn stops', () => {
render(
<NativeChatToolRun blocks={askBlocks('running')} expandSignal activeTurnIsWorking={false} />
)
expect(screen.queryByText('Awaiting user input:')).toBeNull()
expect(screen.getByText('Asked:')).toBeInTheDocument()
})
it('keeps a pending question visible alongside another active tool', () => {
render(
<NativeChatToolRun
blocks={[
...askBlocks('running'),
{ type: 'tool-call', name: 'Read', input: { file_path: 'a.ts' }, state: 'running' }
]}
expandSignal
activeTurnIsWorking
/>
)
expect(screen.getByText('Awaiting user input:')).toBeInTheDocument()
expect(screen.getByText('Reading 1 file')).toBeInTheDocument()
expect(screen.getByText('Read a.ts')).toBeInTheDocument()
})
it('preserves errors from failed question calls', () => {
render(
<NativeChatToolRun
blocks={[
{ type: 'tool-call', name: 'AskUserQuestion', input: ASK_INPUT, state: 'failed' },
{ type: 'tool-result', output: 'Question rejected', isError: true }
]}
expandSignal
activeTurnIsWorking={false}
/>
)
openToolRunMembers()
expect(screen.queryByText('Awaiting user input:')).toBeNull()
expect(screen.queryByText('Asked:')).toBeNull()
expect(screen.getAllByText('Question rejected').length).toBeGreaterThan(0)
})
it('replaces a running ask call with the awaiting row', () => {
const { container } = render(
<NativeChatToolRun blocks={askBlocks('running')} expandSignal activeTurnIsWorking />
)
expect(screen.getByText('Awaiting user input:')).toHaveClass(
'animate-pulse',
'motion-reduce:animate-none'
)
expect(screen.getByText(QUESTION)).toBeInTheDocument()
expect(container.querySelector('.lucide-message-square-more')).toBeInTheDocument()
// The raw call and its payload are exactly what this row exists to replace.
expect(screen.queryByText(/Running AskUserQuestion/)).toBeNull()
expect(screen.queryByText(/AskUserQuestion/)).toBeNull()
})
it('reports a settled ask without the pulse or a tool-count header', () => {
const { container } = render(
<NativeChatToolRun
blocks={askBlocks('completed')}
expandSignal
activeTurnIsWorking={false}
/>
)
expect(screen.getByText('Asked:')).not.toHaveClass('animate-pulse')
expect(screen.getByText(QUESTION)).toBeInTheDocument()
// A run that is only the ask has no work left to head, so it draws no header.
expect(container.querySelector('[data-native-chat-tool-run-state]')).toBeNull()
})
it('leaves a question that fits on its line as plain text', () => {
const { container } = render(
<NativeChatToolRun
blocks={askBlocks('completed')}
expandSignal
activeTurnIsWorking={false}
/>
)
expect(screen.getByText(QUESTION)).toHaveClass('truncate')
expect(container.querySelector('button')).toBeNull()
})
it('opens a clipped question below the toggle and folds it back', () => {
const restore = clipEveryLine()
try {
render(
<NativeChatToolRun
blocks={askBlocks('completed')}
expandSignal
activeTurnIsWorking={false}
/>
)
const toggle = screen.getByRole('button', { name: /Asked:/ })
expect(toggle).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(toggle)
expect(toggle).toHaveAttribute('aria-expanded', 'true')
const full = screen.getByText(QUESTION)
expect(full).not.toHaveClass('truncate')
// Outside the button, so it selects like prose and a click in it keeps it open.
expect(full.closest('button')).toBeNull()
fireEvent.click(full)
expect(toggle).toHaveAttribute('aria-expanded', 'true')
fireEvent.click(toggle)
expect(toggle).toHaveAttribute('aria-expanded', 'false')
expect(screen.getByText(QUESTION)).toHaveClass('truncate')
} finally {
restore()
}
})
it('keeps an opened question open when the windowed row remounts', () => {
const restore = clipEveryLine()
try {
const { rerender } = render(<DisclosureHarness mounted />)
fireEvent.click(screen.getByRole('button', { name: /Asked:/ }))
rerender(<DisclosureHarness mounted={false} />)
expect(screen.queryByText(QUESTION)).toBeNull()
rerender(<DisclosureHarness mounted />)
const toggle = screen.getByRole('button', { name: /Asked:/ })
expect(toggle).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText(QUESTION).closest('button')).toBeNull()
// Folding it keeps the same control, so keyboard focus is not dropped.
toggle.focus()
fireEvent.click(toggle)
expect(document.activeElement).toBe(toggle)
expect(toggle).toHaveAttribute('aria-expanded', 'false')
} finally {
restore()
}
})
it('offers no expansion when the row names only a question count', () => {
const restore = clipEveryLine()
try {
const input = { questions: [{ question: 'First?' }, { question: 'Second?' }] }
render(
<NativeChatToolRun
blocks={[{ type: 'tool-call', name: 'AskUserQuestion', input, state: 'completed' }]}
expandSignal
activeTurnIsWorking={false}
/>
)
expect(screen.getByText('2 questions')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Asked:/ })).toBeNull()
} finally {
restore()
}
})
it('counts only the work that ran in the header beside the ask', () => {
const blocks: NativeChatBlock[] = [
{ type: 'tool-call', name: 'Read', input: { file_path: 'a.ts' }, state: 'completed' },
{ type: 'tool-call', name: 'AskUserQuestion', input: ASK_INPUT, state: 'running' }
]
render(<NativeChatToolRun blocks={blocks} expandSignal activeTurnIsWorking />)
expect(screen.getByText('Awaiting user input:')).toBeInTheDocument()
// One call ran; being asked a question is not work to summarize. The agent
// is blocked on the reader, so the run reads settled, not in progress.
expect(screen.getByText('Read 1 file')).toBeInTheDocument()
expect(screen.queryByText('Reading 1 file')).toBeNull()
})
it('draws the row from the tool name when the payload names no question', () => {
render(
<NativeChatToolRun
blocks={[{ type: 'tool-call', name: 'request_user_input', input: {}, state: 'running' }]}
expandSignal
activeTurnIsWorking
/>
)
expect(screen.getByText('Awaiting user input')).toBeInTheDocument()
expect(screen.queryByText(/request_user_input/)).toBeNull()
})
})
})
}
{
const VIEWPORT = 200
function calls(count: number): NativeChatBlock[] {
return Array.from({ length: count }, (_, index) => ({
type: 'tool-call' as const,
name: 'Read',
callId: `call-${index}`,
input: `{"file_path":"src/file-${index}.ts"}`
}))
}
/** happy-dom lays nothing out, so the box's geometry is whatever the test says it is. */
function layOut(contentHeight: { current: number }): void {
vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(
() => contentHeight.current
)
vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(VIEWPORT)
}
function membersBox(container: HTMLElement): HTMLElement {
const box = container.querySelector<HTMLElement>('[data-native-chat-tool-run-members]')
if (!box) {
throw new Error('members box did not render')
}
return box
}
/** A live run inside the transcript's disclosure store, which is what remembers its box. */
function Transcript({
mounted = true,
count = 3
}: {
mounted?: boolean
count?: number
}): React.JSX.Element {
const disclosures = useNativeChatDisclosures()
return (
<NativeChatDisclosureContext.Provider value={disclosures}>
{mounted ? (
<NativeChatToolRun
blocks={calls(count)}
expandSignal
activeTurnIsWorking
disclosureId="message-1"
/>
) : null}
</NativeChatDisclosureContext.Provider>
)
}
describe('NativeChatToolRun members box', () => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('keeps a live run on its newest call until the reader scrolls away', () => {
const contentHeight = { current: 600 }
layOut(contentHeight)
const run = (count: number): React.JSX.Element => (
<NativeChatToolRun blocks={calls(count)} expandSignal activeTurnIsWorking />
)
const { container, rerender } = render(run(3))
const box = membersBox(container)
expect(box.scrollTop).toBe(600)
contentHeight.current = 900
rerender(run(4))
expect(box.scrollTop).toBe(900)
box.scrollTop = 100
fireEvent.scroll(box)
contentHeight.current = 1200
rerender(run(5))
expect(box.scrollTop).toBe(100)
box.scrollTop = 1200 - VIEWPORT
fireEvent.scroll(box)
contentHeight.current = 1500
rerender(run(6))
expect(box.scrollTop).toBe(1500)
})
it('comes back where the reader left it after windowing unmounts the row', () => {
const contentHeight = { current: 900 }
layOut(contentHeight)
const { container, rerender } = render(<Transcript mounted count={3} />)
const box = membersBox(container)
box.scrollTop = 100
fireEvent.scroll(box)
rerender(<Transcript mounted={false} count={3} />)
contentHeight.current = 1200
rerender(<Transcript mounted count={4} />)
// Neither back at the newest call nor following again: both were the reader's choice.
expect(membersBox(container).scrollTop).toBe(100)
})
it('forgets its place when the reader closes it', () => {
layOut({ current: 900 })
const { container } = render(<Transcript />)
const box = membersBox(container)
box.scrollTop = 100
fireEvent.scroll(box)
const header = container.querySelector<HTMLElement>('[data-native-chat-tool-run-state]')!
fireEvent.click(header)
fireEvent.click(header)
// A live run the reader reopens shows what it is doing now.
expect(membersBox(container).scrollTop).toBe(900)
})
it('opens a settled run at its first call', () => {
layOut({ current: 600 })
const { container } = render(
<NativeChatToolRun blocks={calls(3)} expandSignal activeTurnIsWorking={false} />
)
expect(membersBox(container).scrollTop).toBe(0)
})
it('brings a revealed member to the top of the box and reports the box', () => {
layOut({ current: 600 })
const { container } = render(
<NativeChatToolRun blocks={calls(3)} expandSignal activeTurnIsWorking={false} />
)
const box = membersBox(container)
const member = box.querySelectorAll('button')[2]!
vi.spyOn(box, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 40, 300, VIEWPORT))
vi.spyOn(member, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 340, 300, 26))
expect(revealNativeChatToolRunMember(member)).toBe(box)
expect(box.scrollTop).toBe(300)
})
})
describe('NativeChatToolRun asides', () => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('draws the rows it is handed among its calls, in order', () => {
const blocks = calls(2)
const { container } = render(
<NativeChatToolRun
blocks={blocks}
asides={{
before: new Map([[blocks[1]!, [<p key="between">between</p>]]]),
after: [<p key="last">last</p>]
}}
expandSignal
activeTurnIsWorking={false}
/>
)
const rows = [...membersBox(container).querySelectorAll('button, p')].map(
(row) => row.textContent ?? ''
)
expect(rows).toHaveLength(4)
expect(rows[0]).toContain('file-0.ts')
expect(rows[1]).toBe('between')
expect(rows[2]).toContain('file-1.ts')
expect(rows[3]).toBe('last')
})
})
}
{
function runHeader(container: HTMLElement): HTMLElement {
const header = container.querySelector('button')
if (!header) {
throw new Error('run header did not render')
}
return header
}
describe('tool run sentences', () => {
afterEach(async () => {
cleanup()
await i18n.changeLanguage('en')
})
it.each(['Task', 'Agent'])('separates failure counts in command and %s runs', (name) => {
const blocks: NativeChatBlock[] = [
{ type: 'tool-call', name: 'Bash', input: { command: 'false' }, state: 'failed' },
{ type: 'tool-result', output: 'exit 1', isError: true },
{
type: 'tool-call',
name,
input: { description: 'explored settings search entries' },
state: 'completed'
}
]
const { container } = render(<NativeChatToolRun blocks={blocks} expandSignal={false} />)
const header = runHeader(container)
expect(header).toHaveTextContent('Ran 1 command and ran 1 agent · 1 failed')
expect(header).toHaveAccessibleName(/Failed tool calls: 1/)
expect(header).not.toHaveAccessibleName(/·/)
fireEvent.click(header)
expect(screen.getByText('Subagent')).toBeInTheDocument()
expect(screen.getByTitle('explored settings search entries')).toBeInTheDocument()
})
it('uses the agent glyph for a lone Agent transcript call', () => {
const { container } = render(
<NativeChatToolRun
blocks={[
{
type: 'tool-call',
name: 'Agent',
input: { description: 'inspect settings' },
state: 'completed'
}
]}
expandSignal
/>
)
expect(runHeader(container)).toHaveTextContent('Ran 1 agent')
expect(container.querySelectorAll('button .lucide-bot')).toHaveLength(2)
})
it('relabels an already-mounted run and its rows when the UI language changes', async () => {
const blocks: NativeChatBlock[] = [
{ type: 'tool-call', name: 'Bash', input: { command: 'false' }, state: 'failed' },
{ type: 'tool-result', output: 'exit 1', isError: true },
{
type: 'tool-call',
name: 'Agent',
input: { description: 'explore settings' },
state: 'completed'
}
]
const { container } = render(
<NativeChatToolRun
blocks={blocks}
backgroundTasks={[
{
type: 'background-task',
taskId: 'workflow-1',
kind: 'workflow',
label: 'task',
state: 'blocked',
outputFile: '/tmp/result.txt'
}
]}
expandSignal
/>
)
const header = runHeader(container)
expect(header).toHaveTextContent('Ran 1 command and ran 1 agent · 1 failed')
expect(screen.getByText('Subagent')).toBeInTheDocument()
expect(screen.getByText('Background workflow')).toBeInTheDocument()
expect(screen.getByText('Output: /tmp/result.txt')).toBeInTheDocument()
await act(async () => {
await i18n.changeLanguage('es')
})
expect(header).toHaveTextContent('Se ejecutó 1 comando y se ejecutó 1 agente · 1 fallidas')
expect(header).toHaveAccessibleName(/Llamadas a herramientas fallidas: 1/)
expect(screen.getByText('Subagente')).toBeInTheDocument()
expect(screen.getByText('Flujo de trabajo en segundo plano')).toBeInTheDocument()
expect(screen.getByText('Salida: /tmp/result.txt')).toBeInTheDocument()
expect(screen.getByText('bloqueado · con error')).toBeInTheDocument()
})
})
}
@@ -1,239 +0,0 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import type { NativeChatBlock } from '../../../../shared/native-chat-types'
import { NativeChatToolRun } from './NativeChatToolRun'
import {
NativeChatDisclosureContext,
useNativeChatDisclosures
} from './native-chat-disclosure-store'
import { openToolRunMembers } from './native-chat-tool-run-members-test-support'
afterEach(cleanup)
const QUESTION = 'What would you like me to do next in this repo?'
const ASK_INPUT = { questions: [{ question: QUESTION }] }
function askBlocks(state: 'running' | 'completed'): NativeChatBlock[] {
return [{ type: 'tool-call', name: 'AskUserQuestion', input: ASK_INPUT, state }]
}
/** Lays every element out wider than its box, as a long question is on its line. */
function clipEveryLine(): () => void {
const originals = ['scrollWidth', 'clientWidth'].map(
(name) => [name, Object.getOwnPropertyDescriptor(HTMLElement.prototype, name)] as const
)
Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {
configurable: true,
get: () => 400
})
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
configurable: true,
get: () => 100
})
return () => {
for (const [name, original] of originals) {
if (original) {
Object.defineProperty(HTMLElement.prototype, name, original)
} else {
Reflect.deleteProperty(HTMLElement.prototype, name)
}
}
}
}
function DisclosureHarness({ mounted }: { mounted: boolean }): React.JSX.Element {
const disclosures = useNativeChatDisclosures()
return (
<NativeChatDisclosureContext.Provider value={disclosures}>
{mounted ? (
<NativeChatToolRun
blocks={askBlocks('completed')}
expandSignal
activeTurnIsWorking={false}
disclosureId="message-1"
/>
) : null}
</NativeChatDisclosureContext.Provider>
)
}
describe('NativeChatToolRun awaiting-input row', () => {
it('does not revive stale tool state after a turn stops', () => {
render(
<NativeChatToolRun blocks={askBlocks('running')} expandSignal activeTurnIsWorking={false} />
)
expect(screen.queryByText('Awaiting user input:')).toBeNull()
expect(screen.getByText('Asked:')).toBeInTheDocument()
})
it('keeps a pending question visible alongside another active tool', () => {
render(
<NativeChatToolRun
blocks={[
...askBlocks('running'),
{ type: 'tool-call', name: 'Read', input: { file_path: 'a.ts' }, state: 'running' }
]}
expandSignal
activeTurnIsWorking
/>
)
expect(screen.getByText('Awaiting user input:')).toBeInTheDocument()
expect(screen.getByText('Reading 1 file')).toBeInTheDocument()
expect(screen.getByText('Read a.ts')).toBeInTheDocument()
})
it('preserves errors from failed question calls', () => {
render(
<NativeChatToolRun
blocks={[
{ type: 'tool-call', name: 'AskUserQuestion', input: ASK_INPUT, state: 'failed' },
{ type: 'tool-result', output: 'Question rejected', isError: true }
]}
expandSignal
activeTurnIsWorking={false}
/>
)
openToolRunMembers()
expect(screen.queryByText('Awaiting user input:')).toBeNull()
expect(screen.queryByText('Asked:')).toBeNull()
expect(screen.getAllByText('Question rejected').length).toBeGreaterThan(0)
})
it('replaces a running ask call with the awaiting row', () => {
const { container } = render(
<NativeChatToolRun blocks={askBlocks('running')} expandSignal activeTurnIsWorking />
)
expect(screen.getByText('Awaiting user input:')).toHaveClass(
'animate-pulse',
'motion-reduce:animate-none'
)
expect(screen.getByText(QUESTION)).toBeInTheDocument()
expect(container.querySelector('.lucide-message-square-more')).toBeInTheDocument()
// The raw call and its payload are exactly what this row exists to replace.
expect(screen.queryByText(/Running AskUserQuestion/)).toBeNull()
expect(screen.queryByText(/AskUserQuestion/)).toBeNull()
})
it('reports a settled ask without the pulse or a tool-count header', () => {
const { container } = render(
<NativeChatToolRun blocks={askBlocks('completed')} expandSignal activeTurnIsWorking={false} />
)
expect(screen.getByText('Asked:')).not.toHaveClass('animate-pulse')
expect(screen.getByText(QUESTION)).toBeInTheDocument()
// A run that is only the ask has no work left to head, so it draws no header.
expect(container.querySelector('[data-native-chat-tool-run-state]')).toBeNull()
})
it('leaves a question that fits on its line as plain text', () => {
const { container } = render(
<NativeChatToolRun blocks={askBlocks('completed')} expandSignal activeTurnIsWorking={false} />
)
expect(screen.getByText(QUESTION)).toHaveClass('truncate')
expect(container.querySelector('button')).toBeNull()
})
it('opens a clipped question below the toggle and folds it back', () => {
const restore = clipEveryLine()
try {
render(
<NativeChatToolRun
blocks={askBlocks('completed')}
expandSignal
activeTurnIsWorking={false}
/>
)
const toggle = screen.getByRole('button', { name: /Asked:/ })
expect(toggle).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(toggle)
expect(toggle).toHaveAttribute('aria-expanded', 'true')
const full = screen.getByText(QUESTION)
expect(full).not.toHaveClass('truncate')
// Outside the button, so it selects like prose and a click in it keeps it open.
expect(full.closest('button')).toBeNull()
fireEvent.click(full)
expect(toggle).toHaveAttribute('aria-expanded', 'true')
fireEvent.click(toggle)
expect(toggle).toHaveAttribute('aria-expanded', 'false')
expect(screen.getByText(QUESTION)).toHaveClass('truncate')
} finally {
restore()
}
})
it('keeps an opened question open when the windowed row remounts', () => {
const restore = clipEveryLine()
try {
const { rerender } = render(<DisclosureHarness mounted />)
fireEvent.click(screen.getByRole('button', { name: /Asked:/ }))
rerender(<DisclosureHarness mounted={false} />)
expect(screen.queryByText(QUESTION)).toBeNull()
rerender(<DisclosureHarness mounted />)
const toggle = screen.getByRole('button', { name: /Asked:/ })
expect(toggle).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText(QUESTION).closest('button')).toBeNull()
// Folding it keeps the same control, so keyboard focus is not dropped.
toggle.focus()
fireEvent.click(toggle)
expect(document.activeElement).toBe(toggle)
expect(toggle).toHaveAttribute('aria-expanded', 'false')
} finally {
restore()
}
})
it('offers no expansion when the row names only a question count', () => {
const restore = clipEveryLine()
try {
const input = { questions: [{ question: 'First?' }, { question: 'Second?' }] }
render(
<NativeChatToolRun
blocks={[{ type: 'tool-call', name: 'AskUserQuestion', input, state: 'completed' }]}
expandSignal
activeTurnIsWorking={false}
/>
)
expect(screen.getByText('2 questions')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Asked:/ })).toBeNull()
} finally {
restore()
}
})
it('counts only the work that ran in the header beside the ask', () => {
const blocks: NativeChatBlock[] = [
{ type: 'tool-call', name: 'Read', input: { file_path: 'a.ts' }, state: 'completed' },
{ type: 'tool-call', name: 'AskUserQuestion', input: ASK_INPUT, state: 'running' }
]
render(<NativeChatToolRun blocks={blocks} expandSignal activeTurnIsWorking />)
expect(screen.getByText('Awaiting user input:')).toBeInTheDocument()
// One call ran; being asked a question is not work to summarize. The agent
// is blocked on the reader, so the run reads settled, not in progress.
expect(screen.getByText('Read 1 file')).toBeInTheDocument()
expect(screen.queryByText('Reading 1 file')).toBeNull()
})
it('draws the row from the tool name when the payload names no question', () => {
render(
<NativeChatToolRun
blocks={[{ type: 'tool-call', name: 'request_user_input', input: {}, state: 'running' }]}
expandSignal
activeTurnIsWorking
/>
)
expect(screen.getByText('Awaiting user input')).toBeInTheDocument()
expect(screen.queryByText(/request_user_input/)).toBeNull()
})
})
@@ -1,173 +0,0 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { NativeChatBlock } from '../../../../shared/native-chat-types'
import {
NativeChatDisclosureContext,
useNativeChatDisclosures
} from './native-chat-disclosure-store'
import { NativeChatToolRun } from './NativeChatToolRun'
import { revealNativeChatToolRunMember } from './NativeChatToolRunMembers'
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
const VIEWPORT = 200
function calls(count: number): NativeChatBlock[] {
return Array.from({ length: count }, (_, index) => ({
type: 'tool-call' as const,
name: 'Read',
callId: `call-${index}`,
input: `{"file_path":"src/file-${index}.ts"}`
}))
}
/** happy-dom lays nothing out, so the box's geometry is whatever the test says it is. */
function layOut(contentHeight: { current: number }): void {
vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(
() => contentHeight.current
)
vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(VIEWPORT)
}
function membersBox(container: HTMLElement): HTMLElement {
const box = container.querySelector<HTMLElement>('[data-native-chat-tool-run-members]')
if (!box) {
throw new Error('members box did not render')
}
return box
}
/** A live run inside the transcript's disclosure store, which is what remembers its box. */
function Transcript({
mounted = true,
count = 3
}: {
mounted?: boolean
count?: number
}): React.JSX.Element {
const disclosures = useNativeChatDisclosures()
return (
<NativeChatDisclosureContext.Provider value={disclosures}>
{mounted ? (
<NativeChatToolRun
blocks={calls(count)}
expandSignal
activeTurnIsWorking
disclosureId="message-1"
/>
) : null}
</NativeChatDisclosureContext.Provider>
)
}
describe('NativeChatToolRun members box', () => {
it('keeps a live run on its newest call until the reader scrolls away', () => {
const contentHeight = { current: 600 }
layOut(contentHeight)
const run = (count: number): React.JSX.Element => (
<NativeChatToolRun blocks={calls(count)} expandSignal activeTurnIsWorking />
)
const { container, rerender } = render(run(3))
const box = membersBox(container)
expect(box.scrollTop).toBe(600)
contentHeight.current = 900
rerender(run(4))
expect(box.scrollTop).toBe(900)
box.scrollTop = 100
fireEvent.scroll(box)
contentHeight.current = 1200
rerender(run(5))
expect(box.scrollTop).toBe(100)
box.scrollTop = 1200 - VIEWPORT
fireEvent.scroll(box)
contentHeight.current = 1500
rerender(run(6))
expect(box.scrollTop).toBe(1500)
})
it('comes back where the reader left it after windowing unmounts the row', () => {
const contentHeight = { current: 900 }
layOut(contentHeight)
const { container, rerender } = render(<Transcript mounted count={3} />)
const box = membersBox(container)
box.scrollTop = 100
fireEvent.scroll(box)
rerender(<Transcript mounted={false} count={3} />)
contentHeight.current = 1200
rerender(<Transcript mounted count={4} />)
// Neither back at the newest call nor following again: both were the reader's choice.
expect(membersBox(container).scrollTop).toBe(100)
})
it('forgets its place when the reader closes it', () => {
layOut({ current: 900 })
const { container } = render(<Transcript />)
const box = membersBox(container)
box.scrollTop = 100
fireEvent.scroll(box)
const header = container.querySelector<HTMLElement>('[data-native-chat-tool-run-state]')!
fireEvent.click(header)
fireEvent.click(header)
// A live run the reader reopens shows what it is doing now.
expect(membersBox(container).scrollTop).toBe(900)
})
it('opens a settled run at its first call', () => {
layOut({ current: 600 })
const { container } = render(
<NativeChatToolRun blocks={calls(3)} expandSignal activeTurnIsWorking={false} />
)
expect(membersBox(container).scrollTop).toBe(0)
})
it('brings a revealed member to the top of the box and reports the box', () => {
layOut({ current: 600 })
const { container } = render(
<NativeChatToolRun blocks={calls(3)} expandSignal activeTurnIsWorking={false} />
)
const box = membersBox(container)
const member = box.querySelectorAll('button')[2]!
vi.spyOn(box, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 40, 300, VIEWPORT))
vi.spyOn(member, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 340, 300, 26))
expect(revealNativeChatToolRunMember(member)).toBe(box)
expect(box.scrollTop).toBe(300)
})
})
describe('NativeChatToolRun asides', () => {
it('draws the rows it is handed among its calls, in order', () => {
const blocks = calls(2)
const { container } = render(
<NativeChatToolRun
blocks={blocks}
asides={{
before: new Map([[blocks[1]!, [<p key="between">between</p>]]]),
after: [<p key="last">last</p>]
}}
expandSignal
activeTurnIsWorking={false}
/>
)
const rows = [...membersBox(container).querySelectorAll('button, p')].map(
(row) => row.textContent ?? ''
)
expect(rows).toHaveLength(4)
expect(rows[0]).toContain('file-0.ts')
expect(rows[1]).toBe('between')
expect(rows[2]).toContain('file-1.ts')
expect(rows[3]).toBe('last')
})
})
@@ -1,106 +0,0 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { i18n } from '@/i18n/i18n'
import type { NativeChatBlock } from '../../../../shared/native-chat-types'
import { NativeChatToolRun } from './NativeChatToolRun'
afterEach(async () => {
cleanup()
await i18n.changeLanguage('en')
})
function runHeader(container: HTMLElement): HTMLElement {
const header = container.querySelector('button')
if (!header) {
throw new Error('run header did not render')
}
return header
}
describe('tool run sentences', () => {
it.each(['Task', 'Agent'])('separates failure counts in command and %s runs', (name) => {
const blocks: NativeChatBlock[] = [
{ type: 'tool-call', name: 'Bash', input: { command: 'false' }, state: 'failed' },
{ type: 'tool-result', output: 'exit 1', isError: true },
{
type: 'tool-call',
name,
input: { description: 'explored settings search entries' },
state: 'completed'
}
]
const { container } = render(<NativeChatToolRun blocks={blocks} expandSignal={false} />)
const header = runHeader(container)
expect(header).toHaveTextContent('Ran 1 command and ran 1 agent · 1 failed')
expect(header).toHaveAccessibleName(/Failed tool calls: 1/)
expect(header).not.toHaveAccessibleName(/·/)
fireEvent.click(header)
expect(screen.getByText('Subagent')).toBeInTheDocument()
expect(screen.getByTitle('explored settings search entries')).toBeInTheDocument()
})
it('uses the agent glyph for a lone Agent transcript call', () => {
const { container } = render(
<NativeChatToolRun
blocks={[
{
type: 'tool-call',
name: 'Agent',
input: { description: 'inspect settings' },
state: 'completed'
}
]}
expandSignal
/>
)
expect(runHeader(container)).toHaveTextContent('Ran 1 agent')
expect(container.querySelectorAll('button .lucide-bot')).toHaveLength(2)
})
it('relabels an already-mounted run and its rows when the UI language changes', async () => {
const blocks: NativeChatBlock[] = [
{ type: 'tool-call', name: 'Bash', input: { command: 'false' }, state: 'failed' },
{ type: 'tool-result', output: 'exit 1', isError: true },
{
type: 'tool-call',
name: 'Agent',
input: { description: 'explore settings' },
state: 'completed'
}
]
const { container } = render(
<NativeChatToolRun
blocks={blocks}
backgroundTasks={[
{
type: 'background-task',
taskId: 'workflow-1',
kind: 'workflow',
label: 'task',
state: 'blocked',
outputFile: '/tmp/result.txt'
}
]}
expandSignal
/>
)
const header = runHeader(container)
expect(header).toHaveTextContent('Ran 1 command and ran 1 agent · 1 failed')
expect(screen.getByText('Subagent')).toBeInTheDocument()
expect(screen.getByText('Background workflow')).toBeInTheDocument()
expect(screen.getByText('Output: /tmp/result.txt')).toBeInTheDocument()
await act(async () => {
await i18n.changeLanguage('es')
})
expect(header).toHaveTextContent('Se ejecutó 1 comando y se ejecutó 1 agente · 1 fallidas')
expect(header).toHaveAccessibleName(/Llamadas a herramientas fallidas: 1/)
expect(screen.getByText('Subagente')).toBeInTheDocument()
expect(screen.getByText('Flujo de trabajo en segundo plano')).toBeInTheDocument()
expect(screen.getByText('Salida: /tmp/result.txt')).toBeInTheDocument()
expect(screen.getByText('bloqueado · con error')).toBeInTheDocument()
})
})
@@ -1,44 +0,0 @@
/**
* Integration cards build their status pill inside the JSX call, which the
* coverage audit does not inspect — it looks at JSX attributes and object
* properties, not conditional expressions passed to a prop. These assertions
* pin the catalog contract so a card that goes back to a bare literal fails here.
*/
import { describe, expect, it } from 'vitest'
import en from './locales/en.json'
function lookup(key: string): string | undefined {
const value = key
.split('.')
.reduce<unknown>(
(node, part) =>
node && typeof node === 'object' ? (node as Record<string, unknown>)[part] : undefined,
en as unknown
)
return typeof value === 'string' ? value : undefined
}
const REQUIRED_KEYS: Record<string, string> = {
// Linear and other task trackers
'auto.components.settings.task.tracker.integration.cards.statusConnected': 'Connected',
'auto.components.settings.task.tracker.integration.cards.statusNotConnected': 'Not connected',
// Jira
'auto.components.settings.jira.integration.card.statusConnected': 'Connected',
'auto.components.settings.jira.integration.card.statusNotConnected': 'Not connected',
// Bitbucket, Azure DevOps, Gitea
'auto.components.settings.token.source.control.integration.cards.statusConnected': 'Connected',
'auto.components.settings.token.source.control.integration.cards.statusConfigured': 'Configured',
'auto.components.settings.token.source.control.integration.cards.statusUnavailable':
'Unavailable',
'auto.components.settings.token.source.control.integration.cards.statusNotConfigured':
'Not configured',
'auto.components.settings.token.source.control.integration.cards.statusOptionalSetup':
'Optional setup',
'auto.components.settings.token.source.control.integration.cards.statusAuthFailed': 'Auth failed'
}
describe('integration card status labels', () => {
it.each(Object.entries(REQUIRED_KEYS))('keeps %s in the English catalog', (key, expected) => {
expect(lookup(key)).toBe(expected)
})
})
@@ -1,22 +0,0 @@
import { describe, expect, it } from 'vitest'
import en from './locales/en.json'
import es from './locales/es.json'
import fr from './locales/fr.json'
import ja from './locales/ja.json'
import ko from './locales/ko.json'
import zh from './locales/zh.json'
// Main words a notification from these keys with an English fallback, so a locale missing one
// silently notifies in English; no other gate catches it.
describe('notification agent status locales', () => {
it.each(Object.entries({ en, es, fr, ja, ko, zh }))(
'%s words every verdict',
(_locale, catalog) => {
const words = catalog.notifications.agentStatus
for (const key of ['needsInput', 'working', 'stopped', 'failed', 'finished'] as const) {
expect(words[key]).toEqual(expect.any(String))
expect(words[key].length).toBeGreaterThan(0)
}
}
)
})
@@ -1,33 +0,0 @@
import { describe, expect, it } from 'vitest'
import en from './locales/en.json'
import es from './locales/es.json'
import ja from './locales/ja.json'
import ko from './locales/ko.json'
import zh from './locales/zh.json'
const localizedCatalogs = { es, ja, ko, zh }
const english = en.auto.components.new.workspace.SmartWorkspaceNameField
const recoveryKeys = [
'loadingJira',
'jiraDisconnected',
'jiraSiteNotConnected',
'jiraRuntimeUpdate',
'jiraReadFailed',
'chooseJiraAccount',
'jiraLoaded',
'openSettings',
'retryJira'
] as const
describe('smart workspace Jira locale copy', () => {
it.each(Object.entries(localizedCatalogs))(
'%s localizes every Jira status and recovery string',
(_code, catalog) => {
const localized = catalog.auto.components.new.workspace.SmartWorkspaceNameField
for (const key of recoveryKeys) {
expect(localized[key].trim()).not.toBe('')
expect(localized[key]).not.toBe(english[key])
}
}
)
})
@@ -1,26 +0,0 @@
import { describe, expect, it } from 'vitest'
import en from './locales/en.json'
import es from './locales/es.json'
describe('worktree visibility locales', () => {
it('uses number-neutral Spanish source counts', () => {
const found = es.auto.components.sidebar.WorktreeVisibilitySourceList.found
expect(found.replace('{{value0}}', '1')).toBe('Encontrados: 1')
expect(found.replace('{{value0}}', '2')).toBe('Encontrados: 2')
})
it('keeps current fallback-derived keys in the English catalog', () => {
expect(en.auto.components.sidebar.NewExternalWorktreesInboxLine).toMatchObject({
'6c07f3a91e': '{{value0}} on {{value1}}'
})
expect(en.auto.components.sidebar.WorktreeVisibilityHelpPopover).toMatchObject({
ec1e6a10fb: 'Other worktrees start hidden to avoid unexpected sidebar clutter.',
'1c68c9cf77':
'Enable a source for all current and future worktrees, or show individual worktrees below.'
})
expect(en.auto.components.sidebar.HiddenWorktreeRecoveryList).toMatchObject({
'64e6f53f05': 'Show one without enabling its source.'
})
})
})
+1 -114
View File
@@ -1,120 +1,7 @@
import { describe, expect, it } from 'vitest'
import { TUI_AGENT_CONFIG } from './tui-agent-config'
import {
SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT,
isUsableSkillsCliAgentKey,
SKILLS_CLI_UNIVERSAL_AGENT_KEY,
toSkillsCliAgentKeys
} from './skills-cli-agent-keys'
// Why: the community `skills` CLI validates --agent against this namespace and
// exits 1 on anything else, so a typo here breaks installs outright. Captured
// from `skills add --agent <invalid>`, which prints its own valid list (v1.5.20).
const SKILLS_CLI_VALID_AGENT_KEYS = new Set([
'aider-desk',
'amp',
'antigravity',
'antigravity-cli',
'astrbot',
'autohand-code',
'augment',
'bob',
'claude-code',
'openclaw',
'cline',
'codearts-agent',
'codebuddy',
'codemaker',
'codestudio',
'codex',
'command-code',
'continue',
'cortex',
'crush',
'cursor',
'deepagents',
'devin',
'dexto',
'droid',
'eve',
'firebender',
'forgecode',
'gemini-cli',
'github-copilot',
'goose',
'grok',
'hermes-agent',
'inference-sh',
'jazz',
'junie',
'iflow-cli',
'kilo',
'kimchi',
'kimi-code-cli',
'kiro-cli',
'kode',
'lingma',
'loaf',
'mcpjam',
'mistral-vibe',
'moxby',
'mux',
'opencode',
'openhands',
'ona',
'pi',
'qoder',
'qoder-cn',
'qwen-code',
'replit',
'reasonix',
'rovodev',
'roo',
'tabnine-cli',
'terramind',
'tinycloud',
'trae',
'trae-cn',
'warp',
'windsurf',
'zed',
'zcode',
'zencoder',
'zenflow',
'neovate',
'pochi',
'promptscript',
'adal',
'universal'
])
import { isUsableSkillsCliAgentKey, toSkillsCliAgentKeys } from './skills-cli-agent-keys'
describe('skills CLI agent keys', () => {
it('only maps onto keys the skills CLI accepts', () => {
for (const [agent, key] of Object.entries(SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT)) {
if (key !== null) {
expect(SKILLS_CLI_VALID_AGENT_KEYS, `${agent} -> ${key}`).toContain(key)
}
}
expect(SKILLS_CLI_VALID_AGENT_KEYS).toContain(SKILLS_CLI_UNIVERSAL_AGENT_KEY)
})
it('covers every agent Orca can detect', () => {
// Why: a new TuiAgent must be considered here, even if the answer is null —
// otherwise it silently falls back to universal-only with no decision made.
expect(Object.keys(SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT).sort()).toEqual(
Object.keys(TUI_AGENT_CONFIG).sort()
)
})
it("follows Orca's own evidence for the two non-obvious mappings", () => {
// Why: src/shared/native-chat-agent-profiles.ts states OpenClaude reads
// Claude-owned roots, so it is not unmappable.
expect(SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT.openclaude).toBe('claude-code')
// Why: Orca detects trae via `traecli`, which tui-agent-config calls an alias
// only TRAE CN ships, so the CN directory is the right target.
expect(SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT.trae).toBe('trae-cn')
})
it('rejects values the skills CLI would drop, and allows the explicit wildcard', () => {
for (const bad of ['-y', '--copy', '', ' ', 'a b', 'a,b']) {
expect(isUsableSkillsCliAgentKey(bad), bad).toBe(false)
@@ -1,4 +1,3 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import {
HOLD_ALTERNATE_SCREEN_OPEN,
@@ -54,38 +53,3 @@ describe('hidden pressure fixture', () => {
)
})
})
// Ratchet: the builder is only worth anything while the specs still route through it,
// and a fixture hand-rolled back to paint-and-exit would surface days later in a
// scheduled Electron run — the detection channel that produced this ticket.
describe('spec alternate-screen fixtures', () => {
const SPEC_FIXTURE_BUILDERS: [spec: string, builder: string][] = [
['terminal-hidden-view-parking.spec.ts', 'writeParkedFrameScript'],
['terminal-hidden-view-parking.spec.ts', 'writeCycleReferenceScript'],
['terminal-hidden-tui-visual-restore.spec.ts', 'writeHiddenFrameScript']
]
function readSpec(spec: string): string {
return readFileSync(new URL(spec, import.meta.url), 'utf8')
}
function topLevelFunctionBody(source: string, name: string): string {
const start = source.indexOf(`function ${name}(`)
expect(start, `${name} was renamed or removed; re-point this ratchet`).toBeGreaterThan(-1)
// Why '\n}' and a bound: '\n}\n' misses CRLF checkouts, and an unresolved indexOf slices to EOF,
// letting a later builder call in the same file satisfy the assertion vacuously.
const end = source.indexOf('\n}', start)
expect(end, `${name} has no closing brace; re-point this ratchet`).toBeGreaterThan(start)
return source.slice(start, end)
}
it.each(SPEC_FIXTURE_BUILDERS)('%s writes %s through the shared builder', (spec, builder) => {
expect(topLevelFunctionBody(readSpec(spec), builder)).toContain('alternateScreenFixtureScript(')
})
it('the OSC 8 spec stages its link fixture through the shared builder', () => {
expect(readSpec('terminal-osc8-cold-park-restore.spec.ts')).toContain(
'stageNodeScriptForTerminal(alternateScreenFixtureScript('
)
})
})