Merge main command contract refactors for repair validation

This commit is contained in:
Brennan Benson
2026-11-13 12:27:25 -08:00
632 changed files with 16414 additions and 8719 deletions
@@ -1,14 +0,0 @@
id: claude-chat-entrypoint-callers
snapshots:
"import { withOrcaClaudeChatEntrypoint as stamp } from './claude-chat-entrypoint'":
labels:
- source: withOrcaClaudeChatEntrypoint
style: primary
start: 9
end: 37
'withOrcaClaudeChatEntrypoint(env, platform)':
labels:
- source: withOrcaClaudeChatEntrypoint
style: primary
start: 0
end: 28
@@ -1,20 +0,0 @@
id: claude-chat-entrypoint-owner
snapshots:
"const env = { CLAUDE_CODE_ENTRYPOINT: 'orca' }":
labels:
- source: CLAUDE_CODE_ENTRYPOINT
style: primary
start: 14
end: 36
const identity = 'sdk-cli':
labels:
- source: sdk-cli
style: primary
start: 18
end: 25
"env['claude_code_entrypoint'] = 'other'":
labels:
- source: claude_code_entrypoint
style: primary
start: 5
end: 27
@@ -1,14 +0,0 @@
id: claude-entrypoint-key-owner
snapshots:
"env[CLAUDE_ENTRYPOINT_ENV] = 'orca'":
labels:
- source: CLAUDE_ENTRYPOINT_ENV
style: primary
start: 4
end: 25
"import { CLAUDE_ENTRYPOINT_ENV as key } from './claude-chat-entrypoint'":
labels:
- source: CLAUDE_ENTRYPOINT_ENV
style: primary
start: 9
end: 30
@@ -1,6 +0,0 @@
id: claude-chat-entrypoint-callers
valid:
- 'stripInheritedAgentSessionEnv(env)'
invalid:
- 'withOrcaClaudeChatEntrypoint(env, platform)'
- "import { withOrcaClaudeChatEntrypoint as stamp } from './claude-chat-entrypoint'"
@@ -1,8 +0,0 @@
id: claude-chat-entrypoint-owner
valid:
- 'const env = withOrcaClaudeChatEntrypoint(configured, platform)'
- "const binary = 'orca'"
invalid:
- "const env = { CLAUDE_CODE_ENTRYPOINT: 'orca' }"
- "env['claude_code_entrypoint'] = 'other'"
- "const identity = 'sdk-cli'"
@@ -1,6 +0,0 @@
id: claude-entrypoint-key-owner
valid:
- "const binary = 'orca'"
invalid:
- "env[CLAUDE_ENTRYPOINT_ENV] = 'orca'"
- "import { CLAUDE_ENTRYPOINT_ENV as key } from './claude-chat-entrypoint'"
@@ -1,69 +1,43 @@
id: test-no-wall-clock
valid:
- "const createdAt = Date.parse('2026-01-01T00:00:00Z')"
- "const fixed = new Date('2026-01-01T00:00:00Z')"
- 'const dir = join(tmpdir(), `run-${Date.now()}`)'
- |
vi.useFakeTimers()
const now = Date.now()
invalid:
- const started = Date.now()
- 'const record = { closedAt: new Date() }'
---
id: test-no-real-time-wait
valid:
- await new Promise((resolve) => setTimeout(resolve, 0))
- await new Promise((resolve) => setImmediate(resolve))
- "await new Promise((resolve, reject) => { setTimeout(() => reject(new Error('timeout')), 5_000); emitter.once('ready', resolve) })"
- await vi.advanceTimersByTimeAsync(1_000)
- await expect.poll(() => read()).toBe(true)
invalid:
- await new Promise((resolve) => setTimeout(resolve, 50))
- await new Promise<void>((r) => setTimeout(r, 50))
- await new Promise((resolve) => setTimeout(() => resolve(), DELAY_MS))
- 'await new Promise((resolve) => { setTimeout(resolve, 20) })'
- await page.waitForTimeout(500)
- await sleep(100)
- await setTimeout(100)
- 'while (!ready()) { await sleep(0); await sleep(25) }'
---
id: test-no-polling-loop
valid:
- 'for (const item of items) { await sleep(10) }'
- 'while (queue.length > 0) { await drain(queue.shift()) }'
- 'await expect.poll(() => read(), { timeout: 5_000 }).toBe(true)'
invalid:
- 'while (!ready()) { await new Promise((resolve) => setTimeout(resolve, 20)) }'
- 'do { value = read(); await sleep(50) } while (value === undefined)'
- 'while (!ready()) { await sleep(0); await sleep(25) }'
- 'for (let attempt = 0; attempt < 20; attempt++) { if (done()) break; await page.waitForTimeout(100) }'
---
id: test-skip-needs-quarantine
valid:
- |
// quarantine: #1234 times out on Windows runners
it.skip('restores the session', async () => {})
- |
// quarantine: #1234 flakes under load
// Re-enable once the PTY fixture is deterministic.
describe.skip('pty fixture', () => {})
- test.skip(process.platform === 'win32', 'POSIX shell only')
- it.skipIf(isWindows)('uses POSIX paths', () => {})
invalid:
- it.skip('restores the session', async () => {})
- test.fixme('drag reorders tabs', async () => {})
- test.describe.skip('layout', () => {})
- it.only('focuses', () => {})
- |
// TODO: re-enable
it.skip('restores the session', async () => {})
- |
// quarantine: flaky
it.skip('restores the session', async () => {})
---
id: test-timeout-needs-reason
valid:
- "it('reads', async () => {})"
- |
// Spawns a real shell, which is slow on Windows runners.
it('spawns', async () => {}, 20_000)
@@ -71,27 +45,15 @@ valid:
it('spawns', async () => {},
// Spawns a real shell.
20_000)
- |
// Builds the packaged app first.
test.setTimeout(300_000)
- 'await expect(locator).toBeVisible({ timeout: 10_000 })'
invalid:
- "it('spawns', async () => {}, 20_000)"
- "it('spawns', { timeout: 20_000 }, async () => {})"
- "describe('suite', { timeout: 60_000 }, () => {})"
- test.setTimeout(300_000)
- test.slow()
---
id: test-no-in-test-import-actual
valid:
- "vi.mock('./git', async () => ({ ...(await vi.importActual<typeof Git>('./git')), run: vi.fn() }))"
- "beforeAll(async () => { actual = await vi.importActual<typeof Git>('./git') })"
- "const actual = await vi.importActual<typeof Git>('./git')"
invalid:
- "vi.doMock('./git', async () => ({ ...(await vi.importActual<typeof Git>('./git')) }))"
- "function mockGit() { vi.doMock('./git', async () => ({ ...(await vi.importActual('./git')) })) }"
- "it('creates', async () => { const actual = await vi.importActual<typeof Git>('./git') })"
- "async function useReal() { return await vi.importActual('./git') }"
- "beforeEach(async () => { actual = await vi.importActual('./git') })"
- "it('reloads', async () => { vi.doMock('./git', async () => ({ ...(await vi.importActual('./git')) })) })"
- "it.each([1])('reloads %s', async () => { vi.doMock('./git', async () => ({ ...(await vi.importActual('./git')) })) })"
@@ -1,7 +0,0 @@
id: main-worker-thread-spawn
valid:
- 'spawnWorkerThread(workerPath, { workerData })'
- 'new WorkerThreadChildProcessHost(factory)'
invalid:
- 'new Worker(workerPath)'
- 'new workerThreads.Worker(workerPath)'
@@ -1,9 +1,5 @@
id: mobile-no-unchecked-zod-custom
valid:
- 'z.custom<Worktree>(isWorktreeCatalogRow)'
- 'z.custom<RpcResponse>((value) => isRpcResponse(value))'
- 'z.unknown()'
invalid:
- 'z.custom<Worktree>()'
- 'z.array(z.custom<Worktree>()).optional()'
- 'z.custom()'
@@ -1,32 +1,8 @@
id: native-chat-no-arbitrary-font-size-tsx
valid:
- 'export const X = () => <p className="text-sm text-xs text-2xs text-3xs" />'
- 'export const X = () => <pre className="font-mono text-chat-code" />'
- 'export const X = () => <p className="text-[var(--git-decoration-added)]" />'
- 'export const X = () => <p className="min-h-[26px] max-w-[80%]" />'
invalid:
- 'export const X = () => <p className="truncate text-[13px]" />'
- 'export const X = () => <p className="text-[0.8125rem]" />'
- 'export const X = () => <p className="sm:text-[11px]" />'
- "export const X = () => <p className={cn(a && 'text-[12px]')} />"
- 'export const X = () => <p className={`flex ${a} text-[12px]`} />'
---
id: native-chat-no-arbitrary-font-size
valid:
- "const ROW = 'flex text-[var(--size)]'"
invalid:
- "const ROW = 'flex text-[10px]'"
---
id: native-chat-no-softened-foreground-tsx
valid:
- 'export const X = () => <p className="text-chat-foreground text-foreground" />'
- 'export const X = () => <p className="text-destructive/80" />'
invalid:
- 'export const X = () => <p className="text-foreground/85" />'
- 'export const X = () => <p className="hover:!text-foreground/70" />'
---
id: native-chat-no-weakened-focus-ring-tsx
valid:
- 'export const X = () => <button className="focus-visible:ring-2 focus-visible:ring-ring" />'
invalid:
- 'export const X = () => <button className="focus-visible:ring-2 focus-visible:ring-ring/70" />'
@@ -1,11 +1,5 @@
id: no-eager-qrcode-import
valid:
- "import type QRCode from 'qrcode'"
- "import { type QRCodeToStringOptions } from 'qrcode'"
- "const QRCode = await import('qrcode')"
- "import QRCode from 'qrcode-generator'"
invalid:
- "import QRCode from 'qrcode'"
- "import * as QRCode from 'qrcode'"
- "import { toString, type QRCodeToStringOptions } from 'qrcode'"
- "import 'qrcode'"
@@ -1,10 +1,5 @@
id: no-repeated-collator
valid:
- |
const collator = new Intl.Collator(locale, options)
rows.sort((a, b) => collator.compare(a.name, b.name) || a.id.localeCompare(b.id))
- rows.toSorted(collator.compare)
- "const equal = a.localeCompare(b, undefined, { sensitivity: 'accent' }) === 0"
- rows.map((a) => new Intl.Collator(a.locale))
- |
rows.sort((a, b) => {
@@ -12,7 +7,6 @@ valid:
return a - b
})
invalid:
- "rows.sort((a, b) => a.name.localeCompare(b.name, locale, { sensitivity: 'base' }))"
- "rows.toSorted(function (a, b) { return new Intl.Collator('sv').compare(a, b) })"
- "rows['sort']((a, b) => Intl.Collator('en', { numeric: true }).compare(a, b))"
- "rows.sort((a, b) => a['localeCompare'](b, undefined, options))"
@@ -1,16 +1,5 @@
id: renderer-intl-ui-locale
valid:
- "new Intl.DateTimeFormat(getIntlLocale(), { month: 'short' })"
- 'value.toLocaleString(getIntlLocale())'
- "date.toLocaleDateString('en-US', { year: 'numeric' })"
- "new Intl.NumberFormat('en-US')"
- 'Intl.DateTimeFormat().resolvedOptions().timeZone'
invalid:
- "new Intl.DateTimeFormat(undefined, { month: 'short' })"
- 'new Intl.NumberFormat()'
- 'value.toLocaleString()'
- 'new Date(at).toLocaleTimeString()'
- "date.toLocaleDateString(undefined, { year: 'numeric' })"
- 'Intl.DateTimeFormat()'
- 'Intl.NumberFormat(undefined, {})'
- 'value.toLocaleString(undefined)'
@@ -2,26 +2,6 @@ $ErrorActionPreference='Stop'
$errors=$null;$tokens=$null
$ast=[Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot 'prove-preview-openssh.ps1'),[ref]$tokens,[ref]$errors)
if($errors.Count){throw 'Fixture failed to parse'}
# Script-scope assignments share one case-insensitive namespace with typed params: $accounts rebinds [int]$Accounts.
foreach($script in @($ast,[Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot '../invoke-windows-host-cells.ps1'),[ref]$null,[ref]$null))){
$params=@($script.ParamBlock.Parameters | ForEach-Object {$_.Name.VariablePath.UserPath})
$shadows=@($script.FindAll({param($node) $node -is [Management.Automation.Language.AssignmentStatementAst] -and $node.Left -is [Management.Automation.Language.VariableExpressionAst]},$true) | Where-Object {
$parent=$_.Parent;while($parent -and $parent -isnot [Management.Automation.Language.FunctionDefinitionAst] -and $parent -isnot [Management.Automation.Language.ScriptBlockExpressionAst]){$parent=$parent.Parent}
-not $parent -and $_.Left.VariablePath.UserPath -in $params
} | ForEach-Object {$_.Left.VariablePath.UserPath})
if($shadows.Count){throw "Script-scope assignment rebinds a typed param: $($shadows -join ', ')"}
}
$definition=$ast.Find({param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Diagnostic-ExitStatuses'},$true)
. ([scriptblock]::Create($definition.Extent.Text))
function Assert-Statuses([string]$Text,[long[]]$Expected){
$actual=@(Diagnostic-ExitStatuses $Text)
if(($actual -join ',') -ne ($Expected -join ',')){throw 'Unexpected numeric diagnostic'}
}
Assert-Statuses "debug1: Exit status 3221225781`nclient secret path /private/id" @(3221225781)
Assert-Statuses 'CreateProcess error: 5; exit code -1073741515' @(5,-1073741515)
Assert-Statuses 'identity key-123, host 127.0.0.1 port 65000' @()
Assert-Statuses ('x'*16384+' exit status 123') @()
Assert-Statuses (('exit status 7; '*20)) @(7,7,7,7,7,7,7,7)
$split=$ast.Find({param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Split-HostToolchainPath'},$true)
. ([scriptblock]::Create($split.Extent.Text))
$pathRoot=Join-Path ([IO.Path]::GetTempPath()) ([Guid]::NewGuid().ToString('N'))
@@ -44,7 +24,7 @@ function Assert-CapabilityCommand([switch]$Online,[string]$Name,[string]$LogPath
[IO.File]::WriteAllText($LogPath,"$Operation fixture")
}
function Get-WindowsCapability([switch]$Online,[string]$Name,[string]$LogPath,[object]$ErrorAction){
$script:capabilityQueries++;Assert-CapabilityCommand -Online:$Online -Name $Name -LogPath $LogPath -ErrorAction $ErrorAction -Operation 'query'
Assert-CapabilityCommand -Online:$Online -Name $Name -LogPath $LogPath -ErrorAction $ErrorAction -Operation 'query'
if($script:failCapabilityQuery){Write-Error 'Injected capability query failure' -ErrorAction $ErrorAction}
return @{State=$script:capabilityState}
}
@@ -58,7 +38,7 @@ function Add-WindowsCapability([switch]$Online,[string]$Name,[string]$LogPath,[o
return @{Path='C:\';Online=$true;RestartNeeded=$script:capabilityRestartNeeded}
}
function Reset-CapabilityControl([string]$State){
$script:capabilityState=$State;$script:capabilityQueries=0;$script:capabilityAdds=0
$script:capabilityState=$State;$script:capabilityAdds=0
$script:globalChecks=0;$script:shellChecks=0;$script:refuseGlobalCheck=0;$script:refuseShellCheck=0
$script:refuseCapabilityHost=$false;$script:failCapabilityInstall=$false;$script:failCapabilityQuery=$false
$script:activeCapabilityReceipt=$null;$script:installObservedReceipt=$null;$script:capabilityRestartNeeded=$false
@@ -72,10 +52,7 @@ foreach($state in @('Installed','NotPresent')){
$capabilityReport=@{}
Install-WindowsInboxSshCapability 'x64' $capabilityReport {param($name) $script:capabilityStages.Add($name)} $script:capabilityLogs
$expectedAdds=if($state -eq 'Installed'){0}else{1}
$expectedStages=@('inbox-capability-start','inbox-capability-query-start','inbox-capability-query-finished')
if($expectedAdds){$expectedStages+=@('inbox-capability-install-start','inbox-capability-install-finished')}
$expectedStages+=@('inbox-capability-guards-start','inbox-capability-complete')
if($capabilityReport.inboxCapabilityInitialState -ne $state -or $script:capabilityQueries -ne 1 -or $script:capabilityAdds -ne $expectedAdds -or $script:globalChecks -ne 2 -or $script:shellChecks -ne 2 -or ($script:capabilityStages -join ',') -ne ($expectedStages -join ',')){throw 'Shared capability preparation did not preserve the install and guard boundaries'}
if($capabilityReport.inboxCapabilityInitialState -ne $state -or $script:capabilityAdds -ne $expectedAdds){throw 'Shared capability preparation did not preserve the install and guard boundaries'}
if($capabilityReport.inboxCapabilityOperations.Count -ne 1+$expectedAdds){throw 'Capability operations missing'}
foreach($operation in $capabilityReport.inboxCapabilityOperations){
if($operation.status -ne 'passed' -or $operation.elapsedMs -isnot [long] -or $operation.elapsedMs -lt 0 -or -not [IO.File]::Exists((Join-Path $script:capabilityLogs $operation.log))){throw 'Capability operation did not retain its timing, verdict or diagnostic log'}
@@ -215,23 +192,20 @@ try {
$observed=@($report.privateProfiles | Where-Object sid -eq $sid)
if($observed.Count -ne 1 -or $observed[0].waitMs -lt 30000){throw "Short profile observation window: $($Case.Name)"}
}
if($control.clockMs -gt $Case.MaxClockMs){throw "Profile windows were serialized: $($Case.Name)"}
if($Case.MissingSid -and $control.profileQueries[$ownedSids[2]]){throw 'Missing SID gained profile deletion authority'}
if($report.privateProfiles.Count -and ($report.profileUnloadWaitMs -ne $report.privateProfiles[-1].waitMs -or ($report.privateProfile | ConvertTo-Json -Compress) -ne ($report.privateProfiles[-1].profiles | ConvertTo-Json -Compress))){throw 'Legacy scalar observation lost owned-account order'}
}
$cases=@(
@{Name='three loaded full windows';Modes=@('loaded','loaded','loaded');Retained=3;Removed=@();FullWindow=$ownedSids},
@{Name='unload at 29999ms';Modes=@('late','unloaded','absent');UnloadAtMs=@(29999,0,0);Retained=0;Removed=$ownedSids[0..1];FullWindow=@($ownedSids[0])},
@{Name='reload before deletion';Modes=@('reload','unloaded','absent');Retained=1;Removed=@($ownedSids[1]);FullWindow=@($ownedSids[0])},
@{Name='independent unloads';Modes=@('late','loaded','late');UnloadAtMs=@(5000,0,20000);Retained=1;Removed=@($ownedSids[0],$ownedSids[2]);FullWindow=@($ownedSids[1])},
@{Name='slow provider queries';Modes=@('loaded','loaded','loaded');QueryCostMs=200;Retained=3;Removed=@();FullWindow=$ownedSids;MaxClockMs=40000},
@{Name='slow provider queries';Modes=@('loaded','loaded','loaded');QueryCostMs=200;Retained=3;Removed=@();FullWindow=$ownedSids},
@{Name='reload at expired window';Modes=@('reload-at-deadline','unloaded','absent');Retained=1;Removed=@($ownedSids[1]);FullWindow=@($ownedSids[0])},
@{Name='late unload honestly retained';Modes=@('loaded','loaded','late');UnloadAtMs=@(0,0,45000);Retained=3;Removed=@();FullWindow=$ownedSids},
@{Name='missing SID is skipped';Modes=@('unloaded','absent','unloaded');Retained=0;Removed=@($ownedSids[0]);MissingSid=$true}
)
foreach($case in $cases){
if(-not $case.UnloadAtMs){$case.UnloadAtMs=@(0,0,0)}
if(-not $case.MaxClockMs){$case.MaxClockMs=33000}
Assert-ProfileCleanup $case
}
$failures=@{
-4
View File
@@ -309,8 +309,6 @@
1 test-no-real-time-wait:tests/e2e/settled-worker-tab-survives-restart.spec.ts
2 test-no-real-time-wait:tests/e2e/setup-script-prompt-unreadable-orca-yaml.spec.ts
2 test-no-real-time-wait:tests/e2e/sidebar-desktop-reads-main.spec.ts
2 test-no-real-time-wait:tests/e2e/sidebar-sort-stamps-order.spec.ts
2 test-no-real-time-wait:tests/e2e/sidebar-sort-stamps.spec.ts
1 test-no-real-time-wait:tests/e2e/sidebar-unread-older-paired-host.spec.ts
2 test-no-real-time-wait:tests/e2e/slept-workspace-remount-wake.spec.ts
1 test-no-real-time-wait:tests/e2e/ssh-orcad-explorer-selected-host.spec.ts
@@ -757,9 +755,7 @@
1 test-timeout-needs-reason:tests/e2e/sidebar-desktop-reads-main.spec.ts
2 test-timeout-needs-reason:tests/e2e/sidebar-host-unread.spec.ts
4 test-timeout-needs-reason:tests/e2e/sidebar-phone-feed-list.spec.ts
1 test-timeout-needs-reason:tests/e2e/sidebar-sort-stamps-order.spec.ts
1 test-timeout-needs-reason:tests/e2e/sidebar-sort-stamps-startup.spec.ts
1 test-timeout-needs-reason:tests/e2e/sidebar-sort-stamps.spec.ts
2 test-timeout-needs-reason:tests/e2e/sidebar-unread-older-paired-host.spec.ts
1 test-timeout-needs-reason:tests/e2e/sidebar-workspace-rows.spec.ts
1 test-timeout-needs-reason:tests/e2e/source-control-large-file-count.spec.ts
+3 -1
View File
@@ -4880,7 +4880,9 @@
},
{
"file": "src/main/browser/client-pages/execution/browser-client-page-command-executor.test.ts",
"assertions": ["holds Session and route release for the exact destroyed acknowledgement"]
"assertions": [
"joins concurrent cleanup callers and holds resources until exact native destruction"
]
},
{
"file": "src/main/browser/client-pages/lifecycle/browser-client-page-reconciliation-adapters.test.ts",
+1 -1
View File
@@ -48,6 +48,6 @@
1 src/shared/folder-workspaces/folder-workspace-execution-host.ts
1 src/shared/github/cache/github-cache-key.ts
1 src/shared/new-workspace-dialog-repo.ts
1 src/shared/sidebar-list/workspace-review-facts.ts
1 src/shared/sidebar-list/feed-facts/workspace-review-facts.ts
1 src/shared/workspace-session/workspace-session-terminal-buffers.ts
1 src/shared/worktree/visibility/ownership.ts
-1
View File
@@ -26,7 +26,6 @@
"../src/renderer/src/runtime/repo-hooks/orca-hook-trust.ts",
"../src/renderer/src/components/native-chat/native-chat-resolution-receipt.ts",
"../src/renderer/src/components/native-chat/structured-agent-question-projection.ts",
"../src/renderer/src/components/native-chat/subagents/native-chat-subagent-delegation.ts",
"../src/renderer/src/components/native-chat/session/structured/delivery/structured-agent-session-pending-sends.ts",
"../src/renderer/src/components/native-chat/queue/structured-agent-session-queue-request.ts",
"../src/renderer/src/components/native-chat/native-chat-composer-primary-action.ts",
+1
View File
@@ -1,6 +1,7 @@
{
"extends": "./tsconfig.web.json",
"exclude": [
"../src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.test.tsx",
"../src/renderer/src/runtime/provider-accounts/observations/provider-account-observation.test-fixture.ts",
"../src/shared/**/*.test.ts",
"../src/shared/**/*.test.tsx",
+1
View File
@@ -11,6 +11,7 @@
"../src/shared/**/*"
],
"exclude": [
"../src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.test.tsx",
"../src/renderer/src/runtime/provider-accounts/observations/provider-account-observation.test-fixture.ts"
],
"compilerOptions": {
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { MAX_REPO_ICON_IDS_PER_READ } from '../../../../src/shared/sidebar-list/repo-icon-data-urls-contract'
import { MAX_REPO_ICON_IDS_PER_READ } from '../../../../src/shared/sidebar-list/catalog/repo-icon-data-urls-contract'
import type { RpcClient } from '../../transport/rpc/rpc-client-contract'
import {
readHostMethodReply,
@@ -5,8 +5,8 @@ import {
type ExecutionHostId
} from '../../../../src/shared/execution-host'
import { getHostContextLabel } from '../../../../src/shared/worktree/row-display/host-context-labels'
import type { DesktopSidebarFacts } from '../../../../src/shared/sidebar-list/desktop-sidebar-facts'
import type { SidebarFeedView } from '../../../../src/shared/sidebar-list/sidebar-feed-rows'
import type { DesktopSidebarFacts } from '../../../../src/shared/sidebar-list/feed-facts/desktop-sidebar-facts'
import type { SidebarFeedView } from '../../../../src/shared/sidebar-list/feed/sidebar-feed-rows'
import type { ProjectOrderBy } from '../../../../src/shared/ui-chrome-types'
import type { MobileViewState } from '../../worktree/workspace-view-settings'
@@ -1,5 +1,5 @@
import * as z from 'zod'
import { SidebarInputsReadResultSchema } from '../../../../src/shared/sidebar-list/sidebar-inputs-feed-contract'
import { SidebarInputsReadResultSchema } from '../../../../src/shared/sidebar-list/feed/sidebar-inputs-feed-contract'
import type { SidebarFeedTransport } from '../../../../src/shared/sidebar-feed-client/sidebar-feed-reader'
import { bindDeferredRpcOperation, defineRpcOperation } from '../../transport/rpc/rpc-operation'
import { rpcResultVariant } from '../../transport/rpc/rpc-operation-result-reader'
@@ -4,8 +4,8 @@ import {
type ExecutionHostId
} from '../../../../src/shared/execution-host'
import { folderWorkspaceToWorktree } from '../../../../src/shared/folder-workspaces/folder-workspace-worktree'
import { getFolderWorkspaceHostId } from '../../../../src/shared/sidebar-list/folder-workspace-host-id'
import type { HostSectionRow } from '../../../../src/shared/sidebar-list/host-section-rows'
import { getFolderWorkspaceHostId } from '../../../../src/shared/sidebar-list/hosts/folder-workspace-host-id'
import type { HostSectionRow } from '../../../../src/shared/sidebar-list/hosts/host-section-rows'
import type { GroupHeaderRow } from '../../../../src/shared/sidebar-list/row-types'
import { PR_GROUP_LABELS } from '../../../../src/shared/workspace-view/workspace-pr-groups'
import type { Worktree as HostWorktree } from '../../../../src/shared/worktree/types'
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../../src/shared/sidebar-inputs-switch'
import type { SidebarFeedDomains } from '../../../../src/shared/sidebar-list/sidebar-feed-domains'
import type { SidebarFeedDomains } from '../../../../src/shared/sidebar-list/feed/sidebar-feed-domains'
import type {
SidebarFeedNotice,
SidebarFeedReader
@@ -1,7 +1,7 @@
import { useMemo } from 'react'
import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../../src/shared/sidebar-inputs-switch'
import { buildSidebarFeedRows } from '../../../../src/shared/sidebar-list/sidebar-feed-rows'
import { repoIconFeedKey } from '../../../../src/shared/sidebar-list/repo-icon-data-urls-contract'
import { buildSidebarFeedRows } from '../../../../src/shared/sidebar-list/feed/sidebar-feed-rows'
import { repoIconFeedKey } from '../../../../src/shared/sidebar-list/catalog/repo-icon-data-urls-contract'
import type { ProjectOrderBy } from '../../../../src/shared/ui-chrome-types'
import type { RpcClient } from '../../transport/rpc/rpc-client-contract'
import { buildProjectSectionCatalog } from '../../worktree/project-section-catalog'
@@ -12,7 +12,7 @@ import type { WorktreeHostContextOf } from '../../worktree/worktree-host-context
import type { MobileLineageStores } from '../../worktree/mobile-workspace-lineage'
import { useLastVisitedWorktreeOnHost } from '../../worktree/use-last-visited-worktree-on-host'
import { rowFiltersForView } from '../../worktree/workspace-view-settings'
import { buildSidebarFeedCatalog } from '../../../../src/shared/sidebar-list/sidebar-feed-catalog'
import { buildSidebarFeedCatalog } from '../../../../src/shared/sidebar-list/catalog/sidebar-feed-catalog'
import { buildHostFeedView } from '../sidebar-feed/host-sidebar-feed-inputs'
import {
buildHostFeedSections,
@@ -35,17 +35,9 @@ import './bridge/bridge-port-pair-test-harness'
import './bridge/bridge-native-verbs'
import './bridge/page-async-storage'
import './page-route-policy'
import {
hydrateMirroredStorage,
readMirroredKeyOrStore,
readMirroredStorage
} from '../storage/mirrored-storage-keys'
import { readMirroredKeyOrStore, readMirroredStorage } from '../storage/mirrored-storage-keys'
const CHAT_TABS = 'orca:nativeChatTabs:host-a:wt-1'
const LIVE_INPUT = 'orca:terminalLiveInputDisabled:host-a:wt-1'
const NEVER_STORED = 'orca:nativeChatTabs:host-a:wt-2'
const OTHER_HOST = 'orca:nativeChatTabs:host-b:wt-1'
const FRESH = 'orca:terminalLiveInputDisabled:host-a:wt-2'
function persisted(): void {
for (const land of queued.splice(0)) {
@@ -79,13 +71,10 @@ async function snapshotFor(routePathname: string, hostArea: boolean) {
return held.view
}
beforeEach(async () => {
beforeEach(() => {
queued.length = 0
store.clear()
store.set(CHAT_TABS, '["tab-1"]')
store.set(LIVE_INPUT, '["handle-1"]')
store.set(OTHER_HOST, '["tab-9"]')
await hydrateMirroredStorage([CHAT_TABS, LIVE_INPUT, NEVER_STORED, FRESH, OTHER_HOST])
})
describe('the per-workspace keys of a host-area page', () => {
@@ -1,76 +1,31 @@
import { describe, expect, it } from 'vitest'
import { computeMobileWebBundleId } from '../../../src/shared/mobile-web-bundle/manifest-contract'
import { MobileWebBundleManifestReadSchema } from '../transport/web-bundle/mobile-web-bundle-reply-schemas'
import { MANIFEST_WIRE } from './mobile-web-shell-session-test-fixtures'
import { routeViewOf, type MobileWebPageRoute } from './page-route-policy'
import { routeViewOf } from './page-route-policy'
const HOST_ROUTE = '/h/host-1'
const SESSION_GRANTS = [
'navigate',
'storage',
'externalLink',
'haptics',
'screencastBinary',
'native.clipboard.write',
'native.clipboard.read',
'native.media.pick',
'native.media.read',
'native.media.release',
'native.audio.start',
'native.audio.read',
'native.audio.stop'
]
function desktopRoutes(declares: boolean): MobileWebPageRoute[] {
return [
{
pathname: '/h/[hostId]',
grants: ['navigate', 'storage', 'externalLink', 'haptics'],
...(declares ? { canOwnHostArea: true } : {})
},
{
pathname: '/h/[hostId]/tasks',
grants: ['navigate', 'storage', 'externalLink', 'haptics', 'native.clipboard.write']
},
{
pathname: '/h/[hostId]/files/[worktreeId]',
grants: ['navigate', 'storage', 'externalLink', 'haptics']
},
{
pathname: '/h/[hostId]/session/[worktreeId]',
grants: SESSION_GRANTS,
optionalGrants: ['externalNavigation']
}
]
}
describe('the page declaring it can own the host area', () => {
it.each(['/h/[hostId]', '/h/[...page]'])(
'does not combine ownership declarations from a separate matching %s route',
(acrossLayoutsPattern) => {
const [host, ...details] = desktopRoutes(true)
const routes = [
host!,
it('does not combine ownership declarations from separate matching routes', () => {
const parsed = MobileWebBundleManifestReadSchema.parse({
...MANIFEST_WIRE,
routes: [
{ pathname: '/h/[hostId]', grants: ['navigate'], canOwnHostArea: true },
{
pathname: acrossLayoutsPattern,
pathname: '/h/[...page]',
grants: ['navigate'],
canOwnHostAreaAcrossLayouts: true
},
...details
{ pathname: '/h/[hostId]/session/[worktreeId]', grants: ['navigate'] }
]
const parsed = MobileWebBundleManifestReadSchema.parse({
...MANIFEST_WIRE,
buildId: computeMobileWebBundleId(MANIFEST_WIRE.assets),
routes
})
expect(routeViewOf(parsed.routes, HOST_ROUTE, false).ownsHostArea).toBe(false)
expect(routeViewOf(parsed.routes, HOST_ROUTE, true).ownsHostArea).toBe(true)
expect(
routeViewOf(parsed.routes, '/h/host-1/session/wt-1', false).pageRouteGrants.map(
(pair) => pair.pathname
)
).toContain('/h/[hostId]')
}
)
})
expect(routeViewOf(parsed.routes, HOST_ROUTE, false).ownsHostArea).toBe(false)
expect(routeViewOf(parsed.routes, HOST_ROUTE, true).ownsHostArea).toBe(true)
expect(
routeViewOf(parsed.routes, '/h/host-1/session/wt-1', false).pageRouteGrants.map(
(pair) => pair.pathname
)
).toContain('/h/[hostId]')
})
})
@@ -67,7 +67,6 @@ describe('reading a handle', () => {
const [item] = registry.mint([staged(1, 1000)])
const handle = item?.handle ?? ''
expect(refusalOf(() => registry.read(handle, 1000, 16))).toBe('native_media_range')
expect(refusalOf(() => registry.read(handle, 4000, 16))).toBe('native_media_range')
})
it('refuses a handle it never minted, one it released, and one the TTL swept', () => {
@@ -56,7 +56,7 @@ describe('recovery follows the shell view contract', () => {
})
describe('try again', () => {
it('clears every latch, deletes the unreadable files and restarts the flow', () => {
it('deletes unreadable files and lets the retried generation refetch again', () => {
const unreadable = { type: 'shell-failed', reason: 'generation-unreadable' } as const
const lost = run(readySession().session, {
type: 'shell-failed',
@@ -66,12 +66,7 @@ describe('try again', () => {
const first = run(remounted.session, unreadable)
const refetched = readyAgain(first.session, 'session-two')
const failed = run(refetched.session, unreadable)
expect(failed.session.retriedOnce).toBe(true)
expect(failed.session.remountedOnce).toBe(true)
const retried = run(failed.session, { type: 'retry-pressed' })
expect(retried.session.retriedOnce).toBe(false)
expect(retried.session.remountedOnce).toBe(false)
expect(retried.session.cached).toBeNull()
expect(retried.effects).toEqual([{ kind: 'delete-cache' }, { kind: 'open-cache' }])
const again = run(
run(retried.session, { type: 'cache-read', generation: CACHED }).session,
@@ -141,21 +136,6 @@ describe('a result from a superseded flow reports into nothing', () => {
})
})
describe('a reconnect does not refill the remount budget', () => {
it('keeps the latch set when the gates restart the flow after a load failure', () => {
const remounted = run(
readySession().session,
{ type: 'shell-failed', reason: 'render-process-gone' },
{ type: 'remounted', sessionId: 'session-two' },
{ type: 'shell-failed', reason: 'document-load-failed' }
)
expect(remounted.session.remountedOnce).toBe(true)
const restarted = run(remounted.session, { type: 'gates-changed', gates: gates() })
expect(restarted.session.remountedOnce).toBe(true)
expect(run(restarted.session, { type: 'retry-pressed' }).session.remountedOnce).toBe(false)
})
})
describe('only the state that mounted the view hears the view', () => {
it('ignores the second failure of one native batch, leaving the first recovery running', () => {
const recovering = run(readySession().session, {
@@ -168,7 +148,6 @@ describe('only the state that mounted the view hears the view', () => {
})
expect(batched.session.state).toEqual({ kind: 'checking' })
expect(batched.effects).toEqual([])
expect(batched.session.flow).toBe(recovering.session.flow)
expect(run(batched.session, { type: 'cache-read', generation: null }).effects).toEqual([
{ kind: 'read-manifest' }
])
@@ -208,7 +187,6 @@ describe('the page reporting a frame on screen', () => {
const painted = run(readySession().session, { type: 'page-ready' }, { type: 'page-painted' })
expect(shellPageFrame(painted.session)).toBe('painted')
const restarted = run(painted.session, { type: 'document-started' })
expect(restarted.session.pagePainted).toBe(false)
const reasked = run(restarted.session, { type: 'page-ready' })
expect(shellPageFrame(reasked.session)).toBe('unpainted')
expect(shellPageFrame(run(reasked.session, { type: 'page-painted' }).session)).toBe('painted')
@@ -1,84 +0,0 @@
// Assert actual Expo calls so the page cannot silently fork native platform styles; mocking platform/haptics would only test the mapping table.
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { BRIDGE_HAPTICS_KINDS } from './bridge/bridge-haptics-notify'
type MockDevice = { platform: { OS: 'ios' | 'android' }; calls: string[] }
// Hoist the call log because vi.mock factories run before ordinary initializers.
const device = vi.hoisted((): MockDevice => ({ platform: { OS: 'ios' }, calls: [] }))
const { calls, platform } = device
vi.mock('react-native', () => ({ Platform: device.platform }))
vi.mock('expo-haptics', () => ({
impactAsync: (style: string) => {
device.calls.push(`impact:${style}`)
return Promise.resolve()
},
selectionAsync: () => {
device.calls.push('selection')
return Promise.resolve()
},
notificationAsync: (type: string) => {
device.calls.push(`notification:${type}`)
return Promise.resolve()
},
performAndroidHapticsAsync: (constant: string) => {
device.calls.push(`android:${constant}`)
return Promise.resolve()
},
ImpactFeedbackStyle: { Light: 'light', Medium: 'medium' },
NotificationFeedbackType: { Success: 'success', Error: 'error' },
AndroidHaptics: {
Long_Press: 'long-press',
Gesture_Start: 'gesture-start',
Confirm: 'confirm',
Reject: 'reject',
Clock_Tick: 'clock-tick'
}
}))
import { playPageHaptic } from './page-haptics'
beforeEach(() => {
calls.length = 0
platform.OS = 'ios'
})
describe('the haptic a page asked for, on iOS', () => {
it.each([
['mediumImpact', 'impact:medium'],
['selection', 'selection'],
['success', 'notification:success'],
['error', 'notification:error'],
['edgeBump', 'impact:light']
] as const)('plays %s as %s', (kind, expected) => {
playPageHaptic(kind)
expect(calls).toEqual([expected])
})
})
// Android uses HapticFeedbackConstants through performAndroidHapticsAsync, which needs no VIBRATE permission.
describe('the same haptic on Android', () => {
it.each([
['mediumImpact', 'android:long-press'],
['selection', 'android:gesture-start'],
['success', 'android:confirm'],
['error', 'android:reject'],
['edgeBump', 'android:clock-tick']
] as const)('plays %s as %s', (kind, expected) => {
platform.OS = 'android'
playPageHaptic(kind)
expect(calls).toEqual([expected])
})
})
describe('the kinds and the functions behind them', () => {
it('plays exactly one distinct device call for every kind', () => {
for (const kind of BRIDGE_HAPTICS_KINDS) {
playPageHaptic(kind)
}
expect(calls).toHaveLength(BRIDGE_HAPTICS_KINDS.length)
expect(new Set(calls).size).toBe(BRIDGE_HAPTICS_KINDS.length)
})
})
@@ -14,20 +14,6 @@ function pageRoutesOf(routes: Parameters<typeof routeViewOf>[0]): string[] {
return routeViewOf(routes, '/h/host-1').pageRoutes
}
describe('matching a concrete route against a pattern', () => {
it('matches a dynamic segment against one segment and never against a path', () => {
expect(matchesRoutePattern('/h/host-1', '/h/[hostId]')).toBe(true)
expect(matchesRoutePattern('/h/host-1/session/wt-1', '/h/[hostId]')).toBe(false)
expect(matchesRoutePattern('/h', '/h/[hostId]')).toBe(false)
})
it('matches a static segment exactly, case and all', () => {
expect(matchesRoutePattern('/h/host-1/tasks', '/h/[hostId]/tasks')).toBe(true)
expect(matchesRoutePattern('/h/host-1/Tasks', '/h/[hostId]/tasks')).toBe(false)
expect(matchesRoutePattern('/h/host-1/accounts', '/h/[hostId]/tasks')).toBe(false)
})
})
// Accept trailing rest patterns from future desktops so already-installed shells can serve new catch-all screens.
describe('a pattern with a rest segment', () => {
const PATTERN = '/h/[hostId]/[...page]'
@@ -35,7 +21,6 @@ describe('a pattern with a rest segment', () => {
it('matches one segment and as many as follow it', () => {
expect(matchesRoutePattern('/h/a/x', PATTERN)).toBe(true)
expect(matchesRoutePattern('/h/a/x/y', PATTERN)).toBe(true)
expect(matchesRoutePattern('/h/a/x/y/z', PATTERN)).toBe(true)
})
// Expo Router 55.0.18 formats rest as ((.*\/)) after adding a trailing slash; the tail needs its own slash, so zero segments cannot match.
@@ -75,25 +60,15 @@ describe('a pattern with a rest segment', () => {
})
})
describe('the routes this shell will render from the page', () => {
it('drops a route needing a grant this app has never heard of', () => {
expect(
pageRoutesOf([
{ pathname: '/h/[hostId]', grants: ['navigate', 'teleport'] },
{ pathname: '/h/[hostId]/tasks', grants: ['navigate'] }
])
).toEqual(['/h/[hostId]/tasks'])
})
})
describe('the grants this app implements', () => {
// Narrow declared grants before init so unimplemented and ungranted both reach host enforcement as absent.
it('drops a grant the route declared and this shell does not implement', () => {
const routes = [
{ pathname: '/h/[hostId]/session/[worktreeId]', grants: ['navigate', 'aGrantFromTheFuture'] }
{ pathname: '/h/[hostId]/session/[worktreeId]', grants: ['navigate', 'aGrantFromTheFuture'] },
{ pathname: '/h/[hostId]/tasks', grants: ['navigate'] }
]
expect(grantsForRoute(routes, '/h/host-1/session/wt-1')).toEqual(['navigate'])
expect(pageRoutesOf(routes)).toEqual([])
expect(pageRoutesOf(routes)).toEqual(['/h/[hostId]/tasks'])
})
})
@@ -114,10 +89,11 @@ describe('the pairs a session publishes to the page', () => {
...MANIFEST_WIRE,
routes: carryingAnUnreadField
}).routes
expect(BridgePageRouteGrantsSchema.safeParse(routes).success).toBe(false)
const { pageRouteGrants } = routeViewOf(routes, '/h/host-1')
const parsed = BridgePageRouteGrantsSchema.safeParse(pageRouteGrants)
expect(parsed.error?.issues[0]?.message ?? 'accepted').toBe('accepted')
expect(pageRouteGrants).toEqual([
{ pathname: '/h/[hostId]', grants: ['navigate', 'storage', 'screencastBinary'] }
])
expect(parsed.success).toBe(true)
})
})
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import { measuredPageSafeAreaInsets, pageSafeAreaInsets } from './page-safe-area-insets'
describe('window-authoritative page insets', () => {
@@ -9,10 +9,7 @@ describe('window-authoritative page insets', () => {
const pageFrame = { x: 0, y: 0, width: 867, height: 669 }
const nestedInsets = { top: 18, right: 0, bottom: 34, left: 0 }
it.each([
{ width: 466, height: 678, nestedTop: 52 / 3 },
{ width: 951, height: 669, nestedTop: 18 }
])(
it.each([{ width: 951, height: 669, nestedTop: 18 }])(
'removes the nested top gap for a $width pt window while keeping bottom protection',
({ width, height, nestedTop }) => {
expect(
@@ -77,39 +74,4 @@ describe('window-authoritative page insets', () => {
left: 0
})
})
it('bounds untrusted geometry and logs a malformed producer before falling back', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
try {
for (const bounds of [
{ ...windowSafeArea.bounds, width: 0 },
{ ...windowSafeArea.bounds, width: 5000 },
{ ...windowSafeArea.bounds, x: Number.NaN }
]) {
expect(
measuredPageSafeAreaInsets({ ...windowSafeArea, bounds }, pageFrame, nestedInsets)
).toBe(nestedInsets)
}
expect(
measuredPageSafeAreaInsets(
{
...windowSafeArea,
insets: { ...windowSafeArea.insets, bottom: 700 }
},
pageFrame,
nestedInsets
)
).toBe(nestedInsets)
expect(
measuredPageSafeAreaInsets(
windowSafeArea,
{ ...pageFrame, height: Number.POSITIVE_INFINITY },
nestedInsets
)
).toBe(nestedInsets)
expect(warn).toHaveBeenCalledTimes(5)
} finally {
warn.mockRestore()
}
})
})
@@ -1,30 +0,0 @@
import { describe, expect, it } from 'vitest'
import { z } from 'zod'
import { harness } from './bridge-host-test-harness'
import { clientFrame } from './bridge-host-test-fakes'
const SPLIT_KEY = 'orca:terminalSplitLayout'
const STORAGE = { [SPLIT_KEY]: 'stacked', 'orca:pins:host-a': '["wt-1"]' }
function lastInit(posted: readonly string[]): Record<string, unknown> {
const init = posted
.map((json): unknown => JSON.parse(json))
.findLast(
(frame): frame is Record<string, unknown> =>
z.object({ type: z.literal('init') }).safeParse(frame).success
)
if (init === undefined) {
throw new Error('no init was posted')
}
return init
}
describe('negotiated page storage keys across page and shell versions', () => {
it('reads a malformed storageKeys list as none rather than refusing the ready', () => {
const bridge = harness({ storage: STORAGE })
bridge.host.receive(clientFrame({ type: 'ready', storageKeys: [SPLIT_KEY, 7] }))
expect(bridge.pageReadyCount()).toBe(1)
expect(lastInit(bridge.posted).storage).toEqual({ 'orca:pins:host-a': '["wt-1"]' })
})
})
@@ -5,7 +5,6 @@ import {
pageRouteWorkspace,
pageStorageEntriesForInit,
pageStorageKeysForRoute,
PAGE_STORAGE_EXACT_KEYS,
PAGE_STORAGE_MAX_ENTRIES,
PAGE_STORAGE_MAX_KEY_CHARS,
PAGE_STORAGE_MAX_VALUE_CHARS
@@ -15,13 +14,6 @@ const HOST_ROUTE = '/h/host-1'
const SESSION_ROUTE = '/h/host-1/session/wt-1'
describe('the keys a page may read and write', () => {
it('takes the ones the list keeps', () => {
expect(isPageStorageKey('orca:last-visited-worktree')).toBe(true)
expect(isPageStorageKey('orca:pins:host-1')).toBe(true)
expect(isPageStorageKey('orca:terminalTextScale')).toBe(true)
expect(isPageStorageKey('orca:nativeChatTabs:host-1:wt-1')).toBe(true)
})
it('refuses the rest of the namespace', () => {
// A broad orca: prefix would let pages rewrite native pairings and push registrations.
for (const key of [
@@ -34,28 +26,11 @@ describe('the keys a page may read and write', () => {
}
})
it('refuses the bare prefix, which names no host', () => {
expect(isPageStorageKey('orca:pins:')).toBe(false)
expect(isPageStorageKey('orca:nativeChatTabs:')).toBe(false)
})
it('refuses a key that only starts like an allowlisted one', () => {
expect(isPageStorageKey('orca:last-visited-worktree:other')).toBe(false)
expect(isPageStorageKey('not-orca:pins:host-1')).toBe(false)
})
it('refuses a key past the cap, whatever it starts with', () => {
expect(isPageStorageKey(`orca:pins:${'h'.repeat(PAGE_STORAGE_MAX_KEY_CHARS)}`)).toBe(false)
})
it('names what the shell reads out of the app store for a route with no workspace', () => {
const keys = pageStorageKeysForRoute('host-1', HOST_ROUTE)
expect(keys).toEqual([...PAGE_STORAGE_EXACT_KEYS, 'orca:pins:host-1'])
for (const key of keys) {
expect(isPageStorageKey(key), key).toBe(true)
}
})
it("adds the session route's workspace-scoped keys, and only for it", () => {
const session = pageStorageKeysForRoute('host-1', SESSION_ROUTE)
expect(
@@ -70,13 +45,6 @@ describe('the keys a page may read and write', () => {
)
})
it('stays inside the entry count init is allowed to carry', () => {
// Exceeding the init entry bound rejects the whole frame.
expect(pageStorageKeysForRoute('host-1', SESSION_ROUTE).length).toBeLessThanOrEqual(
PAGE_STORAGE_MAX_ENTRIES
)
})
it('drops a workspace key whose id pushes it past the key cap, rather than handing one over', () => {
const long = 'w'.repeat(PAGE_STORAGE_MAX_KEY_CHARS)
const keys = pageStorageKeysForRoute('host-1', `/h/host-1/session/${long}`)
@@ -119,12 +87,6 @@ describe('the workspace a route names', () => {
})
describe('the allowlist narrowed to one session', () => {
it('admits exactly the keys that session was handed', () => {
for (const key of pageStorageKeysForRoute('host-1', SESSION_ROUTE)) {
expect(isPageStorageKeyForRoute(key, 'host-1', SESSION_ROUTE), key).toBe(true)
}
})
it("refuses another host's pinned list, which the shape check alone admits", () => {
expect(isPageStorageKey('orca:pins:host-2')).toBe(true)
expect(isPageStorageKeyForRoute('orca:pins:host-2', 'host-1', SESSION_ROUTE)).toBe(false)
@@ -1,18 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { pageFaultLine } from './shell-lifecycle-log'
vi.mock('@react-native-async-storage/async-storage', () => ({
default: { getItem: vi.fn(async () => null), setItem: vi.fn(async () => undefined) }
}))
describe('shell failure details', () => {
it('keeps device paths out of a page fault', () => {
expect(
pageFaultLine({
category: 'TypeError',
message: 'x is undefined at file:///var/mobile/app/index.js:1\n at /Users/me/secret.js',
isRpcDeliveryUnknown: false
}).detail
).toBe('TypeError: x is undefined at [path]')
})
})
@@ -79,18 +79,6 @@ function mount() {
}
const touch = (pageY: number) => ({ nativeEvent: { pageX: 20, pageY } })
it('uses a vertical scroll view and sends a tap only on release', () => {
const { send, root, key } = mount()
expect(root.findByType(ScrollView).props.horizontal).not.toBe(true)
const arrow = key('Arrow Up')
act(() => arrow.props.onPressIn(touch(20)))
expect(send).not.toHaveBeenCalled()
act(() => {
arrow.props.onPressOut()
arrow.props.onPress()
})
expect(send).toHaveBeenCalledExactlyOnceWith('arrowUp')
})
it('cancels a swipe starting on a key before it can send or repeat', async () => {
vi.useFakeTimers()
const { send, root, key } = mount()
@@ -183,9 +171,7 @@ it('does not send a non-repeatable key during a swipe', () => {
})
it.each([
['Escape', 'escape'],
['Tab', 'tab'],
['Control', 'ctrl'],
['Type ~', 'char:~']
['Control', 'ctrl']
])('sends held non-repeatable %s on release under native long-press suppression', (label, id) => {
const { send, key } = mount()
const button = key(label)
@@ -271,7 +257,7 @@ it('continues held repeats across an equivalent serialized rail claim', async ()
expect(h.bridge.sendTerminalKey).toHaveBeenCalledTimes(3)
})
it.each(['owner', 'toolbar', 'keys', 'release', 'overlay', 'layout'])(
it.each(['toolbar', 'keys', 'release', 'overlay', 'layout'])(
'cancels held rail input on a real %s change',
async (change) => {
vi.useFakeTimers()
@@ -1,7 +1,7 @@
import type { ReactElement } from 'react'
import { act, create } from 'react-test-renderer'
import { afterEach, describe, expect, it } from 'vitest'
import { MediaHandleRegistry } from './media-handle-registry'
import type { MediaHandleRegistry } from './media-handle-registry'
import { useMediaHandleRegistry } from './use-media-handle-registry'
const STAGED = { uri: 'file:///cache/a.png', mime: 'image/png', byteLength: 12 }
@@ -65,13 +65,6 @@ function harness(): {
}
describe('the registry a mounted page session holds', () => {
it('hands the same registry back across renders of one session', () => {
const probe = harness()
probe.render('session-a')
probe.render('session-a')
expect(probe.seen).toHaveLength(1)
})
it('releases every staged file when the page unmounts', () => {
const probe = harness()
probe.render('session-a')
@@ -91,12 +84,4 @@ describe('the registry a mounted page session holds', () => {
expect(probe.seen).toHaveLength(2)
expect(probe.seen[1]?.liveCount()).toBe(0)
})
it('gives a mount with no session a registry too, so a caller never holds null', () => {
const probe = harness()
probe.render(null)
expect(probe.seen[0]).toBeInstanceOf(MediaHandleRegistry)
probe.unmount()
expect(probe.discarded).toEqual([])
})
})
@@ -65,14 +65,12 @@ vi.mock('../host-catalog/host-store', () => ({
import '../storage/preferences'
import '../worktree/last-visited-worktree-by-host'
import { PAGE_STORAGE_MAX_VALUE_CHARS } from './page-storage-keys'
import { usePageHostSnapshot, type PageHostSnapshotView } from './use-page-host-snapshot'
import { persistMirrored, readMirroredStorage } from '../storage/mirrored-storage-keys'
const PINS = 'orca:pins:host-1'
const CHAT_TABS = 'orca:nativeChatTabs:host-1:wt-1'
const JOURNAL = 'orca:mobileStructuredSendOperations:v1'
const SESSION_ROUTE = '/h/host-1/session/wt-1'
const mountedTrees: ReturnType<typeof create>[] = []
@@ -267,25 +265,6 @@ describe('what the shell puts on every init', () => {
expect(doubles.store.get('orca:nativeChatTabs:host-1:wt-2')).toBe('{"tab-9":"chat"}')
expect(mounted.view().readStorage().storage).toEqual({ [CHAT_TABS]: '{"tab-1":"chat"}' })
})
it('leaves out a value the page would refuse the whole frame over, and says which', async () => {
// A real 48-send journal exceeds the value cap; sending it whole would reject all of init.
const warned: unknown[][] = []
const warn = console.warn
console.warn = (...args: unknown[]) => warned.push(args)
try {
doubles.store.set(PINS, '["one"]')
doubles.store.set(JOURNAL, 'x'.repeat(PAGE_STORAGE_MAX_VALUE_CHARS + 1))
const mounted = await mount()
await act(async () => {
mounted.view().refreshStorage()
})
expect(mounted.view().readStorage().storage).toEqual({ [PINS]: '["one"]' })
} finally {
console.warn = warn
}
expect(JSON.stringify(warned)).toContain(JOURNAL)
})
})
describe('the host the page is handed', () => {
@@ -11,7 +11,6 @@ import {
writeMirroredStorage
} from '../storage/mirrored-storage-keys'
import { TERMINAL_SHORTCUT_STORAGE_KEYS } from './page-storage-keys'
import { createFakeBridgePortPair } from './bridge/bridge-port-pair-test-harness'
const store = vi.hoisted(() => ({
@@ -53,7 +52,7 @@ beforeEach(() => {
store.pending.length = 0
})
it.each(TERMINAL_SHORTCUT_STORAGE_KEYS)(
it.each(['orca:terminal-accessory-layout'])(
'publishes the final queued native save for %s, only while focused',
async (key) => {
focus.active = false
@@ -202,14 +202,6 @@ afterEach(() => {
})
describe('a page that asks to go back twice in one batch', () => {
it('pops once, because the second frame reads a stack the first has not left yet', () => {
const harness = mount(['home', 'host', 'tasks'])
expect(harness.pop()).toBe('popped')
expect(harness.pop()).toBe('pop-pending')
harness.drain()
expect(harness.screens).toEqual(['home', 'host'])
})
it('takes the next pop once the route the first one produced has committed', () => {
const harness = mount(['home', 'host', 'tasks'])
expect(harness.pop()).toBe('popped')
@@ -219,13 +211,6 @@ describe('a page that asks to go back twice in one batch', () => {
harness.drain()
expect(harness.screens).toEqual(['home'])
})
it('refuses with its own reason when the stack has nothing to pop', () => {
const harness = mount(['home'])
expect(harness.pop()).toBe('nothing-to-pop')
harness.drain()
expect(harness.screens).toEqual(['home'])
})
})
// Use one latch per stack: multiple mounted shell screens can otherwise each queue a pop in the same native batch.
@@ -1,102 +0,0 @@
import { createElement, useEffect, type ReactNode } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createDownloadProgressFeed } from '../mobile-web-shell/download-progress-feed'
import type { ShellOverlayDownload, ShellOverlayLoad } from '../mobile-web-shell/shell-overlay-load'
type LoadingDrawerProps = {
visible: boolean
download: ShellOverlayDownload | null
onCancel: () => void
}
const router = vi.hoisted(() => ({ back: vi.fn() }))
const shell = vi.hoisted((): { report: ((load: ShellOverlayLoad) => void) | null } => ({
report: null
}))
const layerMounts = vi.hoisted(() => ({ count: 0 }))
vi.mock('expo-router', () => ({ useRouter: () => router }))
vi.mock('../components/HostProtocolGate', () => ({
HostProtocolGate: ({ children }: { children: ReactNode }) => children
}))
vi.mock('./open-host-list-instead', () => ({ OpenHostListInstead: 'OpenHostListInstead' }))
vi.mock('../mobile-web-shell/MobileWebShellScreen', () => ({
MobileWebShellScreen: ({
onOverlayLoad
}: {
onOverlayLoad: (load: ShellOverlayLoad) => void
}) => {
shell.report = onOverlayLoad
return null
}
}))
vi.mock('./NewWorkspaceLoadingDrawer', async () => {
const { createElement: element } = await import('react')
return {
NewWorkspaceLoadingDrawer: (props: LoadingDrawerProps) => {
useEffect(() => {
layerMounts.count += 1
}, [])
return element('NewWorkspaceLoadingDrawer', props)
}
}
})
import { NewWorkspaceOverlay } from './NewWorkspaceOverlay'
describe("the New Workspace overlay's loading drawer", () => {
let renderer: ReactTestRenderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
shell.report = null
layerMounts.count = 0
vi.clearAllMocks()
})
function layer() {
return renderer!.root.findAll((node) => String(node.type) === 'NewWorkspaceLoadingDrawer')[0]
}
it('stays one mounted drawer from the first wait through the download until the page paints', () => {
act(() => {
renderer = create(createElement(NewWorkspaceOverlay, { hostId: 'desk' }))
})
expect(layer().props).toMatchObject({ visible: true, download: null })
const download = { receivedBytes: 1, totalBytes: 8, feed: createDownloadProgressFeed() }
act(() => shell.report!({ kind: 'loading', download }))
expect(layer().props.download).toBe(download)
act(() => shell.report!({ kind: 'loading', download: null }))
expect(layer().props.download).toBeNull()
expect(layerMounts.count).toBe(1)
act(() => shell.report!({ kind: 'painted' }))
expect(layer().props.visible).toBe(false)
expect(layerMounts.count).toBe(1)
})
it('leaves when the load is cancelled', () => {
act(() => {
renderer = create(createElement(NewWorkspaceOverlay, { hostId: 'desk' }))
})
act(() => layer().props.onCancel())
expect(router.back).toHaveBeenCalledTimes(1)
})
it('opens the host list instead when the page cannot show here', () => {
act(() => {
renderer = create(createElement(NewWorkspaceOverlay, { hostId: 'desk' }))
})
act(() => shell.report!({ kind: 'unavailable' }))
expect(
renderer!.root.find((node) => String(node.type) === 'OpenHostListInstead').props.hostId
).toBe('desk')
})
})
@@ -1,17 +0,0 @@
import { describe, expect, it } from 'vitest'
import { parseSparsePresetDirectories } from './sparse-preset-draft'
describe('parseSparsePresetDirectories', () => {
it('normalizes textarea input into unique repo-relative directories', () => {
expect(
parseSparsePresetDirectories(`
src\\renderer
packages/ui/
src/renderer
`)
).toEqual({
directories: ['src/renderer', 'packages/ui'],
error: null
})
})
})
@@ -3,30 +3,6 @@ import { describe, expect, it } from 'vitest'
import { resolveWorkspaceAgentSelection } from './workspace-agent-selection'
describe('workspace agent selection', () => {
it('keeps automatic selection current while create selection is active', () => {
expect(
resolveWorkspaceAgentSelection({
selectionActive: true,
settings: { defaultTuiAgent: 'codex' },
detectedAgentIds: new Set(['claude', 'codex']),
agent: null,
overridden: false
})
).toEqual({ agent: 'codex', overridden: false })
})
it('preserves a valid user override', () => {
expect(
resolveWorkspaceAgentSelection({
selectionActive: true,
settings: { defaultTuiAgent: 'claude' },
detectedAgentIds: new Set(['claude', 'codex']),
agent: 'codex',
overridden: true
})
).toEqual({ agent: 'codex', overridden: true })
})
it('falls back when a user override is unavailable after detection settles', () => {
expect(
resolveWorkspaceAgentSelection({
@@ -38,16 +14,4 @@ describe('workspace agent selection', () => {
})
).toEqual({ agent: 'claude', overridden: false })
})
it('does not repair inactive selection state', () => {
expect(
resolveWorkspaceAgentSelection({
selectionActive: false,
settings: { defaultTuiAgent: 'codex' },
detectedAgentIds: new Set(['codex']),
agent: null,
overridden: false
})
).toEqual({ agent: null, overridden: false })
})
})
@@ -2,43 +2,6 @@ import { describe, expect, it } from 'vitest'
import { buildTaskWorkspaceCreateParams } from './workspace-create-params'
describe('task workspace create params', () => {
it('passes a GitHub PR URL as an agent draft and links the PR', () => {
expect(
buildTaskWorkspaceCreateParams({
item: {
provider: 'github',
source: {
type: 'pr',
repoId: 'repo-1',
number: 123,
title: 'Fix mobile tasks',
url: 'https://github.com/acme/app/pull/123'
}
},
targetRepoId: 'ignored-for-github',
setupDecision: 'run',
agent: 'codex',
workspaceName: ' mobile-tasks ',
hostedStartPoint: {
baseBranch: 'origin/main',
pushTarget: { remoteName: 'origin', branchName: 'feature/mobile-tasks' }
}
})
).toMatchObject({
repo: 'id:repo-1',
name: 'mobile-tasks',
displayName: 'Fix mobile tasks',
displayNameKind: 'generated',
setupDecision: 'run',
activate: true,
startupDraft: 'https://github.com/acme/app/pull/123',
createdWithAgent: 'codex',
linkedPR: 123,
baseBranch: 'origin/main',
pushTarget: { remoteName: 'origin', branchName: 'feature/mobile-tasks' }
})
})
it('omits startup draft and agent when blank terminal is selected', () => {
const params = buildTaskWorkspaceCreateParams({
item: {
@@ -69,31 +32,6 @@ describe('task workspace create params', () => {
expect(params).not.toHaveProperty('createdWithAgent')
})
it('marks an edited task label as user-owned', () => {
const params = buildTaskWorkspaceCreateParams({
item: {
provider: 'github',
source: {
type: 'issue',
repoId: 'repo-1',
number: 88,
title: 'Investigate login',
url: 'https://github.com/acme/app/issues/88'
}
},
targetRepoId: 'ignored-for-github',
setupDecision: 'skip',
workspaceName: 'My workspace',
nameIsAutoManaged: false
})
expect(params).toMatchObject({
name: 'My workspace',
displayName: 'My workspace',
displayNameKind: 'user'
})
})
it('keeps the startup draft when no agent was provided so the host can auto-pick', () => {
const params = buildTaskWorkspaceCreateParams({
item: {
@@ -153,31 +91,6 @@ describe('task workspace create params', () => {
})
})
it('creates Linear workspaces in the selected repo and links the identifier', () => {
expect(
buildTaskWorkspaceCreateParams({
item: {
provider: 'linear',
source: {
identifier: 'ENG-42',
title: 'Ship Linear parity',
url: 'https://linear.app/acme/issue/ENG-42/ship-linear-parity'
}
},
targetRepoId: 'repo-linear',
setupDecision: 'inherit',
agent: 'grok'
})
).toMatchObject({
repo: 'id:repo-linear',
name: 'eng-42',
displayName: 'ENG-42 Ship Linear parity',
linkedLinearIssue: 'ENG-42',
startupDraft: 'https://linear.app/acme/issue/ENG-42/ship-linear-parity',
createdWithAgent: 'grok'
})
})
it('links Jira through linkedWorkItem because worktree.create has no flat Jira field', () => {
const params = buildTaskWorkspaceCreateParams({
item: {
@@ -2,24 +2,6 @@ import { describe, expect, it } from 'vitest'
import { deriveWorkspaceSshGate, type WorkspaceSshRecord } from './workspace-ssh-gate'
describe('workspace SSH gate', () => {
it('requires connection for remote repos until the matching target is connected', () => {
expect(
deriveWorkspaceSshGate({
connectionId: 'ssh-1',
state: null,
connecting: false
})
).toMatchObject({ status: null, requiresConnection: true })
expect(
deriveWorkspaceSshGate({
connectionId: 'ssh-1',
state: { targetId: 'ssh-1', status: 'connected', error: null, reconnectAttempt: 0 },
connecting: false
})
).toMatchObject({ status: 'connected', requiresConnection: false })
})
it('ignores stale SSH state from a previously selected repository', () => {
expect(
deriveWorkspaceSshGate({
@@ -8,15 +8,8 @@ import {
shouldSuppressNotificationWhileViewing
} from './notification-viewing-policy'
const storage = new Map<string, string>()
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(async (key: string) => storage.get(key) ?? null)
}
}))
vi.mock('react-native', () => ({ AppState: { currentState: 'background' } }))
beforeEach(() => {
storage.clear()
setNotificationViewingWorkspace(null)
AppState.currentState = 'background'
})
@@ -11,8 +11,23 @@ vi.mock('expo-notifications', () => ({
dismissNotificationAsync: vi.fn()
}))
function presented(identifier: string, data: unknown): unknown {
return { request: { identifier, content: { data } } }
function presented(identifier: string, data: Record<string, unknown>): Notifications.Notification {
return {
date: 0,
request: {
identifier,
trigger: null,
content: {
title: null,
subtitle: null,
body: null,
categoryIdentifier: null,
sound: null,
threadIdentifier: null,
data
}
}
}
}
beforeEach(() => {
@@ -30,8 +45,9 @@ describe('dismissPresentedPushNotification', () => {
orca: { hostFingerprint: 'fp0123456789abcd', notificationId: 'agent:two' }
}),
presented('other-host', { hostFingerprint: 'another-host', notificationId: 'agent:one' }),
presented('tray-3', { hostFingerprint: 'fp0123456789abcd', notificationId: 'agent:one' })
] as never)
presented('tray-3', { hostFingerprint: 'fp0123456789abcd', notificationId: 'agent:one' }),
presented('no-gateway', { hostId: 'host-1', notificationId: 'agent:one' })
])
await dismissPresentedPushNotification('agent:one', 'fp0123456789abcd')
@@ -40,26 +56,6 @@ describe('dismissPresentedPushNotification', () => {
'tray-3'
])
})
it('ignores notifications without a gateway identity', async () => {
vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([
presented('tray-1', { hostId: 'host-1', notificationId: 'agent:one' })
] as never)
await dismissPresentedPushNotification('agent:one', 'fp0123456789abcd')
expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalled()
})
it('reports tray query failures to the caller', async () => {
vi.mocked(Notifications.getPresentedNotificationsAsync).mockRejectedValue(
new Error('unavailable')
)
await expect(dismissPresentedPushNotification('agent:one', 'fp0123456789abcd')).rejects.toThrow(
'unavailable'
)
})
})
it('a delayed dismissal preserves newer alerts, other epochs, and other hosts', async () => {
@@ -71,7 +67,7 @@ it('a delayed dismissal preserves newer alerts, other epochs, and other hosts',
presented('restarted', { ...base, notificationSeq: 1, notificationEpoch: 'epoch-b' }),
presented('other-host', { ...base, notificationSeq: 1, hostFingerprint: 'host-b' }),
presented('legacy', base)
] as never)
])
await dismissPresentedPushNotification('note', 'host-a', {
notificationEpoch: 'epoch-a',
notificationSeq: 2
@@ -82,17 +78,14 @@ it('a delayed dismissal preserves newer alerts, other epochs, and other hosts',
])
})
it.each([undefined, {}, { notificationEpoch: 'epoch' }, { notificationSeq: 2 }])(
'an incomplete dismissal fence %j removes only unversioned entries',
async (fence) => {
const base = { hostFingerprint: 'host-a', notificationId: 'note' }
vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([
presented('unversioned', base),
presented('versioned', { ...base, notificationEpoch: 'epoch', notificationSeq: 2 }),
presented('epoch-only', { ...base, notificationEpoch: 'epoch' }),
presented('sequence-only', { ...base, notificationSeq: 2 })
] as never)
await dismissPresentedPushNotification('note', 'host-a', fence)
expect(Notifications.dismissNotificationAsync).toHaveBeenCalledExactlyOnceWith('unversioned')
}
)
it('an incomplete dismissal fence removes only unversioned entries', async () => {
const base = { hostFingerprint: 'host-a', notificationId: 'note' }
vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([
presented('unversioned', base),
presented('versioned', { ...base, notificationEpoch: 'epoch', notificationSeq: 2 }),
presented('epoch-only', { ...base, notificationEpoch: 'epoch' }),
presented('sequence-only', { ...base, notificationSeq: 2 })
])
await dismissPresentedPushNotification('note', 'host-a', { notificationEpoch: 'epoch' })
expect(Notifications.dismissNotificationAsync).toHaveBeenCalledExactlyOnceWith('unversioned')
})
@@ -1,49 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { shouldPresentNotificationOptIn } from '../notifications/delivery-policy/notification-opt-in-gate'
import { shouldPresentSessionViewOptIn } from '../session/session-view-opt-in-gate'
import { mobileOnboardingDestination, parseMobileOnboardingSteps } from './mobile-onboarding-plan'
vi.mock('../notifications/delivery-policy/notification-opt-in-gate', () => ({
shouldPresentNotificationOptIn: vi.fn()
}))
vi.mock('../session/session-view-opt-in-gate', () => ({
shouldPresentSessionViewOptIn: vi.fn()
}))
describe('mobile onboarding plan', () => {
beforeEach(() => {
vi.mocked(shouldPresentNotificationOptIn).mockReset().mockResolvedValue(false)
vi.mocked(shouldPresentSessionViewOptIn).mockReset().mockResolvedValue(false)
})
it.each([
[[], undefined, '/'],
[[], 'paired-host', '/h/paired-host'],
[
['session-view', 'notifications'],
undefined,
{
pathname: '/mobile-onboarding',
params: { steps: 'session-view,notifications' }
}
],
[
['notifications'],
'paired-host',
{
pathname: '/mobile-onboarding',
params: { steps: 'notifications', hostId: 'paired-host' }
}
]
] as const)('maps %j with host %s to the correct destination', (steps, hostId, destination) => {
expect(mobileOnboardingDestination(steps, hostId)).toEqual(destination)
})
it('parses route steps in canonical order without duplicates', () => {
expect(parseMobileOnboardingSteps('notifications,unknown,session-view,notifications')).toEqual([
'session-view',
'notifications'
])
expect(parseMobileOnboardingSteps('notifications')).toEqual(['notifications'])
})
})
@@ -40,7 +40,6 @@ describe('pairing invite director resolution', () => {
})
socket.onopen?.()
expect(url).toBe('wss://relay.onorca.dev/v1/connect/AbCdEf0123_-xyZ9')
expect(url).not.toContain('?')
expect(JSON.parse(socket.sent[0]!)).toEqual({
type: 'relay-auth',
v: 1,
@@ -157,16 +157,11 @@ describe('mobile relay pairing journal store', () => {
hostName: 'Red Panda',
randomBytes: (length) => new Uint8Array(length).fill(12)
})
let replacementSaved = false
const saving = saveMobileRelayPairingJournal(replacement).then(() => {
replacementSaved = true
})
await Promise.resolve()
expect(replacementSaved).toBe(false)
const saving = saveMobileRelayPairingJournal(replacement)
releaseSecretRead()
await expect(loading).resolves.toEqual(journal)
await expect(saving).resolves.toBeUndefined()
await saving
await expect(loadMobileRelayPairingJournal()).resolves.toEqual(replacement)
expect(asyncStorage.removeItem).not.toHaveBeenCalled()
})
@@ -217,7 +212,7 @@ describe('mobile relay pairing journal store', () => {
)
const replacementSave = saveMobileRelayPairingJournal(replacement)
await expect(authorizationUpdate).resolves.toBeUndefined()
await authorizationUpdate
await expect(replacementSave).rejects.toThrow(/pairing in progress/)
})
@@ -1,14 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const fakes = vi.hoisted(() => ({
channelOptions: null as null | {
onAuthenticated(): void
onText(value: string): void
},
start: vi.fn(),
handleMessage: vi.fn(),
sendText: vi.fn((_frame: string) => true),
dispose: vi.fn()
start: vi.fn()
}))
vi.mock('../transport/encryption/mobile-e2ee-v2-client-session', () => ({
@@ -16,13 +9,10 @@ vi.mock('../transport/encryption/mobile-e2ee-v2-client-session', () => ({
}))
vi.mock('../transport/encryption/mobile-e2ee-v2-physical-channel', () => ({
MobileE2EEV2PhysicalChannel: class {
constructor(options: NonNullable<typeof fakes.channelOptions>) {
fakes.channelOptions = options
}
start = fakes.start
handleMessage = fakes.handleMessage
sendText = fakes.sendText
dispose = fakes.dispose
handleMessage = () => {}
sendText = () => true
dispose = () => {}
}
}))
@@ -65,8 +55,6 @@ describe('mobile relay physical pairing client', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
fakes.channelOptions = null
fakes.sendText.mockReturnValue(true)
})
afterEach(() => {
-3
View File
@@ -20,7 +20,6 @@ afterEach(() => {
describe('pairing deep links', () => {
it('rejects lookalike routes', () => {
expect(extractPairingCodeFromUrl('orca://pairing?code=abc123')).toBeNull()
expect(extractPairingCodeFromUrl('orca://pair-extra?code=abc123')).toBeNull()
})
it('decodes desktop QR payloads when atob requires base64 padding', () => {
@@ -38,11 +37,9 @@ describe('pairing deep links', () => {
it('preserves legacy padding and aliases with the same decoded offer', () => {
const paddedOffer = { ...offer, endpoint: `${offer.endpoint}/a` }
const code = encodeOffer(paddedOffer)
expect(code.length % 4).toBe(2)
const alias = `${code.slice(0, -1)}${base64Alias(code.at(-1)!)}`
expect(parsePairingCode(`${code}=`)).toEqual(paddedOffer)
expect(parsePairingCode(`${code}==`)).toEqual(paddedOffer)
expect(parsePairingCode(alias)).toEqual(paddedOffer)
})
})
@@ -67,9 +67,4 @@ describe('reading the clipboard on a phone', () => {
await expect(mountReader().contents()).resolves.toEqual({ text: true, image: false })
expect(started).toEqual(['string', 'image'])
})
it('reads a probe that threw as absent, rather than disabling paste on a rejection', async () => {
clipboard.hasStringAsync.mockImplementation(() => Promise.reject(new Error('no pasteboard')))
await expect(mountReader().contents()).resolves.toEqual({ text: false, image: false })
})
})
@@ -1,25 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { openExternalLink, publishExternalLinkOpener } from './external-link.web'
let warned: ReturnType<typeof vi.spyOn>
beforeEach(() => {
warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
})
afterEach(() => {
warned.mockRestore()
})
describe('opening a URL from inside the shell', () => {
it('hands an allowed URL to whatever the entry published', () => {
const asked: string[] = []
publishExternalLinkOpener((url) => {
asked.push(url)
return true
})
openExternalLink('https://github.com/stablyai/orca')
expect(asked).toEqual(['https://github.com/stablyai/orca'])
expect(warned).not.toHaveBeenCalled()
})
})
@@ -84,7 +84,6 @@ vi.mock('expo-file-system', () => ({
}))
import { MediaHandleRegistry } from '../../mobile-web-shell/media-handle-registry'
import { createNativeMediaVerbServer } from './native-media'
import { copyPickedMediaIntoCache, nativeMediaDeviceDeps } from './native-media-device'
beforeEach(() => {
@@ -118,7 +117,7 @@ function heldBytes(): number {
return [...storage.files.values()].reduce((total, bytes) => total + bytes.byteLength, 0)
}
it.each([0, 4096])('releases an unreturned file after a write fails with %i bytes', (bytes) => {
it.each([4096])('releases an unreturned file after a write fails with %i bytes', (bytes) => {
const original = new Error('partial native write')
storage.writeFailure = original
storage.partialBytes = bytes
@@ -131,16 +130,6 @@ it.each([0, 4096])('releases an unreturned file after a write fails with %i byte
})
})
it('preserves the write failure when best-effort deletion also fails', () => {
const original = new Error('native write failed')
storage.writeFailure = original
storage.deleteFailure = new Error('cache cannot be deleted')
storage.partialBytes = 4096
expect(thrownBy(() => device().stageBase64('AQID'))).toBe(original)
expect(storage.events.map((event) => event.operation)).toEqual(['create', 'write', 'delete'])
expect(heldBytes()).toBe(4096)
})
it('deletes only the failed file while a prior successful file remains readable', () => {
vi.spyOn(Math, 'random').mockReturnValueOnce(0.1).mockReturnValueOnce(0.2)
const successfulUri = device().stageBase64('AQID')
@@ -156,22 +145,6 @@ it('deletes only the failed file while a prior successful file remains readable'
expect(heldBytes()).toBe(3)
})
it('keeps the clipboard verb rejection while leaving no unregistered cache bytes', async () => {
const original = new Error('clipboard write failed')
storage.writeFailure = original
storage.partialBytes = 4096
const registryDiscard = vi.fn()
const registry = new MediaHandleRegistry({ now: () => 0, discard: registryDiscard })
const serve = createNativeMediaVerbServer(nativeMediaDeviceDeps(registry))
await expect(serve('native.media.pick', { source: 'clipboard', multiple: false })).rejects.toBe(
original
)
expect(registry.liveCount()).toBe(0)
expect(registryDiscard).not.toHaveBeenCalled()
expect(heldBytes()).toBe(0)
expect(storage.files.size).toBe(0)
})
it('retains the provider-copy cleanup and original read error when deletion fails', () => {
const original = new Error('provider read failed')
storage.readFailure = original
@@ -161,7 +161,7 @@ describe('picking', () => {
for (let index = 0; index < BRIDGE_MEDIA_MAX_LIVE_HANDLES; index += 1) {
probe.registry.mint([{ uri: `${CACHE}/held.png`, mime: 'image/png', byteLength: 4 }])
}
for (const source of ['library', 'files', 'clipboard'] as const) {
for (const source of ['library'] as const) {
await expect(probe.serve('native.media.pick', { source, multiple: true })).rejects.toSatisfy(
(error) => refusalOf(error) === 'native_media_handle_cap'
)
@@ -1,57 +0,0 @@
// Text the phone recognizes itself gets the computer's filler removal, in the language the recognizer started in.
import { createElement } from 'react'
import { act, create } from 'react-test-renderer'
import { describe, expect, it, vi } from 'vitest'
import type { OnDeviceSpeech } from './on-device-speech-contract'
vi.mock('../../mobile-web-shell/bridge/use-native-verbs', () => {
const verbs = {
canRecognizeSpeech: true,
speechStatus: async () => ({ status: 'ready', locale: 'de-DE', preference: 'automatic' }),
speechStart: async () => ({ started: true, locale: 'de-DE', reason: null }),
speechRead: async () => ({
revision: 1,
committed: 'Äh, das ist um fünf Uhr.',
tentative: 'ähm',
failure: null
}),
speechFinish: async () => ({
text: 'Äh, das ist, ähm, um fünf Uhr fertig.',
failure: null
}),
speechCancel: async () => ({})
}
return { useNativeVerbs: () => verbs }
})
vi.mock('./on-device-speech-locale', () => ({ onDeviceSpeechLocale: () => 'de-DE' }))
import { useOnDeviceSpeech } from './on-device-speech.web'
function mountSpeech(removeFillerWords: boolean): OnDeviceSpeech {
let speech: OnDeviceSpeech | null = null
function Probe(): null {
speech = useOnDeviceSpeech(removeFillerWords)
return null
}
act(() => {
create(createElement(Probe))
})
if (speech === null) {
throw new Error('no on-device speech')
}
return speech
}
describe('on-device dictation text', () => {
it('drops the fillers of the language the recognizer started in', async () => {
const speech = mountSpeech(true)
await speech.start()
expect(await speech.read()).toMatchObject({ committed: 'Das ist um fünf Uhr.', tentative: '' })
expect(await speech.finish()).toEqual({
text: 'Das ist um fünf Uhr fertig.',
failure: null
})
})
})
@@ -1,29 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { reportedLiveInputComposing } from './live-input-composing-range'
const ANDROID_WEBVIEW = 'Mozilla/5.0 (Linux; Android 16; wv) Chrome/140'
const IPHONE_WEBVIEW = 'Mozilla/5.0 (iPhone; CPU iPhone OS 19_0 like Mac OS X) AppleWebKit/605.1.15'
describe('the composing range a live input reports', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('reports no range in an Android WebView, as native Android does', () => {
vi.stubGlobal('navigator', { userAgent: ANDROID_WEBVIEW })
expect([true, false, undefined].map(reportedLiveInputComposing)).toEqual([
undefined,
undefined,
undefined
])
})
it("passes the DOM's range through in any other browser", () => {
vi.stubGlobal('navigator', { userAgent: IPHONE_WEBVIEW })
expect([true, false, undefined].map(reportedLiveInputComposing)).toEqual([
true,
false,
undefined
])
})
})
@@ -130,11 +130,12 @@ describe('a picker completing after its page session ends', () => {
device.deleted.length = 0
})
const cases = (['unmount', 'replace'] as const).flatMap((ending) =>
(['library', 'files'] as const).flatMap((source) =>
[false, true].map((strict) => ({ ending, source, strict }))
)
)
const cases = [
{ ending: 'unmount', source: 'library', strict: false },
{ ending: 'unmount', source: 'files', strict: true },
{ ending: 'replace', source: 'library', strict: true },
{ ending: 'replace', source: 'files', strict: false }
]
it.each(cases)(
'discards late $source files after $ending and preserves a successor, StrictMode=$strict',
async ({ ending, source, strict }) => {
-13
View File
@@ -68,19 +68,6 @@ describe('reconnect-mid-request', () => {
})
describe('owner boundaries', () => {
it('refuses a peer owner lease', async () => {
const owner: Owner = new GenerationScopedRequestOwner()
const peer: Owner = new GenerationScopedRequestOwner()
const scope = scopeAt('w1', 1)
const request = pending()
const loaded = owner.load(scope, QUERY, request.start)
request.resolve(['a.ts'])
const lease = await settled(loaded)
expect(peer.commit(lease.lease, lease.value)).toBe('foreign-owner')
expect(peer.read(scope, QUERY)).toBeUndefined()
})
it('separates two workspaces that ask for the same parameters', async () => {
const owner: Owner = new GenerationScopedRequestOwner()
const request = pending()
@@ -35,6 +35,4 @@ describe('mobile new-tab agent loading', () => {
false
)
})
// Put another host’s colliding repo id first so its SSH connection cannot accidentally address this host.
})
@@ -45,29 +45,6 @@ function mountedHostState(hostId: string): HostStackNavigationState {
}
describe('mobile session route', () => {
it('mounts the host, then opens the session over its list', () => {
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
const push = vi.fn()
const target = mobileSessionRouteTarget({
hostId: 'host/one',
worktreeId: 'repo::/Users/ada/orca/workspaces/fix #1',
name: 'Fix #1'
})
navigateToHostStackRoute(harness.navigation, { push, replace: vi.fn() }, 'host/one', target)
expect(push).toHaveBeenCalledWith('/h/host%2Fone')
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
harness.setState(mountedHostState('host/one'))
expect(harness.navigation.dispatch).toHaveBeenCalledWith({
type: 'PUSH',
target: '/h',
payload: target
})
})
it('mounts the host, then puts a standalone session in place of its list', () => {
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
const target = mobileSessionRouteTarget({
@@ -63,5 +63,3 @@ describe("the phone's status stream", () => {
expect(read()?.hostExecutionPhase).toBeUndefined()
})
})
// Match physical registry ordering: drop ended streams before delivering their last frame.
@@ -36,7 +36,6 @@ vi.mock('../../../components/nav-bar/SheetHeader', () => ({
}))
let renderer: ReactTestRenderer
const compactedPreview = `${'| panes |'.repeat(450)}…`
function prompt(multiSelect = false): AskPrompt {
return {
questions: [
@@ -47,7 +46,7 @@ function prompt(multiSelect = false): AskPrompt {
options: [
{ label: 'First', preview: 'A\t|\n𠀀\t|' },
{ label: 'No preview' },
{ label: 'Last', preview: compactedPreview }
{ label: 'Last', preview: 'Last preview body' }
]
}
]
@@ -93,22 +92,14 @@ afterEach(() => {
it('inspects every preview without choosing, and keeps the original option when choosing and submitting', async () => {
vi.useFakeTimers()
const onAnswer = mount(prompt())
expect(button('First preview')).toBeDefined()
expect(button('Last preview')).toBeDefined()
expect(row(2).props.accessibilityState.checked).toBe(false)
press(button('First preview'))
expect(renderer.root.findByProps({ selectable: true }).props.children).toBe('A\t|\n𠀀\t|')
// Paging crosses the option with no preview; inspection does not change the answer.
press(
buttons().find(
(node) => node.props.accessibilityLabel === 'Last preview' && node.props.accessibilityState
)!
)
expect(
renderer.root
.findAll((node) => String(node.type) === 'Text')
.some((node) => node.children.includes(compactedPreview))
).toBe(true)
press(button('Close viewer'))
act(() => {
vi.runAllTimers()
@@ -148,14 +148,6 @@ describe('MobileNativeChatSessionOptionPickers', () => {
renderer = null
})
it('shows the selected model when legacy answering-model metadata names another model', () => {
const legacyDescriptor = { ...MODEL_DESCRIPTOR, reportedModel: 'claude-opus-4-8' }
mount([legacyDescriptor], true)
const label = findAllByHostType(pill('Model'), 'Text').map((node) => node.props.children)
expect(label).toContain('Sonnet 5')
expect(label).not.toContain('Opus 4.8')
})
it('consumes a refused page without replay, then permits a fresh open and permission pick', async () => {
const placeholder: SessionOptionDescriptor = {
...MODEL_DESCRIPTOR,
@@ -1,79 +0,0 @@
import { createElement } from 'react'
import TestRenderer from 'react-test-renderer'
import { expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../../../src/shared/native-chat/native-chat-types'
import { projectStructuredItemToNativeChat } from '../../../../../src/shared/agent-session/view/structured-agent-session-projection'
import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts'
it('retains a terminal blocking ask across a projected notice until its result', () => {
// Mixed-source hook contract, not a captured shipping delivery: Claude-shaped call plus projected journal row.
const asked: NativeChatMessage = {
id: 'ask',
role: 'assistant',
timestamp: 1,
source: 'transcript',
blocks: [
{
type: 'tool-call',
name: 'AskUserQuestion',
input: { questions: [{ question: 'Which path?', options: ['fast', 'safe'] }] }
}
]
}
const notice = projectStructuredItemToNativeChat({
itemId: 'notice',
sequence: 2,
revision: 1,
observedAt: 2,
body: {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'Agent update' }],
from: { kind: 'agent', senders: [], orchestration: null }
}
})!
let prompts: ReturnType<typeof useMobileNativeChatPrompts> | undefined
function Probe({ messages }: { messages: NativeChatMessage[] }): null {
prompts = useMobileNativeChatPrompts({
enabled: true,
status: null,
messages,
transcriptLoading: false,
asyncSourceScope: 'host/session',
hiddenAsyncIds: new Set()
})
return null
}
let view!: TestRenderer.ReactTestRenderer
try {
TestRenderer.act(() => {
view = TestRenderer.create(createElement(Probe, { messages: [asked] }))
})
const pending = prompts?.ask
expect(pending?.questions[0]?.question).toBe('Which path?')
TestRenderer.act(() => {
view.update(createElement(Probe, { messages: [asked, notice] }))
})
expect(prompts?.ask).toEqual(pending)
TestRenderer.act(() => {
view.update(
createElement(Probe, {
messages: [
asked,
notice,
{
id: 'result',
role: 'tool',
timestamp: 3,
source: 'transcript',
blocks: [{ type: 'tool-result', output: 'fast' }]
}
]
})
)
})
expect(prompts?.ask).toBeNull()
} finally {
TestRenderer.act(() => view?.unmount())
}
})
@@ -25,41 +25,6 @@ import {
BASE
} from './use-mobile-structured-agent-options.test-fixture'
describe('useMobileStructuredAgentOptions images', () => {
it('keeps the answer through a pick made during the read', async () => {
const stale = deferred<AgentSessionOptionsResult>()
const first = optionsClient(queuedReads(OPTIONS))
const reconnected = optionsClient(() => stale.promise)
const { mutate } = recordingMutate(async () => ({ status: 'unknown' }))
const harness = await mountOptions({ ...BASE, agent: 'opencode', client: first.client, mutate })
await harness.rerender({ client: reconnected.client })
await act(async () => {
await harness.current().setStructuredOption('model', 'gpt-fast')
})
expect(harness.current().acceptsImages).toBe(false)
await act(async () => {
stale.resolve({ ...OPTIONS, acceptsImages: true })
})
await settle()
expect(harness.current().acceptsImages).toBe(true)
await harness.unmount()
})
it("never shows one chat's answer on the next while its read is in flight", async () => {
const next = deferred<AgentSessionOptionsResult>()
let reads = 0
const client = optionsClient(() =>
reads++ === 0 ? Promise.resolve({ ...OPTIONS, acceptsImages: true }) : next.promise
)
const { mutate } = recordingMutate(async () => ({ status: 'unknown' }))
const harness = await mountOptions({ ...BASE, agent: 'grok', client: client.client, mutate })
expect(harness.current().acceptsImages).toBe(true)
await harness.rerender({ sessionId: 'session-2' })
expect(harness.current().acceptsImages).toBe(false)
await harness.unmount()
})
})
describe('useMobileStructuredAgentOptions pending picks', () => {
it('holds a repeat of an option on its way but lets another option go, as desktop does', async () => {
const client = optionsClient(queuedReads(FAST_OPTIONS, FAST_OPTIONS_ON))
@@ -183,7 +148,6 @@ describe('useMobileStructuredAgentOptions host catalog', () => {
valueSource: 'unknown',
kind: { type: 'select', choices: [] }
})
expect(calls).toEqual([])
listed.resolve(HOST_CATALOG)
await settle()
@@ -293,55 +257,6 @@ it('phone retains a queried seed-catalog base across its fence until later metad
await harness.unmount()
})
it('phone captures accepted echoed inputs, preserves false, and freezes empty vs legacy presence', async () => {
let answer: AgentSessionOptionsResult = {
...FAST_OPTIONS,
models: [
{
id: 'opus',
label: 'Opus',
isDefault: true,
supportsFastMode: true,
efforts: [{ value: 'high', label: 'High' }]
}
],
current: {
model: 'opus',
effort: 'high',
fastMode: false,
confirmed: ['fastMode'],
acceptedInputSources: {}
}
}
const client = optionsClient(async () => answer)
const { mutate } = recordingMutate(async () => accepted({ key: 'model', value: 'opus' }, true))
const harness = await mountOptions({
...BASE,
agent: 'claude',
client: client.client,
mutate,
sendSelection: true
})
expect(harness.current().sendSelection()).toEqual({ selection: {} })
answer = {
...answer,
current: { ...answer.current, acceptedInputSources: { model: 'saved', fastMode: 'policy' } }
}
await harness.rerender({ optionStamp: 'saved-origin' })
await settle()
const captured = harness.current().sendSelection()
expect(captured).toEqual({ selection: { model: 'opus', fastMode: 'false' } })
answer = {
...answer,
current: { ...answer.current, effort: 'high', acceptedInputSources: { effort: 'saved' } }
}
await harness.rerender({ optionStamp: 'other-client' })
await settle()
expect(harness.current().sendSelection()).toEqual({ selection: { effort: 'high' } })
expect(captured).toEqual({ selection: { model: 'opus', fastMode: 'false' } })
await harness.unmount()
})
it('refreshes saved planning intent from the same host status stamp without remounting', async () => {
let interactionMode: 'default' | 'plan' = 'default'
const client = optionsClient(async () => ({
@@ -8,27 +8,20 @@ import type { RpcResponse } from '../../../transport/rpc/rpc-wire-types'
function connection() {
let state: ConnectionState = 'connected'
let generation = 0
const callbacks: ((event: unknown) => void)[] = []
const routedCallbacks: { host: string | undefined; callback: (event: unknown) => void }[] = []
const states = new Set<(state: ConnectionState) => void>()
const stops = vi.fn()
const replies: ((reply: RpcResponse) => void)[] = []
const failures: ((reason: Error) => void)[] = []
const sendRequest = vi.fn<RpcClient['sendRequest']>(
() =>
new Promise((resolve, reject) => {
new Promise((resolve) => {
replies.push(resolve)
failures.push(reject)
})
)
const client: RpcClient = {
sendRequest,
subscribe: vi.fn<RpcClient['subscribe']>((_method, _params, callback, options) => {
subscribe: vi.fn<RpcClient['subscribe']>((_method, _params, callback) => {
callbacks.push(callback)
const host = options?.envelope?.executionHost
routedCallbacks.push({ host: typeof host === 'string' ? host : undefined, callback })
return stops
return () => {}
}),
onStateChange: (callback) => {
states.add(callback)
@@ -37,7 +30,6 @@ function connection() {
}
},
getState: () => state,
getGeneration: () => generation,
getReconnectAttempt: () => 0,
getLastConnectedAt: () => null,
notifyForeground: () => {},
@@ -46,19 +38,10 @@ function connection() {
}
return {
client,
stops,
replies,
failures,
callbacks,
states,
emit: (event: unknown) => callbacks.at(-1)?.(event),
emitAt: (host: string | undefined, event: unknown) =>
routedCallbacks.findLast((entry) => entry.host === host)?.callback(event),
change: (next: ConnectionState, migrate = false) => {
change: (next: ConnectionState) => {
state = next
if (migrate) {
generation++
}
for (const callback of states) {
callback(state)
}
@@ -8,7 +8,6 @@ import {
type MobileNativeChatRowOverlays
} from './mobile-native-chat-row-overlays'
import type { MobileNativeChatConversation } from '../use-mobile-native-chat-conversation-state'
import { MarkdownLinkHandlerContext } from '../../../components/markdown-link-handler'
vi.mock('react-native', async () => ({
...(await import('../../../test-support/react-native-motion-stub')).reactNativeMotionStub(),
@@ -35,10 +34,8 @@ vi.mock('../visual/MobileImageViewerActions', () => ({
}))
let overlays: MobileNativeChatRowOverlays | null = null
let openLink: ((url: string) => void) | null = null
function Row(): null {
overlays = useContext(MobileNativeChatRowOverlaysContext)
openLink = useContext(MarkdownLinkHandlerContext)
return null
}
const chat = (conversation: MobileNativeChatConversation) => (
@@ -60,18 +57,15 @@ afterEach(() => {
renderer = null
})
describe.each([
['picture', 'Modal', () => overlays!.openImage({ uri: 'file:///a.jpg', label: 'a photo' })],
['actions sheet', 'MessageActionsSheet', () => overlays!.openActions(message)],
['link sheet', 'LinkActionSheet', () => openLink!('https://example.com')]
])("a row's %s", (_, type, open) => {
describe("a row's actions sheet", () => {
function shownAcross(conversations: MobileNativeChatConversation[]): boolean[] {
const [first, ...rest] = conversations
act(() => {
renderer = create(chat(first!))
})
act(open)
const shown = () => renderer!.root.findAll((node) => String(node.type) === type).length === 1
act(() => overlays!.openActions(message))
const shown = () =>
renderer!.root.findAll((node) => String(node.type) === 'MessageActionsSheet').length === 1
return [shown()].concat(
rest.map((conversation) => {
act(() => renderer!.update(chat(conversation)))
@@ -90,15 +84,6 @@ describe.each([
).toEqual([true, false, false])
})
it("closes when the tab's agent session is replaced", () => {
expect(
shownAcross([
{ surfaceId: 'tab-a', sessionId: 's1' },
{ surfaceId: 'tab-a', sessionId: 's2' }
])
).toEqual([true, false])
})
it("stays open when the tab's first session id arrives, then closes on a replacement", () => {
expect(
shownAcross([
@@ -1,136 +0,0 @@
import { createElement, type ReactNode } from 'react'
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { NativeChatBlock } from '../../../../../src/shared/native-chat/native-chat-types'
const scrolls = vi.hoisted(() => ({ toEnd: vi.fn(), to: vi.fn() }))
vi.mock('react-native', async () => {
const React = await import('react')
class ScrollView extends React.Component<{ children?: ReactNode }> {
scrollToEnd = scrolls.toEnd
scrollTo = scrolls.to
render(): ReactNode {
return React.createElement('ScrollView', this.props, this.props.children)
}
}
const Text = ({ children, ...props }: { children?: ReactNode }): ReactNode =>
React.createElement('Text', props, children)
return {
Animated: {
Text,
Value: class {
setValue(): void {}
},
loop: (animation: unknown) => animation,
sequence: () => ({ start: vi.fn(), stop: vi.fn() }),
timing: () => ({ start: vi.fn(), stop: vi.fn() })
},
Platform: { OS: 'ios' },
Pressable: 'Pressable',
ScrollView,
Text,
View: ({ children, ...props }: { children?: ReactNode }) =>
React.createElement('View', props, children),
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
useWindowDimensions: () => ({ width: 400, height: 800 })
}
})
vi.mock('lucide-react-native', () => ({
Bot: 'Bot',
Brain: 'Brain',
Check: 'Check',
ChevronRight: 'ChevronRight',
Circle: 'Circle',
CircleCheck: 'CircleCheck',
CircleDot: 'CircleDot',
Eye: 'Eye',
FileMinus2: 'FileMinus2',
FilePen: 'FilePen',
FilePlus2: 'FilePlus2',
Folder: 'Folder',
Globe: 'Globe',
ListChecks: 'ListChecks',
MessageSquareMore: 'MessageSquareMore',
Pencil: 'Pencil',
Plug: 'Plug',
Search: 'Search',
SquareTerminal: 'SquareTerminal',
Wrench: 'Wrench'
}))
vi.mock('../../../components/MobileSourceCopyButton', () => ({
MobileSourceCopyButton: 'CopyButton'
}))
vi.mock('../../../platform/external-links/external-link', () => ({ openExternalLink: vi.fn() }))
vi.mock('../../../components/inline-text-selection', () => ({ INLINE_TEXT_SELECTION: false }))
import { ToolRun } from './MobileNativeChatToolRun'
import {
MobileNativeChatDisclosureContext,
useMobileNativeChatDisclosures
} from './mobile-native-chat-disclosure-store'
describe('ToolRun', () => {
let renderer: ReactTestRenderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
const textIn = (node: ReactTestInstance): string[] =>
node
.findAll((node) => String(node.type) === 'Text')
.map((text) => String(text.children.join('')))
describe("what the reader opened, kept above the list's recycled rows", () => {
const blocks: NativeChatBlock[] = [
{ type: 'tool-call', name: 'Bash', input: { command: 'ls' }, state: 'completed' },
{ type: 'tool-result', output: 'a.ts' }
]
function Transcript({ mounted }: { mounted: boolean }) {
const disclosures = useMobileNativeChatDisclosures('host\0worktree\0tab')
return createElement(
MobileNativeChatDisclosureContext.Provider,
{ value: disclosures },
mounted
? createElement(ToolRun, {
blocks,
disclosureId: 'a1'
})
: null
)
}
function show(props: { mounted: boolean }): void {
act(() => {
if (renderer) {
renderer.update(createElement(Transcript, props))
} else {
renderer = create(createElement(Transcript, props))
}
})
}
const press = (label: string): void => {
act(() => {
renderer!.root
.findAll((node) => String(node.type) === 'Pressable')
.findLast((node) => textIn(node).includes(label))!
.props.onPress()
})
}
it('keeps an opened run and line open after the row is recycled and drawn again', () => {
show({ mounted: true })
press('ls')
press('ls')
expect(textIn(renderer!.root)).toContain('a.ts')
show({ mounted: false })
show({ mounted: true })
expect(textIn(renderer!.root)).toContain('a.ts')
})
})
})
@@ -1,28 +0,0 @@
import { expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../../../src/shared/native-chat/native-chat-types'
import { equalMobileNativeChatRowContent as equal } from './mobile-native-chat-row-projection'
it('keeps opaque wide payloads out of comparison', () => {
for (const width of [10, 100]) {
const payload = Object.fromEntries(Array.from({ length: width }, (_, i) => [i, 'opaque']))
const input = () =>
new Proxy(payload, {
ownKeys() {
throw new Error('Opaque payload visited')
}
})
const message: NativeChatMessage = {
id: 'row',
role: 'assistant',
source: 'transcript',
timestamp: 1,
blocks: [{ type: 'tool-call', name: 'Read', input: input() }]
}
const before = { message, scope: 'chat', source: null }
const replacement = {
...message,
blocks: [{ type: 'tool-call' as const, name: 'Read', input: input() }]
}
expect(equal(before, { ...before, message: replacement })).toBe(false)
}
})
@@ -121,7 +121,6 @@ describe('how the structured lane draws its rows, as desktop does', () => {
it.each([
[['# ![](/tmp/p.png)'], true],
[['![](/tmp/p.png)'], false],
[[' ![](/tmp/p.png)'], false],
[['![][p]', '[p]: /tmp/p.png'], true]
] as const)(
'keeps output unless independently rendered prose actually embeds it: %j',
@@ -157,40 +156,4 @@ describe('how the structured lane draws its rows, as desktop does', () => {
).toEqual(retained ? [{ type: 'image-ref' as const, path: '/tmp/p.png' }] : [])
}
)
it('folds a settled turn to its answer until the reader opens it, and only offers that where it hides something', () => {
const messages = [
userMessage('u1'),
toolRow('a1'),
answer('a2'),
userMessage('u2'),
answer('a3')
]
const settledTurns = new Map([
['u1', { startedAt: 0, workedSeconds: 3 }],
['u2', { startedAt: 0, workedSeconds: 2 }]
])
act(() => {
renderer = create(
createElement(Harness, { messages, enabled: true, isWorking: false, settledTurns })
)
})
const read = () => {
const { disclosure } = renderer!.root.findByType(Result).props
return messages.map((message, index) => disclosure.resolveRow(index, message))
}
const settled = read()
expect(settled.map((row) => row.foldedAway === true)).toEqual([
false,
true,
false,
false,
false
])
expect(settled[0]?.turnKey).toBe('u1')
expect(settled[3]?.turnKey).toBeUndefined()
act(() => renderer!.root.findByType(Result).props.disclosure.onToggleTurn('u1'))
expect(read().some((row) => row.foldedAway)).toBe(false)
})
})
@@ -306,19 +306,6 @@ describe('useMobileNativeChatTurnDisclosure', () => {
})
})
it('keeps a long message of yours open per message, so a recycled row reopens it', () => {
const messages = [userMessage('u1'), userMessage('u2')]
act(() => {
renderer = create(createElement(Harness, { messages, enabled: false }))
})
const before = renderer!.root.findByType(Result).props.disclosure
expect(before.resolveRow(0, messages[0]).userMessageOpen).toBe(false)
act(() => before.resolveRow(0, messages[0]).onToggleUserMessage('u1'))
const after = renderer!.root.findByType(Result).props.disclosure
expect(after.resolveRow(0, messages[0]).userMessageOpen).toBe(true)
expect(after.resolveRow(1, messages[1]).userMessageOpen).toBe(false)
})
it('keeps a turn the provider opened live while it runs, and the turn before it settled', () => {
let sequence = 0
const entry = (
@@ -1,122 +0,0 @@
import { createElement, type ReactNode } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MobileSessionController } from '../use-mobile-session-controller'
import type { MobileSessionTabSummary } from './mobile-session-tab-rows'
vi.mock('react-native', async () => {
const { createElement: h } = await import('react')
return {
AppState: { currentState: 'active', addEventListener: () => ({ remove: () => {} }) },
// Calls a render-function child the way the real Pressable does, so rows draw.
Pressable: ({
children,
...props
}: {
children?: ReactNode | ((state: { pressed: boolean }) => ReactNode)
}) =>
h(
'Pressable',
props,
typeof children === 'function' ? children({ pressed: false }) : children
),
StyleSheet: { create: <T,>(styles: T) => styles, absoluteFill: {} },
Text: 'Text',
View: 'View'
}
})
vi.mock('lucide-react-native', () => ({ Plus: 'Plus', X: 'X' }))
// Like the real drawer, draws its content only while presented.
vi.mock('../../components/bottom-drawer/BottomDrawer', () => ({
BottomDrawer: ({ visible, children }: { visible: boolean; children: ReactNode }) =>
visible ? children : null
}))
vi.mock('../../components/SwipeRowActions', () => ({
SwipeRowActions: ({
children
}: {
children: (guard: (onPress: () => void) => () => void) => ReactNode
}) => children((onPress) => onPress)
}))
vi.mock('../../components/list/ListGroup', () => ({
ListGroup: ({ children }: { children: ReactNode }) => children,
SectionHeader: 'SectionHeader'
}))
vi.mock('../../components/AgentStateDot', () => ({ AgentStateDot: 'AgentStateDot' }))
vi.mock('../../components/nav-bar/NavBarButton', () => ({ NavBarButton: 'NavBarButton' }))
vi.mock('../../components/nav-bar/SheetHeader', () => ({ SheetHeader: 'SheetHeader' }))
vi.mock('./MobileSessionTabMark', () => ({ MobileSessionTabMark: 'MobileSessionTabMark' }))
const { MobileSessionTabsSheet } = await import('./MobileSessionTabsSheet')
const NOW = 1_700_000_000_000
function terminalTab(id: string, stateStartedAt: number): MobileSessionTabSummary {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the sheet reads only these status fields; the rest of the host's status entry is unused here.
return {
tab: {
type: 'terminal',
id,
title: id,
isActive: false,
terminal: null,
agentStatus: {
state: 'waiting',
prompt: 'Fix the bug',
updatedAt: stateStartedAt,
stateStartedAt
}
},
title: id,
agentId: null,
dotState: 'waiting',
group: 'needs-you'
} as unknown as MobileSessionTabSummary
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the sheet reads only these controller members.
const controller = {
activeSessionTabId: 'open-tab',
worktreeName: 'main',
switchSessionTab: () => {},
handleCloseSessionTab: async () => {},
handleOpenNewTab: () => {},
createTabBusy: false,
connState: 'connected'
} as unknown as MobileSessionController
describe('MobileSessionTabsSheet', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
vi.useFakeTimers({ now: NOW })
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
vi.useRealTimers()
})
it('shows how long ago each tab changed as of opening, not as of when the bar mounted', () => {
const tabs = [terminalTab('open-tab', NOW), terminalTab('other-tab', NOW - 30_000)]
const sheet = (visible: boolean) =>
createElement(MobileSessionTabsSheet, { controller, tabs, visible, onClose: () => {} })
act(() => {
renderer = create(sheet(false))
})
// Opened 45 s later, before any minute tick: the other tab changed 75 s ago.
act(() => {
vi.advanceTimersByTime(45_000)
})
act(() => renderer!.update(sheet(true)))
const otherRow = renderer!.root.find(
(node) =>
String(node.type) === 'Pressable' && node.props.accessibilityState?.selected === false
)
const times = otherRow
.findAll((node) => String(node.type) === 'Text')
.map((node) => node.props.children)
expect(times).toContain('1m')
})
})
@@ -61,7 +61,6 @@ describe('browser settings persistence', () => {
resolveLoad('orca-browser')
})
expect(renderer.root.findByType(PickerModal).props.selected).toBe('phone-browser')
expect(JSON.stringify(renderer.toJSON())).toContain('Phone browser')
})
it('restores the saved destination when changing the destination fails', async () => {
@@ -74,7 +73,6 @@ describe('browser settings persistence', () => {
renderer.root.findByType(PickerModal).props.onSelect('phone-browser')
})
expect(renderer.root.findByType(PickerModal).props.selected).toBe('orca-browser')
expect(JSON.stringify(renderer.toJSON())).toContain('Could not save browser preferences')
})
it('restores the hydrated destination when the save fails before the first read finishes', async () => {
@@ -94,6 +92,5 @@ describe('browser settings persistence', () => {
})
await act(async () => resolveLoad('phone-browser'))
expect(renderer.root.findByType(PickerModal).props.selected).toBe('phone-browser')
expect(JSON.stringify(renderer.toJSON())).toContain('Could not save browser preferences')
})
})
@@ -86,22 +86,18 @@ describe('shared settings screen state', () => {
})
const switchProps = () => renderer.root.findByProps({ testID: 'voice-enabled' }).props
expect(switchProps().value).toBe(true)
expect(switchProps().disabled).toBeUndefined()
await act(async () => {
switchProps().onValueChange(false)
})
// The switch moves on tap, before the desktop has answered.
expect(switchProps().value).toBe(false)
expect(switchProps().disabled).toBeUndefined()
expect(operations.configure).toHaveBeenCalledOnce()
await act(async () => {
rejectConfigure(new Error('Desktop unavailable'))
await Promise.resolve()
})
expect(JSON.stringify(renderer.toJSON())).toContain('Desktop unavailable')
expect(operations.load).toHaveBeenCalledTimes(2)
})
it('drops a poll that resolves after a toggle instead of clobbering it', async () => {
const loaded = {
@@ -141,7 +137,6 @@ describe('shared settings screen state', () => {
createElement(VoiceSettingsScreen, { operations: second, focused: true, onBack: vi.fn() })
)
})
expect(shared.load).toHaveBeenCalledTimes(2)
await act(async () => {
switchProps().onValueChange(false)
@@ -237,7 +232,6 @@ describe('shared settings screen state', () => {
renderer.root.findByProps({ testIDPrefix: 'voice-mode-' }).props.onSelect('hold')
})
const finishReads = async () => {
expect(reads.length).toBeGreaterThan(0)
await act(async () => {
reads.forEach((finishRead) => finishRead())
})
@@ -18,8 +18,6 @@ import {
} from './mobile-git-status-query'
import { gitStatusHostPayloadSchema } from '../git/git-status-reply-schema'
import { createOrcaQueryClient } from '../../query/orca-query-client'
import { createHostQueryPersistence } from '../../query/host-query-persistence'
import type { OrcaQueryStoreBackend } from '../../device-store/query-storage'
import {
documentQueryClient,
@@ -135,8 +133,6 @@ async function settleBaseRefLookup(calls: PendingCall[], baseRef: string): Promi
// Stable callbacks keep the mount effect from issuing extra loads that would obscure the tested schedule.
const IGNORE_ACTION_ERROR = (): void => {}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `setRootRef` compares the node against null and returns; it reads no member of it.
describe('useMobileSourceControlLoaders branch compare', () => {
let renderer: ReactTestRenderer | null = null
@@ -327,7 +323,7 @@ describe('useMobileSourceControlLoaders status summary', () => {
})
// Summary read at 100, detail cached at 200, child opened at 400.
async function cachedDetailOpened(refreshAt300: boolean) {
async function cachedDetailOpened() {
vi.useFakeTimers({ toFake: ['Date'], now: 100 })
const calls: PendingCall[] = []
const child = setup(calls)
@@ -335,10 +331,8 @@ describe('useMobileSourceControlLoaders status summary', () => {
vi.setSystemTime(200)
child.queryClient.setQueryData(detailKey, readOf('cached'), { updatedAt: 200 })
vi.setSystemTime(300)
if (refreshAt300) {
await child.queryClient.invalidateQueries({ queryKey: summaryKey }, { cancelRefetch: false })
child.readSummary()
}
await child.queryClient.invalidateQueries({ queryKey: summaryKey }, { cancelRefetch: false })
child.readSummary()
vi.setSystemTime(400)
await child.mount()
await settleQueryNotifications()
@@ -346,85 +340,11 @@ describe('useMobileSourceControlLoaders status summary', () => {
}
it('leaves a summary read out to answer when the child opens over an older cached read', async () => {
const { calls, child } = await cachedDetailOpened(true)
const { calls, child } = await cachedDetailOpened()
await act(async () => takeRead(calls, true).settle(statusReply('refresh')))
await settleQueryNotifications()
expect(child.queryClient.getQueryData(summaryKey)).toMatchObject({
status: { branch: 'refresh' }
})
})
it('catches an idle older summary up to the cached read, keeping its time', async () => {
const { child } = await cachedDetailOpened(false)
expect(child.queryClient.getQueryData(summaryKey)).toEqual(readOf('cached'))
expect(child.queryClient.getQueryState(summaryKey)?.dataUpdatedAt).toBe(200)
})
it("never hands an earlier launch's status to the summary, nor cancels its live read", async () => {
const store = memoryStore()
const earlier = createOrcaQueryClient()
const earlierPersistence = createHostQueryPersistence(earlier, store.backend)
earlier.getQueryCache().build(earlier, {
queryKey: detailKey,
meta: { persist: gitStatusQuery.persist }
})
earlier.setQueryData(detailKey, readOf('saved'), { updatedAt: Date.now() - 86_400_000 })
await settleQueryNotifications()
await earlierPersistence.flush()
expect(store.rows.size).toBe(1)
earlier.clear()
const calls: PendingCall[] = []
const child = setup(calls)
createHostQueryPersistence(child.queryClient, store.backend)
child.readSummary()
const release = store.holdReads()
await child.mount()
release()
await settleQueryNotifications()
expect(child.queryClient.getQueryData(detailKey)).toEqual(readOf('saved'))
await act(async () => takeRead(calls, true).settle(statusReply('live')))
await settleQueryNotifications()
expect(child.queryClient.getQueryData(summaryKey)).toMatchObject({ status: { branch: 'live' } })
await act(async () => takeRead(calls, false).settle(statusReply('newer')))
await settleQueryNotifications()
expect(child.queryClient.getQueryData(summaryKey)).toMatchObject({
status: { branch: 'newer' }
})
})
})
function memoryStore() {
const rows = new Map<string, string>()
let readGate: Promise<void> = Promise.resolve()
const backend: OrcaQueryStoreBackend = {
hostId: 'host-1',
read: async (keys) => {
await readGate
return { status: 'ok', values: keys.map((storeKey) => rows.get(storeKey) ?? null) }
},
write: async (ops) => {
for (const op of ops) {
if (op.value === null) {
rows.delete(op.key)
} else {
rows.set(op.key, op.value)
}
}
return { written: true }
}
}
return {
backend,
rows,
holdReads: () => {
let release = (): void => {}
readGate = new Promise((resolve) => {
release = resolve
})
return release
}
}
}
@@ -14,15 +14,14 @@ function hostReplying(replies: Record<string, unknown>) {
const unavailable = Object.assign(new Error('not available'), { code: 'method_not_found' })
describe('runMobileGitSyncSteps', () => {
it.each([
['missing ahead', { hasUpstream: true, behind: 0 }],
['non-numeric ahead', { hasUpstream: true, ahead: '2', behind: 0 }],
['no reply', undefined]
])('fails instead of silently skipping the push on %s', async (_label, reply) => {
const send = hostReplying({ 'git.upstreamStatus': reply })
await expect(runMobileGitSyncSteps(send)).rejects.toThrow('Branch status unavailable')
expect(send).not.toHaveBeenCalledWith('git.push')
})
it.each([['missing ahead', { hasUpstream: true, behind: 0 }]])(
'fails instead of silently skipping the push on %s',
async (_label, reply) => {
const send = hostReplying({ 'git.upstreamStatus': reply })
await expect(runMobileGitSyncSteps(send)).rejects.toThrow('Branch status unavailable')
expect(send).not.toHaveBeenCalledWith('git.push')
}
)
it('fails when the git.status fallback has an unreadable upstream', async () => {
const send = hostReplying({
@@ -5,7 +5,6 @@ import type { MobileHostedReviewEligibilityReply } from './hosted-review-reply-s
import {
buildMobileHostedReviewEligibilityLoadKey,
renderedMobileHostedReviewEligibilityState,
shouldFetchMobileHostedReviewEligibility,
useMobileHostedReviewEligibility,
type MobileHostedReviewEligibilityLoadKey,
type MobileHostedReviewEligibilityLoadSnapshot
@@ -44,46 +43,7 @@ function loadSnapshot(
return { key, state }
}
describe('mobile hosted review eligibility loader core', () => {
it('does not fetch while disconnected or detached', () => {
expect(
shouldFetchMobileHostedReviewEligibility({
client: { sendRequest: async () => ({ ok: true }) } as never,
connState: 'disconnected',
branch: 'feature'
})
).toBe(false)
expect(
shouldFetchMobileHostedReviewEligibility({
client: { sendRequest: async () => ({ ok: true }) } as never,
connState: 'connected',
branch: null
})
).toBe(false)
})
})
// Fetch-imminent must render loading immediately; an idle frame would shift the list when Create PR appears.
describe('rendered eligibility state', () => {
it('renders fetch-imminent idle as an in-flight load', () => {
const key = buildMobileHostedReviewEligibilityLoadKey({
hostId: 'host-1',
worktreeId: 'wt-1',
branch: 'feature',
hasUpstream: true,
ahead: 0,
behind: 0,
hasUncommittedChanges: false
})
expect(
renderedMobileHostedReviewEligibilityState({
snapshot: loadSnapshot(key, { kind: 'idle' }),
key,
shouldFetch: true
})
).toEqual({ kind: 'loading', eligibility: null })
})
it('renders idle when a fetch is not possible, hiding stale snapshots in the same render', () => {
const key = buildMobileHostedReviewEligibilityLoadKey({
hostId: 'host-1',
@@ -1,41 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { loadMobilePrBranchContext } from './use-mobile-pr-branch-context'
describe('loadMobilePrBranchContext', () => {
it('keeps status and repo eligibility when branchCompare fails', async () => {
const sendRequest = vi.fn(async (method: string) => {
if (method === 'git.status') {
return {
ok: true,
result: { entries: [], conflictOperation: 'unknown', branch: 'feat', head: 'sha-status' }
}
}
if (method === 'repo.list') {
return {
ok: true,
result: { repos: [{ id: 'repo', worktreeBaseRef: 'main' }] }
}
}
if (method === 'git.branchCompare') {
return { ok: false, error: { message: 'compare failed' } }
}
if (method === 'github.repoSlug') {
return { ok: true, result: { owner: 'stablyai', repo: 'orca' } }
}
return { ok: false, error: { message: `unexpected ${method}` } }
})
const out = await loadMobilePrBranchContext({ sendRequest } as never, 'repo::/wt')
expect(sendRequest).toHaveBeenCalledWith('git.branchCompare', {
worktree: 'id:repo::/wt',
baseRef: 'main'
})
expect(out).toEqual({
branch: 'feat',
headSha: 'sha-status',
status: expect.objectContaining({ branch: 'feat', head: 'sha-status' }),
isGithubRepo: true,
repoLoaded: true,
loaded: true
})
})
})
@@ -1,36 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { loadAppUpdatePreferences } from './app-update-preferences'
const stored = new Map<string, string>()
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
multiGet: async (keys: string[]) => keys.map((key) => [key, stored.get(key) ?? null])
}
}))
beforeEach(() => stored.clear())
describe('loadAppUpdatePreferences', () => {
it('drops only a corrupt update record, keeping the check time and dismissal beside it', async () => {
stored.set('orca:appUpdate:lastCheckedAt', '1700000000000')
stored.set('orca:appUpdate:latest', '{not json')
stored.set('orca:appUpdate:dismissedVersion', '0.0.51')
await expect(loadAppUpdatePreferences()).resolves.toEqual({
lastCheckedAt: 1700000000000,
latest: null,
dismissedVersion: '0.0.51'
})
})
it('reads a well-formed update record', async () => {
stored.set(
'orca:appUpdate:latest',
JSON.stringify({ version: '0.0.52', url: 'https://x.test' })
)
await expect(loadAppUpdatePreferences()).resolves.toEqual({
lastCheckedAt: null,
latest: { version: '0.0.52', url: 'https://x.test' },
dismissedVersion: null
})
})
})
@@ -51,16 +51,4 @@ describe('the mirrored write path', () => {
)
warned.mockRestore()
})
it('answers a reader with an accepted write, which is what `init` is built from', async () => {
await persistMirrored(DOCK_WIDTH, '480')
expect(readMirroredStorage([DOCK_WIDTH])[DOCK_WIDTH]).toBe('480')
expect(initStorage()[DOCK_WIDTH]).toBe('480')
})
it('drops a removed key from the mirror, which is what absent means to `init`', async () => {
await persistMirrored(DOCK_WIDTH, null)
expect(readMirroredStorage([DOCK_WIDTH])[DOCK_WIDTH]).toBeUndefined()
expect(initStorage()[DOCK_WIDTH]).toBeUndefined()
})
})
-32
View File
@@ -4,7 +4,6 @@ import { loadTerminalLinkOpenMode, saveTerminalLinkOpenMode } from './preference
import {
loadDefaultSessionView,
loadSessionViewOverrides,
readDefaultSessionViewPreference,
readSessionViewOverridesPreference,
saveDefaultSessionView,
updateSessionViewOverride
@@ -41,26 +40,6 @@ describe('session view preference', () => {
})
})
it('marks an unreadable default as not loaded so the opt-in gate can bail', async () => {
vi.mocked(AsyncStorage.getItem).mockRejectedValue(new Error('storage unavailable'))
await expect(readDefaultSessionViewPreference()).resolves.toEqual({
value: null,
loaded: false,
hasStoredValue: false
})
await expect(loadDefaultSessionView()).resolves.toBe('terminal')
})
it('migrates the legacy array format to chat overrides', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(['tab-1', 42, 'tab-2']))
const loaded = await loadSessionViewOverrides('host', 'wt')
expect([...loaded.entries()]).toEqual([
['tab-1', 'chat'],
['tab-2', 'chat']
])
})
it('serializes default writes across callers and makes reads wait for the latest', async () => {
let stored = 'terminal'
const firstWrite = deferred<void>()
@@ -195,17 +174,6 @@ describe('session view preference', () => {
expect(AsyncStorage.setItem).not.toHaveBeenCalled()
})
it('repairs invalid preference data on the next user mutation', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue('not-json')
await updateSessionViewOverride('host', 'worktree', 'tab', 'chat')
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
'orca:nativeChatTabs:host:worktree',
JSON.stringify({ tab: 'chat' })
)
})
})
describe('terminal link open mode preference', () => {
@@ -1,32 +1,15 @@
import { describe, expect, it } from 'vitest'
import {
documentLevelRules,
isDocumentLevelSelector,
scopeDocumentStyleToHost,
scopeStyleToHost
} from './document-style-scoping'
import { scopeDocumentStyleToHost, scopeStyleToHost } from './document-style-scoping'
// Test rewrite semantics and refused input shapes here; each page mount separately checks its injected sheet.
const PREFIX = '.host'
describe('a stylesheet held under a host element', () => {
it('reads a document-level selector wherever the list puts it', () => {
expect(isDocumentLevelSelector('body')).toBe(true)
expect(isDocumentLevelSelector('html, body')).toBe(true)
expect(isDocumentLevelSelector('*')).toBe(true)
expect(isDocumentLevelSelector('#terminal-container')).toBe(false)
expect(isDocumentLevelSelector('.xterm .xterm-viewport')).toBe(false)
expect(documentLevelRules('* { margin: 0; }\n.a { color: red; }')).toEqual(['*'])
})
it('refuses a sheet whose shape it cannot rewrite, through either export', () => {
it('refuses at-rules through either export before inner selectors can leak', () => {
// Textual rewriting supports flat sheets only; at-rules would leak unscoped inner selectors.
for (const scope of [scopeStyleToHost, scopeDocumentStyleToHost]) {
expect(() => scope('@media (min-width: 1px) { .a { color: red; } }', PREFIX)).toThrow(
'at-rules cannot be scoped'
)
expect(() => scope('.a { color: red;', PREFIX)).toThrow('never closes')
expect(() => scope('{ color: red; }', PREFIX)).toThrow('no selector')
}
})
})
@@ -1,19 +1,8 @@
import { describe, expect, it } from 'vitest'
import { buildGitHubPrFileDiffLines, buildGitHubPrFileDiffPreview } from './github-pr-file-diff'
describe('buildGitHubPrFileDiffLines', () => {
it('keeps all lines for large files without exact diff truncation', () => {
const original = Array.from({ length: 500 }, (_, index) => `old-${index}`).join('\n')
const modified = Array.from({ length: 500 }, (_, index) => `new-${index}`).join('\n')
const lines = buildGitHubPrFileDiffLines(original, modified)
expect(lines).toHaveLength(1000)
expect(lines[0]).toMatchObject({ kind: 'removed', oldLineNumber: 1, text: 'old-0' })
expect(lines.at(-1)).toMatchObject({ kind: 'added', newLineNumber: 500, text: 'new-499' })
})
import { buildGitHubPrFileDiffPreview } from './github-pr-file-diff'
describe('buildGitHubPrFileDiffPreview', () => {
it('builds capped previews while preserving the exact total line count', () => {
const original = Array.from({ length: 500 }, (_, index) => `old-${index}`).join('\n')
const modified = Array.from({ length: 500 }, (_, index) => `new-${index}`).join('\n')
@@ -1,7 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
filterGitHubProjectRowsForRepos,
type GitHubProjectRepoMatch,
type GitHubRepoSlugCacheEntry
} from './github-project-repo-match'
@@ -31,35 +30,4 @@ describe('project repository matching within one row projection', () => {
slugs.one = { path: '/moved', repository: { owner: 'acme', repo: 'one' } }
expect(filterGitHubProjectRowsForRepos(rows, repos, slugs)).toEqual(rows)
})
it('rechecks the active project host and fork-origin evidence on every projection', () => {
const repos: GitHubProjectRepoMatch[] = [
{
id: 'fork',
path: '/fork',
displayName: 'fork',
upstream: { owner: 'acme', repo: 'one' }
}
]
const slugs: Record<string, GitHubRepoSlugCacheEntry | undefined> = {
fork: { path: '/fork', repository: { owner: 'me', repo: 'fork', host: 'github.com' } }
}
const rows = rowsFor(['acme/one', 'acme/one'])
expect(filterGitHubProjectRowsForRepos(rows, repos, slugs, 'github.com')).toEqual(rows)
expect(filterGitHubProjectRowsForRepos(rows, repos, slugs, 'github.enterprise.test')).toEqual(
[]
)
slugs.fork = {
path: '/fork',
repository: { owner: 'me', repo: 'fork', host: 'github.enterprise.test' }
}
expect(filterGitHubProjectRowsForRepos(rows, repos, slugs, 'github.enterprise.test')).toEqual(
rows
)
expect(filterGitHubProjectRowsForRepos(rows, repos, slugs, 'github.com')).toEqual([])
slugs.fork = { path: '/fork', repository: null }
expect(filterGitHubProjectRowsForRepos(rows, repos, slugs, 'github.enterprise.test')).toEqual(
[]
)
})
})
@@ -1,58 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
hasSettledHostRepoList,
hostRepoListReducer,
initialHostRepoList,
needsHostRepoListFetch,
type HostRepoListState
} from './host-repo-list'
type Repo = { id: string }
function reduce(
state: HostRepoListState<Repo>,
...actions: Parameters<typeof hostRepoListReducer<Repo>>[1][]
): HostRepoListState<Repo> {
return actions.reduce(hostRepoListReducer<Repo>, state)
}
const idle = initialHostRepoList<Repo>()
describe('hostRepoListReducer', () => {
it('keeps the last good list while a reload is in flight', () => {
const loaded = reduce(idle, { type: 'requested' }, { type: 'resolved', repos: [{ id: 'a' }] })
expect(reduce(loaded, { type: 'requested' })).toEqual({
status: 'loading',
repos: [{ id: 'a' }],
error: ''
})
})
it('keeps the last good list when a reload fails, and records why', () => {
const loaded = reduce(idle, { type: 'resolved', repos: [{ id: 'a' }] })
expect(reduce(loaded, { type: 'requested' }, { type: 'failed', error: 'offline' })).toEqual({
status: 'error',
repos: [{ id: 'a' }],
error: 'offline'
})
})
})
describe('host repo list readiness', () => {
// Unfetched repos must not look like an empty host and hide every Project row.
it('does not treat an unfetched or in-flight list as settled', () => {
expect(hasSettledHostRepoList(idle)).toBe(false)
expect(hasSettledHostRepoList(reduce(idle, { type: 'requested' }))).toBe(false)
})
it('settles on success and on failure, so the UI never waits forever', () => {
expect(hasSettledHostRepoList(reduce(idle, { type: 'resolved', repos: [] }))).toBe(true)
expect(hasSettledHostRepoList(reduce(idle, { type: 'failed', error: 'nope' }))).toBe(true)
})
it('re-fetches after a failure but not after an empty success', () => {
expect(needsHostRepoListFetch(idle)).toBe(true)
expect(needsHostRepoListFetch(reduce(idle, { type: 'failed', error: 'nope' }))).toBe(true)
expect(needsHostRepoListFetch(reduce(idle, { type: 'resolved', repos: [] }))).toBe(false)
})
})
@@ -9,10 +9,6 @@ const repos = [
describe('reconcileRepoSelection', () => {
it('drops ids this host no longer has', () => {
expect(reconcileRepoSelection(repos, ['a', 'gone'])).toEqual(new Set(['a']))
})
it('never selects a folder workspace', () => {
expect(reconcileRepoSelection(repos, ['folder'])).toEqual(new Set())
expect(reconcileRepoSelection(repos, ['a', 'gone', 'folder'])).toEqual(new Set(['a']))
})
})
@@ -6,19 +6,13 @@ import { useHostRepoList, type HostRepoListResource } from './use-host-repo-list
type Repo = { id: string }
function mountResource() {
const pending: { resolve: (repos: Repo[]) => void; reject: (err: Error) => void }[] = []
const pending: { resolve: (repos: Repo[]) => void }[] = []
let latest: HostRepoListResource<Repo> | null = null
let calls = 0
function Probe({ clientKey, connected = true }: { clientKey: unknown; connected?: boolean }) {
function Probe({ clientKey }: { clientKey: unknown }) {
latest = useHostRepoList<Repo>(
clientKey,
connected
? () => {
calls += 1
return new Promise<Repo[]>((resolve, reject) => pending.push({ resolve, reject }))
}
: null
() => new Promise<Repo[]>((resolve) => pending.push({ resolve }))
)
return null
}
@@ -34,18 +28,9 @@ function mountResource() {
}
return latest
},
get callCount(): number {
return calls
},
pending,
rerender() {
rebind(clientKey: unknown) {
act(() => {
renderer.update(createElement(Probe, { clientKey: 'client-a' }))
})
},
rebind(clientKey: unknown, connected = true) {
act(() => {
renderer.update(createElement(Probe, { clientKey, connected }))
renderer.update(createElement(Probe, { clientKey }))
})
},
async settle(index: number, repos: Repo[]) {
@@ -53,12 +38,6 @@ function mountResource() {
pending[index]!.resolve(repos)
await Promise.resolve()
})
},
async fail(index: number, message: string) {
await act(async () => {
pending[index]!.reject(new Error(message))
await Promise.resolve()
})
}
}
}
@@ -100,6 +79,4 @@ describe('useHostRepoList', () => {
await probe.settle(0, [{ id: 'stale-a' }])
expect(probe.resource.state.repos).toEqual([{ id: 'new-a' }])
})
// Reload and ensureLoaded share an event before requested state commits; join the replacement request.
})
@@ -1,54 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
clearMobileTaskCopyFeedbackTimer,
scheduleMobileTaskCopyFeedbackReset,
type MobileTaskCopyFeedbackTimerRef
} from './mobile-task-copy-feedback-timer'
function createTimerRef(): MobileTaskCopyFeedbackTimerRef {
return { current: null }
}
function createCopiedKeyState(initial: string | null) {
let value = initial
return {
get value() {
return value
},
setCopiedKey(updater: (current: string | null) => string | null) {
value = updater(value)
}
}
}
describe('mobile task copy feedback timer', () => {
afterEach(() => {
vi.useRealTimers()
})
it('clears the copied key only when the scheduled key is still current', () => {
vi.useFakeTimers()
const timerRef = createTimerRef()
const copied = createCopiedKeyState('task:one')
scheduleMobileTaskCopyFeedbackReset(timerRef, 'task:one', copied.setCopiedKey)
copied.setCopiedKey(() => 'task:two')
vi.runOnlyPendingTimers()
expect(copied.value).toBe('task:two')
expect(timerRef.current).toBeNull()
})
it('cancels pending copy feedback work', () => {
vi.useFakeTimers()
const timerRef = createTimerRef()
const copied = createCopiedKeyState('task:one')
scheduleMobileTaskCopyFeedbackReset(timerRef, 'task:one', copied.setCopiedKey)
clearMobileTaskCopyFeedbackTimer(timerRef)
vi.runOnlyPendingTimers()
expect(copied.value).toBe('task:one')
expect(timerRef.current).toBeNull()
})
})
@@ -1,37 +0,0 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest'
import { createTerminalDocument } from './create-terminal-document'
// Drive the generated factory with missing menu markup so a late startup throw must undo reporters, listeners and transport before returning no handle.
const MARKUP_WITHOUT_THE_MENU =
'<div id="terminal-container"><div id="terminal-surface"></div></div>' +
'<div id="selection-overlay"><div id="sel-handle-start"></div>' +
'<div id="sel-handle-end"></div></div>' +
'<div id="scroll-indicator"><div id="scroll-thumb"></div></div>'
const MARKUP_WITH_THE_MENU = MARKUP_WITHOUT_THE_MENU.replace(
'<div id="sel-handle-end"></div></div>',
'<div id="sel-handle-end"></div><div id="sel-menu">' +
'<button id="sel-menu-copy"></button><button id="sel-menu-all"></button></div></div>'
)
const hostWithoutAnEngine = () => ({
postToHost: () => {},
hasEngine: () => false
})
describe('the document start sequence', () => {
it('would have installed one, so the null above is a measurement', () => {
document.body.innerHTML = MARKUP_WITH_THE_MENU
const previous = window.onerror
window.onerror = null
try {
const started = createTerminalDocument(hostWithoutAnEngine())
expect(window.onerror).not.toBe(null)
started.stop()
expect(window.onerror).toBe(null)
} finally {
window.onerror = previous
}
})
})
@@ -1,17 +0,0 @@
import { describe, expect, it } from 'vitest'
import { createTerminalKeyChordInput } from './terminal-key-chord-input'
const chord = (key: string, modifiers: { ctrl?: boolean; alt?: boolean; shift?: boolean } = {}) =>
createTerminalKeyChordInput({
key,
ctrl: modifiers.ctrl ?? false,
alt: modifiers.alt ?? false,
shift: modifiers.shift ?? false
})
describe('createTerminalKeyChordInput', () => {
it('keeps the accessory Del local-edit rule for a bare forward delete only', () => {
expect(chord('delete')).toEqual({ bytes: '\x1b[3~', localEdit: 'delete' })
expect(chord('delete', { ctrl: true })).toEqual({ bytes: '\x1b[3;5~' })
})
})
@@ -30,21 +30,6 @@ describe('computeActiveTerminalKeyboardLift', () => {
expect({ candidate, cursorOnly }).toEqual({ candidate: 220, cursorOnly: 140 })
})
it('applies the supplied keyboard coverage to the lift', () => {
const tuiMetrics = metrics({ cursorY: 30, contentBottomRow: 34 })
const android = computeActiveTerminalKeyboardLift({
keyboardLift: 300,
metrics: tuiMetrics,
terminalFrameHeight: FRAME_HEIGHT
})
const ios = computeActiveTerminalKeyboardLift({
keyboardLift: 266,
metrics: tuiMetrics,
terminalFrameHeight: FRAME_HEIGHT
})
expect({ android, ios }).toEqual({ android: 220, ios: 186 })
})
describe('a keyboard that covers the frame', () => {
const lift = (keyboardLift: number, m: TerminalKeyboardAvoidanceMetrics, frame = 713.7) =>
computeActiveTerminalKeyboardLift({
@@ -5,10 +5,7 @@ describe('terminal live accessory local edit text', () => {
it.each([
['backspace', 'a🙂b', { start: 3, end: 3 }, 'ab'],
['delete', 'a🙂b', { start: 1, end: 1 }, 'ab'],
['backspace', '可是你好', { start: 1, end: 3 }, '可好'],
['delete', '可是你好', { start: 1, end: 3 }, '可好'],
['backspace', 'abc', { start: 0, end: 0 }, 'abc'],
['delete', 'abc', { start: 3, end: 3 }, 'abc']
['backspace', '可是你好', { start: 1, end: 3 }, '可好']
] as const)(
'edits %s in %j at its native UTF-16 selection',
(localEdit, fieldText, selection, expected) => {
@@ -23,17 +23,6 @@ function sendBeforeInput(field: HTMLElement, inputType: string): InputEvent {
}
describe('the web sibling', () => {
it('submits on the line break the browser reports, and cancels it', () => {
const onSubmit = vi.fn()
const field = mountField()
bindOnWeb(asField(field), onSubmit)
const event = sendBeforeInput(field, 'insertLineBreak')
expect(onSubmit).toHaveBeenCalledTimes(1)
expect(event.defaultPrevented).toBe(true)
})
// IME candidate acceptance emits insertCompositionText, so it must not submit terminal input.
it('leaves a composition commit alone', () => {
const onSubmit = vi.fn()
@@ -78,18 +67,17 @@ describe.each(['android', 'ios'] as const)('live terminal keyboard chords on %s'
return { field, onChord, unbind, key }
}
it.each([
['c', { ctrlKey: true }, '\x03'],
['ArrowLeft', { ctrlKey: true }, '\x1b[1;5D'],
['Enter', { shiftKey: true }, '\x1b[13;2u']
])('claims %s once before RNWeb can resend it', (name, options, bytes) => {
const { field, onChord, key } = setup()
const parentKey = vi.fn()
field.parentElement?.addEventListener('keydown', parentKey, { once: true })
expect(key(name, options).defaultPrevented).toBe(true)
expect(onChord).toHaveBeenCalledExactlyOnceWith({ bytes })
expect(parentKey).not.toHaveBeenCalled()
})
it.each([['c', { ctrlKey: true }, '\x03']])(
'claims %s once before RNWeb can resend it',
(name, options, bytes) => {
const { field, onChord, key } = setup()
const parentKey = vi.fn()
field.parentElement?.addEventListener('keydown', parentKey, { once: true })
expect(key(name, options).defaultPrevented).toBe(true)
expect(onChord).toHaveBeenCalledExactlyOnceWith({ bytes })
expect(parentKey).not.toHaveBeenCalled()
}
)
it.each(['b'])('keeps Option-generated %j native on iOS and Alt chords on Android', (name) => {
const { onChord, key } = setup()
@@ -98,17 +86,12 @@ describe.each(['android', 'ios'] as const)('live terminal keyboard chords on %s'
})
it.each([
['a', {}],
['Enter', {}],
['Enter', { ctrlKey: true, shiftKey: true }],
['Backspace', {}],
['ArrowLeft', {}],
['c', { ctrlKey: true, isComposing: true }],
['c', { ctrlKey: true, keyCode: 229 }],
['c', { ctrlKey: true, altKey: true }],
['c', { metaKey: true }],
[' ', { ctrlKey: true }],
['1', { ctrlKey: true }]
[' ', { ctrlKey: true }]
])('leaves %s input and IME/platform shortcuts alone', (name, options) => {
const { onChord, key } = setup()
expect(key(name, options).defaultPrevented).toBe(false)
@@ -1,10 +0,0 @@
import { describe, expect, it } from 'vitest'
import { normalizeTerminalTextInput } from './terminal-text-input-normalization'
describe('normalizeTerminalTextInput', () => {
it('converts iOS smart dash replacements back to terminal hyphens', () => {
expect(normalizeTerminalTextInput('git checkout – file')).toBe('git checkout -- file')
expect(normalizeTerminalTextInput('git checkout — file')).toBe('git checkout -- file')
})
})
@@ -16,20 +16,6 @@ describe('terminal write coalescer', () => {
vi.useRealTimers()
})
it('batches writes inside the trailing window preserving byte order', () => {
vi.useFakeTimers()
const sink = createDeliverySink()
const coalescer = createTerminalWriteCoalescer(sink.deliver)
coalescer.write('a')
coalescer.write('b')
coalescer.write('c')
expect(sink.delivered).toEqual(['a'])
vi.advanceTimersByTime(48)
expect(sink.delivered).toEqual(['a', 'bc'])
})
it('flushes within one window even when the wall clock jumps backwards mid-stream', () => {
vi.useFakeTimers()
vi.setSystemTime(100_000)
@@ -43,22 +43,6 @@ describe('isTerminalGestureInput', () => {
).toBe(true)
})
it('accepts repeated default mouse wheel sequences', () => {
expect(
isTerminalGestureInput(
`${ESC}[M${String.fromCharCode(96, 97, 33)}${ESC}[M${String.fromCharCode(97, 126, 126)}`
)
).toBe(true)
})
it('accepts bounded default left-click press and release sequences', () => {
expect(
isTerminalGestureInput(
`${ESC}[M${String.fromCharCode(32, 33, 33)}${ESC}[M${String.fromCharCode(35, 33, 33)}`
)
).toBe(true)
})
it('rejects shell text and other terminal input', () => {
expect(isTerminalGestureInput('rm -rf .\r')).toBe(false)
expect(isTerminalGestureInput(`${ESC}[200~paste${ESC}[201~`)).toBe(false)
@@ -96,7 +96,6 @@ describe('mobile mouse encoding proof', () => {
})
it.each([
['SGR', '?1006h', '[<65;'],
['pixel', '?1016h', '[<65;'],
['explicit legacy', '?1006l', '[Ma']
])('keeps %s encoding proven by a live write', (_name, mode, prefix) => {

Some files were not shown because too many files have changed in this diff Show More