mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
[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:
@@ -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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,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,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)
|
||||
|
||||
+826
-287
File diff suppressed because it is too large
Load Diff
-203
@@ -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)
|
||||
})
|
||||
})
|
||||
+532
-312
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
-249
@@ -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()
|
||||
})
|
||||
})
|
||||
+795
-428
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,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('
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user