mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 16:02:43 +00:00
Reduce redundant CI runs, pnpm uploads, and fixture startups (#23145)
* Reduce redundant CI runs, store uploads, and fixture processes * Avoid repeating draft-independent mobile checks on readiness --------- Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
This commit is contained in:
@@ -50,8 +50,9 @@ runs:
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
cache: ${{ github.event_name != 'pull_request' && 'pnpm' || '' }}
|
||||
cache-dependency-path: ${{ inputs.cache-dependency-path }}
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Setup requested Node.js
|
||||
id: requested-node
|
||||
@@ -59,8 +60,31 @@ runs:
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
cache: pnpm
|
||||
cache: ${{ github.event_name != 'pull_request' && 'pnpm' || '' }}
|
||||
cache-dependency-path: ${{ inputs.cache-dependency-path }}
|
||||
package-manager-cache: false
|
||||
|
||||
# PR-local stores compete with reusable build caches for the repository quota.
|
||||
- name: Resolve pnpm download store
|
||||
id: pnpm-store
|
||||
if: github.event_name == 'pull_request'
|
||||
shell: bash
|
||||
env:
|
||||
LOCKFILE_HASH: ${{ hashFiles(inputs.cache-dependency-path) }}
|
||||
run: |
|
||||
test -n "$LOCKFILE_HASH"
|
||||
cache_path="$(pnpm store path --silent)"
|
||||
test -n "$cache_path"
|
||||
printf 'path=%s\n' "$cache_path" >> "$GITHUB_OUTPUT"
|
||||
printf 'arch=%s\n' "$(node -p 'require("node:os").arch()')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Match setup-node's key and path so existing default-branch stores remain reusable.
|
||||
- name: Restore pnpm download store without saving
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: node-cache-${{ runner.os }}-${{ steps.pnpm-store.outputs.arch }}-pnpm-${{ hashFiles(inputs.cache-dependency-path) }}
|
||||
|
||||
- name: Validate native runtime
|
||||
shell: bash
|
||||
|
||||
@@ -3,17 +3,17 @@ name: Bun profile persistence
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/main/persistence/**'
|
||||
- 'src/main/sqlite/**'
|
||||
- 'src/main/worker-thread-entry-path.ts'
|
||||
- 'src/main/orcad/**'
|
||||
- 'src/main/daemon/pty-subprocess/**'
|
||||
- 'src/main/providers/**'
|
||||
- 'src/shared/**'
|
||||
- 'src/**'
|
||||
- 'config/**'
|
||||
- 'native/**'
|
||||
- 'tests/**'
|
||||
- 'resources/**'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'tsconfig.json'
|
||||
- '.npmrc'
|
||||
- '.pnpmfile.cjs'
|
||||
- '.github/actions/install-node-dependencies/**'
|
||||
- '.github/workflows/bun-profile-tests.yml'
|
||||
workflow_dispatch:
|
||||
@@ -26,7 +26,33 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
should_run: ${{ steps.scope.outputs.should_run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 2
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
- name: Detect Bun build and test inputs
|
||||
id: scope
|
||||
shell: bash
|
||||
run: |
|
||||
# Compare the tested merge with its base, retaining both sides of renames.
|
||||
if git diff --name-only --no-renames -z HEAD^1 HEAD > "$RUNNER_TEMP/bun-changes"; then
|
||||
node config/scripts/bun-profile-change-scope.mjs "$RUNNER_TEMP/bun-changes"
|
||||
else
|
||||
echo 'should_run=true' >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
persistence:
|
||||
needs: changes
|
||||
# Missing/failed detection runs the full matrix; manual runs remain unconditional.
|
||||
if: ${{ !cancelled() && needs.changes.outputs.should_run != 'false' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -56,6 +82,9 @@ jobs:
|
||||
node out/orcad/orcad.js --orcad-profile-state-preflight 00000000-0000-4000-8000-000000000018
|
||||
|
||||
linux_glibc_floor:
|
||||
needs: changes
|
||||
# Missing/failed detection runs the full matrix; manual runs remain unconditional.
|
||||
if: ${{ !cancelled() && needs.changes.outputs.should_run != 'false' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -78,6 +107,9 @@ jobs:
|
||||
- run: pnpm test:bun:profile --artifact
|
||||
|
||||
linux_musl:
|
||||
needs: changes
|
||||
# Missing/failed detection runs the full matrix; manual runs remain unconditional.
|
||||
if: ${{ !cancelled() && needs.changes.outputs.should_run != 'false' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
||||
@@ -6,7 +6,6 @@ on:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
paths:
|
||||
- 'mobile/**'
|
||||
# Mobile launch contracts exercise the real host dispatcher and durable receipt store.
|
||||
|
||||
@@ -6,7 +6,6 @@ on:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
|
||||
concurrency:
|
||||
group: pr-test-loc-${{ github.event.pull_request.number }}
|
||||
|
||||
+30
-14
@@ -1,4 +1,5 @@
|
||||
name: PR Checks
|
||||
run-name: 'PR ${{ github.event.pull_request.number }} | source ${{ github.sha }} | workflow ${{ github.workflow_sha }}'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -27,23 +28,28 @@ jobs:
|
||||
# Reuse one lightweight checkout for detection and the always-required guards.
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
outputs:
|
||||
should_run: ${{ steps.filter.outputs.should_run }}
|
||||
native_cache_changed: ${{ steps.filter.outputs.native_cache_changed }}
|
||||
reused_run_id: ${{ steps.readiness.outputs.run_id }}
|
||||
# A proven success masks required work only; advisory routing still uses the full diff.
|
||||
native_cache_changed: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.native_cache_changed }}
|
||||
mobile_dependencies: ${{ steps.filter.outputs.mobile_dependencies }}
|
||||
mobile_web_app: ${{ steps.filter.outputs.mobile_web_app }}
|
||||
static_analysis: ${{ steps.filter.outputs.static_analysis }}
|
||||
typecheck: ${{ steps.filter.outputs.typecheck }}
|
||||
git_compatibility: ${{ steps.filter.outputs.git_compatibility }}
|
||||
codex_index_heal_contract: ${{ steps.filter.outputs.codex_index_heal_contract }}
|
||||
xterm_patch_sync: ${{ steps.filter.outputs.xterm_patch_sync }}
|
||||
shell_contracts: ${{ steps.filter.outputs.shell_contracts }}
|
||||
test: ${{ steps.filter.outputs.test }}
|
||||
orcad_browser: ${{ steps.filter.outputs.orcad_browser }}
|
||||
cross-version-wire: ${{ steps.filter.outputs.cross-version-wire }}
|
||||
managed_hook_node18: ${{ steps.filter.outputs.managed_hook_node18 }}
|
||||
package: ${{ steps.filter.outputs.package }}
|
||||
package_windows: ${{ steps.filter.outputs.package_windows }}
|
||||
mobile_web_app: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.mobile_web_app }}
|
||||
static_analysis: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.static_analysis }}
|
||||
typecheck: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.typecheck }}
|
||||
git_compatibility: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.git_compatibility }}
|
||||
codex_index_heal_contract: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.codex_index_heal_contract }}
|
||||
xterm_patch_sync: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.xterm_patch_sync }}
|
||||
shell_contracts: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.shell_contracts }}
|
||||
test: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.test }}
|
||||
orcad_browser: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.orcad_browser }}
|
||||
cross-version-wire: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.cross-version-wire }}
|
||||
managed_hook_node18: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.managed_hook_node18 }}
|
||||
package: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.package }}
|
||||
package_windows: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.package_windows }}
|
||||
e2e_should_run: ${{ steps.e2e_filter.outputs.should_run }}
|
||||
test_files: ${{ steps.e2e_filter.outputs.test_files }}
|
||||
ssh_source_changed: ${{ steps.e2e_filter.outputs.ssh_source_changed }}
|
||||
@@ -66,6 +72,7 @@ jobs:
|
||||
/config/scripts/check-readme-local-links.mjs
|
||||
/config/scripts/pr-code-change-scope.mjs
|
||||
/config/scripts/pr-e2e-source-routing.mjs
|
||||
/config/scripts/pr-ready-check-reuse.mjs
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
|
||||
@@ -79,6 +86,15 @@ jobs:
|
||||
- name: Check README local links
|
||||
run: node config/scripts/check-readme-local-links.mjs
|
||||
|
||||
# Readiness changes eligibility for advisory tests, not the already-tested source.
|
||||
- name: Find identical successful required checks
|
||||
id: readiness
|
||||
if: github.event.action == 'ready_for_review'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_CHECK_WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||||
run: node config/scripts/pr-ready-check-reuse.mjs
|
||||
|
||||
- name: Classify changed paths
|
||||
id: filter
|
||||
env:
|
||||
|
||||
@@ -5,7 +5,8 @@ import { build } from 'esbuild'
|
||||
import {
|
||||
buildOrcadEntry,
|
||||
externalNativeAddons,
|
||||
ORCAD_EXTERNAL_MODULES
|
||||
ORCAD_EXTERNAL_MODULES,
|
||||
ORCAD_CHILD_ENTRY_POINTS
|
||||
} from './orcad-entry-build.mjs'
|
||||
import { createRequire } from 'node:module'
|
||||
import {
|
||||
@@ -44,14 +45,14 @@ const OUT_DIR = process.env.ORCAD_OUT_DIR
|
||||
// Why beside orcad.js: the watcher runs in a forked child so a native @parcel/watcher
|
||||
// fault crashes that child instead of the server, and `resolveWatcherProcessEntryPath`
|
||||
// looks for it in the app root. A deployment has no desktop out/main to fall back to.
|
||||
const WATCHER_ENTRY = join(ROOT, 'src/main/ipc/parcel-watcher-process-entry.ts')
|
||||
const WATCHER_ENTRY = join(ROOT, ORCAD_CHILD_ENTRY_POINTS.watcher)
|
||||
const WATCHER_OUT_FILE = join(OUT_DIR, 'parcel-watcher-process-entry.js')
|
||||
// Why beside orcad.js: orcad forks the terminal daemon so PTYs outlive the runtime process,
|
||||
// and `getDaemonEntryPath()` probes the app root for this exact filename. Without it every
|
||||
// orcad restart would SIGKILL every running terminal.
|
||||
const DAEMON_ENTRY = join(ROOT, 'src/main/daemon/daemon-entry.ts')
|
||||
const DAEMON_ENTRY = join(ROOT, ORCAD_CHILD_ENTRY_POINTS.daemon)
|
||||
const DAEMON_OUT_FILE = join(OUT_DIR, 'daemon-entry.js')
|
||||
const PTY_GATE_ENTRY = join(ROOT, 'src/main/daemon/pty-subprocess/windows-bun-pty-gate-entry.ts')
|
||||
const PTY_GATE_ENTRY = join(ROOT, ORCAD_CHILD_ENTRY_POINTS.ptyGate)
|
||||
const PTY_GATE_OUT_FILE = join(OUT_DIR, 'windows-bun-pty-gate-entry.js')
|
||||
const OUT_FILE = join(OUT_DIR, 'orcad.js')
|
||||
const BUILD_TARGET = process.env.ORCAD_BUILD_TARGET
|
||||
@@ -179,7 +180,7 @@ const childResults = await Promise.all([
|
||||
buildForkedChild(PTY_GATE_ENTRY, PTY_GATE_OUT_FILE),
|
||||
...['writer', 'backup'].map((role) =>
|
||||
buildForkedChild(
|
||||
join(ROOT, `src/main/persistence/profile-state/profile-state-${role}-worker-entry.ts`),
|
||||
join(ROOT, ORCAD_CHILD_ENTRY_POINTS[role]),
|
||||
join(OUT_DIR, `profile-state-${role}-worker-entry.js`)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { build } from 'esbuild'
|
||||
import { appendFileSync, globSync, readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import {
|
||||
externalNativeAddons,
|
||||
ORCAD_CHILD_ENTRY_POINTS,
|
||||
ORCAD_ENTRY_POINT
|
||||
} from './orcad-entry-build.mjs'
|
||||
import { bunProfileTestPaths } from './bun-profile-test-paths.mjs'
|
||||
|
||||
const ROOT = resolve(import.meta.dirname, '../..')
|
||||
const BUILD_SCRIPTS = [
|
||||
'config/scripts/build-orcad-bun.mjs',
|
||||
'config/scripts/build-orcad.mjs',
|
||||
'config/scripts/build-windows-process-tree-relay-addon.mjs',
|
||||
'config/scripts/run-bun-profile-tests.mjs',
|
||||
'config/vitest.config.ts',
|
||||
'config/scripts/happy-dom-offscreen-canvas.ts',
|
||||
'config/scripts/happy-dom-mutation-observer-retention.ts',
|
||||
'config/scripts/vitest-host-ports-setup.ts'
|
||||
]
|
||||
const ALWAYS_FILES = new Set([
|
||||
'package.json',
|
||||
'pnpm-lock.yaml',
|
||||
'pnpm-workspace.yaml',
|
||||
'.npmrc',
|
||||
'.pnpmfile.cjs',
|
||||
'tsconfig.json',
|
||||
'.github/workflows/bun-profile-tests.yml',
|
||||
'config/scripts/bun-profile-change-scope.mjs',
|
||||
'config/scripts/bun-profile-change-scope.test.mjs'
|
||||
])
|
||||
const ALWAYS_PREFIXES = [
|
||||
'.github/actions/install-node-dependencies/',
|
||||
// These areas also contain worker paths and fixtures opened without an import.
|
||||
'src/main/persistence/',
|
||||
'src/main/sqlite/',
|
||||
'src/main/orcad/',
|
||||
'src/main/daemon/pty-subprocess/',
|
||||
'src/main/providers/',
|
||||
'config/patches/',
|
||||
'config/tsconfig',
|
||||
'native/',
|
||||
'resources/licenses/ripgrep/'
|
||||
]
|
||||
|
||||
export function discoverBunProfileTests(root = ROOT) {
|
||||
const selectors = bunProfileTestPaths({ artifact: true })
|
||||
return globSync(
|
||||
['src/**/*.test.{ts,tsx}', 'config/scripts/**/*.test.{ts,mjs}', 'tests/e2e/**/*.unit.test.ts'],
|
||||
{ cwd: root }
|
||||
)
|
||||
.map((file) => file.replaceAll('\\', '/'))
|
||||
.filter((file) => selectors.some((selector) => file.includes(selector)))
|
||||
.sort()
|
||||
}
|
||||
|
||||
export async function collectBunProfileInputs({ root = ROOT, entryPoints } = {}) {
|
||||
const entries = entryPoints ?? [
|
||||
ORCAD_ENTRY_POINT,
|
||||
...Object.values(ORCAD_CHILD_ENTRY_POINTS),
|
||||
...BUILD_SCRIPTS,
|
||||
...discoverBunProfileTests(root)
|
||||
]
|
||||
const result = await build({
|
||||
absWorkingDir: root,
|
||||
entryPoints: entries,
|
||||
bundle: true,
|
||||
write: false,
|
||||
outdir: resolve(root, '.bun-profile-scope'),
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
splitting: true,
|
||||
packages: 'external',
|
||||
loader: { '.svg': 'empty', '.png': 'empty', '.webp': 'empty', '.css': 'empty' },
|
||||
plugins: [externalNativeAddons],
|
||||
metafile: true,
|
||||
logLevel: 'silent'
|
||||
})
|
||||
if (result.warnings.length > 0) {
|
||||
throw new Error(result.warnings.map((warning) => warning.text).join('\n'))
|
||||
}
|
||||
return new Set(
|
||||
Object.keys(result.metafile.inputs).map((file) =>
|
||||
file.replaceAll('\\', '/').replace(/\?.*$/, '')
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export async function classifyBunProfileChanges(changedFiles, collect = collectBunProfileInputs) {
|
||||
if (changedFiles.length === 0) {
|
||||
return { shouldRun: true, reason: 'No complete changed-file evidence' }
|
||||
}
|
||||
const selectors = bunProfileTestPaths({ artifact: true })
|
||||
const forced = changedFiles.find(
|
||||
(file) =>
|
||||
ALWAYS_FILES.has(file) ||
|
||||
ALWAYS_PREFIXES.some((prefix) => file.startsWith(prefix)) ||
|
||||
selectors.some((selector) => file.includes(selector))
|
||||
)
|
||||
if (forced) {
|
||||
return { shouldRun: true, reason: `Build or CI input changed: ${forced}` }
|
||||
}
|
||||
try {
|
||||
const inputs = await collect()
|
||||
const matched = changedFiles.find((file) => inputs.has(file))
|
||||
return {
|
||||
shouldRun: Boolean(matched),
|
||||
reason: matched ? `Runtime or test dependency changed: ${matched}` : 'No Bun inputs changed'
|
||||
}
|
||||
} catch (error) {
|
||||
return { shouldRun: true, reason: `Dependency graph unavailable: ${String(error)}` }
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const changedFiles = readFileSync(process.argv[2], 'utf8').split('\0').filter(Boolean)
|
||||
const result = await classifyBunProfileChanges(changedFiles)
|
||||
console.log(result.reason)
|
||||
const output = `should_run=${String(result.shouldRun)}\n`
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, output)
|
||||
} else {
|
||||
process.stdout.write(output)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
import {
|
||||
classifyBunProfileChanges,
|
||||
collectBunProfileInputs,
|
||||
discoverBunProfileTests
|
||||
} from './bun-profile-change-scope.mjs'
|
||||
import { bunProfileTestPaths } from './bun-profile-test-paths.mjs'
|
||||
import { ORCAD_CHILD_ENTRY_POINTS } from './orcad-entry-build.mjs'
|
||||
|
||||
const temporaryDirs = []
|
||||
afterEach(() => {
|
||||
for (const root of temporaryDirs.splice(0)) {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
function moduleTree(files) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bun-profile-scope-'))
|
||||
temporaryDirs.push(root)
|
||||
for (const [file, source] of Object.entries(files)) {
|
||||
const path = join(root, file)
|
||||
mkdirSync(join(path, '..'), { recursive: true })
|
||||
writeFileSync(path, source)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
it('follows static imports, re-exports, dynamic imports and require without executing source', async () => {
|
||||
const root = moduleTree({
|
||||
'entry.ts': `import './first'; export * from './exports'; import('./dynamic'); require('./required'); throw Error('never execute')`,
|
||||
'first.ts': `import './nested/leaf'`,
|
||||
'exports.ts': 'export const value = 1',
|
||||
'dynamic.ts': 'export const value = 2',
|
||||
'required.ts': 'module.exports = 3',
|
||||
'nested/leaf.ts': 'export const value = 4',
|
||||
'unrelated.ts': 'throw Error("unrelated")'
|
||||
})
|
||||
const inputs = await collectBunProfileInputs({ root, entryPoints: ['entry.ts'] })
|
||||
expect([...inputs].sort()).toEqual([
|
||||
'dynamic.ts',
|
||||
'entry.ts',
|
||||
'exports.ts',
|
||||
'first.ts',
|
||||
'nested/leaf.ts',
|
||||
'required.ts'
|
||||
])
|
||||
})
|
||||
|
||||
it('runs the matrix when a dependency is deleted or graph analysis fails', async () => {
|
||||
const root = moduleTree({ 'entry.ts': `import './deleted'` })
|
||||
const result = await classifyBunProfileChanges(['deleted.ts'], () =>
|
||||
collectBunProfileInputs({ root, entryPoints: ['entry.ts'] })
|
||||
)
|
||||
expect(result.shouldRun).toBe(true)
|
||||
expect(result.reason).toContain('Dependency graph unavailable')
|
||||
expect((await classifyBunProfileChanges([])).shouldRun).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['tests/e2e/daemon-running-work-probe.unit.test.ts'],
|
||||
['config/scripts/zip-extractor-command.test.mjs'],
|
||||
['config/scripts/zip-extractor-command.test.mjs', 'config/scripts/renamed-command.test.mjs']
|
||||
])(
|
||||
'runs deleted or renamed selected tests even when absent from the graph: %j',
|
||||
async (...files) => {
|
||||
expect((await classifyBunProfileChanges(files, async () => new Set())).shouldRun).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
'package.json',
|
||||
'pnpm-lock.yaml',
|
||||
'pnpm-workspace.yaml',
|
||||
'.npmrc',
|
||||
'tsconfig.json',
|
||||
'config/tsconfig.node.json',
|
||||
'config/patches/node-pty@1.1.0.patch',
|
||||
'native/windows-registry/src/addon.cc',
|
||||
'.github/actions/install-node-dependencies/action.yml',
|
||||
'.github/workflows/bun-profile-tests.yml',
|
||||
'src/main/persistence/profile-state/new-worker.ts'
|
||||
])('always selects build, native and dynamically opened inputs: %s', async (file) => {
|
||||
expect((await classifyBunProfileChanges([file], async () => new Set())).shouldRun).toBe(true)
|
||||
})
|
||||
|
||||
describe('the actual Bun build and profile-test dependency graph', () => {
|
||||
let inputs
|
||||
beforeAll(async () => {
|
||||
inputs = await collectBunProfileInputs()
|
||||
}, 60_000)
|
||||
|
||||
it.each([
|
||||
'config/scripts/ci-shard-timings.json',
|
||||
'config/scripts/mobile-web-app-terminal-render.test.mjs',
|
||||
'src/main/ssh/ssh-relay-upload-stage-commands.test.ts',
|
||||
'src/main/menu/register-app-menu.ts'
|
||||
])('skips unrelated work: %s', async (file) => {
|
||||
expect((await classifyBunProfileChanges([file], async () => inputs)).shouldRun).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
...Object.values(ORCAD_CHILD_ENTRY_POINTS),
|
||||
'src/shared/keybindings/definitions-core-1.ts',
|
||||
'src/main/runtime/orca-runtime.ts',
|
||||
'src/main/windows/windows-process-table.ts',
|
||||
'src/main/worker-thread-entry-path.ts',
|
||||
'config/scripts/zip-extractor-command.mjs',
|
||||
'config/scripts/windows-process-tree-gyp-rebuild.mjs',
|
||||
'config/scripts/profile-state-worker-smoke.mjs',
|
||||
'config/scripts/vitest-host-ports-setup.ts',
|
||||
'tests/e2e/daemon-running-work-probe.unit.test.ts'
|
||||
])('retains the full matrix for a real runtime, worker or test input: %s', async (file) => {
|
||||
expect(inputs.has(file)).toBe(true)
|
||||
expect((await classifyBunProfileChanges([file], async () => inputs)).shouldRun).toBe(true)
|
||||
})
|
||||
|
||||
it('retains all selected tests and uses the same selectors as the Bun runner', () => {
|
||||
const tests = discoverBunProfileTests()
|
||||
expect(tests.length).toBeGreaterThan(80)
|
||||
expect(tests.every((file) => inputs.has(file))).toBe(true)
|
||||
expect(
|
||||
bunProfileTestPaths().every((selector) => tests.some((file) => file.includes(selector)))
|
||||
).toBe(true)
|
||||
const runner = readFileSync(new URL('./run-bun-profile-tests.mjs', import.meta.url), 'utf8')
|
||||
expect(runner).toContain('testArgs.length > 0 ? testArgs : bunProfileTestPaths({ artifact })')
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps all ten platform jobs and runs them when detection is skipped or fails', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(new URL('../../.github/workflows/bun-profile-tests.yml', import.meta.url), 'utf8')
|
||||
)
|
||||
expect(workflow.on).toHaveProperty('workflow_dispatch')
|
||||
expect(workflow.jobs.changes.if).toBe("github.event_name == 'pull_request'")
|
||||
expect(workflow.jobs.changes.steps[0].with['fetch-depth']).toBe(2)
|
||||
expect(workflow.jobs.changes.steps[0].with['persist-credentials']).toBe(false)
|
||||
const detect = workflow.jobs.changes.steps.find((step) => step.id === 'scope')
|
||||
expect(detect.run).toContain('git diff --name-only --no-renames -z HEAD^1 HEAD')
|
||||
let count = 0
|
||||
for (const jobName of ['persistence', 'linux_glibc_floor', 'linux_musl']) {
|
||||
const job = workflow.jobs[jobName]
|
||||
expect(job.needs).toBe('changes')
|
||||
expect(job.if).toBe("${{ !cancelled() && needs.changes.outputs.should_run != 'false' }}")
|
||||
count += job.strategy.matrix.os.length
|
||||
}
|
||||
expect(count).toBe(10)
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
export function bunProfileTestPaths({ artifact = false } = {}) {
|
||||
return [
|
||||
'src/main/persistence/profile-state',
|
||||
'src/main/persistence/loading-store/profile-state',
|
||||
'src/main/sqlite',
|
||||
'src/main/orcad/orcad-entry.test.ts',
|
||||
'src/main/orcad/orcad-push-startup.test.ts',
|
||||
...(artifact
|
||||
? [
|
||||
'src/main/daemon/pty-subprocess/bun-pty-process.integration.test.ts',
|
||||
'src/main/daemon/pty-subprocess/bun-pty-job-control.integration.test.ts',
|
||||
'src/main/daemon/pty-subprocess/bun-pty-process-suspension.test.ts',
|
||||
'src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts',
|
||||
'src/main/daemon/pty-subprocess/spawn-file-foreground-rejected-agents.test.ts',
|
||||
'tests/e2e/daemon-running-work-probe.unit.test.ts',
|
||||
'src/main/daemon/pty-subprocess/windows-bun-pty-gate.integration.test.ts',
|
||||
'src/main/providers/local-pty-bun-artifact.integration.test.ts',
|
||||
'src/main/providers/agent-foreground-process-git-bash.win32.test.ts',
|
||||
'src/main/orcad/orcad-bun-launcher.integration.test.ts',
|
||||
'config/scripts/zip-extractor-command.test.mjs'
|
||||
]
|
||||
: [])
|
||||
]
|
||||
}
|
||||
@@ -10,8 +10,9 @@ describe('CI dependency download caches', () => {
|
||||
it('scopes desktop stores to the root lockfile and lets mixed installs opt in', () => {
|
||||
expect(action.inputs['cache-dependency-path'].default).toBe('pnpm-lock.yaml')
|
||||
for (const step of action.runs.steps.filter((step) => step.uses === 'actions/setup-node@v6')) {
|
||||
expect(step.with.cache).toBe('pnpm')
|
||||
expect(step.with.cache).toBe("${{ github.event_name != 'pull_request' && 'pnpm' || '' }}")
|
||||
expect(step.with['cache-dependency-path']).toBe('${{ inputs.cache-dependency-path }}')
|
||||
expect(step.with['package-manager-cache']).toBe(false)
|
||||
}
|
||||
const install = action.runs.steps.find((step) => step.name === 'Install dependencies')
|
||||
expect(install.if).toBeUndefined()
|
||||
@@ -27,6 +28,28 @@ describe('CI dependency download caches', () => {
|
||||
'mobile/pnpm-lock.yaml'
|
||||
])
|
||||
})
|
||||
|
||||
it('restores PR stores with setup-node keys without registering a post-job save', () => {
|
||||
const resolve = action.runs.steps.find((step) => step.id === 'pnpm-store')
|
||||
const restore = action.runs.steps.find(
|
||||
(step) => step.name === 'Restore pnpm download store without saving'
|
||||
)
|
||||
expect(resolve.if).toBe("github.event_name == 'pull_request'")
|
||||
expect(restore.if).toBe(resolve.if)
|
||||
expect(restore.uses).toBe('actions/cache/restore@v5')
|
||||
expect(restore.with.path).toBe('${{ steps.pnpm-store.outputs.path }}')
|
||||
expect(restore.with.key).toBe(
|
||||
'node-cache-${{ runner.os }}-${{ steps.pnpm-store.outputs.arch }}-pnpm-${{ hashFiles(inputs.cache-dependency-path) }}'
|
||||
)
|
||||
expect(restore.with['restore-keys']).toBeUndefined()
|
||||
expect(resolve.env.LOCKFILE_HASH).toBe('${{ hashFiles(inputs.cache-dependency-path) }}')
|
||||
expect(action.runs.steps.indexOf(resolve)).toBeLessThan(action.runs.steps.indexOf(restore))
|
||||
expect(action.runs.steps.indexOf(restore)).toBeLessThan(
|
||||
action.runs.steps.findIndex((step) => step.name === 'Install dependencies')
|
||||
)
|
||||
const saves = action.runs.steps.filter((step) => step.uses === 'actions/cache/save@v5')
|
||||
expect(saves).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('release install targets', () => {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
cpSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs'
|
||||
import { runProcessSync } from './script-child-process.mjs'
|
||||
|
||||
const { values } = parseArgs({
|
||||
options: { samples: { type: 'string', default: '3' }, output: { type: 'string' } }
|
||||
})
|
||||
const samples = Number(values.samples)
|
||||
assert(Number.isInteger(samples) && samples > 0 && samples <= 10, '--samples must be 1–10')
|
||||
const repository = resolve(import.meta.dirname, '../..')
|
||||
const temporary = mkdtempSync(join(tmpdir(), 'orca-ci-pnpm-store-'))
|
||||
const checkout = join(temporary, 'checkout')
|
||||
const store = join(temporary, 'store')
|
||||
const archive = join(temporary, 'store.tar.zst')
|
||||
const uncompressed = join(temporary, 'store.tar')
|
||||
const manifests = ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml']
|
||||
const pnpm = resolvePnpmCliInvocation()
|
||||
const results = []
|
||||
|
||||
function command(program, args, cwd = checkout) {
|
||||
const start = performance.now()
|
||||
const result = runProcessSync({
|
||||
program,
|
||||
args,
|
||||
cwd,
|
||||
env: { ...process.env, CI: 'true', ORCA_BACKGROUND_LAUNCH: '1' },
|
||||
timeoutMs: 300_000
|
||||
})
|
||||
assert.equal(result.code, 0, `${program}: ${result.stdout}\n${result.stderr}`)
|
||||
return { milliseconds: performance.now() - start, stdout: result.stdout.trim() }
|
||||
}
|
||||
|
||||
function pnpmCommand(args) {
|
||||
return command(pnpm.command, [...pnpm.prefixArgs, ...args])
|
||||
}
|
||||
|
||||
function digest() {
|
||||
const hash = createHash('sha256')
|
||||
for (const file of manifests) {
|
||||
hash.update(readFileSync(join(checkout, file)))
|
||||
}
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
function install() {
|
||||
return pnpmCommand(['install', '--frozen-lockfile', '--ignore-scripts', '--store-dir', store])
|
||||
.milliseconds
|
||||
}
|
||||
|
||||
function transferArchive(restore) {
|
||||
const start = performance.now()
|
||||
// Windows BSD tar needs a separate zstd process, as in the Actions cache toolkit.
|
||||
if (process.platform === 'win32') {
|
||||
if (restore) {
|
||||
command('zstd', ['-d', '-f', 'store.tar.zst', '-o', 'store.tar'], temporary)
|
||||
command('tar', ['-xf', 'store.tar', '-C', store], temporary)
|
||||
} else {
|
||||
command('tar', ['--format=posix', '-cf', 'store.tar', '-C', store, '.'], temporary)
|
||||
command('zstd', ['-T0', '-f', 'store.tar', '-o', 'store.tar.zst'], temporary)
|
||||
}
|
||||
rmSync(uncompressed)
|
||||
} else {
|
||||
command(
|
||||
'tar',
|
||||
restore
|
||||
? ['-xf', archive, '--use-compress-program', 'zstd -d', '-C', store]
|
||||
: ['--format=posix', '-cf', archive, '--use-compress-program', 'zstd -T0', '-C', store, '.']
|
||||
)
|
||||
}
|
||||
return performance.now() - start
|
||||
}
|
||||
|
||||
try {
|
||||
mkdirSync(checkout)
|
||||
for (const file of [...manifests, 'native/windows-registry/package.json', 'config/patches']) {
|
||||
const target = join(checkout, file)
|
||||
mkdirSync(resolve(target, '..'), { recursive: true })
|
||||
cpSync(join(repository, file), target, { recursive: true })
|
||||
}
|
||||
const sourceDigest = digest()
|
||||
const pnpmVersion = pnpmCommand(['--version']).stdout
|
||||
const zstdVersion = command('zstd', ['--version']).stdout
|
||||
const tarVersion = command('tar', ['--version']).stdout
|
||||
|
||||
for (let sample = 0; sample < samples; sample++) {
|
||||
const policies = sample % 2 === 0 ? ['save', 'restore-only'] : ['restore-only', 'save']
|
||||
for (const policy of policies) {
|
||||
rmSync(join(checkout, 'node_modules'), { recursive: true, force: true })
|
||||
rmSync(store, { recursive: true, force: true })
|
||||
const installMs = install()
|
||||
assert.equal(digest(), sourceDigest, 'frozen install changed a manifest')
|
||||
let archiveMs = 0
|
||||
let archiveBytes = 0
|
||||
if (policy === 'save') {
|
||||
archiveMs = transferArchive(false)
|
||||
archiveBytes = statSync(archive).size
|
||||
}
|
||||
const result = {
|
||||
sample,
|
||||
policy,
|
||||
installMs,
|
||||
archiveMs,
|
||||
archiveBytes,
|
||||
totalMs: installMs + archiveMs
|
||||
}
|
||||
results.push(result)
|
||||
console.error(JSON.stringify(result))
|
||||
}
|
||||
}
|
||||
|
||||
// Both policies restore identical bytes on a hit; measure that common cost separately.
|
||||
rmSync(join(checkout, 'node_modules'), { recursive: true, force: true })
|
||||
rmSync(store, { recursive: true, force: true })
|
||||
mkdirSync(store)
|
||||
const restoreMs = transferArchive(true)
|
||||
const hitInstallMs = install()
|
||||
assert.equal(digest(), sourceDigest)
|
||||
const report = {
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
node: process.version,
|
||||
pnpm: pnpmVersion,
|
||||
tar: tarVersion,
|
||||
zstd: zstdVersion,
|
||||
sourceDigest,
|
||||
results,
|
||||
hit: { restoreMs, installMs: hitInstallMs, totalMs: restoreMs + hitInstallMs },
|
||||
scope:
|
||||
'Desktop script-free frozen install, fresh isolated store per miss; alternating policy order.',
|
||||
limit:
|
||||
'Measures local install/archive CPU and disk. Excludes GitHub cache transfer and remote service time.'
|
||||
}
|
||||
const json = `${JSON.stringify(report, null, 2)}\n`
|
||||
if (values.output) {
|
||||
writeFileSync(resolve(values.output), json)
|
||||
}
|
||||
console.log(json)
|
||||
} finally {
|
||||
rmSync(temporary, { recursive: true, force: true })
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
const action = parse(readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8'))
|
||||
const installScript = action.runs.steps.find((step) => step.name === 'Install dependencies').run
|
||||
const storeScript = action.runs.steps.find((step) => step.id === 'pnpm-store').run
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return spawnSync(command, args, { encoding: 'utf8', ...options })
|
||||
@@ -35,7 +36,10 @@ function createFixture() {
|
||||
).toBe(0)
|
||||
|
||||
const pnpm = join(bin, 'pnpm')
|
||||
writeFileSync(pnpm, '#!/bin/sh\nexit 0\n')
|
||||
writeFileSync(
|
||||
pnpm,
|
||||
'#!/bin/sh\nif [ "$1" = store ]; then printf "%s\\n" "$PNPM_TEST_STORE_PATH"; fi\nexit 0\n'
|
||||
)
|
||||
chmodSync(pnpm, 0o755)
|
||||
return { bin, detachedCwd, root, workspace }
|
||||
}
|
||||
@@ -52,6 +56,50 @@ function executeInstallScript(fixture) {
|
||||
}
|
||||
|
||||
describe('install-node-dependencies action', () => {
|
||||
it.each(['/home/runner/pnpm store/v11', 'C:\\Users\\runner\\pnpm store\\v11'])(
|
||||
'preserves setup-node store path %s and lowercase architecture',
|
||||
(storePath) => {
|
||||
const fixture = createFixture()
|
||||
const output = join(fixture.root, 'github-output')
|
||||
try {
|
||||
const result = run('bash', ['-e', '-o', 'pipefail', '-c', storeScript], {
|
||||
env: {
|
||||
...process.env,
|
||||
GITHUB_OUTPUT: output,
|
||||
LOCKFILE_HASH: 'lockfile-digest',
|
||||
PNPM_TEST_STORE_PATH: storePath,
|
||||
PATH: `${fixture.bin}${delimiter}${process.env.PATH}`
|
||||
}
|
||||
})
|
||||
expect(result.status, result.stderr || result.stdout).toBe(0)
|
||||
expect(readFileSync(output, 'utf8')).toBe(`path=${storePath}\narch=${process.arch}\n`)
|
||||
} finally {
|
||||
rmSync(fixture.root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['', 'store'],
|
||||
['lockfile-digest', '']
|
||||
])('rejects a missing lockfile hash or store path (%s, %s)', (hash, storePath) => {
|
||||
const fixture = createFixture()
|
||||
try {
|
||||
const result = run('bash', ['-e', '-o', 'pipefail', '-c', storeScript], {
|
||||
env: {
|
||||
...process.env,
|
||||
GITHUB_OUTPUT: join(fixture.root, 'github-output'),
|
||||
LOCKFILE_HASH: hash,
|
||||
PNPM_TEST_STORE_PATH: storePath,
|
||||
PATH: `${fixture.bin}${delimiter}${process.env.PATH}`
|
||||
}
|
||||
})
|
||||
expect(result.status).toBe(1)
|
||||
} finally {
|
||||
rmSync(fixture.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('skips the lockfile diff when a job container has no Git metadata', () => {
|
||||
const fixture = createFixture()
|
||||
try {
|
||||
|
||||
@@ -116,8 +116,12 @@ it('leaves the switch out of every other workflow, so only a release can set it'
|
||||
const MOBILE_WORKFLOWS = ['mobile.yml', 'mobile-android-release.yml', 'mobile-ios-release.yml']
|
||||
/** Paths under which a Metro or Expo build cache lives, in the spellings a workflow would use. */
|
||||
const BUNDLER_CACHE_PATHS = ['metro-cache', '.expo', 'node_modules/.cache']
|
||||
/** The one restored path these workflows compute in a script, and so this test cannot read. */
|
||||
const REVIEWED_COMPUTED_PATH = '${{ steps.electron-package-cache.outputs.cache-root }}'
|
||||
/** Store/archive paths computed by scripts rather than declared in the workflows. */
|
||||
const REVIEWED_COMPUTED_PATHS = [
|
||||
'${{ steps.electron-package-cache.outputs.cache-root }}',
|
||||
'${{ steps.pnpm-store.outputs.path }}',
|
||||
"${{ github.event_name != 'pull_request' && 'pnpm' || '' }} store"
|
||||
]
|
||||
|
||||
/** Every step a workflow runs, descending into the repository's own composite actions. */
|
||||
function stepsIncludingComposites(file) {
|
||||
@@ -175,7 +179,7 @@ describe('what the mobile jobs restore from cache', () => {
|
||||
it('reads every restored path, rather than passing one it cannot evaluate', () => {
|
||||
const computed = MOBILE_CACHE_RESTORES.filter(({ paths }) => paths.includes('${{'))
|
||||
|
||||
expect(computed.map(({ paths }) => paths)).toEqual(computed.map(() => REVIEWED_COMPUTED_PATH))
|
||||
expect(computed.filter(({ paths }) => !REVIEWED_COMPUTED_PATHS.includes(paths))).toEqual([])
|
||||
})
|
||||
|
||||
it('restores no Metro or Expo build cache, which would decide the shell before the env does', () => {
|
||||
|
||||
@@ -3,6 +3,15 @@ import { join } from 'node:path'
|
||||
|
||||
const root = join(import.meta.dirname, '..', '..')
|
||||
|
||||
export const ORCAD_ENTRY_POINT = 'src/main/orcad/main.ts'
|
||||
export const ORCAD_CHILD_ENTRY_POINTS = {
|
||||
watcher: 'src/main/ipc/parcel-watcher-process-entry.ts',
|
||||
daemon: 'src/main/daemon/daemon-entry.ts',
|
||||
ptyGate: 'src/main/daemon/pty-subprocess/windows-bun-pty-gate-entry.ts',
|
||||
writer: 'src/main/persistence/profile-state/profile-state-writer-worker-entry.ts',
|
||||
backup: 'src/main/persistence/profile-state/profile-state-backup-worker-entry.ts'
|
||||
}
|
||||
|
||||
export const ORCAD_EXTERNAL_MODULES = [
|
||||
'electron',
|
||||
'node-pty',
|
||||
@@ -32,7 +41,7 @@ const jsoncParserEsm = {
|
||||
|
||||
export function buildOrcadEntry(outfile) {
|
||||
return build({
|
||||
entryPoints: [join(root, 'src/main/orcad/main.ts')],
|
||||
entryPoints: [join(root, ORCAD_ENTRY_POINT)],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
|
||||
@@ -517,9 +517,12 @@ describe('PR Checks skip wiring', () => {
|
||||
expect(classify.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"')
|
||||
expect(classify.run).toContain('node config/scripts/pr-code-change-scope.mjs')
|
||||
expect(classify.run).toContain('tee -a "$GITHUB_OUTPUT"')
|
||||
for (const jobName of ['should_run', 'native_cache_changed', ...expensiveJobs]) {
|
||||
expect(prWorkflow.jobs.code_paths.outputs.should_run).toBe(
|
||||
'${{ steps.filter.outputs.should_run }}'
|
||||
)
|
||||
for (const jobName of ['native_cache_changed', ...expensiveJobs]) {
|
||||
expect(prWorkflow.jobs.code_paths.outputs[jobName], jobName).toBe(
|
||||
`\${{ steps.filter.outputs.${jobName} }}`
|
||||
`\${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.${jobName} }}`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
import { runProcess } from '../../src/shared/child-process/run-process'
|
||||
import { PR_CHECK_JOBS } from './pr-code-change-scope.mjs'
|
||||
|
||||
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
|
||||
const gate = workflow.jobs.verify.steps.find((step) => step.name === 'Require successful checks')
|
||||
const variable = (job) => job.replaceAll('-', '_').toUpperCase()
|
||||
|
||||
function requiredResults(shouldRun) {
|
||||
return Object.fromEntries(
|
||||
PR_CHECK_JOBS.flatMap((job) => [
|
||||
[variable(job), shouldRun ? 'success' : 'skipped'],
|
||||
[`${variable(job)}_SHOULD_RUN`, String(shouldRun)]
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
async function verify(results) {
|
||||
return runProcess({
|
||||
program: 'bash',
|
||||
args: ['-c', gate.run],
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_BACKGROUND_LAUNCH: '1',
|
||||
CODE_PATHS: 'success',
|
||||
SHOULD_RUN: 'true',
|
||||
...results
|
||||
},
|
||||
timeoutMs: 10_000
|
||||
})
|
||||
}
|
||||
|
||||
// The aggregate runs as Bash on Linux; Windows has no required Bash installation.
|
||||
describe.skipIf(process.platform === 'win32')(
|
||||
'readiness reuse through the real aggregate gate',
|
||||
() => {
|
||||
it('accepts completed required checks or skips authorized by exact-source evidence', async () => {
|
||||
expect((await verify(requiredResults(true))).code).toBe(0)
|
||||
expect((await verify(requiredResults(false))).code).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects every missing, failed or cancelled required result when reuse is unavailable', async () => {
|
||||
for (const job of PR_CHECK_JOBS) {
|
||||
for (const result of ['', 'skipped', 'failure', 'cancelled']) {
|
||||
const verdict = await verify({ ...requiredResults(true), [variable(job)]: result })
|
||||
expect(verdict.code, `${job}: ${result}`).toBe(1)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('requires the detector to succeed even when every downstream job is skipped', async () => {
|
||||
for (const result of ['', 'skipped', 'failure', 'cancelled']) {
|
||||
expect((await verify({ ...requiredResults(false), CODE_PATHS: result })).code).toBe(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects unexpected downstream execution when the proven plan requires skips', async () => {
|
||||
for (const job of PR_CHECK_JOBS) {
|
||||
const verdict = await verify({ ...requiredResults(false), [variable(job)]: 'success' })
|
||||
expect(verdict.code, job).toBe(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,99 @@
|
||||
import { appendFileSync, readFileSync } from 'node:fs'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
export function prCheckRunTitle({ number, sourceSha, workflowSha }) {
|
||||
return `PR ${number} | source ${sourceSha} | workflow ${workflowSha}`
|
||||
}
|
||||
|
||||
export function reusablePrCheckRun(runs, identity) {
|
||||
if (
|
||||
!Number.isSafeInteger(identity.number) ||
|
||||
identity.number < 1 ||
|
||||
![identity.sourceSha, identity.workflowSha, identity.headSha].every((sha) =>
|
||||
/^[a-f0-9]{40}$/.test(sha ?? '')
|
||||
)
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return runs.find(
|
||||
(run) =>
|
||||
Number.isSafeInteger(run.id) &&
|
||||
run.id > 0 &&
|
||||
String(run.id) !== identity.runId &&
|
||||
run.path === '.github/workflows/pr.yml' &&
|
||||
run.event === 'pull_request' &&
|
||||
run.status === 'completed' &&
|
||||
run.conclusion === 'success' &&
|
||||
run.head_sha === identity.headSha &&
|
||||
run.display_title === prCheckRunTitle(identity)
|
||||
)
|
||||
}
|
||||
|
||||
export async function lookupReadyCheckRun(env, event, request = fetch) {
|
||||
if (
|
||||
env.GITHUB_EVENT_NAME !== 'pull_request' ||
|
||||
event.action !== 'ready_for_review' ||
|
||||
event.pull_request?.draft !== false
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
const identity = {
|
||||
number: event.pull_request?.number,
|
||||
headSha: event.pull_request?.head?.sha,
|
||||
sourceSha: env.GITHUB_SHA,
|
||||
workflowSha: env.PR_CHECK_WORKFLOW_SHA,
|
||||
runId: env.GITHUB_RUN_ID
|
||||
}
|
||||
const repository = env.GITHUB_REPOSITORY
|
||||
if (!/^[\w.-]+\/[\w.-]+$/.test(repository ?? '') || !env.GH_TOKEN) {
|
||||
return undefined
|
||||
}
|
||||
const url = new URL(
|
||||
`/repos/${repository}/actions/workflows/pr.yml/runs`,
|
||||
env.GITHUB_API_URL ?? 'https://api.github.com'
|
||||
)
|
||||
url.search = new URLSearchParams({
|
||||
event: 'pull_request',
|
||||
head_sha: identity.headSha ?? '',
|
||||
status: 'success',
|
||||
per_page: '20'
|
||||
}).toString()
|
||||
try {
|
||||
const response = await request(url, {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
Authorization: `Bearer ${env.GH_TOKEN}`,
|
||||
'User-Agent': 'orca-pr-ready-check-reuse',
|
||||
'X-GitHub-Api-Version': '2022-11-28'
|
||||
},
|
||||
signal: AbortSignal.timeout(10_000)
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`Run lookup returned HTTP ${response.status}`)
|
||||
}
|
||||
const result = await response.json()
|
||||
if (!Array.isArray(result.workflow_runs)) {
|
||||
throw new Error('Run lookup omitted workflow runs')
|
||||
}
|
||||
return reusablePrCheckRun(result.workflow_runs, identity)
|
||||
} catch (error) {
|
||||
console.log(`No reusable result: ${error.message}; running the required checks.`)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
let run
|
||||
try {
|
||||
const event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8'))
|
||||
run = await lookupReadyCheckRun(process.env, event)
|
||||
} catch (error) {
|
||||
console.log(`No reusable result: ${error.message}; running the required checks.`)
|
||||
}
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `reused=${Boolean(run)}\nrun_id=${run?.id ?? ''}\n`)
|
||||
console.log(
|
||||
run
|
||||
? `Required checks already passed for this source and workflow: ${run.html_url}`
|
||||
: 'No identical successful run found; running the required checks.'
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
import { PR_CHECK_JOBS } from './pr-code-change-scope.mjs'
|
||||
import {
|
||||
lookupReadyCheckRun,
|
||||
prCheckRunTitle,
|
||||
reusablePrCheckRun
|
||||
} from './pr-ready-check-reuse.mjs'
|
||||
|
||||
const identity = {
|
||||
number: 42,
|
||||
sourceSha: 'a'.repeat(40),
|
||||
workflowSha: 'b'.repeat(40),
|
||||
headSha: 'c'.repeat(40),
|
||||
runId: '456'
|
||||
}
|
||||
const passed = {
|
||||
id: 123,
|
||||
path: '.github/workflows/pr.yml',
|
||||
event: 'pull_request',
|
||||
status: 'completed',
|
||||
conclusion: 'success',
|
||||
head_sha: identity.headSha,
|
||||
display_title: prCheckRunTitle(identity)
|
||||
}
|
||||
const event = {
|
||||
action: 'ready_for_review',
|
||||
pull_request: { number: 42, draft: false, head: { sha: identity.headSha } }
|
||||
}
|
||||
const env = {
|
||||
GITHUB_EVENT_NAME: 'pull_request',
|
||||
GITHUB_SHA: identity.sourceSha,
|
||||
PR_CHECK_WORKFLOW_SHA: identity.workflowSha,
|
||||
GITHUB_RUN_ID: identity.runId,
|
||||
GITHUB_REPOSITORY: 'stablyai/orca',
|
||||
GH_TOKEN: 'read-only-test-token'
|
||||
}
|
||||
const response = (runs) => ({ ok: true, json: async () => ({ workflow_runs: runs }) })
|
||||
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('ready-for-review required check reuse', () => {
|
||||
it('reuses a completed success only for the identical PR, merge source and workflow', () => {
|
||||
expect(reusablePrCheckRun([passed], identity)).toBe(passed)
|
||||
expect(reusablePrCheckRun([], identity)).toBeUndefined()
|
||||
for (const key of ['number', 'sourceSha', 'workflowSha', 'headSha', 'runId']) {
|
||||
const changed = key === 'number' ? 43 : key === 'runId' ? '123' : 'd'.repeat(40)
|
||||
expect(reusablePrCheckRun([passed], { ...identity, [key]: changed }), key).toBeUndefined()
|
||||
}
|
||||
for (const key of ['number', 'sourceSha', 'workflowSha', 'headSha']) {
|
||||
expect(reusablePrCheckRun([passed], { ...identity, [key]: undefined }), key).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ status: 'in_progress' },
|
||||
{ status: 'queued' },
|
||||
{ conclusion: 'failure' },
|
||||
{ conclusion: 'cancelled' },
|
||||
{ conclusion: 'skipped' },
|
||||
{ event: 'push' },
|
||||
{ path: '.github/workflows/other.yml' },
|
||||
{ display_title: 'Previous workflow without source provenance' },
|
||||
{ id: -1 }
|
||||
])('rejects unusable evidence %j', (change) => {
|
||||
expect(reusablePrCheckRun([{ ...passed, ...change }], identity)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('bounds the read-only lookup to this workflow and PR head', async () => {
|
||||
const request = vi.fn(async () => response([passed]))
|
||||
expect(await lookupReadyCheckRun(env, event, request)).toBe(passed)
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
const [url, options] = request.mock.calls[0]
|
||||
expect(url.origin).toBe('https://api.github.com')
|
||||
expect(url.pathname).toBe('/repos/stablyai/orca/actions/workflows/pr.yml/runs')
|
||||
expect(Object.fromEntries(url.searchParams)).toEqual({
|
||||
event: 'pull_request',
|
||||
head_sha: identity.headSha,
|
||||
status: 'success',
|
||||
per_page: '20'
|
||||
})
|
||||
expect(options.method).toBeUndefined()
|
||||
expect(options.signal).toBeInstanceOf(AbortSignal)
|
||||
})
|
||||
|
||||
it('runs ordinary pushes and draft checks without consulting old results', async () => {
|
||||
const request = vi.fn()
|
||||
for (const action of ['opened', 'synchronize', 'reopened', 'converted_to_draft']) {
|
||||
expect(await lookupReadyCheckRun(env, { ...event, action }, request)).toBeUndefined()
|
||||
}
|
||||
expect(
|
||||
await lookupReadyCheckRun(
|
||||
env,
|
||||
{ ...event, pull_request: { ...event.pull_request, draft: true } },
|
||||
request
|
||||
)
|
||||
).toBeUndefined()
|
||||
expect(await lookupReadyCheckRun({ ...env, GH_TOKEN: '' }, event, request)).toBeUndefined()
|
||||
expect(
|
||||
await lookupReadyCheckRun({ ...env, GITHUB_EVENT_NAME: 'push' }, event, request)
|
||||
).toBeUndefined()
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to full checks when the API is unavailable or evidence is malformed', async () => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
for (const request of [
|
||||
async () => ({ ok: false, status: 403 }),
|
||||
async () => ({ ok: false, status: 429 }),
|
||||
async () => ({ ok: true, json: async () => ({}) }),
|
||||
async () => {
|
||||
throw new Error('Network unavailable')
|
||||
}
|
||||
]) {
|
||||
expect(await lookupReadyCheckRun(env, event, request)).toBeUndefined()
|
||||
}
|
||||
expect(await lookupReadyCheckRun(env, event, async () => response([]))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps required skips conditional on proof and leaves advisory routing eligible', () => {
|
||||
expect(workflow['run-name']).toBe(
|
||||
'PR ${{ github.event.pull_request.number }} | source ${{ github.sha }} | workflow ${{ github.workflow_sha }}'
|
||||
)
|
||||
expect(workflow.on.pull_request.types).toContain('ready_for_review')
|
||||
const detector = workflow.jobs.code_paths
|
||||
expect(detector.permissions).toEqual({ contents: 'read', actions: 'read' })
|
||||
const readiness = detector.steps.find((step) => step.id === 'readiness')
|
||||
expect(readiness.if).toBe("github.event.action == 'ready_for_review'")
|
||||
expect(readiness.env.PR_CHECK_WORKFLOW_SHA).toBe('${{ github.workflow_sha }}')
|
||||
expect(readiness.run).toBe('node config/scripts/pr-ready-check-reuse.mjs')
|
||||
expect(detector.steps[0].with['sparse-checkout']).toContain(
|
||||
'/config/scripts/pr-ready-check-reuse.mjs'
|
||||
)
|
||||
for (const job of ['native_cache_changed', ...PR_CHECK_JOBS]) {
|
||||
expect(detector.outputs[job]).toBe(
|
||||
`\${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.${job} }}`
|
||||
)
|
||||
}
|
||||
expect(detector.outputs.should_run).toBe('${{ steps.filter.outputs.should_run }}')
|
||||
for (const name of [
|
||||
'test_files',
|
||||
'ssh_source_changed',
|
||||
'native_ime_source_changed',
|
||||
'wsl_source_changed'
|
||||
]) {
|
||||
expect(detector.outputs[name]).toBe(`\${{ steps.e2e_filter.outputs.${name} }}`)
|
||||
}
|
||||
expect(detector.steps.find((step) => step.id === 'e2e_filter').if).toBe(
|
||||
"github.event.pull_request.draft != true && steps.filter.outputs.should_run == 'true'"
|
||||
)
|
||||
expect(workflow.jobs.verify.if).toBe('${{ !cancelled() }}')
|
||||
expect(workflow.jobs.verify.needs).toEqual(['code_paths', ...PR_CHECK_JOBS])
|
||||
})
|
||||
|
||||
it.each(['pr-test-loc.yml', 'mobile.yml'])(
|
||||
'does not rerun draft-independent %s on readiness changes',
|
||||
(file) => {
|
||||
const source = readFileSync(`.github/workflows/${file}`, 'utf8')
|
||||
const independent = parse(source)
|
||||
expect(independent.on.pull_request.types).toEqual(['opened', 'synchronize', 'reopened'])
|
||||
expect(source).not.toContain('pull_request.draft')
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -269,11 +269,20 @@ describe('PR workflow parallelism', () => {
|
||||
expect(steps[pnpmIndex].uses).toBe('pnpm/setup@v2')
|
||||
expect(steps[pnpmIndex].with.version).toBeUndefined()
|
||||
expect(steps[pnpmIndex].with.install).toBe(false)
|
||||
expect(steps[nodeIndex].with.cache).toBe('pnpm')
|
||||
const saveOutsidePrs = "${{ github.event_name != 'pull_request' && 'pnpm' || '' }}"
|
||||
expect(steps[nodeIndex].with.cache).toBe(saveOutsidePrs)
|
||||
expect(steps[nodeIndex].if).toBe("inputs.node-version == ''")
|
||||
expect(steps[requestedNodeIndex].if).toBe("inputs.node-version != ''")
|
||||
expect(steps[requestedNodeIndex].with['node-version']).toBe('${{ inputs.node-version }}')
|
||||
expect(steps[requestedNodeIndex].with.cache).toBe('pnpm')
|
||||
expect(steps[requestedNodeIndex].with.cache).toBe(saveOutsidePrs)
|
||||
const restoreIndex = steps.findIndex(
|
||||
(step) => step.name === 'Restore pnpm download store without saving'
|
||||
)
|
||||
expect(restoreIndex).toBeGreaterThan(requestedNodeIndex)
|
||||
expect(restoreIndex).toBeLessThan(
|
||||
steps.findIndex((step) => step.name === 'Install dependencies')
|
||||
)
|
||||
expect(steps[restoreIndex].uses).toBe('actions/cache/restore@v5')
|
||||
})
|
||||
|
||||
it('uses the repository package-manager version for every direct pnpm setup', () => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '../../src/shared/orcad-profile-preflight.ts'
|
||||
import { currentTarget } from './build-orcad-bun.mjs'
|
||||
import { runProcessSync } from './script-child-process.mjs'
|
||||
import { bunProfileTestPaths } from './bun-profile-test-paths.mjs'
|
||||
|
||||
const root = resolve(import.meta.dirname, '../..')
|
||||
const target = currentTarget()
|
||||
@@ -69,28 +70,5 @@ run(runtimePath, [
|
||||
'run',
|
||||
'--config',
|
||||
'config/vitest.config.ts',
|
||||
...(testArgs.length > 0
|
||||
? testArgs
|
||||
: [
|
||||
'src/main/persistence/profile-state',
|
||||
'src/main/persistence/loading-store/profile-state',
|
||||
'src/main/sqlite',
|
||||
'src/main/orcad/orcad-entry.test.ts',
|
||||
'src/main/orcad/orcad-push-startup.test.ts',
|
||||
...(artifact
|
||||
? [
|
||||
'src/main/daemon/pty-subprocess/bun-pty-process.integration.test.ts',
|
||||
'src/main/daemon/pty-subprocess/bun-pty-job-control.integration.test.ts',
|
||||
'src/main/daemon/pty-subprocess/bun-pty-process-suspension.test.ts',
|
||||
'src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts',
|
||||
'src/main/daemon/pty-subprocess/spawn-file-foreground-rejected-agents.test.ts',
|
||||
'tests/e2e/daemon-running-work-probe.unit.test.ts',
|
||||
'src/main/daemon/pty-subprocess/windows-bun-pty-gate.integration.test.ts',
|
||||
'src/main/providers/local-pty-bun-artifact.integration.test.ts',
|
||||
'src/main/providers/agent-foreground-process-git-bash.win32.test.ts',
|
||||
'src/main/orcad/orcad-bun-launcher.integration.test.ts',
|
||||
'config/scripts/zip-extractor-command.test.mjs'
|
||||
]
|
||||
: [])
|
||||
])
|
||||
...(testArgs.length > 0 ? testArgs : bunProfileTestPaths({ artifact }))
|
||||
])
|
||||
|
||||
@@ -1,5 +1,42 @@
|
||||
# CI efficiency and runner capacity
|
||||
|
||||
## Four follow-up changes
|
||||
|
||||
- Keep the readiness event, but reuse required checks only after an Actions API
|
||||
lookup proves that the same PR head, tested merge commit, and workflow commit
|
||||
already completed successfully. A changed base, missing proof, failed lookup,
|
||||
or still-running check falls back to the full checks. Advisory tests retain
|
||||
their normal readiness routing. The mobile and line-count workflows have no
|
||||
draft-dependent work, so they no longer run again when a draft becomes ready.
|
||||
- Route the Bun matrix using the actual headless build and selected tests'
|
||||
transitive imports, with conservative inclusion for dynamic workers, native
|
||||
inputs, fixtures, and toolchain changes. A graph failure runs the full matrix;
|
||||
manual dispatch still runs all ten platform jobs. The shared test selectors
|
||||
retain the same 85 files. Unrelated shard timings and mobile-test tooling can
|
||||
skip the matrix; shared shortcut definitions remain real runtime dependencies
|
||||
and still run it. Building the graph does not execute the imported modules.
|
||||
- Restore pnpm stores on PRs using setup-node's existing key and store path,
|
||||
without publishing more PR-private copies. Non-PR setup-node caching and
|
||||
native/TypeScript caches keep their existing behavior. A missing main store
|
||||
still installs with the frozen lockfile. The mixed root/mobile store may miss
|
||||
repeatedly because the existing main warmer only seeds the root lockfile.
|
||||
- Batch only the PowerShell quota-fixture reservations within each test, using
|
||||
the original generated scripts in fresh local scopes. Commands under test
|
||||
retain separate processes, real file identities, and existing race assertions.
|
||||
A traced local run confirms 44 PowerShell starts become 24, with all 21 cases
|
||||
passing. Alternating after/before/after elapsed times were 50.00/59.28/37.00
|
||||
seconds on a shared macOS arm64 host; that variance does not justify a precise
|
||||
percentage or hosted runner-time claim. Test budgets and worker counts are
|
||||
unchanged.
|
||||
|
||||
The reproducible pnpm-store comparison is
|
||||
`ORCA_BACKGROUND_LAUNCH=1 node config/scripts/ci-pnpm-store-benchmark.mjs --samples=3`.
|
||||
On macOS arm64 with BSD tar, three alternating fresh-store pairs eliminated a
|
||||
median 332,746,995-byte archive per miss. Median install time was 17.33 seconds
|
||||
before and 16.94 after; the removed archive step alone took 53.51 seconds.
|
||||
Those local disk/CPU measurements exclude uploads and are not a prediction of
|
||||
Linux or Windows hosted savings. Restore cost is common to both policies.
|
||||
|
||||
## September 26 verification
|
||||
|
||||
[PR #23053](https://github.com/stablyai/orca/pull/23053) was merged before its
|
||||
|
||||
@@ -15,6 +15,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { getRemoteHostPlatform, type RemoteHostPlatform } from './ssh-remote-platform'
|
||||
import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell'
|
||||
import {
|
||||
cleanupOwnedRelayUploadStageCommand,
|
||||
parseReservedRelayUploadStage,
|
||||
@@ -84,6 +85,22 @@ function createStage(
|
||||
const reservedOwner = /^\.sftp-namespace-[0-9a-f]{32}$/u.test(stageOwner) ? stageOwner : owner
|
||||
const reservation = runCommand(host, reserveRelayUploadStageCommand(host, pool, reservedOwner))
|
||||
expect(reservation.status, reservation.stderr).toBe(0)
|
||||
return populateReservedStage(
|
||||
pool,
|
||||
index,
|
||||
stageOwner === reservedOwner ? undefined : stageOwner,
|
||||
stale,
|
||||
state
|
||||
)
|
||||
}
|
||||
|
||||
function populateReservedStage(
|
||||
pool: string,
|
||||
index: number,
|
||||
replacementOwner?: string,
|
||||
stale = false,
|
||||
state: 'slot' | 'claim' | 'delete' = 'slot'
|
||||
): string {
|
||||
const slot = join(pool, `slot-${index}`)
|
||||
const stage = join(pool, `${state}-${index}`)
|
||||
if (stage !== slot) {
|
||||
@@ -91,8 +108,8 @@ function createStage(
|
||||
renameSync(slot, stage)
|
||||
}
|
||||
const marker = join(stage, '.orca-upload-owner')
|
||||
if (stageOwner !== reservedOwner) {
|
||||
writeFileSync(marker, stageOwner)
|
||||
if (replacementOwner !== undefined) {
|
||||
writeFileSync(marker, replacementOwner)
|
||||
}
|
||||
writeFileSync(join(stage, 'payload', 'relay.js'), `relay-${index}`)
|
||||
if (stale) {
|
||||
@@ -102,16 +119,48 @@ function createStage(
|
||||
return stage
|
||||
}
|
||||
|
||||
function createQuotaStages(host: RemoteHostPlatform, pool: string, count: number): void {
|
||||
if (host.commandDialect !== 'powershell' || count < 2) {
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
createStage(host, pool, index)
|
||||
}
|
||||
return
|
||||
}
|
||||
const command = reserveRelayUploadStageCommand(host, pool, owner)
|
||||
// Only fixture setup shares a process; each unmodified script gets a fresh local scope.
|
||||
const batch = powerShellCommand(
|
||||
[
|
||||
"$ErrorActionPreference = 'Stop'",
|
||||
`$fixtureScript = ${powerShellLiteral(decodePowerShellCommand(command))}`,
|
||||
'$reservation = [PowerShell]::Create()',
|
||||
'try {',
|
||||
`foreach ($fixtureIndex in 1..${count}) {`,
|
||||
'$reservation.Commands.Clear()',
|
||||
'$reservation.Streams.Error.Clear()',
|
||||
'$null = $reservation.AddScript($fixtureScript, $true)',
|
||||
'$reservation.Invoke()',
|
||||
'if ($reservation.InvocationStateInfo.State -ne "Completed") { throw $reservation.InvocationStateInfo.Reason }',
|
||||
'}',
|
||||
'} finally { $reservation.Dispose() }'
|
||||
].join('\n')
|
||||
)
|
||||
const result = runCommand(host, batch)
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
const reservations = result.stdout.trim().split(/\r?\n/u)
|
||||
expect(reservations).toHaveLength(count)
|
||||
for (const [index, output] of reservations.entries()) {
|
||||
expect(parseReservedRelayUploadStage(host, pool, owner, output).slotName).toBe(`slot-${index}`)
|
||||
populateReservedStage(pool, index)
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
// Why: every case spawns a real interpreter per command, and on non-Windows hosts the PowerShell
|
||||
// path also forks `/usr/bin/stat` per file-identity lookup — the 8-entry reservation alone measured
|
||||
// ~50s idle, nearly all of it process-spawn sys time, and the full suite multiplies that under CPU
|
||||
// contention. Sized for spawn count, not the assertions, which run in microseconds.
|
||||
// Allow for real interpreter startup and file-identity probes under full-suite contention.
|
||||
const SPAWNED_INTERPRETER_TIMEOUT_MS = 240_000
|
||||
|
||||
describe.each([
|
||||
@@ -126,9 +175,7 @@ describe.each([
|
||||
(_label, host) => {
|
||||
it.each([0, 1, 7, 8, 9])('bounds reservation with %i occupied entries', (count) => {
|
||||
const pool = createPool()
|
||||
for (let index = 0; index < Math.min(count, RELAY_UPLOAD_STAGE_SLOT_COUNT); index += 1) {
|
||||
createStage(host, pool, index)
|
||||
}
|
||||
createQuotaStages(host, pool, Math.min(count, RELAY_UPLOAD_STAGE_SLOT_COUNT))
|
||||
if (count > RELAY_UPLOAD_STAGE_SLOT_COUNT) {
|
||||
mkdirSync(join(pool, 'foreign-extra'))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user