mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 17:17:38 +00:00
* ci: fail when a checked-in test file has no runner Two node:test files under .github/actions lost their runner when #31792 trimmed cloud pretest, and the two monaco .bench.ts budgets were never run by any CI job. Wire both back in and add a static check that every test-like file is collected by a declared runner or imported by a collected entry (the src .spec.* suite-module convention). * ci: ignore type-only imports and quote the lease glob for cmd.exe * ci: parse type-only imports, cover UNIT_EXCLUDE, route cloud runner edits Type-only imports are now dropped with the TypeScript parser, so a type alias before a runtime re-export no longer hides it. UNIT_EXCLUDE files must be named by a pr.yml or unit-tests.yml step. A cloud-only diff that adds a test or edits a runner config now runs the static job. The monaco benchmarks stay manual (pnpm test:perf:benchmarks) until a CI runner proves their wall-clock budgets. * ci: require UNIT_INCLUDE names in dedicated jobs and erase typeof import() * ci: resolve each runtime import to the one file Vite loads The unit dependency graph keeps every plausible candidate so it can invalidate conservatively, which cannot prove a suite runs: an import of ./case.spec counted both case.spec.ts and case.spec.tsx. Resolve with Vite's extension order instead. * ci: take runtime imports from transpiled output Imports used only as types are erased without a type modifier, so parsing for type-only syntax still followed them. Derive edges from ts.transpileModule output, which erases what Vitest's transform erases, and only transpile files that can reach an uncollected candidate. * ci: fix the agent-status benchmark config and route all cloud code The agent-status benchmark config inherited the unit projects, whose includes override its own, so it ran the whole unit suite and never the benchmark. Clear projects as the performance config does. Route every cloud code file to the static job, since content-detected tests can have any name.
672 lines
27 KiB
JavaScript
672 lines
27 KiB
JavaScript
import { spawn, spawnSync } from 'node:child_process'
|
|
import { readFileSync } from 'node:fs'
|
|
import { join, resolve } from 'node:path'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { parse } from 'yaml'
|
|
import {
|
|
classifyPrJobs,
|
|
isDocsOnlyPath,
|
|
PR_CHECK_JOBS,
|
|
shouldRunPrChecks,
|
|
STATIC_ANALYSIS_SCAN_ROOTS
|
|
} from './pr-code-change-scope.mjs'
|
|
|
|
const projectDir = resolve(import.meta.dirname, '../..')
|
|
const prWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
|
|
|
|
const expensiveJobs = [
|
|
'static_analysis',
|
|
'typecheck',
|
|
'git_compatibility',
|
|
'codex_index_heal_contract',
|
|
'xterm_patch_sync',
|
|
'shell_contracts',
|
|
'test',
|
|
'orcad_browser',
|
|
'cross-version-wire',
|
|
'managed_hook_node18',
|
|
'package',
|
|
'package_windows'
|
|
]
|
|
|
|
const ALWAYS_ON = ['static_analysis', 'typecheck', 'test']
|
|
|
|
function expectedJobs(overrides, { alwaysOn = true } = {}) {
|
|
return Object.fromEntries(
|
|
PR_CHECK_JOBS.map((job) => [
|
|
job,
|
|
(alwaysOn && ALWAYS_ON.includes(job)) || Boolean(overrides[job])
|
|
])
|
|
)
|
|
}
|
|
|
|
function expectClassification(files, overrides) {
|
|
const result = classifyPrJobs(files)
|
|
const shouldRun = shouldRunPrChecks(files)
|
|
expect(result.should_run).toBe(shouldRun)
|
|
expect(result).toMatchObject({
|
|
should_run: shouldRun,
|
|
...expectedJobs(overrides, { alwaysOn: shouldRun })
|
|
})
|
|
}
|
|
|
|
describe('docs-only path classification', () => {
|
|
it('treats the WeChat README PR files as docs-only', () => {
|
|
expect(
|
|
shouldRunPrChecks([
|
|
'README.md',
|
|
'docs/assets/wechat-qr-group8.jpg',
|
|
'docs/readme/README.zh-CN.md'
|
|
])
|
|
).toBe(false)
|
|
})
|
|
|
|
it('skips root instruction files and GitHub markdown templates', () => {
|
|
expect(isDocsOnlyPath('AGENTS.md')).toBe(true)
|
|
expect(isDocsOnlyPath('CLAUDE.md')).toBe(true)
|
|
expect(isDocsOnlyPath('LICENSE')).toBe(true)
|
|
expect(isDocsOnlyPath('.github/CONTRIBUTING.md')).toBe(true)
|
|
expect(isDocsOnlyPath('.github/pull_request_template.md')).toBe(true)
|
|
expect(isDocsOnlyPath('.github/ISSUE_TEMPLATE/bug_report.yml')).toBe(true)
|
|
expect(isDocsOnlyPath('.github/CODEOWNERS')).toBe(true)
|
|
})
|
|
|
|
it('still runs PR Checks for product markdown and CI', () => {
|
|
expect(isDocsOnlyPath('skills/computer-use/SKILL.md')).toBe(false)
|
|
expect(isDocsOnlyPath('skill-guides/orca-cli.md')).toBe(false)
|
|
expect(isDocsOnlyPath('.github/workflows/pr.yml')).toBe(false)
|
|
expect(isDocsOnlyPath('src/main/index.ts')).toBe(false)
|
|
expect(isDocsOnlyPath('config/scripts/pr-code-change-scope.mjs')).toBe(false)
|
|
expect(shouldRunPrChecks(['README.md', 'src/main/index.ts'])).toBe(true)
|
|
})
|
|
|
|
it('runs PR Checks when the diff is empty rather than skipping by accident', () => {
|
|
expect(shouldRunPrChecks([])).toBe(true)
|
|
})
|
|
|
|
it('does not start desktop PR Checks for mobile-only diffs', () => {
|
|
expect(shouldRunPrChecks(['mobile/src/App.tsx', 'mobile/package.json'])).toBe(false)
|
|
})
|
|
|
|
it('does not start desktop PR Checks for cloud-only diffs', () => {
|
|
expect(
|
|
shouldRunPrChecks([
|
|
'cloud/apps/relay/src/index.ts',
|
|
'cloud/package.json',
|
|
'cloud/.gitleaks.toml',
|
|
'.github/workflows/cloud-verify.yml'
|
|
])
|
|
).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('per-job path classification', () => {
|
|
it('runs every expensive job on an empty diff rather than skipping by accident', () => {
|
|
const result = classifyPrJobs([])
|
|
expect(result.should_run).toBe(true)
|
|
for (const job of PR_CHECK_JOBS) {
|
|
expect(result[job], job).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('skips every expensive job for docs-only diffs', () => {
|
|
expectClassification(['docs/assets/wechat-qr-group8.jpg', 'LICENSE'], {})
|
|
})
|
|
|
|
it('runs packaging and always-on jobs for product source, not git/xterm/shell lanes', () => {
|
|
expectClassification(['src/renderer/src/components/tab-bar/TabBar.tsx'], {
|
|
package: true,
|
|
package_windows: true
|
|
})
|
|
})
|
|
|
|
it('runs Git compatibility only when git capability inputs change', () => {
|
|
expectClassification(['src/shared/git-capability-cache.ts'], {
|
|
git_compatibility: true,
|
|
package: true,
|
|
package_windows: true
|
|
})
|
|
expectClassification(['src/shared/git-binary-compatibility.test.ts'], {
|
|
git_compatibility: true
|
|
})
|
|
expectClassification(['.github/actions/prepare-git-compatibility/action.yml'], {
|
|
git_compatibility: true
|
|
})
|
|
// The contract pins the local-main fast-forward's exact arguments.
|
|
expectClassification(['src/shared/worktree/local-base-branch-fast-forward.ts'], {
|
|
git_compatibility: true,
|
|
package: true,
|
|
package_windows: true
|
|
})
|
|
})
|
|
|
|
it('runs the Codex index-heal contract only when the heal or its transport changes', () => {
|
|
expectClassification(['src/main/codex/codex-app-server-session.test.ts'], {})
|
|
expectClassification(['src/main/codex/codex-session-index-heal.ts'], {
|
|
codex_index_heal_contract: true,
|
|
package: true,
|
|
package_windows: true
|
|
})
|
|
expectClassification(['src/main/sqlite/sync-database.ts'], {
|
|
codex_index_heal_contract: true,
|
|
package: true,
|
|
package_windows: true
|
|
})
|
|
expectClassification(['src/main/codex/codex-app-server-session.ts'], {
|
|
codex_index_heal_contract: true,
|
|
package: true,
|
|
package_windows: true
|
|
})
|
|
expectClassification(['src/main/codex/codex-index-heal-binary-contract.test.ts'], {
|
|
codex_index_heal_contract: true
|
|
})
|
|
// Keep the real-binary gate live when a trust, transport, launch or hook-approval dependency changes.
|
|
for (const file of [
|
|
'src/main/agent-trust-presets.ts',
|
|
'src/main/codex/config-toml-trust.ts',
|
|
'src/main/codex/codex-hook-trust-derivation.ts',
|
|
'src/main/codex/codex-hook-local-install.ts',
|
|
'src/main/codex/codex-hook-orca-approvals.ts',
|
|
'src/main/codex/codex-hook-reconcile.ts',
|
|
'src/main/codex/config-toml-hook-trust-edit.ts',
|
|
'src/main/codex-cli/codex-read-only-app-server-args.ts',
|
|
'src/main/codex/codex-app-server-capability-signal.ts',
|
|
'src/main/provider-process/provider-process-exit-deadline.ts',
|
|
'src/main/provider-process/provider-process-launch.ts',
|
|
'src/main/provider-process/provider-record-reader.ts',
|
|
'src/main/codex/codex-session-backfill.ts',
|
|
'src/main/codex/codex-session-index-heal-state.ts',
|
|
'src/main/codex-cli/command.ts',
|
|
'src/main/win32-utils.ts',
|
|
'src/shared/node-cli-command-resolution.ts',
|
|
'src/shared/windows-batch-spawn.ts'
|
|
]) {
|
|
expectClassification([file], {
|
|
codex_index_heal_contract: true,
|
|
package: true,
|
|
package_windows: true
|
|
})
|
|
}
|
|
// A neighbouring Codex module must not drag the real-binary job in.
|
|
expectClassification(['src/main/codex/codex-home-paths.ts'], {
|
|
package: true,
|
|
package_windows: true
|
|
})
|
|
})
|
|
|
|
it('runs xterm patch sync only when xterm inputs change', () => {
|
|
expectClassification(['config/patches/xterm-upstream.json'], {
|
|
xterm_patch_sync: true
|
|
})
|
|
expectClassification(['config/patches/@xterm__xterm@6.1.0-beta.287.patch'], {
|
|
xterm_patch_sync: true
|
|
})
|
|
})
|
|
|
|
it('runs dependency isolation only when what packages may resolve changes', () => {
|
|
expectClassification(['config/scripts/undeclared-dependency-resolution.test.ts'], {
|
|
dependency_isolation: true
|
|
})
|
|
expect(classifyPrJobs(['pnpm-workspace.yaml']).dependency_isolation).toBe(true)
|
|
expect(classifyPrJobs(['config/pnpmfile.cjs']).dependency_isolation).toBe(true)
|
|
expect(classifyPrJobs(['src/renderer/src/App.tsx']).dependency_isolation).toBe(false)
|
|
})
|
|
|
|
it('runs native package jobs only for the platform that ships the changed native', () => {
|
|
expectClassification(['native/windows-cli-launcher/src/main.rs'], { package_windows: true })
|
|
expectClassification(['native/computer-use-linux/runtime.py'], { package: true })
|
|
expectClassification(['native/wayland-clipboard/wayland-clipboard.c'], {
|
|
package: true
|
|
})
|
|
expectClassification(['native/computer-use-macos/Package.swift'], {})
|
|
})
|
|
|
|
it('runs Linux packaging when an artifact contract changes', () => {
|
|
for (const file of [
|
|
'config/scripts/package-linux-formats.mjs',
|
|
'config/scripts/package-linux-formats-appimage.mjs',
|
|
'config/scripts/process-failure-message.mjs',
|
|
'config/scripts/space-sharing-copy.mjs',
|
|
'.github/actions/prepare-linux-package-fixture/action.yml',
|
|
'.github/actions/prepare-wayland-clipboard/action.yml',
|
|
'config/docker/cli-launch-contract/Dockerfile',
|
|
'config/docker/cli-launch-contract/run-cli-case.sh',
|
|
'config/docker/headless-pairing/Dockerfile',
|
|
'config/docker/headless-pairing/run-appimage-case.sh',
|
|
'config/docker/headless-serve-shutdown/Dockerfile',
|
|
'config/scripts/run-linux-cli-launch-contract-docker.mjs',
|
|
'config/scripts/run-headless-linux-pairing-docker.mjs',
|
|
'config/scripts/static-appimage-package-contract.cjs'
|
|
]) {
|
|
expectClassification([file], {
|
|
package: true
|
|
})
|
|
}
|
|
})
|
|
|
|
it('runs Linux packaging for the daemon shutdown descendant oracle and its production paths', () => {
|
|
for (const file of [
|
|
'config/docker/daemon-shutdown-descendants/Dockerfile',
|
|
'config/docker/daemon-shutdown-descendants/bundle-entry.ts',
|
|
'config/docker/daemon-shutdown-descendants/fixture.cjs',
|
|
'config/docker/daemon-shutdown-descendants/run-case.sh',
|
|
'config/scripts/run-daemon-shutdown-descendants-docker.mjs'
|
|
]) {
|
|
expectClassification([file], {
|
|
package: true
|
|
})
|
|
}
|
|
for (const file of [
|
|
'src/main/daemon/terminal-host.ts',
|
|
'src/main/daemon/terminal-session-teardown.ts',
|
|
'src/main/daemon/terminal-host-session-shutdown.ts',
|
|
'src/main/daemon/terminal-descendant-shutdown.ts',
|
|
'src/main/pty-descendant-termination.ts',
|
|
'src/main/pty-descendant-exit-verification.ts',
|
|
'src/main/pty-process-table-parser.ts'
|
|
]) {
|
|
expectClassification([file], { package: true, package_windows: true })
|
|
}
|
|
})
|
|
|
|
it('runs both package jobs when the shared skills runtime verifier changes', () => {
|
|
expectClassification(['config/scripts/verify-skills-cli-runtime.cjs'], {
|
|
package: true,
|
|
package_windows: true
|
|
})
|
|
})
|
|
|
|
it('runs shell contracts when live-shell inputs change', () => {
|
|
expectClassification(['src/main/daemon/shell-ready.ts'], {
|
|
shell_contracts: true,
|
|
package: true,
|
|
package_windows: true
|
|
})
|
|
})
|
|
|
|
it('runs shell contracts when wrapper templates or live-shell fixtures change', () => {
|
|
expectClassification(['src/main/shell-templates.ts'], {
|
|
shell_contracts: true,
|
|
package: true,
|
|
package_windows: true
|
|
})
|
|
expectClassification(['src/main/shell-startup-launch-intent-fixtures.ts'], {
|
|
shell_contracts: true,
|
|
package: true,
|
|
package_windows: true
|
|
})
|
|
})
|
|
|
|
it('runs shell contracts for the structured-session login-shell test, its harness and its subject', () => {
|
|
expectClassification(
|
|
['src/main/runtime/structured-session-cli-login-shell.live-shell.test.ts'],
|
|
{ shell_contracts: true }
|
|
)
|
|
for (const file of [
|
|
'src/main/runtime/structured-session-login-shell-test-harness.ts',
|
|
'src/main/runtime/structured-session-child-env.ts',
|
|
'src/main/runtime/structured-session-child-env.test-support.ts'
|
|
]) {
|
|
expectClassification([file], {
|
|
shell_contracts: true,
|
|
package: true,
|
|
package_windows: true
|
|
})
|
|
}
|
|
})
|
|
|
|
it('runs orcad browser when Chrome launch, session, or tab modules change', () => {
|
|
for (const file of [
|
|
'src/main/orcad/external-chromium-browser-session.ts',
|
|
'src/main/orcad/external-chromium-command-arguments.ts',
|
|
'src/main/orcad/external-chromium-tab-registry.ts',
|
|
'src/main/orcad/external-chromium-tab-projection.ts'
|
|
]) {
|
|
expectClassification([file], {
|
|
orcad_browser: true,
|
|
package: true,
|
|
package_windows: true
|
|
})
|
|
}
|
|
expectClassification(['src/main/orcad/orcad-native-preflight.ts'], {
|
|
package: true,
|
|
package_windows: true
|
|
})
|
|
})
|
|
|
|
it('runs the mobile web app job for the builder, the page source and the shell policy', () => {
|
|
for (const file of [
|
|
'config/scripts/build-mobile-web-app-bundle.mjs',
|
|
'config/scripts/run-mobile-web-app-checks.mjs',
|
|
'src/packages/process-host/src/run-process.ts',
|
|
'config/scripts/mobile-web-app/mobile-web-app-route-manifest.mjs',
|
|
'config/scripts/mobile-web-app/platform-seams/mobile-web-app-haptics-seam.mjs',
|
|
'config/scripts/mobile-web-app/platform-seams/mobile-web-app-haptics-seam.test.mjs',
|
|
'mobile/web-entry/index.tsx',
|
|
'mobile/app/h/[hostId]/index.tsx',
|
|
'mobile/src/transport/client-context.web.tsx',
|
|
'mobile/modules/orca-mobile-web-shell/ios/MobileWebShellCsp.swift',
|
|
'src/shared/native-chat-visual-shell.ts',
|
|
// The vendored Expo module the page resolves a .web.ts out of.
|
|
'mobile/packages/expo-two-way-audio/src/ExpoTwoWayAudioModule.web.ts'
|
|
]) {
|
|
expect(classifyPrJobs([file]).mobile_web_app, file).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('runs it on a mobile-only diff, which should_run alone would skip', () => {
|
|
const classified = classifyPrJobs(['mobile/app/h/[hostId]/tasks.tsx'])
|
|
expect([classified.should_run, classified.mobile_web_app]).toEqual([false, true])
|
|
})
|
|
|
|
it('needs no package.json prefix, because package.json already forces every job', () => {
|
|
// build:mobile-web is defined there, so the job has to run on an edit to it. A prefix
|
|
// that broad is not how: GLOBAL_FORCE_FILES already covers the file.
|
|
expect(classifyPrJobs(['package.json']).mobile_web_app).toBe(true)
|
|
})
|
|
|
|
it('leaves it off for changes that cannot reach the page', () => {
|
|
for (const file of ['docs/reference/x.md', 'src/main/orcad/orcad-native-preflight.ts']) {
|
|
expect(classifyPrJobs([file]).mobile_web_app, file).toBe(false)
|
|
}
|
|
})
|
|
|
|
it('runs cross-version wire checks for every working-tree wire module', () => {
|
|
for (const file of [
|
|
'src/shared/protocol-version.ts',
|
|
'src/shared/terminal-stream-protocol.ts',
|
|
'src/shared/agent-session-wire.ts',
|
|
'src/shared/agent-session-provider-handle.ts',
|
|
'src/shared/agent-session-mutation-envelope.ts',
|
|
'src/shared/agent-session-journal-item-key.ts',
|
|
'src/shared/agent-session-journal-types.ts',
|
|
'src/main/ai-vault/structured-session-ownership.ts',
|
|
'src/main/native-chat/agent-session-journal/journal-cursor.ts',
|
|
'src/main/native-chat/agent-session-journal/journal-reducer.ts',
|
|
'src/main/native-chat/agent-session-journal/journal-row-schema.ts',
|
|
'src/main/native-chat/agent-session-wire/structured-agent-session-host.ts',
|
|
'src/main/runtime/agent-session-record-store.ts',
|
|
'src/main/runtime/rpc/dispatcher.ts',
|
|
'src/main/runtime/rpc/methods/ai-vault.ts',
|
|
'src/shared/rpc-contract/browser-tab-create-params.ts',
|
|
'src/main/runtime/rpc/methods/session-tabs.ts',
|
|
'src/main/runtime/rpc/methods/structured-agent-session.ts',
|
|
'src/main/runtime/rpc/methods/structured-agent-session-gate.ts',
|
|
'src/main/runtime/rpc/methods/structured-agent-session-hold.ts',
|
|
'src/main/runtime/rpc/methods/structured-agent-session-schemas.ts',
|
|
'src/main/runtime/rpc/methods/terminal.ts',
|
|
'src/main/runtime/runtime-worktree-agent-rows.ts',
|
|
'src/main/runtime/runtime-worktree-pty-agent-sources.ts',
|
|
'src/shared/runtime-worktree-contracts.ts',
|
|
'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts',
|
|
'src/shared/structured-agent-session-projection.ts',
|
|
'src/shared/agent-turn-outcome.ts',
|
|
'src/main/runtime/orchestration/db.ts',
|
|
'src/main/runtime/orchestration/orchestration-schema-version-skew.ts'
|
|
]) {
|
|
expectClassification([file], {
|
|
'cross-version-wire': true,
|
|
package: true,
|
|
package_windows: true,
|
|
// The session-tabs RPC publishes layout, so the layout oracle gates it too.
|
|
layout_oracle: file === 'src/main/runtime/rpc/methods/session-tabs.ts'
|
|
})
|
|
}
|
|
expectClassification(
|
|
['tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts'],
|
|
{ 'cross-version-wire': true }
|
|
)
|
|
})
|
|
|
|
it('runs workflow-self-change and lockfile diffs as force-all', () => {
|
|
const result = classifyPrJobs(['.github/workflows/pr.yml'])
|
|
expect(result.should_run).toBe(true)
|
|
for (const job of PR_CHECK_JOBS) {
|
|
expect(result[job], job).toBe(true)
|
|
}
|
|
expect(classifyPrJobs(['pnpm-lock.yaml']).git_compatibility).toBe(true)
|
|
})
|
|
|
|
it('primes native caches only when their immutable inputs change', () => {
|
|
expect(classifyPrJobs([]).native_cache_changed).toBe(true)
|
|
expect(classifyPrJobs(['README.md']).native_cache_changed).toBe(false)
|
|
expect(classifyPrJobs(['src/main/index.ts']).native_cache_changed).toBe(false)
|
|
for (const file of [
|
|
'package.json',
|
|
'pnpm-lock.yaml',
|
|
'.github/actions/install-node-dependencies/action.yml',
|
|
'.github/actions/prepare-native-runtime/action.yml',
|
|
'config/scripts/ensure-native-runtime.mjs',
|
|
'config/scripts/rebuild-native-deps.mjs',
|
|
'config/patches/node-pty@1.1.0.patch'
|
|
]) {
|
|
expect(classifyPrJobs([file]).native_cache_changed, file).toBe(true)
|
|
}
|
|
})
|
|
|
|
// Why: static analysis lints changed mobile files with a type-aware pass, and
|
|
// mobile is a separate pnpm project. Without its node_modules every mobile type
|
|
// resolves to an `error` type and the changed-code gate fails on phantom
|
|
// findings, which is exactly how a react-test-renderer union broke a PR.
|
|
it('installs mobile dependencies exactly when mobile files change', () => {
|
|
expect(classifyPrJobs([]).mobile_dependencies).toBe(true)
|
|
expect(classifyPrJobs(['README.md']).mobile_dependencies).toBe(false)
|
|
expect(classifyPrJobs(['src/main/index.ts']).mobile_dependencies).toBe(false)
|
|
expect(
|
|
classifyPrJobs(['src/main/index.ts', 'mobile/src/session/a.test.ts']).mobile_dependencies
|
|
).toBe(true)
|
|
// Why true: a mobile-only diff still skips the desktop suite, but the repo-wide audits lint
|
|
// mobile/, so static analysis runs and its changed-code pass needs the mobile types.
|
|
expect(classifyPrJobs(['mobile/package.json']).mobile_dependencies).toBe(true)
|
|
expect(classifyPrJobs(['mobile/package.json']).should_run).toBe(false)
|
|
expect(classifyPrJobs(['README.md', 'mobile/src/a.ts']).mobile_dependencies).toBe(true)
|
|
})
|
|
|
|
// Why: `mobile/` is desktop-irrelevant for every other job, so a mobile-only diff used to skip
|
|
// the audits that do lint it. That is how #20702 landed two duplicate imports which then failed
|
|
// this gate on every later PR's merge ref until #20895 swept them.
|
|
it('runs static analysis for a mobile-only diff without dragging in the desktop suite', () => {
|
|
const result = classifyPrJobs([
|
|
'mobile/src/test-support/rpc-recording/adapters/push-registration-mount-adapters.ts'
|
|
])
|
|
expect(result.static_analysis).toBe(true)
|
|
expect(result.mobile_dependencies).toBe(true)
|
|
expect(result.should_run).toBe(false)
|
|
for (const job of ['typecheck', 'test', 'package', 'package_windows', 'git_compatibility']) {
|
|
expect(result[job], job).toBe(false)
|
|
}
|
|
})
|
|
|
|
// The ratchet: adding a tree to an audit command has to widen this trigger on its own.
|
|
it('runs static analysis for every tree the audit commands scan', () => {
|
|
expect(STATIC_ANALYSIS_SCAN_ROOTS).toEqual(
|
|
expect.arrayContaining(['src', 'config', 'tests', 'mobile'])
|
|
)
|
|
for (const root of STATIC_ANALYSIS_SCAN_ROOTS) {
|
|
expect(classifyPrJobs([`${root}/changed-file.ts`]).static_analysis, root).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('leaves diffs the audits never read out of static analysis', () => {
|
|
expect(classifyPrJobs(['docs/assets/logo.png']).static_analysis).toBe(false)
|
|
expect(classifyPrJobs(['cloud/apps/relay/Dockerfile']).static_analysis).toBe(false)
|
|
})
|
|
|
|
it('keeps unit-test-only diffs out of packaging', () => {
|
|
expectClassification(['src/main/git/git-status.test.ts'], {
|
|
git_compatibility: true
|
|
})
|
|
})
|
|
|
|
it('emits GitHub output pairs from the shipped CLI', () => {
|
|
const result = spawnSync(process.execPath, ['config/scripts/pr-code-change-scope.mjs'], {
|
|
cwd: projectDir,
|
|
encoding: 'utf8',
|
|
input: 'config/patches/xterm-upstream.json\n'
|
|
})
|
|
expect(result.status, result.stderr).toBe(0)
|
|
expect(result.stdout).toContain('should_run=true\n')
|
|
expect(result.stdout).toContain('xterm_patch_sync=true\n')
|
|
expect(result.stdout).toContain('git_compatibility=false\n')
|
|
expect(result.stdout).toContain('package=false\n')
|
|
expect(result.stdout).toContain('test=true\n')
|
|
})
|
|
|
|
// A long-lived PR whose base.sha has gone stale diffs thousands of files, so the writer
|
|
// outruns one pipe buffer. A single fd-0 read then returns early, breaks the writer's pipe,
|
|
// and still exits 0 -- emitting no pairs at all, which silently skips every lane.
|
|
it('classifies a path that arrives after the first pipe buffer', async () => {
|
|
const filler = Array.from(
|
|
{ length: 12_000 },
|
|
(_, index) => `docs/reference/generated-placeholder-${index}.md`
|
|
)
|
|
const input = `${[...filler, 'config/patches/xterm-upstream.json'].join('\n')}\n`
|
|
expect(input.length).toBeGreaterThan(64 * 1024)
|
|
|
|
const child = spawn(process.execPath, ['config/scripts/pr-code-change-scope.mjs'], {
|
|
cwd: projectDir,
|
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
})
|
|
let stdout = ''
|
|
let stderr = ''
|
|
let brokePipe = false
|
|
child.stdout.setEncoding('utf8')
|
|
child.stderr.setEncoding('utf8')
|
|
child.stdout.on('data', (chunk) => (stdout += chunk))
|
|
child.stderr.on('data', (chunk) => (stderr += chunk))
|
|
child.stdin.on('error', (error) => {
|
|
brokePipe ||= error.code === 'EPIPE'
|
|
})
|
|
|
|
const exitCode = await new Promise((resolvePromise) => {
|
|
child.on('close', resolvePromise)
|
|
let offset = 0
|
|
const step = () => {
|
|
if (offset >= input.length) {
|
|
child.stdin.end()
|
|
return
|
|
}
|
|
child.stdin.write(input.slice(offset, offset + 64 * 1024))
|
|
offset += 64 * 1024
|
|
setTimeout(step, 20)
|
|
}
|
|
step()
|
|
})
|
|
|
|
expect(stderr).not.toContain('EAGAIN')
|
|
expect(brokePipe).toBe(false)
|
|
expect(exitCode, stderr).toBe(0)
|
|
expect(stdout).toContain('should_run=true\n')
|
|
expect(stdout).toContain('xterm_patch_sync=true\n')
|
|
})
|
|
})
|
|
|
|
describe('PR Checks skip wiring', () => {
|
|
it('runs the candidate daemon shutdown Docker oracle in the existing Linux package job', () => {
|
|
const steps = prWorkflow.jobs.package.steps
|
|
const install = steps.findIndex(
|
|
(step) => step.uses === './.github/actions/install-node-dependencies'
|
|
)
|
|
const oracle = steps.findIndex(
|
|
(step) => step.name === 'Verify Linux daemon shutdown descendant cleanup'
|
|
)
|
|
expect(install).toBeGreaterThan(-1)
|
|
expect(oracle).toBeGreaterThan(install)
|
|
expect(steps[oracle].run).toBe('node config/scripts/run-daemon-shutdown-descendants-docker.mjs')
|
|
expect(steps[oracle].env.ORCA_BACKGROUND_LAUNCH).toBe('1')
|
|
})
|
|
|
|
it('classifies the PR range with a tested script and expands renames', () => {
|
|
const classify = prWorkflow.jobs.code_paths.steps.find(
|
|
(step) => step.name === 'Classify changed paths'
|
|
)
|
|
expect(classify.run).toContain('--diff-filter=ACDMR')
|
|
expect(classify.run).toContain('--no-renames')
|
|
// HEAD is the merge commit, so HEAD^1 is the base side and no merge base is computed.
|
|
// That is what lets this job check out shallowly, which every other job waits on.
|
|
expect(classify.run).toContain('node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA"')
|
|
expect(classify.run).toContain('"$DIFF_BASE" HEAD')
|
|
expect(classify.run).not.toContain('--merge-base "$')
|
|
expect(classify.run).toContain('node config/scripts/pr-code-change-scope.mjs')
|
|
expect(classify.run).toContain('tee -a "$GITHUB_OUTPUT"')
|
|
expect(prWorkflow.jobs.code_paths.outputs.should_run).toBe(
|
|
'${{ steps.filter.outputs.should_run }}'
|
|
)
|
|
for (const jobName of expensiveJobs) {
|
|
expect(prWorkflow.jobs.code_paths.outputs[jobName], jobName).toBe(
|
|
`\${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.${jobName} }}`
|
|
)
|
|
}
|
|
})
|
|
|
|
it('gives static analysis the mobile types its type-aware pass resolves', () => {
|
|
expect(prWorkflow.jobs.code_paths.outputs.mobile_dependencies).toBe(
|
|
'${{ steps.filter.outputs.mobile_dependencies }}'
|
|
)
|
|
const steps = prWorkflow.jobs.preflight.steps
|
|
const install = steps.findIndex(
|
|
(step) => step.uses === './.github/actions/install-mobile-dependencies'
|
|
)
|
|
const gate = steps.findIndex((step) => step.name === 'Enforce changed-code quality')
|
|
expect(install).toBeGreaterThan(-1)
|
|
expect(install).toBeLessThan(gate)
|
|
expect(steps[install].if).toBe(
|
|
"needs.code_paths.outputs.static_analysis == 'true' && needs.code_paths.outputs.mobile_dependencies == 'true'"
|
|
)
|
|
// The install itself moved into the action the packaging jobs share; assert it there so
|
|
// this job cannot keep the step while the action stops installing anything.
|
|
const action = parse(
|
|
readFileSync(
|
|
join(projectDir, '.github/actions/install-mobile-dependencies/action.yml'),
|
|
'utf8'
|
|
)
|
|
)
|
|
const [installStep] = action.runs.steps
|
|
expect(installStep['working-directory']).toBe('mobile')
|
|
expect(installStep.run).toContain('--frozen-lockfile')
|
|
})
|
|
|
|
it('gates each expensive job on its classifier and cache prerequisite', () => {
|
|
for (const jobName of expensiveJobs.filter(
|
|
(jobName) => !['test', 'static_analysis', 'typecheck', 'orcad_browser'].includes(jobName)
|
|
)) {
|
|
expect(prWorkflow.jobs[jobName].needs, jobName).toEqual(['code_paths', 'preflight'])
|
|
expect(prWorkflow.jobs[jobName].if, jobName).toContain(
|
|
`needs.code_paths.outputs.${jobName} == 'true'`
|
|
)
|
|
}
|
|
expect(prWorkflow.jobs.test.if).toContain("needs.preflight.result == 'success'")
|
|
expect(prWorkflow.jobs.test.if).toContain("needs.code_paths.outputs.test == 'true'")
|
|
expect(prWorkflow.jobs.test.with.shards).toBe('${{ needs.preflight.outputs.shards }}')
|
|
expect(prWorkflow.jobs.unit_plan).toBeUndefined()
|
|
expect(prWorkflow.jobs.test_native_cache).toBeUndefined()
|
|
})
|
|
|
|
it('skips e2e detection on docs-only PRs without dropping the draft gate', () => {
|
|
const filter = prWorkflow.jobs.code_paths.steps.find((step) => step.id === 'e2e_filter')
|
|
expect(filter.if).toBe(
|
|
"github.event.pull_request.draft != true && steps.filter.outputs.should_run == 'true'"
|
|
)
|
|
expect(prWorkflow.jobs['e2e-paths']).toBeUndefined()
|
|
})
|
|
|
|
it('lets verify pass skipped jobs the classifier turned off', () => {
|
|
const verifyStep = prWorkflow.jobs.verify.steps.find(
|
|
(step) => step.name === 'Require successful checks'
|
|
)
|
|
expect(prWorkflow.jobs.verify.needs[0]).toBe('code_paths')
|
|
expect(verifyStep.env.SHOULD_RUN).toBe('${{ needs.code_paths.outputs.should_run }}')
|
|
expect(verifyStep.run).toContain('"$CODE_PATHS" != "success"')
|
|
expect(verifyStep.run).toContain('expected skipped')
|
|
expect(verifyStep.run).toContain('expected success')
|
|
for (const job of prWorkflow.jobs.verify.needs) {
|
|
if (job === 'code_paths' || job === 'preflight') {
|
|
continue
|
|
}
|
|
const envVar = `${job.replaceAll('-', '_').toUpperCase()}_SHOULD_RUN`
|
|
expect(verifyStep.env[envVar]).toBe(`\${{ needs.code_paths.outputs.${job} }}`)
|
|
expect(verifyStep.run).toContain(`"$${envVar}"`)
|
|
}
|
|
})
|
|
})
|