mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 10:27:33 +00:00
Merge origin/main Ubuntu package mirror fix
This commit is contained in:
@@ -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 "$@"
|
||||
@@ -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
|
||||
'
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
-45
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
-52
@@ -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)
|
||||
})
|
||||
|
||||
-54
@@ -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: [
|
||||
|
||||
-87
@@ -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')
|
||||
})
|
||||
-223
@@ -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(
|
||||
|
||||
-151
@@ -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')
|
||||
})
|
||||
})
|
||||
+2
-78
@@ -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,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 })
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user