diff --git a/config/scripts/release-javascript-benchmark.mjs b/config/scripts/release-javascript-benchmark.mjs
index 4fd991ae2ae..ea3f296abda 100644
--- a/config/scripts/release-javascript-benchmark.mjs
+++ b/config/scripts/release-javascript-benchmark.mjs
@@ -76,6 +76,14 @@ function summarize() {
const reports = readdirSync(reportDir, { recursive: true })
.filter((file) => /(?:baseline|shared)-.*\.json$/.test(file))
.map((file) => JSON.parse(readFileSync(join(reportDir, file), 'utf8')))
+ const baselines = reports.filter((report) => report.mode === 'baseline')
+ assert.equal(reports.length, 8, 'Expected both measurements on all four packaging hosts')
+ assert.equal(baselines.length, 4, 'Expected a baseline on all four packaging hosts')
+ assert.equal(
+ new Set(reports.map((report) => `${report.mode}/${report.platform}/${report.arch}`)).size,
+ reports.length,
+ 'Duplicate release JavaScript measurements'
+ )
const { jobs } = JSON.parse(readFileSync(join(reportDir, 'jobs.json'), 'utf8'))
const seconds = (step) => (Date.parse(step.completed_at) - Date.parse(step.started_at)) / 1000
const lines = [
@@ -84,7 +92,7 @@ function summarize() {
]
let savedMs = 0
let differences = 0
- for (const baseline of reports.filter((report) => report.mode === 'baseline')) {
+ for (const baseline of baselines) {
const shared = reports.find(
(report) =>
report.mode === 'shared' &&
@@ -112,7 +120,6 @@ function summarize() {
)
}
}
- assert.equal(reports.length, 8, 'Expected both measurements on all four packaging hosts')
const producer = jobs.find((job) =>
job.steps.some((step) => step.name === 'Build and archive release JavaScript')
)
diff --git a/config/scripts/release-policy.test.mjs b/config/scripts/release-policy.test.mjs
index 6d36717d785..fc3d1a589f1 100644
--- a/config/scripts/release-policy.test.mjs
+++ b/config/scripts/release-policy.test.mjs
@@ -135,6 +135,15 @@ describe('release policy', () => {
await expect(restoreLatestStable(github, repoRef)).resolves.toBe('v1.4.215')
expect(github.rest.repos.updateRelease).toHaveBeenCalledTimes(1)
+ expect(github.paginate).toHaveBeenCalledWith(github.rest.repos.listReleases, {
+ ...repoRef,
+ per_page: 100
+ })
+ expect(github.rest.repos.updateRelease).toHaveBeenCalledWith({
+ ...repoRef,
+ release_id: 14215,
+ make_latest: 'true'
+ })
})
it('compares versions numerically when picking Latest', async () => {
diff --git a/config/scripts/release-protected-main-push.test.mjs b/config/scripts/release-protected-main-push.test.mjs
index b84fcf78ef7..bcae63332c8 100644
--- a/config/scripts/release-protected-main-push.test.mjs
+++ b/config/scripts/release-protected-main-push.test.mjs
@@ -24,6 +24,9 @@ function pushRelease(mode, pushMain = 'true') {
rejected)
echo 'remote: error: GH013: Required status check is missing.' >&2
return 1 ;;
+ pull-request-text)
+ echo 'remote: Changes must be made through a pull request.' >&2
+ return 1 ;;
disconnected)
echo 'fatal: unable to access remote' >&2
return 128 ;;
@@ -64,11 +67,14 @@ describe('release tag push with protected main', () => {
expect(result.calls).toEqual(['push origin HEAD:refs/heads/main', 'push origin v1.4.229'])
})
- it.each(['rejected', 'disconnected'])('stops without a tag for %s main push errors', (mode) => {
- const result = pushRelease(mode)
- expect(result.code).not.toBe(0)
- expect(result.calls).toEqual(['push origin HEAD:refs/heads/main'])
- })
+ it.each(['rejected', 'disconnected', 'pull-request-text'])(
+ 'stops without a tag for %s main push errors',
+ (mode) => {
+ const result = pushRelease(mode)
+ expect(result.code).not.toBe(0)
+ expect(result.calls).toEqual(['push origin HEAD:refs/heads/main'])
+ }
+ )
it('only pushes the tag for an off-main source', () => {
const result = pushRelease('protected', 'false')
diff --git a/config/scripts/release-rc-history.test.mjs b/config/scripts/release-rc-history.test.mjs
index 5e2e051c238..e865387eaeb 100644
--- a/config/scripts/release-rc-history.test.mjs
+++ b/config/scripts/release-rc-history.test.mjs
@@ -1,4 +1,4 @@
-import { execFileSync } from 'node:child_process'
+import { runProcessSync } from '@orca/process-host'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -10,11 +10,9 @@ import {
} from './release-rc-history.mjs'
function git(cwd, args) {
- return execFileSync('git', args, {
- cwd,
- encoding: 'utf8',
- stdio: ['ignore', 'pipe', 'pipe']
- }).trim()
+ const result = runProcessSync({ program: 'git', args, cwd, timeoutMs: 10_000 })
+ expect(result.code, result.stderr).toBe(0)
+ return result.stdout.trim()
}
function withGitRepo(run) {
@@ -71,6 +69,8 @@ describe('release RC history', () => {
// this. Under-reporting rc.6 here lets an explicit 1.4.36-rc.6 cut land
// below the v1.4.36-rc.6.perf build that clients already run.
commit(repo, 'release: v1.4.36-rc.6.perf')
+ git(repo, ['tag', 'v1.4.36-rc.6.perf'])
+ git(repo, ['tag', '--delete', 'v1.4.36-rc.6.perf'])
expect(highestRcForBase('1.4.36', { cwd: repo })).toBe(6)
})
@@ -82,6 +82,8 @@ describe('release RC history', () => {
commit(repo, 'release: v1.4.36-rc.5')
git(repo, ['tag', 'v1.4.36-rc.5'])
commit(repo, 'release: v1.4.36-rc.6')
+ git(repo, ['tag', 'v1.4.36-rc.6'])
+ git(repo, ['tag', '--delete', 'v1.4.36-rc.6'])
expect(highestRcForBase('1.4.36', { cwd: repo })).toBe(6)
})
@@ -98,4 +100,14 @@ describe('release RC history', () => {
expect(highestRcForBase('1.4.36', { cwd: repo })).toBe(6)
})
})
+ it('counts an RC tag independently of release subjects and returns null without either record', () => {
+ withGitRepo((repo) => {
+ commit(repo, 'initial')
+ expect(highestRcForBase('1.4.36', { cwd: repo })).toBeNull()
+ git(repo, ['tag', 'v1.4.36-rc.7'])
+ expect(highestRcForBase('1.4.36', { cwd: repo })).toBe(7)
+ git(repo, ['tag', '--delete', 'v1.4.36-rc.7'])
+ expect(highestRcForBase('1.4.36', { cwd: repo })).toBeNull()
+ })
+ })
})
diff --git a/config/scripts/release-tag-pattern-census.test.mjs b/config/scripts/release-tag-pattern-census.test.mjs
index 36b1c148f49..863f28c9761 100644
--- a/config/scripts/release-tag-pattern-census.test.mjs
+++ b/config/scripts/release-tag-pattern-census.test.mjs
@@ -29,6 +29,7 @@ const DESKTOP_ONLY_PROOFS = {
},
'config/scripts/publish-complete-draft-releases.mjs': async () => {
const { isReleaseCutDraft } = await import('./publish-complete-draft-releases.mjs')
+ expect(isReleaseCutDraft(rulesRelease('v1.4.2-rc.1', { draft: true }))).toBe(true)
for (const tag of RULES_TAGS) {
expect(isReleaseCutDraft(rulesRelease(tag, { draft: true }))).toBe(false)
}
@@ -43,6 +44,10 @@ const DESKTOP_ONLY_PROOFS = {
},
'config/scripts/assert-github-release-is-draft.mjs': async () => {
const { matchingDesktopReleases } = await import('./assert-github-release-is-draft.mjs')
+ const desktop = { tag_name: 'v1.4.1' }
+ expect(
+ matchingDesktopReleases([...RULES_TAGS.map((tag) => rulesRelease(tag)), desktop], 'v1.4.1')
+ ).toEqual([desktop])
expect(
matchingDesktopReleases(
RULES_TAGS.map((tag) => rulesRelease(tag)),
@@ -52,13 +57,18 @@ const DESKTOP_ONLY_PROOFS = {
},
'config/scripts/verify-release-required-assets.mjs': async () => {
const { verifyRequiredReleaseAssets } = await import('./verify-release-required-assets.mjs')
+ const releases = RULES_TAGS.map((tag) => rulesRelease(tag))
vi.stubGlobal(
'fetch',
- vi.fn(async () => new Response(JSON.stringify(RULES_TAGS.map((tag) => rulesRelease(tag)))))
+ vi.fn(async () => new Response(JSON.stringify(releases)))
)
await expect(
verifyRequiredReleaseAssets({ repo: 'stablyai/orca', tag: 'v1.4.1', token: '' })
).rejects.toThrow('was not found')
+ releases.push({ tag_name: 'v1.4.1', assets: [] })
+ await expect(
+ verifyRequiredReleaseAssets({ repo: 'stablyai/orca', tag: 'v1.4.1', token: '' })
+ ).rejects.toThrow('Release v1.4.1 is missing required assets.')
}
}
@@ -68,14 +78,9 @@ afterEach(() => {
describe('release tag pattern census', () => {
it.each(Object.keys(DESKTOP_ONLY_PROOFS))(
- '%s imports the shared patterns or proves it admits only desktop tags',
+ '%s excludes agent state rules releases from desktop selection',
async (path) => {
- const proof = DESKTOP_ONLY_PROOFS[path]
- expect(
- proof,
- `${path} lists releases: import release-tag-patterns.mjs or add a proof`
- ).toBeDefined()
- await proof()
+ await DESKTOP_ONLY_PROOFS[path]()
}
)
@@ -84,6 +89,8 @@ describe('release tag pattern census', () => {
expect(isAgentStateRulesTag(tag)).toBe(true)
expect(DESKTOP_STABLE_TAG.test(tag) || DESKTOP_RC_TAG.test(tag)).toBe(false)
}
+ expect(DESKTOP_STABLE_TAG.test('v1.4.1')).toBe(true)
+ expect(DESKTOP_RC_TAG.test('v1.4.2-rc.1')).toBe(true)
expect(() => agentStateRulesTag(1, 'beta')).toThrow()
})
})
diff --git a/config/scripts/release-windows-signing-gate.test.mjs b/config/scripts/release-windows-signing-gate.test.mjs
index 0416e340a14..5a6d022d44a 100644
--- a/config/scripts/release-windows-signing-gate.test.mjs
+++ b/config/scripts/release-windows-signing-gate.test.mjs
@@ -13,15 +13,23 @@ const submissions = steps.filter((step) =>
step.uses?.startsWith('signpath/github-action-submit-signing-request@')
)
-function signingAllowed(submission, outcome, attempt = 1, platform = 'win', uploaded = 'success') {
+function signingAllowed(submission, outcome, attempt = 1, platform = 'win', uploads = {}) {
const expression = submission.if.replace(/steps\.([a-z][a-z0-9-]*)/g, 'steps["$1"]')
return runInNewContext(expression, {
matrix: { platform },
github: { run_attempt: attempt },
steps: {
'mac-signing-gate': { outcome },
- 'upload-unsigned-inner': { outcome: uploaded },
- 'upload-unsigned-windows-installer': { outcome: uploaded }
+ 'upload-unsigned-inner': {
+ outcome: Object.hasOwn(uploads, 'upload-unsigned-inner')
+ ? uploads['upload-unsigned-inner']
+ : 'success'
+ },
+ 'upload-unsigned-windows-installer': {
+ outcome: Object.hasOwn(uploads, 'upload-unsigned-windows-installer')
+ ? uploads['upload-unsigned-windows-installer']
+ : 'success'
+ }
}
})
}
@@ -65,11 +73,21 @@ describe('Windows release signing waits for macOS success', () => {
})
it('allows neither signing request without successful macOS verification and upload', () => {
+ const uploadBySubmission = {
+ 'submit-inner-signing': 'upload-unsigned-inner',
+ 'submit-signing-request': 'upload-unsigned-windows-installer'
+ }
for (const submission of submissions) {
+ const upload = uploadBySubmission[submission.id]
+ expect(upload).toBeDefined()
+ const otherUpload = Object.values(uploadBySubmission).find((id) => id !== upload)
expect(signingAllowed(submission, 'success')).toBe(true)
+ expect(signingAllowed(submission, 'success', 1, 'win', { [otherUpload]: 'failure' })).toBe(
+ true
+ )
for (const outcome of ['failure', 'cancelled', 'skipped', '', undefined]) {
expect(signingAllowed(submission, outcome), `${submission.id}: ${outcome}`).toBe(false)
- expect(signingAllowed(submission, 'success', 1, 'win', outcome ?? null)).toBe(false)
+ expect(signingAllowed(submission, 'success', 1, 'win', { [upload]: outcome })).toBe(false)
}
for (const attempt of [2, 3]) {
expect(signingAllowed(submission, 'success', attempt)).toBe(false)
@@ -92,7 +110,7 @@ describe('Windows release signing waits for macOS success', () => {
})
it('runs the verifier CLI from paths requiring URL encoding, as Windows paths do', async () => {
- const directory = realpathSync(mkdtempSync(join(tmpdir(), 'orca signing gate ')))
+ const directory = realpathSync(mkdtempSync(join(tmpdir(), 'orca signing gate # % ')))
const script = join(directory, 'mac verifier.mjs')
writeFileSync(script, readFileSync('config/scripts/run-release-mac-build-workflow.mjs'))
try {
diff --git a/config/scripts/renderer-scrollbar-style-plugin.test.mjs b/config/scripts/renderer-scrollbar-style-plugin.test.mjs
index 0250297cc35..00b6cea5c55 100644
--- a/config/scripts/renderer-scrollbar-style-plugin.test.mjs
+++ b/config/scripts/renderer-scrollbar-style-plugin.test.mjs
@@ -17,40 +17,60 @@ function lintSources(sources) {
}
const violations = [
- ['unstyled class', 'export const X = () =>
'],
+ [
+ 'unstyled class',
+ 'export const X = () => ',
+ '"max-h-64 overflow-y-auto"'
+ ],
[
'unstyled suffix-important class',
- 'export const X = () => '
+ 'export const X = () => ',
+ '"max-h-64 overflow-y-auto!"'
],
[
'unknown scrollbar class',
- 'export const X = () => '
+ 'export const X = () => ',
+ '"overflow-auto scrollbar-none"'
],
[
'separate class composer arguments',
- "export const X = () => "
+ "export const X = () => ",
+ "'overflow-y-auto'"
],
[
'conditional scrollbar',
- "export const X = ({ enabled }) => "
+ "export const X = ({ enabled }) => ",
+ "'overflow-y-auto'"
],
[
'arbitrary class wrapper',
- "export const X = () => "
+ "export const X = () => ",
+ "'overflow-y-auto'"
],
[
'mismatched responsive variants',
- 'export const X = () => '
+ 'export const X = () => ',
+ '"overflow-y-auto md:scrollbar-sleek"'
+ ],
+ [
+ 'inline overflow',
+ "export const X = () => ",
+ "overflowY: 'auto'"
],
- ['inline overflow', "export const X = () => "],
[
'logical inline style spread',
- "export const X = ({ open }) => "
+ "export const X = ({ open }) => ",
+ "overflowY: 'auto'"
+ ],
+ [
+ 'JSX spread class',
+ "export const X = () => ",
+ "'overflow-y-auto'"
],
- ['JSX spread class', "export const X = () => "],
[
'later spread override',
- 'export const X = () => '
+ 'export const X = () => ',
+ "'overflow-y-auto'"
]
]
@@ -103,8 +123,16 @@ beforeAll(() => {
})
describe('renderer scrollbar style Oxlint plugin', () => {
- it.each(violations)('reports %s', (_name, source) => {
- expect(diagnosticsBySource.get(source)).toHaveLength(1)
+ it.each(violations)('reports %s', (_name, source, reportedSource) => {
+ const findings = diagnosticsBySource.get(source)
+ expect(findings).toHaveLength(1)
+ expect(findings[0].code).toBe('renderer-scrollbar-style(require-styled-vertical-scrollbar)')
+ const { offset, length } = findings[0].labels[0].span
+ expect(
+ Buffer.from(source)
+ .subarray(offset, offset + length)
+ .toString('utf8')
+ ).toBe(reportedSource)
})
it.each(accepted)('accepts %s', (_name, source) => {
diff --git a/config/scripts/script-entry-detection.test.mjs b/config/scripts/script-entry-detection.test.mjs
index 00c6b54b57d..b9ab958ab86 100644
--- a/config/scripts/script-entry-detection.test.mjs
+++ b/config/scripts/script-entry-detection.test.mjs
@@ -1,7 +1,8 @@
-import { execFileSync } from 'node:child_process'
+import { runProcessSync } from '@orca/process-host'
import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
+import { pathToFileURL } from 'node:url'
import { describe, expect, it } from 'vitest'
import { isDirectInvocation } from './script-entry-detection.mjs'
@@ -21,6 +22,15 @@ describe('isDirectInvocation', () => {
expect(isDirectInvocation(import.meta.url, '')).toBe(false)
})
+ it('compares the literal entry path when realpath cannot resolve it', () => {
+ const entry = join(tmpdir(), 'unresolved entry # %.mjs')
+ const realpath = () => {
+ throw new Error('unresolvable fixture path')
+ }
+ expect(isDirectInvocation(pathToFileURL(entry).href, entry, { realpath })).toBe(true)
+ expect(isDirectInvocation(import.meta.url, entry, { realpath })).toBe(false)
+ })
+
// Why an injected converter: a win32 path cannot be exercised through node:url's pathToFileURL
// on a posix runner, and CI is ubuntu.
const toWin32FileUrl = (windowsPath) => new URL(`file:///${windowsPath.replaceAll('\\', '/')}`)
@@ -35,9 +45,6 @@ describe('isDirectInvocation', () => {
realpath: keepAsIs
})
).toBe(true)
- // The regression this guards: `file://${argv[1]}` yields file://C:\orca\... on Windows,
- // so the builder exited 0 having written nothing and packaging failed downstream.
- expect(`file://${scriptPath}`).not.toBe(moduleUrl)
})
})
@@ -58,7 +65,13 @@ describe('running a builder through a symlink', () => {
)
const link = join(scratch, 'entry-link.mjs')
await symlink(real, link)
- expect(execFileSync(process.execPath, [link], { encoding: 'utf8' })).toBe('true')
+ const result = runProcessSync({
+ program: process.execPath,
+ args: [link],
+ timeoutMs: 10_000
+ })
+ expect(result.code, result.stderr).toBe(0)
+ expect(result.stdout).toBe('true')
} finally {
await rm(scratch, { recursive: true, force: true })
}
diff --git a/config/scripts/script-module-dependencies.test.mjs b/config/scripts/script-module-dependencies.test.mjs
index 117d00a4fe2..8d7cc721769 100644
--- a/config/scripts/script-module-dependencies.test.mjs
+++ b/config/scripts/script-module-dependencies.test.mjs
@@ -4,15 +4,20 @@ import {
mkdtempSync,
readdirSync,
readFileSync,
+ rmSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
-import { describe, expect, it } from 'vitest'
+import { afterAll, describe, expect, it } from 'vitest'
import { copyScriptWithLocalModules } from './script-module-dependencies.mjs'
const fixtureDir = mkdtempSync(join(tmpdir(), 'script-module-dependencies-'))
+afterAll(() => {
+ rmSync(fixtureDir, { recursive: true, force: true })
+})
+
function sourceTree(files) {
const sourceDir = mkdtempSync(join(fixtureDir, 'source-'))
for (const [name, contents] of Object.entries(files)) {
@@ -25,7 +30,11 @@ function copiedNames(files, entryName) {
const sourceDir = sourceTree(files)
const destinationDir = join(mkdtempSync(join(fixtureDir, 'dest-')), 'scripts')
copyScriptWithLocalModules(join(sourceDir, entryName), destinationDir)
- return readdirSync(destinationDir).sort()
+ const names = readdirSync(destinationDir).sort()
+ for (const name of names) {
+ expect(readFileSync(join(destinationDir, name), 'utf8'), name).toBe(files[name])
+ }
+ return names
}
describe('copyScriptWithLocalModules', () => {
diff --git a/config/scripts/sort-comparator-performance-plugin.test.mjs b/config/scripts/sort-comparator-performance-plugin.test.mjs
index 378750a85ef..bfa2d97e48c 100644
--- a/config/scripts/sort-comparator-performance-plugin.test.mjs
+++ b/config/scripts/sort-comparator-performance-plugin.test.mjs
@@ -48,6 +48,20 @@ describe('sort comparator performance', () => {
(finding) => finding.code === 'sort-comparator-performance(no-repeated-collator)'
)
).toBe(true)
+ const reportedSources = findings.map(({ labels }) => {
+ const { offset, length } = labels[0].span
+ return Buffer.from(sources.repeatedCollation)
+ .subarray(offset, offset + length)
+ .toString('utf8')
+ })
+ expect(reportedSources.sort()).toEqual(
+ [
+ "a.name.localeCompare(b.name, locale, { sensitivity: 'base' })",
+ "new Intl.Collator('sv')",
+ "Intl.Collator('en', { numeric: true })",
+ "a['localeCompare'](b, undefined, options)"
+ ].sort()
+ )
})
it('allows one collator per sort, bare comparisons, and unrelated callbacks', () => {
diff --git a/config/scripts/telemetry-bundle-constant-patterns.test.mjs b/config/scripts/telemetry-bundle-constant-patterns.test.mjs
index 57f8e0c7217..ffacd06b8a8 100644
--- a/config/scripts/telemetry-bundle-constant-patterns.test.mjs
+++ b/config/scripts/telemetry-bundle-constant-patterns.test.mjs
@@ -7,8 +7,10 @@ import {
describe('telemetry bundle constant patterns', () => {
it.each(['const', 'let', 'var'])('accepts %s declarations', (declaration) => {
- expect(`${declaration} BUILD_IDENTITY = "rc"`).toMatch(BUILD_IDENTITY_RE)
- expect(`${declaration} WRITE_KEY = "phc_example-key_123"`).toMatch(WRITE_KEY_RE)
+ expect(BUILD_IDENTITY_RE.exec(`${declaration} BUILD_IDENTITY = "rc"`)?.[1]).toBe('rc')
+ expect(WRITE_KEY_RE.exec(`${declaration} WRITE_KEY = "phc_example-key_123"`)?.[1]).toBe(
+ 'phc_example-key_123'
+ )
})
it('rejects assignments and invalid values', () => {
@@ -20,7 +22,10 @@ describe('telemetry bundle constant patterns', () => {
it('accepts minified adjacent declarations', () => {
const bundle = 'var dde=`stable`,fde=`phc_example-key_123`,pde=(dde===`stable`)'
- expect(bundle).toMatch(MINIFIED_TELEMETRY_RE)
+ expect(MINIFIED_TELEMETRY_RE.exec(bundle)?.slice(1, 3)).toEqual([
+ 'stable',
+ 'phc_example-key_123'
+ ])
})
it('accepts the identity as a later declarator in a shared declaration', () => {
diff --git a/config/scripts/verify-packaged-browser-participation.test.mjs b/config/scripts/verify-packaged-browser-participation.test.mjs
index 6508777e19a..c0ada4ffc11 100644
--- a/config/scripts/verify-packaged-browser-participation.test.mjs
+++ b/config/scripts/verify-packaged-browser-participation.test.mjs
@@ -1,8 +1,5 @@
import { describe, expect, it } from 'vitest'
-import {
- verifyPackagedBrowserParticipation,
- PACKAGED_BROWSER_TEST_TITLES
-} from './verify-packaged-browser-participation.mjs'
+import { verifyPackagedBrowserParticipation } from './verify-packaged-browser-participation.mjs'
function report() {
return {
@@ -11,7 +8,10 @@ function report() {
{
suites: [
{
- specs: PACKAGED_BROWSER_TEST_TITLES.map((title) => ({
+ specs: [
+ 'keeps an old packaged client on the current server-hosted path',
+ 'keeps a current client on an old packaged server-hosted path'
+ ].map((title) => ({
title,
tests: Array.from({ length: 3 }, () => ({
expectedStatus: 'passed',
diff --git a/config/scripts/verify-packaged-orcad-template.test.mjs b/config/scripts/verify-packaged-orcad-template.test.mjs
index 3aeec7b1021..ae845abb9aa 100644
--- a/config/scripts/verify-packaged-orcad-template.test.mjs
+++ b/config/scripts/verify-packaged-orcad-template.test.mjs
@@ -29,7 +29,7 @@ afterEach(async () => {
})
describe('verifyPackagedOrcadTemplate', () => {
- it('accepts the exact six-target packaged template', async () => {
+ it('accepts the complete packaged template', async () => {
const fixture = await createFixture()
expect(() => verifyPackagedOrcadTemplate(fixture.root)).not.toThrow()
diff --git a/config/scripts/verify-packaged-serve-payload.test.mjs b/config/scripts/verify-packaged-serve-payload.test.mjs
index b683174df34..ebc28853ee4 100644
--- a/config/scripts/verify-packaged-serve-payload.test.mjs
+++ b/config/scripts/verify-packaged-serve-payload.test.mjs
@@ -134,13 +134,10 @@ describe('assertPackagedServeSelectionEntry', () => {
return resourcesDir
}
- it('looks where orca serve runs the entry from', () => {
- expect(SERVE_SELECTION_ENTRY).toBe(`main/${ORCAD_LOCAL_SERVE_SELECTION_ENTRY}.js`)
- })
-
it('passes an unpacked entry whose relative requires are all unpacked', () => {
const resourcesDir = packagedEntry({
- [SERVE_SELECTION_ENTRY]: 'require("../chunks/a.js"); require("node:fs")',
+ [`main/${ORCAD_LOCAL_SERVE_SELECTION_ENTRY}.js`]:
+ 'require("../chunks/a.js"); require("node:fs")',
'main/chunks/a.js': 'require("./b"); require("../persistence")',
'main/chunks/b.js': '',
'main/persistence/index.js': ''
diff --git a/config/scripts/verify-release-required-assets.test.mjs b/config/scripts/verify-release-required-assets.test.mjs
index 9b593f5716a..8a1adcabbf3 100644
--- a/config/scripts/verify-release-required-assets.test.mjs
+++ b/config/scripts/verify-release-required-assets.test.mjs
@@ -76,33 +76,44 @@ describe('extractManifestAssetNames', () => {
})
describe('verifyRequiredReleaseAssets', () => {
+ function releaseFetch(release, manifestAsset, manifestText) {
+ return vi.fn(async (url) => {
+ const releasesUrl = 'https://api.github.com/repos/stablyai/orca/releases'
+ if (url === `${releasesUrl}?per_page=100`) {
+ return jsonResponse([release])
+ }
+ if (url === `${releasesUrl}/assets/${manifestAsset.id}`) {
+ return jsonResponse(manifestText)
+ }
+ if (release.assets.some((asset) => url === `${releasesUrl}/assets/${asset.id}`)) {
+ return jsonResponse('version: 1.4.27\n')
+ }
+ throw new Error(`Unexpected release request: ${url}`)
+ })
+ }
+
it('fails when a manifest-referenced asset has not been uploaded', async () => {
const tag = 'v1.4.27'
- const required = getRequiredReleaseAssetNames(tag)
- const assets = required.filter((name) => name !== 'Orca-1.4.27-arm64-mac.zip')
- const release = releaseWithAssets(tag, assets)
+ const release = releaseWithAssets(tag, getRequiredReleaseAssetNames(tag))
const latestMacAsset = release.assets.find((asset) => asset.name === 'latest-mac.yml')
- const fetchMock = vi
- .fn()
- .mockResolvedValueOnce(jsonResponse([release]))
- .mockResolvedValueOnce(
- jsonResponse(
- [
- 'version: 1.4.27',
- 'files:',
- ' - url: Orca-1.4.27-arm64-mac.zip',
- ' sha512: test',
- 'path: Orca-1.4.27-arm64-mac.zip'
- ].join('\n')
- )
+ vi.stubGlobal(
+ 'fetch',
+ releaseFetch(
+ release,
+ latestMacAsset,
+ [
+ 'version: 1.4.27',
+ 'files:',
+ ' - url: mac-updater-only.zip',
+ ' sha512: test',
+ 'path: mac-updater-only.zip'
+ ].join('\n')
)
- .mockResolvedValue(jsonResponse('version: 1.4.27\n'))
- vi.stubGlobal('fetch', fetchMock)
+ )
await expect(
verifyRequiredReleaseAssets({ repo: 'stablyai/orca', tag, token: 'token' })
- ).rejects.toThrow('Missing: Orca-1.4.27-arm64-mac.zip')
- expect(latestMacAsset).toBeTruthy()
+ ).rejects.toThrow('Missing: mac-updater-only.zip')
})
it('checks assets referenced by the Linux arm64 updater manifest', async () => {
@@ -110,26 +121,22 @@ describe('verifyRequiredReleaseAssets', () => {
const required = getRequiredReleaseAssetNames(tag)
const release = releaseWithAssets(tag, required)
const arm64Manifest = release.assets.find((asset) => asset.name === 'latest-linux-arm64.yml')
- const fetchMock = vi
- .fn()
- .mockResolvedValueOnce(jsonResponse([release]))
- .mockResolvedValueOnce(jsonResponse('version: 1.4.27\n'))
- .mockResolvedValueOnce(
- jsonResponse(
- [
- 'version: 1.4.27',
- 'files:',
- ' - url: orca-linux-arm64.AppImage.blockmap',
- 'path: orca-linux-arm64.AppImage'
- ].join('\n')
- )
+ vi.stubGlobal(
+ 'fetch',
+ releaseFetch(
+ release,
+ arm64Manifest,
+ [
+ 'version: 1.4.27',
+ 'files:',
+ ' - url: orca-linux-arm64.AppImage.blockmap',
+ 'path: orca-linux-arm64.AppImage'
+ ].join('\n')
)
- .mockResolvedValue(jsonResponse('version: 1.4.27\n'))
- vi.stubGlobal('fetch', fetchMock)
+ )
await expect(
verifyRequiredReleaseAssets({ repo: 'stablyai/orca', tag, token: 'token' })
).rejects.toThrow('Missing: orca-linux-arm64.AppImage.blockmap')
- expect(arm64Manifest).toBeTruthy()
})
})
diff --git a/mobile/src/transport/connection-log-background-flush.test.ts b/mobile/src/transport/connection-log-background-flush.test.ts
index e5563a9fccd..59a7b775e56 100644
--- a/mobile/src/transport/connection-log-background-flush.test.ts
+++ b/mobile/src/transport/connection-log-background-flush.test.ts
@@ -1,45 +1,83 @@
-import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { Mock } from 'vitest'
import { subscribeConnectionLogBackgroundFlush } from './connection-log-background-flush'
type AppStateListener = (next: string) => void
-let appStateListener: AppStateListener | null = null
+const appStateListeners = new Map>()
const appStateRemove = vi.fn()
+const subscriptions = new Set<() => void>()
vi.mock('react-native', () => ({
AppState: {
- addEventListener: (_event: string, listener: AppStateListener) => {
- appStateListener = listener
- return { remove: appStateRemove }
+ addEventListener: (event: string, listener: AppStateListener) => {
+ const listeners = appStateListeners.get(event) ?? new Set()
+ appStateListeners.set(event, listeners)
+ listeners.add(listener)
+ return {
+ remove: () => {
+ appStateRemove()
+ listeners.delete(listener)
+ }
+ }
}
}
}))
+function subscribe(flush: () => void): () => void {
+ const unsubscribe = subscribeConnectionLogBackgroundFlush(flush)
+ subscriptions.add(unsubscribe)
+ return () => {
+ subscriptions.delete(unsubscribe)
+ unsubscribe()
+ }
+}
+
+function emitAppState(next: string): void {
+ for (const listener of appStateListeners.get('change') ?? []) {
+ listener(next)
+ }
+}
+
describe('subscribeConnectionLogBackgroundFlush', () => {
let flush: Mock<() => void>
beforeEach(() => {
vi.clearAllMocks()
- appStateListener = null
+ appStateListeners.clear()
flush = vi.fn<() => void>()
})
+ afterEach(() => {
+ for (const unsubscribe of subscriptions) {
+ unsubscribe()
+ }
+ subscriptions.clear()
+ expect([...appStateListeners.values()].every((listeners) => listeners.size === 0)).toBe(true)
+ })
+
it('flushes when the app goes to the background', () => {
- subscribeConnectionLogBackgroundFlush(flush)
- appStateListener?.('background')
+ subscribe(flush)
+ emitAppState('background')
expect(flush).toHaveBeenCalledTimes(1)
})
it('does not flush on foreground or transient inactive states', () => {
- subscribeConnectionLogBackgroundFlush(flush)
- appStateListener?.('active')
- appStateListener?.('inactive')
+ subscribe(flush)
+ expect(appStateListeners.get('change')?.size).toBe(1)
+ emitAppState('active')
+ emitAppState('inactive')
expect(flush).not.toHaveBeenCalled()
})
it('removes the listener on unsubscribe', () => {
- const unsubscribe = subscribeConnectionLogBackgroundFlush(flush)
+ const otherFlush = vi.fn()
+ const unsubscribe = subscribe(flush)
+ subscribe(otherFlush)
unsubscribe()
+ emitAppState('background')
+ expect(flush).not.toHaveBeenCalled()
+ expect(otherFlush).toHaveBeenCalledTimes(1)
expect(appStateRemove).toHaveBeenCalledTimes(1)
})
})
diff --git a/mobile/src/transport/connection-log-buffer.test.ts b/mobile/src/transport/connection-log-buffer.test.ts
index 51d8bbb044d..10b35319f6f 100644
--- a/mobile/src/transport/connection-log-buffer.test.ts
+++ b/mobile/src/transport/connection-log-buffer.test.ts
@@ -76,18 +76,26 @@ describe('connection log buffer', () => {
it('redacts credentials before retaining or persisting an event', async () => {
const save = vi.fn(async () => {})
- const store = createConnectionLogStore(3, { load: async () => [], save })
+ const store = createConnectionLogStore(3, {
+ load: async () => [{ ...entry(0), detail: 'authorization=Bearer legacy-secret' }],
+ save
+ })
store.append('host-a', {
...entry(1),
detail: 'resumeToken=do-not-copy deviceToken:also-secret'
})
+ expect(store.get('host-a')[0]?.detail).toBe('resumeToken=[redacted] deviceToken:[redacted]')
await store.hydrate('host-a')
- expect(store.get('host-a')[0]?.detail).toBe('resumeToken=[redacted] deviceToken:[redacted]')
+ expect(store.get('host-a').map((event) => event.detail)).toEqual([
+ 'authorization=[redacted]',
+ 'resumeToken=[redacted] deviceToken:[redacted]'
+ ])
await vi.waitFor(() => expect(save).toHaveBeenCalled())
expect(JSON.stringify(save.mock.calls)).not.toContain('do-not-copy')
expect(JSON.stringify(save.mock.calls)).not.toContain('also-secret')
+ expect(JSON.stringify(save.mock.calls)).not.toContain('legacy-secret')
})
it('redacts quoted credential values when the object key is unquoted', async () => {
@@ -176,17 +184,29 @@ describe('connection log buffer', () => {
})
it('retries one transient persistence failure without requiring another append', async () => {
- const save = vi.fn().mockResolvedValue(undefined)
- const store = createConnectionLogStore(3, { load: async () => [], save })
+ vi.useFakeTimers()
+ try {
+ const save = vi.fn().mockResolvedValue(undefined)
+ const store = createConnectionLogStore(3, { load: async () => [], save })
- await store.hydrate('host-a')
- await vi.waitFor(() => expect(save).toHaveBeenCalled())
- save.mockReset()
- save.mockRejectedValueOnce(new Error('storage unavailable')).mockResolvedValueOnce(undefined)
- store.append('host-a', entry(1))
+ await store.hydrate('host-a')
+ await vi.advanceTimersByTimeAsync(0)
+ expect(save).toHaveBeenCalledOnce()
+ save.mockReset()
+ save.mockRejectedValueOnce(new Error('storage unavailable')).mockResolvedValueOnce(undefined)
+ store.append('host-a', entry(1))
- await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(2))
- expect(save).toHaveBeenLastCalledWith('host-a', [entry(1)])
+ await vi.advanceTimersByTimeAsync(0)
+ expect(save).toHaveBeenCalledOnce()
+ await vi.advanceTimersByTimeAsync(199)
+ expect(save).toHaveBeenCalledOnce()
+ await vi.advanceTimersByTimeAsync(1)
+ expect(save).toHaveBeenCalledTimes(2)
+ expect(save).toHaveBeenLastCalledWith('host-a', [entry(1)])
+ } finally {
+ vi.clearAllTimers()
+ vi.useRealTimers()
+ }
})
it('does not retry failed automatic hydration for every appended event', async () => {
diff --git a/mobile/src/transport/connection-log-persistence-backpressure.test.ts b/mobile/src/transport/connection-log-persistence-backpressure.test.ts
index 0819fc87ee3..e3a110560a2 100644
--- a/mobile/src/transport/connection-log-persistence-backpressure.test.ts
+++ b/mobile/src/transport/connection-log-persistence-backpressure.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createConnectionLogStore } from './connection-log-buffer'
import type { ConnectionLogEntry } from './types'
@@ -15,6 +15,17 @@ async function drainMicrotasks(): Promise {
}
}
+beforeEach(() => {
+ vi.useFakeTimers()
+})
+
+afterEach(() => {
+ if (vi.isFakeTimers()) {
+ vi.clearAllTimers()
+ vi.useRealTimers()
+ }
+})
+
describe('connection log persistence backpressure', () => {
it('writes only the latest pending snapshot when storage falls behind', async () => {
const saved: string[][] = []
@@ -44,11 +55,11 @@ describe('connection log persistence backpressure', () => {
}
expect(saved).toEqual([['0']])
release()
- for (let i = 0; i < 1000; i++) {
- await drainMicrotasks()
- }
+ await drainMicrotasks()
expect(saved).toHaveLength(2)
- expect(saved[1]).toEqual(store.get('a').map((item) => item.id))
+ const expected = Array.from({ length: 200 }, (_, index) => String(801 + index))
+ expect(saved[1]).toEqual(expected)
+ expect(store.get('a').map((item) => item.id)).toEqual(expected)
})
it('persists a different host while one host has a stalled save', async () => {
diff --git a/mobile/src/transport/connection-log-persistence-revisions.test.ts b/mobile/src/transport/connection-log-persistence-revisions.test.ts
index cf3f57929b0..20096177cfe 100644
--- a/mobile/src/transport/connection-log-persistence-revisions.test.ts
+++ b/mobile/src/transport/connection-log-persistence-revisions.test.ts
@@ -1,5 +1,6 @@
-import { describe, expect, it, vi } from 'vitest'
-import { createConnectionLogStore, type ConnectionLogPersistence } from './connection-log-buffer'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { createConnectionLogStore } from './connection-log-buffer'
+import type { ConnectionLogPersistence } from './connection-log-buffer'
import type { ConnectionLogEntry } from './types'
const entry = (id: number): ConnectionLogEntry => ({
@@ -8,7 +9,16 @@ const entry = (id: number): ConnectionLogEntry => ({
level: 'info',
message: `event ${id}`
})
-const drain = () => new Promise((resolve) => setTimeout(resolve, 0))
+const drain = () => vi.advanceTimersByTimeAsync(0)
+
+beforeEach(() => {
+ vi.useFakeTimers()
+})
+
+afterEach(() => {
+ vi.clearAllTimers()
+ vi.useRealTimers()
+})
describe('connection log persistence revisions', () => {
it('writes a synchronous burst once per host after hydration', async () => {
@@ -23,8 +33,14 @@ describe('connection log persistence revisions', () => {
}
await drain()
expect(save).toHaveBeenCalledTimes(2)
- expect(save).toHaveBeenCalledWith('a', store.get('a'))
- expect(save).toHaveBeenCalledWith('b', store.get('b'))
+ expect(save).toHaveBeenCalledWith(
+ 'a',
+ Array.from({ length: 200 }, (_, i) => entry(800 + i))
+ )
+ expect(save).toHaveBeenCalledWith(
+ 'b',
+ Array.from({ length: 200 }, (_, i) => entry(2800 + i))
+ )
})
it('shares one snapshot across startup appends waiting on hydration', async () => {
diff --git a/mobile/src/transport/connection-retry-action.test.ts b/mobile/src/transport/connection-retry-action.test.ts
index d5e74a95482..1d6ae4c55c2 100644
--- a/mobile/src/transport/connection-retry-action.test.ts
+++ b/mobile/src/transport/connection-retry-action.test.ts
@@ -7,6 +7,9 @@ describe('connectionRetryAction', () => {
const reload = vi.fn()
connectionRetryAction({ hostId: 'h', needsReconnect: false, forceReconnect, reload })?.()
expect(reload).toHaveBeenCalledTimes(1)
+ if (forceReconnect) {
+ expect(forceReconnect).not.toHaveBeenCalled()
+ }
}
})
@@ -32,13 +35,13 @@ describe('connectionRetryAction', () => {
})
it('re-reads when there is no host to re-dial', () => {
- const reload = vi.fn()
- connectionRetryAction({
- hostId: undefined,
- needsReconnect: true,
- forceReconnect: null,
- reload
- })?.()
- expect(reload).toHaveBeenCalledTimes(1)
+ for (const forceReconnect of [null, vi.fn()]) {
+ const reload = vi.fn()
+ connectionRetryAction({ hostId: undefined, needsReconnect: true, forceReconnect, reload })?.()
+ expect(reload).toHaveBeenCalledTimes(1)
+ if (forceReconnect) {
+ expect(forceReconnect).not.toHaveBeenCalled()
+ }
+ }
})
})
diff --git a/mobile/src/transport/connection-revival-triggers.test.ts b/mobile/src/transport/connection-revival-triggers.test.ts
index 108710317fa..3711aafd02f 100644
--- a/mobile/src/transport/connection-revival-triggers.test.ts
+++ b/mobile/src/transport/connection-revival-triggers.test.ts
@@ -1,60 +1,110 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { Mock } from 'vitest'
import { subscribeConnectionRevivalTriggers } from './connection-revival-triggers'
-import { startSocketDeadlineClock } from './socket-deadline-clock'
+import { recordSocketDeadlineAppState, startSocketDeadlineClock } from './socket-deadline-clock'
+type RevivalNudge = Parameters[0]
type AppStateListener = (next: string) => void
type NetworkSnapshot = { isConnected?: boolean; type?: string }
type NetworkListener = (state: NetworkSnapshot) => void
-let appStateListener: AppStateListener | null = null
-let networkListener: NetworkListener | null = null
+const appStateListeners = new Map>()
+const networkListeners = new Set()
+const subscriptions = new Set<() => void>()
let seededNetworkState: NetworkSnapshot = { isConnected: true, type: 'WIFI' }
+let readNetworkState = () => Promise.resolve(seededNetworkState)
const appStateRemove = vi.fn()
const networkRemove = vi.fn()
vi.mock('react-native', () => ({
AppState: {
currentState: 'active',
- addEventListener: (_event: string, listener: AppStateListener) => {
- appStateListener = listener
- return { remove: appStateRemove }
+ addEventListener: (event: string, listener: AppStateListener) => {
+ const listeners = appStateListeners.get(event) ?? new Set()
+ appStateListeners.set(event, listeners)
+ listeners.add(listener)
+ return {
+ remove: () => {
+ appStateRemove()
+ listeners.delete(listener)
+ }
+ }
}
}
}))
vi.mock('expo-network', () => ({
- getNetworkStateAsync: () => Promise.resolve(seededNetworkState),
+ getNetworkStateAsync: () => readNetworkState(),
addNetworkStateListener: (listener: NetworkListener) => {
- networkListener = listener
- return { remove: networkRemove }
+ networkListeners.add(listener)
+ return {
+ remove: () => {
+ networkRemove()
+ networkListeners.delete(listener)
+ }
+ }
}
}))
-// Why: the baseline seed resolves on a microtask; flush it so listener
-// events in the test observe the same ordering as a real subscription.
-async function subscribeAndSeed(nudge: () => void): Promise<() => void> {
- const unsubscribe = subscribeConnectionRevivalTriggers(nudge)
+async function subscribeAndSeed(nudge: RevivalNudge): Promise<() => void> {
+ const unsubscribe = subscribe(nudge)
await Promise.resolve()
return unsubscribe
}
+function subscribe(nudge: RevivalNudge): () => void {
+ const unsubscribe = subscribeConnectionRevivalTriggers(nudge)
+ subscriptions.add(unsubscribe)
+ return () => {
+ subscriptions.delete(unsubscribe)
+ unsubscribe()
+ }
+}
+
+function emitAppState(next: string): void {
+ expect(appStateListeners.get('change')?.size).toBe(1)
+ for (const listener of appStateListeners.get('change') ?? []) {
+ listener(next)
+ }
+}
+
+function emitNetwork(state: NetworkSnapshot): void {
+ expect(networkListeners.size).toBe(1)
+ for (const listener of networkListeners) {
+ listener(state)
+ }
+}
+
describe('subscribeConnectionRevivalTriggers', () => {
- let nudge: ReturnType
+ let nudge: Mock
beforeEach(() => {
vi.clearAllMocks()
- appStateListener = null
- networkListener = null
+ appStateListeners.clear()
+ networkListeners.clear()
+ readNetworkState = () => Promise.resolve(seededNetworkState)
seededNetworkState = { isConnected: true, type: 'WIFI' }
- nudge = vi.fn()
+ nudge = vi.fn()
+ })
+
+ afterEach(() => {
+ for (const unsubscribe of subscriptions) {
+ unsubscribe()
+ }
+ subscriptions.clear()
+ expect([...appStateListeners.values()].every((listeners) => listeners.size === 0)).toBe(true)
+ expect(networkListeners.size).toBe(0)
+ recordSocketDeadlineAppState('active')
+ vi.restoreAllMocks()
})
it('nudges when the app returns to the foreground, not on background', async () => {
await subscribeAndSeed(nudge)
- appStateListener?.('background')
+ emitAppState('background')
expect(nudge).not.toHaveBeenCalled()
- appStateListener?.('active')
+ emitAppState('active')
expect(nudge).toHaveBeenCalledTimes(1)
+ expect(nudge).toHaveBeenLastCalledWith('app-resume')
})
it('keeps elapsed background time when a device sleep pauses the monotonic clock', async () => {
@@ -65,12 +115,13 @@ describe('subscribeConnectionRevivalTriggers', () => {
const unsubscribe = await subscribeAndSeed(nudge)
const elapsed = startSocketDeadlineClock()
try {
- appStateListener?.('background')
+ emitAppState('background')
wall += 3 * 60 * 60_000
monotonic += 12_000
- appStateListener?.('active')
+ emitAppState('active')
expect(elapsed()).toBe(3 * 60 * 60_000)
expect(nudge).toHaveBeenCalledTimes(1)
+ expect(nudge).toHaveBeenLastCalledWith('app-resume')
} finally {
unsubscribe()
wallClock.mockRestore()
@@ -80,37 +131,51 @@ describe('subscribeConnectionRevivalTriggers', () => {
it('nudges when the network comes back online', async () => {
await subscribeAndSeed(nudge)
- networkListener?.({ isConnected: false, type: 'NONE' })
+ emitNetwork({ isConnected: false, type: 'NONE' })
expect(nudge).not.toHaveBeenCalled()
- networkListener?.({ isConnected: true, type: 'WIFI' })
+ emitNetwork({ isConnected: true, type: 'WIFI' })
expect(nudge).toHaveBeenCalledTimes(1)
+ expect(nudge).toHaveBeenLastCalledWith('network-change')
})
it('nudges when the app started offline and the first event is the recovery', async () => {
seededNetworkState = { isConnected: false, type: 'NONE' }
await subscribeAndSeed(nudge)
- networkListener?.({ isConnected: true, type: 'WIFI' })
+ emitNetwork({ isConnected: true, type: 'WIFI' })
expect(nudge).toHaveBeenCalledTimes(1)
+ expect(nudge).toHaveBeenLastCalledWith('network-change')
})
it('nudges on a Wi-Fi to cellular handoff that never reports offline', async () => {
await subscribeAndSeed(nudge)
- networkListener?.({ isConnected: true, type: 'CELLULAR' })
+ emitNetwork({ isConnected: true, type: 'CELLULAR' })
expect(nudge).toHaveBeenCalledTimes(1)
+ expect(nudge).toHaveBeenLastCalledWith('network-change')
})
it('stays quiet when the network state matches the seeded baseline', async () => {
await subscribeAndSeed(nudge)
- networkListener?.({ isConnected: true, type: 'WIFI' })
- networkListener?.({ isConnected: true, type: 'WIFI' })
+ emitNetwork({ isConnected: true, type: 'WIFI' })
+ emitNetwork({ isConnected: true, type: 'WIFI' })
expect(nudge).not.toHaveBeenCalled()
})
- it('ignores a stale seed that resolves after unsubscribe', async () => {
- seededNetworkState = { isConnected: false, type: 'NONE' }
- const unsubscribe = subscribeConnectionRevivalTriggers(nudge)
- unsubscribe()
+ it('keeps a newer network event when a stale seed resolves later', async () => {
+ let finishSeed: (state: NetworkSnapshot) => void = () => {}
+ const pendingSeed = new Promise((resolve) => {
+ finishSeed = resolve
+ })
+ readNetworkState = () => pendingSeed
+ const unsubscribe = subscribe(nudge)
+ emitNetwork({ isConnected: true, type: 'CELLULAR' })
+ finishSeed({ isConnected: true, type: 'WIFI' })
await Promise.resolve()
+ emitNetwork({ isConnected: true, type: 'CELLULAR' })
+ expect(nudge).not.toHaveBeenCalled()
+ emitNetwork({ isConnected: true, type: 'WIFI' })
+ expect(nudge).toHaveBeenCalledTimes(1)
+ expect(nudge).toHaveBeenLastCalledWith('network-change')
+ unsubscribe()
expect(appStateRemove).toHaveBeenCalledTimes(1)
expect(networkRemove).toHaveBeenCalledTimes(1)
})
diff --git a/mobile/src/transport/host-app-version-store.test.ts b/mobile/src/transport/host-app-version-store.test.ts
index 9f7d916c3a7..3fe7dced09b 100644
--- a/mobile/src/transport/host-app-version-store.test.ts
+++ b/mobile/src/transport/host-app-version-store.test.ts
@@ -1,8 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const asyncStorageMock = vi.hoisted(() => ({
- getItem: vi.fn(),
- setItem: vi.fn()
+ values: new Map(),
+ getItem: vi.fn<(key: string) => Promise>(),
+ setItem: vi.fn<(key: string, value: string) => Promise>()
}))
vi.mock('@react-native-async-storage/async-storage', () => ({
@@ -14,36 +15,48 @@ import { loadHostAppVersion, recordHostAppVersion } from './host-app-version-sto
describe('host app version store', () => {
beforeEach(() => {
- vi.clearAllMocks()
- asyncStorageMock.getItem.mockResolvedValue(null)
- asyncStorageMock.setItem.mockResolvedValue(undefined)
+ asyncStorageMock.values.clear()
+ asyncStorageMock.getItem.mockReset().mockImplementation(async (key) => {
+ return asyncStorageMock.values.get(key) ?? null
+ })
+ asyncStorageMock.setItem.mockReset().mockImplementation(async (key, value) => {
+ asyncStorageMock.values.set(key, value)
+ })
})
it('persists a bounded version once per host', async () => {
- asyncStorageMock.getItem.mockResolvedValueOnce(null).mockResolvedValueOnce('1.4.191')
-
await recordHostAppVersion('host-1', ' 1.4.191 ')
await recordHostAppVersion('host-1', '1.4.191')
+ await recordHostAppVersion('host-2', '1.4.188')
- expect(asyncStorageMock.setItem).toHaveBeenCalledOnce()
+ await expect(loadHostAppVersion('host-1')).resolves.toBe('1.4.191')
+ await expect(loadHostAppVersion('host-2')).resolves.toBe('1.4.188')
+
+ expect(asyncStorageMock.setItem).toHaveBeenCalledTimes(2)
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
'orca:host-app-version:v1:host-1',
'1.4.191'
)
+ expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
+ 'orca:host-app-version:v1:host-2',
+ '1.4.188'
+ )
})
it('loads a previously observed version without requiring a live host', async () => {
- asyncStorageMock.getItem.mockResolvedValue('1.4.188')
+ asyncStorageMock.values.set('orca:host-app-version:v1:host-1', '1.4.188')
await expect(loadHostAppVersion('host-1')).resolves.toBe('1.4.188')
+ await expect(loadHostAppVersion('host-2')).resolves.toBeNull()
+ expect(asyncStorageMock.setItem).not.toHaveBeenCalled()
})
it('rejects multiline, empty, and oversized host-provided values', async () => {
- expect(normalizeHostAppVersion('1.4.191\nInjected: value')).toBeNull()
- expect(normalizeHostAppVersion(' ')).toBeNull()
- expect(normalizeHostAppVersion('x'.repeat(65))).toBeNull()
-
- await recordHostAppVersion('host-1', '1.4.191\nInjected: value')
+ for (const value of ['1.4.191\nInjected: value', ' ', 'x'.repeat(65)]) {
+ expect(normalizeHostAppVersion(value)).toBeNull()
+ await recordHostAppVersion('host-1', value)
+ }
+ expect(asyncStorageMock.getItem).not.toHaveBeenCalled()
expect(asyncStorageMock.setItem).not.toHaveBeenCalled()
})
})
diff --git a/mobile/src/transport/host-catalog-selection.test.ts b/mobile/src/transport/host-catalog-selection.test.ts
index ea5d2246fbe..74f6a628580 100644
--- a/mobile/src/transport/host-catalog-selection.test.ts
+++ b/mobile/src/transport/host-catalog-selection.test.ts
@@ -1,16 +1,17 @@
import { describe, expect, it } from 'vitest'
import { selectConnectableHostProfiles, sortHostsByLastConnected } from './host-catalog-selection'
-import type { HostCatalogEntry, HostProfile } from './types'
+import type { HostCatalogEntry, HostProfile, StoredHostProfile } from './types'
-const profile: HostProfile = {
+const stored: StoredHostProfile = {
id: 'host-ready',
name: 'Ready',
endpoint: 'ws://127.0.0.1:1',
- deviceToken: 'token',
publicKeyB64: 'key-ready',
lastConnected: 0
}
+const profile: HostProfile = { ...stored, deviceToken: 'token' }
+
function unavailable(
id: string,
credentialStatus: 'temporarily-unavailable' | 'missing'
@@ -30,7 +31,7 @@ describe('selectConnectableHostProfiles', () => {
it('excludes unavailable catalog entries from connection priming', () => {
expect(
selectConnectableHostProfiles([
- { ...profile, credentialStatus: 'ready', profile },
+ { ...stored, credentialStatus: 'ready', profile },
unavailable('host-locked', 'temporarily-unavailable'),
unavailable('host-missing', 'missing')
])
diff --git a/mobile/src/transport/host-descriptor-store.test.ts b/mobile/src/transport/host-descriptor-store.test.ts
index d356c02ea45..c027c4ced5c 100644
--- a/mobile/src/transport/host-descriptor-store.test.ts
+++ b/mobile/src/transport/host-descriptor-store.test.ts
@@ -57,6 +57,7 @@ describe('host descriptor store', () => {
await act(async () => {
renderer = create(createElement(Row, { hostId: 'host-1' }))
})
+ expect(seen['host-1']?.at(-1)).toEqual({ machineName: 'Desk', platform: 'darwin' })
const renders = seen['host-1']?.length ?? 0
await act(async () => {
recordHostDescriptor('host-1', { machineName: 'Desk', platform: 'darwin' })
@@ -81,6 +82,8 @@ describe('host descriptor store', () => {
recordHostDescriptor('host-1', { machineName: 'Desk', platform: 'darwin' })
})
+ expect(seen['host-1']?.at(-1)).toEqual({ machineName: 'Desk', platform: 'darwin' })
+ expect(seen['host-2']?.at(-1)).toBeNull()
expect(seen['host-2']).toHaveLength(host2Renders)
})
})
diff --git a/mobile/src/transport/host-list-load-sharing.test.ts b/mobile/src/transport/host-list-load-sharing.test.ts
index 9d2f10e8b8d..921003a3e40 100644
--- a/mobile/src/transport/host-list-load-sharing.test.ts
+++ b/mobile/src/transport/host-list-load-sharing.test.ts
@@ -1,10 +1,26 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { dropSharedHostListLoad, shareHostListLoad } from './host-list-load-sharing'
+import {
+ dropSharedHostListLoad,
+ shareHostListLoad,
+ type HostListSnapshot
+} from './host-list-load-sharing'
import type { HostProfile } from './types'
+function snapshot(id: string): HostListSnapshot {
+ const stored = {
+ id,
+ name: id,
+ endpoint: 'ws://127.0.0.1:1',
+ publicKeyB64: `key-${id}`,
+ lastConnected: 0
+ }
+ const profile: HostProfile = { ...stored, deviceToken: 'token' }
+ return { catalog: [{ ...stored, credentialStatus: 'ready', profile }], profiles: [profile] }
+}
+
function deferred() {
- let resolve: (hosts: HostProfile[]) => void = () => {}
- const promise = new Promise((res) => {
+ let resolve: (hosts: HostListSnapshot) => void = () => {}
+ const promise = new Promise((res) => {
resolve = res
})
return { promise, resolve }
@@ -21,42 +37,60 @@ describe('shareHostListLoad', () => {
const first = shareHostListLoad(load)
const second = shareHostListLoad(load)
- pass.resolve([])
+ const value = snapshot('host-ready')
+ pass.resolve(value)
expect(load).toHaveBeenCalledTimes(1)
expect(await first).toBe(await second)
+ expect(await first).toEqual(value)
+
+ const next = shareHostListLoad(load)
+ expect(load).toHaveBeenCalledTimes(2)
+ expect(await next).toEqual(value)
})
it('starts a fresh pass for callers that arrive after a write dropped it', async () => {
const stale = deferred()
const fresh = deferred()
- const load = vi.fn().mockReturnValueOnce(stale.promise).mockReturnValueOnce(fresh.promise)
+ const load = vi
+ .fn<() => Promise>()
+ .mockReturnValueOnce(stale.promise)
+ .mockReturnValueOnce(fresh.promise)
const before = shareHostListLoad(load)
dropSharedHostListLoad()
const after = shareHostListLoad(load)
- stale.resolve([])
- fresh.resolve([])
+ const staleValue = snapshot('host-stale')
+ const freshValue = snapshot('host-fresh')
+ stale.resolve(staleValue)
+ fresh.resolve(freshValue)
expect(load).toHaveBeenCalledTimes(2)
expect(await after).not.toBe(await before)
+ expect(await before).toEqual(staleValue)
+ expect(await after).toEqual(freshValue)
})
it('keeps the replacement pass on offer when the dropped one settles late', async () => {
const stale = deferred()
const fresh = deferred()
- const load = vi.fn().mockReturnValueOnce(stale.promise).mockReturnValueOnce(fresh.promise)
+ const load = vi
+ .fn<() => Promise>()
+ .mockReturnValueOnce(stale.promise)
+ .mockReturnValueOnce(fresh.promise)
const before = shareHostListLoad(load)
dropSharedHostListLoad()
const replacement = shareHostListLoad(load)
// The dropped pass settles after its replacement was registered.
- stale.resolve([])
- await before
+ const staleValue = snapshot('host-stale')
+ stale.resolve(staleValue)
+ expect(await before).toEqual(staleValue)
expect(shareHostListLoad(load)).toBe(replacement)
expect(load).toHaveBeenCalledTimes(2)
- fresh.resolve([])
- await replacement
+ const freshValue = snapshot('host-fresh')
+ fresh.resolve(freshValue)
+ expect(await replacement).toEqual(freshValue)
})
})
diff --git a/mobile/src/transport/host-names.test.ts b/mobile/src/transport/host-names.test.ts
index e4e06417ac3..ffa608638b7 100644
--- a/mobile/src/transport/host-names.test.ts
+++ b/mobile/src/transport/host-names.test.ts
@@ -7,13 +7,15 @@ describe('getNextHostNameFromHosts', () => {
getNextHostNameFromHosts([
{ name: 'Host 2' },
{ name: 'Custom workstation' },
+ { name: 'Host 99 extra' },
+ { name: 'My Host 100' },
{ name: 'Host 7' },
{ name: 'Host 3' }
])
).toBe('Host 8')
})
- it('handles very large host lists without spreading number arrays', () => {
+ it('handles 130,000 host names', () => {
const hosts = Array.from({ length: 130_000 }, (_, index) => ({
name: `Host ${index + 1}`
}))
diff --git a/mobile/src/transport/persisted-connection-log-store.test.ts b/mobile/src/transport/persisted-connection-log-store.test.ts
index 6635ccec696..ffb8d00edf0 100644
--- a/mobile/src/transport/persisted-connection-log-store.test.ts
+++ b/mobile/src/transport/persisted-connection-log-store.test.ts
@@ -19,6 +19,8 @@ describe('persisted connection log store', () => {
})
afterEach(() => {
+ vi.restoreAllMocks()
+ vi.clearAllTimers()
vi.useRealTimers()
vi.resetModules()
})
@@ -40,7 +42,15 @@ describe('persisted connection log store', () => {
message: 'Relay: active relay session failed'
}
]
- vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(stored))
+ const key = 'orca.mobile.connection-log.v1.host-a'
+ const storage = new Map([[key, JSON.stringify(stored)]])
+ vi.mocked(AsyncStorage.getItem).mockImplementation(
+ async (requestedKey) => storage.get(requestedKey) ?? null
+ )
+ vi.mocked(AsyncStorage.setItem).mockImplementation(async (requestedKey, value) => {
+ storage.set(requestedKey, value)
+ })
+ vi.spyOn(Math, 'random').mockReturnValueOnce(0.25).mockReturnValue(0.5)
vi.resetModules()
const { connectionLogStore, recordConnectionClientSessionStart } =
await import('./persisted-connection-log-store')
@@ -48,10 +58,17 @@ describe('persisted connection log store', () => {
recordConnectionClientSessionStart('host-a')
await connectionLogStore.hydrate('host-a')
- expect(connectionLogStore.get('host-a').map((entry) => entry.code)).toEqual([
+ const expected = connectionLogStore.get('host-a')
+ expect(expected.map((entry) => entry.code)).toEqual([
'client-session-started',
'relay-session-failed',
'client-session-started'
])
+ await vi.advanceTimersByTimeAsync(0)
+ expect(storage.get(key)).toBe(JSON.stringify(expected))
+ vi.resetModules()
+ const reloaded = await import('./persisted-connection-log-store')
+ await reloaded.connectionLogStore.hydrate('host-a')
+ expect(reloaded.connectionLogStore.get('host-a')).toEqual(expected)
})
})
diff --git a/mobile/src/transport/runtime-status-probe.test.ts b/mobile/src/transport/runtime-status-probe.test.ts
index 697a0e7d85f..24ca2cc00d3 100644
--- a/mobile/src/transport/runtime-status-probe.test.ts
+++ b/mobile/src/transport/runtime-status-probe.test.ts
@@ -10,16 +10,17 @@ type ProbeOutcome = RpcResponse | Error
function makeClient(outcomes: ProbeOutcome[]): {
client: UnvalidatedRpcRequestPort
calls: () => number
+ requests: () => Parameters[]
} {
let calls = 0
- const client: UnvalidatedRpcRequestPort = {
- sendRequest: () => {
+ const client = {
+ sendRequest: vi.fn(() => {
const outcome = outcomes[Math.min(calls, outcomes.length - 1)]!
calls += 1
return outcome instanceof Error ? Promise.reject(outcome) : Promise.resolve(outcome)
- }
+ })
}
- return { client, calls: () => calls }
+ return { client, calls: () => calls, requests: () => client.sendRequest.mock.calls }
}
const ok = (result: unknown): RpcResponse => ({
@@ -35,36 +36,50 @@ async function flushMicrotasks(): Promise {
}
describe('startRuntimeStatusProbe', () => {
+ let cancelProbe: (() => void) | undefined
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
+ cancelProbe?.()
+ cancelProbe = undefined
vi.useRealTimers()
})
it('delivers the parsed status once and stops', async () => {
- const { client, calls } = makeClient([
+ const { client, calls, requests } = makeClient([
ok({ machineName: 'm4airs-Air', hostPlatform: 'darwin', capabilities: ['a.v1'] })
])
const seen: (HostStatusReply | null)[] = []
- const cancel = startRuntimeStatusProbe(client, (status) => seen.push(status))
+ cancelProbe = startRuntimeStatusProbe(client, (status) => seen.push(status))
await flushMicrotasks()
expect(seen).toHaveLength(1)
- expect(seen[0]).toMatchObject({ machineName: 'm4airs-Air', hostPlatform: 'darwin' })
+ expect(seen[0]).toEqual({
+ machineName: 'm4airs-Air',
+ hostPlatform: 'darwin',
+ capabilities: ['a.v1']
+ })
+ expect(requests()).toEqual([['status.get']])
expect(calls()).toBe(1)
await vi.advanceTimersByTimeAsync(60_000)
expect(calls()).toBe(1)
- cancel()
+ cancelProbe()
})
it('delivers null for an answer this build cannot decode, without retrying', async () => {
- const { client, calls } = makeClient([ok(null)])
+ const { client, calls, requests } = makeClient([ok(null)])
const seen: (HostStatusReply | null)[] = []
- const cancel = startRuntimeStatusProbe(client, (status) => seen.push(status))
+ const failed = vi.fn()
+ cancelProbe = startRuntimeStatusProbe(client, (status) => seen.push(status), failed)
await flushMicrotasks()
expect(seen).toEqual([null])
+ await vi.advanceTimersByTimeAsync(60_000)
+ expect(seen).toEqual([null])
+ expect(failed).not.toHaveBeenCalled()
+ expect(requests()).toEqual([['status.get']])
+ expect(vi.getTimerCount()).toBe(0)
expect(calls()).toBe(1)
- cancel()
+ cancelProbe()
})
it('retries a refusal and a cutover rejection until a status lands', async () => {
@@ -74,49 +89,66 @@ describe('startRuntimeStatusProbe', () => {
error: { code: 'unavailable', message: 'no' },
_meta: { runtimeId: 'r1' }
}
- const { client, calls } = makeClient([
+ const { client, calls, requests } = makeClient([
refusal,
new LogicalClientCutoverError(),
ok({ machineName: 'Studio' })
])
const seen: (HostStatusReply | null)[] = []
- const cancel = startRuntimeStatusProbe(client, (status) => seen.push(status))
+ cancelProbe = startRuntimeStatusProbe(client, (status) => seen.push(status))
await flushMicrotasks()
expect(seen).toEqual([])
- await vi.advanceTimersByTimeAsync(1_000)
+ expect(calls()).toBe(1)
+ await vi.advanceTimersByTimeAsync(999)
+ expect(calls()).toBe(1)
+ await vi.advanceTimersByTimeAsync(1)
await flushMicrotasks()
- await vi.advanceTimersByTimeAsync(250)
+ expect(calls()).toBe(2)
+ expect(seen).toEqual([])
+ await vi.advanceTimersByTimeAsync(249)
+ expect(calls()).toBe(2)
+ await vi.advanceTimersByTimeAsync(1)
await flushMicrotasks()
expect(seen).toHaveLength(1)
expect(seen[0]).toMatchObject({ machineName: 'Studio' })
expect(calls()).toBe(3)
- cancel()
+ expect(requests()).toEqual([['status.get'], ['status.get'], ['status.get']])
+ expect(vi.getTimerCount()).toBe(0)
+ cancelProbe()
})
- it('reports each failed attempt once its retry is scheduled, and never an answer', async () => {
- const { client } = makeClient([
+ it('reports failed attempts after scheduling retries and cancels pending work', async () => {
+ const { client, calls, requests } = makeClient([
new Error('request timed out'),
- new LogicalClientCutoverError(),
- ok(null)
+ new LogicalClientCutoverError()
])
const seen: (HostStatusReply | null)[] = []
let failed = 0
- const cancel = startRuntimeStatusProbe(
+ const failedTimerCounts: number[] = []
+ cancelProbe = startRuntimeStatusProbe(
client,
(status) => seen.push(status),
() => {
failed += 1
+ failedTimerCounts.push(vi.getTimerCount())
}
)
await flushMicrotasks()
expect(failed).toBe(1)
+ expect(failedTimerCounts).toEqual([1])
+ expect(vi.getTimerCount()).toBe(1)
await vi.advanceTimersByTimeAsync(1_000)
await flushMicrotasks()
expect(failed).toBe(2)
- await vi.advanceTimersByTimeAsync(250)
- await flushMicrotasks()
- expect(seen).toEqual([null])
+ expect(failedTimerCounts).toEqual([1, 1])
+ expect(vi.getTimerCount()).toBe(1)
+ cancelProbe()
+ expect(vi.getTimerCount()).toBe(0)
+ await vi.advanceTimersByTimeAsync(60_000)
+ expect(seen).toEqual([])
expect(failed).toBe(2)
- cancel()
+ expect(failedTimerCounts).toEqual([1, 1])
+ expect(calls()).toBe(2)
+ expect(requests()).toEqual([['status.get'], ['status.get']])
})
})
diff --git a/mobile/tests-typecheck-baseline.txt b/mobile/tests-typecheck-baseline.txt
index 30f2b0b9d8a..c9e34063af3 100644
--- a/mobile/tests-typecheck-baseline.txt
+++ b/mobile/tests-typecheck-baseline.txt
@@ -85,10 +85,8 @@ src/test-support/rpc-recording/native-mounting-substitutes.test.ts
src/test-support/rpc-recording/operation-module-loader.test.ts
src/test-support/rpc-recording/screen-native-substitutes.test.ts
src/transport/client-context.test.ts
-src/transport/connection-revival-triggers.test.ts
src/transport/host-credential-cleanup.test.ts
src/transport/host-edit-navigation.test.ts
-src/transport/host-list-load-sharing.test.ts
src/transport/host-removal-lifecycle.test.ts
src/transport/host-status-gates.test.ts
src/transport/host-store.test.ts
diff --git a/src/main/automations/dispatch-tokens.test.ts b/src/main/automations/dispatch-tokens.test.ts
index 4898f75fe4d..03225a0d6f9 100644
--- a/src/main/automations/dispatch-tokens.test.ts
+++ b/src/main/automations/dispatch-tokens.test.ts
@@ -1,5 +1,6 @@
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
import {
+ beginAutomationDispatchTokenUse,
createAutomationDispatchToken,
getAutomationDispatchTokenCountForTests,
MAX_AUTOMATION_DISPATCH_TOKENS
@@ -7,10 +8,30 @@ import {
describe('automation dispatch tokens', () => {
it('bounds distinct token churn', () => {
- for (let index = 0; index < MAX_AUTOMATION_DISPATCH_TOKENS + 4; index += 1) {
- createAutomationDispatchToken(`automation-${index}`, `run-${index}`)
- }
+ vi.useFakeTimers({ toFake: ['Date'] })
+ vi.setSystemTime(1_700_000_000_000)
+ try {
+ const tokens = Array.from({ length: MAX_AUTOMATION_DISPATCH_TOKENS + 4 }, (_, index) => {
+ const automationId = `automation-${index}`
+ const runId = `run-${index}`
+ return {
+ automationId,
+ runId,
+ token: createAutomationDispatchToken(automationId, runId)
+ }
+ })
- expect(getAutomationDispatchTokenCountForTests()).toBe(MAX_AUTOMATION_DISPATCH_TOKENS)
+ expect(getAutomationDispatchTokenCountForTests()).toBe(MAX_AUTOMATION_DISPATCH_TOKENS)
+ for (const token of tokens.slice(0, 4)) {
+ expect(beginAutomationDispatchTokenUse({ ...token, reservationId: 'request-1' })).toBe(
+ false
+ )
+ }
+ for (const token of tokens.slice(4)) {
+ expect(beginAutomationDispatchTokenUse({ ...token, reservationId: 'request-1' })).toBe(true)
+ }
+ } finally {
+ vi.useRealTimers()
+ }
})
})
diff --git a/src/main/memory/collector-windows-sweep.test.ts b/src/main/memory/collector-windows-sweep.test.ts
index 94c66efa031..39f05713b4a 100644
--- a/src/main/memory/collector-windows-sweep.test.ts
+++ b/src/main/memory/collector-windows-sweep.test.ts
@@ -568,27 +568,30 @@ describe('collectMemorySnapshot on Windows', () => {
expect(snapshot.totalMemory).toBe(52428800)
})
- it('carries no commit metric on Unix, where ps has no committed-bytes column', async () => {
- vi.spyOn(os, 'platform').mockReturnValue('darwin')
- mockPsResponse('10 1 0 1024')
- listRegisteredPtysMock.mockReturnValue([
- {
- ptyId: 'pty-1',
- worktreeId: 'repo-1::/repo',
- sessionId: 'session-1',
- paneKey: 'pane-1',
- pid: 10
- }
- ])
- const { collectMemorySnapshot } = await loadCollector()
+ it.each(['darwin', 'linux'] as const)(
+ 'reports RSS without commit values on %s',
+ async (platform) => {
+ vi.spyOn(os, 'platform').mockReturnValue(platform)
+ mockPsResponse('10 1 0 1024')
+ listRegisteredPtysMock.mockReturnValue([
+ {
+ ptyId: 'pty-1',
+ worktreeId: 'repo-1::/repo',
+ sessionId: 'session-1',
+ paneKey: 'pane-1',
+ pid: 10
+ }
+ ])
+ const { collectMemorySnapshot } = await loadCollector()
- const snapshot = await collectMemorySnapshot(emptyStore)
+ const snapshot = await collectMemorySnapshot(emptyStore)
- expect(snapshot.processMemoryMetric).toBe('rss')
- expect(snapshot.processCommitMetric).toBeUndefined()
- expect(snapshot.totalPrivateMemory).toBeUndefined()
- expect(snapshot.worktrees[0].sessions[0].privateMemory).toBeUndefined()
- })
+ expect(snapshot.processMemoryMetric).toBe('rss')
+ expect(snapshot.processCommitMetric).toBeUndefined()
+ expect(snapshot.totalPrivateMemory).toBeUndefined()
+ expect(snapshot.worktrees[0].sessions[0].privateMemory).toBeUndefined()
+ }
+ )
it("attributes a shared ancestor's commit to one PTY only", async () => {
vi.spyOn(os, 'platform').mockReturnValue('win32')
diff --git a/src/main/memory/process-memory-metric.test.ts b/src/main/memory/process-memory-metric.test.ts
deleted file mode 100644
index f19e64e165a..00000000000
--- a/src/main/memory/process-memory-metric.test.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { describe, expect, it } from 'vitest'
-import { getProcessMemoryMetric } from './process-memory-metric'
-
-describe('process memory metric', () => {
- it('declares RSS for Unix process sweeps', () => {
- expect(getProcessMemoryMetric('darwin')).toBe('rss')
- expect(getProcessMemoryMetric('linux')).toBe('rss')
- })
-
- it('declares working set for Windows process sweeps', () => {
- expect(getProcessMemoryMetric('win32')).toBe('working-set')
- })
-})
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts
index dce8626769a..4c70e3d6b8b 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts
@@ -1,3 +1,7 @@
+import type {
+ AgentChildWorkStopAcknowledgement,
+ AgentSessionBackgroundTaskStops
+} from '../../../shared/agent-child-work-stop-targets'
// Chat Stop ends the captured child for every provider; default prompt Cancel interrupts its turn.
import type {
@@ -17,6 +21,15 @@ export type AgentSessionCancelOutcome = {
export type AgentSessionPromptCancelRoute = { kind: 'dismiss' } | { kind: 'stop' }
export type StructuredAgentSessionAdapterStop = {
+ /** Stop named tasks only; acknowledge each accepted stop immediately so later batch failures cannot erase it. */
+ stopBackgroundTasks?(input: {
+ sessionId: string
+ fence: number
+ taskIds: readonly string[]
+ acknowledge?: (acknowledgement: AgentChildWorkStopAcknowledgement) => void
+ }): Promise<{ cancelled: boolean; stillRunning?: true }>
+ /** Undefined when no live session exposes stop support. */
+ backgroundTaskStops?(sessionId: string): AgentSessionBackgroundTaskStops | undefined
/** Cancel only the requested turn; absent turnId also covers dispatches whose turns have not opened. */
cancelTurn(input: {
sessionId: string
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts
index 7a0d8921ed2..a504f0d8e58 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts
@@ -7,10 +7,6 @@ import type {
} from '../../../shared/agent-session-rewind'
// The host owns journal/lease authority; adapters report delivery uncertainty without guessing.
-import type {
- AgentChildWorkStopAcknowledgement,
- AgentSessionBackgroundTaskStops
-} from '../../../shared/agent-child-work-stop-targets'
import type {
AgentJournalAnsweredTurnIdentity,
AgentJournalItemIdentity,
@@ -289,15 +285,6 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop &
replacesGoal: boolean
signal?: AbortSignal
}): Promise<{ ok: true } | { ok: false; rejected: string }>
- /** Stop named tasks only; acknowledge each accepted stop immediately so later batch failures cannot erase it. */
- stopBackgroundTasks?(input: {
- sessionId: string
- fence: number
- taskIds: readonly string[]
- acknowledge?: (acknowledgement: AgentChildWorkStopAcknowledgement) => void
- }): Promise<{ cancelled: boolean; stillRunning?: true }>
- /** Undefined when no live session exposes stop support. */
- backgroundTaskStops?(sessionId: string): AgentSessionBackgroundTaskStops | undefined
/** Derive unanswered retained work from the live child; false without one. */
holdsDispatch?(sessionId: string): boolean
/** Captures only this input's pending answer/output in the supplied live acquisition. */
diff --git a/src/main/runtime/rpc/methods/connector.test.ts b/src/main/runtime/rpc/methods/connector.test.ts
index 8e366c8e049..a08cc8c7cc1 100644
--- a/src/main/runtime/rpc/methods/connector.test.ts
+++ b/src/main/runtime/rpc/methods/connector.test.ts
@@ -1,10 +1,45 @@
import { describe, expect, it } from 'vitest'
-import { requireSeam } from './connector'
+import { eraseRpcMethods, type RpcContext } from '../core'
+import { CONNECTOR_METHODS } from './connector'
+
+const methods = eraseRpcMethods(CONNECTOR_METHODS)
+
+// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: disabled handlers must refuse at this gate before reading any other runtime service.
+const context = { runtime: { getExternalConversationSeam: () => null } } as RpcContext
describe('connector RPC methods', () => {
- it('are refused while the host has connectors off', () => {
- expect(() => requireSeam({ getExternalConversationSeam: () => null })).toThrow(
- 'connectors_disabled'
+ it.each([
+ [
+ 'connector.start',
+ {
+ source: 'slack',
+ externalId: 'thread-1',
+ deliveryId: 'delivery-1',
+ agent: 'codex',
+ target: { kind: 'existing', worktree: 'workspace-1' },
+ prompt: 'Help with this thread'
+ }
+ ],
+ [
+ 'connector.send',
+ { source: 'slack', externalId: 'thread-1', deliveryId: 'delivery-1', text: 'Follow up' }
+ ],
+ ['connector.pendingPrompt', { source: 'slack', externalId: 'thread-1' }],
+ [
+ 'connector.answer',
+ { source: 'slack', externalId: 'thread-1', promptId: 'prompt-1', optionId: 'option-1' }
+ ],
+ ['connector.cancel', { source: 'slack', externalId: 'thread-1', deliveryId: 'delivery-1' }],
+ ['connector.watch', { forMs: 0 }]
+ ] as const)('%s refuses requests while the host has connectors off', async (name, input) => {
+ const method = methods.find((candidate) => candidate.name === name)
+ if (!method?.params) {
+ throw new Error(`Missing connector method: ${name}`)
+ }
+ const params = method.params.parse(input)
+
+ await expect(Promise.resolve().then(() => method.handler(params, context))).rejects.toThrow(
+ new Error('connectors_disabled')
)
})
})
diff --git a/src/main/sidecar-snapshot-file.test.ts b/src/main/sidecar-snapshot-file.test.ts
index 7df50763602..91b9318fbf7 100644
--- a/src/main/sidecar-snapshot-file.test.ts
+++ b/src/main/sidecar-snapshot-file.test.ts
@@ -6,12 +6,27 @@ import {
describe('sidecar snapshot queues', () => {
it('releases completed per-file queue entries', async () => {
- await Promise.all(
- Array.from({ length: 600 }, (_, index) =>
- withSidecarSnapshotQueue(`snapshot-${index}`, async () => undefined)
- )
+ const gate = Promise.withResolvers()
+ let started = 0
+ const tasks = Array.from({ length: 600 }, (_, index) =>
+ withSidecarSnapshotQueue(`snapshot-${index}`, async () => {
+ started += 1
+ await gate.promise
+ return `result-${index}`
+ })
)
+ try {
+ await Promise.resolve()
+ expect(started).toBe(600)
+ expect(_getSidecarSnapshotPendingFileCountForTests()).toBe(600)
+ } finally {
+ gate.resolve()
+ }
+
+ expect(await Promise.all(tasks)).toEqual(
+ Array.from({ length: 600 }, (_, index) => `result-${index}`)
+ )
await Promise.resolve()
expect(_getSidecarSnapshotPendingFileCountForTests()).toBe(0)
})
diff --git a/src/renderer/src/lib/launch-work-item-direct-messages.test.ts b/src/renderer/src/lib/launch-work-item-direct-messages.test.ts
index d1127cffd43..6b21e624735 100644
--- a/src/renderer/src/lib/launch-work-item-direct-messages.test.ts
+++ b/src/renderer/src/lib/launch-work-item-direct-messages.test.ts
@@ -1,8 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
-vi.mock('@/lib/new-workspace', () => ({
- isGitLabIssueUrl: (url: string) => url.includes('gitlab.example')
-}))
+vi.mock('@/lib/new-workspace', () => import('./linked-work-item-provider'))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback
@@ -11,7 +9,7 @@ vi.mock('@/i18n/i18n', () => ({
import { gitLabIssueNumber } from './launch-work-item-direct-messages'
describe('gitLabIssueNumber', () => {
- it('preserves zero-valued issue numbers when the URL is a GitLab issue URL', () => {
+ it('preserves GitLab issue numbers and rejects other work items', () => {
expect(
gitLabIssueNumber({
type: 'issue',
@@ -19,5 +17,47 @@ describe('gitLabIssueNumber', () => {
url: 'https://gitlab.example/acme/project/-/issues/0'
})
).toBe(0)
+ expect(
+ gitLabIssueNumber({
+ type: 'issue',
+ number: 7,
+ url: 'https://gitlab.com/acme/project/-/work_items/7'
+ })
+ ).toBe(7)
+ expect(
+ gitLabIssueNumber({
+ type: 'mr',
+ number: 7,
+ url: 'https://gitlab.example/acme/project/-/issues/7'
+ })
+ ).toBeUndefined()
+ expect(
+ gitLabIssueNumber({
+ type: 'issue',
+ number: 7,
+ url: 'https://github.com/acme/project/issues/7'
+ })
+ ).toBeUndefined()
+ expect(
+ gitLabIssueNumber({
+ type: 'issue',
+ number: 7,
+ url: 'https://gitlab.example/acme/project/-/merge_requests/7'
+ })
+ ).toBeUndefined()
+ expect(
+ gitLabIssueNumber({
+ type: 'issue',
+ url: 'https://gitlab.example/acme/project/-/issues/7'
+ })
+ ).toBeUndefined()
+ expect(
+ gitLabIssueNumber({
+ type: 'issue',
+ number: null,
+ url: 'https://gitlab.example/acme/project/-/issues/7'
+ })
+ ).toBeUndefined()
+ expect(gitLabIssueNumber({ type: 'issue', number: 7 })).toBeUndefined()
})
})
diff --git a/src/renderer/src/lib/workspace-port-groups.test.ts b/src/renderer/src/lib/workspace-port-groups.test.ts
index ae76580e868..e96a67b6fba 100644
--- a/src/renderer/src/lib/workspace-port-groups.test.ts
+++ b/src/renderer/src/lib/workspace-port-groups.test.ts
@@ -7,6 +7,10 @@ import {
describe('workspace port group caches', () => {
it('returns stable empty references when no scan result exists', () => {
+ expect(getWorkspacePortsByWorktreeId(null).size).toBe(0)
+ expect(getWorkspacePortGroups(null)).toEqual([])
+ expect(getExternalWorkspacePorts(null)).toEqual([])
+
expect(getWorkspacePortsByWorktreeId(null)).toBe(getWorkspacePortsByWorktreeId(undefined))
expect(getWorkspacePortGroups(null)).toBe(getWorkspacePortGroups(undefined))
expect(getExternalWorkspacePorts(null)).toBe(getExternalWorkspacePorts(undefined))
diff --git a/src/renderer/src/lib/workspace-session-resume-outcome.test.ts b/src/renderer/src/lib/workspace-session-resume-outcome.test.ts
index 8e5bc5a45b9..f094e686dee 100644
--- a/src/renderer/src/lib/workspace-session-resume-outcome.test.ts
+++ b/src/renderer/src/lib/workspace-session-resume-outcome.test.ts
@@ -1,19 +1,35 @@
import { describe, expect, it } from 'vitest'
-import { createTestStore } from '@/store/slices/store-test-helpers'
+import { createTestStore, makeTab } from '@/store/slices/store-test-helpers'
import { buildWorkspaceSessionPayload } from './workspace-session'
describe("the window's saved session", () => {
it("never carries main's resume outcomes, which are live state", () => {
const store = createTestStore()
+ const tab = makeTab({
+ id: 'tab-1',
+ worktreeId: 'wt-1',
+ ptyId: 'pty-1',
+ title: 'restored',
+ createdAt: 1
+ })
store.setState({
+ tabsByWorktree: { 'wt-1': [tab] },
+ terminalLayoutsByTabId: {
+ 'tab-1': {
+ root: { type: 'leaf', leafId: 'leaf-1' },
+ activeLeafId: 'leaf-1',
+ expandedLeafId: null
+ }
+ },
terminalPaneResumeOutcomesByPaneKey: {
'tab-1:leaf-1': { ptyId: 'pty-1', ptyGeneration: 3, outcome: 'restored' }
}
})
- const saved = JSON.stringify(buildWorkspaceSessionPayload(store.getState()))
+ const saved = buildWorkspaceSessionPayload(store.getState())
- expect(saved).not.toContain('resumeOutcome')
- expect(saved).not.toContain('restored')
+ expect(saved).not.toHaveProperty('terminalPaneResumeOutcomesByPaneKey')
+ expect(saved.tabsByWorktree).toEqual({ 'wt-1': [tab] })
+ expect(saved.terminalLayoutsByTabId).toEqual(store.getState().terminalLayoutsByTabId)
})
})
diff --git a/src/shared/agent-child-row-model.test.ts b/src/shared/agent-child-row-model.test.ts
index a99ef0a7152..d5e5ca42d04 100644
--- a/src/shared/agent-child-row-model.test.ts
+++ b/src/shared/agent-child-row-model.test.ts
@@ -149,7 +149,8 @@ describe('buildAgentChildRowModels: the display vocabulary', () => {
{ displayState: 'unverifiable', detail: { kind: 'no-update' } }
]
])('%s', (_name, views, expected) => {
- expect(only(views)).toMatchObject(expected)
+ const row = only(views)
+ expect({ displayState: row.displayState, detail: row.detail }).toEqual(expected)
})
it('suppresses the tool line while monitoring, even when a stale operation arrives', () => {
@@ -356,7 +357,12 @@ describe('agentChildRowContextForParent: the reader clock', () => {
it("measures a mirrored parent on this machine's receipt clock, never the host's", () => {
const context = agentChildRowContextForParent(MIRRORED_PARENT, false)
- expect(context).toMatchObject({ parentObservedAt: 1_190_000, hostClockOffsetMs: -20 * 60_000 })
+ expect(context).toEqual({
+ parentEvidenceFresh: false,
+ transportObservation: 'live',
+ parentObservedAt: 1_190_000,
+ hostClockOffsetMs: -20 * 60_000
+ })
const [legacy] = buildLegacyAgentChildRowModels(
[{ id: 'a', state: 'working', startedAt: 10 }],
context
diff --git a/src/shared/agent-launch-operation.test.ts b/src/shared/agent-launch-operation.test.ts
index 6c2907225fa..0bac95f08fe 100644
--- a/src/shared/agent-launch-operation.test.ts
+++ b/src/shared/agent-launch-operation.test.ts
@@ -55,7 +55,7 @@ describe('fields the launch fingerprint covers', () => {
)
})
- it('separates two launches that reserved different panes', () => {
+ it('separates two launches that reserved different panes or sessions', () => {
// The key is baked into the pane's env; replaying another pane's key strands the new reservation.
expect(computeAgentLaunchFingerprint({ ...BASE, paneKey: PANE_KEY })).not.toBe(
computeAgentLaunchFingerprint({ ...BASE, paneKey: OTHER_PANE_KEY })
@@ -63,6 +63,13 @@ describe('fields the launch fingerprint covers', () => {
expect(computeAgentLaunchFingerprint({ ...BASE, paneKey: PANE_KEY })).not.toBe(
computeAgentLaunchFingerprint(BASE)
)
+ const sessionId = '11111111-1111-4111-8111-111111111111'
+ expect(computeAgentLaunchFingerprint({ ...BASE, sessionId })).not.toBe(
+ computeAgentLaunchFingerprint({ ...BASE, sessionId: '22222222-2222-4222-8222-222222222222' })
+ )
+ expect(computeAgentLaunchFingerprint({ ...BASE, sessionId })).not.toBe(
+ computeAgentLaunchFingerprint(BASE)
+ )
})
it('separates a retry that picked another surface, or none, from the first launch', () => {
@@ -122,13 +129,4 @@ describe('compatibility with rows written before these fields existed', () => {
computeAgentLaunchFingerprint({ ...BASE, agentArgs: '--model opus', cwd: '/repo/packages/a' })
).toBe(beforePaneKey)
})
-
- it('still matches when the caller sends only telemetry the digest excludes', () => {
- const previousBuild = canonicalAgentSessionDigest({
- method: 'agent.launch',
- agent: BASE.agent,
- target: BASE.target
- })
- expect(fingerprintOfWirePayload({ ...BASE, launchSource: 'quick_command' })).toBe(previousBuild)
- })
})
diff --git a/src/shared/agent-launch-remote.test.ts b/src/shared/agent-launch-remote.test.ts
index 4656dab39fc..bfd5dd47262 100644
--- a/src/shared/agent-launch-remote.test.ts
+++ b/src/shared/agent-launch-remote.test.ts
@@ -26,7 +26,7 @@ describe('repoIsRemote', () => {
expect(repoIsRemote({ connectionId: null, executionHostId: 'runtime:vm-1' })).toBe(false)
})
- it('keeps plain local and WSL rows local', () => {
+ it('keeps plain local rows local', () => {
expect(repoIsRemote({ connectionId: null, executionHostId: null })).toBe(false)
expect(repoIsRemote({ connectionId: null, executionHostId: 'local' })).toBe(false)
})
diff --git a/src/shared/agent-pending-prompt.test.ts b/src/shared/agent-pending-prompt.test.ts
index a8ffc8fc6bf..ba632038d10 100644
--- a/src/shared/agent-pending-prompt.test.ts
+++ b/src/shared/agent-pending-prompt.test.ts
@@ -20,24 +20,23 @@ function waiting(interactivePrompt: string, toolName?: string) {
describe('readAgentPromptAnswers', () => {
it('reads an approval with the cards’ own allow and deny keys', () => {
- const read = readAgentPromptAnswers(waiting(approval), 'claude', 'p1', true)
- expect(read?.prompt).toEqual({
- kind: 'approval',
- promptId: 'p1',
- tool: 'Bash',
- summary: 'rm -rf build',
- options: [
- { id: 'allow_once', label: 'Allow once' },
- { id: 'reject_once', label: 'Reject' }
- ]
- })
- expect(Object.fromEntries(read!.keysByOption)).toEqual({
- allow_once: '1',
- reject_once: '\x1b'
- })
- expect(
- readAgentPromptAnswers(waiting(approval), 'codex', 'p1', true)?.keysByOption.get('allow_once')
- ).toBe('1')
+ for (const agent of ['claude', 'codex']) {
+ const read = readAgentPromptAnswers(waiting(approval), agent, 'p1', true)
+ expect(read?.prompt).toEqual({
+ kind: 'approval',
+ promptId: 'p1',
+ tool: 'Bash',
+ summary: 'rm -rf build',
+ options: [
+ { id: 'allow_once', label: 'Allow once' },
+ { id: 'reject_once', label: 'Reject' }
+ ]
+ })
+ expect(Object.fromEntries(read!.keysByOption)).toEqual({
+ allow_once: '1',
+ reject_once: '\x1b'
+ })
+ }
})
it('reads a one-question single choice with each option’s selector key', () => {
@@ -58,7 +57,10 @@ describe('readAgentPromptAnswers', () => {
})
it('leaves to Orca what one keystroke cannot answer', () => {
- const unsupported = { kind: 'unsupported', promptId: 'p' }
+ const unsupported = {
+ prompt: { kind: 'unsupported', promptId: 'p' },
+ keysByOption: new Map()
+ }
const cases = [
waiting(question({}, 2)),
waiting(question({ multiSelect: true })),
@@ -66,18 +68,29 @@ describe('readAgentPromptAnswers', () => {
waiting('not json'),
{ state: 'blocked' as const }
]
- for (const row of cases) {
- expect(readAgentPromptAnswers(row, 'claude', 'p', true)?.prompt).toEqual(unsupported)
+ for (const agent of ['claude', 'codex']) {
+ for (const row of cases) {
+ expect(readAgentPromptAnswers(row, agent, 'p', true)).toEqual(unsupported)
+ }
}
+ const asyncQuestion = JSON.stringify({
+ questions: [{ title: 'Which color?', options: ['Red', 'Blue'] }]
+ })
+ expect(
+ readAgentPromptAnswers(waiting(asyncQuestion, 'request_user_input_async'), 'codex', 'p', true)
+ ).toEqual(unsupported)
})
it('leaves every prompt to Orca for an agent without hooks or known keys', () => {
- expect(readAgentPromptAnswers(waiting(approval), 'claude', 'p', false)?.prompt.kind).toBe(
- 'unsupported'
- )
- expect(readAgentPromptAnswers(waiting(approval), 'aider', 'p', true)?.prompt.kind).toBe(
- 'unsupported'
- )
+ const unsupported = {
+ prompt: { kind: 'unsupported', promptId: 'p' },
+ keysByOption: new Map()
+ }
+ expect(readAgentPromptAnswers(waiting(approval), 'claude', 'p', false)).toEqual(unsupported)
+ expect(readAgentPromptAnswers(waiting(approval), 'aider', 'p', true)).toEqual(unsupported)
+ expect(
+ readAgentPromptAnswers(waiting(question(), 'AskUserQuestion'), 'grok', 'p', true)
+ ).toEqual(unsupported)
})
it('reads nothing while the agent is not waiting on the user', () => {
diff --git a/src/shared/agent-row-conversation-name.test.ts b/src/shared/agent-row-conversation-name.test.ts
index 621a05508c2..8e114db8809 100644
--- a/src/shared/agent-row-conversation-name.test.ts
+++ b/src/shared/agent-row-conversation-name.test.ts
@@ -25,10 +25,13 @@ describe('getAgentRowConversationName', () => {
})
it('keeps OpenCode semantic session titles whole', () => {
- const tab = makeTab({ title: 'OC | build the release pipeline' })
+ const tab = makeTab({ title: 'OC | build the release pipeline', generatedTitle: 'Older title' })
expect(getAgentRowConversationName(tab, 'opencode', false)).toBe(
'OC | build the release pipeline'
)
+ expect(getAgentRowConversationName(tab, 'opencode', true)).toBe(
+ 'OC | build the release pipeline'
+ )
})
it("shows the title owned by the row's provider session", () => {
@@ -40,6 +43,11 @@ describe('getAgentRowConversationName', () => {
expect(getAgentRowConversationName(tab, 'claude', true, undefined, 's1')).toBe(
'Fix the lease probe'
)
+ expect(getAgentRowConversationName(tab, 'claude', true, undefined, 's2')).toBe(
+ 'Fix intake flow'
+ )
+ expect(getAgentRowConversationName(tab, 'codex', true, undefined, 's1')).toBe('Fix intake flow')
+ expect(getAgentRowConversationName(tab, 'claude', true)).toBe('Fix intake flow')
})
it('lets a manual rename and a quick command still outrank the provider title', () => {
@@ -208,6 +216,13 @@ describe('getAgentRowConversationName', () => {
})
it('rejects empty, glyph-only, and default terminal titles', () => {
+ expect(
+ getAgentRowConversationName(
+ makeTab({ title: 'Workbench', defaultTitle: 'Workbench' }),
+ 'claude',
+ false
+ )
+ ).toBeNull()
expect(getAgentRowConversationName(makeTab(), 'claude', false)).toBeNull()
expect(getAgentRowConversationName(makeTab({ title: '✳' }), 'claude', false)).toBeNull()
expect(
diff --git a/src/shared/agent-row-primary-text.test.ts b/src/shared/agent-row-primary-text.test.ts
index 31803e29eed..9586bc4ddf9 100644
--- a/src/shared/agent-row-primary-text.test.ts
+++ b/src/shared/agent-row-primary-text.test.ts
@@ -72,7 +72,7 @@ Keep the raw preamble out of the sidebar.`
})
it('falls back to the raw prompt outside orchestration workers', () => {
- expect(getAgentRowPrimaryText({ prompt: 'Fix checkout race' })).toBe('Fix checkout race')
+ expect(getAgentRowPrimaryText({ prompt: ' Fix checkout race\n ' })).toBe('Fix checkout race')
})
// Why: full preambles bury === TASK === after multi-KB CLI instructions. The
@@ -226,12 +226,10 @@ Checkout race body`,
}
})
).toBe('Fix checkout race')
- })
-
- it('ignores sticky orchestration for non-dispatch generated titles', () => {
expect(
getAgentRowGeneratedTitleText({
- prompt: 'Refactor the auth middleware',
+ prompt:
+ 'You are working inside Orca, a multi-agent IDE. Your task ID is: task-2 === TASK === New worker body',
orchestration: {
taskId: 'task-1',
dispatchId: 'ctx-1',
@@ -239,6 +237,20 @@ Checkout race body`,
displayName: 'Stale worker label'
}
})
- ).toBe('Refactor the auth middleware')
+ ).toBe('New worker body')
+ })
+
+ it('ignores sticky orchestration for non-dispatch generated titles', () => {
+ expect(
+ getAgentRowGeneratedTitleText({
+ prompt: ' Refactor the auth middleware\n ',
+ orchestration: {
+ taskId: 'task-1',
+ dispatchId: 'ctx-1',
+ taskTitle: 'Stale task',
+ displayName: 'Stale worker label'
+ }
+ })
+ ).toBe(' Refactor the auth middleware\n ')
})
})
diff --git a/src/shared/agent-session-approval-blocked-path.test.ts b/src/shared/agent-session-approval-blocked-path.test.ts
index 45b9a9184c2..6d227b173da 100644
--- a/src/shared/agent-session-approval-blocked-path.test.ts
+++ b/src/shared/agent-session-approval-blocked-path.test.ts
@@ -40,6 +40,13 @@ describe('approvalBlockedPathToShow', () => {
})
it('checks only the text the card draws: a plan replaces the detail', () => {
+ expect(
+ approvalBlockedPathToShow({
+ blockedPath: '/repo/notes.md',
+ subject: { kind: 'plan', text: 'Read /repo/notes.md' },
+ detail: 'unused'
+ })
+ ).toBeNull()
expect(
approvalBlockedPathToShow({
blockedPath: '/repo/plan.md',
@@ -72,7 +79,10 @@ describe('approvalBlockedPathToShow', () => {
).toBe('/home/me/secret.txt')
})
- it('shows a path the text names only as the start of a longer name', () => {
+ it('distinguishes whole paths from longer names across all occurrences', () => {
+ expect(
+ approvalBlockedPathToShow({ blockedPath: '/srv/data', detail: 'ls /srv/data-old /srv/data' })
+ ).toBeNull()
expect(
approvalBlockedPathToShow({ blockedPath: '/srv/data', detail: 'ls /srv/data-old/x' })
).toBe('/srv/data')
diff --git a/src/shared/agent-session-approval-subject.test.ts b/src/shared/agent-session-approval-subject.test.ts
index 2ad176e6932..999bca464f0 100644
--- a/src/shared/agent-session-approval-subject.test.ts
+++ b/src/shared/agent-session-approval-subject.test.ts
@@ -5,7 +5,10 @@ import {
pendingPromptsAllUnanswerableHere
} from './agent-session-approval-subject'
import type { AgentJournalItemBody } from './agent-session-journal-types'
-import { AGENT_JOURNAL_APPROVAL_SUBJECT_KINDS } from './agent-session-journal-schemas'
+import {
+ AGENT_JOURNAL_APPROVAL_SUBJECT_KINDS,
+ isAdmissibleAgentJournalItemBody
+} from './agent-session-journal-schemas'
it('draws exactly the subject kinds the journal schema knows', () => {
expect([...DRAWN_APPROVAL_SUBJECT_KINDS].sort()).toEqual(
@@ -20,14 +23,14 @@ it("reads only a subject of a kind it does not draw as a newer build's", () => {
})
function pending(body: Record): { body: AgentJournalItemBody } {
- return JSON.parse(
- JSON.stringify({
- body: {
- ...body,
- resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
- }
- })
- )
+ const promptBody = {
+ ...body,
+ resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
+ }
+ if (!isAdmissibleAgentJournalItemBody(promptBody)) {
+ throw new Error('Invalid pending prompt fixture')
+ }
+ return { body: promptBody }
}
const NEWER = pending({
kind: 'approval',
@@ -43,7 +46,7 @@ const PLAN = pending({
subject: { kind: 'plan', text: 'do it' },
options: [{ id: 'allow', label: 'Approve' }]
})
-const QUESTION = pending({ kind: 'question', title: 'Which?', options: [] })
+const QUESTION = pending({ kind: 'question', question: 'Which?', options: [] })
it('holds only while every pending prompt is an approval this build cannot answer', () => {
expect(pendingPromptsAllUnanswerableHere([])).toBe(false)
@@ -54,6 +57,8 @@ it('holds only while every pending prompt is an approval this build cannot answe
const cancelled = JSON.parse(
JSON.stringify(NEWER).replace('"state":"pending"', '"state":"cancelled"')
)
+ expect(pendingPromptsAllUnanswerableHere([cancelled, NEWER])).toBe(true)
+ expect(pendingPromptsAllUnanswerableHere([NEWER, cancelled])).toBe(true)
expect(pendingPromptsAllUnanswerableHere([cancelled, PLAN])).toBe(false)
expect(pendingPromptsAllUnanswerableHere([cancelled])).toBe(false)
})
diff --git a/src/shared/agent-session-conversation-name.test.ts b/src/shared/agent-session-conversation-name.test.ts
index aa824e5f042..c3dcfd23fea 100644
--- a/src/shared/agent-session-conversation-name.test.ts
+++ b/src/shared/agent-session-conversation-name.test.ts
@@ -31,7 +31,8 @@ describe('normalizeAgentSessionConversationName', () => {
})
it('bounds a pasted essay to the stored maximum', () => {
- const normalized = normalizeAgentSessionConversationName('a'.repeat(1000))
+ const normalized = normalizeAgentSessionConversationName(`First ${'x'.repeat(994)}`)
+ expect(normalized).toBe(`First ${'x'.repeat(194)}`)
expect(normalized).toHaveLength(AGENT_SESSION_CONVERSATION_NAME_MAX_LENGTH)
expect(isAgentSessionConversationName(normalized)).toBe(true)
})
@@ -50,14 +51,9 @@ describe('isAgentSessionConversationName', () => {
})
describe('normalizeAgentSessionConversationName hostile text', () => {
- it('strips control characters and bidi overrides', () => {
- // U+202E renders what follows right-to-left, so a tab could show a label
- // that reads as text the name does not contain.
- expect(normalizeAgentSessionConversationName('Fix\u202Egnp.exe probe')).toBe(
- 'Fix gnp.exe probe'
- )
+ it('strips controls and lone surrogates', () => {
expect(normalizeAgentSessionConversationName('Fix\u0007the probe')).toBe('Fix the probe')
- expect(normalizeAgentSessionConversationName('Fix\u200Bthe probe')).toBe('Fix the probe')
+ expect(normalizeAgentSessionConversationName('Fix\uD800 the\uDC00 probe')).toBe('Fix the probe')
expect(normalizeAgentSessionConversationName('\u202E\u200B ')).toBeNull()
})
diff --git a/src/shared/agent-session-question-answer.test.ts b/src/shared/agent-session-question-answer.test.ts
index 3f8820d3013..d3850c4dcc2 100644
--- a/src/shared/agent-session-question-answer.test.ts
+++ b/src/shared/agent-session-question-answer.test.ts
@@ -18,10 +18,21 @@ describe('agent-session grouped question answers', () => {
{ questionId: 'q2', optionIds: [], other: 'SSH host' }
]
- it('round-trips grouped multi-select and free-text answers', () => {
+ it('round-trips grouped answers and rejects malformed wire payloads', () => {
expect(decodeAgentSessionQuestionAnswers(encodeAgentSessionQuestionAnswers(answers))).toEqual(
answers
)
+ const malformed = [
+ 'not-grouped',
+ 'question-group:%',
+ 'question-group:not-json',
+ 'question-group:{}',
+ `${GROUP_ANSWER_PREFIX}${JSON.stringify([answers[0], { questionId: 'q2', optionIds: [1] }])}`,
+ `${GROUP_ANSWER_PREFIX}${JSON.stringify([{ questionId: 'q1', optionIds: [], other: 1 }])}`
+ ]
+ for (const value of malformed) {
+ expect(decodeAgentSessionQuestionAnswers(value)).toBeNull()
+ }
})
it('keeps compact answers readable by the original percent-decoding contract', () => {
@@ -48,9 +59,12 @@ describe('agent-session grouped question answers', () => {
expect(
decodeAgentSessionQuestionAnswers(encodeAgentSessionQuestionAnswers(unicodeAnswers))
).toEqual(unicodeAnswers)
+ expect(
+ decodeWithOriginalPercentDecoder(encodeAgentSessionQuestionAnswers(unicodeAnswers))
+ ).toEqual(unicodeAnswers)
})
- it("fits Claude's maximum choice group within a 512-character host response bound", () => {
+ it("keeps Claude's maximum choice group compact without losing selections", () => {
const maximumSelections = Array.from({ length: 4 }, (_, questionIndex) => ({
questionId: `q${questionIndex + 1}`,
optionIds: Array.from(
@@ -59,7 +73,10 @@ describe('agent-session grouped question answers', () => {
)
}))
- expect(encodeAgentSessionQuestionAnswers(maximumSelections).length).toBeLessThanOrEqual(512)
+ const encoded = encodeAgentSessionQuestionAnswers(maximumSelections)
+ expect(encoded.length).toBeLessThanOrEqual(512)
+ expect(decodeAgentSessionQuestionAnswers(encoded)).toEqual(maximumSelections)
+ expect(decodeWithOriginalPercentDecoder(encoded)).toEqual(maximumSelections)
})
it('validates each grouped answer against its question shape', () => {
@@ -83,6 +100,24 @@ describe('agent-session grouped question answers', () => {
]
expect(isValidAgentSessionQuestionAnswers(questions, answers)).toBe(true)
+ expect(isValidAgentSessionQuestionAnswers(questions, answers.toReversed())).toBe(true)
+ const invalid = [
+ [],
+ [...answers, { questionId: 'q3', optionIds: [] }],
+ [answers[0]!, answers[0]!],
+ [{ ...answers[0]!, questionId: 'unknown' }, answers[1]!],
+ [{ ...answers[0]!, optionIds: [] }, answers[1]!],
+ [{ ...answers[0]!, other: 'not offered' }, answers[1]!]
+ ]
+ for (const candidate of invalid) {
+ expect(isValidAgentSessionQuestionAnswers(questions, candidate)).toBe(false)
+ }
+ expect(
+ isValidAgentSessionQuestionAnswers(
+ [{ ...questions[0]!, multiSelect: false }, questions[1]!],
+ answers
+ )
+ ).toBe(false)
expect(
isValidAgentSessionQuestionAnswers(questions, [
{ questionId: 'q1', optionIds: ['unknown'] },
diff --git a/src/shared/agent-session-question-free-text.test.ts b/src/shared/agent-session-question-free-text.test.ts
index 8037bc38c24..a49be4dff93 100644
--- a/src/shared/agent-session-question-free-text.test.ts
+++ b/src/shared/agent-session-question-free-text.test.ts
@@ -14,18 +14,22 @@ describe('provider free-text question metadata', () => {
freeTextInput: { allowEmpty: true, multiline: true, initialValue: 'first\nsecond' }
}
- it.each(['', ' ', ' first\nsecond\u2028third\u2029fourth '])(
- 'preserves the exact answer %j across the older wire form',
- (other) => {
- const answers = [{ questionId: 'editor:1', optionIds: [], other }]
- const encoded = legacyAgentSessionSelectedOptionId(body, answers)
- expect(encoded).not.toBeNull()
- expect(legacyAgentSessionQuestionAnswers(body, encoded!)).toEqual(answers)
- expect(isValidAgentSessionQuestionAnswers(agentSessionPromptQuestions(body), answers)).toBe(
- true
- )
- }
- )
+ it.each([
+ ['', 'editor%3A1:'],
+ [' ', 'editor%3A1:%20%20'],
+ [
+ ' first\nsecond\u2028third\u2029fourth ',
+ 'editor%3A1:%20first%0Asecond%E2%80%A8third%E2%80%A9fourth%20'
+ ]
+ ])('preserves the exact answer %j across the older wire form', (other, expectedEncoded) => {
+ const answers = [{ questionId: 'editor:1', optionIds: [], other }]
+ const encoded = legacyAgentSessionSelectedOptionId(body, answers)
+ expect(encoded).toBe(expectedEncoded)
+ expect(legacyAgentSessionQuestionAnswers(body, encoded!)).toEqual(answers)
+ expect(isValidAgentSessionQuestionAnswers(agentSessionPromptQuestions(body), answers)).toBe(
+ true
+ )
+ })
it('requires an explicitly supplied answer even when empty is allowed', () => {
expect(
diff --git a/src/shared/agent-status-subject.test.ts b/src/shared/agent-status-subject.test.ts
index be28dbcff83..332b4057992 100644
--- a/src/shared/agent-status-subject.test.ts
+++ b/src/shared/agent-status-subject.test.ts
@@ -46,15 +46,18 @@ describe('agent status subjects', () => {
makePtyRunAgentStatusSubject(scope({ wslDistro: 'Ubuntu' }), identity),
makePtyRunAgentStatusSubject(scope({ executionHostId: 'ssh:target-a' }), identity),
makePtyRunAgentStatusSubject(scope({ executionHostId: 'runtime:peer-a' }), identity),
- makePtyRunAgentStatusSubject(
- scope({ workspaceId: 'folder-1', workspaceKind: 'folder' }),
- identity
- ),
+ makePtyRunAgentStatusSubject(scope({ workspaceId: 'workspace-2' }), identity),
+ makePtyRunAgentStatusSubject(scope({ workspaceKind: 'folder' }), identity),
makePtyAgentStatusSubject(scope(), identity),
makeStructuredAgentStatusSubject(scope(), identity)
]
expect(new Set(subjects.map(serializeAgentStatusSubject))).toHaveLength(subjects.length)
+ for (const [index, subject] of subjects.entries()) {
+ for (const other of subjects.slice(index + 1)) {
+ expect(agentStatusSubjectsEqual(subject, other)).toBe(false)
+ }
+ }
})
it('keeps the existing structured-session encoding byte-for-byte stable', () => {
@@ -69,7 +72,7 @@ describe('agent status subjects', () => {
it.each([
null,
- { kind: 'future' },
+ { kind: 'future', ...scope(), paneKey: PANE_KEY },
{
kind: 'pty-run',
...scope(),
@@ -125,7 +128,8 @@ describe('agent status subjects', () => {
'',
'agent-status-subject-v1:not-json',
'agent-status-subject-v1:["pty-run","local",null,"workspace-1","git-worktree"]',
- 'agent-status-subject-v1:["unknown","local",null,"workspace-1","git-worktree","id"]'
+ 'agent-status-subject-v1:["unknown","local",null,"workspace-1","git-worktree","id"]',
+ 'agent-status-subject-v1:["pty-run","local",null,"workspace-1","git-worktree","run_11111111-1111-4111-8111-111111111111","extra"]'
])('rejects malformed serialized subject %#', (value) => {
expect(deserializeAgentStatusSubject(value)).toBeNull()
})
diff --git a/src/shared/structured-agent-session-option-codec.spec.ts b/src/shared/structured-agent-session-option-codec.spec.ts
index f1e70014bf1..beb35cb8b72 100644
--- a/src/shared/structured-agent-session-option-codec.spec.ts
+++ b/src/shared/structured-agent-session-option-codec.spec.ts
@@ -13,13 +13,24 @@ describe('structured agent session option codec', () => {
})
it('rejects string booleans on the UI side and non-canonical strings on the wire side', () => {
- expect(encodeStructuredAgentSessionOptionValue('fastMode', 'true')).toBeNull()
- expect(decodeStructuredAgentSessionOptionValue('fastMode', 'TRUE')).toBeNull()
- expect(decodeStructuredAgentSessionOptionValue('fastMode', '1')).toBeNull()
+ for (const value of ['true', 'false']) {
+ expect(encodeStructuredAgentSessionOptionValue('fastMode', value)).toBeNull()
+ }
+ for (const value of ['TRUE', '1', 'False', ' true', 'false ', '', '0']) {
+ expect(decodeStructuredAgentSessionOptionValue('fastMode', value)).toBeNull()
+ }
})
- it('passes existing string options through unchanged', () => {
- expect(encodeStructuredAgentSessionOptionValue('model', 'gpt-example')).toBe('gpt-example')
- expect(decodeStructuredAgentSessionOptionValue('effort', 'high')).toBe('high')
+ it('preserves string options in both directions and rejects boolean values', () => {
+ for (const [id, value] of [
+ ['model', 'gpt-example'],
+ ['effort', 'high'],
+ ['serviceTier', 'flex']
+ ] as const) {
+ expect(encodeStructuredAgentSessionOptionValue(id, value)).toBe(value)
+ expect(decodeStructuredAgentSessionOptionValue(id, value)).toBe(value)
+ expect(encodeStructuredAgentSessionOptionValue(id, true)).toBeNull()
+ expect(encodeStructuredAgentSessionOptionValue(id, false)).toBeNull()
+ }
})
})
diff --git a/src/shared/structured-agent-session-option-view.test.ts b/src/shared/structured-agent-session-option-view.test.ts
index 416f3b4cc79..397507e195c 100644
--- a/src/shared/structured-agent-session-option-view.test.ts
+++ b/src/shared/structured-agent-session-option-view.test.ts
@@ -82,7 +82,7 @@ describe('a Claude model its CLI no longer lists', () => {
}
})
- it('keeps a remembered model and effort, including under a model with no listed effort', () => {
+ it('waits for an unlisted seed and carries a current effort without an offered menu', () => {
// The placeholder stands until the host answers with the chat's own model; create seeds it.
expect(carried(withHostList(), remembered)).toBeUndefined()
const haiku = { id: 'haiku', label: 'Haiku', isDefault: false, efforts: [] }
@@ -92,6 +92,13 @@ describe('a Claude model its CLI no longer lists', () => {
current: { model: 'haiku', effort: 'high' }
})
expect(shown(listed, undefined, {})).toEqual({ model: 'haiku', effort: 'high' })
+ expect(carried(listed)).toEqual({ model: 'haiku', effort: 'high' })
+ expect(
+ structuredAgentSessionShownSnapshot(listed).find((entry) => entry.id === 'effort')
+ ).toMatchObject({
+ settable: false,
+ kind: { currentValue: 'high', choices: [{ value: 'high', label: 'high' }] }
+ })
})
})
@@ -102,10 +109,22 @@ describe('what a send carries', () => {
expect(carried(withHostList(fresh(), listing))).toEqual({ model: 'sonnet' })
})
- it('carries a pick on its way as given', () => {
- const listed = withHostList(fresh(), { ...LISTED, models: [OPUS, SONNET] })
- expect(carried(listed, remembered, { model: 'sonnet' })).toMatchObject({ model: 'sonnet' })
- expect(carried(withHostList(), undefined, { model: 'claude-retired' })).toMatchObject({
+ it('carries held picks without committing them to the chat', () => {
+ const listed = {
+ ...withHostList(fresh(), { ...LISTED, models: [OPUS, SONNET] }),
+ pendingId: 'model'
+ }
+ const originalRecord = structuredClone(listed.record)
+ const view = structuredAgentSessionOptionView(listed, remembered, {
+ model: 'sonnet',
+ effort: 'high'
+ })
+ expect(structuredAgentSessionSendSelection(view)).toEqual({ model: 'sonnet', effort: 'high' })
+ expect(view.pendingId).toBe('model')
+ expect(view.record).not.toBe(listed.record)
+ expect(listed.record).toEqual(originalRecord)
+ expect(carried(listed, remembered, { model: 'sonnet' })).toEqual({ model: 'sonnet' })
+ expect(carried(withHostList(), undefined, { model: 'claude-retired' })).toEqual({
model: 'claude-retired'
})
})
@@ -152,6 +171,12 @@ describe("a chat's model and effort, apart from the defaults filling them in", (
const state = resumed()
expect(shown(state, undefined, {})).toEqual({ model: 'opus', effort: 'low' })
expect(carried(state)).toEqual({ model: 'opus', effort: 'low' })
+ expect(
+ structuredAgentSessionShownSnapshot(state).map(({ id, valueSource }) => ({ id, valueSource }))
+ ).toEqual([
+ { id: 'model', valueSource: 'dispatched' },
+ { id: 'effort', valueSource: 'dispatched' }
+ ])
})
it('lets a new chat pick its effort before its first message, under the default it shows', () => {