Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,8 @@ jobs:
. != "tests/e2e/paired-startup-exec-readiness.spec.ts" and
. != "tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts" and
. != "tests/e2e/ssh-localhost.spec.ts" and
. != "tests/e2e/terminal-ibus-hangul-native.spec.ts"
. != "tests/e2e/terminal-ibus-hangul-native.spec.ts" and
. != "tests/e2e/orcad-serve-mode-switch.spec.ts"
)' <<<"$TEST_FILES_JSON" > "$RUNNER_TEMP/general-e2e-specs"
fi
mapfile -t TEST_FILES < "$RUNNER_TEMP/general-e2e-specs"
Expand Down Expand Up @@ -487,6 +488,48 @@ jobs:
ORCA_RUN_DOCKER_SSH_BROWSER_E2E: '1'
run: node_modules/.bin/vitest run --config config/vitest.config.ts tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts

orcad-serve-mode-switch:
name: orca serve Electron/orcad mode switch (D7)
needs: [build, prepare-native-cache]
if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/orcad-serve-mode-switch.spec.ts')
runs-on: ubuntu-latest
timeout-minutes: 30
env:
ORCA_BACKGROUND_LAUNCH: '1'
steps:
- uses: actions/checkout@v6
with:
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
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: electron
- uses: actions/download-artifact@v8
with:
name: e2e-build-out
path: out/
# The packaged orcad slot the T6-11 launcher runs: its own node-pty under the pinned Node.
- name: Build this runner's orcad slot
run: |
pnpm build:orcad-prebuilds
pnpm build:orcad-prebuilds --require-slots "$(node config/scripts/build-orcad-prebuilds.mjs --print-slot)"
pnpm build:orcad
- name: Switch serve hosts on one profile
env:
SKIP_BUILD: '1'
ORCA_E2E_ORCAD_SERVE: '1'
ORCA_E2E_FORWARD_APP_LOGS: '1'
run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/orcad-serve-mode-switch.spec.ts --project=electron-headless --workers=1
- uses: actions/upload-artifact@v7
if: failure()
with:
name: orcad-serve-mode-switch-traces
path: test-results/
retention-days: 7
if-no-files-found: ignore

ssh-localhost:
name: localhost SSH terminal and hooks
needs: [build, prepare-native-cache]
Expand Down
12 changes: 10 additions & 2 deletions config/scripts/ci-e2e-job-selection.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,14 @@ export const NODE_NETWORK_E2E_SPEC =
'tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts'
export const LOCALHOST_SSH_E2E_SPEC = 'tests/e2e/ssh-localhost.spec.ts'
export const NATIVE_IME_E2E_SPEC = 'tests/e2e/terminal-ibus-hangul-native.spec.ts'
// Needs the packaged orcad slot, which only its own job builds.
export const ORCAD_SERVE_MODE_SWITCH_E2E_SPEC = 'tests/e2e/orcad-serve-mode-switch.spec.ts'
export const DEDICATED_E2E_SPECS = [
...DOCKER_SSH_E2E_SPECS,
NODE_NETWORK_E2E_SPEC,
LOCALHOST_SSH_E2E_SPEC,
NATIVE_IME_E2E_SPEC
NATIVE_IME_E2E_SPEC,
ORCAD_SERVE_MODE_SWITCH_E2E_SPEC
]
const dedicatedSpecs = new Set(DEDICATED_E2E_SPECS)
const dockerSpecs = new Set(DOCKER_SSH_E2E_SPECS)
Expand Down Expand Up @@ -76,7 +79,12 @@ export function classifyE2eJobs(input, sshSourceChanged = 'false') {
e2e_needs_build:
runChanged ||
sshSourceChanged !== 'false' ||
specs.some((spec) => dockerSpecs.has(spec) || spec === LOCALHOST_SSH_E2E_SPEC)
specs.some(
(spec) =>
dockerSpecs.has(spec) ||
spec === LOCALHOST_SSH_E2E_SPEC ||
spec === ORCAD_SERVE_MODE_SWITCH_E2E_SPEC
)
}
}

