mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
test: remove duplicate coverage and strengthen boundary assertions (#33047)
* test: strengthen launch, subject identity, and release witnesses * test: strengthen approval paths and prompt answer boundaries * test: strengthen conversation names and mobile log persistence * refactor: group background task stop contract with stop operations * test: strengthen question, title, and release validation * test: distinguish row text and generated dispatch titles * test: verify mobile revival events and retry exclusivity * test: reject incomplete child row output assertions * test: verify copied modules and option selection contracts * test: verify mobile host state and status retry boundaries * test: observe connector refusals and real diagnostic delivery * test: consolidate platform memory coverage and verify bounded work * test: allow cached legacy failure reads within work bounds * test: verify packaging boundaries with independent fixtures --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -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')
|
||||
)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -17,40 +17,60 @@ function lintSources(sources) {
|
||||
}
|
||||
|
||||
const violations = [
|
||||
['unstyled class', 'export const X = () => <div className="max-h-64 overflow-y-auto" />'],
|
||||
[
|
||||
'unstyled class',
|
||||
'export const X = () => <div className="max-h-64 overflow-y-auto" />',
|
||||
'"max-h-64 overflow-y-auto"'
|
||||
],
|
||||
[
|
||||
'unstyled suffix-important class',
|
||||
'export const X = () => <div className="max-h-64 overflow-y-auto!" />'
|
||||
'export const X = () => <div className="max-h-64 overflow-y-auto!" />',
|
||||
'"max-h-64 overflow-y-auto!"'
|
||||
],
|
||||
[
|
||||
'unknown scrollbar class',
|
||||
'export const X = () => <div className="overflow-auto scrollbar-none" />'
|
||||
'export const X = () => <div className="overflow-auto scrollbar-none" />',
|
||||
'"overflow-auto scrollbar-none"'
|
||||
],
|
||||
[
|
||||
'separate class composer arguments',
|
||||
"export const X = () => <div className={cn('overflow-y-auto', 'scrollbar-sleek')} />"
|
||||
"export const X = () => <div className={cn('overflow-y-auto', 'scrollbar-sleek')} />",
|
||||
"'overflow-y-auto'"
|
||||
],
|
||||
[
|
||||
'conditional scrollbar',
|
||||
"export const X = ({ enabled }) => <div className={cn('overflow-y-auto', enabled && 'scrollbar-sleek')} />"
|
||||
"export const X = ({ enabled }) => <div className={cn('overflow-y-auto', enabled && 'scrollbar-sleek')} />",
|
||||
"'overflow-y-auto'"
|
||||
],
|
||||
[
|
||||
'arbitrary class wrapper',
|
||||
"export const X = () => <div className={identity('overflow-y-auto')} />"
|
||||
"export const X = () => <div className={identity('overflow-y-auto')} />",
|
||||
"'overflow-y-auto'"
|
||||
],
|
||||
[
|
||||
'mismatched responsive variants',
|
||||
'export const X = () => <div className="overflow-y-auto md:scrollbar-sleek" />'
|
||||
'export const X = () => <div className="overflow-y-auto md:scrollbar-sleek" />',
|
||||
'"overflow-y-auto md:scrollbar-sleek"'
|
||||
],
|
||||
[
|
||||
'inline overflow',
|
||||
"export const X = () => <div style={{ overflowY: 'auto' }} />",
|
||||
"overflowY: 'auto'"
|
||||
],
|
||||
['inline overflow', "export const X = () => <div style={{ overflowY: 'auto' }} />"],
|
||||
[
|
||||
'logical inline style spread',
|
||||
"export const X = ({ open }) => <div style={{ ...(open && { overflowY: 'auto' }) }} />"
|
||||
"export const X = ({ open }) => <div style={{ ...(open && { overflowY: 'auto' }) }} />",
|
||||
"overflowY: 'auto'"
|
||||
],
|
||||
[
|
||||
'JSX spread class',
|
||||
"export const X = () => <div {...{ className: 'overflow-y-auto' }} />",
|
||||
"'overflow-y-auto'"
|
||||
],
|
||||
['JSX spread class', "export const X = () => <div {...{ className: 'overflow-y-auto' }} />"],
|
||||
[
|
||||
'later spread override',
|
||||
'export const X = () => <div className="scrollbar-sleek" {...{ className: \'overflow-y-auto\' }} />'
|
||||
'export const X = () => <div className="scrollbar-sleek" {...{ className: \'overflow-y-auto\' }} />',
|
||||
"'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) => {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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': ''
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, Set<AppStateListener>>()
|
||||
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<AppStateListener>()
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<void> {
|
||||
}
|
||||
}
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -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<void>((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 () => {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<typeof subscribeConnectionRevivalTriggers>[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<string, Set<AppStateListener>>()
|
||||
const networkListeners = new Set<NetworkListener>()
|
||||
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<AppStateListener>()
|
||||
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<typeof vi.fn>
|
||||
let nudge: Mock<RevivalNudge>
|
||||
|
||||
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<RevivalNudge>()
|
||||
})
|
||||
|
||||
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<NetworkSnapshot>((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)
|
||||
})
|
||||
|
||||
@@ -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<string, string>(),
|
||||
getItem: vi.fn<(key: string) => Promise<string | null>>(),
|
||||
setItem: vi.fn<(key: string, value: string) => Promise<void>>()
|
||||
}))
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
])
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<HostProfile[]>((res) => {
|
||||
let resolve: (hosts: HostListSnapshot) => void = () => {}
|
||||
const promise = new Promise<HostListSnapshot>((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<HostListSnapshot>>()
|
||||
.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<HostListSnapshot>>()
|
||||
.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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}`
|
||||
}))
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,16 +10,17 @@ type ProbeOutcome = RpcResponse | Error
|
||||
function makeClient(outcomes: ProbeOutcome[]): {
|
||||
client: UnvalidatedRpcRequestPort
|
||||
calls: () => number
|
||||
requests: () => Parameters<UnvalidatedRpcRequestPort['sendRequest']>[]
|
||||
} {
|
||||
let calls = 0
|
||||
const client: UnvalidatedRpcRequestPort = {
|
||||
sendRequest: () => {
|
||||
const client = {
|
||||
sendRequest: vi.fn<UnvalidatedRpcRequestPort['sendRequest']>(() => {
|
||||
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<void> {
|
||||
}
|
||||
|
||||
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']])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<void>()
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 ')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<string, unknown>): { 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)
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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'] },
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user