mirror of
https://github.com/stablyai/orca.git
synced 2026-10-13 00:02:54 +00:00
test(updater): capture Windows auto-relaunch boundary
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
name: PR 18505 disposable packaged update QA
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'qa/pr18505-hosted-update-final'
|
||||
paths:
|
||||
- '.github/workflows/pr18505-hosted-update.yml'
|
||||
- 'tests/tools/pr18505-hosted-update/**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
windows-in-app-update:
|
||||
name: windows-control-${{ matrix.traffic-mode }}
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
traffic-mode: [none, until-old-exit]
|
||||
env:
|
||||
SOURCE_TAG: v1.4.198-adhoc.20260904205337
|
||||
FINAL_TAG: v1.4.198-adhoc.20260904215141
|
||||
FINAL_VERSION: 1.4.198-adhoc.20260904215141
|
||||
HIGHER_TAG: v1.4.198-adhoc.20260904215158
|
||||
HIGHER_VERSION: 1.4.198-adhoc.20260904215158
|
||||
EXPECTED_HEAD: 1b3077382d1d77bb459ec6c937940d64f5fde812
|
||||
ORCA_QA_SINGLE_HOP: '1'
|
||||
ORCA_QA_TRAFFIC_MODE: ${{ matrix.traffic-mode }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 2
|
||||
persist-credentials: false
|
||||
- name: Verify QA commit parent
|
||||
shell: bash
|
||||
run: test "$(git rev-parse HEAD^)" = "$EXPECTED_HEAD"
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
- name: Verify releases and download older source installer
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force artifacts/source | Out-Null
|
||||
$source = gh release view $env:SOURCE_TAG --repo stablyai/orca-adhoc --json isDraft,isPrerelease,assets | ConvertFrom-Json
|
||||
if ($source.isDraft -or -not $source.isPrerelease) { throw "$env:SOURCE_TAG is not a published prerelease" }
|
||||
if (-not ($source.assets.name -contains 'orca-windows-setup.exe')) { throw "$env:SOURCE_TAG has no Windows installer" }
|
||||
foreach ($tag in @($env:FINAL_TAG, $env:HIGHER_TAG)) {
|
||||
$release = gh release view $tag --repo stablyai/orca-adhoc --json body,isDraft,isPrerelease,assets | ConvertFrom-Json
|
||||
if ($release.isDraft -or -not $release.isPrerelease) { throw "$tag is not a published prerelease" }
|
||||
if (-not $release.body.Contains($env:EXPECTED_HEAD)) { throw "$tag was not built from $env:EXPECTED_HEAD" }
|
||||
if (-not ($release.assets.name -contains 'orca-windows-setup.exe')) { throw "$tag has no Windows installer" }
|
||||
}
|
||||
gh release download $env:SOURCE_TAG --repo stablyai/orca-adhoc --pattern orca-windows-setup.exe --dir artifacts/source
|
||||
- name: Exercise real in-app update at traffic boundary
|
||||
shell: pwsh
|
||||
run: node tests/tools/pr18505-hosted-update/windows-live-update.mjs artifacts/source/orca-windows-setup.exe $env:FINAL_TAG $env:FINAL_VERSION $env:HIGHER_TAG $env:HIGHER_VERSION "artifacts/windows-$env:ORCA_QA_TRAFFIC_MODE"
|
||||
- name: Prove manual recovery opens installed target idle
|
||||
if: failure()
|
||||
shell: pwsh
|
||||
run: node tests/tools/pr18505-hosted-update/windows-manual-recovery.mjs $env:FINAL_VERSION "artifacts/windows-$env:ORCA_QA_TRAFFIC_MODE/profile" "artifacts/windows-$env:ORCA_QA_TRAFFIC_MODE"
|
||||
- name: Record exact-artifact NSIS evidence boundary
|
||||
if: always()
|
||||
shell: pwsh
|
||||
run: |
|
||||
$output = "artifacts/windows-$env:ORCA_QA_TRAFFIC_MODE"
|
||||
New-Item -ItemType Directory -Force $output | Out-Null
|
||||
@(
|
||||
"The exact release installer uses stock electron-builder NSIS without nsis.debugLogging.",
|
||||
"There is no native NSIS LogSet file to preserve without rebuilding a different artifact.",
|
||||
"app.log preserves electron-updater's exact installer invocation and arguments; the JSONL timeline preserves install-tree changes."
|
||||
) | Set-Content "$output/nsis-evidence-boundary.txt"
|
||||
$roots = @(
|
||||
"$env:LOCALAPPDATA/orca-updater",
|
||||
"$env:LOCALAPPDATA/Programs"
|
||||
)
|
||||
$manifest = foreach ($root in $roots) {
|
||||
if (Test-Path $root) {
|
||||
Get-ChildItem -LiteralPath $root -File -Recurse -ErrorAction SilentlyContinue |
|
||||
Select-Object FullName, Length, LastWriteTimeUtc, VersionInfo
|
||||
}
|
||||
}
|
||||
$manifest | ConvertTo-Json -Depth 4 | Set-Content "$output/installed-file-manifest.json"
|
||||
- name: Upload Windows proof
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: pr18505-windows-control-${{ matrix.traffic-mode }}
|
||||
path: artifacts/windows-${{ matrix.traffic-mode }}/**
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
|
||||
macos-in-app-update:
|
||||
if: false
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
SOURCE_TAG: v1.4.198-adhoc.20260904205337
|
||||
FINAL_TAG: v1.4.198-adhoc.20260904215141
|
||||
FINAL_VERSION: 1.4.198-adhoc.20260904215141
|
||||
HIGHER_TAG: v1.4.198-adhoc.20260904215158
|
||||
HIGHER_VERSION: 1.4.198-adhoc.20260904215158
|
||||
EXPECTED_HEAD: 1b3077382d1d77bb459ec6c937940d64f5fde812
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 2
|
||||
persist-credentials: false
|
||||
- name: Verify QA commit parent
|
||||
run: test "$(git rev-parse HEAD^)" = "$EXPECTED_HEAD"
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
- name: Resolve hosted runner architecture
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$(uname -m)" in
|
||||
arm64) echo 'MAC_ARCH=arm64' >> "$GITHUB_ENV" ;;
|
||||
x86_64) echo 'MAC_ARCH=x64' >> "$GITHUB_ENV" ;;
|
||||
*) echo "unsupported macOS architecture: $(uname -m)" >&2; exit 1 ;;
|
||||
esac
|
||||
- name: Verify releases and download older source DMG
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p artifacts/source artifacts/macos
|
||||
source_json="$(gh release view "$SOURCE_TAG" --repo stablyai/orca-adhoc --json isDraft,isPrerelease,assets)"
|
||||
jq -e '.isDraft == false and .isPrerelease == true' <<<"$source_json" >/dev/null
|
||||
jq -e --arg asset "orca-macos-${MAC_ARCH}.dmg" '.assets | map(.name) | index($asset) != null' <<<"$source_json" >/dev/null
|
||||
for tag in "$FINAL_TAG" "$HIGHER_TAG"; do
|
||||
release_json="$(gh release view "$tag" --repo stablyai/orca-adhoc --json body,isDraft,isPrerelease,assets)"
|
||||
jq -e --arg head "$EXPECTED_HEAD" '.isDraft == false and .isPrerelease == true and (.body | contains($head))' <<<"$release_json" >/dev/null
|
||||
jq -e --arg asset "orca-macos-${MAC_ARCH}.dmg" '.assets | map(.name) | index($asset) != null' <<<"$release_json" >/dev/null
|
||||
done
|
||||
gh release download "$SOURCE_TAG" --repo stablyai/orca-adhoc --pattern "orca-macos-${MAC_ARCH}.dmg" --dir artifacts/source
|
||||
- name: Install signed older source app in disposable login
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mount="$RUNNER_TEMP/orca-source-dmg"
|
||||
mkdir -p "$mount"
|
||||
hdiutil attach "artifacts/source/orca-macos-${MAC_ARCH}.dmg" -nobrowse -readonly -mountpoint "$mount"
|
||||
trap 'hdiutil detach "$mount" >/dev/null 2>&1 || true' EXIT
|
||||
test -d "$mount/Orca.app"
|
||||
sudo ditto "$mount/Orca.app" /Applications/Orca.app
|
||||
sudo chown -R "$(id -u):$(id -g)" /Applications/Orca.app
|
||||
codesign --verify --deep --strict /Applications/Orca.app
|
||||
xattr -dr com.apple.quarantine /Applications/Orca.app || true
|
||||
hdiutil detach "$mount"
|
||||
trap - EXIT
|
||||
swiftc tests/tools/pr18505-hosted-update/mac-visible-window.swift -o artifacts/macos/mac-visible-window
|
||||
- name: Exercise two real ShipIt updates under bounded CLI traffic
|
||||
env:
|
||||
ORCA_QA_MAC_WINDOW_PROBE: ${{ github.workspace }}/artifacts/macos/mac-visible-window
|
||||
run: node tests/tools/pr18505-hosted-update/windows-live-update.mjs /Applications/Orca.app/Contents/MacOS/Orca "$FINAL_TAG" "$FINAL_VERSION" "$HIGHER_TAG" "$HIGHER_VERSION" artifacts/macos
|
||||
- name: Upload macOS proof
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: pr18505-macos-packaged-update
|
||||
path: artifacts/macos/**
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
|
||||
linux-package-manager-upgrade:
|
||||
if: false
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
EXPECTED_HEAD: 1b3077382d1d77bb459ec6c937940d64f5fde812
|
||||
FINAL_VERSION: 1.4.198-qa.18505.1
|
||||
HIGHER_VERSION: 1.4.198-qa.18505.2
|
||||
FINAL_DPKG_VERSION: 1.4.198~qa.18505.1
|
||||
HIGHER_DPKG_VERSION: 1.4.198~qa.18505.2
|
||||
DPKG_VERSION_FORMAT: '${Version}'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 2
|
||||
persist-credentials: false
|
||||
- name: Verify QA commit parent
|
||||
run: test "$(git rev-parse HEAD^)" = "$EXPECTED_HEAD"
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: electron
|
||||
- name: Build exact-head package inputs
|
||||
run: |
|
||||
set -euo pipefail
|
||||
status=0
|
||||
pnpm run build:cli || status=1
|
||||
scripts=(build:relay build:electron-vite:parallel)
|
||||
pids=()
|
||||
for script in "${scripts[@]}"; do pnpm run "$script" & pids+=("$!"); done
|
||||
for pid in "${pids[@]}"; do wait "$pid" || status=1; done
|
||||
test "$status" -eq 0
|
||||
pnpm run build:web-from-renderer
|
||||
pnpm run build:native
|
||||
- name: Build final-head and strictly-higher deb packages
|
||||
env:
|
||||
ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p artifacts/linux packages
|
||||
ORCA_LOCAL_BUILD_VERSION="$FINAL_VERSION" pnpm exec electron-builder --config config/electron-builder.config.cjs --linux deb --x64 --publish never
|
||||
mv "dist/orca-ide_${FINAL_VERSION}_amd64.deb" packages/final.deb
|
||||
ORCA_LOCAL_BUILD_VERSION="$HIGHER_VERSION" pnpm exec electron-builder --config config/electron-builder.config.cjs --linux deb --x64 --publish never
|
||||
mv "dist/orca-ide_${HIGHER_VERSION}_amd64.deb" packages/higher.deb
|
||||
dpkg-deb --info packages/final.deb > artifacts/linux/final-package-info.txt
|
||||
dpkg-deb --info packages/higher.deb > artifacts/linux/higher-package-info.txt
|
||||
sha256sum packages/*.deb > artifacts/linux/package-sha256.txt
|
||||
dpkg --compare-versions "$HIGHER_DPKG_VERSION" gt "$FINAL_DPKG_VERSION"
|
||||
- name: Install and visibly launch exact final-head package
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y ./packages/final.deb
|
||||
test "$(dpkg-query -W -f="$DPKG_VERSION_FORMAT" orca-ide)" = "$FINAL_DPKG_VERSION"
|
||||
Xvfb :99 -screen 0 1440x900x24 >artifacts/linux/xvfb.log 2>&1 &
|
||||
echo "$!" > artifacts/linux/xvfb-pid.txt
|
||||
echo 'DISPLAY=:99' >> "$GITHUB_ENV"
|
||||
DISPLAY=:99 node tests/tools/pr18505-hosted-update/linux-packaged-upgrade.mjs /opt/Orca/orca-ide "$FINAL_VERSION" "$RUNNER_TEMP/profile-linux" artifacts/linux final seed
|
||||
- name: Upgrade through apt and visibly launch strictly higher package
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get install -y ./packages/higher.deb
|
||||
test "$(dpkg-query -W -f="$DPKG_VERSION_FORMAT" orca-ide)" = "$HIGHER_DPKG_VERSION"
|
||||
DISPLAY=:99 node tests/tools/pr18505-hosted-update/linux-packaged-upgrade.mjs /opt/Orca/orca-ide "$HIGHER_VERSION" "$RUNNER_TEMP/profile-linux" artifacts/linux higher reuse
|
||||
sudo dpkg -r orca-ide
|
||||
- name: Stop exact Xvfb PID
|
||||
if: always()
|
||||
run: |
|
||||
pid="$(cat artifacts/linux/xvfb-pid.txt 2>/dev/null || true)"
|
||||
if [[ "$pid" =~ ^[0-9]+$ ]] && kill -0 "$pid" 2>/dev/null; then kill -TERM "$pid"; fi
|
||||
- name: Upload Linux proof
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: pr18505-linux-packaged-upgrade
|
||||
path: artifacts/linux/**
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,173 @@
|
||||
import { chromium } from 'playwright'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import {
|
||||
seedFreshProfile,
|
||||
buildFreshProfile,
|
||||
createSeededRepo
|
||||
} from '../win-update-e2e/onboarding-profile.mjs'
|
||||
|
||||
const [exePathArg, expectedVersion, profileDirArg, outputDirArg, label, profileMode] =
|
||||
process.argv.slice(2)
|
||||
if (
|
||||
!exePathArg ||
|
||||
!expectedVersion ||
|
||||
!profileDirArg ||
|
||||
!outputDirArg ||
|
||||
!label ||
|
||||
!['seed', 'reuse'].includes(profileMode)
|
||||
) {
|
||||
throw new Error(
|
||||
'usage: linux-packaged-upgrade.mjs <exe> <version> <profile> <output> <label> <seed|reuse>'
|
||||
)
|
||||
}
|
||||
const exePath = path.resolve(exePathArg)
|
||||
const profileDir = path.resolve(profileDirArg)
|
||||
const outputDir = path.resolve(outputDirArg)
|
||||
mkdirSync(outputDir, { recursive: true })
|
||||
const isolatedHome = path.join(profileDir, 'home')
|
||||
mkdirSync(isolatedHome, { recursive: true })
|
||||
const profileDataPath = path.join(profileDir, 'orca-data.json')
|
||||
if (profileMode === 'seed') {
|
||||
seedFreshProfile(
|
||||
profileDir,
|
||||
buildFreshProfile({ repo: createSeededRepo(path.join(profileDir, 'fixture-repo')) })
|
||||
)
|
||||
} else if (!existsSync(profileDataPath)) {
|
||||
throw new Error(`upgraded package did not preserve the existing profile at ${profileDataPath}`)
|
||||
}
|
||||
const env = {
|
||||
...process.env,
|
||||
ORCA_E2E_USER_DATA_DIR: profileDir,
|
||||
ORCA_E2E_HOME_DIR: isolatedHome,
|
||||
ORCA_USER_DATA_PATH: profileDir,
|
||||
ORCA_BACKGROUND_LAUNCH: '1',
|
||||
HOME: isolatedHome,
|
||||
ELECTRON_ENABLE_LOGGING: '1'
|
||||
}
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
const app = spawn(exePath, ['--no-sandbox', '--remote-debugging-port=9333'], {
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
if (!app.pid) {
|
||||
throw new Error('packaged Linux app launch returned no PID')
|
||||
}
|
||||
const appPid = app.pid
|
||||
const stopOwnedApp = () => {
|
||||
try {
|
||||
process.kill(appPid, 'SIGTERM')
|
||||
} catch {}
|
||||
}
|
||||
const isOwnedAppAlive = () => {
|
||||
try {
|
||||
process.kill(appPid, 0)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
process.once('exit', stopOwnedApp)
|
||||
const logs = []
|
||||
app.stdout.on('data', (chunk) => logs.push(chunk))
|
||||
app.stderr.on('data', (chunk) => logs.push(chunk))
|
||||
writeFileSync(path.join(outputDir, `${label}-pid.txt`), `${app.pid}\n`)
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
const deadline = Date.now() + 120_000
|
||||
let cdpReady = false
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch('http://127.0.0.1:9333/json')
|
||||
const targets = await response.json()
|
||||
if (targets.some((target) => target.type === 'page')) {
|
||||
cdpReady = true
|
||||
break
|
||||
}
|
||||
} catch {}
|
||||
await sleep(500)
|
||||
}
|
||||
if (!cdpReady) {
|
||||
throw new Error('timed out waiting for packaged Linux CDP page')
|
||||
}
|
||||
const browser = await chromium.connectOverCDP('http://127.0.0.1:9333')
|
||||
const page = browser
|
||||
.contexts()
|
||||
.flatMap((context) => context.pages())
|
||||
.find((candidate) => candidate.url().startsWith('file:'))
|
||||
if (!page) {
|
||||
throw new Error('no packaged Orca renderer page found')
|
||||
}
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
await page.locator('body').waitFor({ state: 'visible', timeout: 60_000 })
|
||||
const runtimeVersion = await page.evaluate(() => window.api.updater.getVersion())
|
||||
if (runtimeVersion !== expectedVersion) {
|
||||
throw new Error(`runtime version ${runtimeVersion} != ${expectedVersion}`)
|
||||
}
|
||||
let settingsSurfaceVisible = false
|
||||
try {
|
||||
const settingsButton = page.getByRole('button', { name: 'Settings', exact: true })
|
||||
await settingsButton.waitFor({ state: 'visible', timeout: 20_000 })
|
||||
await settingsButton.click({ timeout: 20_000 })
|
||||
await page
|
||||
.getByText('Updates', { exact: true })
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 60_000 })
|
||||
await page
|
||||
.getByText(`Current version: ${expectedVersion}`, { exact: true })
|
||||
.waitFor({ state: 'visible', timeout: 30_000 })
|
||||
settingsSurfaceVisible = true
|
||||
} catch (error) {
|
||||
writeFileSync(
|
||||
path.join(outputDir, `${label}-settings-unavailable.json`),
|
||||
JSON.stringify(
|
||||
{
|
||||
error: String(error),
|
||||
bodyText: (await page.locator('body').innerText()).slice(0, 8_000),
|
||||
buttonLabels: await page.locator('button').evaluateAll((buttons) =>
|
||||
buttons.map(
|
||||
(button) => button.getAttribute('aria-label') ?? button.textContent?.trim() ?? ''
|
||||
)
|
||||
)
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
}
|
||||
await page.screenshot({ path: path.join(outputDir, `${label}-packaged.png`), fullPage: false })
|
||||
writeFileSync(
|
||||
path.join(outputDir, `${label}-visible-state.json`),
|
||||
JSON.stringify(
|
||||
{
|
||||
pid: app.pid,
|
||||
profileMode,
|
||||
runtimeVersion,
|
||||
settingsSurfaceVisible,
|
||||
title: await page.title(),
|
||||
url: page.url(),
|
||||
bodyText: (await page.locator('body').innerText()).slice(0, 8000)
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
await browser.close()
|
||||
stopOwnedApp()
|
||||
let cleanupDeadline = Date.now() + 15_000
|
||||
while (isOwnedAppAlive() && Date.now() < cleanupDeadline) {
|
||||
await sleep(250)
|
||||
}
|
||||
if (isOwnedAppAlive()) {
|
||||
process.kill(appPid, 'SIGKILL')
|
||||
cleanupDeadline = Date.now() + 5_000
|
||||
while (isOwnedAppAlive() && Date.now() < cleanupDeadline) {
|
||||
await sleep(250)
|
||||
}
|
||||
}
|
||||
if (isOwnedAppAlive()) {
|
||||
throw new Error(`packaged Linux app PID ${appPid} did not exit after exact-PID cleanup`)
|
||||
}
|
||||
process.removeListener('exit', stopOwnedApp)
|
||||
writeFileSync(path.join(outputDir, `${label}-app.log`), Buffer.concat(logs).toString('utf8'))
|
||||
@@ -0,0 +1,35 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
|
||||
guard CommandLine.arguments.count == 2, let expectedPid = Int32(CommandLine.arguments[1]) else {
|
||||
fputs("usage: mac-visible-window <pid>\n", stderr)
|
||||
exit(2)
|
||||
}
|
||||
|
||||
let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements]
|
||||
guard let rows = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] else {
|
||||
fputs("CGWindowListCopyWindowInfo failed\n", stderr)
|
||||
exit(3)
|
||||
}
|
||||
|
||||
let visible = rows.contains { row in
|
||||
guard
|
||||
let ownerPid = row[kCGWindowOwnerPID as String] as? NSNumber,
|
||||
let layer = row[kCGWindowLayer as String] as? NSNumber,
|
||||
let alpha = row[kCGWindowAlpha as String] as? NSNumber,
|
||||
let bounds = row[kCGWindowBounds as String] as? [String: Any],
|
||||
let width = bounds["Width"] as? NSNumber,
|
||||
let height = bounds["Height"] as? NSNumber
|
||||
else {
|
||||
return false
|
||||
}
|
||||
return ownerPid.int32Value == expectedPid && layer.intValue == 0 && alpha.doubleValue > 0 &&
|
||||
width.doubleValue > 0 && height.doubleValue > 0
|
||||
}
|
||||
|
||||
if visible {
|
||||
print("visible")
|
||||
exit(0)
|
||||
}
|
||||
print("not-visible")
|
||||
exit(1)
|
||||
@@ -0,0 +1,634 @@
|
||||
import { chromium } from 'playwright'
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import {
|
||||
seedFreshProfile,
|
||||
buildFreshProfile,
|
||||
createSeededRepo
|
||||
} from '../win-update-e2e/onboarding-profile.mjs'
|
||||
import { silentInstall } from '../win-update-e2e/installer-steps.mjs'
|
||||
import { runScriptFileJson } from '../win-update-e2e/powershell-runner.mjs'
|
||||
import {
|
||||
captureOptionalSettingsSurface,
|
||||
createInstallLifecycleRecorder,
|
||||
sendRuntimeRequest,
|
||||
startInstallFileTimeline
|
||||
} from './windows-update-evidence.mjs'
|
||||
|
||||
// Shared by disposable Windows NSIS and macOS ShipIt jobs; source is an installer or app executable respectively.
|
||||
|
||||
const [sourceInstaller, finalTag, finalVersion, higherTag, higherVersion, outputDirArg] =
|
||||
process.argv.slice(2)
|
||||
if (
|
||||
!sourceInstaller ||
|
||||
!finalTag ||
|
||||
!finalVersion ||
|
||||
!higherTag ||
|
||||
!higherVersion ||
|
||||
!outputDirArg
|
||||
) {
|
||||
throw new Error(
|
||||
'usage: windows-live-update.mjs <source-installer> <final-tag> <final-version> <higher-tag> <higher-version> <output-dir>'
|
||||
)
|
||||
}
|
||||
|
||||
const outputDir = path.resolve(outputDirArg)
|
||||
mkdirSync(outputDir, { recursive: true })
|
||||
if (process.platform !== 'win32' && process.platform !== 'darwin') {
|
||||
throw new Error(`packaged in-app updater harness does not support ${process.platform}`)
|
||||
}
|
||||
const profileDir = path.join(outputDir, 'profile')
|
||||
const isolatedHome = path.join(profileDir, 'home')
|
||||
mkdirSync(isolatedHome, { recursive: true })
|
||||
const freshProfile = buildFreshProfile({
|
||||
repo: createSeededRepo(path.join(profileDir, 'fixture-repo'))
|
||||
})
|
||||
freshProfile.ui = { lastUpdateCheckAt: Date.now() }
|
||||
seedFreshProfile(profileDir, freshProfile)
|
||||
|
||||
const exePath =
|
||||
process.platform === 'win32'
|
||||
? silentInstall(path.resolve(sourceInstaller)).exePath
|
||||
: path.resolve(sourceInstaller)
|
||||
const cliPath =
|
||||
process.platform === 'win32'
|
||||
? path.join(path.dirname(exePath), 'resources', 'bin', 'orca.exe')
|
||||
: path.resolve(path.dirname(exePath), '..', 'Resources', 'bin', 'orca')
|
||||
if (!existsSync(cliPath)) {
|
||||
throw new Error(`packaged CLI missing: ${cliPath}`)
|
||||
}
|
||||
|
||||
const appEnv = {
|
||||
...process.env,
|
||||
ORCA_E2E_USER_DATA_DIR: profileDir,
|
||||
ORCA_E2E_HOME_DIR: isolatedHome,
|
||||
ORCA_USER_DATA_PATH: profileDir,
|
||||
ORCA_BACKGROUND_LAUNCH: '1',
|
||||
HOME: isolatedHome,
|
||||
USERPROFILE: isolatedHome,
|
||||
ELECTRON_ENABLE_LOGGING: '1'
|
||||
}
|
||||
delete appEnv.ELECTRON_RUN_AS_NODE
|
||||
|
||||
const appLog = path.join(outputDir, 'app.log')
|
||||
const appOut = await import('node:fs').then(({ openSync }) => openSync(appLog, 'a'))
|
||||
const runtimePath = path.join(profileDir, 'orca-runtime.json')
|
||||
const ownedPids = new Set()
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
const trafficMode = process.env.ORCA_QA_TRAFFIC_MODE ?? 'continuous'
|
||||
if (!['continuous', 'until-old-exit', 'none'].includes(trafficMode)) {
|
||||
throw new Error(`invalid ORCA_QA_TRAFFIC_MODE: ${trafficMode}`)
|
||||
}
|
||||
|
||||
function isPidAlive(pid) {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function stopOwnedPids() {
|
||||
for (const pid of ownedPids) {
|
||||
if (!isPidAlive(pid)) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM')
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
process.once('exit', stopOwnedPids)
|
||||
|
||||
async function waitFor(predicate, timeoutMs, label, intervalMs = 500) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
let last
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
last = await predicate()
|
||||
if (last) {
|
||||
return last
|
||||
}
|
||||
} catch (error) {
|
||||
last = error
|
||||
}
|
||||
await sleep(intervalMs)
|
||||
}
|
||||
throw new Error(`timeout waiting for ${label}; last=${String(last)}`)
|
||||
}
|
||||
|
||||
async function waitForRuntime(excludedRuntimeIds, label) {
|
||||
return waitFor(
|
||||
() => {
|
||||
if (!existsSync(runtimePath)) {
|
||||
return false
|
||||
}
|
||||
const value = JSON.parse(readFileSync(runtimePath, 'utf8'))
|
||||
if (!Number.isInteger(value.pid) || value.pid <= 0 || typeof value.runtimeId !== 'string') {
|
||||
return false
|
||||
}
|
||||
if (excludedRuntimeIds.has(value.runtimeId) || !isPidAlive(value.pid)) {
|
||||
return false
|
||||
}
|
||||
return value
|
||||
},
|
||||
300_000,
|
||||
label,
|
||||
1_000
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForCdpPage() {
|
||||
await waitFor(
|
||||
async () => {
|
||||
const response = await fetch('http://127.0.0.1:9333/json')
|
||||
const targets = await response.json()
|
||||
return targets.some((target) => target.type === 'page')
|
||||
},
|
||||
120_000,
|
||||
'CDP page'
|
||||
)
|
||||
const browser = await chromium.connectOverCDP('http://127.0.0.1:9333')
|
||||
const page = await waitFor(
|
||||
async () => {
|
||||
return browser
|
||||
.contexts()
|
||||
.flatMap((context) => context.pages())
|
||||
.find((candidate) => {
|
||||
try {
|
||||
return new URL(candidate.url()).pathname.endsWith('/index.html')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
},
|
||||
60_000,
|
||||
'Orca renderer page'
|
||||
)
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
await waitFor(
|
||||
() =>
|
||||
page
|
||||
.evaluate(() => window.api.updater.getVersion())
|
||||
.then((version) => (typeof version === 'string' && version.length > 0 ? version : false)),
|
||||
120_000,
|
||||
'packaged updater renderer bridge'
|
||||
)
|
||||
return { browser, page }
|
||||
}
|
||||
|
||||
async function launchDriven(label, excludedRuntimeIds) {
|
||||
await waitFor(
|
||||
async () => {
|
||||
try {
|
||||
await fetch('http://127.0.0.1:9333/json')
|
||||
return false
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
},
|
||||
30_000,
|
||||
'previous CDP listener exit'
|
||||
)
|
||||
|
||||
const launcher = spawn(exePath, ['--remote-debugging-port=9333'], {
|
||||
env: appEnv,
|
||||
stdio: ['ignore', appOut, appOut]
|
||||
})
|
||||
if (!launcher.pid) {
|
||||
throw new Error(`${label} packaged app launch returned no PID`)
|
||||
}
|
||||
ownedPids.add(launcher.pid)
|
||||
launcher.once('exit', () => ownedPids.delete(launcher.pid))
|
||||
writeFileSync(path.join(outputDir, `${label}-launcher-pid.txt`), `${launcher.pid}\n`)
|
||||
|
||||
const [{ browser, page }, runtime] = await Promise.all([
|
||||
waitForCdpPage(),
|
||||
waitForRuntime(excludedRuntimeIds, `${label} authoritative runtime`)
|
||||
])
|
||||
await waitFor(() => hasVisibleWindow(runtime.pid), 60_000, `${label} visible top-level window`)
|
||||
const settingsVisible = await captureOptionalSettingsSurface(page, outputDir, label)
|
||||
ownedPids.add(runtime.pid)
|
||||
writeFileSync(path.join(outputDir, `${label}-pid.txt`), `${runtime.pid}\n`)
|
||||
return { browser, page, runtime, settingsVisible }
|
||||
}
|
||||
|
||||
async function terminateExactRuntime(session, label) {
|
||||
const disconnected = session.browser?.isConnected()
|
||||
? new Promise((resolve) => session.browser.once('disconnected', resolve))
|
||||
: Promise.resolve()
|
||||
if (isPidAlive(session.runtime.pid)) {
|
||||
process.kill(session.runtime.pid, 'SIGTERM')
|
||||
}
|
||||
await waitFor(() => !isPidAlive(session.runtime.pid), 30_000, `${label} exact PID exit`)
|
||||
ownedPids.delete(session.runtime.pid)
|
||||
await Promise.race([disconnected, sleep(10_000)])
|
||||
}
|
||||
|
||||
function readVisibleWindows() {
|
||||
const snapshot = runScriptFileJson(path.resolve('tests/tools/win-update-e2e/window-enum.ps1'))
|
||||
if (Array.isArray(snapshot?.windows)) {
|
||||
return snapshot.windows
|
||||
}
|
||||
return snapshot?.windows ? [snapshot.windows] : []
|
||||
}
|
||||
|
||||
function hasVisibleWindow(pid) {
|
||||
if (process.platform === 'win32') {
|
||||
return readVisibleWindows().some((entry) => Number(entry.pid) === pid)
|
||||
}
|
||||
const probe = process.env.ORCA_QA_MAC_WINDOW_PROBE
|
||||
if (!probe) {
|
||||
throw new Error('ORCA_QA_MAC_WINDOW_PROBE is required on macOS')
|
||||
}
|
||||
const result = spawnSync(probe, [String(pid)], { encoding: 'utf8', timeout: 30_000 })
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
return result.status === 0 && result.stdout.trim() === 'visible'
|
||||
}
|
||||
|
||||
function invokeBounded(args, lane, trafficLog, trafficResults) {
|
||||
return new Promise((resolve) => {
|
||||
const startedAt = Date.now()
|
||||
const child = spawn(cliPath, args, { env: appEnv, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
if (child.pid) {
|
||||
ownedPids.add(child.pid)
|
||||
}
|
||||
const chunks = []
|
||||
child.stdout.on('data', (chunk) => chunks.push(chunk))
|
||||
child.stderr.on('data', (chunk) => chunks.push(chunk))
|
||||
let settled = false
|
||||
let timer
|
||||
const finish = (result) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
if (child.pid) {
|
||||
ownedPids.delete(child.pid)
|
||||
}
|
||||
trafficResults[lane].calls += 1
|
||||
if (result.code === 0) {
|
||||
trafficResults[lane].successes += 1
|
||||
}
|
||||
writeFileSync(
|
||||
trafficLog,
|
||||
`${JSON.stringify({ lane, args, pid: child.pid, startedAt, finishedAt: Date.now(), durationMs: Date.now() - startedAt, ...result, output: Buffer.concat(chunks).toString('utf8').slice(0, 2000) })}\n`,
|
||||
{ flag: 'a' }
|
||||
)
|
||||
resolve(result)
|
||||
}
|
||||
timer = setTimeout(() => {
|
||||
try {
|
||||
child.kill()
|
||||
} catch {}
|
||||
}, 10_000)
|
||||
child.once('exit', (code, signal) => finish({ code, signal }))
|
||||
child.once('error', (error) => finish({ error: error.message }))
|
||||
})
|
||||
}
|
||||
|
||||
async function startTraffic(label) {
|
||||
const trafficLog = path.join(outputDir, `${label}-cli-traffic.jsonl`)
|
||||
const trafficResults = {
|
||||
status: { calls: 0, successes: 0 },
|
||||
orchestration: { calls: 0, successes: 0 }
|
||||
}
|
||||
if (trafficMode === 'none') {
|
||||
writeFileSync(
|
||||
trafficLog,
|
||||
`${JSON.stringify({ at: Date.now(), trafficMode, event: 'traffic-disabled' })}\n`
|
||||
)
|
||||
return {
|
||||
trafficResults,
|
||||
async stop() {}
|
||||
}
|
||||
}
|
||||
const commands = [
|
||||
['status', ['status', '--json']],
|
||||
['orchestration', ['orchestration', 'run-list', '--limit', '5', '--json']]
|
||||
]
|
||||
const warmup = await Promise.all(
|
||||
commands.map(([lane, args]) => invokeBounded(args, lane, trafficLog, trafficResults))
|
||||
)
|
||||
if (warmup.some((result) => result.code !== 0)) {
|
||||
throw new Error(`${label} CLI traffic warmup failed: ${JSON.stringify(warmup)}`)
|
||||
}
|
||||
|
||||
let stop = false
|
||||
const lanes = commands.map(async ([lane, args]) => {
|
||||
const deadline = Date.now() + 120_000
|
||||
while (!stop && Date.now() < deadline) {
|
||||
await invokeBounded(args, lane, trafficLog, trafficResults)
|
||||
if (!stop) {
|
||||
await sleep(750)
|
||||
}
|
||||
}
|
||||
})
|
||||
let stopPromise
|
||||
return {
|
||||
trafficResults,
|
||||
async stop() {
|
||||
stopPromise ??= (async () => {
|
||||
stop = true
|
||||
await Promise.all(lanes)
|
||||
if (
|
||||
trafficResults.status.calls < 2 ||
|
||||
trafficResults.orchestration.calls < 2 ||
|
||||
trafficResults.status.successes < 1 ||
|
||||
trafficResults.orchestration.successes < 1
|
||||
) {
|
||||
throw new Error(`${label} bounded CLI traffic failed: ${JSON.stringify(trafficResults)}`)
|
||||
}
|
||||
})()
|
||||
await stopPromise
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function assertRuntimeVersion(runtime, expectedVersion, label) {
|
||||
const statusResult = await waitFor(
|
||||
() => {
|
||||
const result = spawnSync(cliPath, ['status', '--json'], {
|
||||
env: appEnv,
|
||||
encoding: 'utf8',
|
||||
timeout: 10_000
|
||||
})
|
||||
if (result.status !== 0 || !result.stdout.trim()) {
|
||||
return false
|
||||
}
|
||||
const parsed = JSON.parse(result.stdout)
|
||||
return parsed?.result?.runtime?.appVersion === expectedVersion &&
|
||||
parsed?.result?.app?.pid === runtime.pid &&
|
||||
parsed?.result?.app?.desktopWindowStatus === 'available'
|
||||
? parsed
|
||||
: false
|
||||
},
|
||||
120_000,
|
||||
`${label} target runtime CLI status`,
|
||||
1_000
|
||||
)
|
||||
|
||||
const cliVersion = spawnSync(cliPath, ['--version'], {
|
||||
env: appEnv,
|
||||
encoding: 'utf8',
|
||||
timeout: 10_000
|
||||
})
|
||||
if (cliVersion.status !== 0 || cliVersion.stdout.trim() !== expectedVersion) {
|
||||
throw new Error(
|
||||
`${label} packaged CLI version mismatch: ${cliVersion.stdout} ${cliVersion.stderr}`
|
||||
)
|
||||
}
|
||||
const replacementVisible = hasVisibleWindow(runtime.pid)
|
||||
if (!replacementVisible) {
|
||||
throw new Error(
|
||||
`${label} automatic replacement PID ${runtime.pid} has no visible top-level window`
|
||||
)
|
||||
}
|
||||
return { statusResult, cliVersion: cliVersion.stdout.trim(), replacementVisible }
|
||||
}
|
||||
|
||||
async function runHop(session, targetTag, targetVersion, label, seenRuntimeIds) {
|
||||
const beforeVersion = await session.page.evaluate(() => window.api.updater.getVersion())
|
||||
if (beforeVersion === targetVersion) {
|
||||
throw new Error(`${label} source and target versions are identical: ${targetVersion}`)
|
||||
}
|
||||
const beforeStatus = await session.page.evaluate(() => window.api.updater.getStatus())
|
||||
if (beforeStatus.state !== 'idle') {
|
||||
throw new Error(`${label} started in non-idle updater state: ${JSON.stringify(beforeStatus)}`)
|
||||
}
|
||||
await session.page.screenshot({
|
||||
path: path.join(
|
||||
outputDir,
|
||||
`${label}-01-source-${session.settingsVisible ? 'idle' : 'visible-app'}.png`
|
||||
),
|
||||
fullPage: false
|
||||
})
|
||||
|
||||
await session.page.evaluate(
|
||||
({ targetTag: tag }) => window.api.updater.check({ channel: 'adhoc', targetTag: tag }),
|
||||
{ targetTag }
|
||||
)
|
||||
const available = await waitFor(
|
||||
() =>
|
||||
session.page
|
||||
.evaluate(() => window.api.updater.getStatus())
|
||||
.then((status) => (status.state === 'available' ? status : false)),
|
||||
120_000,
|
||||
`${label} pinned update available`,
|
||||
1_000
|
||||
)
|
||||
if (available.version !== targetVersion || available.source !== 'adhoc') {
|
||||
throw new Error(`${label} unexpected available status: ${JSON.stringify(available)}`)
|
||||
}
|
||||
await session.page.screenshot({
|
||||
path: path.join(outputDir, `${label}-02-update-available.png`),
|
||||
fullPage: false
|
||||
})
|
||||
|
||||
await session.page.evaluate(() => window.api.updater.download())
|
||||
const downloaded = await waitFor(
|
||||
() =>
|
||||
session.page
|
||||
.evaluate(() => window.api.updater.getStatus())
|
||||
.then((status) => (status.state === 'downloaded' ? status : false)),
|
||||
300_000,
|
||||
`${label} pinned update downloaded`,
|
||||
1_000
|
||||
)
|
||||
if (downloaded.version !== targetVersion) {
|
||||
throw new Error(`${label} wrong downloaded version: ${JSON.stringify(downloaded)}`)
|
||||
}
|
||||
await session.page.screenshot({
|
||||
path: path.join(outputDir, `${label}-03-update-downloaded.png`),
|
||||
fullPage: false
|
||||
})
|
||||
|
||||
const timeline = startInstallFileTimeline({
|
||||
label,
|
||||
outputDir,
|
||||
sourcePid: session.runtime.pid,
|
||||
exePath,
|
||||
cliPath,
|
||||
runtimePath
|
||||
})
|
||||
const traffic = await startTraffic(label)
|
||||
const recordLifecycle = createInstallLifecycleRecorder({ label, outputDir, trafficMode })
|
||||
let trafficStoppedAt = null
|
||||
const stopTraffic = async () => {
|
||||
if (trafficStoppedAt !== null) {
|
||||
return
|
||||
}
|
||||
await traffic.stop()
|
||||
trafficStoppedAt = Date.now()
|
||||
recordLifecycle('traffic-stopped', { trafficStoppedAt })
|
||||
}
|
||||
let completed = false
|
||||
try {
|
||||
const disconnected = new Promise((resolve) => session.browser.once('disconnected', resolve))
|
||||
const quitRequestedAt = Date.now()
|
||||
recordLifecycle('quit-requested', { quitRequestedAt, sourcePid: session.runtime.pid })
|
||||
await session.page.evaluate(() => window.api.updater.quitAndInstall())
|
||||
const oldExitTask = (async () => {
|
||||
await waitFor(
|
||||
() => !isPidAlive(session.runtime.pid),
|
||||
120_000,
|
||||
`${label} old authoritative PID exit`
|
||||
)
|
||||
const exitedAt = Date.now()
|
||||
recordLifecycle('old-pid-exited', { oldExitedAt: exitedAt, sourcePid: session.runtime.pid })
|
||||
ownedPids.delete(session.runtime.pid)
|
||||
if (trafficMode === 'until-old-exit') {
|
||||
await stopTraffic()
|
||||
}
|
||||
return exitedAt
|
||||
})()
|
||||
await Promise.race([
|
||||
disconnected,
|
||||
sleep(120_000).then(() => {
|
||||
throw new Error(`${label} initial CDP session did not disconnect for update`)
|
||||
})
|
||||
])
|
||||
const cdpDisconnectedAt = Date.now()
|
||||
recordLifecycle('cdp-disconnected', { cdpDisconnectedAt })
|
||||
const oldExitedAt = await oldExitTask
|
||||
|
||||
const replacement = await waitForRuntime(seenRuntimeIds, `${label} automatic replacement runtime`)
|
||||
const replacementObservedAt = Date.now()
|
||||
recordLifecycle('replacement-observed', {
|
||||
replacementObservedAt,
|
||||
replacementPid: replacement.pid,
|
||||
replacementRuntimeId: replacement.runtimeId
|
||||
})
|
||||
ownedPids.add(replacement.pid)
|
||||
seenRuntimeIds.add(replacement.runtimeId)
|
||||
writeFileSync(path.join(outputDir, `${label}-replacement-pid.txt`), `${replacement.pid}\n`)
|
||||
const runtimeEvidence = await assertRuntimeVersion(replacement, targetVersion, label)
|
||||
const automaticUpdaterStatus = await sendRuntimeRequest(
|
||||
replacement,
|
||||
'updater.getStatus',
|
||||
null
|
||||
)
|
||||
if (automaticUpdaterStatus?.state !== 'idle') {
|
||||
throw new Error(
|
||||
`${label} automatic replacement updater not idle: ${JSON.stringify(automaticUpdaterStatus)}`
|
||||
)
|
||||
}
|
||||
await stopTraffic()
|
||||
completed = true
|
||||
|
||||
return {
|
||||
replacementRuntime: replacement,
|
||||
evidence: {
|
||||
beforeVersion,
|
||||
settingsSurfaceVisible: session.settingsVisible,
|
||||
targetTag,
|
||||
targetVersion,
|
||||
trafficMode,
|
||||
quitRequestedAt,
|
||||
cdpDisconnectedAt,
|
||||
oldExitedAt,
|
||||
trafficStoppedAt,
|
||||
replacementObservedAt,
|
||||
automaticUpdaterStatus,
|
||||
replacement: {
|
||||
pid: replacement.pid,
|
||||
runtimeId: replacement.runtimeId,
|
||||
startedAt: replacement.startedAt
|
||||
},
|
||||
...runtimeEvidence,
|
||||
trafficResults: traffic.trafficResults
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await timeline.stop()
|
||||
if (!completed) {
|
||||
await stopTraffic()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const seenRuntimeIds = new Set()
|
||||
let session = await launchDriven('source', seenRuntimeIds)
|
||||
seenRuntimeIds.add(session.runtime.runtimeId)
|
||||
const sourceVersion = await session.page.evaluate(() => window.api.updater.getVersion())
|
||||
|
||||
const firstHopResult = await runHop(session, finalTag, finalVersion, 'hop-1', seenRuntimeIds)
|
||||
if (process.env.ORCA_QA_SINGLE_HOP === '1') {
|
||||
writeFileSync(
|
||||
path.join(outputDir, 'result.json'),
|
||||
JSON.stringify({ sourceVersion, firstHop: firstHopResult.evidence }, null, 2)
|
||||
)
|
||||
await terminateExactRuntime(
|
||||
{ browser: session.browser, runtime: firstHopResult.replacementRuntime },
|
||||
'hop-1 automatic replacement'
|
||||
)
|
||||
} else {
|
||||
await terminateExactRuntime(
|
||||
{ browser: session.browser, runtime: firstHopResult.replacementRuntime },
|
||||
'hop-1 replacement'
|
||||
)
|
||||
session = await launchDriven('final-head', seenRuntimeIds)
|
||||
seenRuntimeIds.add(session.runtime.runtimeId)
|
||||
const finalHeadVersion = await session.page.evaluate(() => window.api.updater.getVersion())
|
||||
if (finalHeadVersion !== finalVersion) {
|
||||
throw new Error(`final-head relaunch version ${finalHeadVersion} != ${finalVersion}`)
|
||||
}
|
||||
const finalHeadIdleStatus = await session.page.evaluate(() => window.api.updater.getStatus())
|
||||
if (finalHeadIdleStatus.state !== 'idle') {
|
||||
throw new Error(`final-head relaunch updater not idle: ${JSON.stringify(finalHeadIdleStatus)}`)
|
||||
}
|
||||
await session.page.screenshot({
|
||||
path: path.join(outputDir, 'hop-1-04-final-head-idle.png'),
|
||||
fullPage: false
|
||||
})
|
||||
|
||||
const secondHopResult = await runHop(session, higherTag, higherVersion, 'hop-2', seenRuntimeIds)
|
||||
await terminateExactRuntime(
|
||||
{ browser: session.browser, runtime: secondHopResult.replacementRuntime },
|
||||
'hop-2 replacement'
|
||||
)
|
||||
session = await launchDriven('higher-final-head', seenRuntimeIds)
|
||||
seenRuntimeIds.add(session.runtime.runtimeId)
|
||||
const higherRuntimeVersion = await session.page.evaluate(() => window.api.updater.getVersion())
|
||||
if (higherRuntimeVersion !== higherVersion) {
|
||||
throw new Error(`higher-version relaunch version ${higherRuntimeVersion} != ${higherVersion}`)
|
||||
}
|
||||
const higherIdleStatus = await session.page.evaluate(() => window.api.updater.getStatus())
|
||||
if (higherIdleStatus.state !== 'idle') {
|
||||
throw new Error(`higher-version relaunch updater not idle: ${JSON.stringify(higherIdleStatus)}`)
|
||||
}
|
||||
if (session.settingsVisible) {
|
||||
await session.page.getByText(`Current version: ${higherVersion}`, { exact: true }).waitFor({
|
||||
state: 'visible',
|
||||
timeout: 30_000
|
||||
})
|
||||
}
|
||||
await session.page.screenshot({
|
||||
path: path.join(outputDir, 'hop-2-04-higher-final-head-idle.png'),
|
||||
fullPage: false
|
||||
})
|
||||
|
||||
writeFileSync(
|
||||
path.join(outputDir, 'result.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
sourceVersion,
|
||||
finalHeadVersion,
|
||||
higherRuntimeVersion,
|
||||
finalHeadIdleStatus,
|
||||
higherIdleStatus,
|
||||
firstHop: firstHopResult.evidence,
|
||||
secondHop: secondHopResult.evidence
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
|
||||
await terminateExactRuntime(session, 'final driven runtime')
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { chromium } from 'playwright'
|
||||
import { locateInstalledExe } from '../win-update-e2e/installer-steps.mjs'
|
||||
import { runScriptFileJson } from '../win-update-e2e/powershell-runner.mjs'
|
||||
|
||||
const [expectedVersion, profileDirArg, outputDirArg] = process.argv.slice(2)
|
||||
if (!expectedVersion || !profileDirArg || !outputDirArg || process.platform !== 'win32') {
|
||||
throw new Error('usage: windows-manual-recovery.mjs <version> <profile> <output> (Windows only)')
|
||||
}
|
||||
const profileDir = path.resolve(profileDirArg)
|
||||
const outputDir = path.resolve(outputDirArg)
|
||||
mkdirSync(outputDir, { recursive: true })
|
||||
const exePath = locateInstalledExe()
|
||||
if (!exePath) {
|
||||
throw new Error('updated Orca.exe is not installed')
|
||||
}
|
||||
const cliPath = path.join(path.dirname(exePath), 'resources', 'bin', 'orca.exe')
|
||||
const runtimePath = path.join(profileDir, 'orca-runtime.json')
|
||||
const isolatedHome = path.join(profileDir, 'home')
|
||||
const appEnv = {
|
||||
...process.env,
|
||||
ORCA_E2E_USER_DATA_DIR: profileDir,
|
||||
ORCA_E2E_HOME_DIR: isolatedHome,
|
||||
ORCA_USER_DATA_PATH: profileDir,
|
||||
ORCA_BACKGROUND_LAUNCH: '1',
|
||||
HOME: isolatedHome,
|
||||
USERPROFILE: isolatedHome,
|
||||
ELECTRON_ENABLE_LOGGING: '1'
|
||||
}
|
||||
delete appEnv.ELECTRON_RUN_AS_NODE
|
||||
const ownedPids = new Set()
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
function isPidAlive(pid) {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function stopOwnedPids() {
|
||||
for (const pid of ownedPids) {
|
||||
if (!isPidAlive(pid)) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM')
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
process.once('exit', stopOwnedPids)
|
||||
|
||||
async function waitFor(predicate, timeoutMs, label) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const value = await predicate()
|
||||
if (value) {
|
||||
return value
|
||||
}
|
||||
} catch {}
|
||||
await sleep(500)
|
||||
}
|
||||
throw new Error(`timeout waiting for ${label}`)
|
||||
}
|
||||
|
||||
const recoveryLog = path.join(outputDir, 'manual-recovery-app.log')
|
||||
const appOut = await import('node:fs').then(({ openSync }) => openSync(recoveryLog, 'a'))
|
||||
const launcher = spawn(exePath, ['--remote-debugging-port=9333'], {
|
||||
env: appEnv,
|
||||
stdio: ['ignore', appOut, appOut]
|
||||
})
|
||||
if (!launcher.pid) {
|
||||
throw new Error('manual recovery launch returned no PID')
|
||||
}
|
||||
ownedPids.add(launcher.pid)
|
||||
launcher.once('exit', () => ownedPids.delete(launcher.pid))
|
||||
writeFileSync(path.join(outputDir, 'manual-recovery-launcher-pid.txt'), `${launcher.pid}\n`)
|
||||
|
||||
const runtime = await waitFor(() => {
|
||||
if (!existsSync(runtimePath)) {
|
||||
return false
|
||||
}
|
||||
const value = JSON.parse(readFileSync(runtimePath, 'utf8'))
|
||||
return Number.isInteger(value.pid) && isPidAlive(value.pid) ? value : false
|
||||
}, 120_000, 'manual recovery runtime')
|
||||
ownedPids.add(runtime.pid)
|
||||
|
||||
await waitFor(async () => {
|
||||
const response = await fetch('http://127.0.0.1:9333/json')
|
||||
const targets = await response.json()
|
||||
return targets.some((target) => target.type === 'page')
|
||||
}, 120_000, 'manual recovery CDP page')
|
||||
const browser = await chromium.connectOverCDP('http://127.0.0.1:9333')
|
||||
const page = await waitFor(() => {
|
||||
return browser
|
||||
.contexts()
|
||||
.flatMap((context) => context.pages())
|
||||
.find((candidate) => candidate.url().startsWith('file:'))
|
||||
}, 60_000, 'manual recovery renderer')
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
const [runtimeVersion, updaterStatus] = await Promise.all([
|
||||
page.evaluate(() => window.api.updater.getVersion()),
|
||||
page.evaluate(() => window.api.updater.getStatus())
|
||||
])
|
||||
if (runtimeVersion !== expectedVersion || updaterStatus.state !== 'idle') {
|
||||
throw new Error(
|
||||
`manual recovery state mismatch: ${JSON.stringify({ runtimeVersion, updaterStatus })}`
|
||||
)
|
||||
}
|
||||
const cliVersion = spawnSync(cliPath, ['--version'], {
|
||||
env: appEnv,
|
||||
encoding: 'utf8',
|
||||
timeout: 10_000
|
||||
})
|
||||
if (cliVersion.status !== 0 || cliVersion.stdout.trim() !== expectedVersion) {
|
||||
throw new Error(`manual recovery CLI mismatch: ${cliVersion.stdout} ${cliVersion.stderr}`)
|
||||
}
|
||||
const windows = runScriptFileJson(path.resolve('tests/tools/win-update-e2e/window-enum.ps1'))
|
||||
const visible = (Array.isArray(windows?.windows) ? windows.windows : [windows?.windows]).some(
|
||||
(entry) => Number(entry?.pid) === runtime.pid
|
||||
)
|
||||
if (!visible) {
|
||||
throw new Error(`manual recovery PID ${runtime.pid} has no visible top-level window`)
|
||||
}
|
||||
await page.screenshot({ path: path.join(outputDir, 'manual-recovery-idle.png'), fullPage: false })
|
||||
writeFileSync(
|
||||
path.join(outputDir, 'manual-recovery-result.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
expectedVersion,
|
||||
runtimeVersion,
|
||||
updaterStatus,
|
||||
cliVersion: cliVersion.stdout.trim(),
|
||||
exePath,
|
||||
launcherPid: launcher.pid,
|
||||
runtimePid: runtime.pid,
|
||||
runtimeId: runtime.runtimeId,
|
||||
visible
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
await browser.close()
|
||||
stopOwnedPids()
|
||||
await waitFor(() => [...ownedPids].every((pid) => !isPidAlive(pid)), 30_000, 'owned PID exit')
|
||||
process.removeListener('exit', stopOwnedPids)
|
||||
@@ -0,0 +1,160 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { statSync, writeFileSync } from 'node:fs'
|
||||
import { createConnection } from 'node:net'
|
||||
import path from 'node:path'
|
||||
|
||||
export function sendRuntimeRequest(metadata, method, params) {
|
||||
const transport =
|
||||
metadata.transports?.find((entry) => ['unix', 'named-pipe'].includes(entry.kind)) ??
|
||||
metadata.transport
|
||||
if (!transport?.endpoint || !metadata.authToken) {
|
||||
throw new Error('runtime metadata has no compatible local transport')
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = randomUUID()
|
||||
const socket = createConnection(transport.endpoint)
|
||||
let buffer = ''
|
||||
let settled = false
|
||||
const timer = setTimeout(() => finish(new Error(`runtime RPC ${method} timed out`)), 30_000)
|
||||
function finish(error, value) {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
socket.end()
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
resolve(value)
|
||||
}
|
||||
}
|
||||
socket.setEncoding('utf8')
|
||||
socket.once('error', (error) => finish(error))
|
||||
socket.once('close', () => finish(new Error(`runtime RPC ${method} closed before response`)))
|
||||
socket.on('data', (chunk) => {
|
||||
buffer += chunk
|
||||
let newline = buffer.indexOf('\n')
|
||||
while (newline !== -1 && !settled) {
|
||||
const line = buffer.slice(0, newline)
|
||||
buffer = buffer.slice(newline + 1)
|
||||
newline = buffer.indexOf('\n')
|
||||
if (!line.trim()) {
|
||||
continue
|
||||
}
|
||||
const frame = JSON.parse(line)
|
||||
if (frame._keepalive === true) {
|
||||
continue
|
||||
}
|
||||
if (frame.id !== id) {
|
||||
return finish(new Error(`runtime RPC ${method} response ID mismatch`))
|
||||
}
|
||||
if (frame.ok !== true) {
|
||||
return finish(new Error(`runtime RPC ${method} failed: ${JSON.stringify(frame.error)}`))
|
||||
}
|
||||
return finish(null, frame.result)
|
||||
}
|
||||
})
|
||||
socket.once('connect', () =>
|
||||
socket.write(`${JSON.stringify({ id, authToken: metadata.authToken, method, params })}\n`)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function snapshotFile(filePath) {
|
||||
try {
|
||||
const stat = statSync(filePath)
|
||||
return { exists: true, size: stat.size, mtimeMs: stat.mtimeMs }
|
||||
} catch {
|
||||
return { exists: false }
|
||||
}
|
||||
}
|
||||
|
||||
function isPidAlive(pid) {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
export function createInstallLifecycleRecorder({ label, outputDir, trafficMode }) {
|
||||
const lifecyclePath = path.join(outputDir, `${label}-install-lifecycle.jsonl`)
|
||||
return (event, extra = {}) => {
|
||||
writeFileSync(
|
||||
lifecyclePath,
|
||||
`${JSON.stringify({ event, at: Date.now(), trafficMode, ...extra })}\n`,
|
||||
{ flag: 'a' }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function captureOptionalSettingsSurface(page, outputDir, label) {
|
||||
const settingsButton = page.getByRole('button', { name: 'Settings', exact: true })
|
||||
try {
|
||||
await settingsButton.waitFor({ state: 'visible', timeout: 20_000 })
|
||||
await settingsButton.click({ timeout: 20_000 })
|
||||
await page.getByText('Updates', { exact: true }).first().waitFor({
|
||||
state: 'visible',
|
||||
timeout: 60_000
|
||||
})
|
||||
return true
|
||||
} catch (error) {
|
||||
const diagnostics = await page.evaluate(() => ({
|
||||
bodyText: document.body?.innerText.slice(0, 8_000),
|
||||
buttonLabels: Array.from(document.querySelectorAll('button')).map(
|
||||
(button) => button.getAttribute('aria-label') ?? button.textContent?.trim() ?? ''
|
||||
),
|
||||
title: document.title,
|
||||
url: location.href,
|
||||
visibilityState: document.visibilityState,
|
||||
viewport: { width: innerWidth, height: innerHeight }
|
||||
}))
|
||||
writeFileSync(
|
||||
path.join(outputDir, `${label}-settings-unavailable.json`),
|
||||
JSON.stringify({ error: String(error), diagnostics }, null, 2)
|
||||
)
|
||||
await page.screenshot({
|
||||
path: path.join(outputDir, `${label}-visible-app.png`),
|
||||
fullPage: false
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function startInstallFileTimeline({
|
||||
label,
|
||||
outputDir,
|
||||
sourcePid,
|
||||
exePath,
|
||||
cliPath,
|
||||
runtimePath
|
||||
}) {
|
||||
const timelinePath = path.join(outputDir, `${label}-install-file-timeline.jsonl`)
|
||||
let stopped = false
|
||||
const task = (async () => {
|
||||
while (!stopped) {
|
||||
writeFileSync(
|
||||
timelinePath,
|
||||
`${JSON.stringify({
|
||||
at: Date.now(),
|
||||
sourcePidAlive: isPidAlive(sourcePid),
|
||||
appExecutable: snapshotFile(exePath),
|
||||
cliExecutable: snapshotFile(cliPath),
|
||||
runtimeMetadata: snapshotFile(runtimePath)
|
||||
})}\n`,
|
||||
{ flag: 'a' }
|
||||
)
|
||||
await sleep(250)
|
||||
}
|
||||
})()
|
||||
return {
|
||||
async stop() {
|
||||
stopped = true
|
||||
await task
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user