Merge remote-tracking branch 'origin/main' into nwparker/e2e-fix-2

# Conflicts:
#	tests/e2e/github-cli-stall-repro.spec.ts
#	tests/e2e/onboarding.spec.ts
#	tests/e2e/settings-display-name-ime.spec.ts
#	tests/e2e/source-control-commit-draft-persistence.spec.ts
#	tests/e2e/source-control-commit-message-ai.spec.ts
#	tests/e2e/terminal-shortcuts.spec.ts
#	tests/e2e/workspace-space-git-status.spec.ts
#	tests/e2e/worktree.spec.ts
This commit is contained in:
Neil
2026-07-05 20:14:14 -07:00
23 changed files with 1105 additions and 469 deletions
+8
View File
@@ -878,6 +878,14 @@ jobs:
}
Get-Item -LiteralPath $file
}
# The standalone node.exe that hosts the terminal daemon outside the
# install-dir kill zone the NSIS updater sweeps. See
# src/main/daemon/daemon-host-relocation.ts.
$daemonHostNode = 'dist/win-unpacked/resources/daemon-host/node.exe'
if (-not (Test-Path -LiteralPath $daemonHostNode -PathType Leaf)) {
throw "Missing Windows daemon-host runtime file: $daemonHostNode"
}
Get-Item -LiteralPath $daemonHostNode
- name: Install SignPath PowerShell module
if: matrix.platform == 'win'
+41
View File
@@ -0,0 +1,41 @@
const { copyFileSync, existsSync, mkdirSync } = require('node:fs')
const { join } = require('node:path')
// Where the standalone daemon-host node.exe is staged under packaged resources.
// Mirrored at runtime by src/main/daemon/daemon-host-relocation.ts, which copies
// it into userData/daemon-host/<version>/ and forks the terminal daemon from it.
const DAEMON_HOST_DIR = 'daemon-host'
const DAEMON_HOST_NODE_EXE = 'node.exe'
/**
* Stages a standalone Windows node.exe into resources/daemon-host so the
* detached terminal daemon can be forked from a userData copy that lives
* outside the install directory the NSIS updater force-closes mid-update.
*
* Source is the build host's own node.exe (process.execPath). It is version-
* correct by construction: engines.node pins the build to Node 24.x, whose NAPI
* 10 runtime loads node-pty's Electron-42-built conpty.node. Being an official
* Node binary, it also carries the OpenJS Foundation Authenticode signature, so
* it ships validly signed regardless of whether SignPath re-signs nested PEs.
*
* Windows x64 only — the sole Windows build target. Other targets ship no
* node.exe and the runtime falls open to forking the install-dir Electron host.
*/
function ensurePackagedDaemonHostNode(resourcesDir, electronPlatformName) {
if (electronPlatformName !== 'win32') {
return
}
const nodeExePath = process.execPath
// The build must run under standalone Node (not Electron) so execPath is a
// real node.exe with a NAPI runtime; anything else would strand the daemon.
if (!/node\.exe$/i.test(nodeExePath) || !existsSync(nodeExePath)) {
throw new Error(
`[daemon-host] cannot stage node.exe: build host execPath is not a node.exe (${nodeExePath})`
)
}
const destDir = join(resourcesDir, DAEMON_HOST_DIR)
mkdirSync(destDir, { recursive: true })
copyFileSync(nodeExePath, join(destDir, DAEMON_HOST_NODE_EXE))
}
module.exports = { ensurePackagedDaemonHostNode, DAEMON_HOST_DIR, DAEMON_HOST_NODE_EXE }
+5
View File
@@ -7,6 +7,7 @@ const {
prunePackagedRuntimeNodeModules,
verifyPackagedMainRuntimeDeps
} = require('./packaged-runtime-node-modules.cjs')
const { ensurePackagedDaemonHostNode } = require('./daemon-host-node-runtime.cjs')
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1'
const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1'
@@ -128,6 +129,10 @@ module.exports = {
return
}
prunePackagedRuntimeNodeModules(resourcesDir, context.electronPlatformName, context.arch)
// Why: stage the standalone node.exe that hosts the terminal daemon outside
// the install-dir kill zone the NSIS updater sweeps (win32 only; no-ops
// elsewhere). See src/main/daemon/daemon-host-relocation.ts.
ensurePackagedDaemonHostNode(resourcesDir, context.electronPlatformName)
verifyPackagedMainRuntimeDeps(resourcesDir)
chmodUnixCliLaunchers(resourcesDir, context.electronPlatformName)
chmodMacServeSimHelpers(resourcesDir, context.electronPlatformName)
@@ -17,6 +17,22 @@ const {
prunePackagedZodSources,
verifyPackagedMainRuntimeDeps
} = require('../packaged-runtime-node-modules.cjs')
const { ensurePackagedDaemonHostNode } = require('../daemon-host-node-runtime.cjs')
// process.execPath is read-only in the types; point it at a fixture node.exe.
function withStubbedExecPath(value, fn) {
const original = process.execPath
Object.defineProperty(process, 'execPath', { value, configurable: true, writable: true })
try {
return fn()
} finally {
Object.defineProperty(process, 'execPath', {
value: original,
configurable: true,
writable: true
})
}
}
describe('electron-builder config', () => {
it('excludes repo-only source trees from app.asar', () => {
@@ -207,6 +223,52 @@ describe('electron-builder config', () => {
}
})
it('stages the build host node.exe into resources/daemon-host for Windows', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-daemon-host-node-'))
try {
const fakeHostDir = join(root, 'host')
await mkdir(fakeHostDir, { recursive: true })
const fakeNodeExe = join(fakeHostDir, 'node.exe')
await writeFile(fakeNodeExe, 'fake-node-binary', 'utf8')
const resourcesDir = join(root, 'resources')
await mkdir(resourcesDir, { recursive: true })
withStubbedExecPath(fakeNodeExe, () => ensurePackagedDaemonHostNode(resourcesDir, 'win32'))
await expect(readFile(join(resourcesDir, 'daemon-host', 'node.exe'), 'utf8')).resolves.toBe(
'fake-node-binary'
)
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('does not stage a daemon-host node.exe on non-Windows platforms', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-daemon-host-node-skip-'))
try {
ensurePackagedDaemonHostNode(resourcesDir, 'linux')
await expect(readdir(resourcesDir)).resolves.toEqual([])
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('fails the Windows build when the host binary is not a node.exe', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-daemon-host-node-bad-'))
try {
const notNode = join(root, 'electron.exe')
await writeFile(notNode, 'not-node', 'utf8')
const resourcesDir = join(root, 'resources')
await mkdir(resourcesDir, { recursive: true })
expect(() =>
withStubbedExecPath(notNode, () => ensurePackagedDaemonHostNode(resourcesDir, 'win32'))
).toThrow(/not a node\.exe/)
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('includes @parcel/watcher in the packaged runtime closure', () => {
// Why: the main process imports '@parcel/watcher' for filesystem change
// events; if it is absent from the packaged closure the serve host silently
@@ -178,6 +178,9 @@ describe('Electron runtime package contract', () => {
'dist/win-unpacked/resources/node_modules/node-pty/build/Release'
)
expect(steps[verifyNodePtyIndex].run).toContain('conpty/conpty.dll')
// The daemon-host node.exe must be staged alongside the node-pty runtime so
// the terminal daemon can be forked from outside the install-dir kill zone.
expect(steps[verifyNodePtyIndex].run).toContain('resources/daemon-host/node.exe')
const uploadThroughDownloadScript = steps
.slice(uploadIndex, downloadIndex + 1)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.124-rc.2",
"version": "1.4.124-rc.5",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
@@ -0,0 +1,100 @@
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Mutable Electron app stub, hoisted so the vi.mock factory can close over it.
const { electronApp } = vi.hoisted(() => ({
electronApp: {
isPackaged: false,
userDataPath: '',
version: '0.0.0-test',
getPath: (): string => electronApp.userDataPath,
getVersion: (): string => electronApp.version
}
}))
vi.mock('electron', () => ({ app: electronApp }))
import { resolveDaemonHostSourceDir } from './daemon-host-relocation'
let tempDir: string
beforeEach(() => {
tempDir = mkdtempSync(join(os.tmpdir(), 'daemon-host-relocation-'))
})
afterEach(() => {
vi.unstubAllEnvs()
try {
rmSync(tempDir, { recursive: true, force: true })
} catch {}
})
describe('resolveDaemonHostSourceDir', () => {
it('returns the daemon-host dir when it holds a node.exe', () => {
const resources = join(tempDir, 'resources')
mkdirSync(join(resources, 'daemon-host'), { recursive: true })
writeFileSync(join(resources, 'daemon-host', 'node.exe'), 'host')
expect(resolveDaemonHostSourceDir(resources)).toBe(join(resources, 'daemon-host'))
})
it('returns null when the daemon-host dir has no node.exe', () => {
const resources = join(tempDir, 'resources')
mkdirSync(join(resources, 'daemon-host'), { recursive: true })
expect(resolveDaemonHostSourceDir(resources)).toBeNull()
})
it('returns null when the daemon-host dir is absent', () => {
expect(resolveDaemonHostSourceDir(join(tempDir, 'resources'))).toBeNull()
})
})
// process.resourcesPath is typed read-only; point it at a fixture for the test.
function stubResourcesPath(value: string | undefined): void {
Object.defineProperty(process, 'resourcesPath', { value, configurable: true, writable: true })
}
// Copies a real node.exe path shape; the win32 guard in the module means this
// only exercises the relocation path on Windows.
describe.runIf(process.platform === 'win32')('installRelocatedDaemonHost', () => {
const originalResourcesPath = process.resourcesPath
afterEach(() => {
stubResourcesPath(originalResourcesPath)
vi.resetModules()
})
it('relocates a bundled node.exe and exposes its userData path', async () => {
const resources = join(tempDir, 'resources')
mkdirSync(join(resources, 'daemon-host'), { recursive: true })
writeFileSync(join(resources, 'daemon-host', 'node.exe'), 'fake-node-host')
stubResourcesPath(resources)
electronApp.userDataPath = join(tempDir, 'userData')
electronApp.version = '9.9.9'
vi.stubEnv('ORCA_FORCE_DAEMON_HOST_RELOCATION', '1')
// Fresh module so the one-shot install/singleton state is not carried over.
vi.resetModules()
const mod = await import('./daemon-host-relocation')
mod.installRelocatedDaemonHost()
const execPath = mod.getRelocatedDaemonHostExecPath()
expect(execPath).toBe(join(electronApp.userDataPath, 'daemon-host', '9.9.9', 'node.exe'))
expect(readFileSync(execPath!, 'utf8')).toBe('fake-node-host')
})
it('fails open to null when no bundled node.exe is present', async () => {
const resources = join(tempDir, 'resources')
mkdirSync(resources, { recursive: true })
stubResourcesPath(resources)
electronApp.userDataPath = join(tempDir, 'userData')
vi.stubEnv('ORCA_FORCE_DAEMON_HOST_RELOCATION', '1')
vi.resetModules()
const mod = await import('./daemon-host-relocation')
mod.installRelocatedDaemonHost()
expect(mod.getRelocatedDaemonHostExecPath()).toBeNull()
})
})
+83
View File
@@ -0,0 +1,83 @@
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { app } from 'electron'
import { ensureRelocatedRuntime } from '../pty/install-dir-runtime-relocation'
/**
* Relocates the Node host that runs the detached terminal daemon out of the
* app install directory into userData.
*
* Why: the daemon is `fork()`ed as plain Node via ELECTRON_RUN_AS_NODE, so its
* process image is the install-dir Orca.exe. The NSIS update installer
* force-closes every process whose image path is under the install directory
* before replacing files, which kills the daemon — and every live terminal it
* owns — mid-update. Running the daemon from a version-keyed node.exe staged in
* userData takes its image out of the installer's kill zone so it survives.
*
* Fail-open: if no bundled node.exe is present (dev, or a build that predates
* shipping it), relocation no-ops and the caller keeps forking the install-dir
* Orca.exe host — the pre-relocation behavior, with zero regression.
*/
const RELOCATED_DAEMON_HOST_EXE = 'node.exe'
let relocatedDaemonHostExecPath: string | null = null
let installed = false
/**
* The bundled daemon-host dir under app resources, or null when no node.exe is
* shipped there. Kept separate from install so the resources path is testable.
*/
export function resolveDaemonHostSourceDir(resourcesPath: string): string | null {
const dir = join(resourcesPath, 'daemon-host')
return existsSync(join(dir, RELOCATED_DAEMON_HOST_EXE)) ? dir : null
}
/**
* Copies the bundled daemon-host node.exe into a version-keyed userData dir
* (once) and records its path for the daemon fork. Safe to call more than once;
* only the first call does work.
*/
export function installRelocatedDaemonHost(): void {
if (installed) {
return
}
installed = true
if (process.platform !== 'win32') {
return
}
// The install-dir kill zone only exists for packaged installs; a dev override
// lets the relocation path be exercised without a full NSIS build.
if (!app.isPackaged && process.env.ORCA_FORCE_DAEMON_HOST_RELOCATION !== '1') {
return
}
// Why: fail-open contract — this must never throw. resourcesPath is unset
// outside a real Electron process (tests, node-hosted tooling).
if (typeof process.resourcesPath !== 'string') {
return
}
const sourceDir = resolveDaemonHostSourceDir(process.resourcesPath)
if (!sourceDir) {
return
}
const userData = app.getPath('userData')
const destDir = ensureRelocatedRuntime({
sourceDir,
destRoot: join(userData, 'daemon-host'),
version: app.getVersion(),
// Same runtimeDir daemon-init writes daemon-v<N>.pid into, so a surviving
// daemon's host dir is pinned by its recorded appVersion and never reclaimed
// while it runs.
daemonRuntimeDir: join(userData, 'daemon')
})
if (destDir) {
relocatedDaemonHostExecPath = join(destDir, RELOCATED_DAEMON_HOST_EXE)
}
}
/**
* The relocated node.exe to fork the daemon from, or null to fall back to the
* install-dir Electron host (ELECTRON_RUN_AS_NODE).
*/
export function getRelocatedDaemonHostExecPath(): string | null {
return relocatedDaemonHostExecPath
}
+48 -5
View File
@@ -36,6 +36,11 @@ import {
isDaemonStaleForCurrentBundle,
killStaleDaemon
} from './daemon-health'
import {
getRelocatedDaemonHostExecPath,
installRelocatedDaemonHost
} from './daemon-host-relocation'
import { startSpan } from '../observability/tracer'
import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider'
import {
getLocalPtyProvider,
@@ -153,6 +158,16 @@ function createPreservedDaemonHandle(
protocolVersion = PROTOCOL_VERSION,
mode?: 'degraded-new-pty-fallback'
): DaemonProcessHandle {
// Why: the trace file is the only artifact available in field
// investigations of update-survival incidents; adopt-vs-fresh-fork is the
// first question every one of them asks.
const adoptSpan = startSpan('daemon.adopt', {
attributes: {
'daemon.protocol_version': protocolVersion,
...(mode ? { 'daemon.mode': mode } : {})
}
})
adoptSpan.end()
const handle: DaemonProcessHandle = {
shutdown: async () => {
await cleanupDaemonForProtocol(runtimeDir, protocolVersion)
@@ -260,6 +275,25 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
await killStaleDaemon(runtimeDir, socketPath, tokenPath)
const userDataPath = app.getPath('userData')
// Why: staged here instead of app startup so the ~70MB node.exe copy stays
// off the first-paint path, and is skipped entirely on launches that adopt
// a live daemon (no fork). Latched — repeat calls are free.
installRelocatedDaemonHost()
// Why: on Windows a relocated node.exe in userData hosts the daemon so its
// process image escapes the install-dir the NSIS updater force-closes; when
// absent (dev, non-win32, pre-relocation builds) fall back to the install-dir
// Electron host run as plain Node via ELECTRON_RUN_AS_NODE.
const relocatedHostExecPath = getRelocatedDaemonHostExecPath()
// Why: without this span a silent fallback to the install-dir host (back
// inside the updater kill zone) is indistinguishable from the fixed path
// in field trace files.
const forkSpan = startSpan('daemon.fork', {
attributes: {
'daemon.host': relocatedHostExecPath ? 'relocated-node' : 'install-dir-electron',
...(relocatedHostExecPath ? { 'daemon.host_exec_path': relocatedHostExecPath } : {}),
'app.version': app.getVersion()
}
})
const child = fork(entryPath, ['--socket', socketPath, '--token', tokenPath], {
// Why: detached daemons can outlive dev worktrees. Starting from
// userData keeps process.cwd() valid after a repo/worktree is deleted.
@@ -269,13 +303,17 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
// open, which would prevent Electron from exiting cleanly.
detached: true,
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
// Why: ELECTRON_RUN_AS_NODE makes the forked process run as a plain
// Node.js process instead of an Electron renderer/main process. Without
// it, Electron's GPU/display initialization can interfere with native
// module operations like node-pty's posix_spawn of the spawn-helper.
// Why: a standalone node.exe is already plain Node; reset execArgv so the
// Electron main process's node flags don't leak into it.
...(relocatedHostExecPath ? { execPath: relocatedHostExecPath, execArgv: [] } : {}),
env: {
...process.env,
ELECTRON_RUN_AS_NODE: '1',
// Why: ELECTRON_RUN_AS_NODE makes the forked Electron binary run as a
// plain Node.js process instead of an Electron main process. Without it,
// Electron's GPU/display initialization can interfere with native module
// operations like node-pty's posix_spawn of the spawn-helper. A relocated
// node.exe is already plain Node, so the var is omitted there.
...(relocatedHostExecPath ? {} : { ELECTRON_RUN_AS_NODE: '1' }),
// Why: the detached daemon is plain Node and cannot call Electron's
// app.getPath(), but shell-ready rcfiles must live outside swept tmp.
ORCA_USER_DATA_PATH: userDataPath
@@ -307,6 +345,7 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
// Already dead
}
}
forkSpan.fail(error)
reject(error)
}
function onReadyMessage(msg: unknown): void {
@@ -318,6 +357,10 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
// Why: the daemon process is detached after readiness; leaving
// startup listeners attached retains this launch promise closure.
cleanupStartupListeners()
if (child.pid !== undefined) {
forkSpan.setAttribute('daemon.pid', child.pid)
}
forkSpan.end()
if (child.pid) {
// Why: JSON pid file carries pid + process start time so later
// killStaleDaemon() can verify the pid still belongs to the daemon
+3
View File
@@ -578,6 +578,9 @@ if (hasSingleInstanceLock) {
initOpenCodeUsagePath()
// Why: must run before anything loads node-pty (first PTY spawn) so main
// and the daemon it forks both pick up ORCA_NODE_PTY_NATIVE_DIR.
// (The daemon-host node.exe relocation deliberately does NOT run here: its
// ~70MB copy would sit on the first-paint path. daemon-init stages it at
// fork time instead.)
installRelocatedNodePtyNativeRuntime()
crashReports = CrashReportStore.fromUserData()
recordCrashBreadcrumb('app_started', {
@@ -0,0 +1,251 @@
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'
import os from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { getDaemonPidPath, serializeDaemonPidFile } from '../daemon/daemon-spawner'
import type { DaemonPidFile } from '../daemon/daemon-spawner'
import {
collectInUseRuntimeVersions,
ensureRelocatedRuntime
} from './install-dir-runtime-relocation'
let tempDir: string
beforeEach(() => {
tempDir = mkdtempSync(join(os.tmpdir(), 'install-dir-relocation-'))
})
afterEach(() => {
try {
rmSync(tempDir, { recursive: true, force: true })
} catch {}
})
// A generic runtime tree with a nested dir and a .pdb symbol file, to exercise
// recursive copy and the symbol-exclusion rule independent of any one runtime.
function seedSourceDir(dir: string): void {
mkdirSync(join(dir, 'nested'), { recursive: true })
writeFileSync(join(dir, 'runtime.exe'), 'host')
writeFileSync(join(dir, 'runtime.dll'), 'lib')
writeFileSync(join(dir, 'runtime.pdb'), 'symbols')
writeFileSync(join(dir, 'nested', 'inner.bin'), 'inner')
}
// Writes a daemon pid file exactly as the daemon does (userData/daemon/daemon-v<N>.pid).
function writeDaemonPidFile(
daemonRuntimeDir: string,
protocolVersion: number,
pidFile: DaemonPidFile
): void {
mkdirSync(daemonRuntimeDir, { recursive: true })
writeFileSync(
getDaemonPidPath(daemonRuntimeDir, protocolVersion),
serializeDaemonPidFile(pidFile)
)
}
// Deterministic liveness that treats the given pids as running, so tests never
// depend on the host's real process table.
function aliveFor(...alivePids: number[]): (pid: number) => boolean {
const set = new Set(alivePids)
return (pid) => set.has(pid)
}
describe('collectInUseRuntimeVersions', () => {
it('returns an empty set when the daemon dir does not exist', () => {
expect(collectInUseRuntimeVersions(join(tempDir, 'no-daemon-dir')).size).toBe(0)
})
it('collects the app version a live daemon pins', () => {
const daemonDir = join(tempDir, 'daemon')
writeDaemonPidFile(daemonDir, 18, { pid: 4321, startedAtMs: null, appVersion: '1.4.124-rc.1' })
const inUse = collectInUseRuntimeVersions(daemonDir, aliveFor(4321))
expect([...inUse]).toEqual(['1.4.124-rc.1'])
})
it('omits the version of a daemon whose pid is dead', () => {
const daemonDir = join(tempDir, 'daemon')
writeDaemonPidFile(daemonDir, 18, { pid: 4321, startedAtMs: null, appVersion: '1.4.124-rc.1' })
const inUse = collectInUseRuntimeVersions(daemonDir, aliveFor(/* nobody */))
expect(inUse.size).toBe(0)
})
it('collects one version per live daemon across protocol versions', () => {
const daemonDir = join(tempDir, 'daemon')
writeDaemonPidFile(daemonDir, 18, { pid: 100, startedAtMs: null, appVersion: '1.4.124-rc.1' })
writeDaemonPidFile(daemonDir, 19, { pid: 200, startedAtMs: null, appVersion: '1.4.124-rc.2' })
const inUse = collectInUseRuntimeVersions(daemonDir, aliveFor(100, 200))
expect([...inUse].sort()).toEqual(['1.4.124-rc.1', '1.4.124-rc.2'])
})
it('ignores a daemon whose pid file records no app version', () => {
const daemonDir = join(tempDir, 'daemon')
// Pre-relocation daemon: JSON pid file without appVersion.
writeDaemonPidFile(daemonDir, 18, { pid: 4321, startedAtMs: null })
// Legacy daemon: bare-integer pid file (appVersion parses to null).
mkdirSync(daemonDir, { recursive: true })
writeFileSync(getDaemonPidPath(daemonDir, 17), '9999')
const inUse = collectInUseRuntimeVersions(daemonDir, aliveFor(4321, 9999))
expect(inUse.size).toBe(0)
})
it('ignores non-pid files in the daemon dir', () => {
const daemonDir = join(tempDir, 'daemon')
mkdirSync(daemonDir, { recursive: true })
writeFileSync(join(daemonDir, 'daemon-v18.token'), 'secret')
writeFileSync(join(daemonDir, 'daemon-v18.sock'), 'x')
writeFileSync(join(daemonDir, 'notes.txt'), 'x')
expect(collectInUseRuntimeVersions(daemonDir, aliveFor(1, 2, 3)).size).toBe(0)
})
it('skips a malformed pid file without throwing', () => {
const daemonDir = join(tempDir, 'daemon')
mkdirSync(daemonDir, { recursive: true })
writeFileSync(getDaemonPidPath(daemonDir, 18), 'not-a-pid-at-all')
writeDaemonPidFile(daemonDir, 19, { pid: 200, startedAtMs: null, appVersion: '2.0.0' })
const inUse = collectInUseRuntimeVersions(daemonDir, aliveFor(200))
expect([...inUse]).toEqual(['2.0.0'])
})
it('treats the current process as alive under the default liveness probe', () => {
const daemonDir = join(tempDir, 'daemon')
// startedAtMs null so startTimeMatches short-circuits true on every platform.
writeDaemonPidFile(daemonDir, 18, {
pid: process.pid,
startedAtMs: null,
appVersion: '3.0.0'
})
expect([...collectInUseRuntimeVersions(daemonDir)]).toEqual(['3.0.0'])
})
})
describe('ensureRelocatedRuntime', () => {
it('copies the runtime tree (without symbols) and returns the version dir', () => {
const sourceDir = join(tempDir, 'source')
seedSourceDir(sourceDir)
const destRoot = join(tempDir, 'dest')
const destDir = ensureRelocatedRuntime({
sourceDir,
destRoot,
version: '1.2.3',
daemonRuntimeDir: join(tempDir, 'daemon')
})
expect(destDir).toBe(join(destRoot, '1.2.3'))
expect(readFileSync(join(destDir!, 'runtime.exe'), 'utf8')).toBe('host')
expect(readFileSync(join(destDir!, 'runtime.dll'), 'utf8')).toBe('lib')
expect(readFileSync(join(destDir!, 'nested', 'inner.bin'), 'utf8')).toBe('inner')
expect(existsSync(join(destDir!, 'runtime.pdb'))).toBe(false)
})
it('skips recopying once the completion marker exists', () => {
const sourceDir = join(tempDir, 'source')
seedSourceDir(sourceDir)
const destRoot = join(tempDir, 'dest')
ensureRelocatedRuntime({
sourceDir,
destRoot,
version: '1.2.3',
daemonRuntimeDir: join(tempDir, 'daemon')
})
writeFileSync(join(sourceDir, 'runtime.exe'), 'changed-after-first-copy')
const destDir = ensureRelocatedRuntime({
sourceDir,
destRoot,
version: '1.2.3',
daemonRuntimeDir: join(tempDir, 'daemon')
})
expect(readFileSync(join(destDir!, 'runtime.exe'), 'utf8')).toBe('host')
})
it('redoes an interrupted copy that has no completion marker', () => {
const sourceDir = join(tempDir, 'source')
seedSourceDir(sourceDir)
const destRoot = join(tempDir, 'dest')
mkdirSync(join(destRoot, '1.2.3'), { recursive: true })
writeFileSync(join(destRoot, '1.2.3', 'runtime.exe'), 'torn partial copy')
const destDir = ensureRelocatedRuntime({
sourceDir,
destRoot,
version: '1.2.3',
daemonRuntimeDir: join(tempDir, 'daemon')
})
expect(readFileSync(join(destDir!, 'runtime.exe'), 'utf8')).toBe('host')
})
it('reclaims a stale version dir once no live daemon pins it', () => {
const sourceDir = join(tempDir, 'source')
seedSourceDir(sourceDir)
const destRoot = join(tempDir, 'dest')
const daemonRuntimeDir = join(tempDir, 'daemon')
ensureRelocatedRuntime({ sourceDir, destRoot, version: '1.0.0', daemonRuntimeDir })
// The 1.0.0 daemon exited, so its pid is dead — nothing pins 1.0.0.
writeDaemonPidFile(daemonRuntimeDir, 18, { pid: 4321, startedAtMs: null, appVersion: '1.0.0' })
const destDir = ensureRelocatedRuntime({
sourceDir,
destRoot,
version: '2.0.0',
daemonRuntimeDir,
isDaemonPidAlive: aliveFor(/* 4321 is dead */)
})
expect(destDir).toBe(join(destRoot, '2.0.0'))
expect(existsSync(join(destRoot, '1.0.0'))).toBe(false)
expect(existsSync(join(destRoot, '2.0.0', 'runtime.exe'))).toBe(true)
})
it('preserves a stale version dir a surviving daemon still pins', () => {
const sourceDir = join(tempDir, 'source')
seedSourceDir(sourceDir)
const destRoot = join(tempDir, 'dest')
const daemonRuntimeDir = join(tempDir, 'daemon')
ensureRelocatedRuntime({ sourceDir, destRoot, version: '1.0.0', daemonRuntimeDir })
// The 1.0.0 daemon survived the update and still loads its 1.0.0 runtime dir
// on demand — deleting it would strand the running daemon.
writeDaemonPidFile(daemonRuntimeDir, 18, { pid: 4321, startedAtMs: null, appVersion: '1.0.0' })
const destDir = ensureRelocatedRuntime({
sourceDir,
destRoot,
version: '2.0.0',
daemonRuntimeDir,
isDaemonPidAlive: aliveFor(4321)
})
expect(destDir).toBe(join(destRoot, '2.0.0'))
expect(existsSync(join(destRoot, '1.0.0', 'runtime.exe'))).toBe(true)
expect(existsSync(join(destRoot, '2.0.0', 'runtime.exe'))).toBe(true)
})
it('fails open when the source dir is missing', () => {
expect(
ensureRelocatedRuntime({
sourceDir: join(tempDir, 'does-not-exist'),
destRoot: join(tempDir, 'dest'),
version: '1.2.3',
daemonRuntimeDir: join(tempDir, 'daemon')
})
).toBeNull()
})
})
@@ -0,0 +1,145 @@
import {
copyFileSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync
} from 'node:fs'
import { join } from 'node:path'
import { parseDaemonPidFile, startTimeMatches } from '../daemon/daemon-health'
/**
* Relocates a per-version runtime directory out of the app install directory
* into userData, and reclaims old version dirs no live daemon still pins.
*
* Why: the Windows NSIS update installer force-closes every process (and, for
* the terminal runtime, every native binary loaded from a process) whose image
* lives under the install directory before replacing files. Copying the runtime
* to a version-keyed userData dir takes it out of that kill zone so the detached
* daemon and its PTYs survive updates. Both node-pty's native runtime and the
* daemon's own Node host share this machinery.
*/
export const RELOCATION_COMPLETE_MARKER = '.relocation-complete'
function copyRuntimeTree(sourceDir: string, destDir: string): void {
mkdirSync(destDir, { recursive: true })
for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
const sourcePath = join(sourceDir, entry.name)
const destPath = join(destDir, entry.name)
if (entry.isDirectory()) {
copyRuntimeTree(sourcePath, destPath)
} else if (entry.isFile() && !/\.pdb$/i.test(entry.name)) {
copyFileSync(sourcePath, destPath)
}
}
}
export type IsDaemonPidAlive = (pid: number, startedAtMs: number | null) => boolean
function isDaemonPidAliveDefault(pid: number, startedAtMs: number | null): boolean {
try {
process.kill(pid, 0)
} catch {
return false
}
return startTimeMatches(pid, startedAtMs)
}
/**
* Runtime version dirs still claimed by a live daemon, read from the daemon
* pid files under `daemonRuntimeDir` (`daemon-v<N>.pid`).
*
* Why: a daemon deliberately survives app updates, and it loads its version's
* relocated runtime (node-pty binaries, and — once relocated — its own Node
* host) on demand. Each pid file records that `appVersion`, so a live daemon's
* version dir must never be reclaimed while the process is still running.
*/
export function collectInUseRuntimeVersions(
daemonRuntimeDir: string,
isPidAlive: IsDaemonPidAlive = isDaemonPidAliveDefault
): Set<string> {
const inUse = new Set<string>()
let entries
try {
entries = readdirSync(daemonRuntimeDir, { withFileTypes: true })
} catch {
return inUse
}
for (const entry of entries) {
if (!entry.isFile() || !/^daemon-v\d+\.pid$/.test(entry.name)) {
continue
}
let parsed
try {
parsed = parseDaemonPidFile(readFileSync(join(daemonRuntimeDir, entry.name), 'utf8'))
} catch {
continue
}
// appVersion null => a pre-relocation daemon that loads from the install
// dir and thus pins no version dir here.
if (parsed && parsed.appVersion !== null && isPidAlive(parsed.pid, parsed.startedAtMs)) {
inUse.add(parsed.appVersion)
}
}
return inUse
}
function removeStaleRuntimeVersions(
destRoot: string,
keepVersion: string,
inUseVersions: ReadonlySet<string>
): void {
let entries
try {
entries = readdirSync(destRoot, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
if (!entry.isDirectory() || entry.name === keepVersion || inUseVersions.has(entry.name)) {
continue
}
// Why: Windows maps the daemon's binaries with FILE_SHARE_DELETE, so a
// rename/delete succeeds even while a surviving daemon still needs them —
// a rename heuristic would delete in-use dirs and strand the daemon. Only
// dirs no live daemon claims via its pid file are safe to remove.
try {
rmSync(join(destRoot, entry.name), { recursive: true, force: true })
} catch {
// Still locked or already gone — retry on a future launch.
}
}
}
/**
* Ensures `sourceDir` is copied to `destRoot/version` (once, guarded by a
* completion marker) and reclaims sibling version dirs no live daemon pins.
* Returns the version dir, or null on any failure (callers fail open to
* loading from the install dir — the pre-relocation behavior).
*/
export function ensureRelocatedRuntime(options: {
sourceDir: string
destRoot: string
version: string
daemonRuntimeDir: string
isDaemonPidAlive?: IsDaemonPidAlive
}): string | null {
const { sourceDir, destRoot, version, daemonRuntimeDir, isDaemonPidAlive } = options
const destDir = join(destRoot, version)
try {
// Why: the marker is written only after a full copy, so a crash mid-copy
// leaves no marker and the next launch redoes the copy from scratch.
if (!existsSync(join(destDir, RELOCATION_COMPLETE_MARKER))) {
rmSync(destDir, { recursive: true, force: true })
copyRuntimeTree(sourceDir, destDir)
writeFileSync(join(destDir, RELOCATION_COMPLETE_MARKER), '')
}
const inUseVersions = collectInUseRuntimeVersions(daemonRuntimeDir, isDaemonPidAlive)
removeStaleRuntimeVersions(destRoot, version, inUseVersions)
return destDir
} catch {
return null
}
}
@@ -1,4 +1,4 @@
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import os from 'node:os'
import { dirname, join } from 'node:path'
@@ -12,10 +12,8 @@ vi.mock('electron', () => ({
}
}))
import {
ensureRelocatedNodePtyNativeRuntime,
resolveNodePtyNativeSourceDir
} from './node-pty-runtime-relocation'
import { ensureRelocatedRuntime } from './install-dir-runtime-relocation'
import { resolveNodePtyNativeSourceDir } from './node-pty-runtime-relocation'
let tempDir: string
@@ -31,17 +29,6 @@ afterEach(() => {
} catch {}
})
function seedSourceDir(dir: string): void {
mkdirSync(join(dir, 'conpty'), { recursive: true })
writeFileSync(join(dir, 'conpty.node'), 'binding')
writeFileSync(join(dir, 'conpty_console_list.node'), 'listing')
writeFileSync(join(dir, 'pty.node'), 'winpty-binding')
writeFileSync(join(dir, 'winpty-agent.exe'), 'agent')
writeFileSync(join(dir, 'conpty.pdb'), 'symbols')
writeFileSync(join(dir, 'conpty', 'conpty.dll'), 'dll')
writeFileSync(join(dir, 'conpty', 'OpenConsole.exe'), 'console-host')
}
describe('resolveNodePtyNativeSourceDir', () => {
it('prefers the rebuilt build/Release binding over prebuilds', () => {
const pkg = join(tempDir, 'node-pty')
@@ -66,69 +53,6 @@ describe('resolveNodePtyNativeSourceDir', () => {
})
})
describe('ensureRelocatedNodePtyNativeRuntime', () => {
it('copies the runtime tree (without symbols) and returns the version dir', () => {
const sourceDir = join(tempDir, 'source')
seedSourceDir(sourceDir)
const destRoot = join(tempDir, 'dest')
const destDir = ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.2.3' })
expect(destDir).toBe(join(destRoot, '1.2.3'))
expect(readFileSync(join(destDir!, 'conpty.node'), 'utf8')).toBe('binding')
expect(readFileSync(join(destDir!, 'conpty', 'OpenConsole.exe'), 'utf8')).toBe('console-host')
expect(readFileSync(join(destDir!, 'conpty', 'conpty.dll'), 'utf8')).toBe('dll')
expect(existsSync(join(destDir!, 'conpty.pdb'))).toBe(false)
})
it('skips recopying once the completion marker exists', () => {
const sourceDir = join(tempDir, 'source')
seedSourceDir(sourceDir)
const destRoot = join(tempDir, 'dest')
ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.2.3' })
writeFileSync(join(sourceDir, 'conpty.node'), 'changed-after-first-copy')
const destDir = ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.2.3' })
expect(readFileSync(join(destDir!, 'conpty.node'), 'utf8')).toBe('binding')
})
it('redoes an interrupted copy that has no completion marker', () => {
const sourceDir = join(tempDir, 'source')
seedSourceDir(sourceDir)
const destRoot = join(tempDir, 'dest')
mkdirSync(join(destRoot, '1.2.3'), { recursive: true })
writeFileSync(join(destRoot, '1.2.3', 'conpty.node'), 'torn partial copy')
const destDir = ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.2.3' })
expect(readFileSync(join(destDir!, 'conpty.node'), 'utf8')).toBe('binding')
})
it('removes stale version dirs but keeps the current one', () => {
const sourceDir = join(tempDir, 'source')
seedSourceDir(sourceDir)
const destRoot = join(tempDir, 'dest')
ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.0.0' })
const destDir = ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '2.0.0' })
expect(destDir).toBe(join(destRoot, '2.0.0'))
expect(existsSync(join(destRoot, '1.0.0'))).toBe(false)
expect(existsSync(join(destRoot, '2.0.0', 'conpty.node'))).toBe(true)
})
it('fails open when the source dir is missing', () => {
expect(
ensureRelocatedNodePtyNativeRuntime({
sourceDir: join(tempDir, 'does-not-exist'),
destRoot: join(tempDir, 'dest'),
version: '1.2.3'
})
).toBeNull()
})
})
// Loads the real win32 conpty binding, so it cannot run on other platforms.
describe.runIf(process.platform === 'win32')('patched node-pty loader override', () => {
it('loads the conpty binding from ORCA_NODE_PTY_NATIVE_DIR', () => {
@@ -137,10 +61,11 @@ describe.runIf(process.platform === 'win32')('patched node-pty loader override',
const sourceDir = resolveNodePtyNativeSourceDir(nodePtyPackageDir)
expect(sourceDir).not.toBeNull()
const destDir = ensureRelocatedNodePtyNativeRuntime({
const destDir = ensureRelocatedRuntime({
sourceDir: sourceDir!,
destRoot: join(tempDir, 'relocated-runtime'),
version: 'loader-test'
version: 'loader-test',
daemonRuntimeDir: join(tempDir, 'daemon')
})
expect(destDir).not.toBeNull()
+9 -76
View File
@@ -1,15 +1,8 @@
import {
copyFileSync,
existsSync,
mkdirSync,
readdirSync,
renameSync,
rmSync,
writeFileSync
} from 'node:fs'
import { existsSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import { app } from 'electron'
import { ensureRelocatedRuntime } from './install-dir-runtime-relocation'
/**
* Relocates node-pty's Windows native runtime (conpty.node, conpty.dll,
@@ -25,8 +18,6 @@ import { app } from 'electron'
*/
export const NODE_PTY_NATIVE_DIR_ENV_VAR = 'ORCA_NODE_PTY_NATIVE_DIR'
const RELOCATION_COMPLETE_MARKER = '.relocation-complete'
export function resolveNodePtyNativeSourceDir(nodePtyPackageDir: string): string | null {
// Packaged builds carry the rebuilt binding in build/Release; dev installs
// (and the forced-relocation test path) load from prebuilds.
@@ -42,68 +33,6 @@ export function resolveNodePtyNativeSourceDir(nodePtyPackageDir: string): string
return null
}
function copyRuntimeTree(sourceDir: string, destDir: string): void {
mkdirSync(destDir, { recursive: true })
for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
const sourcePath = join(sourceDir, entry.name)
const destPath = join(destDir, entry.name)
if (entry.isDirectory()) {
copyRuntimeTree(sourcePath, destPath)
} else if (entry.isFile() && !/\.pdb$/i.test(entry.name)) {
copyFileSync(sourcePath, destPath)
}
}
}
function removeStaleRuntimeVersions(destRoot: string, keepVersion: string): void {
let entries
try {
entries = readdirSync(destRoot, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
if (!entry.isDirectory() || entry.name === keepVersion) {
continue
}
// Why: an adopted daemon from a previous version may still run (or later
// respawn) binaries out of its own version dir. Renaming a directory
// fails on Windows while anything inside is open, so a successful rename
// proves the dir is unused and safe to delete.
const doomedPath = join(destRoot, `${entry.name}.stale`)
try {
renameSync(join(destRoot, entry.name), doomedPath)
rmSync(doomedPath, { recursive: true, force: true })
} catch {
// Still in use (or already being cleaned) — retry on a future launch.
}
}
}
export function ensureRelocatedNodePtyNativeRuntime(options: {
sourceDir: string
destRoot: string
version: string
}): string | null {
const { sourceDir, destRoot, version } = options
const destDir = join(destRoot, version)
try {
// Why: the marker is written only after a full copy, so a crash mid-copy
// leaves no marker and the next launch redoes the copy from scratch.
if (!existsSync(join(destDir, RELOCATION_COMPLETE_MARKER))) {
rmSync(destDir, { recursive: true, force: true })
copyRuntimeTree(sourceDir, destDir)
writeFileSync(join(destDir, RELOCATION_COMPLETE_MARKER), '')
}
removeStaleRuntimeVersions(destRoot, version)
return destDir
} catch {
// Fail open: node-pty keeps loading from the install dir, which is the
// pre-relocation behavior (sessions then don't survive updates).
return null
}
}
export function installRelocatedNodePtyNativeRuntime(): void {
if (process.platform !== 'win32') {
return
@@ -126,10 +55,14 @@ export function installRelocatedNodePtyNativeRuntime(): void {
if (!sourceDir) {
return
}
const destDir = ensureRelocatedNodePtyNativeRuntime({
const userData = app.getPath('userData')
const destDir = ensureRelocatedRuntime({
sourceDir,
destRoot: join(app.getPath('userData'), 'node-pty-runtime'),
version: app.getVersion()
destRoot: join(userData, 'node-pty-runtime'),
version: app.getVersion(),
// Keyed to daemon-init's runtimeDir (userData/daemon), where surviving
// daemons write daemon-v<N>.pid recording the runtime version they pin.
daemonRuntimeDir: join(userData, 'daemon')
})
if (destDir) {
process.env[NODE_PTY_NATIVE_DIR_ENV_VAR] = destDir
+13 -14
View File
@@ -21,6 +21,10 @@ if (args[0] === 'api' && args.includes('rate_limit')) {
console.log(JSON.stringify({ resources: { core: { limit: 5000, remaining: 5000, reset: 0 }, graphql: { limit: 5000, remaining: 5000, reset: 0 }, search: { limit: 30, remaining: 30, reset: 0 } } }))
process.exit(0)
}
if (args[0] === 'api' && joined.includes('search/issues')) {
console.log(JSON.stringify({ total_count: 0, incomplete_results: false, items: [] }))
process.exit(0)
}
if (args[0] === 'issue' && args[1] === 'list') {
console.log('[]')
process.exit(0)
@@ -113,23 +117,18 @@ test('GitHub Tasks drawer recovers when gh stalls on issue details', async ({
return { repoId: repo.id }
}, testRepoPath)
// Why: GitHub item details render inline (a plain container, not a Radix
// dialog), so scope to the detail surface via its stable test id.
const drawer = orcaPage
.getByTestId('github-item-detail')
.filter({ hasText: 'Issue detail fetch that hangs in gh' })
await expect(drawer).toBeVisible()
// The item detail opens as an inline task-detail page (no longer a modal
// dialog): the item title heading proves it mounted.
const detailHeading = orcaPage.getByRole('heading', {
name: /Issue detail fetch that hangs in gh/
})
await expect(detailHeading).toBeVisible({ timeout: 10_000 })
// Why: this is the user-visible regression signal. Before ghExecFileAsync had
// a default timeout, the drawer's pending details promise never settled and
// the conversation pane stayed stuck in its loading shell. Since #6473 a
// failed detail fetch degrades to an explicit "unable to load" error rather
// than an empty shell, so the stable proof of recovery is that the drawer
// settles on that message and stops spinning instead of hanging.
await expect(drawer.getByText('Unable to load details for this GitHub item.')).toBeVisible({
// Why: the bounded gh timeout rejects instead of hanging, so this terminal
// error text (replacing the body, not a spinner) proves the stall recovered.
await expect(orcaPage.getByText('Unable to load details for this GitHub item.')).toBeVisible({
timeout: 5_000
})
await expect(drawer.locator('.animate-spin')).toHaveCount(0)
expect(repoId).toBeTruthy()
})
+151
View File
@@ -0,0 +1,151 @@
import type { Page } from '@stablyai/playwright-test'
import { expect } from './orca-app'
import type { CommitMessageAiSettings } from '../../../src/shared/types'
// Why: these specs create worktrees via raw `git worktree add`, which bypasses
// Orca's own add/remove path — the one thing that invalidates the main-process
// worktrees.list scan cache (DETECTED_WORKTREE_SCAN_CACHE_TTL_MS = 5_000). So a
// read inside the TTL window can serve a stale miss. No renderer-reachable
// cache-invalidation seam exists without adding product surface, so poll past
// the deterministic 5s boundary. Budget 10s (not 6s, which sits dangerously
// close to a 5s TTL if one scan is slow); the informative message keeps a
// genuinely never-loading worktree failing loudly.
const WORKTREE_CACHE_TTL_POLL_MS = 10_000
/**
* Loads the repo's worktrees into the renderer store and resolves the id of the
* worktree at `targetWorktreePath`, polling past the 5s scan-cache TTL so a
* raw `git worktree add` that Orca never observed still becomes visible.
*/
async function resolveE2eWorktreeId(
page: Page,
repoPath: string,
targetWorktreePath: string
): Promise<string> {
let worktreeId: string | null = null
await expect
.poll(
async () => {
worktreeId = await page.evaluate(
async ({ repoPath, targetWorktreePath }) => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
await store.getState().fetchRepos()
const repo = store.getState().repos.find((entry) => entry.path === repoPath)
if (!repo) {
throw new Error(`Seeded E2E repo was not registered: ${repoPath}`)
}
// Why: use the store's own fetch (like loadWorktreesUntilPathsPresent)
// so both TTL workarounds stay behaviorally identical.
await store.getState().fetchWorktrees(repo.id)
const listedWorktrees = store.getState().worktreesByRepo[repo.id] ?? []
const normalize = (value: string): string =>
value.startsWith('/private/var/') ? value.slice('/private'.length) : value
const worktree = listedWorktrees.find(
(entry) => normalize(entry.path) === normalize(targetWorktreePath)
)
return worktree?.id ?? null
},
{ repoPath, targetWorktreePath }
)
return worktreeId
},
{
timeout: WORKTREE_CACHE_TTL_POLL_MS,
message: `E2E worktree was not loaded within the worktree-cache TTL window: ${targetWorktreePath}`
}
)
.not.toBeNull()
if (!worktreeId) {
throw new Error(`E2E worktree was not loaded: ${targetWorktreePath}`)
}
return worktreeId
}
/**
* Shared setup for the Source Control specs: resolves the target worktree
* (surviving the scan-cache TTL race), activates it, optionally applies AI
* commit-message settings, loads its git status, and opens the Source Control
* sidebar tab.
*/
export async function openSourceControlForWorktree(
page: Page,
repoPath: string,
targetWorktreePath: string,
options: { commitMessageAi?: CommitMessageAiSettings } = {}
): Promise<void> {
const worktreeId = await resolveE2eWorktreeId(page, repoPath, targetWorktreePath)
await page.evaluate(
async ({ worktreeId, commitMessageAi }) => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
const worktree = Object.values(store.getState().worktreesByRepo)
.flat()
.find((entry) => entry.id === worktreeId)
if (!worktree) {
throw new Error(`E2E worktree disappeared from the store: ${worktreeId}`)
}
store.getState().setActiveWorktree(worktree.id)
if (commitMessageAi) {
await store.getState().updateSettings({ commitMessageAi })
}
const status = await window.api.git.status({ worktreePath: worktree.path })
store.getState().setGitStatus(worktree.id, status)
store.getState().setRightSidebarTab('source-control')
store.getState().setRightSidebarOpen(true)
},
{ worktreeId, commitMessageAi: options.commitMessageAi ?? null }
)
await expect
.poll(
async () =>
page.evaluate(() => {
const state = window.__store?.getState()
return Boolean(state?.rightSidebarOpen && state?.rightSidebarTab === 'source-control')
}),
{ timeout: 5_000 }
)
.toBe(true)
}
/**
* Polls `fetchWorktrees` past the scan-cache TTL until every expected path is
* registered in the store. Used by the Workspace Space git-status spec, which
* adds 60 worktrees via raw git and would otherwise read a stale partial scan.
*/
export async function loadWorktreesUntilPathsPresent(
page: Page,
repoId: string,
expectedPaths: string[]
): Promise<void> {
await expect
.poll(
async () =>
page.evaluate(
async ({ repoId, expectedPaths }) => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
await store.getState().fetchWorktrees(repoId)
const registered = new Set(
(store.getState().worktreesByRepo[repoId] ?? []).map((entry) => entry.path)
)
return expectedPaths.every((entry) => registered.has(entry))
},
{ repoId, expectedPaths }
),
{
timeout: WORKTREE_CACHE_TTL_POLL_MS,
message: `Not all worktrees registered within the worktree-cache TTL window (${expectedPaths.length} expected)`
}
)
.toBe(true)
}
+43 -20
View File
@@ -241,15 +241,17 @@ test.describe('Onboarding flow', () => {
.toBe(oppositeTheme)
await continueOnboarding(orcaPage)
// Why: the theme Continue persists step 2, then persists *through* any
// skipped optional steps (integrations is skipped when gh is installed,
// windows_terminal off macOS), so lastCompletedStep can land at 2, 3, or 4.
// Key off the settled "theme step committed" lower bound rather than a fixed
// window that assumed integrations always renders.
await expect
.poll(async () => (await getOnboardingState(orcaPage)).lastCompletedStep >= 2, {
.poll(async () => (await getOnboardingState(orcaPage)).lastCompletedStep, {
timeout: 5_000,
message: 'lastCompletedStep did not advance after second Continue'
message: 'lastCompletedStep did not advance past the theme step after second Continue'
})
// Why: when optional steps (Integrations/Windows terminal) are auto-skipped
// — e.g. gh is installed, so Integrations is skipped on Linux CI — the
// second Continue persists through them, landing lastCompletedStep past 3.
.toBe(true)
.toBeGreaterThanOrEqual(2)
await expect
.poll(async () => (await getSettings(orcaPage)).theme, { timeout: 5_000 })
.toBe(oppositeTheme)
@@ -421,11 +423,35 @@ test.describe('Onboarding flow', () => {
if (!store) {
throw new Error('window.__store is not available')
}
// Why: seed a live, protocol-compatible runtime status so env-e2e's
// execution host reads 'available' (selectable). Since #5362 a
// configured-but-statusless server correctly reads 'disconnected', which
// makes the Add Project dialog fall back to the Local host UI instead of
// the server UI this test asserts.
// Why: after #5071 the server-path add step gates on the registered
// runtime-environment list (store.runtimeEnvironments), not just the
// activeRuntimeEnvironmentId setting. Seed a redacted environment so the
// host option exists and the "on host" add UI renders.
const now = Date.now()
store.getState().setRuntimeEnvironments([
{
id: 'env-e2e',
name: 'E2E Server',
createdAt: now,
updatedAt: now,
lastUsedAt: null,
runtimeId: null,
source: 'manual',
endpoints: [
{
id: 'ws-env-e2e',
kind: 'websocket',
label: 'WebSocket',
endpoint: 'wss://e2e.invalid/ws'
}
],
preferredEndpointId: 'ws-env-e2e'
}
])
// Why: a runtime host is only auto-selectable (health 'available') when it
// has a live, protocol-compatible status; without one it reads
// 'disconnected' and the Add Project dialog falls back to Local Mac.
// runtimeProtocolVersion 3 clears MIN_COMPATIBLE_RUNTIME_SERVER_VERSION.
store.getState().setRuntimeEnvironmentStatus('env-e2e', {
status: {
runtimeId: 'env-e2e-runtime',
@@ -434,9 +460,10 @@ test.describe('Onboarding flow', () => {
authoritativeWindowId: null,
liveTabCount: 0,
liveLeafCount: 0,
runtimeProtocolVersion: 3
runtimeProtocolVersion: 3,
minCompatibleRuntimeClientVersion: 1
},
checkedAt: Date.now()
checkedAt: now
})
await store.getState().updateSettings({ activeRuntimeEnvironmentId: 'env-e2e' })
})
@@ -449,13 +476,9 @@ test.describe('Onboarding flow', () => {
await onboardingFooterButton(orcaPage, SKIP_TO_PROJECT_SETUP_BUTTON).click()
await expectAddProjectDialog(orcaPage)
// The runtime server host (env-e2e) is selected and reachable — this is the
// proof that Skip preserved the server context instead of falling back to
// Local. The setup actions ("… on this host") target that server.
await expect(orcaPage.getByText('env-e2e')).toBeVisible()
await expect(
orcaPage.getByText(/Existing Git repository or folder on this host/i)
).toBeVisible()
// The runtime env is selected as the Add Project host and the browse action
// is host-scoped, proving the server project-setup UI is preserved on skip.
await expect(orcaPage.getByText('Existing Git repository or folder on this host')).toBeVisible()
await expect(orcaPage.getByRole('button', { name: /Browse folder/i })).toBeVisible()
await expect(orcaPage.getByRole('button', { name: /Clone from URL/i })).toBeVisible()
await expect(orcaPage.getByRole('button', { name: /Create new project/i })).toBeVisible()
+6 -4
View File
@@ -113,10 +113,12 @@ async function typeHangulGanadaSlowly(
await page.waitForTimeout(200)
}
// Why: commit the composition the way a real IME does when the user accepts
// the buffer. Since #6238 the input defers persistence until compositionend,
// so without this the store never receives the composed syllables.
await session.send('Input.insertText', { text: `${committed}${pending}` })
// Why: a real IME commits the pending syllable (space/enter) at the end of a
// word, firing compositionend. Without this final commit the controlled
// input stays in composing state, so the component's defer-until-compositionend
// persist never runs. insertText replaces the composing region in place, so it
// finalizes to the same text rather than double-committing.
await session.send('Input.insertText', { text: committed + pending })
}
test.describe('Repository Display Name IME composition', () => {
@@ -4,6 +4,7 @@ import os from 'node:os'
import path from 'node:path'
import { test, expect } from './helpers/orca-app'
import { waitForSessionReady } from './helpers/store'
import { openSourceControlForWorktree } from './helpers/worktree-registration'
type E2eWorktree = {
branchName: string
@@ -46,79 +47,6 @@ function cleanupWorktree(repoPath: string, worktreePath: string, branchName: str
}
}
async function openSourceControlForWorktree(
page: Parameters<typeof waitForSessionReady>[0],
repoPath: string,
targetWorktreePath: string
): Promise<void> {
await page.evaluate(
async ({ repoPath, targetWorktreePath }) => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
const state = store.getState()
await state.fetchRepos()
const repo = store.getState().repos.find((entry) => entry.path === repoPath)
if (!repo) {
throw new Error(`Seeded E2E repo was not registered: ${repoPath}`)
}
const normalizeMacTmpPath = (value: string): string =>
value.startsWith('/private/var/') ? value.slice('/private'.length) : value
// Why: worktrees:list serves a 5s detection-scan cache that a raw
// `git worktree add` (done outside Orca) does not invalidate, so the
// freshly-created worktree can be briefly absent from the boot scan.
// Poll until the cache refreshes and surfaces it.
const deadline = Date.now() + 10_000
let listedWorktrees = await window.api.worktrees.list({ repoId: repo.id })
let worktree = listedWorktrees.find(
(entry) => normalizeMacTmpPath(entry.path) === normalizeMacTmpPath(targetWorktreePath)
)
while (!worktree && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 250))
listedWorktrees = await window.api.worktrees.list({ repoId: repo.id })
worktree = listedWorktrees.find(
(entry) => normalizeMacTmpPath(entry.path) === normalizeMacTmpPath(targetWorktreePath)
)
}
store.setState((current) => ({
worktreesByRepo: {
...current.worktreesByRepo,
[repo.id]: listedWorktrees
}
}))
if (!worktree) {
throw new Error(
`E2E worktree was not loaded: ${targetWorktreePath}; listed=${listedWorktrees
.map((entry) => entry.path)
.join(', ')}`
)
}
store.getState().setActiveWorktree(worktree.id)
const status = await window.api.git.status({ worktreePath: worktree.path })
store.getState().setGitStatus(worktree.id, status)
store.getState().setRightSidebarOpen(true)
store.getState().setRightSidebarTab('source-control')
},
{ repoPath, targetWorktreePath }
)
await expect
.poll(
async () =>
page.evaluate(() => {
const state = window.__store?.getState()
return Boolean(state?.rightSidebarOpen && state?.rightSidebarTab === 'source-control')
}),
{ timeout: 5_000 }
)
.toBe(true)
}
test.describe('Source Control commit draft persistence', () => {
test('preserves a typed draft when the sidebar tab remounts', async ({
orcaPage,
@@ -4,6 +4,7 @@ import os from 'node:os'
import path from 'node:path'
import { test, expect } from './helpers/orca-app'
import { waitForSessionReady } from './helpers/store'
import { openSourceControlForWorktree } from './helpers/worktree-registration'
function createWorktreeWithStagedChange(repoPath: string): {
branchName: string
@@ -50,80 +51,16 @@ test.describe('Source Control AI commit messages', () => {
try {
await waitForSessionReady(orcaPage)
await orcaPage.evaluate(
async ({ repoPath, worktreePath: targetWorktreePath, agentCommand: command }) => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
const state = store.getState()
await state.fetchRepos()
const repo = store.getState().repos.find((entry) => entry.path === repoPath)
if (!repo) {
throw new Error(`Seeded E2E repo was not registered: ${repoPath}`)
}
const normalizeMacTmpPath = (value: string): string =>
value.startsWith('/private/var/') ? value.slice('/private'.length) : value
// Why: worktrees:list serves a 5s detection-scan cache that a raw
// `git worktree add` (done outside Orca) does not invalidate, so the
// freshly-created worktree can be briefly absent from the boot scan.
// Poll until the cache refreshes and surfaces it.
const deadline = Date.now() + 10_000
let listedWorktrees = await window.api.worktrees.list({ repoId: repo.id })
let worktree = listedWorktrees.find(
(entry) => normalizeMacTmpPath(entry.path) === normalizeMacTmpPath(targetWorktreePath)
)
while (!worktree && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 250))
listedWorktrees = await window.api.worktrees.list({ repoId: repo.id })
worktree = listedWorktrees.find(
(entry) => normalizeMacTmpPath(entry.path) === normalizeMacTmpPath(targetWorktreePath)
)
}
store.setState((current) => ({
worktreesByRepo: {
...current.worktreesByRepo,
[repo.id]: listedWorktrees
}
}))
if (!worktree) {
throw new Error(
`E2E worktree was not loaded: ${targetWorktreePath}; listed=${listedWorktrees
.map((entry) => entry.path)
.join(', ')}`
)
}
store.getState().setActiveWorktree(worktree.id)
await store.getState().updateSettings({
commitMessageAi: {
enabled: true,
agentId: 'custom',
selectedModelByAgent: {},
selectedThinkingByModel: {},
customPrompt: '',
customAgentCommand: command
}
})
const status = await window.api.git.status({ worktreePath: worktree.path })
store.getState().setGitStatus(worktree.id, status)
store.getState().setRightSidebarTab('source-control')
store.getState().setRightSidebarOpen(true)
},
{ repoPath: testRepoPath, worktreePath, agentCommand }
)
await expect
.poll(
async () =>
orcaPage.evaluate(() => {
const state = window.__store?.getState()
return Boolean(state?.rightSidebarOpen && state?.rightSidebarTab === 'source-control')
}),
{ timeout: 5_000 }
)
.toBe(true)
await openSourceControlForWorktree(orcaPage, testRepoPath, worktreePath, {
commitMessageAi: {
enabled: true,
agentId: 'custom',
selectedModelByAgent: {},
selectedThinkingByModel: {},
customPrompt: '',
customAgentCommand: agentCommand
}
})
const textarea = orcaPage.getByRole('textbox', { name: 'Commit message' })
await expect(textarea).toBeVisible({ timeout: 10_000 })
+34 -44
View File
@@ -413,43 +413,6 @@ async function pressShiftedRussianLayoutKey(page: Page): Promise<{
})
}
// Why: handleRequestClosePane pops a "Close Terminal?" dialog when the pane
// reports a running child process. Under E2E, a freshly split pane's
// proc.process is briefly unset so the check returns true spuriously. Click
// Close when the dialog appears so the test's chord-routing assertion stays
// deterministic; no-op when it doesn't.
async function closeActiveSplitPaneWithChord(
page: Page,
mod: string,
expectedCountAfter: number
): Promise<void> {
// Guard: the split pane must still be present. Otherwise Cmd/Ctrl+W closes
// the last pane, which closes the tab and (on Linux) quits the whole app,
// surfacing later as a confusing "target page closed" instead of a clear
// "the split pane went away" signal.
await waitForPaneCount(page, expectedCountAfter + 1)
await focusActiveTerminal(page)
await page.keyboard.press(`${mod}+w`)
// The close is either immediate or gated behind a running-process
// confirmation dialog whose async hasChildProcesses check can surface it a
// beat late (a fixed short wait races it under CI load). Confirm the dialog
// whenever it appears and wait for the pane to actually drain.
await expect
.poll(
async () => {
// The running-process confirmation dialog's affirmative button is
// "Stop and Close" (not "Close").
const confirmButton = page.getByRole('button', { name: 'Stop and Close', exact: true })
if (await confirmButton.isVisible().catch(() => false)) {
await confirmButton.click().catch(() => {})
}
return countVisibleTerminalPanes(page)
},
{ timeout: 10_000, message: 'split pane did not close after Cmd/Ctrl+W' }
)
.toBe(expectedCountAfter)
}
async function pressAndExpectWrite(
page: Page,
app: ElectronApplication,
@@ -482,6 +445,34 @@ const mod = isMac ? 'Meta' : 'Control'
const splitVerticalChord = isMac ? `${mod}+d` : `${mod}+Shift+d`
const splitHorizontalChord = isMac ? `${mod}+Shift+d` : 'Alt+Shift+d'
// Why: a freshly split pane can transiently still report a running child, so
// poll for the confirm dialog and pane-count settling instead of a fixed wait.
async function closeActivePaneAndSettle(page: Page, expectedCount: number): Promise<void> {
await focusActiveTerminal(page)
await page.keyboard.press(`${mod}+w`)
// The "Stop running command?" confirm surfaces a "Stop and Close" action when
// the pane still reports a running child.
const confirmButton = page.getByRole('button', { name: /Stop and Close/i })
await expect
.poll(
async () => {
if (await confirmButton.isVisible().catch(() => false)) {
// Why: surface a click failure so a real actionability/strict-mode
// error isn't hidden behind the generic pane-count timeout.
await confirmButton.click().catch((err) => {
console.warn('closeActivePaneAndSettle: confirm click failed', err)
})
}
return countVisibleTerminalPanes(page)
},
{
timeout: 10_000,
message: `Expected ${expectedCount} visible terminal panes after close`
}
)
.toBe(expectedCount)
}
// Why: serial mode is load-bearing. Tests mutate shared Electron app state
// (pane layout, terminal buffer, expand toggle) and the pty:write spy log is
// a single main-process singleton. Parallel execution would interleave chord
@@ -771,6 +762,9 @@ test.describe('Terminal Shortcuts', () => {
await focusActiveTerminal(orcaPage)
await orcaPage.keyboard.press(splitVerticalChord)
await waitForPaneCount(orcaPage, panesBeforeSplit + 1)
// Why: ensure the new split pane's PTY is actually bound before we later
// close it, so the close cycle can't race an in-progress split.
await waitForActivePanePtyId(orcaPage)
// Cmd/Ctrl+] and Cmd/Ctrl+[ cycle focus (no pane-count change).
await focusActiveTerminal(orcaPage)
@@ -803,19 +797,15 @@ test.describe('Terminal Shortcuts', () => {
.toBe(false)
// Cmd/Ctrl+W closes the active split pane (not the whole tab: >1 pane).
// Why: the close handler checks hasChildProcesses async; a freshly
// spawned pane can transiently report a running child (node-pty's
// proc.process lags the spawn), which surfaces a confirmation dialog
// instead of closing immediately. Confirm it if it appears — the test
// only needs to prove the chord routed to the close handler.
await closeActiveSplitPaneWithChord(orcaPage, mod, panesBeforeSplit)
await closeActivePaneAndSettle(orcaPage, panesBeforeSplit)
// Split horizontally (chord varies by platform — see splitHorizontalChord).
const panesBeforeHSplit = await countVisibleTerminalPanes(orcaPage)
await focusActiveTerminal(orcaPage)
await orcaPage.keyboard.press(splitHorizontalChord)
await waitForPaneCount(orcaPage, panesBeforeHSplit + 1)
await closeActiveSplitPaneWithChord(orcaPage, mod, panesBeforeHSplit)
await waitForActivePanePtyId(orcaPage)
await closeActivePaneAndSettle(orcaPage, panesBeforeHSplit)
// Cmd/Ctrl+F toggles the search overlay.
await focusActiveTerminal(orcaPage)
+42 -33
View File
@@ -1,15 +1,21 @@
import { execFileSync } from 'node:child_process'
import { mkdtempSync, rmSync } from 'node:fs'
import { mkdtempSync, realpathSync, rmSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { test, expect } from './helpers/orca-app'
import { loadWorktreesUntilPathsPresent } from './helpers/worktree-registration'
test.describe('Workspace Space git status checks', () => {
test('checks every scanned deletable row, including rows after the first 50', async ({
orcaPage,
testRepoPath
}) => {
const worktreeParent = mkdtempSync(path.join(os.tmpdir(), 'orca-space-git-status-'))
// Why: on symlinked tmpdirs (/var→/private/var on macOS, /tmp→… on CI) Orca
// registers worktrees under their realpath, so the parent must be canonical
// before `git worktree add` or the recorded paths won't match and rows drop.
const worktreeParent = realpathSync(
mkdtempSync(path.join(os.tmpdir(), 'orca-space-git-status-'))
)
const worktreePaths = Array.from({ length: 60 }, (_, index) =>
path.join(worktreeParent, `worktree-${index}`)
)
@@ -21,54 +27,57 @@ test.describe('Workspace Space git status checks', () => {
stdio: 'pipe'
})
}
const registeredWorktreePaths = worktreePaths.map((worktreePath) =>
realpathSync(worktreePath)
)
const repoId = await orcaPage.evaluate((testRepoPath) => {
const store = window.__store
if (!store) {
throw new Error('Expected e2e store to be exposed')
}
const repo = store.getState().repos.find((item) => item.path === testRepoPath)
if (!repo) {
throw new Error('Expected test repo to be loaded')
}
return repo.id
}, testRepoPath)
// Why: the 60 worktrees were added via raw git, so poll past the 5s scan
// cache TTL until every path registers before deriving the space rows.
await loadWorktreesUntilPathsPresent(orcaPage, repoId, registeredWorktreePaths)
await orcaPage.evaluate(
async ({ testRepoPath, worktreeParent, expectedCount }) => {
async ({ testRepoPath, worktreePaths }) => {
const store = window.__store
if (!store) {
throw new Error('Expected e2e store to be exposed')
}
const repo = store.getState().repos.find((item) => item.path === testRepoPath)
const initialState = store.getState()
const repo = initialState.repos.find((item) => item.path === testRepoPath)
if (!repo) {
throw new Error('Expected test repo to be loaded')
}
await window.api.git.status({ worktreePath: worktreePaths[0] })
// Why: worktrees are created via raw `git worktree add`, which git
// records with the path as given (not symlink-canonicalized), so
// match against the git-reported store paths rather than realpathSync'd
// ones — otherwise on macOS the /var vs /private/var mismatch makes
// git:status reject the probe as an unregistered path. Also poll:
// fetchWorktrees serves a 5s scan cache that raw adds don't invalidate.
const normalizeMacTmpPath = (value: string): string =>
value.startsWith('/private/var/') ? value.slice('/private'.length) : value
const parentKey = normalizeMacTmpPath(worktreeParent)
const deadline = Date.now() + 20_000
const findCreatedWorktrees = () =>
(store.getState().worktreesByRepo[repo.id] ?? []).filter((worktree) =>
normalizeMacTmpPath(worktree.path).startsWith(`${parentKey}/`)
)
await store.getState().fetchWorktrees(repo.id)
let worktrees = findCreatedWorktrees()
while (worktrees.length < expectedCount && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 250))
await store.getState().fetchWorktrees(repo.id)
worktrees = findCreatedWorktrees()
}
if (worktrees.length !== expectedCount) {
const state = store.getState()
const expectedPaths = new Set(worktreePaths)
const worktrees = (state.worktreesByRepo[repo.id] ?? []).filter((worktree) =>
expectedPaths.has(worktree.path)
)
if (worktrees.length !== worktreePaths.length) {
throw new Error(
`Expected ${expectedCount} registered worktrees, got ${
`Expected ${worktreePaths.length} registered worktrees, got ${
worktrees.length
} from ${(store.getState().worktreesByRepo[repo.id] ?? []).length}: ${(
store.getState().worktreesByRepo[repo.id] ?? []
} from ${(state.worktreesByRepo[repo.id] ?? []).length}: ${(
state.worktreesByRepo[repo.id] ?? []
)
.slice(0, 5)
.map((worktree) => worktree.path)
.join(', ')}`
)
}
// Probe with a git-reported store path (a registered worktree root).
await window.api.git.status({ worktreePath: worktrees[0].path })
const rows = worktrees.map((worktree, index) => ({
worktreeId: worktree.id,
repoId: repo.id,
@@ -121,7 +130,7 @@ test.describe('Workspace Space git status checks', () => {
})
store.getState().openSpacePage()
},
{ testRepoPath, worktreeParent, expectedCount: worktreePaths.length }
{ testRepoPath, worktreePaths: registeredWorktreePaths }
)
await expect
+39 -44
View File
@@ -19,7 +19,6 @@
* crash in whatever replaces it.
*/
import { execFileSync } from 'node:child_process'
import type { ConsoleMessage } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import {
@@ -217,24 +216,12 @@ test.describe('Create Workspace', () => {
test('reuses a resolved pasted GitHub URL when quick create submits', async ({
electronApp,
orcaPage,
testRepoPath
orcaPage
}) => {
const title = `E2E smart URL resolution ${Date.now()}`
const url = 'https://github.com/stablyai/orca/pull/2049'
const linkedWorkspacePattern = new RegExp(title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
// Why: create builds from whatever base resolvePrBase resolves to. The
// disposable fixture repo has no `origin`, so a remote-tracking base like
// `origin/main` can't resolve. Resolve to the fixture's actual local HEAD
// branch instead so `git worktree add` succeeds offline — without adding an
// origin remote, which would perturb the pasted-URL work-item lookup below.
const baseBranch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
cwd: testRepoPath
})
.toString()
.trim()
try {
await orcaPage.getByRole('button', { name: 'New workspace', exact: true }).click()
@@ -243,7 +230,7 @@ test.describe('Create Workspace', () => {
await expect(dialog.locator('[data-workspace-name-input="true"]')).toBeVisible()
await electronApp.evaluate(
({ ipcMain }, { title, url, baseBranch }) => {
({ ipcMain }, { title, url }) => {
const counters = globalThis as unknown as {
__smartGitHubLookupCount: number
__smartResolvePrBaseCount: number
@@ -276,40 +263,21 @@ test.describe('Create Workspace', () => {
}
)
ipcMain.removeHandler('worktrees:resolvePrBase')
// Why: the fixture repo has no remote and its default branch name
// depends on the host's git init.defaultBranch (main vs master), so
// resolve the PR base to HEAD, which always exists regardless.
ipcMain.handle('worktrees:resolvePrBase', () => {
counters.__smartResolvePrBaseCount += 1
return { baseBranch }
return { baseBranch: 'HEAD' }
})
},
{ title, url, baseBranch }
{ title, url }
)
const nameInput = dialog.getByPlaceholder(/Type a name/i)
await expect(nameInput).toBeVisible()
await nameInput.fill(url)
// Why: wait for the pasted URL to resolve via a single work-item lookup.
// The base resolution (resolvePrBase) fires separately and its timing
// relative to this point is not deterministic, so only the lookup is
// asserted here; the reuse invariant is checked in full after create.
await expect
.poll(() =>
electronApp.evaluate(() => {
const counters = globalThis as unknown as { __smartGitHubLookupCount?: number }
return counters.__smartGitHubLookupCount ?? -1
})
)
.toBe(1)
const createButton = dialog.getByRole('button', { name: /Create (Workspace|Worktree)/i })
await expect(createButton).toBeEnabled()
await createButton.click()
await expect(dialog).toBeHidden({ timeout: 15_000 })
await expect(orcaPage.getByRole('option', { name: linkedWorkspacePattern })).toBeVisible({
timeout: 10_000
})
await expect(orcaPage.getByRole('option', { name: url })).toHaveCount(0)
await expect(orcaPage.getByText('Linked PR #2049')).toBeVisible()
await expect
.poll(() =>
electronApp.evaluate(() => {
@@ -323,8 +291,34 @@ test.describe('Create Workspace', () => {
}
})
)
// Why: counts are unchanged from before create — the submit reused the
// already-resolved URL/base instead of looking them up again.
.toEqual({ githubLookupCount: 1, resolvePrBaseCount: 0 })
const createButton = dialog.getByRole('button', { name: /Create (Workspace|Worktree)/i })
await expect(createButton).toBeEnabled()
await createButton.click()
await expect(dialog).toBeHidden({ timeout: 15_000 })
await expect(orcaPage.getByRole('option', { name: linkedWorkspacePattern })).toBeVisible({
timeout: 10_000
})
await expect(orcaPage.getByRole('option', { name: url })).toHaveCount(0)
await expect(orcaPage.getByText('Linked PR #2049')).toBeVisible()
// Why: quick create reuses the single GitHub lookup from typing (no
// redundant re-fetch), and since #5733 ("Create PR worktrees from the PR
// head") it resolves the PR start point exactly once at submit time — so
// the base resolves once here rather than being skipped.
await expect
.poll(() =>
electronApp.evaluate(() => {
const counters = globalThis as unknown as {
__smartGitHubLookupCount?: number
__smartResolvePrBaseCount?: number
}
return {
githubLookupCount: counters.__smartGitHubLookupCount ?? -1,
resolvePrBaseCount: counters.__smartResolvePrBaseCount ?? -1
}
})
)
.toEqual({ githubLookupCount: 1, resolvePrBaseCount: 1 })
} finally {
await orcaPage
@@ -371,9 +365,10 @@ test.describe('Create Workspace', () => {
})
)
ipcMain.removeHandler('worktrees:resolvePrBase')
// Why: the fixture repo's default branch is master and has no
// remote; resolve the PR base to a ref that actually exists.
ipcMain.handle('worktrees:resolvePrBase', () => ({ baseBranch: 'master' }))
// Why: the fixture repo has no remote and its default branch name
// depends on the host's git init.defaultBranch (main vs master), so
// resolve the PR base to HEAD, which always exists regardless.
ipcMain.handle('worktrees:resolvePrBase', () => ({ baseBranch: 'HEAD' }))
},
{ title, url }
)