Expand Down
9 changes: 9 additions & 0 deletions config/scripts/pr-e2e-source-routing.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ const NATIVE_IME_HARNESS =
/^(?:config\/scripts\/focus-nested-wayland-terminal\.sh$|config\/scripts\/(?:run-terminal-ibus-hangul-e2e|terminal-ime-engagement-receipt)\.mjs$|tests\/e2e\/terminal-ime-(?:boundary-probe|byte-reader|engagement-receipt)\.ts$|tests\/e2e\/terminal-(?:ibus-hangul|hangul-terminating-digit|macos-2set-korean)-native\.spec\.ts$)/

export const PR_E2E_SOURCE_ROUTES = [
{
id: 'serve.orcad-mode-switch',
specs: ['tests/e2e/orcad-serve-mode-switch.spec.ts'],
matches: (file) =>
isProductSource(file) &&
/^src\/(?:cli\/runtime\/(?:launch|serve-)|main\/orcad\/(?:main|orcad-entry|orcad-instance-lock|orcad-command-arguments|orcad-lifecycle)\.ts$|main\/startup\/desktop-profile-instance-lock\.ts$|main\/daemon\/daemon-(?:spawner|endpoint-adoption|init)|main\/server\/serve-)/.test(
file
)
},
{
id: 'ssh.localhost-agent-hooks',
specs: ['tests/e2e/ssh-localhost.spec.ts'],
Expand Down
1 change: 1 addition & 0 deletions config/scripts/ssh-docker-ci-sharding.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ it('gives every removed SSH spec a dedicated owner even for test-only edits', ()
expect(job.if, spec).toContain(`contains(inputs.test_files, '${spec}')`)
}
expect(excluded.filter((spec) => !owned.includes(spec)).sort()).toEqual([
'tests/e2e/orcad-serve-mode-switch.spec.ts',
'tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts',
'tests/e2e/ssh-localhost.spec.ts',
'tests/e2e/terminal-ibus-hangul-native.spec.ts'
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/helpers/daemon-generation-runtime-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ function assertDisposableRoot(rootDir: string): void {
}
}

function resolveElectronExecutable(repoRoot: string): string {
export function resolveElectronExecutable(repoRoot: string): string {
const relativePath = readFileSync(
path.join(repoRoot, 'node_modules', 'electron', 'path.txt'),
'utf8'
Expand Down
6 changes: 6 additions & 0 deletions tests/e2e/helpers/headless-paired-runtime-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ export type HeadlessPairedRuntimeHost = {
betweenProcesses?: () => void | Promise<void>
}) => Promise<void>
userDataDir: string
/** The isolated launch environment, for another host started on the same profile. */
env: NodeJS.ProcessEnv
/** Electron's own argv for this serve host, without the serve flags. */
electronArgs: string[]
}

type HeadlessHostCleanup = () => Promise<void> | void
Expand Down Expand Up @@ -143,6 +147,8 @@ export async function launchHeadlessPairedRuntimeHost(
client: new RuntimeClient(userDataDir, 5_000),
offer,
userDataDir,
env: isolation.env,
electronArgs: options.executablePath ? [] : getOrcaElectronLaunchArgs(mainPath, false),
restartServeProcess: async (restartOptions = {}) => {
if (options.pinnedServePort !== true) {
throw new Error(
Expand Down
247 changes: 247 additions & 0 deletions tests/e2e/orcad-serve-mode-switch.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
/**
* D7 across a real `orca serve` host switch: a terminal opened under Electron serve survives a
* switch to orcad serve on the same profile (same daemon adopted, terminal reattached) and back,
* and a terminal orcad's daemon owns survives a switch to Electron serve. Also the shared-profile
* lock: each host refuses to serve a profile the other holds.
*
* Needs the packaged orcad slot (`pnpm build:orcad`). Background launch only; nothing is focused.
*
* Run:
* ORCA_E2E_ORCAD_SERVE=1 ORCA_BACKGROUND_LAUNCH=1 pnpm exec playwright test \
* tests/e2e/orcad-serve-mode-switch.spec.ts --config tests/playwright.config.ts \
* --project electron-headless --workers=1
*/
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { runProcess, spawnProcess } from '../../src/shared/child-process/run-process'
import { getDaemonPidPath } from '../../src/main/daemon/daemon-spawner'
import { readDaemonPidRecord } from '../../src/main/daemon/daemon-endpoint-incarnation'
import { resolveBundledOrcadRuntime } from '../../src/main/orcad/orcad-bundled-runtime'
import {
buildDaemonSessionClient,
type DaemonSessionClientResult
} from '../../src/main/orcad/orcad-daemon-session-client-fixture'
import { expect, test } from './helpers/orca-app'
import { resolveElectronExecutable } from './helpers/daemon-generation-runtime-fixture'
import {
launchHeadlessPairedRuntimeHost,
type HeadlessPairedRuntimeHost
} from './helpers/headless-paired-runtime-host'

const RUN = process.env.ORCA_E2E_ORCAD_SERVE === '1'
const slotDir = path.resolve('out/orcad')
const orcadRuntime = RUN && existsSync(slotDir) ? resolveBundledOrcadRuntime(slotDir) : null
const SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE = 3
const ORCAD_EXIT_CONFIGURATION = 78

test.describe.configure({ mode: 'serial' })

let scratch = ''
let clientScript = ''

test.beforeAll(async () => {
test.skip(!RUN, 'Set ORCA_E2E_ORCAD_SERVE=1 (and build out/orcad) to run the serve mode switch.')
expect(orcadRuntime, 'pnpm build:orcad must produce a packaged slot in out/orcad').toBeTruthy()
// Short root: macOS caps a unix socket path at 104 bytes, and the daemon's lives in userData.
scratch = mkdtempSync(path.join(process.platform === 'win32' ? os.tmpdir() : '/tmp', 'orca-d7-'))
clientScript = path.join(scratch, 'daemon-client.cjs')
await buildDaemonSessionClient(clientScript)
})

test.afterAll(() => {
if (scratch) {
rmSync(scratch, { recursive: true, force: true })
}
})

function daemonPid(userDataDir: string): number | null {
return readDaemonPidRecord(getDaemonPidPath(path.join(userDataDir, 'daemon')))?.pid ?? null
}

/** A paired client's terminal, straight to the profile's daemon, under this runner's Node. */
async function terminal(
host: HeadlessPairedRuntimeHost,
op: 'create' | 'attach',
marker: string
): Promise<DaemonSessionClientResult> {
const result = await runProcess({
program: process.execPath,
args: [clientScript, op, path.join(host.userDataDir, 'daemon'), 'serve-d7', marker, scratch],
env: host.env,
timeoutMs: 30_000
})
expect(result.code, result.stderr).toBe(0)
return JSON.parse(result.stdout.trim().split('\n').at(-1) ?? '{}')
}

type OrcadServe = { daemonPid: number; stop: () => Promise<void> }

/** `orca serve` on orcad, as the T6-11 launcher runs it, on the host's own profile. */
async function startOrcadServe(host: HeadlessPairedRuntimeHost): Promise<OrcadServe> {
const child = spawnProcess({
program: orcadRuntime!,
args: [path.join(slotDir, 'orcad.js'), '--bind', '127.0.0.1', '--port', '0', '--json'],
env: { ...host.env, ORCA_USER_DATA: host.userDataDir },
timeoutMs: null
})
let stdout = ''
let stderr = ''
child.stderr.on('data', (chunk: Buffer) => (stderr += chunk.toString('utf8')))
const line = await new Promise<string>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`orcad serve not ready: ${stderr}`)), 120_000)
child.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString('utf8')
const newline = stdout.indexOf('\n')
if (newline !== -1) {
clearTimeout(timer)
resolve(stdout.slice(0, newline))
}
})
child.once('exit', (code) => {
clearTimeout(timer)
reject(new Error(`orcad serve exited ${String(code)}: ${stderr}`))
})
})
const daemon = JSON.parse(line).health?.terminalDaemon
expect(daemon?.state, stderr).toBe('live')
return {
daemonPid: daemon.pid,
stop: async () => {
if (child.exitCode !== null) {
return
}
const exited = new Promise((settle) => child.once('exit', settle))
// POSIX: SIGTERM is orcad's graceful stop and leaves the daemon running by design.
child.kill('SIGTERM')
await exited
}
}
}

test('a terminal survives Electron serve → orcad serve → Electron serve on one profile', async () => {
const host = await launchHeadlessPairedRuntimeHost({
pinnedServePort: true,
userDataParent: scratch
})
try {
const created = await terminal(host, 'create', 'ELECTRON')
expect(created).toMatchObject({ isReattach: false, output: true })
const electronDaemon = daemonPid(host.userDataDir)
expect(electronDaemon).toBeTruthy()

await host.restartServeProcess({
betweenProcesses: async () => {
// Electron serve has quit; orcad serve takes the same profile and adopts its daemon.
const orcad = await startOrcadServe(host)
try {
expect(orcad.daemonPid).toBe(electronDaemon)
expect(await terminal(host, 'attach', 'ORCAD')).toEqual({
pid: created.pid,
isReattach: true,
output: true
})
} finally {
await orcad.stop()
}
}
})

// Back on Electron serve: still the same daemon and the same live terminal.
expect(daemonPid(host.userDataDir)).toBe(electronDaemon)
expect(await terminal(host, 'attach', 'BACK')).toEqual({
pid: created.pid,
isReattach: true,
output: true
})
} finally {
await host.dispose()
}
})

test("Electron serve adopts a terminal orcad's daemon owns", async () => {
const host = await launchHeadlessPairedRuntimeHost({
pinnedServePort: true,
userDataParent: scratch
})
try {
let created: DaemonSessionClientResult | null = null
let orcadDaemon: number | null = null
await host.restartServeProcess({
betweenProcesses: async () => {
// Retire Electron's daemon so orcad serve forks its own on the pinned Node.
const previous = daemonPid(host.userDataDir)
if (previous) {
process.kill(previous, 'SIGKILL')
await expect.poll(() => isAlive(previous), { timeout: 10_000 }).toBe(false)
}
const orcad = await startOrcadServe(host)
try {
orcadDaemon = orcad.daemonPid
expect(orcadDaemon).not.toBe(previous)
created = await terminal(host, 'create', 'ORCAD')
expect(created).toMatchObject({ isReattach: false, output: true })
} finally {
await orcad.stop()
}
}
})
expect(daemonPid(host.userDataDir)).toBe(orcadDaemon)
expect(await terminal(host, 'attach', 'ELECTRON')).toEqual({
pid: created!.pid,
isReattach: true,
output: true
})
} finally {
await host.dispose()
}
})

test('each serve host refuses a profile the other holds', async () => {
const host = await launchHeadlessPairedRuntimeHost({
pinnedServePort: true,
userDataParent: scratch
})
try {
// Electron serve holds the profile: orcad serve refuses with its configuration exit code.
const refused = await runProcess({
program: orcadRuntime!,
args: [path.join(slotDir, 'orcad.js'), '--bind', '127.0.0.1', '--port', '0', '--json'],
env: { ...host.env, ORCA_USER_DATA: host.userDataDir },
timeoutMs: 60_000
})
expect(refused.code).toBe(ORCAD_EXIT_CONFIGURATION)
expect(refused.stdout).toBe('')
expect(refused.stderr).toContain('The Orca desktop app')

await host.restartServeProcess({
betweenProcesses: async () => {
// orcad serve holds it: an Electron launch on the same profile exits as a duplicate.
const orcad = await startOrcadServe(host)
try {
const electron = await runProcess({
program: resolveElectronExecutable(process.cwd()),
args: [...host.electronArgs, '--serve', '--serve-json', '--serve-port', '0'],
env: host.env,
timeoutMs: 90_000
})
expect(electron.code).toBe(SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE)
expect(`${electron.stdout}${electron.stderr}`).toContain('Another orcad')
} finally {
await orcad.stop()
}
}
})
} finally {
await host.dispose()
}
})

function isAlive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
Loading