mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 10:27:33 +00:00
Merge main command contract refactors for repair validation
This commit is contained in:
@@ -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=@{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
-2
@@ -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 () => ({
|
||||
|
||||
+4
-21
@@ -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)
|
||||
}
|
||||
})
|
||||
-37
@@ -121,7 +121,6 @@ describe('how the structured lane draws its rows, as desktop does', () => {
|
||||
it.each([
|
||||
[['# '], true],
|
||||
[[''], false],
|
||||
[[' '], 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)
|
||||
})
|
||||
})
|
||||
|
||||
-13
@@ -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({
|
||||
|
||||
-40
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user