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', () => {