mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Reduce CI import, frame-capture, teardown, and shard costs (#28879)
* Avoid unused module imports and trim cosmetic test coverage * Capture hidden popup frames and attach CDP before guest creation * Overlap relay test projects within the existing worker budget * Consolidate native preparation and add an opt-in transform cache comparison * Release inherited debugger sockets during final E2E teardown * Refresh unit scheduling weights from all five qualified ARM shards * Avoid retaining unasserted RPC call history in the delivery model * Use the supported unconditional background wait syntax * Overlap unconditional output upload with native preparation * Verify relay integration stays enabled for ordinary unit callers
This commit is contained in:
@@ -4,12 +4,14 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- cloud/**
|
||||
- src/packages/process-host/**
|
||||
- .github/workflows/cloud-*.yml
|
||||
- .github/actions/cloud-sql-rollout-lease/**
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- cloud/**
|
||||
- src/packages/process-host/**
|
||||
- .github/workflows/cloud-*.yml
|
||||
- .github/actions/cloud-sql-rollout-lease/**
|
||||
|
||||
@@ -93,8 +95,8 @@ jobs:
|
||||
- run: pnpm build
|
||||
- run: pnpm typecheck
|
||||
|
||||
# Runs in parallel with build. `pnpm test` compiles the one workspace
|
||||
# package it needs through the relay pretest hook, so it does not depend on
|
||||
# Runs in parallel with build. `pnpm test` compiles the workspace
|
||||
# packages it needs through pretest hooks, so it does not depend on
|
||||
# `pnpm build` having run.
|
||||
test:
|
||||
runs-on: ubuntu-22.04
|
||||
@@ -113,6 +115,7 @@ jobs:
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
env:
|
||||
ORCA_BACKGROUND_LAUNCH: '1'
|
||||
ORCA_PUSH_TEST_DATABASE_URL: postgres://relay_test:relay_test@127.0.0.1:5432/orca_relay_test
|
||||
ORCA_RELAY_TEST_POSTGRES_URL: postgres://relay_test:relay_test@127.0.0.1:5432/orca_relay_test
|
||||
steps:
|
||||
|
||||
+64
-37
@@ -52,7 +52,9 @@ jobs:
|
||||
name: build e2e app
|
||||
if: inputs.test_files == '' || github.event_name != 'pull_request' || inputs.needs_build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
ORCA_BACKGROUND_LAUNCH: '1'
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -60,10 +62,44 @@ jobs:
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Install native build tools
|
||||
env:
|
||||
ORCA_E2E_APT_PACKAGES: build-essential python3
|
||||
run: &install_e2e_tools |
|
||||
if [ "$ORCA_E2E_APT_PACKAGES" = 'build-essential python3' ] &&
|
||||
command -v make >/dev/null && command -v g++ >/dev/null && command -v python3 >/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
read -r -a packages <<< "$ORCA_E2E_APT_PACKAGES"
|
||||
# Why apt-mirrors.txt: runner images resolve mirror+file: sources through it, so editing the lists alone leaves Azure in use.
|
||||
for source in /etc/apt/apt-mirrors.txt /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do
|
||||
if [ -f "$source" ]; then
|
||||
sudo sed -i 's|https*://azure\.archive\.ubuntu\.com/ubuntu|https://archive.ubuntu.com/ubuntu|g' "$source"
|
||||
fi
|
||||
done
|
||||
sudo tee /etc/apt/apt.conf.d/99-orca-e2e >/dev/null <<'APTCONF'
|
||||
Acquire::http::Timeout "15";
|
||||
Acquire::https::Timeout "15";
|
||||
Acquire::Retries "1";
|
||||
APTCONF
|
||||
timeout 120 sudo apt-get update
|
||||
timeout 300 sudo apt-get install -y "${packages[@]}" || timeout 300 sudo apt-get install -y --fix-missing "${packages[@]}"
|
||||
|
||||
# Why: the build's plain-Node daemon smoke load resolves node-pty.
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
id: deps
|
||||
with:
|
||||
native-runtime: node
|
||||
persist-native-cache: 'false'
|
||||
cache-electron-package: 'true'
|
||||
|
||||
# Save Node's ABI before the Electron preparation replaces the same build paths.
|
||||
- name: Save compiled Node native modules
|
||||
if: steps.deps.outputs.native-cache-key != '' && steps.deps.outputs.native-cache-hit != 'true'
|
||||
uses: actions/cache/save@v5
|
||||
with:
|
||||
path: ${{ steps.deps.outputs.native-cache-path }}
|
||||
key: ${{ steps.deps.outputs.native-cache-key }}
|
||||
|
||||
# Why: building here avoids parallel builds inside Playwright globalSetup;
|
||||
# paired-browser specs also need the standalone web bundle.
|
||||
@@ -88,7 +124,19 @@ jobs:
|
||||
|
||||
- wait: e2e-cli
|
||||
|
||||
- name: Check shared native runtime action
|
||||
id: native-runtime-action
|
||||
run: |
|
||||
if [ -f .github/actions/prepare-native-runtime/action.yml ]; then
|
||||
echo 'available=true' >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo 'available=false' >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Only the completed output upload overlaps the change from Node to Electron ABI.
|
||||
- name: Upload E2E build output
|
||||
id: e2e-build-upload
|
||||
background: true
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: e2e-build-out${{ inputs.artifact_suffix }}
|
||||
@@ -97,46 +145,25 @@ jobs:
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
|
||||
# Build Electron-native dependencies once per workflow. Consumer shards restore
|
||||
# this immutable cache instead of compiling the same ABI concurrently.
|
||||
prepare-native-cache:
|
||||
name: prepare Electron native cache
|
||||
if: inputs.test_files == '' || github.event_name != 'pull_request' || inputs.needs_build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: Prepare shared Electron native runtime
|
||||
id: electron-native-runtime
|
||||
if: steps.native-runtime-action.outputs.available == 'true' && steps.deps.outputs.node-version != ''
|
||||
uses: ./.github/actions/prepare-native-runtime
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
native-runtime: electron
|
||||
node-version: ${{ steps.deps.outputs.node-version }}
|
||||
|
||||
- name: Install native build tools
|
||||
env:
|
||||
ORCA_E2E_APT_PACKAGES: build-essential python3
|
||||
run: &install_e2e_tools |
|
||||
read -r -a packages <<< "$ORCA_E2E_APT_PACKAGES"
|
||||
# Why apt-mirrors.txt: runner images resolve mirror+file: sources through it, so editing the lists alone leaves Azure in use.
|
||||
for source in /etc/apt/apt-mirrors.txt /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do
|
||||
if [ -f "$source" ]; then
|
||||
sudo sed -i 's|https*://azure\.archive\.ubuntu\.com/ubuntu|https://archive.ubuntu.com/ubuntu|g' "$source"
|
||||
fi
|
||||
done
|
||||
sudo tee /etc/apt/apt.conf.d/99-orca-e2e >/dev/null <<'APTCONF'
|
||||
Acquire::http::Timeout "15";
|
||||
Acquire::https::Timeout "15";
|
||||
Acquire::Retries "1";
|
||||
APTCONF
|
||||
timeout 120 sudo apt-get update
|
||||
timeout 300 sudo apt-get install -y "${packages[@]}" || timeout 300 sudo apt-get install -y --fix-missing "${packages[@]}"
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
- name: Prepare Electron natives for older refs
|
||||
if: steps.native-runtime-action.outputs.available != 'true' || steps.deps.outputs.node-version == ''
|
||||
uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: electron
|
||||
|
||||
- wait: e2e-build-upload
|
||||
|
||||
e2e:
|
||||
name: e2e ${{ matrix.shard_name }}
|
||||
needs: [build, prepare-native-cache]
|
||||
needs: [build]
|
||||
if: inputs.test_files == ''
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
@@ -263,7 +290,7 @@ jobs:
|
||||
|
||||
changed-e2e:
|
||||
name: changed e2e specs
|
||||
needs: [build, prepare-native-cache]
|
||||
needs: [build]
|
||||
if: inputs.test_files != '' && (github.event_name != 'pull_request' || inputs.run_changed_e2e)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
@@ -351,7 +378,7 @@ jobs:
|
||||
|
||||
orcad-serve-mode-switch:
|
||||
name: orca serve Electron/orcad mode switch (D7)
|
||||
needs: [build, prepare-native-cache]
|
||||
needs: [build]
|
||||
if: >-
|
||||
inputs.test_files == '' ||
|
||||
contains(inputs.test_files, 'tests/e2e/orcad-serve-mode-switch.spec.ts') ||
|
||||
@@ -411,7 +438,7 @@ jobs:
|
||||
# orcad template for the fixture's target (Debian, linux-x64-glibc), which only this job builds.
|
||||
orcad-ssh-docker:
|
||||
name: ssh on managed orcad (Docker ${{ matrix.shard }})
|
||||
needs: [build, prepare-native-cache]
|
||||
needs: [build]
|
||||
if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-orcad-')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Unit transform cache comparison
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: unit-module-cache-comparison-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
baseline:
|
||||
uses: ./.github/workflows/unit-tests.yml
|
||||
with:
|
||||
node_versions: '["24.21.0"]'
|
||||
runner: ubuntu-24.04-arm
|
||||
shards: '[{"index":1,"count":5},{"index":2,"count":5},{"index":3,"count":5},{"index":4,"count":5},{"index":5,"count":5}]'
|
||||
artifact_prefix: baseline-unit
|
||||
phase_diagnostics: true
|
||||
run_relay_integration: false
|
||||
|
||||
producer:
|
||||
needs: baseline
|
||||
uses: ./.github/workflows/unit-tests.yml
|
||||
with:
|
||||
node_versions: '["24.21.0"]'
|
||||
runner: ubuntu-24.04-arm
|
||||
shards: '[{"index":1,"count":5},{"index":2,"count":5},{"index":3,"count":5},{"index":4,"count":5},{"index":5,"count":5}]'
|
||||
artifact_prefix: producer-unit
|
||||
module_cache: cold
|
||||
module_cache_namespace: ${{ github.run_id }}-${{ github.run_attempt }}
|
||||
module_cache_seed: true
|
||||
phase_diagnostics: true
|
||||
run_relay_integration: false
|
||||
|
||||
warm:
|
||||
needs: producer
|
||||
uses: ./.github/workflows/unit-tests.yml
|
||||
with:
|
||||
node_versions: '["24.21.0"]'
|
||||
runner: ubuntu-24.04-arm
|
||||
shards: '[{"index":1,"count":5},{"index":2,"count":5},{"index":3,"count":5},{"index":4,"count":5},{"index":5,"count":5}]'
|
||||
artifact_prefix: warm-unit
|
||||
module_cache: warm
|
||||
module_cache_namespace: ${{ github.run_id }}-${{ github.run_attempt }}
|
||||
phase_diagnostics: true
|
||||
run_relay_integration: false
|
||||
@@ -31,13 +31,43 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
module_cache:
|
||||
description: Transform cache policy (off, cold producer, or warm exact restore).
|
||||
default: 'off'
|
||||
type: string
|
||||
|
||||
module_cache_namespace:
|
||||
description: Isolate a same-source cache comparison from existing archives.
|
||||
default: ''
|
||||
type: string
|
||||
|
||||
module_cache_seed:
|
||||
description: Publish comparison caches from a manually dispatched workflow.
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
phase_diagnostics:
|
||||
description: Attach bounded per-file worker phase diagnostics.
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
artifact_prefix:
|
||||
description: Distinguish evidence from repeated same-source comparison lanes.
|
||||
default: unit
|
||||
type: string
|
||||
|
||||
run_relay_integration:
|
||||
description: Run relay contracts alongside normal unit checks.
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
if: inputs.run_shards
|
||||
name: tests node ${{ matrix.node }} ${{ matrix.shard.index }}/${{ matrix.shard.count }}
|
||||
name: ${{ inputs.artifact_prefix != 'unit' && format('{0} ', inputs.artifact_prefix) || '' }}tests node ${{ matrix.node }} ${{ matrix.shard.index }}/${{ matrix.shard.count }}
|
||||
runs-on: ${{ inputs.runner }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
@@ -65,17 +95,51 @@ jobs:
|
||||
with:
|
||||
bun-version-file: config/.bun-version
|
||||
|
||||
- name: Resolve unit module cache
|
||||
id: module-cache
|
||||
if: inputs.module_cache == 'cold' || inputs.module_cache == 'warm'
|
||||
env:
|
||||
ORCA_UNIT_SHARD_INDEX: ${{ matrix.shard.index }}
|
||||
ORCA_UNIT_SHARD_COUNT: ${{ matrix.shard.count }}
|
||||
ORCA_UNIT_CACHE_NAMESPACE: ${{ inputs.module_cache_namespace }}
|
||||
run: node config/scripts/ci-unit-module-cache.mjs identity
|
||||
|
||||
- name: Restore exact unit module cache
|
||||
id: module-cache-restore
|
||||
if: inputs.module_cache == 'warm' && steps.module-cache.outputs.key != ''
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: ${{ steps.module-cache.outputs.path }}
|
||||
key: ${{ steps.module-cache.outputs.key }}
|
||||
|
||||
- name: Validate restored unit module cache or use cold transforms
|
||||
if: steps.module-cache.outputs.key != ''
|
||||
env:
|
||||
ORCA_UNIT_CACHE_RESTORE_HIT: ${{ steps.module-cache-restore.outputs.cache-hit }}
|
||||
run: node config/scripts/ci-unit-module-cache.mjs prepare
|
||||
|
||||
- uses: actions/download-artifact@v8
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: unit-selection-attempt-${{ github.run_attempt }}
|
||||
path: ci-shards/
|
||||
|
||||
- name: Record full unit discovery
|
||||
if: inputs.phase_diagnostics
|
||||
continue-on-error: true
|
||||
run: >-
|
||||
node --input-type=module -e '
|
||||
import { discoverUnitFiles } from "./config/scripts/ci-unit-files.mjs";
|
||||
import { writeAssignment } from "./config/scripts/ci-shard-assignment.mjs";
|
||||
writeAssignment("ci-shards/unit-discovery.json", { files: discoverUnitFiles() });'
|
||||
|
||||
- name: Test shard
|
||||
env:
|
||||
NODE_COMPILE_CACHE: ${{ runner.temp }}/orca-node-compile-cache-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.node }}-${{ matrix.shard.index }}
|
||||
ORCA_BALANCE_UNIT_SHARDS: '1'
|
||||
ORCA_BACKGROUND_LAUNCH: '1'
|
||||
ORCA_UNIT_PHASE_REPORT: ${{ inputs.phase_diagnostics && 'ci-shards/unit-phases.json' || '' }}
|
||||
run: |
|
||||
ORCA_SHARD_SOURCE_SHA="$(git rev-parse HEAD)"
|
||||
export ORCA_SHARD_SOURCE_SHA
|
||||
@@ -83,18 +147,37 @@ jobs:
|
||||
--shard=${{ matrix.shard.index }}/${{ matrix.shard.count }} \
|
||||
${{ inputs.runner == 'ubuntu-24.04-arm' && '--maxWorkers=4' || '' }}
|
||||
|
||||
- name: Seal unit module cache
|
||||
id: module-cache-seal
|
||||
if: >-
|
||||
success() && steps.module-cache.outputs.key != '' &&
|
||||
steps.module-cache-restore.outputs.cache-hit != 'true' &&
|
||||
(github.event_name == 'pull_request' ||
|
||||
github.ref == 'refs/heads/main' && github.event_name != 'pull_request' ||
|
||||
inputs.module_cache_seed && github.event_name == 'workflow_dispatch')
|
||||
run: node config/scripts/ci-unit-module-cache.mjs seal
|
||||
|
||||
- name: Save unit module cache
|
||||
if: steps.module-cache-seal.outputs.ready == 'true'
|
||||
continue-on-error: true
|
||||
uses: actions/cache/save@v5
|
||||
with:
|
||||
path: ${{ steps.module-cache.outputs.path }}
|
||||
key: ${{ steps.module-cache.outputs.key }}
|
||||
|
||||
- name: Upload unit shard assignment
|
||||
if: always()
|
||||
# Diagnostic upload outages must not change the test verdict.
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: unit-shard-node-${{ matrix.node }}-${{ matrix.shard.index }}-attempt-${{ github.run_attempt }}
|
||||
name: ${{ inputs.artifact_prefix }}-shard-node-${{ matrix.node }}-${{ matrix.shard.index }}-attempt-${{ github.run_attempt }}
|
||||
path: ci-shards/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
relay_integration:
|
||||
if: inputs.run_relay_integration
|
||||
name: relay integration node ${{ matrix.node }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@orca-cloud/relay",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
@@ -11,7 +11,7 @@
|
||||
"lint": "tsc -p tsconfig.json --noEmit",
|
||||
"pretest": "pnpm --filter @orca-cloud/postgres-schema build && pnpm --filter @orca-cloud/relay-contract build",
|
||||
"start": "node dist/index.js",
|
||||
"test": "vitest run",
|
||||
"test": "node --import tsx --conditions=orca-source ../../dev/scripts/run-relay-tests.mjs",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -22,6 +22,7 @@ const PROBE_TABLE = 'relay_lost_reply_probe'
|
||||
async function blackholeProxy(target: URL, trigger: string, delayMs?: number) {
|
||||
const sockets = new Set<Socket>()
|
||||
const afterTrigger: Buffer[] = []
|
||||
let forwardingResumed = false
|
||||
const server: Server = createServer((client) => {
|
||||
const upstream = connect(Number(target.port || 5432), target.hostname)
|
||||
sockets.add(client)
|
||||
@@ -33,7 +34,7 @@ async function blackholeProxy(target: URL, trigger: string, delayMs?: number) {
|
||||
upstream.write(chunk)
|
||||
})
|
||||
upstream.on('data', (chunk: Buffer) => {
|
||||
if (!swallowing) client.write(chunk)
|
||||
if (!swallowing || forwardingResumed) client.write(chunk)
|
||||
else if (delayMs !== undefined) setTimeout(() => client.write(chunk), delayMs)
|
||||
})
|
||||
for (const socket of [client, upstream]) {
|
||||
@@ -52,6 +53,9 @@ async function blackholeProxy(target: URL, trigger: string, delayMs?: number) {
|
||||
url.port = String(address.port)
|
||||
return {
|
||||
url: url.toString(),
|
||||
resumeForwarding: () => {
|
||||
forwardingResumed = true
|
||||
},
|
||||
sentAfterTrigger: () => Buffer.concat(afterTrigger).toString('latin1'),
|
||||
close: async () => {
|
||||
for (const socket of sockets) socket.destroy()
|
||||
@@ -174,6 +178,7 @@ describePostgres('a lost PostgreSQL reply against a real server', () => {
|
||||
{ probe: 'stalled_reply_probe' }
|
||||
])
|
||||
expect(performance.now() - startedAt).toBeGreaterThan(6_000)
|
||||
proxy.resumeForwarding()
|
||||
// Not destroyed: the one pooled connection is still the same backend.
|
||||
expect(await pid()).toBe(before)
|
||||
}, 30_000)
|
||||
|
||||
@@ -1,26 +1,22 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defaultExclude, defineConfig } from 'vitest/config'
|
||||
|
||||
// Every test file that opens ORCA_RELAY_TEST_POSTGRES_URL shares one CI
|
||||
// database, and those tests take session-level locks with a 1s lock_timeout.
|
||||
// Running them alongside anything else collides into lock timeouts, capacity
|
||||
// exhaustion, and afterAll hangs. Keep them in their own serialized project so
|
||||
// only they give up file parallelism; the rest of the suite opens SQLite data
|
||||
// directories and stays fully parallel.
|
||||
// PostgreSQL files share session locks and a small connection budget.
|
||||
// The runner overlaps this serialized project with SQLite tests after other packages finish.
|
||||
const sourceDirectory = fileURLToPath(new URL('src', import.meta.url))
|
||||
const sharedPostgresTests = readdirSync(sourceDirectory)
|
||||
.filter((entry) => entry.endsWith('.test.ts'))
|
||||
.filter((entry) =>
|
||||
readFileSync(`${sourceDirectory}/${entry}`, 'utf8').includes(
|
||||
'ORCA_RELAY_TEST_POSTGRES_URL'
|
||||
)
|
||||
readFileSync(join(sourceDirectory, entry), 'utf8').includes('ORCA_RELAY_TEST_POSTGRES_URL')
|
||||
)
|
||||
.map((entry) => `src/${entry}`)
|
||||
|
||||
const timeouts = { testTimeout: 15_000, hookTimeout: 15_000 }
|
||||
|
||||
export default defineConfig({
|
||||
cacheDir: process.env.ORCA_RELAY_TEST_CACHE_DIR,
|
||||
test: {
|
||||
projects: [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import {
|
||||
forceTerminateProcessTree,
|
||||
signalProcessTree
|
||||
} from '@orca/process-host/process-tree-termination'
|
||||
import { mkdtempSync, mkdirSync, realpathSync, rmSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { availableParallelism, tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const vitest = join(dirname(require.resolve('vitest/package.json')), 'vitest.mjs')
|
||||
const relayDirectory = fileURLToPath(new URL('../../apps/relay', import.meta.url))
|
||||
|
||||
export async function runRelayTests({
|
||||
args = [],
|
||||
cwd = relayDirectory,
|
||||
env = process.env,
|
||||
parallelism = availableParallelism(),
|
||||
cli = vitest,
|
||||
run = runProcess,
|
||||
events = process,
|
||||
report = console.error,
|
||||
scratchParent = tmpdir()
|
||||
} = {}) {
|
||||
const workerBudget = Math.max(parallelism - 1, 1)
|
||||
// Explicit selectors and alternate database fixtures retain their existing execution path.
|
||||
const serial =
|
||||
args.length > 0 || workerBudget <= 1 || env.ORCA_IDLE_REHOME_POSTGRES_URL || env.DATABASE_URL
|
||||
const roles = serial
|
||||
? [{ name: 'all', args }]
|
||||
: [
|
||||
{ name: 'relay', args: ['--project=relay', `--maxWorkers=${workerBudget - 1}`] },
|
||||
{ name: 'relay-postgres', args: ['--project=relay-postgres', '--maxWorkers=1'] }
|
||||
]
|
||||
const scratch = realpathSync(mkdtempSync(join(scratchParent, 'orca-relay-tests-')))
|
||||
const abort = new AbortController()
|
||||
const closed = new Set()
|
||||
const terminatedTrees = new Set()
|
||||
let signalExitCode
|
||||
const onInterrupt = () => {
|
||||
signalExitCode ??= 130
|
||||
abort.abort()
|
||||
}
|
||||
const onTerminate = () => {
|
||||
signalExitCode ??= 143
|
||||
abort.abort()
|
||||
}
|
||||
events.on('SIGINT', onInterrupt)
|
||||
events.on('SIGTERM', onTerminate)
|
||||
|
||||
async function runRole(role) {
|
||||
const owned = join(scratch, role.name)
|
||||
try {
|
||||
mkdirSync(owned)
|
||||
return await run({
|
||||
program: process.execPath,
|
||||
args: [cli, 'run', ...role.args],
|
||||
cwd,
|
||||
env: {
|
||||
...env,
|
||||
TMPDIR: owned,
|
||||
TMP: owned,
|
||||
TEMP: owned,
|
||||
ORCA_BACKGROUND_LAUNCH: '1',
|
||||
ORCA_RELAY_TEST_CACHE_DIR: join(owned, 'cache')
|
||||
},
|
||||
stdio: 'inherit',
|
||||
timeoutMs: null,
|
||||
signal: abort.signal,
|
||||
terminationBarrier: {
|
||||
async signal(child, signal) {
|
||||
const terminated = await signalProcessTree(child, signal)
|
||||
if (process.platform === 'win32' && terminated) {
|
||||
terminatedTrees.add(role.name)
|
||||
}
|
||||
return terminated
|
||||
},
|
||||
async force(child) {
|
||||
const terminated = await forceTerminateProcessTree(child)
|
||||
if (terminated) {
|
||||
terminatedTrees.add(role.name)
|
||||
}
|
||||
return terminated
|
||||
}
|
||||
},
|
||||
onChildTerminated: () => closed.add(role.name)
|
||||
})
|
||||
} catch (error) {
|
||||
abort.abort()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
let exitCode = 0
|
||||
try {
|
||||
const results = await Promise.allSettled(roles.map(runRole))
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
report(result.reason)
|
||||
exitCode ||= 1
|
||||
} else if (result.value.code !== 0 || result.value.signal || result.value.timedOut) {
|
||||
exitCode ||= result.value.code || 1
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
events.removeListener('SIGINT', onInterrupt)
|
||||
events.removeListener('SIGTERM', onTerminate)
|
||||
// A cancelled root's close event alone does not prove its descendants stopped using scratch.
|
||||
if (
|
||||
closed.size === roles.length &&
|
||||
(!abort.signal.aborted || terminatedTrees.size === roles.length)
|
||||
) {
|
||||
rmSync(scratch, { recursive: true, force: true })
|
||||
} else {
|
||||
report(`Relay test process closure is unverifiable; retained ${scratch}`)
|
||||
exitCode ||= 1
|
||||
}
|
||||
}
|
||||
return signalExitCode ?? exitCode
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
process.exitCode = await runRelayTests({ args: process.argv.slice(2) })
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { setImmediate } from 'node:timers/promises'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import test from 'node:test'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { runRelayTests } from './run-relay-tests.mjs'
|
||||
|
||||
const success = { code: 0, signal: null, timedOut: false }
|
||||
const databaseUrl = 'postgres://relay_test:relay_test@127.0.0.1:5432/orca_relay_test'
|
||||
const require = createRequire(import.meta.url)
|
||||
const cloud = resolve(import.meta.dirname, '../..')
|
||||
|
||||
function temporary(t) {
|
||||
const directory = realpathSync(mkdtempSync(join(tmpdir(), 'orca-relay-runner-contract-')))
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }))
|
||||
return directory
|
||||
}
|
||||
|
||||
function start(t, options = {}) {
|
||||
const scratchParent = temporary(t)
|
||||
const calls = []
|
||||
const reports = []
|
||||
const events = new EventEmitter()
|
||||
const env = { ORCA_RELAY_TEST_POSTGRES_URL: databaseUrl, PRESERVED: 'yes', ...options.env }
|
||||
const pending = runRelayTests({
|
||||
cwd: join(scratchParent, 'relay'),
|
||||
cli: join(scratchParent, 'vitest.mjs'),
|
||||
parallelism: 4,
|
||||
...options,
|
||||
env,
|
||||
events,
|
||||
scratchParent,
|
||||
report: (message) => reports.push(message),
|
||||
run: (spec) => {
|
||||
const deferred = Promise.withResolvers()
|
||||
calls.push({ spec, ...deferred })
|
||||
return deferred.promise
|
||||
}
|
||||
})
|
||||
return { pending, calls, reports, events, scratchParent, env }
|
||||
}
|
||||
|
||||
function finish(call, result = success, closed = true) {
|
||||
if (closed) {
|
||||
call.spec.onChildTerminated()
|
||||
}
|
||||
call.resolve(result)
|
||||
}
|
||||
|
||||
function assertClean(harness) {
|
||||
assert.deepEqual(readdirSync(harness.scratchParent), [])
|
||||
assert.equal(harness.events.listenerCount('SIGINT'), 0)
|
||||
assert.equal(harness.events.listenerCount('SIGTERM'), 0)
|
||||
}
|
||||
|
||||
test('overlaps both projects within the worker budget and isolates their scratch and caches', async (t) => {
|
||||
const h = start(t)
|
||||
assert.equal(h.calls.length, 2)
|
||||
const [regular, postgres] = h.calls.map((call) => call.spec)
|
||||
assert.deepEqual(regular.args.slice(1), ['run', '--project=relay', '--maxWorkers=2'])
|
||||
assert.deepEqual(postgres.args.slice(1), ['run', '--project=relay-postgres', '--maxWorkers=1'])
|
||||
assert.notEqual(regular.env.TMPDIR, postgres.env.TMPDIR)
|
||||
assert.notEqual(regular.env.ORCA_RELAY_TEST_CACHE_DIR, postgres.env.ORCA_RELAY_TEST_CACHE_DIR)
|
||||
for (const spec of [regular, postgres]) {
|
||||
assert.equal(spec.env.TMP, spec.env.TMPDIR)
|
||||
assert.equal(spec.env.TEMP, spec.env.TMPDIR)
|
||||
assert.equal(spec.env.ORCA_RELAY_TEST_CACHE_DIR, join(spec.env.TMPDIR, 'cache'))
|
||||
assert.equal(spec.env.ORCA_RELAY_TEST_POSTGRES_URL, databaseUrl)
|
||||
assert.equal(spec.env.PRESERVED, 'yes')
|
||||
assert.equal(spec.env.ORCA_BACKGROUND_LAUNCH, '1')
|
||||
assert.ok(existsSync(spec.env.TMPDIR))
|
||||
assert.equal(spec.timeoutMs, null)
|
||||
assert.equal(typeof spec.terminationBarrier.force, 'function')
|
||||
}
|
||||
assert.equal(regular.signal, postgres.signal)
|
||||
let settled = false
|
||||
h.pending.then(() => {
|
||||
settled = true
|
||||
})
|
||||
finish(h.calls[0], { ...success, code: 7 })
|
||||
await setImmediate()
|
||||
assert.equal(settled, false)
|
||||
assert.equal(postgres.signal.aborted, false)
|
||||
assert.ok(existsSync(postgres.env.TMPDIR))
|
||||
finish(h.calls[1])
|
||||
assert.equal(await h.pending, 7)
|
||||
assertClean(h)
|
||||
})
|
||||
|
||||
for (const [name, options] of [
|
||||
['selectors', { args: ['src/assignment.test.ts', '-t', 'requested behavior'] }],
|
||||
[
|
||||
'alternate PostgreSQL',
|
||||
{ env: { ORCA_IDLE_REHOME_POSTGRES_URL: 'postgres://localhost:55440/relay' } }
|
||||
],
|
||||
['application database', { env: { DATABASE_URL: databaseUrl } }],
|
||||
['one CPU', { parallelism: 1 }],
|
||||
['two CPUs', { parallelism: 2 }]
|
||||
]) {
|
||||
test(`retains the original single run for ${name}`, async (t) => {
|
||||
const h = start(t, options)
|
||||
assert.equal(h.calls.length, 1)
|
||||
const { spec } = h.calls[0]
|
||||
assert.deepEqual(spec.args.slice(1), ['run', ...(options.args ?? [])])
|
||||
for (const [key, value] of Object.entries(h.env)) {
|
||||
assert.equal(spec.env[key], value)
|
||||
}
|
||||
finish(h.calls[0])
|
||||
assert.equal(await h.pending, 0)
|
||||
assertClean(h)
|
||||
})
|
||||
}
|
||||
|
||||
for (const [name, result] of [
|
||||
['null exit code', { ...success, code: null }],
|
||||
['termination signal', { ...success, signal: 'SIGTERM' }],
|
||||
['timeout', { ...success, timedOut: true }]
|
||||
]) {
|
||||
test(`fails a ${name} even when other result fields indicate success`, async (t) => {
|
||||
const h = start(t)
|
||||
finish(h.calls[0], result)
|
||||
finish(h.calls[1])
|
||||
assert.equal(await h.pending, 1)
|
||||
assertClean(h)
|
||||
})
|
||||
}
|
||||
|
||||
test('fails and retains scratch when a child never reports closure', async (t) => {
|
||||
const h = start(t)
|
||||
finish(h.calls[0], success, false)
|
||||
finish(h.calls[1])
|
||||
assert.equal(await h.pending, 1)
|
||||
assert.ok(existsSync(h.calls[0].spec.env.TMPDIR))
|
||||
assert.match(String(h.reports[0]), /closure is unverifiable; retained/)
|
||||
assert.equal(h.events.listenerCount('SIGINT'), 0)
|
||||
assert.equal(h.events.listenerCount('SIGTERM'), 0)
|
||||
})
|
||||
|
||||
for (const [signal, exitCode, laterSignal] of [
|
||||
['SIGINT', 130, 'SIGTERM'],
|
||||
['SIGTERM', 143, 'SIGINT']
|
||||
]) {
|
||||
test(`${signal} cancels every child and retains scratch despite root close callbacks`, async (t) => {
|
||||
const h = start(t)
|
||||
h.events.emit(signal)
|
||||
h.events.emit(laterSignal)
|
||||
for (const call of h.calls) {
|
||||
assert.equal(call.spec.signal.aborted, true)
|
||||
finish(call)
|
||||
}
|
||||
assert.equal(await h.pending, exitCode)
|
||||
for (const call of h.calls) {
|
||||
assert.ok(existsSync(call.spec.env.TMPDIR))
|
||||
}
|
||||
assert.match(String(h.reports[0]), /closure is unverifiable; retained/)
|
||||
assert.equal(h.events.listenerCount('SIGINT'), 0)
|
||||
assert.equal(h.events.listenerCount('SIGTERM'), 0)
|
||||
})
|
||||
}
|
||||
|
||||
for (const verified of [1, 2]) {
|
||||
test(`requires verification of both cancelled trees (${verified} verified)`, async (t) => {
|
||||
const h = start(t)
|
||||
h.events.emit('SIGINT')
|
||||
for (const call of h.calls.slice(0, verified)) {
|
||||
// An unspawned handle proves absence without signalling a real process.
|
||||
assert.equal(await call.spec.terminationBarrier.force({ kill: () => true }), true)
|
||||
}
|
||||
for (const call of h.calls) {
|
||||
finish(call)
|
||||
}
|
||||
assert.equal(await h.pending, 130)
|
||||
if (verified === 2) {
|
||||
assertClean(h)
|
||||
} else {
|
||||
assert.ok(existsSync(h.calls[0].spec.env.TMPDIR))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test('reports a spawn failure and waits for its cancelled peer before returning', async (t) => {
|
||||
const h = start(t)
|
||||
const failure = new Error('fixture spawn failed')
|
||||
h.calls[0].reject(failure)
|
||||
let settled = false
|
||||
h.pending.then(() => {
|
||||
settled = true
|
||||
})
|
||||
await setImmediate()
|
||||
assert.equal(h.calls[1].spec.signal.aborted, true)
|
||||
assert.equal(settled, false)
|
||||
finish(h.calls[1])
|
||||
assert.equal(await h.pending, 1)
|
||||
assert.equal(h.reports[0], failure)
|
||||
assert.ok(existsSync(h.calls[1].spec.env.TMPDIR))
|
||||
})
|
||||
|
||||
test('launches with only the Cloud dependency link and no root install or compiled host package', async (t) => {
|
||||
const root = temporary(t)
|
||||
const standalone = join(root, 'cloud')
|
||||
const manifest = JSON.parse(readFileSync(join(cloud, 'package.json'), 'utf8'))
|
||||
const dependency = manifest.devDependencies['@orca/process-host']
|
||||
assert.equal(dependency, 'link:../src/packages/process-host')
|
||||
const packageDir = resolve(standalone, dependency.slice('link:'.length))
|
||||
mkdirSync(packageDir, { recursive: true })
|
||||
for (const file of ['package.json', 'src']) {
|
||||
cpSync(join(cloud, '..', 'src', 'packages', 'process-host', file), join(packageDir, file), {
|
||||
recursive: true
|
||||
})
|
||||
}
|
||||
const scripts = join(standalone, 'dev', 'scripts')
|
||||
mkdirSync(scripts, { recursive: true })
|
||||
cpSync(join(cloud, 'dev', 'scripts', 'run-relay-tests.mjs'), join(scripts, 'run-relay-tests.mjs'))
|
||||
writeFileSync(join(standalone, 'package.json'), JSON.stringify(manifest))
|
||||
mkdirSync(join(standalone, 'apps', 'relay'), { recursive: true })
|
||||
mkdirSync(join(standalone, 'node_modules', '@orca'), { recursive: true })
|
||||
symlinkSync(
|
||||
packageDir,
|
||||
join(standalone, 'node_modules', '@orca', 'process-host'),
|
||||
process.platform === 'win32' ? 'junction' : 'dir'
|
||||
)
|
||||
const fakeVitest = join(standalone, 'node_modules', 'vitest')
|
||||
mkdirSync(fakeVitest)
|
||||
writeFileSync(join(fakeVitest, 'package.json'), '{"name":"vitest","version":"0.0.0"}')
|
||||
writeFileSync(
|
||||
join(fakeVitest, 'vitest.mjs'),
|
||||
'console.log(JSON.stringify({args:process.argv.slice(2), background:process.env.ORCA_BACKGROUND_LAUNCH, database:process.env.ORCA_RELAY_TEST_POSTGRES_URL}))'
|
||||
)
|
||||
assert.equal(existsSync(join(root, 'node_modules')), false)
|
||||
assert.equal(existsSync(join(packageDir, 'dist')), false)
|
||||
const result = await runProcess({
|
||||
program: process.execPath,
|
||||
args: [
|
||||
'--import',
|
||||
pathToFileURL(require.resolve('tsx')).href,
|
||||
'--conditions=orca-source',
|
||||
join(scripts, 'run-relay-tests.mjs'),
|
||||
'src/requested.test.ts'
|
||||
],
|
||||
cwd: standalone,
|
||||
timeoutMs: 15_000,
|
||||
terminationBarrier: true,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_PATH: '',
|
||||
NODE_OPTIONS: '',
|
||||
ORCA_BACKGROUND_LAUNCH: '1',
|
||||
ORCA_RELAY_TEST_POSTGRES_URL: databaseUrl
|
||||
}
|
||||
})
|
||||
assert.equal(result.code, 0, result.stderr)
|
||||
assert.deepEqual(JSON.parse(result.stdout), {
|
||||
args: ['run', 'src/requested.test.ts'],
|
||||
background: '1',
|
||||
database: databaseUrl
|
||||
})
|
||||
assert.equal(existsSync(join(packageDir, 'dist')), false)
|
||||
})
|
||||
+12
-10
@@ -1,12 +1,7 @@
|
||||
{
|
||||
"name": "orca-cloud",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"packageManager": "pnpm@10.34.6",
|
||||
"engines": {
|
||||
"node": ">=24 <27",
|
||||
"pnpm": ">=10"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "pnpm -r build",
|
||||
"dev": "pnpm --filter @orca-cloud/relay dev",
|
||||
@@ -21,14 +16,21 @@
|
||||
"load:relay:model": "node dev/scripts/run-relay-load-model.mjs",
|
||||
"load:relay:recovery-gate": "node dev/scripts/run-relay-recovery-wave-gate.mjs",
|
||||
"ops:relay": "pnpm --filter @orca-cloud/relay-ops dev",
|
||||
"pretest": "node --test dev/scripts/capture-terraform-plan-baseline.test.mjs dev/scripts/operate-relay-asia-admission.test.mjs dev/scripts/prepare-relay-asia-director-cells.test.mjs dev/scripts/prepare-relay-asia-topology-input.test.mjs dev/scripts/production-cloud-sql-rollout-lock.test.mjs dev/scripts/read-relay-serving-regional-placement-version.test.mjs dev/scripts/relay-asia-rollout-evidence.test.mjs dev/scripts/relay-asia-topology-workflow.test.mjs dev/scripts/relay-cloud-sql-connection-budget.test.mjs dev/scripts/relay-load-reader-evidence.test.mjs dev/scripts/relay-control-flag-alerts.test.mjs dev/scripts/relay-db-fault-alerts.test.mjs dev/scripts/relay-lock-contention-alerts.test.mjs dev/scripts/relay-reconnect-herd-alerts.test.mjs dev/scripts/relay-reserve-dead-man-alert.test.mjs dev/scripts/relay-region-hint-metrics.test.mjs dev/scripts/relay-staging-deploy-identity.test.mjs dev/scripts/sanitize-relay-asia-admission-result.test.mjs dev/scripts/terraform-root-partition.test.mjs dev/scripts/validate-relay-asia-topology-plan.test.mjs ../.github/actions/cloud-sql-rollout-lease/action-contract.test.mjs ../.github/actions/cloud-sql-rollout-lease/storage-lease.test.mjs",
|
||||
"test": "pnpm -r test && node --test dev/scripts/check-relay-same-cap-headroom.test.mjs dev/scripts/classify-relay-production-capacity-director.test.mjs dev/scripts/classify-relay-staging-bootstrap.test.mjs dev/scripts/deploy-relay-blue-green.test.mjs dev/scripts/deploy-relay-gce-candidate.test.mjs dev/scripts/deploy-relay-gce-multi-target.test.mjs dev/scripts/drive-relay-director-deploy.test.mjs dev/scripts/github-smoke-token.test.mjs dev/scripts/infra.test.mjs dev/scripts/measure-relay-drain-disconnect-gap.test.mjs dev/scripts/operate-relay-cell-flags.test.mjs dev/scripts/operate-relay-regional-rehome.test.mjs dev/scripts/power-staging-relay.test.mjs dev/scripts/prepare-relay-capacity-canary.test.mjs dev/scripts/prepare-relay-production-capacity-canary.test.mjs dev/scripts/probe-relay-legacy-admission.test.mjs dev/scripts/probe-relay-rehome-trust.test.mjs dev/scripts/production-cell-image-digest-consistency.test.mjs dev/scripts/push-gateway-workflow.test.mjs dev/scripts/push-gateway-recovery.test.mjs dev/scripts/read-relay-production-capacity-identity.test.mjs dev/scripts/relay-admin-endpoint-retry-workflow.test.mjs dev/scripts/relay-admin-transient-retry.test.mjs dev/scripts/relay-admission-selector.test.mjs dev/scripts/relay-gce-terraform-fence.test.mjs dev/scripts/relay-load-connection-failure.test.mjs dev/scripts/relay-load-control-peer.test.mjs dev/scripts/relay-load-director-capacity-gate.test.mjs dev/scripts/relay-load-duplicate-assign.test.mjs dev/scripts/relay-load-model.test.mjs dev/scripts/relay-load-phase-barrier.test.mjs dev/scripts/relay-load-placement-boundary.test.mjs dev/scripts/relay-load-profile.test.mjs dev/scripts/relay-load-rebind-boundary.test.mjs dev/scripts/relay-load-region-behavior.test.mjs dev/scripts/relay-load-request-unit-boundary.test.mjs dev/scripts/relay-load-run-lifecycle.test.mjs dev/scripts/relay-load-splice-retry.test.mjs dev/scripts/relay-monitor-evidence.test.mjs dev/scripts/relay-production-capacity-wave.test.mjs dev/scripts/relay-production-capacity-workflow.test.mjs dev/scripts/relay-production-identity-boundaries.test.mjs dev/scripts/relay-production-same-cap-wave.test.mjs dev/scripts/relay-public-workflow-contract.test.mjs dev/scripts/relay-recovery-wave-gate.test.mjs dev/scripts/relay-region-observation-evidence.test.mjs dev/scripts/relay-rehome-aggregate-evidence.test.mjs dev/scripts/relay-repository.test.mjs dev/scripts/relay-same-cap-job-mode-conditions.test.mjs dev/scripts/relay-same-cap-script-census.test.mjs dev/scripts/relay-same-cap-shadow-gate.test.mjs dev/scripts/relay-staging-c4-refresh-workflow.test.mjs dev/scripts/relay-staging-capacity-identity.test.mjs dev/scripts/staging-relay-apply-guard.test.mjs dev/scripts/validate-relay-capacity-plan.test.mjs dev/scripts/verify-relay-capacity-transition.test.mjs dev/scripts/verify-relay-legacy-bootstrap.test.mjs dev/scripts/workload-identity-attribute-conditions.test.mjs",
|
||||
"typecheck": "pnpm -r typecheck"
|
||||
"pretest": "node --test dev/scripts/capture-terraform-plan-baseline.test.mjs dev/scripts/operate-relay-asia-admission.test.mjs dev/scripts/prepare-relay-asia-director-cells.test.mjs dev/scripts/prepare-relay-asia-topology-input.test.mjs dev/scripts/production-cloud-sql-rollout-lock.test.mjs dev/scripts/read-relay-serving-regional-placement-version.test.mjs dev/scripts/relay-asia-rollout-evidence.test.mjs dev/scripts/relay-asia-topology-workflow.test.mjs dev/scripts/relay-cloud-sql-connection-budget.test.mjs dev/scripts/relay-load-reader-evidence.test.mjs dev/scripts/relay-control-flag-alerts.test.mjs dev/scripts/relay-db-fault-alerts.test.mjs dev/scripts/relay-lock-contention-alerts.test.mjs dev/scripts/relay-reconnect-herd-alerts.test.mjs dev/scripts/relay-reserve-dead-man-alert.test.mjs dev/scripts/relay-region-hint-metrics.test.mjs dev/scripts/relay-staging-deploy-identity.test.mjs dev/scripts/sanitize-relay-asia-admission-result.test.mjs dev/scripts/terraform-root-partition.test.mjs dev/scripts/validate-relay-asia-topology-plan.test.mjs ../.github/actions/cloud-sql-rollout-lease/action-contract.test.mjs ../.github/actions/cloud-sql-rollout-lease/storage-lease.test.mjs && pnpm test:runner",
|
||||
"test": "pnpm -r --filter \"!@orca-cloud/relay\" test && pnpm --filter @orca-cloud/relay test && node --test dev/scripts/check-relay-same-cap-headroom.test.mjs dev/scripts/classify-relay-production-capacity-director.test.mjs dev/scripts/classify-relay-staging-bootstrap.test.mjs dev/scripts/deploy-relay-blue-green.test.mjs dev/scripts/deploy-relay-gce-candidate.test.mjs dev/scripts/deploy-relay-gce-multi-target.test.mjs dev/scripts/drive-relay-director-deploy.test.mjs dev/scripts/github-smoke-token.test.mjs dev/scripts/infra.test.mjs dev/scripts/measure-relay-drain-disconnect-gap.test.mjs dev/scripts/operate-relay-cell-flags.test.mjs dev/scripts/operate-relay-regional-rehome.test.mjs dev/scripts/power-staging-relay.test.mjs dev/scripts/prepare-relay-capacity-canary.test.mjs dev/scripts/prepare-relay-production-capacity-canary.test.mjs dev/scripts/probe-relay-legacy-admission.test.mjs dev/scripts/probe-relay-rehome-trust.test.mjs dev/scripts/production-cell-image-digest-consistency.test.mjs dev/scripts/push-gateway-workflow.test.mjs dev/scripts/push-gateway-recovery.test.mjs dev/scripts/read-relay-production-capacity-identity.test.mjs dev/scripts/relay-admin-endpoint-retry-workflow.test.mjs dev/scripts/relay-admin-transient-retry.test.mjs dev/scripts/relay-admission-selector.test.mjs dev/scripts/relay-gce-terraform-fence.test.mjs dev/scripts/relay-load-connection-failure.test.mjs dev/scripts/relay-load-control-peer.test.mjs dev/scripts/relay-load-director-capacity-gate.test.mjs dev/scripts/relay-load-duplicate-assign.test.mjs dev/scripts/relay-load-model.test.mjs dev/scripts/relay-load-phase-barrier.test.mjs dev/scripts/relay-load-placement-boundary.test.mjs dev/scripts/relay-load-profile.test.mjs dev/scripts/relay-load-rebind-boundary.test.mjs dev/scripts/relay-load-region-behavior.test.mjs dev/scripts/relay-load-request-unit-boundary.test.mjs dev/scripts/relay-load-run-lifecycle.test.mjs dev/scripts/relay-load-splice-retry.test.mjs dev/scripts/relay-monitor-evidence.test.mjs dev/scripts/relay-production-capacity-wave.test.mjs dev/scripts/relay-production-capacity-workflow.test.mjs dev/scripts/relay-production-identity-boundaries.test.mjs dev/scripts/relay-production-same-cap-wave.test.mjs dev/scripts/relay-public-workflow-contract.test.mjs dev/scripts/relay-recovery-wave-gate.test.mjs dev/scripts/relay-region-observation-evidence.test.mjs dev/scripts/relay-rehome-aggregate-evidence.test.mjs dev/scripts/relay-repository.test.mjs dev/scripts/relay-same-cap-job-mode-conditions.test.mjs dev/scripts/relay-same-cap-script-census.test.mjs dev/scripts/relay-same-cap-shadow-gate.test.mjs dev/scripts/relay-staging-c4-refresh-workflow.test.mjs dev/scripts/relay-staging-capacity-identity.test.mjs dev/scripts/staging-relay-apply-guard.test.mjs dev/scripts/validate-relay-capacity-plan.test.mjs dev/scripts/verify-relay-capacity-transition.test.mjs dev/scripts/verify-relay-legacy-bootstrap.test.mjs dev/scripts/workload-identity-attribute-conditions.test.mjs",
|
||||
"typecheck": "pnpm -r typecheck",
|
||||
"test:runner": "node --import tsx --conditions=orca-source --test dev/scripts/run-relay-tests.test.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@orca/process-host": "link:../src/packages/process-host",
|
||||
"@types/node": "^24.19.0",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24 <27",
|
||||
"pnpm": ">=10"
|
||||
},
|
||||
"packageManager": "pnpm@10.34.6"
|
||||
}
|
||||
|
||||
Generated
+3
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
devDependencies:
|
||||
'@orca/process-host':
|
||||
specifier: link:../src/packages/process-host
|
||||
version: link:../src/packages/process-host
|
||||
'@types/node':
|
||||
specifier: ^24.19.0
|
||||
version: 24.19.0
|
||||
|
||||
@@ -97,7 +97,7 @@ it('keeps Electron build and native prerequisites for every consumer requiring t
|
||||
}
|
||||
expect(workflow.jobs['ssh-browser-network-route'].needs).toBeUndefined()
|
||||
for (const name of ['e2e', 'changed-e2e']) {
|
||||
expect(workflow.jobs[name].needs, name).toEqual(['build', 'prepare-native-cache'])
|
||||
expect(workflow.jobs[name].needs, name).toEqual(['build'])
|
||||
}
|
||||
})
|
||||
|
||||
@@ -132,11 +132,9 @@ it('applies allocation hints only to PRs and retains other callers and full refe
|
||||
default: true
|
||||
})
|
||||
}
|
||||
for (const name of ['build', 'prepare-native-cache']) {
|
||||
expect(workflow.jobs[name].if).toBe(
|
||||
"inputs.test_files == '' || github.event_name != 'pull_request' || inputs.needs_build"
|
||||
)
|
||||
}
|
||||
expect(workflow.jobs.build.if).toBe(
|
||||
"inputs.test_files == '' || github.event_name != 'pull_request' || inputs.needs_build"
|
||||
)
|
||||
expect(workflow.jobs.e2e.if).toBe("inputs.test_files == ''")
|
||||
const changed = workflow.jobs['changed-e2e']
|
||||
expect(changed.if).toContain("github.event_name != 'pull_request' || inputs.run_changed_e2e")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { parse } from 'yaml'
|
||||
@@ -10,6 +10,7 @@ const installerPath = '.github/actions/install-node-dependencies/action.yml'
|
||||
const nativePath = '.github/actions/prepare-native-runtime/action.yml'
|
||||
const installer = parse(readFileSync(installerPath, 'utf8'))
|
||||
const native = parse(readFileSync(nativePath, 'utf8'))
|
||||
const e2e = parse(readFileSync('.github/workflows/e2e.yml', 'utf8'))
|
||||
const identity = native.runs.steps.find((step) => step.id === 'native-cache-scope')
|
||||
const inputs = [...identity.env.NATIVE_SOURCE_HASH.matchAll(/'([^']+)'/g)].map((match) => match[1])
|
||||
const paths = [
|
||||
@@ -176,4 +177,138 @@ describe('CI native cache ownership', () => {
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('prepares both native ABIs in the E2E build before releasing its consumers', () => {
|
||||
expect(e2e.jobs['prepare-native-cache']).toBeUndefined()
|
||||
const { steps } = e2e.jobs.build
|
||||
const install = steps.find((step) => step.id === 'deps')
|
||||
const save = steps.find((step) => step.name === 'Save compiled Node native modules')
|
||||
const build = steps.find((step) => step.name === 'Build E2E outputs')
|
||||
const cliWait = steps.find((step) => step.wait === 'e2e-cli')
|
||||
const electron = steps.find((step) => step.id === 'electron-native-runtime')
|
||||
const upload = steps.find((step) => step.name === 'Upload E2E build output')
|
||||
const uploadWait = steps.find((step) => step.wait === upload.id)
|
||||
expect(install.with).toMatchObject({
|
||||
'native-runtime': 'node',
|
||||
'persist-native-cache': 'false',
|
||||
'cache-electron-package': 'true'
|
||||
})
|
||||
expect(save.if).toContain("steps.deps.outputs.native-cache-key != ''")
|
||||
expect(save.if).toContain("steps.deps.outputs.native-cache-hit != 'true'")
|
||||
expect(save.with).toEqual({
|
||||
key: '${{ steps.deps.outputs.native-cache-key }}',
|
||||
path: '${{ steps.deps.outputs.native-cache-path }}'
|
||||
})
|
||||
const ordered = [install, save, build, cliWait, upload, electron, uploadWait]
|
||||
expect(ordered.map((step) => steps.indexOf(step))).toEqual(
|
||||
ordered.map((step) => steps.indexOf(step)).sort((left, right) => left - right)
|
||||
)
|
||||
expect(upload.background).toBe(true)
|
||||
expect(upload.if).toBeUndefined()
|
||||
expect(electron.background).toBeUndefined()
|
||||
expect(electron.uses).toBe('./.github/actions/prepare-native-runtime')
|
||||
expect(electron.with).toEqual({
|
||||
'native-runtime': 'electron',
|
||||
'node-version': '${{ steps.deps.outputs.node-version }}'
|
||||
})
|
||||
expect(uploadWait).toEqual({ wait: upload.id })
|
||||
for (const name of ['e2e', 'changed-e2e', 'orcad-serve-mode-switch', 'orcad-ssh-docker']) {
|
||||
expect(e2e.jobs[name].needs, name).toEqual(['build'])
|
||||
expect(
|
||||
e2e.jobs[name].steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
).with['native-runtime'],
|
||||
name
|
||||
).toBe('electron')
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32').each([false, true])(
|
||||
'detects shared native action availability=%s for older dispatch refs',
|
||||
(available) => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-e2e-native-action-'))
|
||||
try {
|
||||
const output = join(directory, 'output')
|
||||
if (available) {
|
||||
mkdirSync(join(directory, '.github/actions/prepare-native-runtime'), { recursive: true })
|
||||
writeFileSync(join(directory, nativePath), '')
|
||||
}
|
||||
const step = e2e.jobs.build.steps.find(
|
||||
(candidate) => candidate.id === 'native-runtime-action'
|
||||
)
|
||||
const execution = runProcessSync({
|
||||
program: 'bash',
|
||||
args: ['-e', '-o', 'pipefail', '-c', step.run],
|
||||
cwd: directory,
|
||||
env: { ...process.env, GITHUB_OUTPUT: output },
|
||||
timeoutMs: 10_000
|
||||
})
|
||||
expect(execution.code, execution.stderr).toBe(0)
|
||||
expect(readFileSync(output, 'utf8')).toBe(`available=${available}\n`)
|
||||
const fallback = e2e.jobs.build.steps.find(
|
||||
(candidate) => candidate.name === 'Prepare Electron natives for older refs'
|
||||
)
|
||||
expect(fallback.if).toBe(
|
||||
"steps.native-runtime-action.outputs.available != 'true' || steps.deps.outputs.node-version == ''"
|
||||
)
|
||||
expect(fallback.uses).toBe('./.github/actions/install-node-dependencies')
|
||||
expect(fallback.with['native-runtime']).toBe('electron')
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32').each([
|
||||
['true', 'build-essential python3', '', 0, false],
|
||||
['false', 'build-essential python3', '', 0, true],
|
||||
['true', 'build-essential python3 xvfb', '', 0, true],
|
||||
['false', 'build-essential python3', 'update', 7, true],
|
||||
['false', 'build-essential python3', 'install', 7, true]
|
||||
])(
|
||||
'tools=%s packages=%s failure=%s retains bounded setup',
|
||||
(present, packages, failure, status, installs) => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-e2e-native-tools-'))
|
||||
try {
|
||||
const log = join(directory, 'commands')
|
||||
writeFileSync(log, '')
|
||||
const step = e2e.jobs.build.steps.find(
|
||||
(candidate) => candidate.name === 'Install native build tools'
|
||||
)
|
||||
const execution = runProcessSync({
|
||||
program: 'bash',
|
||||
args: [
|
||||
'-e',
|
||||
'-o',
|
||||
'pipefail',
|
||||
'-c',
|
||||
`
|
||||
command() { [ "$TOOLS_PRESENT" = true ]; }
|
||||
sudo() { printf 'sudo %s\\n' "$*" >> "$COMMAND_LOG"; cat >/dev/null; }
|
||||
timeout() {
|
||||
printf 'timeout %s\\n' "$*" >> "$COMMAND_LOG"
|
||||
if [ "$4" = "$FAIL_OPERATION" ]; then return 7; fi
|
||||
}
|
||||
${step.run}`
|
||||
],
|
||||
env: {
|
||||
...process.env,
|
||||
TOOLS_PRESENT: present,
|
||||
ORCA_E2E_APT_PACKAGES: packages,
|
||||
FAIL_OPERATION: failure,
|
||||
COMMAND_LOG: log
|
||||
},
|
||||
timeoutMs: 10_000
|
||||
})
|
||||
expect(execution.code, execution.stderr).toBe(status)
|
||||
const commands = readFileSync(log, 'utf8')
|
||||
expect(commands.includes('timeout 120 sudo apt-get update')).toBe(installs)
|
||||
expect(commands.includes(`timeout 300 sudo apt-get install -y ${packages}`)).toBe(
|
||||
installs && failure !== 'update'
|
||||
)
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
+12224
-11621
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
# Timing-based CI shards
|
||||
|
||||
The eight unit shards and fourteen general E2E shards use longest-processing-time
|
||||
Unit shards and general E2E shards use longest-processing-time
|
||||
assignment of whole files to the currently lightest shard. Ties use file path and
|
||||
then shard index, independent of filesystem enumeration and locale. Unknown,
|
||||
zero, or invalid durations use the baseline's positive median (1 second when no
|
||||
@@ -46,7 +46,32 @@ age as specs change. Full CI runs on the existing runner classes are required to
|
||||
measure elapsed-time and occupancy improvements, including discovery overhead.
|
||||
No retries, assertions, coverage exclusions, runner classes or shard counts changed.
|
||||
|
||||
## Current unit refresh: September 27, 2026
|
||||
## Current unit refresh: October 14, 2026
|
||||
|
||||
The baseline imports all five successful ARM unit shards from
|
||||
[run 38646706136, attempt 1](https://github.com/stablyai/orca/actions/runs/38646706136/attempts/1).
|
||||
The tested merge was `8d7f46198b99432efd104ac7803174173b367c66`, using Node
|
||||
24.21.0, Bun 1.4.3, `ubuntu-24.04-arm`, and four workers per shard. All 12,314
|
||||
files appear exactly once: 12,163 passed and 151 skipped, with zero unhandled
|
||||
errors. Artifact digests, all 20 report hashes, source/runtime provenance, full
|
||||
discovery, and each saved assignment were checked before importing. The E2E
|
||||
weights remain unchanged.
|
||||
|
||||
| Shard | Job ID | Measured worker-seconds | Job seconds |
|
||||
| --- | --- | ---: | ---: |
|
||||
| 1 | 116137259336 | 1,966.470 | 568 |
|
||||
| 2 | 116137259460 | 2,080.624 | 604 |
|
||||
| 3 | 116137259318 | 2,121.495 | 611 |
|
||||
| 4 | 116137259783 | 2,094.487 | 614 |
|
||||
| 5 | 116137259540 | 2,190.636 | 641 |
|
||||
|
||||
Retrospectively assigning those measurements projects 2,090.726–2,090.747
|
||||
worker-seconds per shard, instead of 1,966.470–2,190.636. That is a scheduling
|
||||
projection, not a hosted speedup: module times overlap across workers, and total
|
||||
runner occupancy was 50.63 minutes. Runner capacity, selection, workers, and the
|
||||
assignment algorithm are unchanged. Later source changes need fresh validation.
|
||||
|
||||
## Historical unit refresh: September 27, 2026
|
||||
|
||||
The baseline imports every successful Node 24 shard from
|
||||
[run 36294142683, attempt 1](https://github.com/stablyai/orca/actions/runs/36294142683/attempts/1).
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { constants } from 'node:fs'
|
||||
import { lstat, open, readdir } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { checkModuleTrailer } from './ci-unit-module-cache-trailer.mjs'
|
||||
|
||||
export const MODULE_CACHE_MANIFEST = '_orca_manifest.json'
|
||||
export const MODULE_CACHE_LIMITS = Object.freeze({
|
||||
entries: 50_000,
|
||||
totalBytes: 2 ** 30,
|
||||
entryBytes: 32 * 2 ** 20
|
||||
})
|
||||
const METADATA = '_metadata.json'
|
||||
const MODULE_NAME = /^[a-f0-9]{40}$/
|
||||
const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
const fail = (message) => {
|
||||
throw new Error(`Unit module cache: ${message}`)
|
||||
}
|
||||
|
||||
async function readRegularFile(path) {
|
||||
const before = await lstat(path)
|
||||
if (!before.isFile()) {
|
||||
fail(`not a regular file: ${path}`)
|
||||
}
|
||||
const file = await open(
|
||||
path,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0)
|
||||
)
|
||||
try {
|
||||
const info = await file.stat()
|
||||
if (!info.isFile() || info.size > MODULE_CACHE_LIMITS.entryBytes) {
|
||||
fail(`invalid file size or type: ${path}`)
|
||||
}
|
||||
const buffer = Buffer.alloc(info.size + 1)
|
||||
let bytes = 0
|
||||
while (bytes < buffer.length) {
|
||||
const result = await file.read(buffer, bytes, buffer.length - bytes, bytes)
|
||||
if (!result.bytesRead) {
|
||||
break
|
||||
}
|
||||
bytes += result.bytesRead
|
||||
}
|
||||
if (bytes !== info.size) {
|
||||
fail(`file changed while reading: ${path}`)
|
||||
}
|
||||
return buffer.subarray(0, bytes)
|
||||
} finally {
|
||||
await file.close()
|
||||
}
|
||||
}
|
||||
|
||||
function checkLockfileHash(lockfileHash) {
|
||||
if (typeof lockfileHash !== 'string' || !/^[a-f0-9]{8}$/.test(lockfileHash)) {
|
||||
fail('invalid expected lockfile hash')
|
||||
}
|
||||
}
|
||||
|
||||
export async function inventoryModuleCache({ path, lockfileHash }) {
|
||||
checkLockfileHash(lockfileHash)
|
||||
if (!(await lstat(path)).isDirectory()) {
|
||||
fail('cache root is not a regular directory')
|
||||
}
|
||||
const names = (await readdir(path)).sort()
|
||||
if (names.length > MODULE_CACHE_LIMITS.entries) {
|
||||
fail('too many cache entries')
|
||||
}
|
||||
if (!names.includes(METADATA)) {
|
||||
fail('cache metadata is missing')
|
||||
}
|
||||
let totalBytes = 0
|
||||
let manifestBytes = 0
|
||||
const entries = Array.from({ length: names.length })
|
||||
let next = 0
|
||||
const workers = await Promise.allSettled(
|
||||
Array.from({ length: Math.min(4, names.length) }, async () => {
|
||||
while (next < names.length) {
|
||||
const index = next++
|
||||
const name = names[index]
|
||||
if (name !== METADATA && name !== MODULE_CACHE_MANIFEST && !MODULE_NAME.test(name)) {
|
||||
fail(`invalid cache entry name: ${name}`)
|
||||
}
|
||||
const buffer = await readRegularFile(join(path, name))
|
||||
if (name === MODULE_CACHE_MANIFEST) {
|
||||
manifestBytes = buffer.length
|
||||
} else {
|
||||
totalBytes += buffer.length
|
||||
}
|
||||
if (totalBytes + manifestBytes > MODULE_CACHE_LIMITS.totalBytes) {
|
||||
fail('cache byte limit exceeded')
|
||||
}
|
||||
if (name === MODULE_CACHE_MANIFEST) {
|
||||
continue
|
||||
}
|
||||
if (name === METADATA) {
|
||||
const metadata = JSON.parse(buffer.toString('utf8'))
|
||||
if (!isRecord(metadata) || metadata.lockfileHash !== lockfileHash) {
|
||||
fail('cache lockfile hash mismatch')
|
||||
}
|
||||
} else {
|
||||
checkModuleTrailer(buffer)
|
||||
}
|
||||
entries[index] = {
|
||||
name,
|
||||
bytes: buffer.length,
|
||||
sha256: createHash('sha256').update(buffer).digest('hex')
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
const failure = workers.find((result) => result.status === 'rejected')
|
||||
if (failure) {
|
||||
throw failure.reason
|
||||
}
|
||||
return { entries: entries.filter(Boolean), totalBytes }
|
||||
}
|
||||
|
||||
function checkIdentity({ key, format, versions, sourceSha, lockfileHash }) {
|
||||
checkLockfileHash(lockfileHash)
|
||||
if (typeof key !== 'string' || !key || typeof format !== 'string' || !format) {
|
||||
fail('invalid cache identity')
|
||||
}
|
||||
if (
|
||||
!isRecord(versions) ||
|
||||
!['node', 'bun', 'vitest'].every((name) => typeof versions[name] === 'string' && versions[name])
|
||||
) {
|
||||
fail('invalid cache versions')
|
||||
}
|
||||
if (sourceSha !== undefined && (typeof sourceSha !== 'string' || !MODULE_NAME.test(sourceSha))) {
|
||||
fail('invalid source SHA')
|
||||
}
|
||||
}
|
||||
|
||||
export async function sealModuleCache(options) {
|
||||
checkIdentity(options)
|
||||
if (!options.sourceSha) {
|
||||
fail('source SHA is required when sealing')
|
||||
}
|
||||
const inventory = await inventoryModuleCache(options)
|
||||
const manifest = {
|
||||
version: 1,
|
||||
key: options.key,
|
||||
format: options.format,
|
||||
versions: options.versions,
|
||||
sourceSha: options.sourceSha,
|
||||
lockfileHash: options.lockfileHash,
|
||||
...inventory
|
||||
}
|
||||
const content = `${JSON.stringify(manifest)}\n`
|
||||
if (
|
||||
Buffer.byteLength(content) > MODULE_CACHE_LIMITS.entryBytes ||
|
||||
inventory.totalBytes + Buffer.byteLength(content) > MODULE_CACHE_LIMITS.totalBytes ||
|
||||
inventory.entries.length + 1 > MODULE_CACHE_LIMITS.entries
|
||||
) {
|
||||
fail('sealed cache exceeds limits')
|
||||
}
|
||||
const file = await open(
|
||||
join(options.path, MODULE_CACHE_MANIFEST),
|
||||
constants.O_WRONLY |
|
||||
constants.O_CREAT |
|
||||
(constants.O_NOFOLLOW ?? 0) |
|
||||
(constants.O_NONBLOCK ?? 0),
|
||||
0o600
|
||||
)
|
||||
try {
|
||||
if (!(await file.stat()).isFile()) {
|
||||
fail('manifest is not a regular file')
|
||||
}
|
||||
await file.truncate(0)
|
||||
await file.writeFile(content)
|
||||
} finally {
|
||||
await file.close()
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
|
||||
export async function validateModuleCache(options) {
|
||||
checkIdentity(options)
|
||||
const manifest = JSON.parse(
|
||||
(await readRegularFile(join(options.path, MODULE_CACHE_MANIFEST))).toString('utf8')
|
||||
)
|
||||
if (!isRecord(manifest)) {
|
||||
fail('invalid cache manifest')
|
||||
}
|
||||
checkIdentity(manifest)
|
||||
if (
|
||||
!manifest.sourceSha ||
|
||||
manifest.version !== 1 ||
|
||||
manifest.key !== options.key ||
|
||||
manifest.format !== options.format ||
|
||||
manifest.lockfileHash !== options.lockfileHash ||
|
||||
(options.sourceSha !== undefined && manifest.sourceSha !== options.sourceSha) ||
|
||||
!['node', 'bun', 'vitest'].every((name) => manifest.versions[name] === options.versions[name])
|
||||
) {
|
||||
fail('cache manifest identity mismatch')
|
||||
}
|
||||
const inventory = await inventoryModuleCache(options)
|
||||
if (
|
||||
manifest.totalBytes !== inventory.totalBytes ||
|
||||
!Array.isArray(manifest.entries) ||
|
||||
manifest.entries.length !== inventory.entries.length
|
||||
) {
|
||||
fail('cache inventory mismatch')
|
||||
}
|
||||
for (let index = 0; index < inventory.entries.length; index++) {
|
||||
const expected = manifest.entries[index]
|
||||
const actual = inventory.entries[index]
|
||||
if (
|
||||
!isRecord(expected) ||
|
||||
expected.name !== actual.name ||
|
||||
expected.bytes !== actual.bytes ||
|
||||
expected.sha256 !== actual.sha256
|
||||
) {
|
||||
fail(`cache content mismatch: ${actual.name}`)
|
||||
}
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import {
|
||||
mkdtemp,
|
||||
mkdir,
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
symlink,
|
||||
truncate,
|
||||
writeFile
|
||||
} from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
inventoryModuleCache,
|
||||
MODULE_CACHE_LIMITS,
|
||||
MODULE_CACHE_MANIFEST,
|
||||
sealModuleCache,
|
||||
validateModuleCache
|
||||
} from './ci-unit-module-cache-contents.mjs'
|
||||
|
||||
const roots = []
|
||||
const filename = 'a'.repeat(40)
|
||||
const identity = {
|
||||
key: 'unit-module-v1-Linux-ARM64-runtime-config-lock',
|
||||
format: 'vitest-fs-1.0.0-beta.8',
|
||||
versions: { node: '24.21.0', bun: '1.4.3', vitest: '5.0.3' },
|
||||
sourceSha: 'b'.repeat(40),
|
||||
lockfileHash: '0028fbb2'
|
||||
}
|
||||
|
||||
function moduleTable() {
|
||||
return [
|
||||
{
|
||||
file: '1',
|
||||
id: '1',
|
||||
url: '2',
|
||||
imports: '3',
|
||||
mappings: false,
|
||||
deps: '4',
|
||||
dynamicDeps: '5',
|
||||
staticMocks: '6',
|
||||
moduleType: '7'
|
||||
},
|
||||
'/workspace/src/main.ts',
|
||||
'/src/main.ts',
|
||||
{ urls: '8', ids: '9' },
|
||||
['10'],
|
||||
[],
|
||||
['11'],
|
||||
'esm',
|
||||
['10'],
|
||||
{ '/dependency.ts': '12' },
|
||||
'/dependency.ts',
|
||||
{ method: '13', specifier: '10', hasFactory: true, factoryLoadsOriginal: false },
|
||||
'/workspace/dependency.ts',
|
||||
'mock'
|
||||
]
|
||||
}
|
||||
|
||||
const moduleCode = (table = moduleTable()) =>
|
||||
`export const value = 1\n//# vitestCache=${Buffer.from(JSON.stringify(table)).toString('base64')}`
|
||||
|
||||
async function fixture() {
|
||||
const path = await mkdtemp(join(tmpdir(), 'orca-unit-module-cache-'))
|
||||
roots.push(path)
|
||||
await writeFile(join(path, filename), moduleCode())
|
||||
await writeFile(
|
||||
join(path, '_metadata.json'),
|
||||
JSON.stringify({ lockfileHash: identity.lockfileHash })
|
||||
)
|
||||
return { path, ...identity }
|
||||
}
|
||||
|
||||
async function changeManifest(options, change) {
|
||||
const path = join(options.path, MODULE_CACHE_MANIFEST)
|
||||
const manifest = JSON.parse(await readFile(path, 'utf8'))
|
||||
change(manifest)
|
||||
await writeFile(path, JSON.stringify(manifest))
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((path) => rm(path, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('unit module cache contents', () => {
|
||||
it('seals all transformed files and Vitest metadata with exact content hashes', async () => {
|
||||
const options = await fixture()
|
||||
const inventory = await inventoryModuleCache(options)
|
||||
expect(inventory.entries.map((entry) => entry.name)).toEqual(['_metadata.json', filename])
|
||||
expect(inventory.entries.every((entry) => /^[a-f0-9]{64}$/.test(entry.sha256))).toBe(true)
|
||||
expect(inventory.totalBytes).toBe(
|
||||
inventory.entries.reduce((sum, entry) => sum + entry.bytes, 0)
|
||||
)
|
||||
const manifest = await sealModuleCache(options)
|
||||
expect(manifest).toMatchObject({ version: 1, ...identity, ...inventory })
|
||||
expect(await validateModuleCache(options)).toEqual(manifest)
|
||||
const restoreOptions = { ...options, sourceSha: undefined }
|
||||
expect(await validateModuleCache(restoreOptions)).toEqual(manifest)
|
||||
})
|
||||
|
||||
it('accepts absent optional module type, dependency lists and mocks', async () => {
|
||||
const options = await fixture()
|
||||
await writeFile(
|
||||
join(options.path, filename),
|
||||
moduleCode([
|
||||
{ id: '1', url: '2', file: null, imports: '3', mappings: false },
|
||||
'\u0000virtual-module',
|
||||
'/@id/virtual-module',
|
||||
{ urls: '4', ids: '5' },
|
||||
[],
|
||||
{}
|
||||
])
|
||||
)
|
||||
await sealModuleCache(options)
|
||||
await expect(validateModuleCache(options)).resolves.toMatchObject({ key: identity.key })
|
||||
})
|
||||
|
||||
it.each(['missing', 'modified', 'extra', 'renamed', 'metadata'])(
|
||||
'rejects %s sealed contents',
|
||||
async (change) => {
|
||||
const options = await fixture()
|
||||
await sealModuleCache(options)
|
||||
const path = join(options.path, filename)
|
||||
if (change === 'missing') {
|
||||
await rm(path)
|
||||
}
|
||||
if (change === 'modified') {
|
||||
await writeFile(path, moduleCode().replace('value = 1', 'value = 2'))
|
||||
}
|
||||
if (change === 'extra') {
|
||||
await writeFile(join(options.path, 'c'.repeat(40)), moduleCode())
|
||||
}
|
||||
if (change === 'renamed') {
|
||||
await rename(path, join(options.path, 'c'.repeat(40)))
|
||||
}
|
||||
if (change === 'metadata') {
|
||||
await writeFile(
|
||||
join(options.path, '_metadata.json'),
|
||||
JSON.stringify({ lockfileHash: identity.lockfileHash, changed: true })
|
||||
)
|
||||
}
|
||||
await expect(validateModuleCache(options)).rejects.toThrow(
|
||||
/cache (inventory|content) mismatch/
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['key', 'different-key'],
|
||||
['format', 'different-format'],
|
||||
['sourceSha', 'd'.repeat(40)],
|
||||
['lockfileHash', '12345678'],
|
||||
['version', 2],
|
||||
['versions', { ...identity.versions, bun: '1.4.4' }]
|
||||
])('rejects mismatched manifest %s', async (field, value) => {
|
||||
const options = await fixture()
|
||||
await sealModuleCache(options)
|
||||
await changeManifest(options, (manifest) => {
|
||||
manifest[field] = value
|
||||
})
|
||||
await expect(validateModuleCache(options)).rejects.toThrow(/identity mismatch/)
|
||||
})
|
||||
|
||||
it.each(['missing', 'malformed', 'mismatched'])(
|
||||
'rejects %s Vitest metadata before sealing',
|
||||
async (kind) => {
|
||||
const options = await fixture()
|
||||
const path = join(options.path, '_metadata.json')
|
||||
if (kind === 'missing') {
|
||||
await rm(path)
|
||||
}
|
||||
if (kind === 'malformed') {
|
||||
await writeFile(path, '{')
|
||||
}
|
||||
if (kind === 'mismatched') {
|
||||
await writeFile(path, '{"lockfileHash":"12345678"}')
|
||||
}
|
||||
await expect(sealModuleCache(options)).rejects.toThrow()
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['.tmp-partial', 'invalid-module.js', 'B'.repeat(40)])(
|
||||
'rejects invalid filename %s',
|
||||
async (name) => {
|
||||
const options = await fixture()
|
||||
await writeFile(join(options.path, name), moduleCode())
|
||||
await expect(inventoryModuleCache(options)).rejects.toThrow('invalid cache entry name')
|
||||
}
|
||||
)
|
||||
|
||||
it('rejects a directory masquerading as a transformed file', async () => {
|
||||
const options = await fixture()
|
||||
await mkdir(join(options.path, 'c'.repeat(40)))
|
||||
await expect(inventoryModuleCache(options)).rejects.toThrow('not a regular file')
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'rejects symlinked modules, manifests and cache roots',
|
||||
async () => {
|
||||
const options = await fixture()
|
||||
const outside = await fixture()
|
||||
await symlink(join(outside.path, filename), join(options.path, 'c'.repeat(40)))
|
||||
await expect(inventoryModuleCache(options)).rejects.toThrow('not a regular file')
|
||||
await rm(join(options.path, 'c'.repeat(40)))
|
||||
await symlink(join(outside.path, filename), join(options.path, MODULE_CACHE_MANIFEST))
|
||||
await expect(sealModuleCache(options)).rejects.toThrow('not a regular file')
|
||||
await symlink(options.path, join(outside.path, 'linked-root'))
|
||||
await expect(
|
||||
inventoryModuleCache({ ...options, path: join(outside.path, 'linked-root') })
|
||||
).rejects.toThrow('cache root')
|
||||
}
|
||||
)
|
||||
|
||||
it('rejects oversized entries before allocating their contents', async () => {
|
||||
const options = await fixture()
|
||||
await truncate(join(options.path, filename), MODULE_CACHE_LIMITS.entryBytes + 1)
|
||||
await expect(inventoryModuleCache(options)).rejects.toThrow('invalid file size')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['missing trailer', 'export const value = 1'],
|
||||
['invalid base64', 'x\n//# vitestCache=***'],
|
||||
['noncanonical base64', 'x\n//# vitestCache=W10'],
|
||||
['invalid JSON', 'x\n//# vitestCache=ew=='],
|
||||
['plain JSON object', `x\n//# vitestCache=${Buffer.from('{}').toString('base64')}`]
|
||||
])('rejects %s', async (_label, code) => {
|
||||
const options = await fixture()
|
||||
await writeFile(join(options.path, filename), code)
|
||||
await expect(sealModuleCache(options)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'out-of-range reference',
|
||||
(table) => {
|
||||
table[0].id = '999'
|
||||
}
|
||||
],
|
||||
[
|
||||
'noncanonical reference',
|
||||
(table) => {
|
||||
table[0].id = '01'
|
||||
}
|
||||
],
|
||||
[
|
||||
'numeric id',
|
||||
(table) => {
|
||||
table[0].id = 1
|
||||
}
|
||||
],
|
||||
[
|
||||
'cyclic imports',
|
||||
(table) => {
|
||||
table[0].imports = '0'
|
||||
}
|
||||
],
|
||||
[
|
||||
'invalid mappings',
|
||||
(table) => {
|
||||
table[0].mappings = null
|
||||
}
|
||||
],
|
||||
[
|
||||
'invalid module type',
|
||||
(table) => {
|
||||
table[7] = 'unknown'
|
||||
}
|
||||
],
|
||||
[
|
||||
'invalid import URL',
|
||||
(table) => {
|
||||
table[8][0] = false
|
||||
}
|
||||
],
|
||||
[
|
||||
'invalid import ids',
|
||||
(table) => {
|
||||
table[9]['/dependency.ts'] = 4
|
||||
}
|
||||
],
|
||||
[
|
||||
'invalid dependencies',
|
||||
(table) => {
|
||||
table[4][0] = false
|
||||
}
|
||||
],
|
||||
[
|
||||
'invalid static mocks',
|
||||
(table) => {
|
||||
table[11].hasFactory = 1
|
||||
}
|
||||
],
|
||||
[
|
||||
'inline object',
|
||||
(table) => {
|
||||
table[0].imports = {}
|
||||
}
|
||||
],
|
||||
[
|
||||
'unsafe property',
|
||||
(table) => {
|
||||
table[0] = JSON.parse('{"__proto__":"0"}')
|
||||
}
|
||||
]
|
||||
])('rejects %s in module metadata', async (_label, mutate) => {
|
||||
const options = await fixture()
|
||||
const table = moduleTable()
|
||||
mutate(table)
|
||||
await writeFile(join(options.path, filename), moduleCode(table))
|
||||
await expect(sealModuleCache(options)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
const TRAILER = '\n//# vitestCache='
|
||||
const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
const fail = (message) => {
|
||||
throw new Error(`Unit module cache: ${message}`)
|
||||
}
|
||||
|
||||
export function checkModuleTrailer(buffer) {
|
||||
const text = buffer.toString('utf8')
|
||||
if (!Buffer.from(text).equals(buffer)) {
|
||||
fail('module is not valid UTF-8')
|
||||
}
|
||||
const marker = text.lastIndexOf(TRAILER)
|
||||
if (marker === -1) {
|
||||
fail('module trailer is missing')
|
||||
}
|
||||
const encoded = text.slice(marker + TRAILER.length)
|
||||
const decoded = Buffer.from(encoded, 'base64')
|
||||
if (!encoded || decoded.toString('base64') !== encoded) {
|
||||
fail('module trailer is not canonical base64')
|
||||
}
|
||||
const json = decoded.toString('utf8')
|
||||
if (!Buffer.from(json).equals(decoded)) {
|
||||
fail('module metadata is not valid UTF-8')
|
||||
}
|
||||
const table = JSON.parse(json)
|
||||
if (!Array.isArray(table) || !isRecord(table[0])) {
|
||||
fail('module metadata is not a flatted object table')
|
||||
}
|
||||
const resolve = (value) => {
|
||||
if (typeof value !== 'string') {
|
||||
return value
|
||||
}
|
||||
if (
|
||||
!/^(0|[1-9][0-9]*)$/.test(value) ||
|
||||
!Number.isSafeInteger(Number(value)) ||
|
||||
Number(value) >= table.length
|
||||
) {
|
||||
fail('invalid flatted reference')
|
||||
}
|
||||
return table[Number(value)]
|
||||
}
|
||||
// Check table references directly; the cache only needs the documented metadata fields.
|
||||
for (const slot of table) {
|
||||
if (typeof slot === 'string') {
|
||||
continue
|
||||
}
|
||||
if (!slot || typeof slot !== 'object') {
|
||||
fail('invalid flatted table slot')
|
||||
}
|
||||
if (Object.hasOwn(slot, '__proto__')) {
|
||||
fail('unsafe flatted property')
|
||||
}
|
||||
for (const value of Object.values(slot)) {
|
||||
if (value !== null && typeof value === 'object') {
|
||||
fail('inline object in flatted table')
|
||||
}
|
||||
if (typeof value === 'number' && !Number.isFinite(value)) {
|
||||
fail('invalid flatted number')
|
||||
}
|
||||
resolve(value)
|
||||
}
|
||||
}
|
||||
const fields = Object.fromEntries(
|
||||
Object.entries(table[0]).map(([name, value]) => [name, resolve(value)])
|
||||
)
|
||||
const strings = (value) =>
|
||||
Array.isArray(value) && value.every((item) => typeof resolve(item) === 'string')
|
||||
if (typeof fields.id !== 'string' || typeof fields.url !== 'string') {
|
||||
fail('invalid module id or url')
|
||||
}
|
||||
if (fields.file != null && typeof fields.file !== 'string') {
|
||||
fail('invalid module file')
|
||||
}
|
||||
if (typeof fields.mappings !== 'boolean') {
|
||||
fail('invalid module mappings')
|
||||
}
|
||||
if (
|
||||
fields.moduleType !== undefined &&
|
||||
fields.moduleType !== 'cjs' &&
|
||||
fields.moduleType !== 'esm'
|
||||
) {
|
||||
fail('invalid module type')
|
||||
}
|
||||
if (!isRecord(fields.imports)) {
|
||||
fail('invalid module imports')
|
||||
}
|
||||
const urls = resolve(fields.imports.urls)
|
||||
const ids = resolve(fields.imports.ids)
|
||||
if (
|
||||
!strings(urls) ||
|
||||
!isRecord(ids) ||
|
||||
!Object.values(ids).every((id) => typeof resolve(id) === 'string')
|
||||
) {
|
||||
fail('invalid module import references')
|
||||
}
|
||||
for (const name of ['deps', 'dynamicDeps']) {
|
||||
if (fields[name] !== undefined && !strings(fields[name])) {
|
||||
fail(`invalid module ${name}`)
|
||||
}
|
||||
}
|
||||
if (fields.staticMocks != null) {
|
||||
if (!Array.isArray(fields.staticMocks)) {
|
||||
fail('invalid module static mocks')
|
||||
}
|
||||
for (const reference of fields.staticMocks) {
|
||||
const mock = resolve(reference)
|
||||
if (
|
||||
!isRecord(mock) ||
|
||||
typeof resolve(mock.method) !== 'string' ||
|
||||
typeof resolve(mock.specifier) !== 'string' ||
|
||||
typeof mock.hasFactory !== 'boolean' ||
|
||||
typeof mock.factoryLoadsOriginal !== 'boolean'
|
||||
) {
|
||||
fail('invalid module static mock')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
appendFileSync,
|
||||
existsSync,
|
||||
globSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
statSync
|
||||
} from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { runProcessSync } from '@orca/process-host'
|
||||
import { collectNodeServerInputs } from './node-server-change-scope.mjs'
|
||||
import { workspacePackageManifests } from './workspace-source-exports.mjs'
|
||||
import { writeAssignment } from './ci-shard-assignment.mjs'
|
||||
import { sealModuleCache, validateModuleCache } from './ci-unit-module-cache-contents.mjs'
|
||||
|
||||
const ROOT = resolve(import.meta.dirname, '../..')
|
||||
const IDENTITY_REPORT = 'ci-shards/unit-module-cache-identity.json'
|
||||
const CACHE_REPORT = 'ci-shards/unit-module-cache.json'
|
||||
const CACHE_FORMAT = '1.0.0-beta.8'
|
||||
|
||||
function digestInputs(root, files) {
|
||||
const hash = createHash('sha256')
|
||||
for (const file of [...new Set(files)].sort()) {
|
||||
if (!existsSync(join(root, file))) {
|
||||
hash.update(`${file}\0missing\0`)
|
||||
continue
|
||||
}
|
||||
const bytes = readFileSync(join(root, file))
|
||||
hash.update(`${file}\0${bytes.length}\0`).update(bytes)
|
||||
}
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
export async function moduleCachePolicyHash(root = ROOT) {
|
||||
const runnerScripts = globSync('config/scripts/vitest*.{mjs,ts}', { cwd: root }).filter(
|
||||
(file) => !file.includes('.test.')
|
||||
)
|
||||
const configInputs = await collectNodeServerInputs({
|
||||
root,
|
||||
entryPoints: [
|
||||
'config/vitest.config.ts',
|
||||
'config/scripts/run-vitest.mjs',
|
||||
'config/scripts/ci-unit-timing-reporter.mjs',
|
||||
...runnerScripts
|
||||
]
|
||||
})
|
||||
return digestInputs(root, [
|
||||
...configInputs,
|
||||
...workspacePackageManifests(root),
|
||||
...globSync(['config/patches/**', '**/tsconfig*.json'], {
|
||||
cwd: root,
|
||||
exclude: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/out/**', 'mobile/vendor/**']
|
||||
}).filter((file) => statSync(join(root, file)).isFile()),
|
||||
'package.json',
|
||||
'pnpm-lock.yaml',
|
||||
'pnpm-workspace.yaml',
|
||||
'.npmrc',
|
||||
'.pnpmfile.cjs',
|
||||
'config/.bun-version',
|
||||
'config/scripts/ci-unit-module-cache.mjs',
|
||||
'config/scripts/ci-unit-module-cache-contents.mjs',
|
||||
'config/scripts/ci-unit-module-cache-trailer.mjs'
|
||||
])
|
||||
}
|
||||
|
||||
export function moduleCacheIdentity({
|
||||
root = ROOT,
|
||||
cacheRoot = process.env.RUNNER_TEMP,
|
||||
policyHash,
|
||||
installedLockHash,
|
||||
versions,
|
||||
shard,
|
||||
platform = process.platform,
|
||||
arch = process.arch,
|
||||
nodeEnv = process.env.NODE_ENV ?? 'test',
|
||||
namespace = ''
|
||||
}) {
|
||||
if (
|
||||
!cacheRoot ||
|
||||
!/^[a-f0-9]{64}$/.test(policyHash ?? '') ||
|
||||
!/^[a-f0-9]{64}$/.test(installedLockHash ?? '') ||
|
||||
![versions?.node, versions?.bun, versions?.vitest].every((version) =>
|
||||
/^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/.test(version ?? '')
|
||||
) ||
|
||||
!Number.isInteger(shard?.index) ||
|
||||
!Number.isInteger(shard?.count) ||
|
||||
shard.index < 1 ||
|
||||
shard.index > shard.count
|
||||
) {
|
||||
throw new Error('Unit module cache identity is incomplete')
|
||||
}
|
||||
const identity = {
|
||||
format: CACHE_FORMAT,
|
||||
root: realpathSync(root),
|
||||
platform,
|
||||
arch,
|
||||
versions,
|
||||
policyHash,
|
||||
installedLockHash,
|
||||
nodeEnv,
|
||||
shard,
|
||||
namespace
|
||||
}
|
||||
const digest = createHash('sha256').update(JSON.stringify(identity)).digest('hex')
|
||||
return {
|
||||
...identity,
|
||||
key: `unit-module-v1-${platform}-${arch}-n${versions.node}-b${versions.bun}-v${versions.vitest}-s${shard.index}of${shard.count}-${digest}`,
|
||||
path: join(resolve(cacheRoot), 'orca-vitest-module-cache')
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveModuleCacheIdentity({
|
||||
root = ROOT,
|
||||
cacheRoot = process.env.RUNNER_TEMP,
|
||||
shard,
|
||||
namespace = ''
|
||||
} = {}) {
|
||||
const require = createRequire(join(root, 'package.json'))
|
||||
const bun = runProcessSync({ program: 'bun', args: ['--version'], cwd: root, timeoutMs: 10_000 })
|
||||
if (bun.code !== 0) {
|
||||
throw new Error('Bun version is unavailable')
|
||||
}
|
||||
return moduleCacheIdentity({
|
||||
root,
|
||||
cacheRoot,
|
||||
shard,
|
||||
namespace,
|
||||
policyHash: await moduleCachePolicyHash(root),
|
||||
installedLockHash: createHash('sha256')
|
||||
.update(readFileSync(join(root, 'node_modules', '.pnpm', 'lock.yaml')))
|
||||
.digest('hex'),
|
||||
versions: {
|
||||
node: process.versions.node,
|
||||
bun: bun.stdout.trim(),
|
||||
vitest: require('vitest/package.json').version
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function prepareModuleCache(identity, { restoreHit = false } = {}) {
|
||||
const started = performance.now()
|
||||
try {
|
||||
if (!restoreHit) {
|
||||
throw new Error('No exact cache was restored')
|
||||
}
|
||||
const manifest = await validateModuleCache({
|
||||
path: identity.path,
|
||||
key: identity.key,
|
||||
format: identity.format,
|
||||
versions: identity.versions,
|
||||
lockfileHash: identity.installedLockHash.slice(0, 8)
|
||||
})
|
||||
return {
|
||||
available: true,
|
||||
entries: manifest.entries.length,
|
||||
bytes: manifest.totalBytes,
|
||||
producerSourceSha: manifest.sourceSha,
|
||||
validationMs: performance.now() - started
|
||||
}
|
||||
} catch (error) {
|
||||
rmSync(identity.path, { recursive: true, force: true })
|
||||
return {
|
||||
available: false,
|
||||
reason: String(error),
|
||||
validationMs: performance.now() - started
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function finishModuleCache(identity, { sourceSha } = {}) {
|
||||
const started = performance.now()
|
||||
const manifest = await sealModuleCache({
|
||||
...identity,
|
||||
sourceSha,
|
||||
lockfileHash: identity.installedLockHash.slice(0, 8)
|
||||
})
|
||||
return {
|
||||
entries: manifest.entries.length,
|
||||
bytes: manifest.totalBytes,
|
||||
sealMs: performance.now() - started
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const phase = process.argv[2]
|
||||
const output = (values) => {
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `${key}=${value}\n`)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (phase === 'identity') {
|
||||
try {
|
||||
const identity = await resolveModuleCacheIdentity({
|
||||
shard: {
|
||||
index: Number(process.env.ORCA_UNIT_SHARD_INDEX),
|
||||
count: Number(process.env.ORCA_UNIT_SHARD_COUNT)
|
||||
},
|
||||
namespace: process.env.ORCA_UNIT_CACHE_NAMESPACE ?? ''
|
||||
})
|
||||
writeAssignment(IDENTITY_REPORT, identity)
|
||||
output({ key: identity.key, path: identity.path })
|
||||
if (process.env.GITHUB_ENV) {
|
||||
appendFileSync(process.env.GITHUB_ENV, `ORCA_UNIT_MODULE_CACHE_PATH=${identity.path}\n`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Unit module cache unavailable: ${String(error)}`)
|
||||
}
|
||||
} else {
|
||||
const identity = JSON.parse(readFileSync(IDENTITY_REPORT, 'utf8'))
|
||||
if (phase === 'prepare') {
|
||||
const result = await prepareModuleCache(identity, {
|
||||
restoreHit: process.env.ORCA_UNIT_CACHE_RESTORE_HIT === 'true'
|
||||
})
|
||||
writeAssignment(CACHE_REPORT, { key: identity.key, mode: 'restore', ...result })
|
||||
console.log(JSON.stringify(result))
|
||||
} else if (phase === 'seal') {
|
||||
try {
|
||||
const result = await finishModuleCache(identity, {
|
||||
sourceSha: process.env.ORCA_SHARD_SOURCE_SHA ?? process.env.GITHUB_SHA
|
||||
})
|
||||
const previous = JSON.parse(readFileSync(CACHE_REPORT, 'utf8'))
|
||||
writeAssignment(CACHE_REPORT, { ...previous, produced: result })
|
||||
output({ ready: true })
|
||||
console.log(JSON.stringify(result))
|
||||
} catch (error) {
|
||||
console.warn(`Unit module cache was not saved: ${String(error)}`)
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Unknown unit module cache phase: ${phase}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
import {
|
||||
finishModuleCache,
|
||||
moduleCacheIdentity,
|
||||
prepareModuleCache
|
||||
} from './ci-unit-module-cache.mjs'
|
||||
|
||||
const temporary = []
|
||||
afterEach(() => {
|
||||
for (const directory of temporary.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
function options() {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-unit-cache-identity-'))
|
||||
temporary.push(directory)
|
||||
return {
|
||||
root: directory,
|
||||
cacheRoot: directory,
|
||||
policyHash: 'a'.repeat(64),
|
||||
installedLockHash: 'b'.repeat(64),
|
||||
versions: { node: '24.21.0', bun: '1.4.3', vitest: '5.0.3' },
|
||||
shard: { index: 1, count: 5 },
|
||||
platform: 'linux',
|
||||
arch: 'arm64',
|
||||
nodeEnv: 'test',
|
||||
namespace: ''
|
||||
}
|
||||
}
|
||||
|
||||
describe('persistent unit module cache boundary', () => {
|
||||
it('separates hosts, runtimes, config, lockfiles, roots, shards and experiments', () => {
|
||||
const original = options()
|
||||
const identity = moduleCacheIdentity(original)
|
||||
const anotherRoot = options().root
|
||||
for (const update of [
|
||||
{ root: anotherRoot },
|
||||
{ policyHash: 'c'.repeat(64) },
|
||||
{ installedLockHash: 'd'.repeat(64) },
|
||||
{ platform: 'win32' },
|
||||
{ arch: 'x64' },
|
||||
{ nodeEnv: 'production' },
|
||||
{ namespace: 'comparison' },
|
||||
{ shard: { index: 2, count: 5 } },
|
||||
{ shard: { index: 1, count: 10 } },
|
||||
{ versions: { ...original.versions, node: '26.6.0' } },
|
||||
{ versions: { ...original.versions, bun: '1.4.4' } },
|
||||
{ versions: { ...original.versions, vitest: '5.0.4' } }
|
||||
]) {
|
||||
const next = moduleCacheIdentity({ ...original, ...update })
|
||||
expect(next.key).not.toBe(identity.key)
|
||||
expect(next.path).toBe(identity.path)
|
||||
}
|
||||
expect(moduleCacheIdentity(original)).toEqual(identity)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ cacheRoot: '' },
|
||||
{ policyHash: '' },
|
||||
{ installedLockHash: '' },
|
||||
{ versions: { node: '24', bun: '1.4.3', vitest: '5.0.3' } },
|
||||
{ shard: { index: 0, count: 5 } },
|
||||
{ shard: { index: 6, count: 5 } }
|
||||
])('refuses an incomplete identity %j', (update) => {
|
||||
expect(() => moduleCacheIdentity({ ...options(), ...update })).toThrow('incomplete')
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'starts cold and discards unusable restored bytes (hit %s)',
|
||||
async (hit) => {
|
||||
const identity = moduleCacheIdentity(options())
|
||||
mkdirSync(identity.path)
|
||||
writeFileSync(join(identity.path, 'untrusted'), 'throw Error("must not execute")')
|
||||
const prepared = await prepareModuleCache(identity, { restoreHit: hit })
|
||||
expect(prepared.available).toBe(false)
|
||||
expect(prepared.validationMs).toBeGreaterThanOrEqual(0)
|
||||
expect(() => readFileSync(join(identity.path, 'untrusted'))).toThrow()
|
||||
}
|
||||
)
|
||||
it('restores a matching cache from an earlier source without copying evaluated state', async () => {
|
||||
const identity = moduleCacheIdentity(options())
|
||||
mkdirSync(identity.path)
|
||||
writeFileSync(
|
||||
join(identity.path, '_metadata.json'),
|
||||
JSON.stringify({ lockfileHash: identity.installedLockHash.slice(0, 8) })
|
||||
)
|
||||
const metadata = [
|
||||
{ id: '1', url: '2', file: '1', imports: '3', mappings: false },
|
||||
join(identity.root, 'source.ts'),
|
||||
'/source.ts',
|
||||
{ urls: '4', ids: '5' },
|
||||
[],
|
||||
{}
|
||||
]
|
||||
writeFileSync(
|
||||
join(identity.path, 'a'.repeat(40)),
|
||||
`export const value = 1\n//# vitestCache=${Buffer.from(JSON.stringify(metadata)).toString('base64')}`
|
||||
)
|
||||
const produced = await finishModuleCache(identity, { sourceSha: 'a'.repeat(40) })
|
||||
expect(produced.entries).toBe(2)
|
||||
const prepared = await prepareModuleCache(
|
||||
{ ...identity, sourceSha: 'b'.repeat(40) },
|
||||
{ restoreHit: true }
|
||||
)
|
||||
expect(prepared).toMatchObject({
|
||||
available: true,
|
||||
entries: produced.entries,
|
||||
bytes: produced.bytes,
|
||||
producerSourceSha: 'a'.repeat(40)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('uses an optional exact restore after install and saves successful scoped producers', () => {
|
||||
const workflow = parse(readFileSync('.github/workflows/unit-tests.yml', 'utf8'))
|
||||
const steps = workflow.jobs.test.steps
|
||||
const dependencies = steps.findIndex(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
const identity = steps.findIndex((step) => step.id === 'module-cache')
|
||||
const restore = steps.findIndex((step) => step.id === 'module-cache-restore')
|
||||
const prepare = steps.findIndex((step) => step.run?.endsWith('ci-unit-module-cache.mjs prepare'))
|
||||
const tests = steps.findIndex((step) => step.name === 'Test shard')
|
||||
const seal = steps.find((step) => step.id === 'module-cache-seal')
|
||||
const save = steps.find((step) => step.uses === 'actions/cache/save@v5')
|
||||
expect(identity).toBeGreaterThan(dependencies)
|
||||
expect(restore).toBeGreaterThan(identity)
|
||||
expect(prepare).toBeGreaterThan(restore)
|
||||
expect(tests).toBeGreaterThan(prepare)
|
||||
expect(steps[restore].uses).toBe('actions/cache/restore@v5')
|
||||
expect(steps[restore].with['restore-keys']).toBeUndefined()
|
||||
expect(steps[restore]['continue-on-error']).toBe(true)
|
||||
expect(seal.if).toContain('success()')
|
||||
expect(seal.if).toContain("github.event_name == 'pull_request'")
|
||||
expect(seal.if).toContain("github.ref == 'refs/heads/main'")
|
||||
expect(seal.if).toContain("github.event_name != 'pull_request'")
|
||||
expect(seal.if).toContain("inputs.module_cache_seed && github.event_name == 'workflow_dispatch'")
|
||||
expect(save.if).toBe("steps.module-cache-seal.outputs.ready == 'true'")
|
||||
expect(save.with.key).toBe(steps[restore].with.key)
|
||||
expect(workflow.on.workflow_call.inputs.module_cache.default).toBe('off')
|
||||
expect(workflow.on.workflow_call.inputs.run_relay_integration.default).toBe(true)
|
||||
})
|
||||
|
||||
it('runs full same-source controls serially with five four-worker shards and included producer costs', () => {
|
||||
const workflow = parse(readFileSync('.github/workflows/unit-module-cache-benchmark.yml', 'utf8'))
|
||||
const { baseline, producer, warm } = workflow.jobs
|
||||
expect(producer.needs).toBe('baseline')
|
||||
expect(warm.needs).toBe('producer')
|
||||
for (const job of [baseline, producer, warm]) {
|
||||
expect(job.uses).toBe('./.github/workflows/unit-tests.yml')
|
||||
expect(job.with.node_versions).toBe('["24.21.0"]')
|
||||
expect(job.with.runner).toBe('ubuntu-24.04-arm')
|
||||
expect(JSON.parse(job.with.shards)).toEqual(
|
||||
Array.from({ length: 5 }, (_, index) => ({ index: index + 1, count: 5 }))
|
||||
)
|
||||
expect(job.with.phase_diagnostics).toBe(true)
|
||||
}
|
||||
expect(producer.with.module_cache).toBe('cold')
|
||||
expect(producer.with.module_cache_seed).toBe(true)
|
||||
expect(warm.with.module_cache).toBe('warm')
|
||||
expect(warm.with.module_cache_namespace).toBe(producer.with.module_cache_namespace)
|
||||
})
|
||||
@@ -1,8 +1,114 @@
|
||||
import { relative } from 'node:path'
|
||||
import { relative, resolve } from 'node:path'
|
||||
import { appendFileSync, readFileSync } from 'node:fs'
|
||||
import { writeAssignment } from './ci-shard-assignment.mjs'
|
||||
import { auditUnitSelection } from './ci-unit-selection.mjs'
|
||||
|
||||
export const UNIT_PHASE_FILE_LIMIT = 50_000
|
||||
const phaseNames = [
|
||||
'environmentSetupDuration',
|
||||
'prepareDuration',
|
||||
'collectDuration',
|
||||
'setupDuration',
|
||||
'duration'
|
||||
]
|
||||
|
||||
const nonnegativeNumber = (value) => (Number.isFinite(value) && value >= 0 ? value : null)
|
||||
const emptyPhases = () => Object.fromEntries(phaseNames.map((name) => [name, 0]))
|
||||
const emptyImportSample = () => ({ entries: 0, selfTimeMs: 0, totalTimeMs: 0 })
|
||||
const workerCounters = (state) => ({
|
||||
workersSpawned: Number.isInteger(state?.workersSpawned)
|
||||
? nonnegativeNumber(state.workersSpawned)
|
||||
: null,
|
||||
startupTimeMs: nonnegativeNumber(state?.startupTime)
|
||||
})
|
||||
|
||||
export function buildUnitPhaseReport(ctx, modules, errors, reason, baseline) {
|
||||
const files = []
|
||||
const projects = new Map()
|
||||
const totals = emptyPhases()
|
||||
const phaseSamples = emptyPhases()
|
||||
for (const module of modules) {
|
||||
const diagnostic = module.diagnostic()
|
||||
const project = module.project?.name ?? 'unknown'
|
||||
if (!projects.has(project)) {
|
||||
projects.set(project, {
|
||||
project,
|
||||
files: 0,
|
||||
phases: emptyPhases(),
|
||||
phaseSamples: emptyPhases(),
|
||||
sampledImports: emptyImportSample()
|
||||
})
|
||||
}
|
||||
const aggregate = projects.get(project)
|
||||
aggregate.files += 1
|
||||
const phases = {}
|
||||
for (const name of phaseNames) {
|
||||
const value = nonnegativeNumber(diagnostic?.[name])
|
||||
phases[name] = value
|
||||
if (value !== null) {
|
||||
aggregate.phases[name] += value
|
||||
aggregate.phaseSamples[name] += 1
|
||||
totals[name] += value
|
||||
phaseSamples[name] += 1
|
||||
}
|
||||
}
|
||||
const imports = diagnostic?.importDurations
|
||||
if (imports && typeof imports === 'object') {
|
||||
for (const id in imports) {
|
||||
if (!Object.hasOwn(imports, id)) {
|
||||
continue
|
||||
}
|
||||
const selfTime = nonnegativeNumber(imports[id]?.selfTime)
|
||||
const totalTime = nonnegativeNumber(imports[id]?.totalTime)
|
||||
if (selfTime !== null && totalTime !== null) {
|
||||
aggregate.sampledImports.entries += 1
|
||||
aggregate.sampledImports.selfTimeMs += selfTime
|
||||
aggregate.sampledImports.totalTimeMs += totalTime
|
||||
}
|
||||
}
|
||||
}
|
||||
if (files.length < UNIT_PHASE_FILE_LIMIT) {
|
||||
files.push({
|
||||
file: relative(ctx.config.root, module.moduleId).replaceAll('\\', '/'),
|
||||
project,
|
||||
workerId: nonnegativeNumber(diagnostic?.workerId),
|
||||
concurrencyId: nonnegativeNumber(diagnostic?.concurrencyId),
|
||||
phases
|
||||
})
|
||||
}
|
||||
}
|
||||
const counters = workerCounters(ctx.state)
|
||||
return {
|
||||
metric: 'module-phase-diagnostics-v1',
|
||||
nodeVersion: process.env.ORCA_TEST_NODE_VERSION ?? process.versions.node,
|
||||
...(process.versions.bun ? { bunVersion: process.versions.bun } : {}),
|
||||
vitestVersion: ctx.version ?? null,
|
||||
shard: ctx.config.shard ?? { index: 1, count: 1 },
|
||||
status: reason,
|
||||
unhandledErrors: errors.length,
|
||||
units: 'milliseconds',
|
||||
phaseTime: 'summed-worker-time',
|
||||
fileLimit: UNIT_PHASE_FILE_LIMIT,
|
||||
filesSeen: modules.length,
|
||||
filesOmitted: modules.length - files.length,
|
||||
workerTotals: {
|
||||
scope: baseline ? 'test-run' : 'vitest-session',
|
||||
...Object.fromEntries(
|
||||
Object.entries(counters).map(([name, value]) => [
|
||||
name,
|
||||
value === null || (baseline && baseline[name] === null)
|
||||
? null
|
||||
: nonnegativeNumber(value - (baseline?.[name] ?? 0))
|
||||
])
|
||||
)
|
||||
},
|
||||
phases: totals,
|
||||
phaseSamples,
|
||||
projects: [...projects.values()],
|
||||
files
|
||||
}
|
||||
}
|
||||
|
||||
export function moduleDuration(diagnostic) {
|
||||
return Math.max(
|
||||
1,
|
||||
@@ -21,6 +127,12 @@ export default class UnitTimingReporter {
|
||||
this.ctx = ctx
|
||||
}
|
||||
|
||||
onTestRunStart() {
|
||||
if (process.env.ORCA_UNIT_PHASE_REPORT) {
|
||||
this.workerBaseline = workerCounters(this.ctx.state)
|
||||
}
|
||||
}
|
||||
|
||||
onTestRunEnd(modules, errors, reason) {
|
||||
const results = Object.fromEntries(
|
||||
modules.map((module) => [
|
||||
@@ -61,5 +173,17 @@ export default class UnitTimingReporter {
|
||||
} catch {
|
||||
// Timing evidence remains useful when selection planning was unavailable.
|
||||
}
|
||||
const phaseReport = process.env.ORCA_UNIT_PHASE_REPORT
|
||||
const timingReport = process.env.ORCA_UNIT_TIMING_REPORT ?? 'ci-shards/unit-timings.json'
|
||||
if (phaseReport && resolve(phaseReport) !== resolve(timingReport)) {
|
||||
try {
|
||||
writeAssignment(
|
||||
phaseReport,
|
||||
buildUnitPhaseReport(this.ctx, modules, errors, reason, this.workerBaseline)
|
||||
)
|
||||
} catch {
|
||||
// Optional diagnostics must not change the test verdict.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { importUnitTimingReports } from './ci-unit-timing-import.mjs'
|
||||
import UnitTimingReporter, { moduleDuration } from './ci-unit-timing-reporter.mjs'
|
||||
import UnitTimingReporter, {
|
||||
buildUnitPhaseReport,
|
||||
moduleDuration,
|
||||
UNIT_PHASE_FILE_LIMIT
|
||||
} from './ci-unit-timing-reporter.mjs'
|
||||
|
||||
const report = (index, timings) => ({
|
||||
metric: 'module-duration-v1',
|
||||
@@ -38,6 +42,7 @@ describe('unit worker timing evidence', () => {
|
||||
const previous = process.env.ORCA_UNIT_TIMING_REPORT
|
||||
process.env.ORCA_UNIT_TIMING_REPORT = join(directory, 'unit-timings.json')
|
||||
vi.stubEnv('ORCA_TEST_NODE_VERSION', nodeVersion)
|
||||
vi.stubEnv('ORCA_UNIT_PHASE_REPORT', undefined)
|
||||
try {
|
||||
const reporter = new UnitTimingReporter()
|
||||
reporter.onInit({ config: { root: process.cwd(), shard: { index: 1, count: 8 } } })
|
||||
@@ -121,3 +126,217 @@ describe('unit worker timing evidence', () => {
|
||||
expect(() => importUnitTimingReports([first, report(2, { b: -1 })])).toThrow('timing')
|
||||
})
|
||||
})
|
||||
|
||||
const phaseDiagnostic = (overrides = {}) => ({
|
||||
environmentSetupDuration: 1.5,
|
||||
prepareDuration: 2.5,
|
||||
collectDuration: 30.25,
|
||||
setupDuration: 4.5,
|
||||
duration: 5.25,
|
||||
workerId: 7,
|
||||
concurrencyId: 2,
|
||||
importDurations: {},
|
||||
...overrides
|
||||
})
|
||||
const phaseModule = (file, diagnostic = phaseDiagnostic(), project = 'bun') => ({
|
||||
moduleId: resolve(file),
|
||||
project: { name: project },
|
||||
state: () => 'passed',
|
||||
diagnostic: () => diagnostic
|
||||
})
|
||||
const phaseContext = (state) => ({
|
||||
config: { root: process.cwd(), shard: { index: 1, count: 5 } },
|
||||
version: '5.0.3',
|
||||
state
|
||||
})
|
||||
|
||||
function withReporterOutputs(run) {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-unit-phases-'))
|
||||
const timingPath = join(directory, 'unit-timings.json')
|
||||
const phasePath = join(directory, 'unit-phase-diagnostics.json')
|
||||
vi.stubEnv('ORCA_UNIT_TIMING_REPORT', timingPath)
|
||||
vi.stubEnv('ORCA_UNIT_PHASE_REPORT', undefined)
|
||||
vi.stubEnv('ORCA_UNIT_SELECTION_PLAN', join(directory, 'missing-selection.json'))
|
||||
vi.stubEnv('ORCA_SHARD_SOURCE_SHA', 'phase-source')
|
||||
vi.stubEnv('GITHUB_RUN_ID', 'phase-run')
|
||||
vi.stubEnv('GITHUB_RUN_ATTEMPT', '3')
|
||||
try {
|
||||
run({ timingPath, phasePath })
|
||||
} finally {
|
||||
vi.unstubAllEnvs()
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
describe('optional unit phase diagnostics', () => {
|
||||
it('leaves the timing report identical and writes nothing extra when disabled', () => {
|
||||
withReporterOutputs(({ timingPath, phasePath }) => {
|
||||
const modules = [
|
||||
phaseModule(
|
||||
'src/example.test.ts',
|
||||
phaseDiagnostic({ importDurations: { dependency: { selfTime: 100, totalTime: 200 } } })
|
||||
)
|
||||
]
|
||||
const reporter = new UnitTimingReporter()
|
||||
reporter.onInit(phaseContext({ workersSpawned: 3, startupTime: 500 }))
|
||||
reporter.onTestRunEnd(modules, [], 'passed')
|
||||
const timing = readFileSync(timingPath, 'utf8')
|
||||
expect(existsSync(phasePath)).toBe(false)
|
||||
|
||||
vi.stubEnv('ORCA_UNIT_PHASE_REPORT', phasePath)
|
||||
reporter.onTestRunEnd(modules, [], 'passed')
|
||||
expect(readFileSync(timingPath, 'utf8')).toBe(timing)
|
||||
expect(JSON.parse(timing).timings).toEqual({ 'src/example.test.ts': 44 })
|
||||
expect(JSON.parse(readFileSync(phasePath, 'utf8'))).toMatchObject({
|
||||
sourceSha: 'phase-source',
|
||||
runId: 'phase-run',
|
||||
runAttempt: '3',
|
||||
metric: 'module-phase-diagnostics-v1',
|
||||
vitestVersion: '5.0.3',
|
||||
shard: { index: 1, count: 5 },
|
||||
status: 'passed',
|
||||
unhandledErrors: 0,
|
||||
workerTotals: { scope: 'vitest-session', workersSpawned: 3, startupTimeMs: 500 }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('records fractions and project totals without retaining sampled import records', () => {
|
||||
const report = buildUnitPhaseReport(
|
||||
phaseContext(),
|
||||
[
|
||||
phaseModule(
|
||||
'src/first.test.ts',
|
||||
phaseDiagnostic({
|
||||
importDurations: {
|
||||
first: { selfTime: 2, totalTime: 9, importer: 'src/private-account.ts' },
|
||||
second: { selfTime: 3.5, totalTime: 5.5 },
|
||||
invalid: { selfTime: Number.NaN, totalTime: 4 }
|
||||
}
|
||||
})
|
||||
),
|
||||
phaseModule('src/second.test.ts'),
|
||||
phaseModule('src/node.test.ts', phaseDiagnostic({ workerId: 9 }), 'node-runtime')
|
||||
],
|
||||
[{ message: 'test error' }],
|
||||
'failed'
|
||||
)
|
||||
expect(report.files[0]).toEqual({
|
||||
file: 'src/first.test.ts',
|
||||
project: 'bun',
|
||||
workerId: 7,
|
||||
concurrencyId: 2,
|
||||
phases: {
|
||||
environmentSetupDuration: 1.5,
|
||||
prepareDuration: 2.5,
|
||||
collectDuration: 30.25,
|
||||
setupDuration: 4.5,
|
||||
duration: 5.25
|
||||
}
|
||||
})
|
||||
expect(report.projects).toMatchObject([
|
||||
{
|
||||
project: 'bun',
|
||||
files: 2,
|
||||
phases: { collectDuration: 60.5 },
|
||||
phaseSamples: { collectDuration: 2 },
|
||||
sampledImports: { entries: 2, selfTimeMs: 5.5, totalTimeMs: 14.5 }
|
||||
},
|
||||
{ project: 'node-runtime', files: 1, sampledImports: { entries: 0 } }
|
||||
])
|
||||
expect(report.phases.collectDuration).toBe(90.75)
|
||||
expect(report).toMatchObject({
|
||||
phaseTime: 'summed-worker-time',
|
||||
status: 'failed',
|
||||
unhandledErrors: 1
|
||||
})
|
||||
expect(JSON.stringify(report)).not.toContain('private-account')
|
||||
expect(JSON.stringify(report)).not.toContain('importDurations')
|
||||
})
|
||||
|
||||
it('reports successful worker startup counters for the current run separately', () => {
|
||||
withReporterOutputs(({ phasePath }) => {
|
||||
vi.stubEnv('ORCA_UNIT_PHASE_REPORT', phasePath)
|
||||
const state = { workersSpawned: 2, startupTime: 10 }
|
||||
const reporter = new UnitTimingReporter()
|
||||
reporter.onInit(phaseContext(state))
|
||||
reporter.onTestRunStart()
|
||||
state.workersSpawned = 5
|
||||
state.startupTime = 60.25
|
||||
reporter.onTestRunEnd([phaseModule('src/example.test.ts')], [], 'passed')
|
||||
expect(JSON.parse(readFileSync(phasePath, 'utf8')).workerTotals).toEqual({
|
||||
scope: 'test-run',
|
||||
workersSpawned: 3,
|
||||
startupTimeMs: 50.25
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('caps file detail while keeping aggregates from omitted files', () => {
|
||||
const module = phaseModule('src/repeated.test.ts')
|
||||
const modules = Array.from({ length: UNIT_PHASE_FILE_LIMIT }, () => module)
|
||||
modules.push(
|
||||
phaseModule(
|
||||
'src/omitted.test.ts',
|
||||
phaseDiagnostic({
|
||||
collectDuration: 11,
|
||||
importDurations: { dependency: { selfTime: 2, totalTime: 3 } }
|
||||
})
|
||||
)
|
||||
)
|
||||
const report = buildUnitPhaseReport(phaseContext(), modules, [], 'passed')
|
||||
expect(report.files).toHaveLength(UNIT_PHASE_FILE_LIMIT)
|
||||
expect(report).toMatchObject({
|
||||
fileLimit: UNIT_PHASE_FILE_LIMIT,
|
||||
filesSeen: UNIT_PHASE_FILE_LIMIT + 1,
|
||||
filesOmitted: 1,
|
||||
phases: { collectDuration: UNIT_PHASE_FILE_LIMIT * 30.25 + 11 },
|
||||
projects: [
|
||||
{
|
||||
files: UNIT_PHASE_FILE_LIMIT + 1,
|
||||
sampledImports: { entries: 1, selfTimeMs: 2, totalTimeMs: 3 }
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('marks unavailable counters, phases and worker IDs without inventing measurements', () => {
|
||||
const report = buildUnitPhaseReport(
|
||||
phaseContext({ workersSpawned: -1, startupTime: Number.NaN }),
|
||||
[phaseModule('src/unavailable.test.ts', {})],
|
||||
[],
|
||||
'interrupted'
|
||||
)
|
||||
expect(report.workerTotals).toEqual({
|
||||
scope: 'vitest-session',
|
||||
workersSpawned: null,
|
||||
startupTimeMs: null
|
||||
})
|
||||
expect(report.files[0]).toMatchObject({
|
||||
workerId: null,
|
||||
concurrencyId: null,
|
||||
phases: { collectDuration: null }
|
||||
})
|
||||
expect(report.phaseSamples.collectDuration).toBe(0)
|
||||
expect(report.status).toBe('interrupted')
|
||||
})
|
||||
|
||||
it.each(['unwritable', 'same as timing'])(
|
||||
'preserves timing evidence when the optional output is %s',
|
||||
(target) => {
|
||||
withReporterOutputs(({ timingPath }) => {
|
||||
const reporter = new UnitTimingReporter()
|
||||
reporter.onInit(phaseContext())
|
||||
const modules = [phaseModule('src/example.test.ts')]
|
||||
reporter.onTestRunEnd(modules, [], 'passed')
|
||||
const timing = readFileSync(timingPath, 'utf8')
|
||||
vi.stubEnv(
|
||||
'ORCA_UNIT_PHASE_REPORT',
|
||||
target === 'unwritable' ? `${timingPath}/unwritable.json` : timingPath
|
||||
)
|
||||
expect(() => reporter.onTestRunEnd(modules, [], 'passed')).not.toThrow()
|
||||
expect(readFileSync(timingPath, 'utf8')).toBe(timing)
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -90,7 +90,7 @@ it('builds the e2e app when only a build-dependent orcad spec is requested', ()
|
||||
e2e_needs_build: true
|
||||
})
|
||||
}
|
||||
expect(jobs['orcad-ssh-docker'].needs).toEqual(['build', 'prepare-native-cache'])
|
||||
expect(jobs['orcad-ssh-docker'].needs).toEqual(['build'])
|
||||
})
|
||||
|
||||
it('runs every managed-SSH spec in one sharded lane, only when one is routed', () => {
|
||||
|
||||
@@ -251,7 +251,15 @@ describe('Mobile CI tool preflight routing', () => {
|
||||
it('keeps root shard execution enabled for callers that do not opt out', () => {
|
||||
expect(unit.on.workflow_call.inputs.run_shards.default).toBe(true)
|
||||
expect(evaluate(unit.jobs.test.if, { inputs: { run_shards: true } })).toBe(true)
|
||||
expect(unit.jobs.relay_integration.if).toBeUndefined()
|
||||
const relayDefault = unit.on.workflow_call.inputs.run_relay_integration.default
|
||||
expect(relayDefault).toBe(true)
|
||||
expect(
|
||||
evaluate(unit.jobs.relay_integration.if, { inputs: { run_relay_integration: relayDefault } })
|
||||
).toBe(true)
|
||||
expect(
|
||||
evaluate(unit.jobs.relay_integration.if, { inputs: { run_relay_integration: false } })
|
||||
).toBe(false)
|
||||
expect(workflow.jobs.test.with.run_relay_integration).toBeUndefined()
|
||||
expect(unit.jobs.relay_integration.needs).toBeUndefined()
|
||||
})
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ const EXPECTED_MATRIX = {
|
||||
'.github/workflows/e2e.yml#orcad-serve-mode-switch': { contents: 'read' },
|
||||
'.github/workflows/e2e.yml#orcad-serve-mode-switch-windows': { contents: 'read' },
|
||||
'.github/workflows/e2e.yml#orcad-ssh-docker': { contents: 'read' },
|
||||
'.github/workflows/e2e.yml#prepare-native-cache': { contents: 'read' },
|
||||
'.github/workflows/e2e.yml#ssh-browser-network-route': { contents: 'read' },
|
||||
'.github/workflows/homebrew-bump.yml#bump-cask': { contents: 'read' },
|
||||
'.github/workflows/node-server-tests.yml#changes': { contents: 'read' },
|
||||
|
||||
@@ -30,6 +30,9 @@ const testOptions = {
|
||||
environment: 'node',
|
||||
clearMocks: false,
|
||||
fsModuleCache: true,
|
||||
...(process.env.ORCA_UNIT_MODULE_CACHE_PATH
|
||||
? { fsModuleCachePath: process.env.ORCA_UNIT_MODULE_CACHE_PATH }
|
||||
: {}),
|
||||
env: { ORCA_VITEST_RUNTIME: 'node' },
|
||||
server: { deps: { inline: ['zod'] } },
|
||||
// Node's storage globals and V8 retention checks require the existing child flags.
|
||||
|
||||
@@ -6,10 +6,12 @@ import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { chromium, type Browser, type Page } from 'playwright'
|
||||
import { afterAll, afterEach, beforeAll, expect, vi } from 'vitest'
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, expect, vi } from 'vitest'
|
||||
import { build as buildVite } from 'vite'
|
||||
import { waitForBrowserCdpEndpoint } from './browser-session-ua-cdp-collector'
|
||||
import { popupFixtureHtml } from './browser-popup-compatibility-page-fixture'
|
||||
import { resolveElectronProbeLaunch } from './electron-probe-display-launch'
|
||||
import { startHiddenRendererFrameCapture } from './hidden-renderer-frame-capture-fixture'
|
||||
|
||||
// Hidden renderer startup and CDP events can exceed the default one-second poll on CI.
|
||||
vi.setConfig({ expect: { poll: { timeout: 10_000 } } })
|
||||
@@ -35,6 +37,7 @@ export let browser: Browser | null = null
|
||||
export let origin = ''
|
||||
let stderr = ''
|
||||
const pages = new Map<string, Page>()
|
||||
const stopFrameCaptures = new Map<Page, () => Promise<void>>()
|
||||
const statusPath = join(root, 'status.json')
|
||||
export const artifactDir = process.env.ORCA_POPUP_FIXTURE_ARTIFACT_DIR
|
||||
export const snapshots: ReturnType<typeof status>[] = []
|
||||
@@ -77,25 +80,12 @@ export function status(): {
|
||||
return JSON.parse(readFileSync(statusPath, 'utf8'))
|
||||
}
|
||||
|
||||
function fixtureHtml(): string {
|
||||
return `<!doctype html><meta name="referrer" content="strict-origin-when-cross-origin"><body>
|
||||
<button id="oauth" onclick="window.popup=window.open('/authorize?client_id=synthetic&redirect_uri=${origin}/callback&response_type=code');document.body.dataset.handle=String(!!window.popup)">OAuth</button>
|
||||
<button id="blank" onclick="window.popup=window.open('about:blank#state');document.body.dataset.handle=String(!!window.popup);if(window.popup)window.popup.eval("window.opener.postMessage('blank:'+document.cookie,'*')")">Blank</button>
|
||||
<button id="invalid" onclick="window.popup=window.open('about:blank?state=synthetic');document.body.dataset.handle=String(!!window.popup)">Invalid</button>
|
||||
<button id="burst" onclick="window.handles=Array.from({length:5},()=>window.open('about:blank'));document.body.dataset.handles=JSON.stringify(window.handles.map(Boolean))">Burst</button>
|
||||
<button id="stale" onclick="document.body.dataset.handle='waiting';setTimeout(()=>{window.popup=window.open('about:blank');document.body.dataset.handle=String(!!window.popup)},1100)">Stale</button>
|
||||
<form method="post" target="_blank" action="/post"><input name="code" value="synthetic+value"><button id="post">POST</button></form>
|
||||
<form method="post" target="_blank" action="/multipart" enctype="multipart/form-data"><input name="code" value="synthetic-multipart"><button id="multipart">Multipart</button></form>
|
||||
<form method="post" target="_blank" action="/empty"><button id="empty">Empty POST</button></form>
|
||||
<a id="link" href="/docs" target="_blank">Docs</a>
|
||||
<a id="oauth-link" href="/authorize-link?client_id=synthetic&redirect_uri=${origin}/callback&response_type=code" target="_blank">OAuth link</a>
|
||||
<a id="background" href="/background">Background</a>
|
||||
<a id="shift" href="/shift">Popup link</a>
|
||||
<script>window.addEventListener('message',event=>document.body.dataset.message=event.data)</script>
|
||||
</body>`
|
||||
}
|
||||
|
||||
const cdpConnected = Promise.withResolvers<void>()
|
||||
const server = createServer((request, response) => {
|
||||
if (request.url === '/fixture-cdp-connected') {
|
||||
void cdpConnected.promise.then(() => response.writeHead(204).end())
|
||||
return
|
||||
}
|
||||
const chunks: Buffer[] = []
|
||||
request.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||
request.on('end', () => {
|
||||
@@ -127,7 +117,7 @@ const server = createServer((request, response) => {
|
||||
response.setHeader('Content-Type', 'text/html')
|
||||
response.end(
|
||||
['/local', '/route', '/local-control', '/route-control'].includes(path)
|
||||
? fixtureHtml()
|
||||
? popupFixtureHtml(origin)
|
||||
: `<title>Hostile synthetic title</title><body>${path}<script>if(window.opener)window.opener.postMessage('callback:'+document.cookie,'*')</script></body>`
|
||||
)
|
||||
})
|
||||
@@ -213,6 +203,7 @@ beforeAll(async () => {
|
||||
})
|
||||
await waitForBrowserCdpEndpoint(cdpPort)
|
||||
browser = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`)
|
||||
cdpConnected.resolve()
|
||||
await waitForFixture(() => {
|
||||
if (!existsSync(statusPath)) {
|
||||
return false
|
||||
@@ -246,6 +237,17 @@ beforeAll(async () => {
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
beforeEach(async () => {
|
||||
for (const page of pages.values()) {
|
||||
if (!stopFrameCaptures.has(page)) {
|
||||
stopFrameCaptures.set(
|
||||
page,
|
||||
await startHiddenRendererFrameCapture(page, (hasFrame) => expect.poll(hasFrame).toBe(true))
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
const route = pages.get('route')
|
||||
if (route && !route.isClosed()) {
|
||||
@@ -258,6 +260,8 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
cdpConnected.resolve()
|
||||
await Promise.allSettled([...stopFrameCaptures.values()].map((stop) => stop()))
|
||||
if (artifactDir && existsSync(statusPath)) {
|
||||
mkdirSync(artifactDir, { recursive: true })
|
||||
writeFileSync(
|
||||
|
||||
@@ -286,6 +286,8 @@ async function run(): Promise<void> {
|
||||
writeStatus()
|
||||
}
|
||||
})
|
||||
// Playwright must attach before Electron changes guest targets from pages to webviews.
|
||||
await fetch(`${fixtureOrigin}/fixture-cdp-connected`)
|
||||
await createHost('local', localPartition)
|
||||
phase = 'local'
|
||||
writeStatus()
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export function popupFixtureHtml(origin: string): string {
|
||||
return `<!doctype html><meta name="referrer" content="strict-origin-when-cross-origin"><body>
|
||||
<button id="oauth" onclick="window.popup=window.open('/authorize?client_id=synthetic&redirect_uri=${origin}/callback&response_type=code');document.body.dataset.handle=String(!!window.popup)">OAuth</button>
|
||||
<button id="blank" onclick="window.popup=window.open('about:blank#state');document.body.dataset.handle=String(!!window.popup);if(window.popup)window.popup.eval("window.opener.postMessage('blank:'+document.cookie,'*')")">Blank</button>
|
||||
<button id="invalid" onclick="window.popup=window.open('about:blank?state=synthetic');document.body.dataset.handle=String(!!window.popup)">Invalid</button>
|
||||
<button id="burst" onclick="window.handles=Array.from({length:5},()=>window.open('about:blank'));document.body.dataset.handles=JSON.stringify(window.handles.map(Boolean))">Burst</button>
|
||||
<button id="stale" onclick="document.body.dataset.handle='waiting';setTimeout(()=>{window.popup=window.open('about:blank');document.body.dataset.handle=String(!!window.popup)},1100)">Stale</button>
|
||||
<form method="post" target="_blank" action="/post"><input name="code" value="synthetic+value"><button id="post">POST</button></form>
|
||||
<form method="post" target="_blank" action="/multipart" enctype="multipart/form-data"><input name="code" value="synthetic-multipart"><button id="multipart">Multipart</button></form>
|
||||
<form method="post" target="_blank" action="/empty"><button id="empty">Empty POST</button></form>
|
||||
<a id="link" href="/docs" target="_blank">Docs</a>
|
||||
<a id="oauth-link" href="/authorize-link?client_id=synthetic&redirect_uri=${origin}/callback&response_type=code" target="_blank">OAuth link</a>
|
||||
<a id="background" href="/background">Background</a>
|
||||
<a id="shift" href="/shift">Popup link</a>
|
||||
<script>window.addEventListener('message',event=>document.body.dataset.message=event.data)</script>
|
||||
</body>`
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Page } from 'playwright'
|
||||
|
||||
export async function startHiddenRendererFrameCapture(
|
||||
page: Page,
|
||||
waitForFirstFrame: (hasFrame: () => boolean) => Promise<void>
|
||||
): Promise<() => Promise<void>> {
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
let stopped = false
|
||||
const handlePageClose = (): void => {
|
||||
void stop().catch(() => {})
|
||||
}
|
||||
const stop = async (): Promise<void> => {
|
||||
if (stopped) {
|
||||
return
|
||||
}
|
||||
stopped = true
|
||||
try {
|
||||
await cdp.send('Page.stopScreencast')
|
||||
} finally {
|
||||
page.off('close', handlePageClose)
|
||||
cdp.off('Page.screencastFrame', acknowledgeFrame)
|
||||
await cdp.detach()
|
||||
}
|
||||
}
|
||||
let hasFrame = false
|
||||
const acknowledgeFrame = (frame: { sessionId: number }): void => {
|
||||
hasFrame = true
|
||||
void cdp.send('Page.screencastFrameAck', { sessionId: frame.sessionId }).catch(() => {})
|
||||
}
|
||||
cdp.on('Page.screencastFrame', acknowledgeFrame)
|
||||
page.once('close', handlePageClose)
|
||||
try {
|
||||
// Keep hidden renderers painting without revealing their native windows.
|
||||
await cdp.send('Page.enable')
|
||||
await cdp.send('Page.startScreencast', {
|
||||
format: 'jpeg',
|
||||
quality: 20,
|
||||
maxWidth: 640,
|
||||
maxHeight: 480
|
||||
})
|
||||
await waitForFirstFrame(() => hasFrame)
|
||||
return stop
|
||||
} catch (error) {
|
||||
await stop().catch(() => {})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import './rpc/unused-default-rpc-methods.test-fixture'
|
||||
/**
|
||||
* A phone subscribing to an idle reattached PTY must get its first snapshot on the phone grid.
|
||||
*
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import './rpc/unused-default-rpc-methods.test-fixture'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { RpcDispatcher } from './rpc/dispatcher'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import './unused-default-rpc-methods.test-fixture'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import { ORCHESTRATION_CONTRACT_VERSION } from '../../../shared/protocol-version'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import '../unused-default-rpc-methods.test-fixture'
|
||||
/**
|
||||
* A launch's record across a host restart, against the real durable ledger.
|
||||
*
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import '../unused-default-rpc-methods.test-fixture'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import '../unused-default-rpc-methods.test-fixture'
|
||||
/**
|
||||
* The behaviours that only exist while a read is genuinely in flight or genuinely failing: the
|
||||
* per-connection cap, an abort that arrives mid-read, and a verify whose open throws. All three go
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import '../unused-default-rpc-methods.test-fixture'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import '../unused-default-rpc-methods.test-fixture'
|
||||
import { mkdirSync, mkdtempSync, rmSync, truncateSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import '../unused-default-rpc-methods.test-fixture'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import './unused-default-rpc-methods.test-fixture'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type Database from '../../sqlite/sync-database'
|
||||
|
||||
@@ -32,26 +32,49 @@ vi.mock('./main-process-runtime-service', () => ({
|
||||
}),
|
||||
configureRuntimeServices: vi.fn()
|
||||
}))
|
||||
vi.mock('../star-nag/service')
|
||||
vi.mock('../browser/agent-browser-bridge')
|
||||
vi.mock('../emulator/emulator-bridge')
|
||||
vi.mock('../runtime/rpc/dispatcher')
|
||||
vi.mock('../star-nag/service', () => ({
|
||||
StarNagService: class {
|
||||
start = vi.fn()
|
||||
registerIpcHandlers = vi.fn()
|
||||
}
|
||||
}))
|
||||
vi.mock('../browser/agent-browser-bridge', () => ({
|
||||
AgentBrowserBridge: class {
|
||||
sweepOrphanedSessions = vi.fn()
|
||||
}
|
||||
}))
|
||||
vi.mock('../emulator/emulator-bridge', () => ({ EmulatorBridge: class {} }))
|
||||
vi.mock('../runtime/rpc/dispatcher', () => ({ RpcDispatcher: class {} }))
|
||||
vi.mock('../browser/browser-manager', () => ({ browserManager: {} }))
|
||||
vi.mock('../browser/browser-client-page-automation-runtime')
|
||||
vi.mock('../crash-reporting/process-gone-diagnostics')
|
||||
vi.mock('./main-window-lifecycle-flags')
|
||||
vi.mock('./gpu-lifecycle')
|
||||
vi.mock('../browser/browser-client-page-automation-runtime', () => ({
|
||||
configureBrowserClientPageAutomationRuntime: vi.fn()
|
||||
}))
|
||||
vi.mock('../crash-reporting/process-gone-diagnostics', () => ({
|
||||
startPreGoneCrashSampling: vi.fn()
|
||||
}))
|
||||
vi.mock('./main-window-lifecycle-flags', () => ({ recordProcessGoneCrash: vi.fn() }))
|
||||
vi.mock('./gpu-lifecycle', () => ({ handleGpuChildCrash: vi.fn() }))
|
||||
vi.mock('./configure-process', () => ({ shouldInstallManagedHooks: () => false }))
|
||||
vi.mock('../agent-hooks/install-telemetry')
|
||||
vi.mock('./main-process-observers')
|
||||
vi.mock('./main-process-account-services')
|
||||
vi.mock('./main-process-automations')
|
||||
vi.mock('./main-process-plugins')
|
||||
vi.mock('../worktree-trash')
|
||||
vi.mock('./worktree-removal-records-load')
|
||||
vi.mock('./first-window-deferral')
|
||||
vi.mock('./startup-diagnostics')
|
||||
vi.mock('../opencode/opencode-status-plugin-startup-refresh')
|
||||
vi.mock('../agent-hooks/install-telemetry', () => ({ recordManagedHookInstallFailure: vi.fn() }))
|
||||
vi.mock('./main-process-observers', () => ({ initializeMainProcessObservers: vi.fn() }))
|
||||
vi.mock('./main-process-account-services', () => ({
|
||||
initializeMainProcessAccountServices: vi.fn()
|
||||
}))
|
||||
vi.mock('./main-process-automations', () => ({ initializeMainProcessAutomations: vi.fn() }))
|
||||
vi.mock('./main-process-plugins', () => ({ initializeMainProcessPlugins: vi.fn() }))
|
||||
vi.mock('../worktree-trash', () => ({
|
||||
collectWorktreeTrashSweepRoots: vi.fn(),
|
||||
sweepStaleWorktreeTrash: vi.fn()
|
||||
}))
|
||||
vi.mock('./worktree-removal-records-load', () => ({ loadWorktreeRemovalRecordsForStore: vi.fn() }))
|
||||
vi.mock('./first-window-deferral', () => ({ runAfterFirstWindowShown: vi.fn() }))
|
||||
vi.mock('./startup-diagnostics', () => ({ logStartupMilestone: vi.fn() }))
|
||||
vi.mock('../opencode/opencode-status-plugin-startup-refresh', () => ({
|
||||
refreshInstalledOpenCodeStatusPlugins: vi.fn()
|
||||
}))
|
||||
vi.mock('../orcad/orcad-artifact-cache-retention', () => ({
|
||||
pruneDesktopOrcadArtifactCache: vi.fn()
|
||||
}))
|
||||
|
||||
import { initializeReadyRuntimeServices } from './main-process-ready-runtime'
|
||||
|
||||
|
||||
@@ -102,6 +102,14 @@ vi.mock('../window/foreground-activation-policy', () => ({
|
||||
isWindowlessLaunch: () => false,
|
||||
showWindowWithoutStealingFocus
|
||||
}))
|
||||
vi.mock('../tray/system-tray', () => ({
|
||||
createSystemTray: vi.fn(),
|
||||
setMacMenuBarIconVisible: vi.fn()
|
||||
}))
|
||||
vi.mock('../window/attach-main-window-services', () => ({ ensureAutoUpdaterConfigured: vi.fn() }))
|
||||
vi.mock('../window/dashboard-popout-window', () => ({ getDashboardPopoutWindow: () => null }))
|
||||
vi.mock('../window/createMainWindow', () => ({ loadMainWindow: vi.fn() }))
|
||||
vi.mock('../window/renderer-recovery-prompt', () => ({ presentRendererRecoveryPrompt: vi.fn() }))
|
||||
|
||||
vi.mock('./main-process-ready-foundation', () => ({
|
||||
initializeReadyFoundation: vi.fn(async () => {})
|
||||
|
||||
@@ -19,6 +19,7 @@ vi.mock('@monaco-editor/react', () => ({
|
||||
},
|
||||
loader: { config: vi.fn() }
|
||||
}))
|
||||
vi.mock('@/lib/monaco-setup', () => ({}))
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector({
|
||||
|
||||
@@ -17,6 +17,7 @@ vi.mock('@monaco-editor/react', () => ({
|
||||
},
|
||||
loader: { config: vi.fn() }
|
||||
}))
|
||||
vi.mock('@/lib/monaco-setup', () => ({}))
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector({
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { CommitArea } from './source-control/commit/commit-area'
|
||||
import { resolvePrimaryAction, type PrimaryActionInputs } from './source-control-primary-action'
|
||||
import { resolveDropdownItems } from './source-control-dropdown-items'
|
||||
import type { DropdownActionKind } from './source-control-dropdown-item-types'
|
||||
|
||||
// Why: split out from CommitArea.test.tsx so each file stays under the
|
||||
// project's max-lines budget. These tests cover the directional-icon
|
||||
// mapping for primary action kinds (push / pull / sync / publish); the
|
||||
// commit-checkmark and core CommitArea behaviour live in the sibling file.
|
||||
|
||||
function buildInputs(overrides: Partial<PrimaryActionInputs> = {}): PrimaryActionInputs {
|
||||
return {
|
||||
stagedCount: 1,
|
||||
hasUnstagedChanges: false,
|
||||
hasStageableChanges: false,
|
||||
hasPartiallyStagedChanges: false,
|
||||
hasMessage: true,
|
||||
hasUnresolvedConflicts: false,
|
||||
isCommitting: false,
|
||||
isRemoteOperationActive: false,
|
||||
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function baseProps(overrides: Partial<PrimaryActionInputs> = {}) {
|
||||
const inputs = buildInputs(overrides)
|
||||
return {
|
||||
worktreeId: 'wt-1',
|
||||
groupId: 'group-1',
|
||||
commitMessage: 'feat: add commit area',
|
||||
commitError: null as string | null,
|
||||
commitFailureRecoveryPrompt: null as string | null,
|
||||
pushRecovery: null,
|
||||
remoteActionError: null as string | null,
|
||||
isCommitting: inputs.isCommitting,
|
||||
isFixingCommitFailureWithAI: false,
|
||||
isFixingPushFailureWithAI: false,
|
||||
sourceControlAiActionsVisible: true,
|
||||
aiAgentConfigured: false,
|
||||
isGenerating: false,
|
||||
generateError: null as string | null,
|
||||
stagedCount: inputs.stagedCount,
|
||||
hasPartiallyStagedChanges: inputs.hasPartiallyStagedChanges,
|
||||
hasUnresolvedConflicts: inputs.hasUnresolvedConflicts,
|
||||
isRemoteOperationActive: inputs.isRemoteOperationActive,
|
||||
inFlightRemoteOpKind: inputs.inFlightRemoteOpKind ?? null,
|
||||
primaryAction: resolvePrimaryAction(inputs),
|
||||
dropdownItems: resolveDropdownItems(inputs),
|
||||
onCommitMessageChange: vi.fn(),
|
||||
onGenerate: vi.fn(),
|
||||
onCancelGenerate: vi.fn(),
|
||||
onFixCommitFailureWithAI: vi.fn(),
|
||||
onFixPushFailureWithAI: vi.fn(),
|
||||
onPrimaryAction: vi.fn(),
|
||||
onDropdownAction: vi.fn() as (kind: DropdownActionKind) => void
|
||||
}
|
||||
}
|
||||
|
||||
function primaryButton(props: ReturnType<typeof baseProps>): string {
|
||||
const markup = renderToStaticMarkup(
|
||||
<TooltipProvider>
|
||||
<CommitArea {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
const button = [...markup.matchAll(/<button\b[\s\S]*?<\/button>/g)]
|
||||
.map((match) => match[0])
|
||||
.find((entry) => entry.includes('data-slot="button"'))
|
||||
if (!button) {
|
||||
throw new Error('primary button not found')
|
||||
}
|
||||
return button
|
||||
}
|
||||
|
||||
describe('CommitArea primary action icons', () => {
|
||||
it('renders an up-arrow on a Push primary', () => {
|
||||
expect(
|
||||
primaryButton(
|
||||
baseProps({
|
||||
stagedCount: 0,
|
||||
hasMessage: false,
|
||||
upstreamStatus: { hasUpstream: true, ahead: 1, behind: 0 }
|
||||
})
|
||||
)
|
||||
).toContain('lucide-arrow-up')
|
||||
})
|
||||
|
||||
it('renders no directional icon on a Pull primary', () => {
|
||||
const button = primaryButton(
|
||||
baseProps({
|
||||
stagedCount: 0,
|
||||
hasMessage: false,
|
||||
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 1 }
|
||||
})
|
||||
)
|
||||
expect(button).not.toContain('lucide-arrow-up')
|
||||
expect(button).not.toContain('lucide-arrow-down-up')
|
||||
expect(button).not.toContain('lucide-cloud-upload')
|
||||
})
|
||||
|
||||
it('renders a bidirectional arrow on a Sync primary', () => {
|
||||
expect(
|
||||
primaryButton(
|
||||
baseProps({
|
||||
stagedCount: 0,
|
||||
hasMessage: false,
|
||||
upstreamStatus: { hasUpstream: true, ahead: 1, behind: 1 }
|
||||
})
|
||||
)
|
||||
).toContain('lucide-arrow-down-up')
|
||||
})
|
||||
|
||||
it('renders a cloud-up icon on a Publish primary', () => {
|
||||
expect(
|
||||
primaryButton(
|
||||
baseProps({
|
||||
stagedCount: 0,
|
||||
hasMessage: false,
|
||||
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }
|
||||
})
|
||||
)
|
||||
).toContain('lucide-cloud-upload')
|
||||
})
|
||||
|
||||
it('renders a plus icon on a Stage All primary', () => {
|
||||
expect(
|
||||
primaryButton(
|
||||
baseProps({
|
||||
stagedCount: 0,
|
||||
hasUnstagedChanges: true,
|
||||
hasStageableChanges: true,
|
||||
hasMessage: false,
|
||||
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }
|
||||
})
|
||||
)
|
||||
).toContain('lucide-plus')
|
||||
})
|
||||
})
|
||||
@@ -1,45 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getKeybindingDefinition } from '../../../../shared/keybindings'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { ShortcutCommandBlock } from './ShortcutCommandBlock'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('ShortcutCommandBlock', () => {
|
||||
it('wraps persistent conflict remediation instead of truncating it', () => {
|
||||
const warning = 'Blocked by Mission Control. Remap here or change it in System Settings.'
|
||||
const item = getKeybindingDefinition('tab.selectByIndex')
|
||||
expect(item).not.toBeNull()
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ShortcutCommandBlock
|
||||
item={item!}
|
||||
groupTitle="Tab Navigation"
|
||||
platform="darwin"
|
||||
effective={['Ctrl+1']}
|
||||
modified={false}
|
||||
warnings={[warning]}
|
||||
previousBindings={[]}
|
||||
recordingBindingIndex={null}
|
||||
onStartRecordingAt={vi.fn()}
|
||||
onAppendBinding={vi.fn()}
|
||||
onCancelRecording={vi.fn()}
|
||||
onCapture={vi.fn()}
|
||||
onClearError={vi.fn()}
|
||||
onRemoveBindingAt={vi.fn()}
|
||||
onResetAction={vi.fn()}
|
||||
onDisableAction={vi.fn()}
|
||||
onEnableAction={vi.fn()}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
const helper = screen.getByText(warning)
|
||||
expect(helper.classList.contains('whitespace-normal')).toBe(true)
|
||||
expect(helper.classList.contains('truncate')).toBe(false)
|
||||
})
|
||||
})
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`PREVIEW_BUFFER > matches the locked snapshot 1`] = `
|
||||
"[34m~/orca[0m [35mmain[0m [33m*[0m $ npm test
|
||||
[42m[30m PASS [0m src/preview.test.ts
|
||||
[32m✓[0m renders sample output [2m(3ms)[0m
|
||||
[31m✗ ligatures: => != >= <= ===[0m
|
||||
|
||||
[33mdef[0m [36mtotal[0m(xs: list[[36mint[0m]) -> [36mint[0m:
|
||||
[3m[32m"""Sum the values."""[0m
|
||||
[33mreturn[0m [36msum[0m(x [33mfor[0m x [33min[0m xs)
|
||||
|
||||
[34m~/orca[0m [35mmain[0m [33m*[0m $ git diff
|
||||
[36m@@ -1,2 +1,3 @@[0m
|
||||
[31m-const size = 13[0m
|
||||
[32m+const size = 14[0m
|
||||
|
||||
[34m~/orca[0m [35mmain[0m [33m*[0m $ "
|
||||
`;
|
||||
@@ -1,27 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getDefaultSettings } from '../../../../shared/constants'
|
||||
import { resolveInterfaceSectionSummary } from './appearance-interface-summary'
|
||||
|
||||
describe('resolveInterfaceSectionSummary', () => {
|
||||
it('includes theme, language, and font when the language setting is shown', () => {
|
||||
const settings = {
|
||||
...getDefaultSettings('/tmp'),
|
||||
theme: 'dark' as const,
|
||||
uiLanguage: 'zh' as const,
|
||||
appFontFamily: 'Inter'
|
||||
}
|
||||
|
||||
expect(resolveInterfaceSectionSummary(settings)).toBe('Dark · 中文(简体) · Inter')
|
||||
})
|
||||
|
||||
it('falls back to the default font label when app font is empty', () => {
|
||||
const settings = {
|
||||
...getDefaultSettings('/tmp'),
|
||||
theme: 'light' as const,
|
||||
uiLanguage: 'system' as const,
|
||||
appFontFamily: ''
|
||||
}
|
||||
|
||||
expect(resolveInterfaceSectionSummary(settings)).toBe('Light · System · Default font')
|
||||
})
|
||||
})
|
||||
@@ -1,26 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { PREVIEW_BUFFER } from './terminal-preview-content'
|
||||
|
||||
describe('PREVIEW_BUFFER', () => {
|
||||
it('contains the canonical content markers', () => {
|
||||
// Why: strip ANSI before matching so re-coloring a marker (e.g. splitting
|
||||
// "def render" into separate keyword/identifier spans) doesn't break this
|
||||
// test — what matters is the visible text the user reads.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const stripped = PREVIEW_BUFFER.replace(/\x1b\[[0-9;]*m/g, '')
|
||||
expect(stripped).toContain('npm test')
|
||||
expect(stripped).toContain('PASS')
|
||||
expect(stripped).toContain('ligatures: => != >= <= ===')
|
||||
expect(stripped).toContain('git diff')
|
||||
expect(stripped).toContain('def total')
|
||||
})
|
||||
|
||||
it('ends without a trailing newline so the cursor parks on the prompt', () => {
|
||||
expect(PREVIEW_BUFFER.endsWith('\n')).toBe(false)
|
||||
expect(PREVIEW_BUFFER).toMatch(/\$ $/)
|
||||
})
|
||||
|
||||
it('matches the locked snapshot', () => {
|
||||
expect(PREVIEW_BUFFER).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { cleanupE2ECrashpad } from './electron-crashpad-cleanup'
|
||||
import { recordElectronShutdownTiming } from './electron-shutdown-timing'
|
||||
import type { ElectronApplication } from '@stablyai/playwright-test'
|
||||
|
||||
const GRACEFUL_CLOSE_TIMEOUT_MS = 10_000
|
||||
@@ -214,10 +215,35 @@ export async function forceQuitElectronAppForE2E(
|
||||
).catch(() => undefined)
|
||||
}
|
||||
|
||||
export async function closeElectronAppForE2E(app: ElectronApplication): Promise<void> {
|
||||
export async function closeElectronAppForE2E(
|
||||
app: ElectronApplication,
|
||||
options: { cleanupDaemonsForUserDataDir?: string } = {}
|
||||
): Promise<void> {
|
||||
const proc = app.process()
|
||||
const releasePipes = (): void => releaseExitedProcessPipes(proc)
|
||||
const started = performance.now()
|
||||
let rootExitMs: number | null = hasExited(proc) ? 0 : null
|
||||
let pipesClosedMs: number | null = null
|
||||
let outcome = 'closed'
|
||||
let daemonCleanup: Promise<void> | undefined
|
||||
const startDaemonCleanup = (): void => {
|
||||
if (options.cleanupDaemonsForUserDataDir && !daemonCleanup) {
|
||||
daemonCleanup = cleanupE2EDaemons(options.cleanupDaemonsForUserDataDir)
|
||||
void daemonCleanup.catch(() => undefined)
|
||||
}
|
||||
}
|
||||
const releasePipes = (): void => {
|
||||
if (hasExited(proc)) {
|
||||
rootExitMs ??= performance.now() - started
|
||||
// Linux daemon trees retain Chromium sockets that block Playwright's public close.
|
||||
startDaemonCleanup()
|
||||
}
|
||||
releaseExitedProcessPipes(proc)
|
||||
}
|
||||
const pipesClosed = (): void => {
|
||||
pipesClosedMs = performance.now() - started
|
||||
}
|
||||
proc.once('exit', releasePipes)
|
||||
proc.once('close', pipesClosed)
|
||||
releasePipes()
|
||||
try {
|
||||
await withTimeout(app.close(), GRACEFUL_CLOSE_TIMEOUT_MS, 'Timed out closing Electron app')
|
||||
@@ -228,13 +254,23 @@ export async function closeElectronAppForE2E(app: ElectronApplication): Promise<
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
outcome = 'fallback'
|
||||
if (proc) {
|
||||
await forceKillProcessTree(proc)
|
||||
}
|
||||
} finally {
|
||||
proc.off('exit', releasePipes)
|
||||
proc.off('close', pipesClosed)
|
||||
releasePipes()
|
||||
recordElectronShutdownTiming('close', started, {
|
||||
pid: proc.pid,
|
||||
outcome,
|
||||
rootExitMs,
|
||||
pipesClosedMs
|
||||
})
|
||||
}
|
||||
startDaemonCleanup()
|
||||
await daemonCleanup
|
||||
}
|
||||
|
||||
function readDaemonPidFiles(userDataDir: string): number[] {
|
||||
@@ -268,8 +304,14 @@ export async function cleanupE2EDaemons(userDataDir: string): Promise<void> {
|
||||
// Why: app quit intentionally leaves daemon PTYs alive for warm reattach.
|
||||
// E2E temp profiles are deleted after each test, so their detached daemons
|
||||
// must be stopped explicitly or CI accumulates orphan Electron/shell trees.
|
||||
for (const pid of readDaemonPidFiles(userDataDir)) {
|
||||
await forceKillPidTree(pid)
|
||||
const started = performance.now()
|
||||
const pids = readDaemonPidFiles(userDataDir)
|
||||
try {
|
||||
for (const pid of pids) {
|
||||
await forceKillPidTree(pid)
|
||||
}
|
||||
cleanupE2ECrashpad(userDataDir)
|
||||
} finally {
|
||||
recordElectronShutdownTiming('daemon-cleanup', started, { daemonCount: pids.length })
|
||||
}
|
||||
cleanupE2ECrashpad(userDataDir)
|
||||
}
|
||||
|
||||
@@ -158,6 +158,52 @@ describe('Electron shutdown with inherited pipes', () => {
|
||||
})
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('owned POSIX daemon cleanup', () => {
|
||||
it('preserves a running daemon when final cleanup is not requested', async () => {
|
||||
const daemon = await ownedDaemon('exit')
|
||||
const { proc, app } = exitedAppFixture()
|
||||
proc.exitCode = 0
|
||||
await closeElectronAppForE2E(app)
|
||||
expect(daemon.pids.every((pid) => !pidIsGone(pid))).toBe(true)
|
||||
})
|
||||
|
||||
it('starts final daemon cleanup after app exit and awaits public close and all descendants', async () => {
|
||||
const daemon = await ownedDaemon('descendant')
|
||||
const { proc, app, close } = exitedAppFixture()
|
||||
let finishPublicClose: () => void = () => {}
|
||||
const publicClose = new Promise<void>((resolve) => {
|
||||
finishPublicClose = resolve
|
||||
})
|
||||
let publicCloseFinished = false
|
||||
close.mockImplementation(async () => {
|
||||
await daemon.exited
|
||||
await publicClose
|
||||
publicCloseFinished = true
|
||||
return []
|
||||
})
|
||||
let shutdownFinished = false
|
||||
const closing = closeElectronAppForE2E(app, {
|
||||
cleanupDaemonsForUserDataDir: daemon.profile
|
||||
}).then(() => {
|
||||
shutdownFinished = true
|
||||
})
|
||||
expect(close).toHaveBeenCalledOnce()
|
||||
expect(daemon.pids.every((pid) => !pidIsGone(pid))).toBe(true)
|
||||
proc.exitCode = 0
|
||||
proc.emit('exit', 0, null)
|
||||
const [, signal] = await daemon.exited
|
||||
expect(signal).toBe('SIGTERM')
|
||||
expect(pidIsGone(daemon.pids[1])).toBe(false)
|
||||
expect(publicCloseFinished).toBe(false)
|
||||
expect(shutdownFinished).toBe(false)
|
||||
finishPublicClose()
|
||||
await expect.poll(() => publicCloseFinished).toBe(true)
|
||||
expect(shutdownFinished).toBe(false)
|
||||
await closing
|
||||
await expect.poll(() => daemon.pids.every(pidIsGone), { timeout: 5_000 }).toBe(true)
|
||||
expect(close).toHaveBeenCalledOnce()
|
||||
expect(proc.listenerCount('exit')).toBe(0)
|
||||
}, 10_000)
|
||||
|
||||
it('returns when every captured process exits on SIGTERM', async () => {
|
||||
const daemon = await ownedDaemon('exit')
|
||||
const started = performance.now()
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
type ShutdownPhase = 'close' | 'daemon-cleanup' | 'profile-removal'
|
||||
|
||||
export function recordElectronShutdownTiming(
|
||||
phase: ShutdownPhase,
|
||||
started: number,
|
||||
details: Record<string, number | string | boolean | null | undefined> = {}
|
||||
): void {
|
||||
if (
|
||||
process.env.ORCA_E2E_SHUTDOWN_TIMING !== '1' &&
|
||||
process.env.ORCA_E2E_FORWARD_APP_LOGS !== '1'
|
||||
) {
|
||||
return
|
||||
}
|
||||
console.log(
|
||||
`[e2e:shutdown] ${JSON.stringify({
|
||||
phase,
|
||||
elapsedMs: Number((performance.now() - started).toFixed(3)),
|
||||
...details
|
||||
})}`
|
||||
)
|
||||
}
|
||||
@@ -1,45 +1,9 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { startHiddenRendererFrameCapture as startCapture } from '../../../src/main/browser/hidden-renderer-frame-capture-fixture'
|
||||
import { expect } from './orca-app'
|
||||
|
||||
export async function startHiddenRendererFrameCapture(page: Page): Promise<() => Promise<void>> {
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
let stopped = false
|
||||
const handlePageClose = (): void => {
|
||||
void stop().catch(() => {})
|
||||
}
|
||||
const stop = async (): Promise<void> => {
|
||||
if (stopped) {
|
||||
return
|
||||
}
|
||||
stopped = true
|
||||
try {
|
||||
await cdp.send('Page.stopScreencast')
|
||||
} finally {
|
||||
page.off('close', handlePageClose)
|
||||
cdp.off('Page.screencastFrame', acknowledgeFrame)
|
||||
await cdp.detach()
|
||||
}
|
||||
}
|
||||
let hasFrame = false
|
||||
const acknowledgeFrame = (frame: { sessionId: number }): void => {
|
||||
hasFrame = true
|
||||
void cdp.send('Page.screencastFrameAck', { sessionId: frame.sessionId }).catch(() => {})
|
||||
}
|
||||
cdp.on('Page.screencastFrame', acknowledgeFrame)
|
||||
page.once('close', handlePageClose)
|
||||
try {
|
||||
// Keep hidden renderers painting without revealing their native windows.
|
||||
await cdp.send('Page.enable')
|
||||
await cdp.send('Page.startScreencast', {
|
||||
format: 'jpeg',
|
||||
quality: 20,
|
||||
maxWidth: 640,
|
||||
maxHeight: 480
|
||||
})
|
||||
await expect.poll(() => hasFrame).toBe(true)
|
||||
return stop
|
||||
} catch (error) {
|
||||
await stop().catch(() => {})
|
||||
throw error
|
||||
}
|
||||
return startCapture(page, async (hasFrame) => {
|
||||
await expect.poll(hasFrame).toBe(true)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { cleanupE2EDaemons, closeElectronAppForE2E } from './electron-process-shutdown'
|
||||
import { closeElectronAppForE2E } from './electron-process-shutdown'
|
||||
import { recordElectronShutdownTiming } from './electron-shutdown-timing'
|
||||
import { getOrcaElectronLaunchArgs } from './electron-launch-args'
|
||||
import { retryTransientMainEvaluate } from './electron-main-evaluate-retry'
|
||||
import { getE2ECompletedOnboardingProfile } from './e2e-completed-onboarding-profile'
|
||||
@@ -91,9 +92,11 @@ const ORCA_E2E_SLOWMO_MS = ((): number => {
|
||||
})()
|
||||
|
||||
async function removeUserDataDirAfterShutdown(userDataDir: string): Promise<void> {
|
||||
const started = performance.now()
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
try {
|
||||
rmSync(userDataDir, { recursive: true, force: true })
|
||||
recordElectronShutdownTiming('profile-removal', started)
|
||||
return
|
||||
} catch (error) {
|
||||
if (attempt === 4) {
|
||||
@@ -251,16 +254,14 @@ export const test = base.extend<OrcaTestFixtures, OrcaWorkerFixtures>({
|
||||
)
|
||||
assertElectronResolvedIsolatedHome(resolvedHome, homeIsolation)
|
||||
} catch (error) {
|
||||
await closeElectronAppForE2E(app)
|
||||
await cleanupE2EDaemons(userDataDir)
|
||||
await closeElectronAppForE2E(app, { cleanupDaemonsForUserDataDir: userDataDir })
|
||||
await removeUserDataDirAfterShutdown(userDataDir)
|
||||
throw error
|
||||
}
|
||||
await provideFixture(app)
|
||||
// Why: the Playwright close promise can settle before all Electron and PTY
|
||||
// descendants are gone in CI; worker teardown then hangs on open handles.
|
||||
await closeElectronAppForE2E(app)
|
||||
await cleanupE2EDaemons(userDataDir)
|
||||
await closeElectronAppForE2E(app, { cleanupDaemonsForUserDataDir: userDataDir })
|
||||
await removeUserDataDirAfterShutdown(userDataDir)
|
||||
},
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ function attachModelPane(
|
||||
ReturnType<typeof createRemoteRuntimePtyTransport>['attach']
|
||||
>[0]['callbacks']
|
||||
): ReturnType<typeof createRemoteRuntimePtyTransport> {
|
||||
const call = vi.fn(async (request: { method: string }) =>
|
||||
const call = async (request: { method: string }) =>
|
||||
request.method === 'terminal.resolvePane'
|
||||
? {
|
||||
ok: true,
|
||||
@@ -96,7 +96,7 @@ function attachModelPane(
|
||||
}
|
||||
}
|
||||
: { ok: true, result: { terminal: { handle: MODEL_TERMINAL_HANDLE } } }
|
||||
)
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
api: { runtimeEnvironments: { call, subscribe: link.subscribe } },
|
||||
location: { search: '' }
|
||||
|
||||
@@ -193,6 +193,8 @@ async function updateWindowsDefaultShellSetting(
|
||||
}
|
||||
|
||||
test.describe('Windows terminal shell paste ownership', () => {
|
||||
test.skip(process.platform !== 'win32', 'Windows terminal shell paste coverage is Windows-only')
|
||||
|
||||
async function skipWhenGitBashUnavailable(page: Page): Promise<void> {
|
||||
const isAvailable = await page.evaluate(() => window.api.gitBash.isAvailable())
|
||||
test.skip(!isAvailable, 'Git Bash is not available on this Windows host')
|
||||
@@ -203,8 +205,6 @@ test.describe('Windows terminal shell paste ownership', () => {
|
||||
orcaPage,
|
||||
testRepoPath
|
||||
}) => {
|
||||
test.skip(process.platform !== 'win32', 'PowerShell paste coverage is Windows-only')
|
||||
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
@@ -256,8 +256,6 @@ test.describe('Windows terminal shell paste ownership', () => {
|
||||
orcaPage,
|
||||
testRepoPath
|
||||
}) => {
|
||||
test.skip(process.platform !== 'win32', 'cmd.exe paste coverage is Windows-only')
|
||||
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
@@ -307,7 +305,6 @@ test.describe('Windows terminal shell paste ownership', () => {
|
||||
orcaPage,
|
||||
testRepoPath
|
||||
}) => {
|
||||
test.skip(process.platform !== 'win32', 'Git Bash paste coverage is Windows-only')
|
||||
await skipWhenGitBashUnavailable(orcaPage)
|
||||
|
||||
await waitForSessionReady(orcaPage)
|
||||
@@ -359,7 +356,6 @@ test.describe('Windows terminal shell paste ownership', () => {
|
||||
orcaPage,
|
||||
testRepoPath
|
||||
}) => {
|
||||
test.skip(process.platform !== 'win32', 'WSL paste coverage is Windows-only')
|
||||
test.skip(!hasWslNodeRuntime(), 'WSL with node is not available on this Windows host')
|
||||
|
||||
await waitForSessionReady(orcaPage)
|
||||
@@ -418,7 +414,6 @@ test.describe('Windows terminal shell paste ownership', () => {
|
||||
orcaPage,
|
||||
testRepoPath
|
||||
}) => {
|
||||
test.skip(process.platform !== 'win32', 'WSL paste runtime retention is Windows-only')
|
||||
test.skip(!hasWslNodeRuntime(), 'WSL with node is not available on this Windows host')
|
||||
|
||||
await waitForSessionReady(orcaPage)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineConfig } from '@stablyai/playwright-test'
|
||||
import { resolveBuildSourceCommit } from '../config/scripts/build-source-commit.mjs'
|
||||
|
||||
/**
|
||||
* Playwright config for Orca E2E tests.
|
||||
@@ -14,6 +15,9 @@ import { defineConfig } from '@stablyai/playwright-test'
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
// Keep checkout provenance without fetching the PR base during test setup.
|
||||
captureGitInfo: { commit: false, diff: false },
|
||||
metadata: { sourceCommit: resolveBuildSourceCommit() },
|
||||
globalSetup: './e2e/global-setup.ts',
|
||||
globalTeardown: './e2e/global-teardown.ts',
|
||||
// Why: this suite launches a fresh Electron app and isolated userData dir per
|
||||
|
||||
Reference in New Issue
Block a user