Merge origin/main Ubuntu package mirror fix

This commit is contained in:
Brennan Benson
2026-11-11 08:36:02 -08:00
46 changed files with 121 additions and 4268 deletions
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
if [ "$#" -eq 0 ]; then
echo 'Usage: install-ubuntu-apt-packages.sh <package>...' >&2
exit 2
fi
bash "$(dirname "${BASH_SOURCE[0]}")/prepare-ubuntu-apt.sh"
# Bound both routes while allowing large fallback downloads.
# Run deadlines as root so sudo cannot leave apt in a separate session.
if sudo timeout --kill-after=10s 45 apt-get update -o Acquire::Languages=none && sudo timeout --kill-after=10s 120 apt-get install -y "$@"; then
exit 0
fi
echo 'Ubuntu package setup failed; trying the same packages through the MIT mirror.' >&2
for host in archive.ubuntu.com ports.ubuntu.com mirrors.mit.edu; do
timeout 5 getent ahosts "$host" || true
done
bash "$(dirname "${BASH_SOURCE[0]}")/use-ubuntu-apt-mirror.sh"
bash "$(dirname "${BASH_SOURCE[0]}")/recover-ubuntu-dpkg.sh" || true
# Unrelated repositories may fail refresh; apt still verifies the requested packages.
if ! sudo timeout --kill-after=10s 120 apt-get update -o Acquire::Languages=none; then
echo 'Index refresh incomplete; attempting the requested packages with available authenticated indexes.' >&2
fi
sudo timeout --kill-after=10s 600 apt-get install -y "$@"
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
# A timed-out apt frontend can leave dpkg running in a separate session.
sudo timeout --kill-after=10s 30 bash -euo pipefail -c '
while lslocks --noheadings --raw --notruncate --output PATH | grep -E "^/var/lib/dpkg/lock(-frontend)?$" >/dev/null; do
sleep 1
done
dpkg --configure -a
'
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
# Change repository locations while retaining suites, architectures and signing keys.
for source in /etc/apt/apt-mirrors.txt /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do
if [ -f "$source" ]; then
sudo sed -i -E \
-e 's#https?://(azure\.archive|archive|security)\.ubuntu\.com/ubuntu#https://mirrors.mit.edu/ubuntu#g' \
-e 's#https?://ports\.ubuntu\.com/ubuntu-ports#https://mirrors.mit.edu/ubuntu-ports#g' \
"$source"
fi
done
+11 -2
View File
@@ -123,6 +123,10 @@ jobs:
exit 0
fi
read -r -a packages <<< "$ORCA_E2E_APT_PACKAGES"
if [ -f .github/scripts/install-ubuntu-apt-packages.sh ]; then
bash .github/scripts/install-ubuntu-apt-packages.sh "${packages[@]}"
exit 0
fi
if [ -f .github/scripts/prepare-ubuntu-apt.sh ]; then
bash .github/scripts/prepare-ubuntu-apt.sh
else
@@ -529,7 +533,12 @@ jobs:
ref: ${{ inputs.ref || github.ref }}
persist-credentials: false
- name: Install headless tools
run: sudo apt-get update && sudo apt-get install -y build-essential ripgrep xvfb openbox x11-utils
run: &install_headless_tools |
if [ -f .github/scripts/install-ubuntu-apt-packages.sh ]; then
bash .github/scripts/install-ubuntu-apt-packages.sh build-essential ripgrep xvfb openbox x11-utils
else
sudo apt-get update && sudo apt-get install -y build-essential ripgrep xvfb openbox x11-utils
fi
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: electron
@@ -636,7 +645,7 @@ jobs:
- name: Fetch the 1.4.224 relay sources
run: git fetch --no-tags --depth=1 origin +refs/tags/v1.4.224:refs/tags/v1.4.224
- name: Install headless tools
run: sudo apt-get update && sudo apt-get install -y build-essential ripgrep xvfb openbox x11-utils
run: *install_headless_tools
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: electron
@@ -70,7 +70,6 @@ jobs:
- name: Run golden E2E tests on Linux
if: runner.os == 'Linux'
run: |
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e tests/e2e/golden-core-flows.spec.ts
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:workspace-session-golden
if [ -f tests/e2e/golden-fresh-profile-terminal.spec.ts ]; then
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts
@@ -84,7 +83,6 @@ jobs:
- name: Run golden E2E tests on macOS
if: runner.os == 'macOS'
run: |
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e tests/e2e/golden-core-flows.spec.ts
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:workspace-session-golden
if [ -f tests/e2e/golden-fresh-profile-terminal.spec.ts ]; then
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts
+13 -10
View File
@@ -790,15 +790,7 @@ jobs:
id: shells
background: true
run: |
# Per-connection limits complement the whole-command apt deadlines below.
sudo tee /etc/apt/apt.conf.d/99-orca-shell-contracts >/dev/null <<'APTCONF'
Acquire::http::Timeout "15";
Acquire::https::Timeout "15";
Acquire::Retries "1";
APTCONF
# Unrelated runner repositories may fail refresh; installing zsh must still succeed.
timeout 120 sudo apt-get update || true
timeout 300 sudo apt-get install -y zsh
bash .github/scripts/install-ubuntu-apt-packages.sh zsh
# A fixed 4.x release meets the contract without tracking package-repository updates.
case "$(uname -m)" in
aarch64) arch=aarch64 sha=57349f0111728c64b703a84e611aa97ff4d1fa8466385d64e67bd5bc3ffa49fc ;;
@@ -936,7 +928,18 @@ jobs:
background: true
run: |
bash .github/scripts/prepare-ubuntu-apt.sh
pnpm exec playwright-core install --with-deps webkit
if [ ! -f .github/scripts/use-ubuntu-apt-mirror.sh ]; then
pnpm exec playwright-core install --with-deps webkit
exit 0
fi
echo 'Installing WebKit dependencies through the primary route.'
if ! sudo env "PATH=$PATH" timeout --kill-after=10s 240 ./node_modules/.bin/playwright-core install-deps webkit; then
echo 'Dependency installation did not complete; retrying with the Ubuntu mirror fallback.'
bash .github/scripts/use-ubuntu-apt-mirror.sh
bash .github/scripts/recover-ubuntu-dpkg.sh || true
sudo env "PATH=$PATH" timeout --kill-after=10s 600 ./node_modules/.bin/playwright-core install-deps webkit
fi
timeout --kill-after=10s 180 pnpm exec playwright-core install webkit
# The entry lives in mobile/ so one React resolves; without this every RN import is nothing.
- uses: ./.github/actions/install-mobile-dependencies
+3 -6
View File
@@ -62,7 +62,6 @@ on:
- 'src/shared/shell/launch/shell-argument-quoting.ts'
- '!src/**/*.test.ts'
- 'src/main/managed-server/host-state/orcad-windows-host-lane.test.ts'
- 'tests/e2e/ssh-orcad-auto-convert.spec.ts'
- 'tests/e2e/helpers/orcad-convert-host.ts'
- 'tests/e2e/helpers/orcad-convert-flow.ts'
- 'tests/e2e/helpers/orcad-upgrade-profile.ts'
@@ -78,7 +77,7 @@ on:
workflow_dispatch:
inputs:
cells:
description: Comma-separated cell ids from src/main/ssh/hostile-host-test-support/ssh-windows-host-cells.ts; empty runs the default set (orcad-cli-* cells run only when named).
description: Comma-separated cells orcad-cmd, orcad-powershell, or orcad-cli-managed; empty runs the two shell cells.
required: false
default: ''
preparation_mode:
@@ -144,7 +143,7 @@ jobs:
with:
persist-credentials: false
# Keep complete server/test trees; missing future imports fail the unchanged builds.
# All of src: the orcad-convert cell builds the full e2e app and the bundled CLI.
# All of src: the managed CLI cell builds the full e2e app and bundled CLI.
# mobile: the template ships the mobile web page, which builds from mobile/.
# cloud: config/tsconfig.vitest-transform.json references the cloud projects, so vitest needs them.
sparse-checkout: |
@@ -242,10 +241,8 @@ jobs:
New-Item -ItemType Directory -Force -Path $receipts | Out-Null
$cells=@($env:CELLS -split ',' | ForEach-Object {$_.Trim()} | Where-Object {$_})
if(-not $cells.Count){$cells=@('orcad-cmd','orcad-powershell')}
# One Windows host runs the app-level conversion; it is last because it switches native modules.
if(-not $env:CELLS -and '${{ matrix.arch }}' -eq 'x64' -and '${{ matrix.server }}' -eq 'inbox'){$cells+='orcad-convert'}
# App cells reach the managed server through an SSH local forward, which provisioning grants to the last accounts only.
$appCellIds=@('orcad-convert','orcad-cli-managed','orcad-cli-convert')
$appCellIds=@('orcad-cli-managed')
$cells=@($cells | Where-Object {$appCellIds -notcontains $_})+@($cells | Where-Object {$appCellIds -contains $_})
$forwarding=@($cells | Where-Object {$appCellIds -contains $_}).Count
$archive=''
@@ -4,13 +4,13 @@ param(
[Parameter(Mandatory=$true)][hashtable]$Context,
[Parameter(Mandatory=$true)][ValidateSet('win32-arm64','win32-x64')][string]$Target,
[Parameter(Mandatory=$true)][string]$ReceiptRoot,
[ValidateSet('orcad-cmd','orcad-powershell','orcad-convert','orcad-cli-managed','orcad-cli-convert')][string[]]$Cells=@('orcad-cmd','orcad-powershell')
[ValidateSet('orcad-cmd','orcad-powershell','orcad-cli-managed')][string[]]$Cells=@('orcad-cmd','orcad-powershell')
)
$ErrorActionPreference='Stop'
if($env:GITHUB_ACTIONS -ne 'true' -or $env:ORCA_ISOLATED_SSH_CI -ne '1'){throw 'Disposable CI only'}
$shells=@{'orcad-cmd'='cmd';'orcad-powershell'='powershell';'orcad-convert'='cmd';'orcad-cli-managed'='cmd';'orcad-cli-convert'='cmd'}
$shells=@{'orcad-cmd'='cmd';'orcad-powershell'='powershell';'orcad-cli-managed'='cmd'}
# App-level cells drive the e2e build (and the bundled CLI) against the host; each greps one tagged test.
$appCells=@{'orcad-convert'=@('tests/e2e/ssh-orcad-auto-convert.spec.ts','');'orcad-cli-managed'=@('tests/e2e/ssh-orcad-windows-cli-matrix.spec.ts','@orcad-cli-managed');'orcad-cli-convert'=@('tests/e2e/ssh-orcad-windows-cli-matrix.spec.ts','@orcad-cli-convert')}
$appCells=@{'orcad-cli-managed'=@('tests/e2e/ssh-orcad-windows-cli-matrix.spec.ts','@orcad-cli-managed')}
$electronBuilt=$false
if($Context.accounts.Count -lt $Cells.Count){throw 'Each cell needs its own private account'}
$seenApp=$false
+1 -4
View File
@@ -92,7 +92,6 @@
1 test-no-polling-loop:tests/e2e/fixtures/daemon-generation-reconnect-client.ts
2 test-no-polling-loop:tests/e2e/floating-workspace-reopen-webgl-recovery.spec.ts
1 test-no-polling-loop:tests/e2e/floating-workspace-shared-glyph-atlas.spec.ts
1 test-no-polling-loop:tests/e2e/golden-core-flows.spec.ts
1 test-no-polling-loop:tests/e2e/helpers/computer-cli-driver.ts
2 test-no-polling-loop:tests/e2e/helpers/computer-driver.ts
1 test-no-polling-loop:tests/e2e/helpers/daemon-generation-processes.ts
@@ -302,7 +301,6 @@
4 test-no-real-time-wait:tests/e2e/floating-workspace-reopen-webgl-recovery.spec.ts
4 test-no-real-time-wait:tests/e2e/floating-workspace-shared-glyph-atlas.spec.ts
2 test-no-real-time-wait:tests/e2e/github-project-board.spec.ts
1 test-no-real-time-wait:tests/e2e/golden-core-flows.spec.ts
1 test-no-real-time-wait:tests/e2e/headless-paired-remote-terminal-reveal-grid.spec.ts
1 test-no-real-time-wait:tests/e2e/headless-paired-remote-terminal-viewer-size.spec.ts
2 test-no-real-time-wait:tests/e2e/helpers/computer-cli-driver.ts
@@ -895,7 +893,6 @@
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
5 test-timeout-needs-reason:tests/e2e/ssh-orcad-auto-convert.spec.ts
1 test-timeout-needs-reason:tests/e2e/ssh-orcad-browser-drop-owner.spec.ts
1 test-timeout-needs-reason:tests/e2e/ssh-orcad-browser-routing.spec.ts
1 test-timeout-needs-reason:tests/e2e/ssh-orcad-browser-service-status.spec.ts
@@ -925,7 +922,7 @@
1 test-timeout-needs-reason:tests/e2e/ssh-orcad-terminal-root-owner.spec.ts
1 test-timeout-needs-reason:tests/e2e/ssh-orcad-unbindable-tunnel-port.spec.ts
1 test-timeout-needs-reason:tests/e2e/ssh-orcad-update-anyway.spec.ts
2 test-timeout-needs-reason:tests/e2e/ssh-orcad-windows-cli-matrix.spec.ts
1 test-timeout-needs-reason:tests/e2e/ssh-orcad-windows-cli-matrix.spec.ts
4 test-timeout-needs-reason:tests/e2e/ssh-orcad-workspace-tools.spec.ts
1 test-timeout-needs-reason:tests/e2e/staging-skill-sharing.spec.ts
1 test-timeout-needs-reason:tests/e2e/structured-chat-tabs-open-close.spec.ts
File diff suppressed because one or more lines are too long
@@ -18,7 +18,6 @@
"config/scripts/static-analysis/oxlint-contracts/app-store-performance-plugin.test.mjs": 339,
"config/scripts/desktop-distribution/linux/appimage-node-runtime.test.mjs": 146,
"config/scripts/performance/measurement/benchmark-artifact-comparison.test.mjs": 192,
"config/scripts/performance/measurement/benchmark-sample-summary.test.mjs": 46,
"config/scripts/cli-build/build-cli.test.mjs": 1371,
"config/scripts/desktop-distribution/linux/build-linux-local.test.mjs": 67,
"config/scripts/desktop-distribution/macos/build-mac-notarization-overlap.test.mjs": 127,
@@ -100,9 +99,7 @@
"config/scripts/locale-catalog/locale-brand-prefilter.test.mjs": 246,
"config/scripts/locale-catalog/locale-generic-ui-terms.test.mjs": 102,
"config/scripts/locale-catalog/locale-repair-catalog-missing-leaves.test.mjs": 159,
"config/scripts/locale-catalog/locale-translation-policy-ko-round5.test.mjs": 154,
"config/scripts/locale-catalog/locale-translation-policy.es-pr.test.mjs": 91,
"config/scripts/locale-catalog/locale-translation-policy.ja-relocalization.test.mjs": 116,
"config/scripts/locale-catalog/locale-translation-policy.test.mjs": 189,
"config/scripts/localization/source-extraction/localization-extraction-call-syntax.test.mjs": 317,
"config/scripts/localization/extraction-policy/localization-extraction-change-scope.test.mjs": 732,
@@ -115,7 +112,6 @@
"config/scripts/orcad-distribution/prebuilds/merge-orcad-prebuilds.test.mjs": 67,
"config/scripts/mobile-web/check-entry/mobile-process-host-tooling.test.mjs": 942,
"config/scripts/mobile-web/check-entry/mobile-release-check-scope.test.mjs": 821,
"config/scripts/mobile-web-app/session/mobile-web-app-agent-history-render.test.mjs": 524,
"config/scripts/mobile-web-app/shell/mobile-web-app-agent-icon-render.test.mjs": 591,
"config/scripts/mobile-web-app/shell/mobile-web-app-browser-pane-render.test.mjs": 239,
"config/scripts/mobile-web-app/shell/mobile-web-app-clipboard-image-resize-render.test.mjs": 422,
@@ -133,7 +129,6 @@
"config/scripts/mobile-web-app/routing/mobile-web-app-page-closure-families.test.mjs": 71,
"config/scripts/mobile-web-app/routing/mobile-web-app-page-grant-call-sites.test.mjs": 164,
"config/scripts/mobile-web-app/routing/mobile-web-app-push-subsystem-closure.test.mjs": 57,
"config/scripts/mobile-web-app/session/mobile-web-app-queued-messages-render.test.mjs": 267,
"config/scripts/mobile-web-app/shell/mobile-web-app-render.test.mjs": 254,
"config/scripts/mobile-web-app/session/mobile-web-app-rich-markdown-render.test.mjs": 356,
"config/scripts/mobile-web-app/mobile-web-app-route-manifest.test.mjs": 174,
@@ -460,7 +455,6 @@
"src/main/acp/process/acp-agent-connection.test.ts": 407,
"src/main/acp/discovery/acp-at-rest-commands.test.ts": 1400,
"src/main/acp/transport/acp-json-rpc-peer.test.ts": 195,
"src/main/acp/discovery/acp-model-catalog-probe.test.ts": 774,
"src/main/acp/protocol/acp-permission-requests.test.ts": 254,
"src/main/acp/turns/acp-prompt-content.test.ts": 302,
"src/main/acp/protocol/acp-session-agent-turns.test.ts": 260,
@@ -4543,8 +4537,6 @@
"src/renderer/src/components/automations/hosts/use-automation-host-catalog-readoption.test.tsx": 802,
"src/renderer/src/components/automations/hosts/use-automation-host-catalog.test.tsx": 920,
"src/renderer/src/components/automations/listing/use-automation-list-search.test.tsx": 679,
"src/renderer/src/components/automations/runs/use-automation-runs-dashboard-payload.test.tsx": 6692,
"src/renderer/src/components/automations/runs/use-automation-runs-dashboard.test.tsx": 854,
"src/renderer/src/components/automations/use-selected-automation-run-history.test.tsx": 2104,
"src/renderer/src/components/browser-favicon.test.tsx": 333,
"src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.drag-focus.test.tsx": 5826,
@@ -7361,7 +7353,6 @@
"tests/e2e/helpers/electron-process-shutdown.unit.test.ts": 6545,
"tests/e2e/helpers/golden-source-control.unit.test.ts": 804,
"tests/e2e/helpers/headless-paired-runtime-serve-readiness.unit.test.ts": 53,
"tests/e2e/helpers/orcad-convert-teardown.unit.test.ts": 76,
"tests/e2e/helpers/paired-client-runtime-environment.unit.test.ts": 67,
"tests/e2e/helpers/paired-client-window-reveal.unit.test.ts": 57,
"tests/e2e/helpers/persisted-profile-state.unit.test.ts": 402,
@@ -7567,7 +7558,6 @@
"tests/e2e/github-cli-stall-repro.spec.ts": 18200,
"tests/e2e/github-url-smart-input-transition.spec.ts": 58200,
"tests/e2e/golden-agent-tui-launch.spec.ts": 27300,
"tests/e2e/golden-core-flows.spec.ts": 91100,
"tests/e2e/golden-file-open-edit-save.spec.ts": 25000,
"tests/e2e/golden-fresh-profile-terminal.spec.ts": 24400,
"tests/e2e/golden-posix-fresh-startup.spec.ts": 4900,
@@ -8,8 +8,6 @@ import { writeMobileWebBundleFixtureTree } from '../../build/mobile-web-app/mobi
const require = createRequire(import.meta.url)
const { assertMobileWebBundleBuilt } = require('./verify-packaged-mobile-web-bundle.cjs')
const electronBuilderConfig = require('../../../electron-builder.config.cjs')
const { darkBuildMarkers } = require('../../../dark-build-switches.cjs')
async function withBundle(run, { brotli = false } = {}) {
const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-guard-'))
@@ -37,16 +35,6 @@ describe('assertMobileWebBundleBuilt', () => {
vi.restoreAllMocks()
})
it('fails on a file the manifest does not list, so no stale asset ships inside asar', async () => {
await withBundle(async ({ bundleDir }) => {
// Dropped content-addressed assets never overwrite themselves; reject leftovers rather than pack unverified unreachable bytes.
await writeFile(join(bundleDir, 'assets', 'stale.js'), '// from an earlier build\n', 'utf8')
expect(() => assertMobileWebBundleBuilt(bundleDir)).toThrow(
/does not list: assets\/stale\.js/
)
})
})
it('fails when an asset is tampered with on disk', async () => {
await withBundle(async ({ bundleDir, manifest }) => {
const asset = manifest.assets.find((entry) => entry.path.endsWith('.js'))
@@ -58,14 +46,6 @@ describe('assertMobileWebBundleBuilt', () => {
})
})
it('fails when a listed asset was never written', async () => {
await withBundle(async ({ bundleDir, manifest }) => {
const asset = manifest.assets.find((entry) => entry.path.endsWith('.png'))
await rm(join(bundleDir, asset.path))
expect(() => assertMobileWebBundleBuilt(bundleDir)).toThrow(/which is missing from/)
})
})
it('accepts the brotli copies the manifest lists, and fails one that inflates to other bytes', async () => {
await withBundle(
async ({ bundleDir, manifest }) => {
@@ -84,29 +64,4 @@ describe('assertMobileWebBundleBuilt', () => {
{ brotli: true }
)
})
it('refuses an asset path that escapes the bundle directory', async () => {
await withBundle(async ({ bundleDir }) => {
await rewriteManifest(bundleDir, (manifest) => {
manifest.assets[0].path = '../outside.js'
})
expect(() => assertMobileWebBundleBuilt(bundleDir)).toThrow(/not a safe relative path/)
})
})
})
describe('electron-builder packaging wiring', () => {
it('verifies a dark page build, and refuses to package it', async () => {
await withBundle(async ({ bundleDir }) => {
const [marker] = darkBuildMarkers({ ORCA_SIDEBAR_INPUTS: 'main' })
await writeFile(join(bundleDir, marker.fileName), marker.text, 'utf8')
expect(() => assertMobileWebBundleBuilt(bundleDir)).not.toThrow()
expect(() =>
electronBuilderConfig.beforePack(
{ electronPlatformName: process.platform, arch: process.arch === 'arm64' ? 3 : 1 },
bundleDir
)
).toThrow(`${bundleDir} holds an ORCA_SIDEBAR_INPUTS=main build`)
})
})
})
@@ -1,54 +0,0 @@
import { describe, expect, it } from 'vitest'
import { repairTranslatedValue } from './locale-translation-policy.mjs'
describe('locale-translation-policy ko round 5', () => {
it('re-glues Korean particles after Latin terms without gluing content words', () => {
// A wrong space before the 에 particle is re-glued onto the Latin term.
expect(
repairTranslatedValue({
key: 'auto.components.StarNagCard.cf82170065',
enValue: 'Could not star the repo. Make sure',
localeValue: 'repo 에 스타를 표시할 수 없습니다. 다음을 확인하세요',
locale: 'ko'
})
).toBe('repo에 스타를 표시할 수 없습니다. 다음을 확인하세요')
expect(
repairTranslatedValue({
key: 'auto.components.github.project.ProjectCell.4b5b871da8',
enValue: 'No labels in this repo.',
localeValue: '이 repo 에는 라벨이 없습니다.',
locale: 'ko'
})
).toBe('이 repo에는 라벨이 없습니다.')
expect(
repairTranslatedValue({
key: 'auto.components.terminal.pane.CloseTerminalDialog.6b9a6975f8',
enValue:
'The terminal still has a running process. If you close the terminal, the process will be killed.',
localeValue:
'terminal 에는 여전히 실행 중인 프로세스가 있습니다. terminal을 닫으면 프로세스가 종료됩니다.',
locale: 'ko'
})
).toBe(
'terminal에는 여전히 실행 중인 프로세스가 있습니다. terminal을 닫으면 프로세스가 종료됩니다.'
)
// A correct space before a content word that merely starts with a particle syllable is kept.
expect(
repairTranslatedValue({
key: 'auto.components.JiraIssueWorkspace.76513c7898',
enValue: 'Close Jira issue preview',
localeValue: 'Jira 이슈 미리보기 닫기',
locale: 'ko'
})
).toBe('Jira 이슈 미리보기 닫기')
expect(
repairTranslatedValue({
key: 'auto.components.GitHubItemDialog.3ab6ac0fc8',
enValue: 'Preview and edit the selected GitHub issue or pull request.',
localeValue: '선택한 GitHub 이슈 또는 PR을 미리보기하고 편집합니다.',
locale: 'ko'
})
).toBe('선택한 GitHub 이슈 또는 PR을 미리보기하고 편집합니다.')
})
})
@@ -1,76 +0,0 @@
import { describe, expect, it } from 'vitest'
import { repairTranslatedValue, shouldPreserveEnglishValue } from './locale-translation-policy.mjs'
const ja = (enValue, localeValue, key = 'auto.test.relocalization') =>
repairTranslatedValue({ key, enValue, localeValue, locale: 'ja' })
describe('locale-translation-policy ja relocalization', () => {
it('leaves the second word of a two-word command in Latin', () => {
expect(ja('a commit hook or git commit fails', 'コミットフックまたは git commit が失敗')).toBe(
'コミットフックまたは git commit が失敗'
)
expect(ja('Run orca terminal', 'orca terminal を実行')).toBe('orca terminal を実行')
expect(ja('Install with pnpm install', 'pnpm install でインストール')).toBe(
'pnpm install でインストール'
)
})
it('leaves identifiers, flags, filenames, and Latin-only labels alone', () => {
expect(ja('Pass --agent to select one.', '--agent を渡して選択します。')).toBe(
'--agent を渡して選択します。'
)
expect(ja('Edit agents.md', 'agents.md を編集')).toBe('agents.md を編集')
expect(ja('Agent SDK settings', 'Agent SDK の設定')).toBe('Agent SDK の設定')
expect(ja('Agent SDK settings', 'この Agent SDK の設定')).toBe('この Agent SDK の設定')
expect(ja('Run git commit -m', 'git commit -m を実行')).toBe('git commit -m を実行')
})
it('repairs translated code examples back to executable values', () => {
expect(ja('--model sonnet', '--モデルソネット')).toBe('--model sonnet')
expect(
ja("gh pr list --json number -q '.[0].number'", "gh pr list --json 番号 -q '.[0].number'")
).toBe("gh pr list --json number -q '.[0].number'")
expect(ja('{basePrompt}', '{ベースプロンプト}')).toBe('{basePrompt}')
expect(ja('/pricing', '/価格設定')).toBe('/pricing')
})
it('keeps style blocks in English because a translated selector breaks the view', () => {
expect(shouldPreserveEnglishValue('.pp-card[data-card="starter"] .pp-cta')).toBe(true)
expect(shouldPreserveEnglishValue('@keyframes browser-flash { to { opacity: 1; } }')).toBe(true)
expect(shouldPreserveEnglishValue('.card { background: var(--card); }')).toBe(true)
// Why: the declaration requirement is what keeps prose containing braces translatable.
expect(shouldPreserveEnglishValue('e.g. {{value0}}')).toBe(false)
})
it('leaves ranges and token samples out of the ellipsis rule', () => {
expect(ja('Compare main...HEAD', 'main...HEAD を比較')).toBe('main...HEAD を比較')
})
it('preserves a selector that has no declaration block', () => {
for (const value of [
'div.pricing-grid > div.card.starter:nth-of-type(1) > a.cta',
'.pp-cta',
'#dialog',
'.pp-card[data-card="starter"] .pp-cta',
'button.primary',
'a:hover',
'input[type="text"]',
'localhost:3000'
]) {
expect(shouldPreserveEnglishValue(value)).toBe(true)
}
// Why: two ordinary sentences carry two periods, which must not read as selector joins.
for (const value of [
'Open Settings > Git and try again.',
'Show live workspace ports. Click it for workspace-scoped ports and external listeners.',
'orca.yaml + local',
'.gitignore',
'Status:',
'Done.',
'#{{value0}}'
]) {
expect(shouldPreserveEnglishValue(value)).toBe(false)
}
})
})
@@ -124,30 +124,9 @@ async function framePixel(page) {
})
}
const sourceText = (page) => page.getByText(MARKER, { exact: false })
for (const engine of ['chromium', 'webkit']) {
const describeRender = bundles ? describe : describe.skip
describeRender(`an HTML file on the file-preview route on ${engine}`, () => {
it('paints the page, and Source shows its markup', async () => {
const { context, page, errors } = await openHtmlFile(browsers[engine])
const frame = page.locator('iframe')
await frame.waitFor({ timeout: 60_000 })
await expect.poll(() => framePixel(page), { timeout: 10_000 }).toBe(PAINT_RGB)
if (shots) {
await page.screenshot({ path: join(shots, `html-file-preview-${engine}.png`) })
}
await page.getByRole('tab', { name: 'View HTML source' }).click()
await sourceText(page).waitFor()
expect(await page.locator('iframe').count()).toBe(0)
if (shots) {
await page.screenshot({ path: join(shots, `html-file-source-${engine}.png`) })
}
expect(errors).toEqual([])
await context.close()
}, 90_000)
it('renders unsaved HTML edits and retains them when returning to Source', async () => {
const { context, page, errors } = await openHtmlFile(browsers[engine], { editable: true })
await page.locator('iframe').waitFor({ timeout: 60_000 })
@@ -165,36 +144,5 @@ for (const engine of ['chromium', 'webkit']) {
expect(errors).toEqual([])
await context.close()
}, 90_000)
it('opens a line-targeted file on Source, and a truncated one as text only', async () => {
const targeted = await openHtmlFile(browsers[engine], { line: '3' })
await targeted.page
.getByRole('tab', { name: 'View HTML source' })
.waitFor({ timeout: 60_000 })
await sourceText(targeted.page).waitFor()
expect(
await targeted.page
.getByRole('tab', { name: 'View HTML source' })
.getAttribute('aria-selected')
).toBe('true')
expect(await targeted.page.locator('iframe').count()).toBe(0)
if (shots) {
await targeted.page.screenshot({ path: join(shots, `html-file-line-${engine}.png`) })
}
await targeted.page.getByRole('tab', { name: 'Preview rendered HTML' }).click()
await expect.poll(() => framePixel(targeted.page), { timeout: 10_000 }).toBe(PAINT_RGB)
expect(targeted.errors).toEqual([])
await targeted.context.close()
const truncated = await openHtmlFile(browsers[engine], { truncated: true })
await sourceText(truncated.page).waitFor({ timeout: 60_000 })
expect(await truncated.page.locator('iframe').count()).toBe(0)
expect(await truncated.page.getByRole('tab').count()).toBe(0)
if (shots) {
await truncated.page.screenshot({ path: join(shots, `html-file-truncated-${engine}.png`) })
}
expect(truncated.errors).toEqual([])
await truncated.context.close()
}, 120_000)
})
}
@@ -1,198 +0,0 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { chromium } from 'playwright-core'
import { buildMobileWebAppBundle } from '../../build/mobile-web-app/build-mobile-web-app-bundle.mjs'
import { mobileWebAppDependenciesPresent } from '../mobile-web-app-bundle-dependencies.mjs'
import {
createBundleServer,
waitForPageFrames,
installShellDouble,
readBridgeFaultGrant,
readBridgePagePainted,
readBridgeProtocolVersion,
readShellCsp
} from '../preview/mobile-web-app-render-harness.mjs'
// Browser-only history checks prove real RN-Web closure evaluation, corpus-backed rows, shell Back handoff and deferred chunk delivery.
// Domain file uses shared C1.10 harness to avoid line-cap conflicts between concurrent series.
const HOST_ROUTE = '/h/render-check-host'
/** The pattern `init.pageRoutes` names, which is what the page matches a navigation against. */
const HOST_ROUTE_PATTERN = '/h/[hostId]'
const SHELL_SESSION_ID = 'render-check-session'
const SHELL_BUILD_ID = 'render-check-build'
const SHELL_HOST = {
id: 'render-check-host',
name: 'Render Check Host',
endpoint: 'ws://render-check',
lastConnected: 1
}
const bundles = mobileWebAppDependenciesPresent()
const describeRender = bundles ? describe : describe.skip
let scratch
let server
let browser
let origin
let cspHeader = null
let bridgeVersion = null
let faultGrant = null
let paintName = null
beforeAll(async () => {
if (!bundles) {
return
}
cspHeader = await readShellCsp()
bridgeVersion = await readBridgeProtocolVersion()
faultGrant = await readBridgeFaultGrant()
paintName = await readBridgePagePainted()
scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-app-agent-history-'))
const built = await buildMobileWebAppBundle({ outDir: join(scratch, 'bundle') })
const served = await createBundleServer({ outDir: built.outDir, cspHeader })
server = served.server
origin = served.origin
const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER
browser = await chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}) })
}, 180_000)
afterAll(async () => {
await browser?.close()
server?.close()
if (scratch) {
await rm(scratch, { recursive: true, force: true })
}
})
/** A page carrying every signal these cases read: uncaught errors, console errors, script paths. */
async function openPage({ shellRoute, shellGrants, shellPageRoutes = null, replies } = {}) {
const page = await browser.newPage({ viewport: { width: 390, height: 844 } })
// Install channel at document start; entry reads it during evaluation, before load.
await page.addInitScript(installShellDouble, {
version: bridgeVersion,
sessionId: SHELL_SESSION_ID,
buildId: SHELL_BUILD_ID,
route: shellRoute,
host: SHELL_HOST,
storage: {},
faultGrant,
// The harness falls back to the fault grant alone, which is the ungranted page.
grants: shellGrants ?? [faultGrant],
pageRoutes: shellPageRoutes,
replies
})
const errors = []
const scripts = []
let reportUncaught = () => {}
const uncaught = new Promise((resolve) => {
reportUncaught = resolve
})
page.on('pageerror', (error) => {
errors.push(`${error.name}: ${error.message}`)
reportUncaught(error)
})
page.on('console', (message) => {
if (message.type() === 'error') {
errors.push(`console.error: ${message.text()}`)
}
})
page.on('response', (response) => {
const path = new URL(response.url()).pathname
if (response.status() === 200 && path.endsWith('.js')) {
scripts.push(path)
}
})
return { page, errors, scripts, uncaught }
}
// Wait beyond entry mount for route content; deferred import chunk may still be arriving.
async function waitForRoute({ page, errors, uncaught }, route, awaitText) {
const named = (cause, what) =>
new Error(`${route} ${what}: ${errors.join(' | ') || 'no page or console error'}`, { cause })
const race = async (wait) =>
Promise.race([
wait.then(
() => null,
(error) => error
),
uncaught
])
const cause = await race(
page.waitForFunction(() => document.documentElement.dataset.orcaWebEntry === 'mounted', {
timeout: 30_000,
polling: 250
})
)
if (cause) {
const state = await page.evaluate(
() => document.documentElement.dataset.orcaWebEntry ?? 'absent'
)
throw named(cause, `never mounted (entry ${state})`)
}
const paintCause = await race(
page.waitForFunction((needle) => document.body.innerText.includes(needle), awaitText, {
timeout: 30_000,
polling: 250
})
)
if (paintCause) {
throw named(paintCause, `mounted but never painted ${JSON.stringify(awaitText)}`)
}
for (const fault of await page.evaluate(() => globalThis.__orcaRenderCheckFaults ?? [])) {
errors.push(`page fault: ${fault}`)
}
}
/** Opens the document at `/`, the one path the shell serves, and lets the page route itself. */
async function openRoute(route, awaitText, options = {}) {
const opened = await openPage({ shellRoute: { pathname: route }, ...options })
await opened.page.goto(`${origin}/`, { waitUntil: 'load' })
await waitForRoute(opened, route, awaitText)
return opened
}
/** Every grant-gated notify the page posted, whole and in order, as the shell received them. The
* paint report and the location report are not: every page posts them for its route screen. */
async function readNotifies(page) {
const notifies = await page.evaluate(() => globalThis.__orcaRenderCheckNotifies ?? [])
return notifies.filter((frame) => frame.name !== paintName && frame.name !== 'location')
}
describeRender('the agent-history route in a real browser', () => {
// Prove real RN-Web import/evaluation absent from mocked native units; no-RPC case paints failure, next corpus case paints a row.
// Resume/scope-tab interactions remain native C5.3 device coverage.
it('hands its Back button to the shell, which no unit test can prove in a browser', async () => {
const route = `${HOST_ROUTE}/agent-history/wt-1`
const opened = await openRoute(route, 'Agent Session History', {
shellGrants: [faultGrant, 'navigate'],
shellPageRoutes: [HOST_ROUTE_PATTERN]
})
await opened.page.getByLabel('Back').click()
await expect
.poll(() => readNotifies(opened.page))
.toEqual([{ v: bridgeVersion, type: 'notify', name: 'navigate-back' }])
expect(await readNotifies(opened.page)).toEqual([
{ v: bridgeVersion, type: 'notify', name: 'navigate-back' }
])
// Document has only replaceState entry; page-local Back would do nothing and look like a successful shell handoff.
expect(await opened.page.evaluate(() => location.pathname)).toBe(route)
expect(opened.errors).toEqual([])
await opened.page.close()
}, 60_000)
it('posts nothing for Back when the shell granted no navigate, and stays put', async () => {
// Withhold grant while preserving tap/handler/seam to prove handoff depends on permission.
const route = `${HOST_ROUTE}/agent-history/wt-1`
const opened = await openRoute(route, 'Agent Session History')
await opened.page.getByLabel('Back').click()
await waitForPageFrames(opened.page)
expect(await readNotifies(opened.page)).toEqual([])
expect(await opened.page.evaluate(() => location.pathname)).toBe(route)
expect(opened.errors).toEqual([])
await opened.page.close()
}, 60_000)
})
@@ -1,202 +0,0 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import * as esbuild from 'esbuild'
import { chromium, webkit } from 'playwright-core'
import {
MOBILE_WEB_APP_NATIVE_PARITY_STYLE,
MOBILE_WEB_APP_ROOT_RESET,
mobileWebAppBuildOptions
} from '../../build/mobile-web-app/build-mobile-web-app-bundle.mjs'
import { mobileWebAppDependenciesPresent } from '../mobile-web-app-bundle-dependencies.mjs'
import { createBundleServer, readShellCsp } from '../preview/mobile-web-app-render-harness.mjs'
import { SUPPORTED_UI_LOCALES } from '../../../../src/shared/ui-locale.ts'
const mobileDir = fileURLToPath(new URL('../../../../mobile', import.meta.url))
const engines = [
{
name: 'chromium',
launch: () => {
const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER
return chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}) })
}
},
{ name: 'webkit', launch: () => webkit.launch({ headless: true }) }
]
const entry = `
import { useEffect, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { View } from 'react-native'
import { GestureHandlerRootView } from 'react-native-gesture-handler'
import { MobileNativeChatQueuedMessages } from './src/session/native-chat/input/MobileNativeChatQueuedMessages'
import { MobileNativeChatComposer } from './src/session/native-chat/composer/MobileNativeChatComposer'
import { mobileQueuedMessageCards } from './src/session/native-chat/input/mobile-structured-queued-message-cards'
import { activateMobileUiLocale, translate } from './src/i18n/mobile-i18n'
const [mode, locale] = location.hash.slice(1).split(':')
const texts = ['First queued message', 'Review the mobile transcript and keep every detail. '.repeat(30)]
function Queue() {
const messages = texts.map((text, index) => ({
messageId: 'message-' + index,
position: index + 1,
state: 'waiting',
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] }
}))
return <View style={{ flex: 1, justifyContent: 'flex-end' }}>
<MobileNativeChatQueuedMessages
cards={mobileQueuedMessageCards(messages, [], { agentWorking: true })}
onMove={mode === 'drag' ? async () => true : undefined}
/>
</View>
}
function Editor() {
const [ready, setReady] = useState(false)
useEffect(() => {
void activateMobileUiLocale(locale).then(() => {
window.actionLabels = {
cancel: translate('chat.composer.cancelEditing', 'Cancel editing'),
save: translate('chat.composer.saveEditedMessage', 'Save edited message')
}
setReady(true)
})
}, [])
return ready ? <View style={{ flex: 1, justifyContent: 'flex-end', marginHorizontal: 16 }}>
<MobileNativeChatComposer
value="Edited message"
onChangeText={() => {}}
onSend={async () => true}
editSession={{ cancel: () => {}, refuseCommand: () => false, locked: false }}
sendSurfaceId="editor"
getSendCompletionGeneration={() => 0}
getComposerEditGeneration={() => 0}
onAttachImage={() => {}}
onMicPress={() => {}}
/>
</View> : null
}
createRoot(document.getElementById('root')).render(
<GestureHandlerRootView style={{ flex: 1 }}>{mode === 'editor' ? <Editor /> : <Queue />}</GestureHandlerRootView>
)
`
const bundles = mobileWebAppDependenciesPresent()
const describeLayout = bundles ? describe : describe.skip
const browsers = new Map()
let scratch
let server
let origin
beforeAll(async () => {
if (!bundles) {
return
}
await mkdir(join(mobileDir, '.tmp'), { recursive: true })
scratch = await mkdtemp(join(mobileDir, '.tmp', 'queued-messages-'))
const outDir = join(scratch, 'bundle')
await esbuild.build({
...mobileWebAppBuildOptions([]),
entryPoints: undefined,
stdin: { contents: entry, resolveDir: mobileDir, loader: 'tsx', sourcefile: 'queued.tsx' },
splitting: false,
write: true,
outdir: outDir,
entryNames: 'queued',
metafile: false
})
await writeFile(
join(outDir, 'index.html'),
`<!doctype html><html><head><meta charset="utf-8">${MOBILE_WEB_APP_ROOT_RESET}` +
`${MOBILE_WEB_APP_NATIVE_PARITY_STYLE}</head><body><div id="root"></div>` +
'<script type="module" src="/queued.js"></script></body></html>'
)
const served = await createBundleServer({ outDir, cspHeader: await readShellCsp() })
server = served.server
origin = served.origin
for (const engine of engines) {
browsers.set(engine.name, await engine.launch())
}
}, 180_000)
afterAll(async () => {
for (const browser of browsers.values()) {
await browser.close()
}
server?.close()
if (scratch) {
await rm(scratch, { recursive: true, force: true })
}
})
async function openPage(engine, hash, width) {
const page = await browsers.get(engine.name).newPage({ viewport: { width, height: 700 } })
await page.goto(`${origin}/#${hash}`, { waitUntil: 'load' })
return page
}
function editorActionProblems(page) {
return page.evaluate(() => {
const row = document.querySelector('[data-testid="native-chat-composer-actions"]')
const problems = []
const [cancel, save] = ['cancel', 'save'].map((name) => {
const button = row.querySelector(`[aria-label="${window.actionLabels[name]}"]`)
const label = button.querySelector('div[dir]')
const range = document.createRange()
range.selectNodeContents(label)
const text = range.getBoundingClientRect()
const box = button.getBoundingClientRect()
if (label.scrollWidth > label.clientWidth) {
problems.push(`${name} label truncated`)
}
if (text.left < box.left - 0.5 || text.right > box.right + 0.5) {
problems.push(`${name} label outside its button`)
}
return box
})
if (cancel.right > save.left + 0.5) {
problems.push('cancel overlaps save')
}
if (save.right > row.getBoundingClientRect().right + 0.5) {
problems.push('save past the row')
}
return problems
})
}
describeLayout.each(engines)('mobile queued messages in $name', (engine) => {
it.each(['plain', 'drag'])('scrolls only as far as the %s rows reach', async (mode) => {
const page = await openPage(engine, mode, 390)
try {
await page.locator('[data-testid="queued-card-row"]').nth(1).waitFor()
const range = () =>
page.evaluate(() => {
const box = document.querySelector('[data-testid="queued-box"]')
const rows = [...box.querySelectorAll('[data-testid="queued-card-row"]')]
const scroller = [...box.querySelectorAll('*')].find(
(node) => getComputedStyle(node).overflowY === 'auto'
)
const rowsHeight = rows.reduce((sum, row) => sum + row.getBoundingClientRect().height, 0)
return scroller.scrollHeight - rowsHeight
})
await expect.poll(range).toBeLessThanOrEqual(1)
} finally {
await page.close()
}
})
it('fits whole Save and Cancel labels at 360pt in every shipped locale', async () => {
const results = await Promise.all(
SUPPORTED_UI_LOCALES.map(async (locale) => {
const page = await openPage(engine, `editor:${locale}`, 360)
try {
await page.locator('[data-testid="native-chat-composer-actions"]').waitFor()
return { locale, problems: await editorActionProblems(page) }
} finally {
await page.close()
}
})
)
expect(results.filter(({ problems }) => problems.length > 0)).toEqual([])
}, 60_000)
})
@@ -17,7 +17,6 @@ import {
// Mount real ExpoRoot: only rendering proves synthesized RequireContext has expected shape.
const HOST_ROUTE = '/h/render-check-host'
const HOST_ROUTE_PATTERN = '/h/[hostId]'
const SHELL_SESSION_ID = 'render-check-session'
const SHELL_BUILD_ID = 'render-check-build'
@@ -191,30 +190,6 @@ async function waitForRoute({ page, errors, uncaught }, route, awaitText) {
}
}
// Open / as shell does, then route from init; direct route navigation would hide entry routing bugs.
async function render(route, awaitText, { shellRoute = { pathname: route }, ...shell } = {}) {
const opened = await openPage({ shellRoute, ...shell })
await opened.page.goto(`${origin}/`, { waitUntil: 'load' })
await waitForRoute(opened, route, awaitText)
const text = await opened.page.evaluate(() => document.body.innerText)
// Read page’s session identity to catch mounting without shell’s session or inventing one.
const session = await opened.page.evaluate(() => ({
sessionId: document.documentElement.dataset.orcaWebSessionId ?? null,
buildId: document.documentElement.dataset.orcaWebBuildId ?? null
}))
// Require rewrite from / before render or every host route resolves to Unmatched.
const url = await opened.page.evaluate(() => location.pathname + location.search)
await opened.page.close()
// Name CSP refusals within asserted console errors for useful diagnostics.
return {
errors: opened.errors,
cspErrors: opened.errors.filter((entry) => entry.includes('Content Security Policy')),
text,
session,
url
}
}
const paintReports = (page, name) =>
page.evaluate(
(paint) =>
@@ -223,58 +198,6 @@ const paintReports = (page, name) =>
)
describeRender('the Route A page in a real browser', () => {
it('fills the view, so what it mounted is painted and takes a tap', async () => {
const opened = await openPage({ shellRoute: { pathname: HOST_ROUTE } })
await opened.page.goto(`${origin}/`, { waitUntil: 'load' })
await waitForRoute(opened, HOST_ROUTE, SHELL_HOST.name)
const layout = await opened.page.evaluate(() => {
// No-RPC control at root bottom moves furthest if layout collapses.
const fab = [...document.querySelectorAll('[role="button"]')].find(
(element) => element.getAttribute('aria-label') === 'New workspace'
)
const box = fab?.getBoundingClientRect() ?? null
const hit =
box === null
? null
: document.elementFromPoint(box.x + box.width / 2, box.y + box.height / 2)
return {
rootHeight: document.getElementById('root').getBoundingClientRect().height,
viewportHeight: window.innerHeight,
fabTop: box?.top ?? null,
fabBottom: box?.bottom ?? null,
reachesTheControl: hit !== null && fab.contains(hit)
}
})
await opened.page.close()
expect(opened.errors).toEqual([])
// Measure height: mounted text/innerText can pass while collapsed root paints blank phone screen.
expect(layout.rootHeight).toBe(layout.viewportHeight)
expect(layout.fabTop).toBeGreaterThan(0)
expect(layout.fabBottom).toBeLessThanOrEqual(layout.viewportHeight)
// Require reachable tap: clipped scroller rows retain layout rects.
expect(layout.reachesTheControl).toBe(true)
}, 60_000)
// Native files routes throw requireNativeViewManager at import; deferred import hides this until actual route opens, requiring web siblings.
it('mounts the file preview, reading the file path out of a param and not a segment', async () => {
const previewRoute = `${HOST_ROUTE}/files/preview/worktree-a`
const { errors, cspErrors, text, url } = await render(previewRoute, 'readme.md', {
shellRoute: {
pathname: previewRoute,
params: { relativePath: 'docs/my notes/readme.md', source: 'worktree' }
}
})
expect(cspErrors).toEqual([])
// Require no unsupported RN-Web BackHandler registration; Android hardware Back pops native stack while page Back owns unsaved-draft prompt.
expect(errors).toEqual([])
// Title proves final path segment only; URL checks outbound encoding, not middle-segment decoding.
// Decode hazards are covered by mobile-file-path-route-encoding.test.ts and mobile-file-preview-route.test.ts.
expect(text).toContain('readme.md')
expect(url).toBe(`${previewRoute}?relativePath=docs%2Fmy+notes%2Freadme.md&source=worktree`)
expect(text).not.toContain(UNMATCHED)
}, 60_000)
it('tells the shell when a route chunk throws, rather than sitting on a blank page', async () => {
const chunk = routeChunks['./h/[hostId]/index.tsx']
expect(chunk, Object.keys(routeChunks).join(' ')).toBeTruthy()
@@ -345,128 +268,4 @@ describeRender('the Route A page in a real browser', () => {
heldChunks.delete(path)
}
}, 120_000)
it('says nothing for a redirect screen whose target is still behind its chunk', async () => {
// PR redirect remains mounted behind destination’s pending chunk; its own commit must not report screen paint.
const target = routeChunks['./h/[hostId]/source-control/[worktreeId].tsx']
expect(target, Object.keys(routeChunks).join(' ')).toBeTruthy()
const path = `/assets/${target}`
let arrive = () => {}
heldChunks.set(
path,
new Promise((resolve) => {
arrive = resolve
})
)
try {
const route = `${HOST_ROUTE}/pr/render-check-tree`
const opened = await openPage({
shellRoute: { pathname: route },
shellPageRoutes: [HOST_ROUTE_PATTERN, '/h/[hostId]/pr/[worktreeId]']
})
await opened.page.goto(`${origin}/`, { waitUntil: 'load' })
await opened.page.waitForFunction(
() => location.pathname.includes('/source-control/'),
undefined,
{ timeout: 30_000, polling: 250 }
)
// Router has moved but pending hub chunk still leaves document empty.
await waitForPageFrames(opened.page)
expect(await paintReports(opened.page, paintName)).toBe(0)
arrive()
await opened.page.waitForFunction(
(name) =>
(globalThis.__orcaRenderCheckNotifies ?? []).filter((frame) => frame.name === name)
.length > 0,
paintName,
{ timeout: 30_000, polling: 250 }
)
await opened.page.close()
} finally {
arrive()
heldChunks.delete(path)
}
}, 120_000)
it('refuses a target the shell will not take, rather than opening it in the page', async () => {
// Fault-only grant makes notifyNavigate refuse like disposed/older shell; local fallback once mounted Unmatched or native-only session route.
const opened = await openPage({ shellRoute: { pathname: HOST_ROUTE } })
const { page, errors } = opened
await page.goto(`${origin}/`, { waitUntil: 'load' })
await waitForRoute(opened, HOST_ROUTE, SHELL_HOST.name)
await page.getByLabel('Back to hosts').click()
// Settle handoff microtask before asserting no navigation.
await waitForPageFrames(page)
expect(await page.evaluate(() => location.pathname)).toBe(HOST_ROUTE)
const text = await page.evaluate(() => document.body.innerText)
expect(text).toContain(SHELL_HOST.name)
expect(text).not.toContain(UNMATCHED)
// Require no notify as well as unchanged page; successful shell handoff also leaves document in place.
const notifies = await page.evaluate(() => globalThis.__orcaRenderCheckNotifies ?? [])
expect(notifies.filter((frame) => frame.name === 'navigate')).toEqual([])
// Refused target declines navigation without faulting page.
expect(await page.evaluate(() => globalThis.__orcaRenderCheckFaults ?? [])).toEqual([])
expect(errors).toEqual([])
await page.close()
}, 60_000)
it("fetches the next route's chunks on a client-side navigation", async () => {
const opened = await openPage({ shellRoute: { pathname: HOST_ROUTE } })
const { page, errors, scripts } = opened
await page.goto(`${origin}/`, { waitUntil: 'load' })
await waitForRoute(opened, HOST_ROUTE, SHELL_HOST.name)
const loadedForFirstRoute = [...scripts]
// Shell fetches document once; later route must arrive as a chunk through in-page history navigation.
await page.evaluate((to) => {
history.pushState(null, '', to)
dispatchEvent(new PopStateEvent('popstate'))
}, `${HOST_ROUTE}/tasks`)
await waitForRoute(opened, `${HOST_ROUTE}/tasks`, 'Issues')
expect(new URL(page.url()).pathname).toBe(`${HOST_ROUTE}/tasks`)
const fetchedOnNavigation = scripts.filter((path) => !loadedForFirstRoute.includes(path))
// Require builder-identified tasks chunk, not any fetched script or code already loaded by entry.
const tasksChunk = routeChunks['./h/[hostId]/tasks.tsx']
expect(tasksChunk, Object.keys(routeChunks).join(' ')).toBeTruthy()
expect(fetchedOnNavigation, scripts.join(' ')).toContain(`/assets/${tasksChunk}`)
expect(loadedForFirstRoute).not.toContain(`/assets/${tasksChunk}`)
const text = await page.evaluate(() => document.body.innerText)
expect(text).toContain('Tasks')
expect(text).not.toContain(UNMATCHED)
expect(errors).toEqual([])
await page.close()
}, 60_000)
})
// Probe mounted React Navigation canGoBack through real route chevron/router.back; page exposes no direct test hook.
// First prove same-family tap reaches shell, since an inert handler and refused local Back both leave document unchanged.
describeRender('the stack the page Back button rests on', () => {
async function clickAndSettle(page, selector) {
await page.click(selector)
await waitForPageFrames(page)
return page.evaluate(() => location.pathname + location.search)
}
it('carries a handoff the shell granted across the bridge from a real tap', async () => {
// Grant navigate: this chevron proves handler/bridge path used by navigate-back before interpreting absent notifies.
const opened = await openPage({
shellRoute: { pathname: HOST_ROUTE },
shellGrants: [faultGrant, 'navigate'],
shellPageRoutes: [HOST_ROUTE_PATTERN]
})
await opened.page.goto(`${origin}/`, { waitUntil: 'load' })
await waitForRoute(opened, HOST_ROUTE, SHELL_HOST.name)
const url = await clickAndSettle(opened.page, '[aria-label="Back to hosts"]')
await expect
.poll(() =>
opened.page.evaluate(() =>
(globalThis.__orcaRenderCheckNotifies ?? []).filter((frame) => frame.name === 'navigate')
)
)
.toEqual([{ v: bridgeVersion, type: 'notify', name: 'navigate', href: '/' }])
// Require page stays on served route; local fallthrough would mount a screen document cannot serve.
expect(url).toBe(HOST_ROUTE)
expect(opened.errors).toEqual([])
await opened.page.close()
}, 60_000)
})
@@ -130,35 +130,6 @@ const fieldValue = (page) =>
describeRender(
'the terminal live input on the page',
() => {
it('clears the field when Enter submits, the way it does natively', async () => {
// Use real keydown to exercise RN-Web Enter/onSubmitEditing; command must clear previously sent live text.
const { errors, page } = await openProbe()
await page.focus(`#${LIVE_INPUT_FIELD_ID}`)
await page.keyboard.type('ls')
await page.waitForFunction(
(id) => document.getElementById(id)?.value === 'ls',
LIVE_INPUT_FIELD_ID,
{ timeout: 30_000, polling: 100 }
)
await page.keyboard.press('Enter')
await page.waitForFunction(
() => (globalThis.__orcaLiveInputProbe.sent() ?? []).includes('\r'),
undefined,
{ timeout: 30_000, polling: 100 }
)
// Require exact output once: RN-Web cancels submitted keydown, so page line-break binding must not send second CR.
expect(await page.evaluate(() => globalThis.__orcaLiveInputProbe.sent())).toEqual([
'l',
's',
'\r'
])
expect(await fieldValue(page)).toBe('')
expect(errors).toEqual([])
await page.close()
}, 300_000)
it('submits when Enter arrives with the keyboard composition still open', async () => {
// Android soft-keyboard Enter carries isComposing; RN-Web suppresses onSubmitEditing unlike native editor action.
const { errors, page } = await openProbe()
@@ -270,31 +241,6 @@ describeRender(
expect(errors).toEqual([])
await page.close()
}, 300_000)
it('erases and retypes a word the keyboard corrects when it commits', async () => {
const { errors, page } = await openProbe({ userAgent: ANDROID_WEBVIEW_USER_AGENT })
await page.focus(`#${LIVE_INPUT_FIELD_ID}`)
const input = await page.context().newCDPSession(page)
for (const text of ['t', 'te', 'teh']) {
await compose(input, text)
await waitForField(page, text)
}
await input.send('Input.insertText', { text: 'the' })
await waitForField(page, 'the')
await page.waitForFunction(
() => globalThis.__orcaLiveInputProbe.sent().length === 4,
undefined,
{
timeout: 30_000,
polling: 50
}
)
expect(await sent(page)).toEqual(['t', 'e', 'h', '\u007f\u007fhe'])
expect(errors).toEqual([])
await page.close()
}, 300_000)
})
},
900_000
@@ -1,9 +0,0 @@
import { describe, expect, it } from 'vitest'
import { summarizeBenchmarkSamples } from './benchmark-sample-summary.mjs'
describe('benchmark sample summary', () => {
it('averages the two middle samples for an even-sized median', () => {
expect(summarizeBenchmarkSamples([100, 1, 2, 99]).medianMs).toBe(50.5)
})
})
@@ -1,48 +0,0 @@
import { describe, expect, it } from 'vitest'
import { assessProcessHostImports } from './check-process-host-imports.mjs'
const file = 'src/main/process-consumer.ts'
const manifest = { exports: { '.': './dist/index.js' } }
const assess = (source) => assessProcessHostImports(new Map([[file, source]]), manifest, new Set())
describe('process-host runtime import boundary', () => {
it.each([
"import type { ChildProcess } from 'node:child_process'",
"import type * as ChildProcess from 'child_process'",
"import type ChildProcess = require('node:child_process')",
"export type { ChildProcess } from 'node:child_process'",
"type Child = import('node:child_process').ChildProcess"
])('allows erased Node type ownership: %s', (source) => {
expect(assess(source)).toMatchObject({ direct: [], added: [], violations: [] })
})
it.each([
"import { spawn } from 'node:child_process'",
"import 'child_process'",
"import { type ChildProcess } from 'node:child_process'",
"export { spawn } from 'node:child_process'",
"const child = require('node:child_process')",
"const child = import('node:child_process')",
"import type { ChildProcess } from 'node:child_process'; require('node:child_process')",
"import { createRequire } from 'node:module'; const load = createRequire(import.meta.url); load('node:child_process')",
"process.getBuiltinModule('child_process')"
])('still rejects runtime access: %s', (source) => {
expect(assess(source)).toMatchObject({ direct: [file], added: [file] })
})
it('keeps package export restrictions for type-only imports', () => {
expect(assess("import type { Private } from '@orca/process-host/private'").violations).toEqual([
`${file}: @orca/process-host/private is not a public process-host export`
])
})
it('requires the WSL allowlist only for runtime child-process imports', () => {
const wsl = "const program = 'wsl.exe'"
expect(
assess(`import type { ChildProcess } from 'node:child_process'; ${wsl}`).violations
).toEqual([])
expect(assess(`import { spawn } from 'node:child_process'; ${wsl}`).violations).toEqual([
`${file}: direct wsl.exe exec must call assertWslDistroAllowed`
])
})
})
@@ -1,86 +0,0 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { runProcess, runProcessSync } from '@orca/process-host'
import { expect, it } from 'vitest'
import { resolvePinnedBunExecutable } from '../../tool-invocation/pinned-bun-executable.mjs'
const root = fileURLToPath(new URL('../../../..', import.meta.url))
it('restores environment stubs and finishes background imports before the next shared file', async () => {
const cache = join(root, 'node_modules/.cache')
mkdirSync(cache, { recursive: true })
const directory = mkdtempSync(join(cache, 'shared-worker-reset-'))
const write = (name, source) => writeFileSync(join(directory, name), source)
try {
write(
'vitest.config.mjs',
`import { defineConfig } from 'vitest/config'
import { BaseSequencer } from 'vitest/node'
class Ordered extends BaseSequencer {
async sort(specs) { return specs.sort((a, b) => a.moduleId.localeCompare(b.moduleId)) }
}
export default defineConfig({ test: {
root: ${JSON.stringify(directory)},
include: ['*.spec.mjs'], isolate: false, maxWorkers: 1,
setupFiles: ['./assert-import-finished.mjs', ${JSON.stringify(resolve(root, 'config/scripts/test-runtime/environment/vitest-shared-worker-file-reset.mjs'))}],
sequence: { sequencer: Ordered }
} })`
)
write(
'assert-import-finished.mjs',
`import { afterAll, expect } from 'vitest'
import { readFileSync } from 'node:fs'
afterAll(() => expect(readFileSync(new URL('./result', import.meta.url), 'utf8')).toBe('loaded'))`
)
write(
'import-started.mjs',
`export let started
export const ready = new Promise(resolve => { started = resolve })`
)
write(
'background.mjs',
`import { setImmediate } from 'node:timers/promises'
import { writeFileSync } from 'node:fs'
import { started } from './import-started.mjs'
started()
await setImmediate()
writeFileSync(new URL('./result', import.meta.url), 'loaded')`
)
write(
'01-replace-env.spec.mjs',
`import { it } from 'vitest'
import { ready } from './import-started.mjs'
it('replaces env and starts a background import', async () => {
process.env = { ...process.env }
void import('./background.mjs')
await ready
})`
)
write(
'02-stub-env.spec.mjs',
`import { expect, it, vi } from 'vitest'
it('removes an environment override after a previous file replaced env', () => {
vi.stubEnv('ORCA_SHARED_WORKER_OVERRIDE', 'asia-east2')
vi.unstubAllEnvs()
expect(process.env.ORCA_SHARED_WORKER_OVERRIDE).toBeUndefined()
})`
)
const result = await runProcess({
program: resolvePinnedBunExecutable(root, runProcessSync) ?? 'bun',
args: [
'--bun',
resolve(root, 'node_modules/vitest/vitest.mjs'),
'run',
'--config',
join(directory, 'vitest.config.mjs')
],
cwd: root,
env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' },
timeoutMs: 30_000
})
expect(result.code, result.stdout + result.stderr).toBe(0)
} finally {
rmSync(directory, { recursive: true, force: true })
}
})
@@ -1,95 +1,12 @@
import { once } from 'node:events'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { join } from 'node:path'
import { PassThrough } from 'node:stream'
import { afterAll, afterEach, beforeAll, expect, it, onTestFinished, vi } from 'vitest'
import { createVitest, type PoolOptions, type Vitest } from 'vitest/node'
import * as processes from '@orca/process-host'
import type { ChildProcess } from 'node:child_process'
import { expect, it, onTestFinished } from 'vitest'
import { createVitest } from 'vitest/node'
import { nodeRuntimePool } from './vitest-node-runtime-pool'
let context: Vitest
const children: ChildProcess[] = []
const spawn = processes.spawnProcess
beforeAll(async () => {
context = await createVitest('test', { config: false, watch: false, include: [] })
})
afterEach(async () => {
vi.useRealTimers()
vi.restoreAllMocks()
for (const child of children.splice(0)) {
if (child.pid && child.exitCode === null && child.signalCode === null) {
const exited = once(child, 'exit')
child.kill('SIGKILL')
await exited
}
}
})
afterAll(async () => {
await context.close()
})
function options(executable = process.execPath): PoolOptions {
return {
distPath: '',
project: context.getRootProject(),
method: 'run',
environment: { name: 'node', options: null },
execArgv: [],
env: { ...process.env, ORCA_TEST_NODE_EXECUTABLE: executable }
}
}
function captureChild(): void {
vi.spyOn(processes, 'spawnProcess').mockImplementation((spec) => {
const child = spawn(spec)
children.push(child)
return child
})
}
it('rejects an unavailable Node executable without an uncaught error or leaked pipes', async () => {
const output = context.logger.outputStream
const before = output.getMaxListeners()
const worker = nodeRuntimePool.createPoolWorker(options(resolve('missing-node-executable')))
await expect(worker.start()).rejects.toMatchObject({ code: 'ENOENT' })
await worker.stop()
expect(output.getMaxListeners()).toBe(before)
})
it('waits for a running Node worker to exit and restores its output listener budget', async () => {
captureChild()
const output = context.logger.outputStream
const before = output.getMaxListeners()
const worker = nodeRuntimePool.createPoolWorker(options())
await worker.start()
await worker.stop()
expect(children[0].signalCode).toBe('SIGTERM')
expect(output.getMaxListeners()).toBe(before)
})
it('reports a failed kill within its deadline rather than hanging teardown', async () => {
captureChild()
const worker = nodeRuntimePool.createPoolWorker(options())
const output = context.logger.outputStream
const before = output.getMaxListeners()
await worker.start()
const child = children[0]
vi.spyOn(child, 'kill').mockReturnValue(false)
vi.useFakeTimers()
const stopped = expect(worker.stop()).rejects.toThrow('did not exit after SIGKILL')
await vi.advanceTimersByTimeAsync(2_000)
await stopped
expect(child.kill).toHaveBeenCalledWith('SIGTERM')
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
expect(child.listenerCount('exit')).toBe(0)
expect(output.getMaxListeners()).toBe(before)
})
it('reports a test file that fails to load as a failed file instead of crashing the run', async () => {
const dir = await mkdtemp(join(tmpdir(), 'orca-node-runtime-pool-'))
onTestFinished(() => rm(dir, { recursive: true, force: true }))
@@ -132,7 +132,6 @@ const NODE_RUNTIME_INCLUDE = [
'src/shared/ephemeral-vm-recipe-output-capture.test.ts',
'src/main/window/main-window-updater-deferred-setup.test.ts',
'src/renderer/src/components/terminal-pane/host-state/terminal-pane-host-state-memo.test.ts',
'src/renderer/src/components/automations/runs/use-automation-runs-dashboard-payload.test.tsx',
'src/main/native-chat/agent-session-journal/**/*.test.ts',
'src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-terminal-writer-acquire.test.ts',
'src/main/runtime/agent-session/records/agent-session-conversation-name-store.test.ts',
@@ -153,16 +153,6 @@ export const PR_E2E_SOURCE_ROUTES = [
file
)
},
{
id: 'ssh.orcad-auto-convert',
specs: ['tests/e2e/ssh-orcad-auto-convert.spec.ts'],
matches: (file) =>
MANAGED_SSH_HARNESS.test(file) ||
(isProductSource(file) &&
/^src\/main\/(?:ipc\/(?:ssh\/)?ssh-host-server-|(?:ssh|managed-server\/[a-z-]+)\/(?:ssh-host-server-|orcad-runtime-conversion|orcad-migration-|orcad-retained-source|orcad-runtime-deployment))/.test(
file
))
},
{
id: 'ssh.orcad-connect-failure',
specs: [
@@ -1,87 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../../../transport/rpc/rpc-client-contract'
import { markRpcDeliveryUnknown } from '../../../transport/rpc/rpc-delivery-ambiguity'
import { createMobileStructuredAgentSession } from './mobile-structured-agent-session-launch'
import './mobile-created-structured-sessions'
function clientReturning(
...responses: unknown[]
): RpcClient & { sendRequest: ReturnType<typeof vi.fn> } {
let responseIndex = 0
const sendRequest = vi.fn(async () => responses[responseIndex++])
return { sendRequest } as unknown as RpcClient & { sendRequest: ReturnType<typeof vi.fn> }
}
describe('mobile structured agent-session launch', () => {
it('keeps the outcome unknown when the idempotent retry cannot be sent', async () => {
const client = clientReturning({ ok: true, result: { supported: true } })
client.sendRequest.mockImplementationOnce(async () => ({
ok: true,
result: { supported: true }
}))
client.sendRequest.mockRejectedValueOnce(markRpcDeliveryUnknown(new Error('response lost')))
client.sendRequest.mockRejectedValueOnce(new Error('connection interrupted'))
await expect(
createMobileStructuredAgentSession(client, 'workspace-1', 'codex')
).resolves.toMatchObject({
kind: 'unknown'
})
})
})
it('settles a proven missing-original fork refusal but keeps a lost earlier attempt unknown', async () => {
const refusal = {
ok: true,
result: {
ok: false,
refusal: {
code: 'agent_session_identity_required',
details: { reason: 'recordMissing' },
createAttemptRefusedBeforeCommit: true,
message: 'Source chat unavailable'
}
}
}
const client = clientReturning({ ok: true, result: { supported: true } }, refusal)
const forkFrom = { sessionId: 'copied-chat', throughItemId: 'legacy:opencode:source:reply-1' }
expect(
(await createMobileStructuredAgentSession(client, 'workspace-1', 'opencode', forkFrom)).kind
).toBe('failed')
const lost = clientReturning({ ok: true, result: { supported: true } }, refusal)
lost.sendRequest
.mockImplementationOnce(async () => ({ ok: true, result: { supported: true } }))
.mockRejectedValueOnce(markRpcDeliveryUnknown(new Error('reply lost')))
.mockResolvedValueOnce(refusal)
expect(
(await createMobileStructuredAgentSession(lost, 'workspace-1', 'opencode', forkFrom)).kind
).toBe('unknown')
})
it('settles a proven fork read refusal without settling a prior lost copy', async () => {
const refusal = {
ok: true,
result: {
ok: false,
refusal: {
code: 'agent_session_identity_required',
details: { reason: 'transcriptUnreadable' },
createAttemptRefusedBeforeCommit: true,
message: 'Codex could not read the saved chat.'
}
}
}
const client = clientReturning({ ok: true, result: { supported: true } }, refusal)
const forkFrom = { sessionId: 'source-chat', throughItemId: 'codex:source:selected:1' }
expect(
(await createMobileStructuredAgentSession(client, 'workspace-1', 'codex', forkFrom)).kind
).toBe('failed')
const lost = clientReturning({ ok: true, result: { supported: true } }, refusal)
lost.sendRequest
.mockImplementationOnce(async () => ({ ok: true, result: { supported: true } }))
.mockRejectedValueOnce(markRpcDeliveryUnknown(new Error('copy reply lost')))
.mockResolvedValueOnce(refusal)
expect(
(await createMobileStructuredAgentSession(lost, 'workspace-1', 'codex', forkFrom)).kind
).toBe('unknown')
})
@@ -1,223 +0,0 @@
import { createElement } from 'react'
import {
snapshotEvent as publishedSnapshot,
fieldsOf,
mutationOk,
ok
} from './use-mobile-structured-agent-session-queued.test-fixture'
import { act, type ReactTestRenderer } from 'react-test-renderer'
import { createWithQueryClient } from '../../../test-support/query-client-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentJournalRenderItem } from '../../../../../src/shared/agent-session/journal/agent-session-journal-types'
import type { RpcClient } from '../../../transport/rpc/rpc-client-contract'
import type { ConnectionState } from '../../../transport/connection/connection-state'
import { structuredSendResultFixture } from '../../structured-agent-send-result.test-fixture'
import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session'
const asyncStorage = vi.hoisted(() => ({
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn()
}))
vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage }))
function snapshotEvent(fence = 3, items: AgentJournalRenderItem[] = []) {
return publishedSnapshot({ fence, items })
}
function snapshotWithMessage() {
return snapshotEvent(3, [
{
itemId: 'msg-1',
revision: 1,
sequence: 1,
observedAt: 10,
body: {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'sent before the blip' }]
}
}
])
}
async function defaultSendRequest(method: string, params?: unknown) {
if (method === 'agentSession.send') {
return mutationOk(structuredSendResultFixture('accepted'))
}
if (method === 'agentSession.options') {
return ok({
models: [
{
id: 'gpt-fast',
label: 'GPT Fast',
isDefault: true,
defaultEffort: 'low',
efforts: [
{ value: 'low', label: 'Low' },
{ value: 'high', label: 'High' }
]
},
{
id: 'gpt-slow',
label: 'GPT Slow',
isDefault: false,
defaultEffort: 'high',
efforts: [
{ value: 'low', label: 'Low' },
{ value: 'high', label: 'High' }
]
}
],
current: {
model: 'gpt-fast',
effort: 'low'
}
})
}
if (method === 'agentSession.setOption') {
return ok({
ok: true,
replayed: false,
fence: 3,
cursor: { epoch: 'epoch-1', sequence: 2 },
value: {
key: 'model',
value: 'gpt-fast',
options: { model: 'gpt-fast' }
}
})
}
if (method === 'agentSession.respondToApproval' || method === 'agentSession.respondToQuestion') {
return ok({
ok: true,
replayed: false,
fence: 3,
cursor: { epoch: 'epoch-1', sequence: 3 },
value: {
itemId: String(fieldsOf(params).itemId ?? ''),
revision: 2,
resolution: {
state: 'resolved',
selectedOptionId: String(fieldsOf(params).optionId ?? ''),
resolvedBy: 'mobile',
resolvedAt: 123
}
}
})
}
return ok({})
}
describe('useMobileStructuredAgentSession', () => {
let renderer: ReactTestRenderer | null = null
let hook: ReturnType<typeof useMobileStructuredAgentSession> | null = null
let listener: ((value: unknown) => void) | null = null
const onSendError = vi.fn()
const unsubscribe = vi.fn()
const unsubscribePreference = vi.fn()
const sendRequest = vi.fn(defaultSendRequest)
const subscribe = vi.fn((_method: string, _params: unknown, onData: (value: unknown) => void) => {
if (_method === 'agentSession.subscribe') {
listener = onData
return unsubscribe
}
return unsubscribePreference
})
let clientState: ConnectionState = 'connected'
const stateListeners = new Set<(state: ConnectionState) => void>()
const client: RpcClient = {
sendRequest,
subscribe,
getState: () => clientState,
onStateChange: (stateListener) => {
stateListeners.add(stateListener)
return () => stateListeners.delete(stateListener)
},
getReconnectAttempt: () => 0,
getLastConnectedAt: () => null,
updateTerminalSubscriptionViewport: () => {},
notifyForeground: () => {},
close: () => {}
}
let storedOperations: Map<string, string>
function Harness({
sessionId = 'session-1',
agent = 'codex',
connected = true,
sourceIdentity = 'host-a\0workspace-a'
}: {
sessionId?: string | null
agent?: string | null
connected?: boolean
sourceIdentity?: string
}): null {
hook = useMobileStructuredAgentSession({
client,
pairedHostClient: client,
sessionId,
sourceIdentity,
enabled: true,
connected,
hostSupport: null,
agent,
onSendError
})
return null
}
beforeEach(() => {
vi.clearAllMocks()
storedOperations = new Map()
asyncStorage.getItem.mockImplementation(
async (key: string) => storedOperations.get(key) ?? null
)
asyncStorage.setItem.mockImplementation(async (key: string, value: string) => {
storedOperations.set(key, value)
})
asyncStorage.removeItem.mockImplementation(async (key: string) => {
storedOperations.delete(key)
})
sendRequest.mockImplementation(defaultSendRequest)
listener = null
clientState = 'connected'
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
hook = null
})
it('isolates matching provider session ids across host and workspace sources', async () => {
await act(async () => {
renderer = createWithQueryClient(
createElement(Harness, {
connected: true,
sessionId: 'session-1',
sourceIdentity: 'host-a\0workspace-a'
})
)
})
await vi.waitFor(() => expect(listener).toEqual(expect.any(Function)))
act(() => listener?.(snapshotWithMessage()))
expect(hook?.session.messages).toHaveLength(1)
await act(async () => {
renderer?.update(
createElement(Harness, {
connected: false,
sessionId: 'session-1',
sourceIdentity: 'host-b\0workspace-b'
})
)
})
expect(hook?.session.messages).toEqual([])
})
})
@@ -1,332 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { FakeSession } from '../../transport/connection/mobile-endpoint-supervisor-test-fakes'
import { markRpcDeliveryUnknown } from '../../transport/rpc/rpc-delivery-ambiguity'
import type { RpcResponse } from '../../transport/rpc/rpc-wire-types'
import {
beginFolderComposerSubmission,
createFolderRepoWorktree,
isFolderRepoCreateBusy,
reconcileFolderRepoCreates,
releaseFolderComposerReservation,
type FolderRepoTarget
} from './folder-repo-worktree-create'
const idempotency = vi.hoisted(() => {
const state: { support: { dedupeTtlMs: number } | false } = { support: false }
return state
})
vi.mock('../../tasks/worktree-create-capability', () => ({
readNewWorktreeRuntimeCapabilities: async () => ({
worktreeCreateIdempotency: idempotency.support
})
}))
const FOLDER = 'folder:f-1'
const TAKEN = 'Branch payments already exists\nworktree_create_exact_name_taken'
afterEach(() => {
vi.useRealTimers()
idempotency.support = false
})
let repoCount = 0
// Each test's own repository: the registry is shared, as it is across screens.
function target(executionHost?: string): FolderRepoTarget {
repoCount += 1
return { hostId: 'host-1', executionHost, folderWorktreeId: FOLDER, repoId: `repo-${repoCount}` }
}
function refused(message: string): RpcResponse {
return { id: '1', ok: false, error: { code: 'x', message }, _meta: { runtimeId: 'r' } }
}
const CREATED: RpcResponse = {
id: '1',
ok: true,
result: { worktree: { id: 'repo::/w/payments', displayName: 'payments' } },
_meta: { runtimeId: 'r' }
}
function host(...replies: (RpcResponse | Error)[]): FakeSession {
const client = new FakeSession('connected')
for (const reply of replies) {
if (reply instanceof Error) {
client.sendRequest.mockRejectedValueOnce(reply)
} else {
client.sendRequest.mockResolvedValueOnce(reply)
}
}
return client
}
const create = (repo: FolderRepoTarget, client: FakeSession) =>
createFolderRepoWorktree({ target: repo, client, name: 'payments' })
describe('createFolderRepoWorktree', () => {
it('sends one exact-name create attached to the folder', async () => {
const repo = target()
const client = host(CREATED)
await expect(create(repo, client)).resolves.toEqual({ kind: 'created', name: 'payments' })
expect(client.sendRequest.mock.calls).toEqual([
[
'worktree.create',
{
repo: `id:${repo.repoId}`,
name: 'payments',
displayName: 'payments',
displayNameKind: 'user',
branchIntent: 'create-exact',
setupDecision: 'inherit',
parentWorkspace: FOLDER,
parentWorkspaceOrigin: 'manual'
},
expect.anything()
]
])
})
it('sends nothing for a second tap while the first create runs', async () => {
const repo = target()
const client = new FakeSession('connected')
let answer: (response: RpcResponse) => void = () => {}
client.sendRequest.mockReturnValue(new Promise<RpcResponse>((resolve) => (answer = resolve)))
const first = create(repo, client)
await expect(create(repo, client)).resolves.toEqual({ kind: 'already-running' })
answer(CREATED)
await first
expect(client.sendRequest).toHaveBeenCalledTimes(1)
})
it.each([
['the name is taken', TAKEN],
['the repo asks about setup', 'Setup decision required for this repository']
])('keeps the repository for the composer when %s', async (_case, message) => {
const repo = target()
const outcome = await create(repo, host(refused(message)))
expect(outcome.kind).toBe('needs-composer')
expect(isFolderRepoCreateBusy(repo)).toBe(true)
if (outcome.kind === 'needs-composer') {
releaseFolderComposerReservation(repo, outcome.reservation)
}
expect(isFolderRepoCreateBusy(repo)).toBe(false)
})
it('treats a lost reply as possibly created, past the busy backstop, and never offers the composer', async () => {
vi.useFakeTimers({ toFake: ['setTimeout'] })
const repo = target()
const lost = markRpcDeliveryUnknown(new Error('connection lost'))
await expect(create(repo, host(lost))).resolves.toEqual({ kind: 'maybe-created' })
expect(isFolderRepoCreateBusy(repo)).toBe(true)
await expect(create(repo, host(CREATED))).resolves.toEqual({ kind: 'already-running' })
// The list never showed it; the card takes taps again but the create is still unresolved.
vi.advanceTimersByTime(60_000)
expect(isFolderRepoCreateBusy(repo)).toBe(false)
const retry = host(refused(TAKEN))
await expect(create(repo, retry)).resolves.toEqual({ kind: 'maybe-created' })
expect(retry.sendRequest).toHaveBeenCalledTimes(1)
})
it('retries an unconfirmed create as the name it was sent as, even after the folder is renamed', async () => {
vi.useFakeTimers({ toFake: ['setTimeout'] })
const repo = target()
await create(repo, host(markRpcDeliveryUnknown(new Error('connection lost'))))
vi.advanceTimersByTime(60_000)
const retry = host(refused(TAKEN))
await createFolderRepoWorktree({ target: repo, client: retry, name: 'billing' })
expect(retry.sendRequest.mock.calls[0]?.[1]).toMatchObject({ name: 'payments' })
})
it('asks for setup when an unconfirmed create is refused for it, and resends the same exact name', async () => {
vi.useFakeTimers({ toFake: ['setTimeout'] })
const repo = target()
await create(repo, host(markRpcDeliveryUnknown(new Error('connection lost'))))
vi.advanceTimersByTime(60_000)
const asked = await create(repo, host(refused('Setup decision required for this repository')))
expect(asked.kind).toBe('needs-setup-choice')
expect(isFolderRepoCreateBusy(repo)).toBe(false)
const hold = asked.kind === 'needs-setup-choice' ? asked.hold : -1
const resent = host(refused(TAKEN))
await expect(
createFolderRepoWorktree({
target: repo,
client: resent,
name: 'billing',
skipSetupFor: hold
})
).resolves.toEqual({ kind: 'maybe-created' })
expect(resent.sendRequest.mock.calls[0]?.[1]).toMatchObject({
name: 'payments',
branchIntent: 'create-exact',
setupDecision: 'skip'
})
})
it('sends nothing for a setup choice once its create is listed or replaced', async () => {
vi.useFakeTimers({ toFake: ['setTimeout'] })
const ask = async (repo: FolderRepoTarget) => {
await create(repo, host(markRpcDeliveryUnknown(new Error('connection lost'))))
vi.advanceTimersByTime(60_000)
const asked = await create(repo, host(refused('Setup decision required for this repository')))
return asked.kind === 'needs-setup-choice' ? asked.hold : -1
}
const listed = target()
const listedHold = await ask(listed)
const folder = { hostId: 'host-1', executionHost: undefined, folderWorktreeId: FOLDER }
reconcileFolderRepoCreates(folder, [{ repoId: listed.repoId }])
const replaced = target()
const replacedHold = await ask(replaced)
await ask(replaced)
const late = host(CREATED)
for (const [repo, hold] of [
[listed, listedHold],
[replaced, replacedHold]
] as const) {
await expect(
createFolderRepoWorktree({
target: repo,
client: late,
name: 'billing',
skipSetupFor: hold
})
).resolves.toEqual({ kind: 'checking' })
}
expect(late.sendRequest).not.toHaveBeenCalled()
})
})
describe('createFolderRepoWorktree after a lost reply on a host that dedupes', () => {
it.each([
['answers with the worktree', CREATED, { kind: 'created', name: 'payments' }],
['refuses the name as taken', refused(TAKEN), { kind: 'maybe-created' }]
])('resends the same create once reconnected; the host %s', async (_case, replay, expected) => {
idempotency.support = { dedupeTtlMs: 60_000 }
const repo = target()
const client = new FakeSession('connected')
client.sendRequest
.mockImplementationOnce(async () => {
client.publishState('reconnecting')
setTimeout(() => client.publishState('connected'), 0)
throw markRpcDeliveryUnknown(new Error('connection lost'))
})
.mockResolvedValueOnce(replay)
await expect(create(repo, client)).resolves.toEqual(expected)
const [first, second] = client.sendRequest.mock.calls.map(([, params]) => params)
expect(second).toEqual(first)
expect(first).toHaveProperty('clientMutationId')
})
})
describe('reconcileFolderRepoCreates', () => {
it('forgets a create once the folder lists its worktree on the same host, not another', async () => {
const repo = target('runtime:env-1')
await create(repo, host(CREATED))
const folder = { hostId: 'host-1', executionHost: 'runtime:env-1', folderWorktreeId: FOLDER }
reconcileFolderRepoCreates(folder, [{ repoId: repo.repoId, hostId: 'runtime:env-2' }])
expect(isFolderRepoCreateBusy(repo)).toBe(true)
reconcileFolderRepoCreates(folder, [{ repoId: repo.repoId, hostId: 'runtime:env-1' }])
expect(isFolderRepoCreateBusy(repo)).toBe(false)
})
it('keeps a create still on its way even if the list already shows the repository', async () => {
const repo = target()
const client = new FakeSession('connected')
let answer: (response: RpcResponse) => void = () => {}
client.sendRequest.mockReturnValue(new Promise<RpcResponse>((resolve) => (answer = resolve)))
const folder = { hostId: 'host-1', executionHost: undefined, folderWorktreeId: FOLDER }
const members = [{ repoId: repo.repoId }]
const pending = create(repo, client)
reconcileFolderRepoCreates(folder, members)
expect(isFolderRepoCreateBusy(repo)).toBe(true)
answer(CREATED)
await pending
reconcileFolderRepoCreates(folder, members)
expect(isFolderRepoCreateBusy(repo)).toBe(false)
})
it("doesn't let an earlier create's timer end a newer one", async () => {
vi.useFakeTimers({ toFake: ['setTimeout'] })
const repo = target()
await create(repo, host(markRpcDeliveryUnknown(new Error('lost'))))
vi.advanceTimersByTime(10_000)
const folder = { hostId: 'host-1', executionHost: undefined, folderWorktreeId: FOLDER }
reconcileFolderRepoCreates(folder, [{ repoId: repo.repoId }])
await create(repo, host(CREATED))
vi.advanceTimersByTime(10_000)
expect(isFolderRepoCreateBusy(repo)).toBe(true)
})
})
describe('beginFolderComposerSubmission', () => {
async function composing() {
const repo = target()
const outcome = await create(repo, host(refused(TAKEN)))
if (outcome.kind !== 'needs-composer') {
throw new Error('expected the composer')
}
return { repo, launch: { target: repo, reservation: outcome.reservation, name: 'payments' } }
}
it('admits one create per repository while it is out, even after the form closes', async () => {
const { repo, launch } = await composing()
let formOpen = true
const submission = beginFolderComposerSubmission(repo, launch, () => formOpen)
expect(submission).not.toBeNull()
formOpen = false
releaseFolderComposerReservation(repo, launch.reservation)
expect(beginFolderComposerSubmission(repo, launch, () => true)).toBeNull()
expect(await create(repo, host(CREATED))).toEqual({ kind: 'already-running' })
submission?.settle('uncertain')
expect(isFolderRepoCreateBusy(repo)).toBe(true)
})
it('gives a refused create back to the open form, and a same-host switch its own admission', async () => {
const { repo, launch } = await composing()
const other = { ...repo, repoId: `${repo.repoId}-other` }
beginFolderComposerSubmission(repo, launch, () => true)?.settle('failed')
expect(isFolderRepoCreateBusy(repo)).toBe(true)
const switched = beginFolderComposerSubmission(other, launch, () => true)
expect(switched).not.toBeNull()
expect(beginFolderComposerSubmission(other, launch, () => true)).toBeNull()
switched?.settle('created')
releaseFolderComposerReservation(repo, launch.reservation)
expect(isFolderRepoCreateBusy(repo)).toBe(false)
expect(isFolderRepoCreateBusy(other)).toBe(true)
})
it("never sends anything from a card for a form's unconfirmed create, whatever name it used", async () => {
vi.useFakeTimers({ toFake: ['setTimeout'] })
const { repo, launch } = await composing()
// The form may have sent another name or branch than the card's.
beginFolderComposerSubmission(repo, launch, () => true)?.settle('uncertain')
vi.advanceTimersByTime(60_000)
expect(beginFolderComposerSubmission(repo, launch, () => true)).toBeNull()
const tap = host(CREATED)
await expect(create(repo, tap)).resolves.toEqual({ kind: 'checking' })
await expect(
createFolderRepoWorktree({
target: repo,
client: tap,
name: 'payments',
skipSetupFor: 0
})
).resolves.toEqual({ kind: 'checking' })
expect(tap.sendRequest).not.toHaveBeenCalled()
expect(isFolderRepoCreateBusy(repo)).toBe(false)
})
})
@@ -1,196 +0,0 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { acpLaunchSpecFor } from '../launch/acp-launch-registry'
import type { AcpLaunchSpec } from '../launch/acp-launch-contract'
import { createAcpModelCatalogProbe } from './acp-model-catalog-probe'
import { AcpSessionRuntime } from '../protocol/acp-session-runtime'
import { AcpScriptedAgent } from '../process/testing/acp-scripted-agent.test-support'
import { openCodeAcpAccountBinding } from '../../opencode/opencode-structured-account-home'
import { AcpAgentExitedError } from '../transport/acp-errors'
// Real `omp acp` 18.17.0 session/new replies: started on its default fake/wide, with
// `--model fake/narrow`, and with `--model fake/gone`, which it cannot resolve.
import recorded from '../fixtures/omp-v18.17.0-model-efforts.json'
// Bypass the app's managed-account service for this fixture.
const OPENCODE: AcpLaunchSpec = {
...acpLaunchSpecFor('opencode')!,
account: openCodeAcpAccountBinding(() => ({
list: () => ({ activeAccountId: null, accounts: [] }),
restoreOriginalEnvironment: () => {},
environmentForAccount: () => ({})
}))
}
const agents: AcpScriptedAgent[] = []
afterEach(() => {
for (const agent of agents.splice(0)) {
agent.close()
}
})
describe('ACP model catalog probes', () => {
it('lists the catalog after OpenCode answers session/new with -32603 while starting', async () => {
const agent = new AcpScriptedAgent()
agents.push(agent)
agent.on('initialize', (frame) =>
agent.reply(frame, {
protocolVersion: 1,
agentCapabilities: { sessionCapabilities: { delete: {} } }
})
)
let sessionNews = 0
agent.on('session/new', (frame) => {
sessionNews += 1
if (sessionNews === 1) {
agent.fail(frame, -32603, 'Internal error: Internal service failure', {
errorName: 'InvalidRequestError'
})
return
}
agent.reply(frame, {
sessionId: 'probe-session',
configOptions: [
{
id: 'model',
name: 'Model',
category: 'model',
type: 'select',
currentValue: 'opencode/exo-free',
options: [
{ value: 'opencode/big-pickle', name: 'Big Pickle' },
{ value: 'opencode/exo-free', name: 'Exo Free' }
]
}
]
})
})
agent.on('session/delete', (frame) => agent.reply(frame, {}))
agent.on('session/set_config_option', (frame) => agent.fail(frame, -32602, 'Model unavailable'))
const success = await createAcpModelCatalogProbe(OPENCODE, {
resolveEnvironment: async () => ({ PATH: '/usr/bin', HOME: '/home/user' }),
resolveCommand: () => '/opt/opencode/bin/opencode',
probeVersion: async () => ({ version: '2.0.29' }),
homePath: '/home/user',
dataDirectory: '/orca-data',
connectSession: () => {
const runtime = new AcpSessionRuntime(agent.stdout, agent.stdin)
return {
initialize: () => runtime.initialize(),
start: (options) => runtime.start(options),
setConfigOption: (id, value) => runtime.setConfigOption(id, value),
deleteSession: (sessionId) => runtime.deleteSession(sessionId),
close: async () => runtime.close()
}
}
})({ kind: 'opencode', locator: { kind: 'unmanaged' } }, { workspacePath: '/work/project' })
expect(sessionNews).toBe(2)
expect(success.models.map((model) => model.id)).toEqual([
'opencode/big-pickle',
'opencode/exo-free'
])
})
})
async function listOmp(selectedModels?: string[]) {
const agent = new AcpScriptedAgent()
agents.push(agent)
let launchedModel: string | undefined
agent.on('initialize', (frame) => agent.reply(frame, { protocolVersion: 1 }))
agent.on('session/new', (frame) =>
agent.reply(frame, {
sessionId: 'probe-session',
...(launchedModel === 'fake/narrow'
? recorded.narrow
: launchedModel === 'fake/gone'
? recorded.unresolved
: recorded.new)
})
)
const dataDirectory = await mkdtemp(join(tmpdir(), 'orca-omp-efforts-'))
try {
const listing = await createAcpModelCatalogProbe(acpLaunchSpecFor('omp')!, {
resolveEnvironment: async () => ({ PATH: '/usr/bin', HOME: '/home/user' }),
resolveCommand: () => '/opt/omp/bin/omp',
probeVersion: async () => ({ version: '18.17.0' }),
homePath: '/home/user',
dataDirectory,
connectSession: (launch) => {
const at = launch.args.indexOf('--model')
launchedModel = at === -1 ? undefined : launch.args[at + 1]
if (launchedModel?.includes(':')) {
// OMP 18.17.0 prints `Model "fake/gone:high" not found.` and exits 1 before ACP starts.
const exited = async () => {
throw new AcpAgentExitedError('Model "fake/gone:high" not found.')
}
return {
initialize: exited,
start: exited,
setConfigOption: exited,
deleteSession: exited,
close: async () => {}
}
}
const runtime = new AcpSessionRuntime(agent.stdout, agent.stdin)
return {
initialize: () => runtime.initialize(),
start: (options) => runtime.start(options),
setConfigOption: (id, value) => runtime.setConfigOption(id, value),
deleteSession: (sessionId) => runtime.deleteSession(sessionId),
close: async () => runtime.close()
}
}
})(
{ variable: 'PI_CODING_AGENT_DIR', path: '/homes/omp' },
{ workspacePath: '/work/project', ...(selectedModels ? { selectedModels } : {}) }
)
// A model switch would record usage in the user's OMP account.
expect(agent.frames.map((frame) => frame.method)).toEqual(['initialize', 'session/new'])
return (id: string) => listing.models.find((model) => model.id === id)
} finally {
await rm(dataDirectory, { recursive: true, force: true })
}
}
const levels = (model: { efforts: { value: string }[] } | undefined) =>
model?.efforts.map((effort) => effort.value)
it("lists a picked OMP model with its own thinking levels, not the startup model's", async () => {
const listed = await listOmp()
expect(levels(listed('fake/wide'))).toEqual([
'off',
'auto',
'minimal',
'low',
'medium',
'high',
'xhigh'
])
expect(listed('fake/narrow')).toMatchObject({ efforts: [], effortsKnown: false })
const picked = await listOmp(['fake/narrow'])
expect(levels(picked('fake/narrow'))).toEqual(['off', 'auto', 'low', 'high'])
expect(picked('fake/narrow')).not.toHaveProperty('effortsKnown')
expect(picked('fake/wide')).toMatchObject({ efforts: [], effortsKnown: false })
})
it('trusts no menu when OMP cannot start on the picked model', async () => {
const picked = await listOmp(['fake/gone'])
expect(picked('fake/wide')).toMatchObject({ efforts: [], effortsKnown: false })
})
it('still lists OMP when it cannot start on the picked model', async () => {
const picked = await listOmp(['fake/gone:high'])
expect(levels(picked('fake/wide'))).toEqual([
'off',
'auto',
'minimal',
'low',
'medium',
'high',
'xhigh'
])
})
@@ -1,102 +0,0 @@
{
"new": {
"configOptions": [
{
"id": "model",
"name": "Model",
"category": "model",
"type": "select",
"currentValue": "fake/wide",
"options": [
{ "value": "fake/wide", "name": "Wide", "description": "fake/wide" },
{ "value": "fake/narrow", "name": "Narrow", "description": "fake/narrow" },
{
"value": "apple/on-device",
"name": "Apple AFM 3 Core Advanced",
"description": "apple/on-device"
}
]
},
{
"id": "thinking",
"name": "Thinking",
"category": "thought_level",
"type": "select",
"currentValue": "high",
"options": [
{ "value": "off", "name": "Off" },
{ "value": "auto", "name": "Auto", "description": "Auto-detect per prompt" },
{ "value": "minimal", "name": "minimal" },
{ "value": "low", "name": "low" },
{ "value": "medium", "name": "medium" },
{ "value": "high", "name": "high" },
{ "value": "xhigh", "name": "xhigh" }
]
}
]
},
"narrow": {
"configOptions": [
{
"id": "model",
"name": "Model",
"category": "model",
"type": "select",
"currentValue": "fake/narrow",
"options": [
{ "value": "fake/wide", "name": "Wide", "description": "fake/wide" },
{ "value": "fake/narrow", "name": "Narrow", "description": "fake/narrow" },
{
"value": "apple/on-device",
"name": "Apple AFM 3 Core Advanced",
"description": "apple/on-device"
}
]
},
{
"id": "thinking",
"name": "Thinking",
"category": "thought_level",
"type": "select",
"currentValue": "high",
"options": [
{ "value": "off", "name": "Off" },
{ "value": "auto", "name": "Auto", "description": "Auto-detect per prompt" },
{ "value": "low", "name": "low" },
{ "value": "high", "name": "high" }
]
}
]
},
"unresolved": {
"configOptions": [
{
"id": "model",
"name": "Model",
"category": "model",
"type": "select",
"currentValue": "fake/wide",
"options": [
{ "value": "fake/wide", "name": "Wide", "description": "fake/wide" },
{ "value": "fake/narrow", "name": "Narrow", "description": "fake/narrow" },
{
"value": "apple/on-device",
"name": "Apple AFM 3 Core Advanced",
"description": "apple/on-device"
}
]
},
{
"id": "thinking",
"name": "Thinking",
"category": "thought_level",
"type": "select",
"currentValue": "off",
"options": [
{ "value": "off", "name": "Off" },
{ "value": "auto", "name": "Auto", "description": "Auto-detect per prompt" }
]
}
]
}
}
@@ -2,48 +2,11 @@ import { afterEach, describe, expect, it } from 'vitest'
import type { AgentJournalRenderItem } from '../../../shared/agent-session/journal/agent-session-journal-types'
import { closeProviderTimelineRigs } from '../../native-chat/agent-session-timeline/provider-timeline-assembler-test-support'
import { OPENCODE_ACP_DIALECT } from '../acp-dialects/opencode-dialect'
import { AcpTimelineTranslator } from './acp-timeline-translator'
import {
openAcpFixtureRig,
readAcpFixture,
type AcpFixtureFrame
} from './testing/acp-timeline-fixture.test-support'
import { openAcpFixtureRig } from './testing/acp-timeline-fixture.test-support'
afterEach(closeProviderTimelineRigs)
type Fixture = Awaited<ReturnType<typeof openAcpFixtureRig>>
function subagents(fixture: Fixture) {
return fixture.subagents().map((view) => ({
id: view.providerId,
label: view.description,
membership: view.membership,
outcome: view.outcome
}))
}
function tools(rows: AgentJournalRenderItem[]) {
return rows.flatMap((row) =>
row.body.kind === 'tool-call'
? [{ agentId: row.agentId, name: row.body.name, state: row.body.state }]
: []
)
}
function commands(rows: AgentJournalRenderItem[], agentId: string) {
return rows.flatMap((row) =>
row.agentId === agentId && row.body.kind === 'tool-call'
? [
typeof row.body.input === 'object' &&
row.body.input !== null &&
'command' in row.body.input
? row.body.input.command
: undefined
]
: []
)
}
function texts(rows: AgentJournalRenderItem[], agentId: string) {
return rows.flatMap((row) =>
row.agentId === agentId && row.body.kind === 'message'
@@ -52,146 +15,9 @@ function texts(rows: AgentJournalRenderItem[], agentId: string) {
)
}
/** Frames through the `index`-th one that matches. */
function through(frames: AcpFixtureFrame[], matches: (text: string) => boolean, index = 0) {
const ends = frames.flatMap((frame, at) => (matches(JSON.stringify(frame)) ? [at] : []))
return frames.slice(0, ends[index] + 1)
}
const opened = () => openAcpFixtureRig(OPENCODE_ACP_DIALECT, { journalOwnership: true })
describe('OpenCode subagents through the shared timeline', () => {
it('a subagent is one record that completes, and its own work is filed under it', async () => {
const fixture = await opened()
const rows = await fixture.feed(await readAcpFixture('opencode-v2-subagent-foreground'))
expect(subagents(fixture)).toEqual([
{ id: 'session-2', label: 'Run sleep command', membership: 'settled', outcome: 'succeeded' }
])
expect(tools(rows)).toEqual([
{ agentId: undefined, name: 'subagent', state: 'completed' },
{ agentId: 'session-2', name: 'shell', state: 'completed' }
])
expect(texts(rows, 'session-2')).toEqual(['DONE'])
})
it('a background subagent stays open after the turn ends and its later work still lands', async () => {
const fixture = await opened()
const frames = await readAcpFixture('opencode-v2-subagent-background')
const turn = through(frames, (text) => text.includes('"stopReason"'))
await fixture.feed(turn)
expect(subagents(fixture)).toMatchObject([{ id: 'session-2', membership: 'live' }])
// Commit each frame before the next, so later tool updates read their placement back.
for (const frame of frames.slice(turn.length)) {
await fixture.feed([frame])
}
const rows = await fixture.rig.rows()
expect(subagents(fixture)).toMatchObject([
{ id: 'session-2', membership: 'settled', outcome: 'succeeded' }
])
expect(tools(rows).filter((tool) => tool.agentId === 'session-2')).toEqual([
{ agentId: 'session-2', name: 'shell', state: 'completed' }
])
expect(commands(rows, 'session-2')).toEqual(['sleep 20'])
expect(texts(rows, 'session-2')).toEqual(['DONE'])
})
it('a subagent command that spans the end of the turn keeps running and then completes', async () => {
const fixture = await opened()
const frames = await readAcpFixture('opencode-v2-subagent-background')
const ending = frames.findIndex((frame) => JSON.stringify(frame).includes('"stopReason"'))
const childStart = frames.findIndex((frame) =>
JSON.stringify(frame).includes('"sessionUpdate":"tool_call_update","toolCallId":"session-2:')
)
// The turn ends while the subagent's command runs.
const spanning = [
...frames.slice(0, ending),
...frames.slice(ending + 1, childStart + 1),
frames[ending],
...frames.slice(childStart + 1)
]
const rows = await fixture.feed(spanning)
expect(tools(rows).filter((tool) => tool.agentId === 'session-2')).toEqual([
{ agentId: 'session-2', name: 'shell', state: 'completed' }
])
})
it('Stop interrupts the subagent and settles its running command', async () => {
const fixture = await opened()
const rows = await fixture.feed(await readAcpFixture('opencode-v2-subagent-stop'))
expect(subagents(fixture)).toMatchObject([
{ id: 'session-2', membership: 'settled', outcome: 'cancelled' }
])
expect(
tools(rows).filter((tool) => tool.agentId === 'session-2' && tool.state === 'running')
).toEqual([])
})
it('closing a background subagent after the turn ends interrupts it and leaves nothing running', async () => {
const fixture = await opened()
const frames = await readAcpFixture('opencode-v2-subagent-close')
const closing = frames.findIndex((frame) => frame.message.method === 'session/close')
await fixture.feed(frames.slice(0, closing))
expect(fixture.lane().children.live()).toEqual(['session-2'])
const rows = await fixture.feed(frames.slice(closing))
expect(subagents(fixture)).toMatchObject([
{ id: 'session-2', membership: 'settled', outcome: 'cancelled' }
])
expect(fixture.lane().children.live()).toEqual([])
expect(
tools(rows).filter((tool) => tool.agentId === 'session-2' && tool.state === 'running')
).toEqual([])
})
it('a follow-up to a finished subagent reopens it until its second run ends', async () => {
const fixture = await opened()
const frames = await readAcpFixture('opencode-v2-subagent-rerun')
const running = (text: string) => text.includes('"status":"running"')
const firstRun = through(frames, running, 1)
await fixture.feed(firstRun)
expect(subagents(fixture)).toMatchObject([{ id: 'session-2', membership: 'live' }])
const rows = await fixture.feed(frames.slice(firstRun.length))
expect(subagents(fixture)).toMatchObject([
{ id: 'session-2', membership: 'settled', outcome: 'succeeded' }
])
expect(texts(rows, 'session-2')).toEqual(['FIRST', 'SECOND'])
})
it('a long-running subagent keeps one message open per channel, however many steps it takes', () => {
// OpenCode starts new message ids each step and never appends to an earlier step's.
const mostOpen = (steps: number) => {
const lane = new AcpTimelineTranslator({
sessionId: 'session-1',
dialect: OPENCODE_ACP_DIALECT
})
const open = new Set<string>()
let most = 0
const report = (body: Record<string, unknown>) => {
for (const event of lane.notification(
'opencode/session/child_update',
{ ...CHILD, ...body },
1000
)) {
const key = 'item' in event ? JSON.stringify(event.item) : ''
if (event.type === 'text.delta') {
open.add(key)
} else if (event.type === 'text.close') {
open.delete(key)
}
most = Math.max(most, open.size)
}
}
report({ type: 'status', status: 'running' })
for (let step = 0; step < steps; step++) {
report(chunk('agent_thought_chunk', `msg-${step}:reasoning:0`, 'thinking'))
report(chunk('agent_message_chunk', `msg-${step}`, `step ${step}`))
}
report({ type: 'status', status: 'completed' })
return { most, left: open.size }
}
expect(mostOpen(10)).toEqual({ most: 2, left: 0 })
expect(mostOpen(100)).toEqual({ most: 2, left: 0 })
})
it('a reply that resumes after the subagent thinks or runs a tool keeps all of its text', async () => {
const fixture = await opened()
const lane = fixture.lane()
@@ -1,74 +0,0 @@
// Shared lifecycle contract every EnvironmentProviderDriver must pass; drivers call it from their test.
import { describe, expect, it } from 'vitest'
import {
getEphemeralVmRecipeResultConnection,
getEphemeralVmRecipeResultProjectRoot,
isAbsoluteRuntimePath
} from '../../shared/ephemeral-vm-recipes'
import type {
EnvironmentCreateSpec,
EnvironmentProviderDriver,
EnvironmentReady
} from './environment-provider-driver'
export type EnvironmentProviderDriverContractHarness = {
driver: EnvironmentProviderDriver
/** Ids the backing provider still holds; destroy must leave none behind. */
liveEnvironmentIds: () => Promise<readonly string[]>
/** A fresh driver over the same backing provider, as after an Orca restart. */
reopenDriver: () => EnvironmentProviderDriver
}
// Handles are persisted with the runtime record, so later calls only ever see the JSON form.
function reload<T>(handle: T): T {
return JSON.parse(JSON.stringify(handle))
}
export function runEnvironmentProviderDriverContract(
name: string,
makeHarness: () => Promise<EnvironmentProviderDriverContractHarness>,
spec: (workspaceId: string) => EnvironmentCreateSpec = (workspaceId) => ({ workspaceId })
): void {
describe(`${name} satisfies the environment provider driver contract`, () => {
function expectReady(driver: EnvironmentProviderDriver, ready: EnvironmentReady): void {
expect(ready.handle.driverId).toBe(driver.id)
expect(ready.handle.environmentId).not.toBe('')
expect(['orca-server', 'ssh']).toContain(
getEphemeralVmRecipeResultConnection(ready.result).type
)
expect(isAbsoluteRuntimePath(getEphemeralVmRecipeResultProjectRoot(ready.result))).toBe(true)
if (driver.capabilities.maxSessionMs !== null) {
expect(ready.session.deadlineAt).not.toBeNull()
expect(ready.session.deadlineAt! - ready.session.startedAt).toBeLessThanOrEqual(
driver.capabilities.maxSessionMs
)
}
}
it('creates, suspends, resumes and destroys one environment without leaking it', async () => {
const { driver, liveEnvironmentIds, reopenDriver } = await makeHarness()
const phases: string[] = []
const created = await driver.create(spec('ws-contract-1'), {
onProgress: ({ phase }) => phases.push(phase)
})
expectReady(driver, created)
expect(phases.length).toBeGreaterThan(0)
expect(await liveEnvironmentIds()).toContain(created.handle.environmentId)
expect((await driver.status(created.handle)).status).toBe('running')
const suspended = await driver.suspend(created.handle)
expect(suspended.status).toBe('suspended')
expect((await driver.status(created.handle)).status).toBe('suspended')
const resumed = await driver.resume(created.handle)
expectReady(driver, resumed)
expect(resumed.handle.environmentId).toBe(created.handle.environmentId)
expect((await driver.status(created.handle)).status).toBe('running')
await driver.destroy(created.handle)
expect(await liveEnvironmentIds()).not.toContain(created.handle.environmentId)
// Idempotent: cleanup retries after a lost response or a restart must not fail.
await reopenDriver().destroy(reload(created.handle))
})
})
}
@@ -1,68 +0,0 @@
import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach } from 'vitest'
import { encodePairingOffer } from '../../shared/remote-runtime/pairing/pairing'
import { PAIRING_OFFER_VERSION } from '../../shared/remote-runtime/pairing/mobile-relay-pairing-offer'
import type { OrcaVmRecipe } from '../../shared/orca-yaml-hook-types'
import { runEnvironmentProviderDriverContract } from './environment-provider-driver-contract.test-support'
import { ShellRecipeDriver } from './shell-recipe-driver'
const tmpRoots: string[] = []
afterEach(() => {
for (const root of tmpRoots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
})
// A recipe whose "VMs" are files in a state dir, so the contract can see what destroy left behind.
function makeFileBackedRecipe(): { repoPath: string; stateDir: string; recipe: OrcaVmRecipe } {
const repoPath = mkdtempSync(join(tmpdir(), 'orca-shell-recipe-driver-'))
tmpRoots.push(repoPath)
const stateDir = join(repoPath, 'vms')
mkdirSync(stateDir)
const pairingCode = encodePairingOffer({
v: PAIRING_OFFER_VERSION,
endpoint: 'wss://vm.example.com',
deviceToken: 'token',
publicKeyB64: 'public-key'
})
const vmFile = `require('node:path').join(${JSON.stringify(stateDir)}, process.env.ORCA_VM_INSTANCE_ID)`
const printResult = `console.log(JSON.stringify({ schemaVersion: 1, pairingCode: ${JSON.stringify(pairingCode)}, projectRoot: '/workspace/repo' }))`
const scripts = {
create: `require('node:fs').writeFileSync(${vmFile}, 'running'); ${printResult}`,
suspend: `require('node:fs').writeFileSync(${vmFile}, 'suspended')`,
// Like a real provider: a destroyed VM cannot be woken.
resume: `const fs = require('node:fs'); if (!fs.existsSync(${vmFile})) process.exit(1); fs.writeFileSync(${vmFile}, 'running'); ${printResult}`,
destroy: `require('node:fs').rmSync(${vmFile}, { force: true })`
}
const commands = Object.fromEntries(
Object.entries(scripts).map(([mode, source]) => {
const scriptPath = join(repoPath, `${mode}.js`)
writeFileSync(scriptPath, source)
return [mode, `"${process.execPath}" "${scriptPath}"`]
})
)
return {
repoPath,
stateDir,
recipe: {
id: 'file-backed',
name: 'File-backed VM',
create: commands.create,
suspend: commands.suspend,
resume: commands.resume,
destroy: commands.destroy
}
}
}
runEnvironmentProviderDriverContract('ShellRecipeDriver', async () => {
const { repoPath, stateDir, recipe } = makeFileBackedRecipe()
return {
driver: new ShellRecipeDriver(recipe, repoPath),
liveEnvironmentIds: async () => readdirSync(stateDir),
reopenDriver: () => new ShellRecipeDriver(recipe, repoPath)
}
})
@@ -1,115 +0,0 @@
// A pane's saved recipe follows main's word on its launch, not the session the agent names.
import { describe, expect, it, vi } from 'vitest'
import type { AgentStatusIpcPayload } from '../../../../shared/agent-status/contracts/agent-status-ipc-payload'
import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session/lifecycle/agent-session-resume'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import type { LayoutApplyOutcome } from '../../../../shared/workspace-layout/workspace-layout-command-types'
import { applyCommandToSession } from '../../../persistence/terminal-topology/terminal-pane-admission'
vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
// Preserve side-effect order: mocks, lifecycle, fixtures.
await import('../../orca-runtime-tests/runtime-test-module-registration.spec')
const { OrcaRuntimeService } = await import('../../orca-runtime')
await import('../../orca-runtime-tests/orca-runtime-test-lifecycle.spec')
const {
HEADLESS_LEAF_ID,
TEST_WORKTREE_ID,
makeRuntimeStoreWithWorkspaceSession,
makeWorkspaceSessionWithHeadlessTerminal
} = await import('../../orca-runtime-tests/orca-runtime-test-fixtures.spec')
const PANE_KEY = `host-tab:${HEADLESS_LEAF_ID}`
const PTY_ID = 'persisted-pty'
const SESSION = { key: 'session_id' as const, id: 'claude-session-1' }
const RECIPE = { agentArgs: '--dangerously-skip-permissions', agentEnv: {} }
function savedRecord(origin: SleepingAgentSessionRecord['origin']): SleepingAgentSessionRecord {
return {
paneKey: PANE_KEY,
tabId: 'host-tab',
worktreeId: TEST_WORKTREE_ID,
agent: 'claude',
providerSession: SESSION,
prompt: '',
state: 'done',
capturedAt: 5,
updatedAt: 5,
origin,
connectionId: null,
launchConfig: RECIPE
}
}
function claudeRow(): AgentStatusIpcPayload {
return {
paneKey: PANE_KEY,
worktreeId: TEST_WORKTREE_ID,
agentType: 'claude',
providerSession: SESSION,
state: 'working',
prompt: 'keep going',
receivedAt: 10,
stateStartedAt: 10,
connectionId: null
}
}
function makeRuntime(saved: SleepingAgentSessionRecord) {
const { runtimeStore, getSession, setSession } = makeRuntimeStoreWithWorkspaceSession(
makeWorkspaceSessionWithHeadlessTerminal({
sleepingAgentSessionsByPaneKey: { [PANE_KEY]: saved }
})
)
const applyLayoutCommand = vi.fn(
async (
command: Parameters<typeof applyCommandToSession>[2],
hostIds: ExecutionHostId[]
): Promise<LayoutApplyOutcome> => {
for (const hostId of hostIds) {
const change = applyCommandToSession(hostId, getSession(), command)
if (change.session) {
setSession(change.session)
}
}
return { ok: true, result: {} }
}
)
const runtime = new OrcaRuntimeService(Object.assign(runtimeStore, { applyLayoutCommand }))
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
const binding = { tabId: 'host-tab', leafId: HEADLESS_LEAF_ID, incarnationId: 'p1' }
return {
runtime,
binding,
getSession,
savedAfterStatus: async () => {
runtime.recordHookStatusResume(claudeRow())
await runtime['hookStatusResumeWrites']
return getSession().sleepingAgentSessionsByPaneKey?.[PANE_KEY]
}
}
}
describe('a pane saved recipe and the launch running in it', () => {
it('gives an agent typed into a fresh shell none of the session earlier recipe', async () => {
const { runtime, binding, savedAfterStatus } = makeRuntime(savedRecord('live'))
runtime.registerPty(PTY_ID, TEST_WORKTREE_ID, null, { ...binding, freshSpawn: true })
const saved = await savedAfterStatus()
expect(saved?.state).toBe('working')
expect(saved?.launchConfig).toBeUndefined()
})
it('keeps a sleep snapshot for its wake without lending its recipe to a typed resume', async () => {
const { runtime, binding, getSession, savedAfterStatus } = makeRuntime(
savedRecord('worktree-sleep')
)
runtime.registerPty(PTY_ID, TEST_WORKTREE_ID, null, {
...binding,
agentLaunchAuthority: { launchToken: 'launch-1', launchAgent: 'claude', launchConfig: RECIPE }
})
runtime.emitDaemonPtyTransientFact(PTY_ID, { kind: 'command-finished', exitCode: 0 })
expect(await savedAfterStatus()).toEqual(savedRecord('worktree-sleep'))
Object.assign(runtime, { getAgentProviderSessionSnapshotFn: () => [claudeRow()] })
expect(runtime.quitResumeRecordsFor(getSession())).toEqual([])
})
})
@@ -59,36 +59,6 @@ describe('VadPauseSegmenter', () => {
expect(framesOf(await segmenter.flush())).toEqual([[0.11, 0.12, 0.13, 0.14, 0.7, 0.6]])
})
it('keeps a phrase whole across a pause shorter than 0.6 s', async () => {
const segmenter = new VadPauseSegmenter(fakeVad(), RATE, FRAME)
const ready = await segmenter.push(
audio([0.9, 0.9, 0.2, 0.2, 0.2, 0.2, 0.2, 0.9, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2])
)
expect(framesOf(ready)).toEqual([
[0.9, 0.9, 0.2, 0.2, 0.2, 0.2, 0.2, 0.9, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2]
])
})
it('holds a click shorter than 0.25 s of speech until real speech joins it, without opening a phrase', async () => {
const segmenter = new VadPauseSegmenter(fakeVad(), RATE, FRAME)
expect(await segmenter.push(audio([0.9, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2]))).toEqual([])
expect(segmenter.phraseOpen).toBe(false)
expect(await segmenter.push(audio([0.8, 0.8]))).toEqual([])
expect(segmenter.phraseOpen).toBe(true)
const ready = await segmenter.push(audio([0.2, 0.2, 0.2, 0.2, 0.2, 0.2]))
expect(framesOf(ready)).toEqual([
[0.9, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.8, 0.8, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2]
])
expect(segmenter.phraseOpen).toBe(false)
})
it('returns nothing for a dictation that never had speech', async () => {
const segmenter = new VadPauseSegmenter(fakeVad(), RATE, FRAME)
expect(await segmenter.push(audio([0.1, 0.2, 0.3, 0.1]))).toEqual([])
expect(await segmenter.flush()).toEqual([])
expect(segmenter.heldSamples).toBe(0)
})
it('keeps every held sample when the detector fails mid-dictation', async () => {
const vad = fakeVad()
const segmenter = new VadPauseSegmenter(
@@ -1,151 +0,0 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AutomationRun } from '../../../../../shared/automations/history/automation-run'
import { makeAutomationListRow, makeRun } from '../automations-page-fixtures'
import * as dispatch from '../hosts/automation-row-action-dispatch'
import { useAutomationRunsDashboard } from './use-automation-runs-dashboard'
vi.mock('../hosts/automation-row-action-dispatch', async (importOriginal) => ({
...(await importOriginal<typeof dispatch>()),
dispatchAutomationRunHistoryPage: vi.fn(),
dispatchAutomationRunHistory: vi.fn()
}))
const pageSpy = vi.mocked(dispatch.dispatchAutomationRunHistoryPage)
const selectedSpy = vi.mocked(dispatch.dispatchAutomationRunHistory)
const row = makeAutomationListRow()
const rows = [row]
const context = { capturedOwners: new Map(), authority: { kind: 'desktop' as const } }
const legacyTarget = () => null
const authorityForRow = () => ({ kind: 'desktop' as const })
type DashboardResult = ReturnType<typeof useAutomationRunsDashboard>
type PageResult = Awaited<ReturnType<typeof dispatch.dispatchAutomationRunHistoryPage>>
let root: Root
let container: HTMLDivElement
let latest: DashboardResult | null = null
const frames: { enabled: boolean; ids: string[]; loading: boolean }[] = []
function Harness({ enabled }: { enabled: boolean; detail?: boolean }): null {
latest = useAutomationRunsDashboard({
enabled,
rows,
context,
legacyTarget,
authorityForRow,
reloadToken: 0
})
frames.push({
enabled,
ids: latest.entries.map((entry) => entry.run.id),
loading: latest.loading
})
return null
}
async function render(enabled: boolean, detail: boolean = false): Promise<void> {
await act(async () => {
root.render(<Harness enabled={enabled} detail={detail} />)
})
}
function entries(): DashboardResult['entries'] {
if (!latest) {
throw new Error('Expected the mounted dashboard result')
}
return latest.entries
}
function fullRun(id: string, scheduledFor: number = 10): AutomationRun {
return makeRun({
id,
scheduledFor,
title: `Run ${id}`,
trigger: 'manual',
outputSnapshot: {
format: 'plain_text',
content: `Terminal output for ${id}\n`,
capturedAt: 20,
truncated: false
}
})
}
function pendingPage() {
let complete: ((result: PageResult) => void) | null = null
const promise = new Promise<PageResult>((resolve) => {
complete = resolve
})
return {
promise,
resolve: (result: PageResult): void => {
if (!complete) {
throw new Error('Expected a pending history request')
}
complete(result)
}
}
}
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
document.body.append(container)
root = createRoot(container)
frames.length = 0
})
afterEach(() => {
act(() => root.unmount())
container.remove()
latest = null
frames.length = 0
pageSpy.mockReset()
selectedSpy.mockReset()
})
describe('automation dashboard run payload ownership', () => {
it('preserves the first duplicate, chronological order and cursor retirement when paging', async () => {
pageSpy
.mockResolvedValueOnce({
ok: true,
value: { runs: [fullRun('head', 20)], nextCursor: 'next-page' }
})
.mockResolvedValueOnce({
ok: true,
value: { runs: [fullRun('head', 30), fullRun('older', 10)], nextCursor: null }
})
await render(true)
await act(async () => latest?.loadMore())
expect(entries().map((entry) => [entry.run.id, entry.run.scheduledFor])).toEqual([
['head', 20],
['older', 10]
])
expect(pageSpy.mock.calls[1]?.[2].cursor).toBe('next-page')
expect(latest?.hasMore).toBe(false)
})
it('ignores a closed request that finishes after a fresh re-entry', async () => {
const pending = pendingPage()
pageSpy.mockReturnValueOnce(pending.promise).mockResolvedValueOnce({
ok: true,
value: { runs: [fullRun('fresh')], nextCursor: null }
})
await render(true)
await render(false)
await render(true)
await act(async () => {
pending.resolve({ ok: true, value: { runs: [fullRun('obsolete')], nextCursor: 'stale' } })
})
expect(entries().map((entry) => entry.run.id)).toEqual(['fresh'])
expect(latest?.hasMore).toBe(false)
})
})
@@ -1,131 +0,0 @@
// @vitest-environment happy-dom
import { act, useCallback } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AutomationAuthorityRef } from '../../../../../shared/automations/ownership/automation-owner-ref'
import type { AutomationRunsPage } from '../../../../../shared/automations/history/automation-run'
import type { AutomationHostTarget } from '../hosts/automation-host-client'
import { makeAutomationListRow, makeRun } from '../automations-page-fixtures'
import * as dispatch from '../hosts/automation-row-action-dispatch'
import { useAutomationRunsDashboard } from './use-automation-runs-dashboard'
vi.mock('../hosts/automation-row-action-dispatch', async (importOriginal) => ({
...(await importOriginal<typeof dispatch>()),
dispatchAutomationRunHistoryPage: vi.fn()
}))
const historySpy = vi.mocked(dispatch.dispatchAutomationRunHistoryPage)
const PAGES: Record<string, AutomationRunsPage> = {
head: { runs: [makeRun({ id: 'run-2', createdAt: 2 })], nextCursor: '2:run-2' },
'2:run-2': { runs: [makeRun({ id: 'run-1', createdAt: 1 })], nextCursor: null }
}
const row = makeAutomationListRow()
const rows = [row]
const context = { capturedOwners: new Map(), authority: { kind: 'desktop' as const } }
type DashboardResult = ReturnType<typeof useAutomationRunsDashboard>
let container: HTMLDivElement
let root: Root
let latest: DashboardResult | null = null
type HarnessProps = {
enabled: boolean
authority?: AutomationAuthorityRef
target?: AutomationHostTarget | null
currentRows?: typeof rows
}
function Harness({ enabled, authority, target, currentRows = rows }: HarnessProps): null {
latest = useAutomationRunsDashboard({
enabled,
rows: currentRows,
context,
legacyTarget: useCallback(() => target ?? null, [target]),
authorityForRow: useCallback(() => authority ?? { kind: 'desktop' }, [authority]),
reloadToken: 0
})
return null
}
async function render(enabled: boolean, props: Omit<HarnessProps, 'enabled'> = {}): Promise<void> {
await act(async () => {
root.render(<Harness enabled={enabled} {...props} />)
})
}
beforeEach(() => {
historySpy.mockImplementation(async (_context, _row, options) => ({
ok: true,
value: PAGES[options.cursor ?? 'head']
}))
container = document.createElement('div')
document.body.append(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
latest = null
historySpy.mockReset()
})
describe('useAutomationRunsDashboard', () => {
it('keeps the cursor when a load more fails so the page stays retryable', async () => {
await render(true)
historySpy.mockImplementationOnce(async () => ({
ok: false,
notice: { message: 'offline', recovery: null, severity: 'failure' }
}))
await act(async () => latest?.loadMore())
expect(latest?.entries.map((entry) => entry.run.id)).toEqual(['run-2'])
expect(latest?.hasMore).toBe(true)
await act(async () => latest?.loadMore())
expect(historySpy.mock.calls.at(-1)?.[2].cursor).toBe('2:run-2')
expect(latest?.entries.map((entry) => entry.run.id)).toEqual(['run-2', 'run-1'])
})
it('reloads from the head when the authority is re-paired', async () => {
const paired = (pairingRevision: number): AutomationAuthorityRef => ({
kind: 'runtime',
environmentId: 'env-1',
pairingRevision
})
await render(true, { authority: paired(1) })
await act(async () => latest?.loadMore())
expect(latest?.entries).toHaveLength(2)
historySpy.mockClear()
await render(true, { authority: paired(2) })
expect(historySpy.mock.calls.at(-1)?.[2].cursor).toBeUndefined()
expect(latest?.entries.map((entry) => entry.run.id)).toEqual(['run-2'])
})
it('reloads from the head when an uncaptured row moves to another fallback target', async () => {
await render(true, { target: { kind: 'local' } })
await act(async () => latest?.loadMore())
expect(latest?.entries).toHaveLength(2)
historySpy.mockClear()
await render(true, { target: { kind: 'environment', environmentId: 'env-1' } })
expect(historySpy.mock.calls.at(-1)?.[2].cursor).toBeUndefined()
expect(latest?.entries.map((entry) => entry.run.id)).toEqual(['run-2'])
})
it('updates the host label on existing run history when the host row changes', async () => {
await render(true, { currentRows: [{ ...row, hostLabel: 'previous name' }] })
expect(latest?.entries[0]?.run.id).toBe('run-2')
await render(true, { currentRows: [{ ...row, hostLabel: 'new name' }] })
expect(latest?.entries[0]?.run.id).toBe('run-2')
expect(latest?.entries[0]?.row.hostLabel).toBe('new name')
expect(latest?.entries[0]?.searchText).toContain('new name')
})
})
@@ -7,10 +7,7 @@ import type {
AgentSessionStatusEvent,
AgentSessionStatusSummary
} from '../../../../../../shared/agent-session/agent-session-wire'
import {
LOCAL_EXECUTION_HOST_ID,
type ExecutionHostId
} from '../../../../../../shared/execution-host'
import type { Tab } from '../../../../../../shared/tab-types'
import type { AppState } from '@/store/types'
@@ -27,13 +24,11 @@ const mocks = vi.hoisted(() => {
subscribeStatus: ReturnType<typeof vi.fn>
supportsCapability: ReturnType<typeof vi.fn>
unsubscribe: ReturnType<typeof vi.fn>
applyStatus: ReturnType<typeof vi.fn<(update: unknown) => void>>
} = {
store: null,
subscribeStatus: vi.fn(),
supportsCapability: vi.fn(),
unsubscribe: vi.fn(),
applyStatus: vi.fn()
unsubscribe: vi.fn()
}
return hoisted
})
@@ -41,19 +36,6 @@ const mocks = vi.hoisted(() => {
vi.mock('@/store', async () => {
const { createTestStore } = await import('@/store/testing/app-store-test-fixture')
const useAppStore = createTestStore()
const { transactAgentStatuses } = useAppStore.getState()
useAppStore.setState({
transactAgentStatuses: (operation) =>
transactAgentStatuses((transaction) =>
operation({
...transaction,
apply: (update) => {
mocks.applyStatus(update)
return transaction.apply(update)
}
})
)
})
mocks.store = useAppStore
return { useAppStore }
})
@@ -103,16 +85,6 @@ function statusFor(tabId: string, sessionId: string) {
]
}
function openChat(id: string, sessionId: string, executionHostId?: ExecutionHostId): void {
useAppStore.getState().createUnifiedTab('wt-1', 'agent-session', {
id,
entityId: sessionId,
agentSessionAgent: 'codex',
label: 'Codex Chat',
...(executionHostId ? { executionHostId } : {})
})
}
// Real tab actions annotate their writes, so these reach the bridge's addressed reconciliation.
describe('StructuredAgentSessionStatusBridge with tab actions', () => {
beforeEach(() => {
@@ -136,54 +108,6 @@ describe('StructuredAgentSessionStatusBridge with tab actions', () => {
resetStructuredAgentSessionStatusFeedsForTests()
})
it('removes a closed chat status and keeps an unrelated tab write quiet', async () => {
render(<StructuredAgentSessionStatusBridge />)
act(() => openChat('chat-1', 'session-1', LOCAL_EXECUTION_HOST_ID))
await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce())
act(() =>
feed().emit({ type: 'snapshot', sessions: [summary({ status: 'working', updatedAt: 5 })] })
)
expect(statusFor('chat-1', 'session-1')).toEqual(
expect.objectContaining({ state: 'working', updatedAt: 5 })
)
act(() => useAppStore.getState().setTabLabel('chat-1', 'Renamed'))
expect(statusFor('chat-1', 'session-1')).toEqual(
expect.objectContaining({ state: 'working', terminalTitle: 'Renamed' })
)
act(() => useAppStore.getState().closeUnifiedTab('chat-1'))
expect(statusFor('chat-1', 'session-1')).toBeUndefined()
})
it('retires the old pane when a tab is rebound to another session and ignores its late tick', async () => {
render(<StructuredAgentSessionStatusBridge />)
act(() => openChat('chat-1', 'session-1', LOCAL_EXECUTION_HOST_ID))
await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce())
act(() => feed().emit({ type: 'snapshot', sessions: [summary()] }))
expect(statusFor('chat-1', 'session-1')).toBeDefined()
act(() => {
useAppStore.getState().closeUnifiedTab('chat-1')
openChat('chat-1', 'session-2', LOCAL_EXECUTION_HOST_ID)
})
// The last chat's close released the host feed, so the rebound chat subscribes afresh.
await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledTimes(2))
act(() =>
feed(-1).emit({
type: 'snapshot',
sessions: [summary({ sessionId: 'session-2', status: 'working', updatedAt: 9 })]
})
)
act(() =>
feed(-1).emit({ type: 'status', session: summary({ status: 'working', updatedAt: 10 }) })
)
expect(statusFor('chat-1', 'session-1')).toBeUndefined()
expect(statusFor('chat-1', 'session-2')).toEqual(
expect.objectContaining({ state: 'working', updatedAt: 9 })
)
})
it('rebinds an unstamped chat to its new owner host without changing membership', async () => {
useAppStore.setState({
unifiedTabsByWorktree: {
@@ -60,26 +60,6 @@ function dispatchMiddleMouseUp(target: HTMLElement): MouseEvent {
return event
}
function dispatchMiddleAuxClick(target: HTMLElement): MouseEvent {
const event = new MouseEvent('auxclick', { bubbles: true, button: 1, cancelable: true })
target.dispatchEvent(event)
return event
}
function dispatchNativePasteBeforeInput(target: HTMLElement): Event {
const event =
typeof InputEvent === 'function'
? new InputEvent('beforeinput', {
bubbles: true,
cancelable: true,
data: 'native',
inputType: 'insertFromPaste'
})
: new Event('beforeinput', { bubbles: true, cancelable: true })
target.dispatchEvent(event)
return event
}
function dispatchMiddleClick(target: HTMLElement): void {
dispatchMiddleMouseDown(target)
dispatchMiddleMouseUp(target)
@@ -127,7 +107,6 @@ afterEach(async () => {
document.body.replaceChildren()
setUserAgent(originalUserAgent)
vi.clearAllMocks()
vi.useRealTimers()
})
describe('usePrimarySelectionPaste', () => {
@@ -154,69 +133,4 @@ describe('usePrimarySelectionPaste', () => {
expect(currentTarget.value).toBe('current')
expect(document.activeElement).toBe(currentTarget)
})
it('owns Linux middle-click paste for editable DOM targets', async () => {
setUserAgent('Mozilla/5.0 (X11; Linux x86_64)')
readPrimarySelectionTextMock.mockResolvedValue('beta')
await renderProbe()
const textarea = appendTextarea('alpha omega')
textarea.focus()
textarea.setSelectionRange(6, 11)
let nativeBeforeInput!: Event
let mouseUp!: MouseEvent
let auxClick!: MouseEvent
await act(async () => {
dispatchMiddleMouseDown(textarea)
nativeBeforeInput = dispatchNativePasteBeforeInput(textarea)
mouseUp = dispatchMiddleMouseUp(textarea)
auxClick = dispatchMiddleAuxClick(textarea)
await flushPromises()
})
expect(nativeBeforeInput.defaultPrevented).toBe(true)
expect(mouseUp.defaultPrevented).toBe(true)
expect(auxClick.defaultPrevented).toBe(true)
expect(readPrimarySelectionTextMock).toHaveBeenCalledTimes(1)
expect(textarea.value).toBe('alpha beta')
})
it('does not suppress an unrelated document paste while the terminal window is armed', async () => {
setUserAgent('Mozilla/5.0 (X11; Linux x86_64)')
// Armed terminal suppression must leave unrelated form pastes intact.
consumeNativePasteMock.mockReturnValue(true)
await renderProbe()
const unrelatedTextarea = appendTextarea()
let nativeBeforeInput!: Event
const nativePaste = new Event('paste', { bubbles: true, cancelable: true })
await act(async () => {
nativeBeforeInput = dispatchNativePasteBeforeInput(unrelatedTextarea)
unrelatedTextarea.dispatchEvent(nativePaste)
await flushPromises()
})
expect(nativeBeforeInput.defaultPrevented).toBe(false)
expect(nativePaste.defaultPrevented).toBe(false)
// Check target before consuming; unrelated pastes must not burn the arm and permit a terminal double-paste.
expect(consumeNativePasteMock).not.toHaveBeenCalled()
})
it('does not keep middle-click ownership after the gesture window expires', async () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
readPrimarySelectionTextMock.mockResolvedValue('late')
await renderProbe()
const textarea = appendTextarea('unchanged')
textarea.focus()
dispatchMiddleMouseDown(textarea)
vi.setSystemTime(1_751)
const mouseUp = dispatchMiddleMouseUp(textarea)
await flushPromises()
expect(mouseUp.defaultPrevented).toBe(false)
expect(readPrimarySelectionTextMock).not.toHaveBeenCalled()
expect(textarea.value).toBe('unchanged')
})
})
+1 -59
View File
@@ -1,10 +1,5 @@
import { describe, expect, it } from 'vitest'
import {
createDictationDictionary,
dictationTermsFor,
dictationVocabulary
} from './dictation-dictionary'
import { createDictationTextPipeline } from './dictation-text-pipeline'
import { createDictationDictionary } from './dictation-dictionary'
const spell = (terms: Parameters<typeof createDictationDictionary>[0], text: string): string =>
createDictationDictionary(terms)!(text)
@@ -30,57 +25,4 @@ describe('createDictationDictionary', () => {
'Wrap it in useMemo and pass the onChange call back.'
)
})
it('replaces what the model hears instead, and fixes the spelling of the term itself', () => {
const terms = [{ term: 'kubectl', heardAs: 'Quebecal' }, { term: 'Orca' }, { term: 'pnpm' }]
expect(spell(terms, 'Use Quebecal to scale it.')).toBe('Use kubectl to scale it.')
expect(spell(terms, "Open or ca's settings and run PNPM.")).toBe(
"Open Orca's settings and run pnpm."
)
})
it('keeps a term with symbols whole when the model already wrote it', () => {
const terms = [{ term: 'C#' }, { term: '.NET' }]
expect(spell(terms, 'Port the C# app to .NET, not plan C.')).toBe(
'Port the C# app to .NET, not plan C.'
)
})
it('leaves longer words and words split by a sentence end alone', () => {
const terms = [{ term: 'React' }, { term: 'onChange' }]
expect(spell(terms, 'Reactive state. Turn it on. Change it.')).toBe(
'Reactive state. Turn it on. Change it.'
)
})
it('replaces a "heard as" inside text written without spaces', () => {
expect(spell([{ term: '光輝', heardAs: 'こうき' }], 'こうきさんに送って')).toBe(
'光輝さんに送って'
)
})
it('lets the repo name join its spoken words but never recase a single word', () => {
const oneWord = dictationTermsFor({}, { displayName: 'orca' })
const words = dictationTermsFor({}, { displayName: 'dd-scratch-repo' })
expect(createDictationDictionary(oneWord)).toBeNull()
expect(dictationVocabulary(oneWord)).toEqual(['orca'])
expect(spell(words, 'Open dd scratch repo now.')).toBe('Open dd-scratch-repo now.')
expect(dictationTermsFor({ dictationRepoNameHint: false }, { displayName: 'orca' })).toEqual([])
})
it("prefers a repo's spelling over a personal one", () => {
const terms = dictationTermsFor(
{ dictationTerms: [{ term: 'rpc' }] },
{ dictationTerms: [{ term: 'RPC' }] }
)
expect(spell(terms, 'Call the rpc.')).toBe('Call the RPC.')
})
})
describe('createDictationTextPipeline with terms', () => {
it('spells a lowercase term after a sentence-start filler goes', () => {
const clean = createDictationTextPipeline({ language: 'en' }, {}, [{ term: 'pnpm' }])!
expect(clean('Um, pnpm test.')).toBe('pnpm test.')
})
})
@@ -1,13 +1,10 @@
import { describe, expect, it } from 'vitest'
import { agentJournalItemKey } from '../../../src/shared/agent-session/journal/agent-session-journal-item-key'
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../src/shared/agent-session/journal/agent-session-journal-types'
import { HISTORY_PAGE_CONTENT_BUDGET_BYTES } from '../../../src/main/native-chat/agent-session-wire/publication/history/agent-session-history-page-bounds'
import {
createReleasedStopNoteRig,
noteId,
noteIdentity,
turnItemId,
scope,
ordinary,
unconfirmed,
type ScenarioPort
@@ -19,24 +16,7 @@ export function describeReleasedStopNoteProjection(port: ScenarioPort): void {
(ref) => {
const rig = createReleasedStopNoteRig(port, ref)
const { page, subscribe, events, reduce, render, turn, note, seed } = rig
it('renders the derived page with the interrupted turn outside the page', async () => {
await turn('interrupted')
await note()
const snapshot = rig.journal.snapshot()
const stored = rig.journal.itemBody(noteId)
const notePage = await page({ direction: 'tail', limit: 1 })
expect(notePage.items.map((item) => item.itemId)).toEqual([noteId])
const state = rig.client.reduceStructuredAgentSession(
rig.client.EMPTY_STRUCTURED_AGENT_SESSION,
{
type: 'history-page',
page: notePage
}
)
expect(render(state)).toMatchObject({ type: 'text', text: ordinary.text })
expect(state.items[0]).toEqual(snapshot.items.find((item) => item.itemId === noteId))
expect(rig.journal.itemBody(noteId)).toBe(stored)
})
it.each([false, true])(
'renders the same-revision note with a live turn end (lifecycle batch=%s)',
async (batch) => {
@@ -107,76 +87,6 @@ export function describeReleasedStopNoteProjection(port: ScenarioPort): void {
expect(render(state)).toEqual({ type: 'text', text: ordinary.text })
expect(rig.journal.cursor()).toEqual(held)
})
it('replays to a released desktop and pages a fresh snapshot back to the dependent note', async () => {
const before = await seed()
const held = rig.journal.cursor()
for (const clientMessageId of ['large-a', 'large-b']) {
await rig.journal.appendItem(
{ provider: 'orca', clientMessageId },
{
kind: 'message',
role: 'assistant',
blocks: [
{
type: 'text',
text: 'x'.repeat(Math.floor(HISTORY_PAGE_CONTENT_BUDGET_BYTES * 0.6))
}
]
},
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
)
}
await turn('interrupted')
const first = await page({ direction: 'after', cursor: held, limit: 3 })
expect(first.hasNewer).toBe(true)
const caughtUp = await subscribe(held)
expect(events(caughtUp).every((event) => event.type === 'batch')).toBe(true)
expect(reduce(before, caughtUp).cursor).toEqual(rig.journal.cursor())
const fresh = await subscribe(null)
let after = reduce(before, fresh)
expect(events(fresh)[0]).toMatchObject({ type: 'snapshot', page: { hasOlder: true } })
while (after.hasOlder && !after.items.some((item) => item.itemId === noteId)) {
const requestedCursor = { epoch: after.epoch!, sequence: after.items[0]!.sequence }
const older = await page({ direction: 'before', cursor: requestedCursor })
after = rig.client.reduceStructuredAgentSession(after, {
type: 'older-page',
requestedCursor,
page: older
})
}
expect(render(after)).toEqual({ type: 'text', text: ordinary.text })
expect(after.cursor).toEqual(rig.journal.cursor())
})
it('preserves removal when the operation note is re-keyed before the interrupted end', async () => {
const op = { provider: 'orca', clientMessageId: 'stop:operation-1' } as const
await rig.journal.appendItem(op, unconfirmed, {
fence: 1,
turnScope: AGENT_JOURNAL_THREAD_SCOPE
})
await turn('running')
const initial = await page({ direction: 'tail' })
const before = rig.client.reduceStructuredAgentSession(
rig.client.EMPTY_STRUCTURED_AGENT_SESSION,
{
type: 'history-page',
page: initial
}
)
const held = rig.journal.cursor()
await rig.journal.appendLifecycleBatch({
settlementId: 're-key',
fence: 1,
mutations: [
{ kind: 'tombstone', identity: op },
{ kind: 'item', identity: noteIdentity, body: unconfirmed, turnScope: scope }
]
})
await turn('interrupted')
const replies = await subscribe(held)
const after = reduce(before, replies)
expect(after.items.some((item) => item.itemId === agentJournalItemKey(op))).toBe(false)
expect(render(after)).toEqual({ type: 'text', text: ordinary.text })
})
}
)
}
-495
View File
@@ -1,495 +0,0 @@
import {
openSidebarProjectDialog,
openSidebarWorkspaceComposer
} from './helpers/sidebar-project-dialog'
import { execFileSync } from 'node:child_process'
import { mkdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { mkdtemp } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import type { ElectronApplication, Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { chooseOnboardingAgentSurface } from './helpers/onboarding-navigation'
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import {
countVisibleTerminalPanes,
splitActiveTerminalPane,
waitForActiveTerminalManager,
waitForPaneCount,
waitForPaneIdentitySnapshot
} from './helpers/terminal'
const tempRoots: string[] = []
const SORTABLE_TAB = '[data-testid="sortable-tab"]'
const TASK_SOURCES_HEADING = /Set up GitHub tasks|Connect your task sources/i
const WINDOWS_TERMINAL_HEADING = /Set Windows terminal defaults/i
const ONBOARDING_ADVANCE_LABEL = /^Continue\b|^Add your first project\b/
test.describe.configure({ mode: 'serial' })
test.afterAll(() => {
for (const root of tempRoots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
})
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
async function createGitRepo(prefix: string, name: string): Promise<string> {
// Resolve macOS /var symlinks like git does when registering repo.path.
const rootPath = realpathSync(await mkdtemp(path.join(os.tmpdir(), prefix)))
tempRoots.push(rootPath)
const repoPath = path.join(rootPath, name)
mkdirSync(repoPath, { recursive: true })
execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' })
execFileSync('git', ['config', 'user.email', 'e2e@test.local'], {
cwd: repoPath,
stdio: 'pipe'
})
execFileSync('git', ['config', 'user.name', 'E2E Test'], {
cwd: repoPath,
stdio: 'pipe'
})
writeFileSync(path.join(repoPath, 'README.md'), `# ${name}\n`)
execFileSync('git', ['add', 'README.md'], { cwd: repoPath, stdio: 'pipe' })
execFileSync('git', ['commit', '-m', 'Initial commit'], { cwd: repoPath, stdio: 'pipe' })
execFileSync('git', ['branch', '-M', 'main'], { cwd: repoPath, stdio: 'pipe' })
return repoPath
}
async function chooseFolderInNativeDialog(
electronApp: ElectronApplication,
folderPath: string
): Promise<void> {
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
await electronApp.evaluate(({ dialog }, selectedPath) => {
dialog.showOpenDialog = async () => ({
canceled: false,
filePaths: [selectedPath],
bookmarks: []
})
}, folderPath)
return
} catch (error) {
if (
attempt === 2 ||
!(error instanceof Error) ||
!error.message.includes('Execution context was destroyed')
) {
throw error
}
await new Promise((resolve) => setTimeout(resolve, 250))
}
}
}
function onboardingFooter(page: Page) {
return page
.locator('footer')
.filter({
has: page.getByRole('button', { name: /Back|Continue|Add your first project|Skip/i })
})
.first()
}
async function continueOnboarding(page: Page): Promise<void> {
await onboardingFooter(page).getByRole('button', { name: ONBOARDING_ADVANCE_LABEL }).click()
}
async function selectCodexAgent(page: Page): Promise<void> {
const codexButton = page.getByRole('button', { name: /^Codex\b/ })
const codexVisible = await codexButton
.first()
.waitFor({ state: 'visible', timeout: 1_000 })
.then(() => true)
.catch(() => false)
if (!codexVisible) {
await page.getByText(/Show \d+ more agents/).click()
}
await codexButton.first().click()
await expect(codexButton.first()).toHaveAttribute('aria-pressed', 'true')
}
async function chooseOppositeTheme(page: Page): Promise<void> {
await page.waitForFunction(
() =>
document.documentElement.classList.contains('dark') ||
document.documentElement.classList.contains('light')
)
const startingTheme = await page.evaluate(() =>
document.documentElement.classList.contains('dark') ? 'dark' : 'light'
)
const nextTheme = startingTheme === 'dark' ? 'light' : 'dark'
const tileName = nextTheme === 'light' ? /Bright & crisp/ : /Easy on the eyes/
await page.getByRole('radio', { name: tileName }).click()
await expect
.poll(
() =>
page.evaluate(() =>
document.documentElement.classList.contains('dark') ? 'dark' : 'light'
),
{ timeout: 5_000 }
)
.toBe(nextTheme)
}
async function chooseNotificationSound(page: Page): Promise<void> {
const soundSelect = page.getByRole('combobox').first()
await expect(soundSelect).toContainText(/System Default/i)
await soundSelect.click()
const dingOption = page.getByRole('option', { name: /^Ding$/i })
await expect(dingOption).toBeVisible()
await dingOption.press('Enter')
await expect(soundSelect).toContainText(/Ding/i)
}
async function continueThroughOptionalSetupToNotifications(page: Page): Promise<void> {
const taskSourcesVisible = await page
.getByRole('heading', { name: TASK_SOURCES_HEADING })
.waitFor({ state: 'visible', timeout: 1_000 })
.then(() => true)
.catch(() => false)
if (taskSourcesVisible) {
await continueOnboarding(page)
}
const windowsTerminalVisible = await page
.getByRole('heading', { name: WINDOWS_TERMINAL_HEADING })
.waitFor({ state: 'visible', timeout: 1_000 })
.then(() => true)
.catch(() => false)
if (windowsTerminalVisible) {
await continueOnboarding(page)
}
await expect(page.getByRole('heading', { name: /Set up notifications/i })).toBeVisible()
}
async function completeOnboardingIntoHome(page: Page): Promise<void> {
await continueOnboarding(page)
await expect(page.locator('[data-onboarding-overlay]')).toHaveCount(0)
await expect(page.getByRole('dialog', { name: /Add a project/i })).toHaveCount(0)
}
async function waitForRepoLoaded(page: Page, repoPath: string): Promise<void> {
await expect
.poll(
() =>
page.evaluate((targetPath) => {
const state = window.__store?.getState()
const repo = state?.repos.find((candidate) => candidate.path === targetPath)
if (!state || !repo) {
return false
}
return (state.worktreesByRepo[repo.id] ?? []).length > 0
}, repoPath),
{ timeout: 30_000, message: `repo did not load: ${repoPath}` }
)
.toBe(true)
}
async function expectProjectVisible(page: Page, repoPath: string): Promise<void> {
const repoName = path.basename(repoPath)
await expect(page.getByText(repoName, { exact: true }).first()).toBeVisible({ timeout: 15_000 })
}
async function addProjectFromSidebar(
page: Page,
electronApp: ElectronApplication,
repoPath: string
): Promise<void> {
await chooseFolderInNativeDialog(electronApp, repoPath)
await openSidebarProjectDialog(page)
const addDialog = page.getByRole('dialog', { name: /Add a project/i })
await expect(addDialog).toBeVisible()
await addDialog.getByRole('button', { name: /Browse folder/i }).click()
const confirmDialog = page.getByRole('dialog', { name: /^Add Project$/i })
const needsConfirmation = await confirmDialog
.waitFor({ state: 'visible', timeout: 2_000 })
.then(() => true)
.catch(() => false)
if (needsConfirmation) {
await confirmDialog.getByRole('button', { name: /^Add Project$/ }).click()
}
await waitForRepoLoaded(page, repoPath)
await expectProjectVisible(page, repoPath)
}
async function createWorkspace(page: Page, workspaceName: string): Promise<void> {
await openSidebarWorkspaceComposer(page)
const dialog = page.getByRole('dialog', { name: /Create (Workspace|Worktree)/i })
await expect(dialog).toBeVisible()
const nameInput = dialog.getByPlaceholder(/Type a name/i)
await expect(nameInput).toBeVisible()
await nameInput.fill(workspaceName)
await dialog.getByRole('button', { name: /Create (Workspace|Worktree)/i }).click()
await expect(dialog).toBeHidden({ timeout: 20_000 })
await expectActiveWorkspaceVisible(page, workspaceName)
}
async function expectActiveWorkspaceBelongsToRepo(
page: Page,
workspaceName: string,
repoPath: string
): Promise<void> {
await expectActiveWorkspaceVisible(page, workspaceName)
await expect
.poll(
() =>
page.evaluate(
({ targetRepoPath, targetWorkspaceName }) => {
const state = window.__store?.getState()
if (!state?.activeWorktreeId) {
return null
}
const repo = state.repos.find((candidate) => candidate.path === targetRepoPath)
if (!repo) {
return null
}
const activeWorktree = (state.worktreesByRepo[repo.id] ?? []).find(
(worktree) => worktree.id === state.activeWorktreeId
)
return activeWorktree?.displayName === targetWorkspaceName
},
{ targetRepoPath: repoPath, targetWorkspaceName: workspaceName }
),
{ timeout: 20_000, message: 'active workspace did not belong to the newly added project' }
)
.toBe(true)
}
async function expectActiveWorkspaceVisible(page: Page, workspaceName: string): Promise<void> {
const activeWorkspace = page
.locator('[role="option"][aria-current="page"]')
.filter({ hasText: new RegExp(escapeRegExp(workspaceName)) })
.first()
await expect(activeWorkspace).toBeVisible({ timeout: 20_000 })
}
async function countRenderedTabs(page: Page): Promise<number> {
return page.locator(SORTABLE_TAB).count()
}
async function renderedTabIds(page: Page): Promise<string[]> {
return page.locator(SORTABLE_TAB).evaluateAll((tabs) =>
tabs.flatMap((tab) => {
const tabId = tab.getAttribute('data-tab-id')
return tabId ? [tabId] : []
})
)
}
async function expectTerminalSurface(page: Page): Promise<void> {
const terminalSurface = page
.locator('[data-terminal-tab-id][data-terminal-layout-leaf-ids]:visible')
.first()
await expect(terminalSurface).toBeVisible({ timeout: 30_000 })
await expect(terminalSurface).not.toHaveAttribute('data-os-file-drop-owner')
await expect(terminalSurface.locator('.pane[data-leaf-id]').first()).toHaveAttribute(
'data-os-file-drop-owner',
''
)
}
async function waitForTerminalPaneManager(page: Page): Promise<void> {
await waitForPaneCount(page, 1, 30_000)
await waitForActiveTerminalManager(page, 30_000)
}
async function createTerminalTabThroughMenu(page: Page): Promise<void> {
const tabIdsBefore = await renderedTabIds(page)
await page.getByRole('button', { name: 'New tab' }).click({ force: true })
const newTerminalMenuItem = page.getByRole('menuitem', { name: /New Terminal/i }).first()
await newTerminalMenuItem.click({ force: true })
await expect.poll(() => countRenderedTabs(page), { timeout: 5_000 }).toBe(tabIdsBefore.length + 1)
const createdTabIds = (await renderedTabIds(page)).filter(
(tabId) => !tabIdsBefore.includes(tabId)
)
expect(createdTabIds, 'new terminal tab should render exactly one new tab').toHaveLength(1)
const createdTab = page.locator(`${SORTABLE_TAB}[data-tab-id="${createdTabIds[0]}"]`).first()
await expect(createdTab).toHaveAttribute('data-tab-title', /.+/)
await expectTerminalSurface(page)
}
async function splitTerminalPaneAndAssertIdentity(page: Page): Promise<void> {
const paneCountBefore = await countVisibleTerminalPanes(page)
await splitActiveTerminalPane(page, 'vertical')
await waitForPaneCount(page, paneCountBefore + 1)
const snapshot = await waitForPaneIdentitySnapshot(page, paneCountBefore + 1)
expect(snapshot.panes).toHaveLength(paneCountBefore + 1)
}
async function requestAgentSessionsTour(page: Page): Promise<void> {
await expect
.poll(
() =>
page.evaluate(() => {
const state = window.__store?.getState()
const splitTarget = document.querySelector(
'[data-contextual-tour-target="terminal-pane-split-target"], [data-contextual-tour-target="workspace-agent-terminal-tip"]'
)
const rect = splitTarget?.getBoundingClientRect()
return {
ready: state?.persistedUIReady === true,
onboardingHidden: state?.contextualToursOnboardingVisible === false,
noModal: state?.activeModal === 'none',
splitTargetMeasurable: Boolean(rect && rect.width > 0 && rect.height > 0)
}
}),
{ timeout: 30_000 }
)
.toEqual({
ready: true,
onboardingHidden: true,
noModal: true,
splitTargetMeasurable: true
})
await page.evaluate(() => {
window.__store
?.getState()
.requestContextualTour('workspace-agent-sessions', 'setup_guide_parallel_work', false, {
force: true
})
})
const newTabStep = page.getByRole('dialog', { name: 'New agents start here', exact: true })
await expect(newTabStep).toBeVisible()
await page
.locator(
'[data-workspace-surface-id][aria-hidden="false"] [data-contextual-tour-target="tab-create-menu"]'
)
.click()
await expect(page.getByRole('menu')).toBeVisible()
await expect(newTabStep).toBeHidden()
await expect(page.getByRole('dialog', { name: /Split a terminal pane/i })).toBeHidden()
await expect
.poll(() => page.evaluate(() => window.__store?.getState().activeContextualTourStepIndex))
.toBe(0)
await page.keyboard.press('Escape')
await expect(page.getByRole('menu')).toBeHidden()
const splitStep = page.getByRole('dialog', { name: /Split a terminal pane/i })
await expect(splitStep).toBeVisible()
await splitStep.getByRole('button', { name: 'Back', exact: true }).click()
await expect(newTabStep).toBeVisible()
await page
.locator(
'[data-workspace-surface-id][aria-hidden="false"] [data-contextual-tour-target="tab-create-menu"]'
)
.click()
await expect(newTabStep).toBeHidden()
await page.getByRole('menuitem', { name: /^New Terminal/ }).click()
await expect(page.getByRole('menu')).toBeHidden()
await expect(page.getByRole('dialog', { name: /Split a terminal pane/i })).toBeVisible()
}
async function completeWorkspaceCreationTour(page: Page, workspaceName: string): Promise<void> {
const pickProjectStep = page.getByRole('dialog', { name: /Pick a project/i })
await expect(pickProjectStep).toBeVisible()
// Keyboard-activate Next so a lingering project popover cannot obscure the tour transition.
await pickProjectStep.getByRole('button', { name: /^Next$/ }).focus()
await page.keyboard.press('Enter')
const nameStep = page.getByRole('dialog', { name: /Name it, or start from existing work/i })
await expect(nameStep).toBeVisible()
const autoNameSwitch = nameStep.getByRole('switch', {
name: /Auto-name workspace from first agent message/i
})
const checkedBefore = await autoNameSwitch.getAttribute('aria-checked')
await autoNameSwitch.click()
await expect(autoNameSwitch).toHaveAttribute(
'aria-checked',
checkedBefore === 'true' ? 'false' : 'true'
)
await page.getByRole('button', { name: /^Next$/ }).click()
await expect(
page.getByRole('dialog', { name: /Choose what agent starts the work/i })
).toBeVisible()
await page.getByRole('button', { name: /^Done$/ }).click()
const composer = page.getByRole('dialog', { name: /Create (Workspace|Worktree)/i })
await expect(composer).toBeVisible()
await composer.getByPlaceholder(/Type a name/i).fill(workspaceName)
await composer.getByRole('button', { name: /Create (Workspace|Worktree)/i }).click()
await expect(composer).toBeHidden({ timeout: 20_000 })
await expectActiveWorkspaceVisible(page, workspaceName)
}
test.describe('Existing-user golden core flow', () => {
test('adds project, creates workspace, opens a terminal tab, and splits a pane', async ({
electronApp,
orcaPage
}) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
const repoPath = await createGitRepo('orca-e2e-golden-existing-', 'golden-existing-project')
await addProjectFromSidebar(orcaPage, electronApp, repoPath)
const workspaceName = `golden-existing-${Date.now()}`
await createWorkspace(orcaPage, workspaceName)
await expectActiveWorkspaceBelongsToRepo(orcaPage, workspaceName, repoPath)
await ensureTerminalVisible(orcaPage)
await expectTerminalSurface(orcaPage)
await waitForTerminalPaneManager(orcaPage)
await createTerminalTabThroughMenu(orcaPage)
await splitTerminalPaneAndAssertIdentity(orcaPage)
})
})
test.describe('New-user golden core flow', () => {
test.use({ dismissOnboarding: false, seedTestRepo: false })
test('completes onboarding, adds a project, and follows the workspace tour handoff', async ({
electronApp,
orcaPage
}) => {
await waitForSessionReady(orcaPage)
await chooseOnboardingAgentSurface(orcaPage)
await selectCodexAgent(orcaPage)
await continueOnboarding(orcaPage)
await expect(orcaPage.getByRole('heading', { name: /Make it feel like home/i })).toBeVisible()
await chooseOppositeTheme(orcaPage)
await continueOnboarding(orcaPage)
await continueThroughOptionalSetupToNotifications(orcaPage)
await expect(orcaPage.getByRole('button', { name: /Send Test Notification/i })).toBeVisible()
await chooseNotificationSound(orcaPage)
await completeOnboardingIntoHome(orcaPage)
await orcaPage.getByRole('button', { name: 'Add project', exact: true }).click()
await expect(orcaPage.getByRole('dialog', { name: /Add a project/i })).toBeVisible()
const repoPath = await createGitRepo('orca-e2e-golden-new-', 'golden-new-project')
await chooseFolderInNativeDialog(electronApp, repoPath)
await orcaPage
.getByRole('button', { name: /Browse for a folder|Open a folder|Browse folder/i })
.click()
await waitForRepoLoaded(orcaPage, repoPath)
await expectProjectVisible(orcaPage, repoPath)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await expectTerminalSurface(orcaPage)
await waitForTerminalPaneManager(orcaPage)
await requestAgentSessionsTour(orcaPage)
const paneCountBeforeTourSplit = await countVisibleTerminalPanes(orcaPage)
await orcaPage.getByRole('button', { name: /^Split terminal$/ }).click()
await waitForPaneCount(orcaPage, paneCountBeforeTourSplit + 1)
await waitForPaneIdentitySnapshot(orcaPage, paneCountBeforeTourSplit + 1)
await expect(
orcaPage.getByRole('dialog', { name: /Start another task in parallel/i })
).toBeVisible()
const createControl = orcaPage
.locator('[data-contextual-tour-target="workspace-create-control"]')
.first()
await expect(createControl).toBeVisible()
await expect(createControl).toHaveAttribute('aria-label', 'New workspace')
const createControlBox = await createControl.boundingBox()
expect(createControlBox?.width ?? 0).toBeGreaterThan(0)
expect(createControlBox?.height ?? 0).toBeGreaterThan(0)
await createControl.click()
const workspaceName = `golden-new-${Date.now()}`
await completeWorkspaceCreationTour(orcaPage, workspaceName)
await expectActiveWorkspaceBelongsToRepo(orcaPage, workspaceName, repoPath)
})
})
@@ -1,57 +0,0 @@
import type { OrcadManagedStopResult } from '../../../src/shared/orcad-managed-runtime'
export type ConvertedHostTeardown = {
environmentId: string
targetId: string
worktreeId: string
}
/** Runs in the renderer; only this fixture's converted SSH environment can be stopped. */
export async function decommissionConvertedHost({
environmentId,
targetId,
worktreeId
}: ConvertedHostTeardown): Promise<OrcadManagedStopResult> {
const managedOrcad = window.api.runtimeEnvironments.managedOrcad
if (!managedOrcad) {
throw new Error('Converted host teardown requires the desktop managed server API')
}
const environments = await window.api.runtimeEnvironments.list()
const owned = environments.filter(
(environment) =>
environment.id === environmentId &&
environment.orcadDeployment?.kind !== 'wsl' &&
environment.orcadDeployment?.sshTargetId === targetId
)
if (owned.length !== 1) {
throw new Error('Converted host teardown could not verify its environment and SSH target')
}
const closed = await window.api.runtimeEnvironments.call({
selector: environmentId,
method: 'terminal.closeAll',
params: { worktree: `id:${worktreeId}` },
timeoutMs: 120_000
})
const receipt = closed.ok ? closed.result : null
if (
!receipt ||
typeof receipt !== 'object' ||
!('retiredSurfaces' in receipt) ||
receipt.retiredSurfaces !== true ||
('ptyStopVerdict' in receipt && receipt.ptyStopVerdict !== undefined)
) {
throw new Error(`Converted host terminal stop was not proven: ${JSON.stringify(closed)}`)
}
const stopped = await managedOrcad.stop({
selector: environmentId
})
if (
stopped.outcome !== 'unlinked' ||
stopped.verdict !== 'exited' ||
stopped.environmentId !== environmentId ||
stopped.sshTargetId !== targetId
) {
throw new Error(`Converted host decommission was not proven: ${JSON.stringify(stopped)}`)
}
return stopped
}
@@ -51,7 +51,7 @@ export async function listHostOrcadProcesses(home: string): Promise<HostProcess[
/** The orcad server itself, without the terminal daemon and workers it forks from the same slot. */
export async function listHostOrcadServerProcesses(home: string): Promise<HostProcess[]> {
return (await listHostOrcadProcesses(home)).filter((entry) =>
/[\\/]orcad\.js(?=["\s]|$)/iu.test(entry.commandLine)
/[\\/]orcad(?:-server)?\.js(?=["\s]|$)/iu.test(entry.commandLine)
)
}
-488
View File
@@ -1,488 +0,0 @@
// #24979: convert real relay-era repo/folder/editor state on first SSH connect, retaining hidden source rows.
// Also update bundled orcad on idle reconnect and handle an occupied preferred port.
// ORCA_E2E_ORCAD_CONVERT_HOST selects Docker or a Windows host-cell descriptor; ORCA_E2E_ORCAD_CONVERT_TEMPLATE must match its target.
import { cpSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import type { ElectronApplication, Page, TestInfo } from '@stablyai/playwright-test'
import { expect, test } from './helpers/orca-app'
import { waitForSessionReady } from './helpers/store'
import { createRestartSession } from './helpers/orca-restart'
import {
convertAndRetain,
managedServer,
reconnect,
serverCall
} from './helpers/orcad-convert-flow'
import {
isOrcadFullVersion,
makeOrcadTemplateVariant,
readHostOrcadActivation
} from './helpers/orcad-template-variant'
import { seedRelayEraProfile } from './helpers/orcad-upgrade-profile'
import { findOrcadMigrationSourceCutoverForTarget } from '../../src/main/persistence/migrating-orcad-catalog/cutover-journals/orcad-migration-cutover-journal'
import { toSshExecutionHostId } from '../../src/shared/execution-host'
import {
decommissionConvertedHost,
type ConvertedHostTeardown
} from './helpers/orcad-convert-teardown'
import {
ORCAD_CONVERT_HOST_ENV,
startOrcadConvertHost,
type OrcadConvertHost
} from './helpers/orcad-convert-host'
import {
callEnvironment,
createPairedHostTerminal,
openPairedClientTab
} from './helpers/paired-host-terminal'
import { expectTerminalAccessibilityText } from './helpers/terminal-accessibility-tree'
import { getOrcadSshDeployment } from '../../src/shared/remote-runtime/host-catalog/runtime-environments'
const HOST = process.env[ORCAD_CONVERT_HOST_ENV]
const TEMPLATE_SOURCE = process.env.ORCA_E2E_ORCAD_CONVERT_TEMPLATE
// Fixed per worker so the app's launch env can name them before the test runs.
const SCRATCH = path.join(os.tmpdir(), `orca-orcad-convert-${process.pid}`)
const TEMPLATE_DIR = path.join(SCRATCH, 'orcad-template')
// Whole file: every test needs a real host and a template built for it.
test.skip(
!HOST || !TEMPLATE_SOURCE,
`Set ${ORCAD_CONVERT_HOST_ENV} and ORCA_E2E_ORCAD_CONVERT_TEMPLATE`
)
let host: OrcadConvertHost | null = null
function startHost(testInfo: TestInfo): OrcadConvertHost {
host = startOrcadConvertHost(HOST!, testInfo)
return host
}
test.beforeEach(() => {
rmSync(SCRATCH, { recursive: true, force: true })
mkdirSync(SCRATCH, { recursive: true })
})
test.afterEach(() => {
host?.cleanup()
host = null
rmSync(SCRATCH, { recursive: true, force: true })
})
test.use({
orcaAppExtraEnv: {
ORCA_ORCAD_TEMPLATE_PATH: TEMPLATE_DIR
}
})
test('a relay-era profile converts its SSH host on the first connect after upgrading', async ({
testRepoPath
}, testInfo) => {
test.setTimeout(20 * 60_000)
const host = startHost(testInfo)
const session = createRestartSession(testInfo, {
ORCA_ORCAD_TEMPLATE_PATH: TEMPLATE_SOURCE!
})
let app: ElectronApplication | null = null
let teardown: (ConvertedHostTeardown & { page: Page }) | null = null
try {
// The first launch only establishes the profile the relay-era rows are written into.
const first = await session.launch()
app = first.app
await waitForSessionReady(first.page)
await first.page.evaluate((repoPath) => window.api.repos.add({ path: repoPath }), testRepoPath)
await session.close(app)
app = null
const seeded = seedRelayEraProfile(
session.userDataDir,
host.input,
{ repoPath: host.remoteRepoPath, folderPath: host.remoteFolderPath },
{ terminalTab: true, parkedScrollback: true }
)
// With no relay leases, the gate must prove exited; retain raw SSH logs because this launch bypasses fixture forwarding.
const upgraded = await session.launch({
onStderr: (chunk) => {
if (chunk.includes('[ssh]')) {
process.stderr.write(chunk)
}
}
})
app = upgraded.app
await waitForSessionReady(upgraded.page)
// Mounting the unconverted relay tab dials the host and triggers conversion without spawning locally.
await upgraded.page.evaluate(
({ worktreeId, hostId, tabId }) => {
const state = window.__store!.getState()
state.setActiveWorktree(worktreeId, hostId)
state.setActiveTab(tabId)
state.setActiveTabType('terminal', worktreeId)
},
{
worktreeId: seeded.worktreeId,
hostId: toSshExecutionHostId(seeded.targetId),
tabId: seeded.terminalTabId!
}
)
await expect
.poll(() => managedServer(upgraded.page, seeded.targetId), { timeout: 8 * 60_000 })
.toMatchObject({ kind: 'managed' })
await upgraded.page.screenshot({ path: testInfo.outputPath('relay-era-terminal-dialed.png') })
await convertAndRetain(upgraded.page, session.userDataDir, seeded)
// #30126: the parked panes' history rode the snapshot channel; inlined, it overflowed the manifest.
const moved = findOrcadMigrationSourceCutoverForTarget(session.userDataDir, seeded.targetId)
?.manifest.payload.dormantState
expect(moved?.workspaceSession?.localOnlyScrollbackByTabId).toBeUndefined()
expect(
Object.fromEntries(
(moved?.terminalScrollbackSnapshots ?? [])
.filter((snapshot) => snapshot.tabId === seeded.terminalTabId)
.map((snapshot) => [snapshot.leafId, snapshot.byteLength])
)
).toEqual(
Object.fromEntries(
Object.entries(seeded.parkedScrollbackByLeafId).map(([leafId, buffer]) => [
leafId,
Buffer.byteLength(buffer)
])
)
)
const environment = (
await upgraded.page.evaluate(() => window.api.runtimeEnvironments.list())
).find((entry) => getOrcadSshDeployment(entry)?.sshTargetId === seeded.targetId)
if (!environment) {
throw new Error('Converted host is missing from the environment catalog')
}
teardown = {
page: upgraded.page,
environmentId: environment.id,
targetId: seeded.targetId,
worktreeId: seeded.worktreeId
}
const hostId = `runtime:${environment.id}` as const
await upgraded.page.evaluate((hostId) => {
const state = window.__store!.getState()
state.setGroupBy('none')
window.__store!.setState({ visibleWorkspaceHostIds: ['local', hostId] })
}, hostId)
const folder = upgraded.page.getByText('orcad upgrade folder', { exact: true })
await expect(folder).toBeVisible()
await upgraded.page.screenshot({ path: testInfo.outputPath('managed-folder-host-section.png') })
const sectionHeader = folder.locator('xpath=preceding::*[@data-host-header-drag-id][1]')
await expect(sectionHeader).toHaveAttribute('data-host-header-drag-id', hostId)
const managedHeader = upgraded.page.locator(`[data-host-header-drag-id="${hostId}"]`)
await managedHeader.click()
await expect(folder).toBeHidden()
await managedHeader.click()
await expect(folder).toBeVisible()
if (!host.exec) {
return
}
await upgraded.page.evaluate(
({ worktreeId, environmentId }) => {
window.__store!.getState().setActiveWorktree(worktreeId, `runtime:${environmentId}`)
},
{ worktreeId: seeded.worktreeId, environmentId: environment.id }
)
const terminal = await createPairedHostTerminal(
upgraded.page,
environment.id,
seeded.worktreeId,
'bash'
)
await openPairedClientTab(upgraded.page, seeded.worktreeId, terminal.webTabId)
await callEnvironment(upgraded.page, environment.id, 'terminal.send', {
terminal: terminal.terminal,
text: "orca status --json > /tmp/orca-cli-status.json && orca worktree ps --json > /tmp/orca-cli-workers.json && printf 'QA_%s\\n' 'CLI_READY'",
enter: true
})
await expectTerminalAccessibilityText(upgraded.page, terminal.webTabId, 'QA_CLI_READY')
const status = JSON.parse(host.exec('cat /tmp/orca-cli-status.json'))
expect(status.result.runtime).toMatchObject({
reachable: true,
runtimeId: environment.runtimeId
})
expect(status.result.app.desktopWindowStatus).toBe('blocked')
await upgraded.page.screenshot({ path: testInfo.outputPath('managed-cli-ready.png') })
} finally {
try {
// The Windows account survives client exit; stop its owned runtime before the lane's grace.
if (HOST !== 'docker' && teardown) {
const { page, ...owned } = teardown
const stopped = await page.evaluate(decommissionConvertedHost, owned)
await testInfo.attach('converted-host-teardown', {
body: JSON.stringify(stopped),
contentType: 'application/json'
})
}
} finally {
try {
if (app) {
await session.close(app)
}
} finally {
await session.dispose()
}
}
}
})
test('a host whose sshd refuses TCP forwarding runs a managed server over the stdio bridge', async ({
orcaPage: page
}, testInfo) => {
test.skip(HOST !== 'docker', 'Only the Docker host can change its sshd policy mid-test')
test.setTimeout(15 * 60_000)
cpSync(TEMPLATE_SOURCE!, TEMPLATE_DIR, { recursive: true })
const host = startHost(testInfo)
host.blockTcpForwarding!()
await waitForSessionReady(page)
// Just the connect: a managed host serves repositories through its server, not the relay.
const targetId = await page.evaluate(async (input) => {
const { target } = await window.api.ssh.addTarget({ target: input })
await window.api.ssh.connect({ targetId: target.id })
return target.id
}, host.input)
const mainServer = (): Promise<string> =>
page.evaluate(
async (id) =>
JSON.stringify((await window.api.ssh.getState({ targetId: id }))?.managedServer ?? null),
targetId
)
await expect.poll(mainServer, { timeout: 8 * 60_000 }).toContain('"kind":"managed"')
const target = await page.evaluate(
async (id) => (await window.api.ssh.listTargets()).find((entry) => entry.id === id),
targetId
)
expect(target?.orcadFence).toBeTruthy()
expect(target).not.toHaveProperty('managedServerUnavailable')
const environment = (await page.evaluate(() => window.api.runtimeEnvironments.list())).find(
(entry) => getOrcadSshDeployment(entry)?.sshTargetId === targetId
)
expect(environment, 'a managed server registered for the host').toBeTruthy()
// sshd refuses every forward, so this call can only have ridden a stdio bridge.
await serverCall(page, environment!.id, 'repo.list')
// A reconnect rebuilds the tunnel the same way.
await reconnect(page, targetId)
await expect.poll(mainServer, { timeout: 2 * 60_000 }).toContain('"kind":"managed"')
await serverCall(page, environment!.id, 'repo.list')
})
const heldPortProbe = `(echo > /dev/tcp/127.0.0.1/6768) 2>/dev/null && echo held || true`
// Every installed slot's readiness line names the endpoint that orcad actually bound.
const BOUND_ENDPOINTS = `cat /root/.orca-remote/orcad-*/.orcad-readiness 2>/dev/null | grep -o 'ws://[0-9.:]*' || true`
test('a host whose port 6768 another runtime holds still runs a managed server', async ({
orcaPage: page
}, testInfo) => {
test.skip(HOST !== 'docker', 'Only the Docker host can start a listener mid-test')
test.setTimeout(15 * 60_000)
cpSync(TEMPLATE_SOURCE!, TEMPLATE_DIR, { recursive: true })
const host = startHost(testInfo)
// Stands in for a desktop Orca on the host: it holds 6768 and turns every client away.
host.exec!(
`setsid nohup node -e "require('net').createServer((s) => s.destroy()).listen(6768, '127.0.0.1')" >/dev/null 2>&1 &`
)
await expect.poll(() => host.exec!(heldPortProbe)).toContain('held')
await waitForSessionReady(page)
const targetId = await page.evaluate(async (input) => {
const { target } = await window.api.ssh.addTarget({ target: input })
await window.api.ssh.connect({ targetId: target.id })
return target.id
}, host.input)
await expect
.poll(() => managedServer(page, targetId), { timeout: 8 * 60_000 })
.toMatchObject({ kind: 'managed' })
const environment = (await page.evaluate(() => window.api.runtimeEnvironments.list())).find(
(entry) => getOrcadSshDeployment(entry)?.sshTargetId === targetId
)
expect(environment, 'a managed server registered for the host').toBeTruthy()
// The other listener drops every connection, so this call can only have reached orcad.
await serverCall(page, environment!.id, 'repo.list')
// Without these the case passes vacuously whenever the stand-in is gone before orcad starts.
expect(host.exec!(heldPortProbe)).toContain('held')
expect(host.exec!(BOUND_ENDPOINTS)).toMatch(/ws:\/\/127\.0\.0\.1:(?!6768\b)\d+/)
// A reconnect reads the bound port again rather than assuming 6768.
await reconnect(page, targetId)
await expect
.poll(() => managedServer(page, targetId), { timeout: 2 * 60_000 })
.toMatchObject({ kind: 'managed' })
await serverCall(page, environment!.id, 'repo.list')
})
test('a managed host updates to the bundled orcad on the first connect after an app update', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set.
{}, testInfo) => {
test.skip(HOST !== 'docker', 'Reads the activation record through the Docker host')
test.setTimeout(20 * 60_000)
cpSync(TEMPLATE_SOURCE!, TEMPLATE_DIR, { recursive: true })
const host = startHost(testInfo)
const session = createRestartSession(testInfo, {
ORCA_ORCAD_TEMPLATE_PATH: TEMPLATE_DIR
})
let app: ElectronApplication | null = null
try {
const first = await session.launch()
app = first.app
await waitForSessionReady(first.page)
const targetId = await first.page.evaluate(async (input) => {
const { target } = await window.api.ssh.addTarget({ target: input })
return target.id
}, host.input)
await connectOnce(first.page, targetId)
await expect
.poll(() => managedServer(first.page, targetId), { timeout: 8 * 60_000 })
.toMatchObject({ kind: 'managed' })
const deployed = readHostOrcadActivation(host.exec!)
expect(isOrcadFullVersion(deployed.active)).toBe(true)
await session.close(app)
app = null
// Use template B to model a bundled app update during tunnel restore/next connect.
makeOrcadTemplateVariant(TEMPLATE_DIR, 'B')
const updated = await session.launch({
onStderr: (chunk) => {
if (chunk.includes('[ssh]')) {
process.stderr.write(chunk)
}
}
})
app = updated.app
await waitForSessionReady(updated.page)
// Connect directly: a disconnect racing launch-time tunnel restore cancels the update-triggering connect.
const attempts: string[] = []
await expect
.poll(
async () => {
attempts.push(await connectOnce(updated.page, targetId))
const record = readHostOrcadActivation(host.exec!)
return record.active !== deployed.active
? 'updated'
: JSON.stringify({ attempts: attempts.slice(-3), record })
},
{ timeout: 8 * 60_000, intervals: [5_000] }
)
.toBe('updated')
await expect
.poll(() => managedServer(updated.page, targetId), { timeout: 60_000 })
.toEqual({ kind: 'managed', environmentId: expect.any(String) })
const record = readHostOrcadActivation(host.exec!)
expect(isOrcadFullVersion(record.active)).toBe(true)
expect(record.previous).toBe(deployed.active)
expect(record.activeAppVersion).toBeTruthy()
} finally {
if (app) {
await session.close(app)
}
await session.dispose()
}
})
/** One connect; returns its managed-server state, or why it threw, for a poll to report. */
function connectOnce(page: Page, targetId: string): Promise<string> {
return page.evaluate(async (id) => {
try {
const state = await window.api.ssh.connect({ targetId: id })
return JSON.stringify(state?.managedServer ?? null)
} catch (error) {
return `connect threw: ${String(error)}`
}
}, targetId)
}
test('reconnect restores the managed host name after its conversion catalog fails', async ({
testRepoPath
}, testInfo) => {
test.skip(HOST !== 'docker', 'Catalog fault injection uses the isolated Linux Docker host')
test.setTimeout(180_000)
const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-catalog-retry-'))
const templateDir = path.join(scratch, 'template')
const host = startOrcadConvertHost('docker', testInfo)
const session = createRestartSession(testInfo, { ORCA_ORCAD_TEMPLATE_PATH: templateDir })
let app: Awaited<ReturnType<typeof session.launch>>['app'] | null = null
try {
const first = await session.launch()
app = first.app
await waitForSessionReady(first.page)
// A local project keeps a second host section, so the managed host header renders.
await first.page.evaluate((repoPath) => window.api.repos.add({ path: repoPath }), testRepoPath)
await session.close(app)
app = null
const seeded = seedRelayEraProfile(session.userDataDir, host.input, {
repoPath: host.remoteRepoPath,
folderPath: host.remoteFolderPath
})
// The saved profile is the relay era: no connect has run, so the first one converts.
const upgraded = await session.launch()
app = upgraded.app
const page = upgraded.page
await waitForSessionReady(page)
await page.evaluate(() => window.__store!.getState().setGroupBy('none'))
// Fail the local catalog IPC during a real SSH migration; healing restores its production handler.
await app.evaluate(({ ipcMain }) => {
type Handler = (event: unknown, ...args: unknown[]) => unknown
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Electron's registered invoke handlers are a Map of channel callbacks.
const registry = (ipcMain as unknown as { _invokeHandlers?: Map<string, Handler> })
._invokeHandlers
const original = registry?.get('runtimeEnvironments:list')
if (!original) {
throw new Error('runtimeEnvironments:list is not registered')
}
ipcMain.removeHandler('runtimeEnvironments:list')
ipcMain.handle('runtimeEnvironments:list', () => {
throw new Error('catalog-retry: managed-host catalog temporarily unavailable')
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this test installs and removes its own recovery callback within this app.
const state = globalThis as typeof globalThis & { healCatalogFault?: () => void }
state.healCatalogFault = () => {
ipcMain.removeHandler('runtimeEnvironments:list')
ipcMain.handle('runtimeEnvironments:list', original)
}
})
// Collect rather than waitForEvent: its 30 s clock would start before the conversion below.
let catalogFailed = false
page.on('console', (message) => {
catalogFailed ||= message.text().includes('Could not load the managed server catalogs')
})
cpSync(TEMPLATE_SOURCE!, templateDir, { recursive: true })
await reconnect(page, seeded.targetId)
await expect.poll(() => managedServer(page, seeded.targetId)).toMatchObject({ kind: 'managed' })
await expect.poll(() => catalogFailed).toBe(true)
await page.screenshot({ path: testInfo.outputPath('catalog-failure.png') })
await app.evaluate(() => {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fault installation above owns this optional callback.
const state = globalThis as typeof globalThis & { healCatalogFault?: () => void }
state.healCatalogFault?.()
})
const environment = (await page.evaluate(() => window.api.runtimeEnvironments.list())).find(
(entry) => getOrcadSshDeployment(entry)?.sshTargetId === seeded.targetId
)
if (!environment) {
throw new Error('Managed host missing from main catalog')
}
await page.evaluate(
(id) => window.__store!.setState({ visibleWorkspaceHostIds: ['local', `runtime:${id}`] }),
environment.id
)
await reconnect(page, seeded.targetId)
// The folder row must retain its managed execution host after the catalog heals.
const folder = page.locator('[data-worktree-host-identity]').filter({
has: page.getByText('orcad upgrade folder', { exact: true })
})
await expect(folder).toHaveCount(1, { timeout: 30_000 })
await expect(folder).toHaveAttribute('data-worktree-host-identity', /runtime:/)
await expect(
page.locator('[data-host-header-drag-id^="runtime:"]').filter({ hasText: environment.name })
).toHaveCount(1)
await page.screenshot({ path: testInfo.outputPath('catalog-recovered.png') })
} finally {
if (app) {
await session.close(app)
}
await session.dispose()
host.cleanup()
rmSync(scratch, { recursive: true, force: true })
}
})
+4 -52
View File
@@ -1,5 +1,5 @@
// Run bundled CLI against real Windows OpenSSH host cells; invoke-windows-host-cells.ps1 selects @orcad-cli-managed/@orcad-cli-convert.
// Managed covers deploy/list, terminal reconnect, decommission/redeploy and unverifiable→live restart; convert preserves relay-era source and reaches migrated projects.
// Run bundled CLI against real Windows OpenSSH host cells; invoke-windows-host-cells.ps1 selects @orcad-cli-managed.
// Covers deploy/list, terminal reconnect, decommission/redeploy and unverifiable→live restart.
// ORCA_E2E_ORCAD_CONVERT_HOST names the descriptor; ORCA_E2E_ORCAD_CONVERT_TEMPLATE must match its target.
import { mkdirSync, rmSync } from 'node:fs'
import os from 'node:os'
@@ -8,8 +8,8 @@ import type { ElectronApplication, Page } from '@stablyai/playwright-test'
import { expect, test } from './helpers/orca-app'
import { waitForSessionReady } from './helpers/store'
import { createRestartSession } from './helpers/orca-restart'
import { convertAndRetain, managedServer, reconnect } from './helpers/orcad-convert-flow'
import { seedRelayEraProfile } from './helpers/orcad-upgrade-profile'
import { managedServer, reconnect } from './helpers/orcad-convert-flow'
import { ORCAD_CONVERT_HOST_ENV, startOrcadConvertHost } from './helpers/orcad-convert-host'
import { orcaCliResult, runCompiledOrcaCli } from './helpers/compiled-orca-cli'
import {
@@ -523,51 +523,3 @@ test('@orcad-cli-managed an empty Windows host deploys, serves CLI terminals thr
rmSync(SCRATCH, { recursive: true, force: true })
}
})
test('@orcad-cli-convert a seeded relay-era profile converts its Windows host, keeps its source retained, and the CLI reaches its projects', async (// oxlint-disable-next-line no-empty-pattern -- This test owns its launches through a restart session.
{}, testInfo) => {
skipUnlessWindowsHost()
test.setTimeout(20 * 60_000)
resetScratch()
const host = startOrcadConvertHost(HOST!, testInfo)
const session = createRestartSession(testInfo, launchEnv())
let app: ElectronApplication | null = null
try {
const first = await session.launch()
app = first.app
await waitForSessionReady(first.page)
await session.close(app)
app = null
const seeded = seedRelayEraProfile(session.userDataDir, host.input, {
repoPath: host.remoteRepoPath,
folderPath: host.remoteFolderPath
})
const upgraded = await session.launch({ onStderr: sshStderr })
app = upgraded.app
await waitForSessionReady(upgraded.page)
await convertAndRetain(upgraded.page, session.userDataDir, seeded)
const environmentId = await waitForManaged(upgraded.page, seeded.targetId)
expect(await environmentIds(session.userDataDir)).toContain(environmentId)
expect(
(await hostRows(session.userDataDir)).find((row) => row.id === seeded.targetId)
).toMatchObject({
kind: 'ssh',
connected: true
})
const repos = await orcaCliResult<{ repos: { path: string }[] }>(session.userDataDir, [
'repo',
'list',
'--environment',
environmentId
])
expect(repos.repos.some((repo) => samePath(repo.path, seeded.repoPath))).toBe(true)
} finally {
if (app) {
await session.close(app)
}
await disposeQuietly(session)
host.cleanup()
rmSync(SCRATCH, { recursive: true, force: true })
}
})