mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 08:02:35 +00:00
ci: balance existing unit and E2E shards using recorded timings
This commit is contained in:
@@ -170,8 +170,34 @@ jobs:
|
||||
# artifact instead of starting five concurrent electron-vite builds.
|
||||
# ORCA_E2E_FORWARD_APP_LOGS keeps startup failures visible when Electron
|
||||
# launches but never creates a BrowserWindow.
|
||||
- name: Balance E2E shard from timing evidence
|
||||
env:
|
||||
ORCA_BACKGROUND_LAUNCH: '1'
|
||||
SKIP_BUILD: '1'
|
||||
ORCA_E2E_FORWARD_APP_LOGS: '1'
|
||||
ORCA_E2E_WEB_CLIENT: '1'
|
||||
ORCA_RELAY_PATH: ${{ github.workspace }}/out/relay
|
||||
run: |
|
||||
mkdir -p ci-shards
|
||||
pnpm exec playwright test --config tests/playwright.config.ts --project=electron-headless --list --reporter=json > ci-shards/discovery.json
|
||||
export ORCA_SHARD_SOURCE_SHA="$(git rev-parse HEAD)"
|
||||
node config/scripts/ci-e2e-shard-plan.mjs ci-shards/discovery.json '${{ matrix.shard }}' ci-shards
|
||||
pnpm exec playwright test --config tests/playwright.config.ts --project=electron-headless --test-list=ci-shards/selected.txt --list --reporter=json > ci-shards/selected-discovery.json
|
||||
node config/scripts/ci-e2e-shard-plan.mjs --verify ci-shards/assignment.json ci-shards/selected-discovery.json
|
||||
|
||||
- name: Run E2E tests (${{ matrix.shard_name }})
|
||||
run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay" pnpm run test:e2e --shard=${{ matrix.shard }}
|
||||
env:
|
||||
ORCA_BACKGROUND_LAUNCH: '1'
|
||||
run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay" pnpm run test:e2e --test-list=ci-shards/selected.txt
|
||||
|
||||
- name: Upload E2E shard assignment
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: e2e-shard-${{ matrix.shard_name }}-attempt-${{ github.run_attempt }}
|
||||
path: ci-shards/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
# The frame benchmark needs a mapped window, which the headless shards exclude.
|
||||
- name: Run worktree first-paint benchmark
|
||||
|
||||
@@ -45,7 +45,11 @@ jobs:
|
||||
npx --yes pnpm@10.24.0 --filter '@orca-cloud/relay^...' build
|
||||
|
||||
- name: Test shard
|
||||
env:
|
||||
ORCA_BALANCE_UNIT_SHARDS: '1'
|
||||
ORCA_BACKGROUND_LAUNCH: '1'
|
||||
run: |
|
||||
export ORCA_SHARD_SOURCE_SHA="$(git rev-parse HEAD)"
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
--exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
|
||||
--exclude=src/main/daemon/shell-ready.test.ts \
|
||||
@@ -65,3 +69,12 @@ jobs:
|
||||
--exclude=src/shared/posix-command-path-lookup.test.ts \
|
||||
--exclude=tests/e2e/cross-version-wire/** \
|
||||
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
|
||||
- name: Upload unit shard assignment
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: unit-shard-node-${{ matrix.node }}-${{ matrix.shard }}-attempt-${{ github.run_attempt }}
|
||||
path: ci-shards/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import {
|
||||
balanceFiles,
|
||||
compareIds,
|
||||
readTimingBaseline,
|
||||
writeAssignment
|
||||
} from './ci-shard-assignment.mjs'
|
||||
|
||||
export function discoverE2eFiles(report) {
|
||||
if (report.errors?.length) {
|
||||
throw new Error('Playwright discovery reported errors')
|
||||
}
|
||||
const files = new Map()
|
||||
function visit(suite) {
|
||||
for (const spec of suite.specs ?? []) {
|
||||
const file = spec.file.replaceAll('\\', '/')
|
||||
if (file.startsWith('/') || file.split('/').includes('..') || /[\n\r>›]/.test(file)) {
|
||||
throw new Error(`Unsafe test-list path: ${file}`)
|
||||
}
|
||||
for (const test of spec.tests) {
|
||||
const id = `${test.projectName}:${spec.id}`
|
||||
const ids = files.get(file) ?? []
|
||||
ids.push(id)
|
||||
files.set(file, ids)
|
||||
}
|
||||
}
|
||||
for (const child of suite.suites ?? []) {
|
||||
visit(child)
|
||||
}
|
||||
}
|
||||
for (const suite of report.suites) {
|
||||
visit(suite)
|
||||
}
|
||||
if (!files.size) {
|
||||
throw new Error('Playwright discovered no tests')
|
||||
}
|
||||
const ids = [...files.values()].flat()
|
||||
if (new Set(ids).size !== ids.length) {
|
||||
throw new Error('Duplicate discovered test identity')
|
||||
}
|
||||
return Object.fromEntries([...files.entries()].sort(([a], [b]) => compareIds(a, b)))
|
||||
}
|
||||
|
||||
export function planE2e(report, count, baseline) {
|
||||
const testsByFile = discoverE2eFiles(report)
|
||||
const timings = Object.fromEntries(
|
||||
Object.entries(baseline.timings).map(([file, duration]) => [
|
||||
file.replace(/^tests\/e2e\//, ''),
|
||||
duration
|
||||
])
|
||||
)
|
||||
const assignment = balanceFiles(Object.keys(testsByFile), count, timings)
|
||||
return { ...assignment, testsByFile, baselineSha256: baseline.baselineSha256 }
|
||||
}
|
||||
|
||||
export function verifyE2eSelection(assignment, report) {
|
||||
const actual = Object.values(discoverE2eFiles(report)).flat().sort(compareIds)
|
||||
const expected = assignment.shards[assignment.selectedShard - 1].files
|
||||
.flatMap((file) => assignment.testsByFile[file])
|
||||
.sort(compareIds)
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error('Native Playwright selection differs from shard assignment')
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
if (process.argv[2] === '--verify') {
|
||||
verifyE2eSelection(
|
||||
JSON.parse(readFileSync(process.argv[3], 'utf8')),
|
||||
JSON.parse(readFileSync(process.argv[4], 'utf8'))
|
||||
)
|
||||
} else {
|
||||
const [input, shard, directory] = process.argv.slice(2)
|
||||
const match = shard?.match(/^(\d+)\/(\d+)$/)
|
||||
if (!input || !directory || !match) {
|
||||
throw new Error('Usage: ci-e2e-shard-plan.mjs DISCOVERY INDEX/COUNT OUTPUT_DIRECTORY')
|
||||
}
|
||||
const index = Number(match[1])
|
||||
const count = Number(match[2])
|
||||
if (index < 1 || index > count) {
|
||||
throw new Error('Invalid shard index')
|
||||
}
|
||||
const assignment = planE2e(
|
||||
JSON.parse(readFileSync(input, 'utf8')),
|
||||
count,
|
||||
readTimingBaseline('e2e')
|
||||
)
|
||||
const selected = assignment.shards[index - 1].files
|
||||
if (!selected.length) {
|
||||
throw new Error('Empty E2E shard')
|
||||
}
|
||||
writeAssignment(join(directory, 'assignment.json'), { ...assignment, selectedShard: index })
|
||||
writeFileSync(join(directory, 'selected.txt'), `${selected.join('\n')}\n`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { expect, it } from 'vitest'
|
||||
import { runProcess } from '../../src/shared/child-process/run-process'
|
||||
import { planE2e, verifyE2eSelection } from './ci-e2e-shard-plan.mjs'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
it('native Playwright test-list preserves full discovery, serial suites, skips and headful filtering', async () => {
|
||||
const directory = realpathSync(mkdtempSync(join(tmpdir(), 'orca-playwright-shards-')))
|
||||
const testPackage = JSON.stringify(require.resolve('@stablyai/playwright-test'))
|
||||
const config = join(directory, 'playwright.config.cjs')
|
||||
writeFileSync(
|
||||
config,
|
||||
`module.exports = { testDir: '.', fullyParallel: true, projects: [{ name: 'electron-headless', grepInvert: /@headful/ }] }`
|
||||
)
|
||||
for (let index = 0; index < 17; index++) {
|
||||
writeFileSync(
|
||||
join(directory, `file-${index}.spec.cjs`),
|
||||
`
|
||||
const { test } = require(${testPackage});
|
||||
test('normal', () => {});
|
||||
test.skip('skipped', () => {});
|
||||
test('visible @headful', () => {});
|
||||
test.describe.serial('serial', () => {
|
||||
test('first', () => {});
|
||||
test('second', () => {});
|
||||
});
|
||||
`
|
||||
)
|
||||
}
|
||||
async function discover(extra = []) {
|
||||
const result = await runProcess({
|
||||
program: process.execPath,
|
||||
cwd: directory,
|
||||
args: [
|
||||
join(dirname(require.resolve('playwright/package.json')), 'cli.js'),
|
||||
'test',
|
||||
'--config',
|
||||
config,
|
||||
'--list',
|
||||
'--reporter=json',
|
||||
...extra
|
||||
],
|
||||
env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' },
|
||||
timeoutMs: 20000
|
||||
})
|
||||
expect(result.code, result.stderr).toBe(0)
|
||||
return JSON.parse(result.stdout)
|
||||
}
|
||||
try {
|
||||
const full = await discover()
|
||||
const assignment = planE2e(full, 14, { timings: {} })
|
||||
const ids = []
|
||||
for (let index = 0; index < 14; index++) {
|
||||
const path = join(directory, 'selected.txt')
|
||||
writeFileSync(path, `${assignment.shards[index].files.join('\n')}\n`)
|
||||
const selected = await discover(['--test-list', path])
|
||||
verifyE2eSelection({ ...assignment, selectedShard: index + 1 }, selected)
|
||||
for (const suite of selected.suites) {
|
||||
expect(suite.specs.some((spec) => spec.title.includes('@headful'))).toBe(false)
|
||||
}
|
||||
ids.push(...assignment.shards[index].files.flatMap((file) => assignment.testsByFile[file]))
|
||||
}
|
||||
expect(ids).toHaveLength(17 * 4)
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
expect(() => verifyE2eSelection({ ...assignment, selectedShard: 1 }, full)).toThrow('differs')
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
}, 60000)
|
||||
@@ -0,0 +1,66 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
|
||||
export const compareIds = (a, b) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
|
||||
export function balanceFiles(files, count, timings, overheadMs = 0) {
|
||||
if (!Number.isInteger(count) || count < 1) {
|
||||
throw new Error('Invalid shard count')
|
||||
}
|
||||
if (new Set(files).size !== files.length) {
|
||||
throw new Error('Duplicate discovered file')
|
||||
}
|
||||
const known = Object.values(timings).filter((value) => Number.isFinite(value) && value > 0)
|
||||
known.sort((a, b) => a - b)
|
||||
const fallbackMs = known[Math.floor(known.length / 2)] ?? 1000
|
||||
const weighted = files.map((file) => ({
|
||||
file,
|
||||
durationMs:
|
||||
(Number.isFinite(timings[file]) && timings[file] > 0 ? timings[file] : fallbackMs) +
|
||||
overheadMs
|
||||
}))
|
||||
weighted.sort((a, b) => b.durationMs - a.durationMs || compareIds(a.file, b.file))
|
||||
const shards = Array.from({ length: count }, () => ({ files: [], durationMs: 0 }))
|
||||
for (const entry of weighted) {
|
||||
const target = shards.reduce((best, shard) =>
|
||||
shard.durationMs < best.durationMs ||
|
||||
(shard.durationMs === best.durationMs && shard.files.length < best.files.length)
|
||||
? shard
|
||||
: best
|
||||
)
|
||||
target.files.push(entry.file)
|
||||
target.durationMs += entry.durationMs
|
||||
}
|
||||
for (const shard of shards) {
|
||||
shard.files.sort(compareIds)
|
||||
}
|
||||
const assigned = shards.flatMap((shard) => shard.files).sort(compareIds)
|
||||
if (JSON.stringify(assigned) !== JSON.stringify([...files].sort(compareIds))) {
|
||||
throw new Error('Shard coverage differs from discovery')
|
||||
}
|
||||
return { algorithm: 'file-lpt-v1', fallbackMs, overheadMs, shards }
|
||||
}
|
||||
|
||||
export function readTimingBaseline(suite) {
|
||||
const bytes = readFileSync(new URL('./ci-shard-timings.json', import.meta.url), 'utf8')
|
||||
const baseline = JSON.parse(bytes)
|
||||
return { ...baseline[suite], baselineSha256: createHash('sha256').update(bytes).digest('hex') }
|
||||
}
|
||||
|
||||
export function writeAssignment(path, assignment) {
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
writeFileSync(
|
||||
path,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
sourceSha: process.env.ORCA_SHARD_SOURCE_SHA ?? process.env.GITHUB_SHA ?? null,
|
||||
runId: process.env.GITHUB_RUN_ID ?? null,
|
||||
runAttempt: process.env.GITHUB_RUN_ATTEMPT ?? null,
|
||||
...assignment
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { BaseSequencer } from 'vitest/node'
|
||||
import { balanceFiles } from './ci-shard-assignment.mjs'
|
||||
import { discoverE2eFiles, planE2e } from './ci-e2e-shard-plan.mjs'
|
||||
import { parseTimingLog } from './ci-shard-timing-import.mjs'
|
||||
import TimingSequencer from './ci-unit-sequencer.mjs'
|
||||
|
||||
const directories = []
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
for (const directory of directories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('timing-weighted shard selection', () => {
|
||||
it('distributes long files, includes unknowns exactly once, and ignores discovery order', () => {
|
||||
const files = ['long', 'medium', 'short', 'unknown', 'new', 'zero', 'invalid']
|
||||
const timings = { long: 100, medium: 80, short: 20, zero: 0, invalid: -1, deleted: 20 }
|
||||
const plan = balanceFiles(files, 3, timings, 10)
|
||||
expect(plan).toEqual(balanceFiles(files.toReversed(), 3, timings, 10))
|
||||
expect(plan.fallbackMs).toBe(80)
|
||||
expect(plan.shards.flatMap((shard) => shard.files).sort()).toEqual([...files].sort())
|
||||
expect(Math.max(...plan.shards.map((shard) => shard.durationMs))).toBeLessThan(250)
|
||||
})
|
||||
|
||||
it('has a deterministic cold fallback and permits fewer files than shards', () => {
|
||||
expect(balanceFiles(['b', 'a'], 3, {}).shards).toEqual([
|
||||
{ files: ['a'], durationMs: 1000 },
|
||||
{ files: ['b'], durationMs: 1000 },
|
||||
{ files: [], durationMs: 0 }
|
||||
])
|
||||
expect(() => balanceFiles(['a', 'a'], 8, {})).toThrow('Duplicate')
|
||||
expect(() => balanceFiles(['a'], 0, {})).toThrow('count')
|
||||
})
|
||||
|
||||
it('uses the post-filter Vitest discovery unchanged across eight shards and retains default sort', async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-unit-shards-'))
|
||||
directories.push(directory)
|
||||
vi.stubEnv('ORCA_SHARD_MANIFEST', join(directory, 'assignment.json'))
|
||||
const specs = Array.from({ length: 37 }, (_, i) => ({
|
||||
moduleId: resolve(`src/fixture-${i}.test.ts`)
|
||||
}))
|
||||
const selected = []
|
||||
for (let index = 1; index <= 8; index++) {
|
||||
const sequencer = new TimingSequencer({
|
||||
config: { root: process.cwd(), shard: { index, count: 8 } }
|
||||
})
|
||||
expect(sequencer.sort).toBe(BaseSequencer.prototype.sort)
|
||||
selected.push(...(await sequencer.shard(specs)))
|
||||
const manifest = JSON.parse(readFileSync(join(directory, 'assignment.json'), 'utf8'))
|
||||
expect(manifest.selectedShard).toBe(index)
|
||||
expect(manifest.baselineSha256).toMatch(/^[a-f0-9]{64}$/)
|
||||
}
|
||||
expect(new Set(selected).size).toBe(specs.length)
|
||||
expect(selected).toHaveLength(specs.length)
|
||||
expect(new Set(selected)).toEqual(new Set(specs))
|
||||
})
|
||||
|
||||
it('wires a constructor into the opt-in Vitest config', async () => {
|
||||
vi.stubEnv('ORCA_BALANCE_UNIT_SHARDS', '1')
|
||||
const { default: config } = await import('../vitest.config')
|
||||
expect(config.test.sequence.sequencer).toBe(TimingSequencer)
|
||||
})
|
||||
|
||||
it('keeps nested/serial E2E files atomic and fails closed on discovery errors', () => {
|
||||
const spec = (id, file) => ({ id, file, tests: [{ projectName: 'electron-headless' }] })
|
||||
const report = {
|
||||
suites: [
|
||||
{
|
||||
specs: [spec('a', 'one.spec.ts')],
|
||||
suites: [{ specs: [spec('b', 'one.spec.ts'), spec('c', 'two.spec.ts')] }]
|
||||
}
|
||||
]
|
||||
}
|
||||
const plan = planE2e(report, 14, { timings: { 'tests/e2e/one.spec.ts': 4000 } })
|
||||
expect(plan.shards.flatMap((shard) => shard.files).sort()).toEqual([
|
||||
'one.spec.ts',
|
||||
'two.spec.ts'
|
||||
])
|
||||
expect(plan.testsByFile['one.spec.ts']).toHaveLength(2)
|
||||
expect(() => discoverE2eFiles({ ...report, errors: [{}] })).toThrow('errors')
|
||||
expect(() => discoverE2eFiles({ suites: [] })).toThrow('no tests')
|
||||
expect(() =>
|
||||
discoverE2eFiles({ suites: [{ specs: [spec('a', '../escape.spec.ts')] }] })
|
||||
).toThrow('Unsafe')
|
||||
expect(() =>
|
||||
discoverE2eFiles({
|
||||
suites: [{ specs: [spec('a', 'one.spec.ts'), spec('a', 'one.spec.ts')] }]
|
||||
})
|
||||
).toThrow('Duplicate')
|
||||
})
|
||||
|
||||
it('imports ANSI unit timings and E2E failures without counting headful reruns', () => {
|
||||
const parsed = parseTimingLog(
|
||||
[
|
||||
'\u001b[32m✓\u001b[39m src/a.test.ts (2 tests) 35ms',
|
||||
'Duration 1s (transform 0.1s, setup 0.2s, import 0.3s, tests 0.04s, environment 0.4s)',
|
||||
'✓ 1 [electron-headless] › tests/e2e/a.spec.ts:1:1 › works (2s)',
|
||||
'✘ 2 [electron-headless] › tests/e2e/a.spec.ts:2:1 › fails (1.2m)',
|
||||
'✓ 3 [electron-headful] › tests/e2e/a.spec.ts:3:1 › benchmark (9s)'
|
||||
].join('\n')
|
||||
)
|
||||
expect(parsed).toEqual({
|
||||
unit: { 'src/a.test.ts': 35 },
|
||||
e2e: { 'tests/e2e/a.spec.ts': 74000 },
|
||||
overheadMs: 1000
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { stripVTControlCharacters } from 'node:util'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
export function parseTimingLog(text) {
|
||||
const clean = stripVTControlCharacters(text)
|
||||
const unit = {}
|
||||
const e2e = {}
|
||||
for (const match of clean.matchAll(
|
||||
/[✓×❯] ([\w./-]+\.test\.(?:ts|tsx|mjs)) \([^\n]*?\)\s+([\d.]+)ms/g
|
||||
)) {
|
||||
unit[match[1]] = Number(match[2])
|
||||
}
|
||||
for (const match of clean.matchAll(
|
||||
/[✓✘]\s+\d+ \[electron-headless\] › (tests\/e2e\/[^:]+):\d+:\d+ › .*? \(([\d.]+)(ms|s|m)\)/g
|
||||
)) {
|
||||
e2e[match[1]] = (e2e[match[1]] ?? 0) + Number(match[2]) * { ms: 1, s: 1000, m: 60000 }[match[3]]
|
||||
}
|
||||
const summary = clean.match(
|
||||
/Duration\s+[\d.]+s \(transform ([\d.]+)s, setup ([\d.]+)s, import ([\d.]+)s, tests [\d.]+s, environment ([\d.]+)s\)/
|
||||
)
|
||||
return {
|
||||
unit,
|
||||
e2e,
|
||||
overheadMs: summary ? summary.slice(1).reduce((sum, value) => sum + Number(value) * 1000, 0) : 0
|
||||
}
|
||||
}
|
||||
|
||||
export function importTimingLogs(directory, unitRun, e2eRun) {
|
||||
const baseline = {
|
||||
unit: { runId: unitRun, jobIds: [], overheadMs: 0, timings: {} },
|
||||
e2e: { runId: e2eRun, jobIds: [], overheadMs: 0, timings: {} }
|
||||
}
|
||||
for (const file of readdirSync(directory)
|
||||
.filter((file) => /^log-\d+\.txt$/.test(file))
|
||||
.sort()) {
|
||||
const parsed = parseTimingLog(readFileSync(join(directory, file), 'utf8'))
|
||||
for (const suite of ['unit', 'e2e']) {
|
||||
if (!Object.keys(parsed[suite]).length) {
|
||||
continue
|
||||
}
|
||||
baseline[suite].jobIds.push(file.match(/\d+/)[0])
|
||||
for (const [name, duration] of Object.entries(parsed[suite])) {
|
||||
if (suite === 'unit' && name in baseline.unit.timings) {
|
||||
throw new Error(`Duplicate unit timing: ${name}`)
|
||||
}
|
||||
baseline[suite].timings[name] = (baseline[suite].timings[name] ?? 0) + duration
|
||||
}
|
||||
}
|
||||
baseline.unit.overheadMs += parsed.overheadMs
|
||||
}
|
||||
for (const suite of ['unit', 'e2e']) {
|
||||
if (!baseline[suite].jobIds.length) {
|
||||
throw new Error(`No ${suite} timing evidence`)
|
||||
}
|
||||
baseline[suite].timings = Object.fromEntries(
|
||||
Object.entries(baseline[suite].timings).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
)
|
||||
}
|
||||
baseline.unit.overheadMs = Math.ceil(
|
||||
baseline.unit.overheadMs / Object.keys(baseline.unit.timings).length
|
||||
)
|
||||
return baseline
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const [directory, unitRun, e2eRun, output] = process.argv.slice(2)
|
||||
if (!directory || !unitRun || !e2eRun || !output) {
|
||||
throw new Error('Usage: ci-shard-timing-import.mjs LOG_DIRECTORY UNIT_RUN E2E_RUN OUTPUT')
|
||||
}
|
||||
writeFileSync(
|
||||
output,
|
||||
`${JSON.stringify(importTimingLogs(directory, unitRun, e2eRun), null, 2)}\n`
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,93 @@
|
||||
# Timing-based CI shards
|
||||
|
||||
The eight unit shards and fourteen general E2E shards use longest-processing-time
|
||||
assignment of whole files to the currently lightest shard. Ties use file path and
|
||||
then shard index, independent of filesystem enumeration and locale. Unknown,
|
||||
zero, or invalid durations use the baseline's positive median (1 second when no
|
||||
positive evidence exists). Deleted files never enter discovery. Unit weights add
|
||||
526ms per file for measured transform/setup/import/environment overhead.
|
||||
|
||||
Unit assignment runs inside Vitest's sequencer after discovery and CLI exclusions;
|
||||
Vitest's default sort, workers and isolation remain intact. It is enabled only by
|
||||
`ORCA_BALANCE_UNIT_SHARDS=1`; ordinary local runs and explicit file filters retain
|
||||
their existing behavior. E2E uses Playwright's native `--list` and `--test-list`,
|
||||
retaining project filters, skipped tests and complete serial groups within files.
|
||||
The workflow verifies selected test IDs against full discovery before executing.
|
||||
Dedicated SSH, native IME, WSL and first-paint lanes are unchanged.
|
||||
|
||||
## Evidence and limits
|
||||
|
||||
`ci-shard-timings.json` records run IDs and every contributing job ID:
|
||||
|
||||
- Unit run **34675583768**, Node 24, all eight successful shards: 8,484 completed
|
||||
file durations. The summed transform/setup/import/environment durations divided
|
||||
by measured file count give a rounded-up **526ms** per-file overhead allowance.
|
||||
The original shard weighted loads were **764–849 worker-seconds**, versus
|
||||
**792–792** after balancing the identical measured files. File counts change
|
||||
from **1,056–1,065** to **1,060–1,061**.
|
||||
- General E2E run **34652504501**, all fourteen shard logs: 291 files with completed
|
||||
headless test durations, including failures. Headful benchmark reruns are not
|
||||
counted. Original completed test loads were **540–1,727 seconds**, versus
|
||||
**1,083–1,093** after whole-file balancing on the same measured files. The longest
|
||||
measured file is **528 seconds**, below the balanced shard load.
|
||||
- Current checkout discovery at validation contained **8,553 unit files** after the
|
||||
workflow's exact exclusions and **733 headless E2E tests in 340 files**. New and
|
||||
unmeasured files remain selected. Projected current loads were about **797
|
||||
worker-seconds** per unit shard (1,068–1,070 files) and **1,190–1,200 seconds** per
|
||||
E2E shard (22–25 files).
|
||||
|
||||
These are scheduling projections, not measured post-change wall-clock gains.
|
||||
Unit durations overlap across workers and the overhead allowance is an average,
|
||||
not a per-file import profile. E2E evidence includes failed shards and can omit
|
||||
unfinished tests; unknowns receive a deterministic estimate. Historical timings
|
||||
age as specs change. Full CI runs on the existing runner classes are required to
|
||||
measure elapsed-time and occupancy improvements, including discovery overhead.
|
||||
No retries, assertions, coverage exclusions, runner classes or shard counts changed.
|
||||
|
||||
## Reproduction and refresh
|
||||
|
||||
Every shard uploads an artifact named with its shard, Node version where relevant,
|
||||
and run attempt. `assignment.json` contains the checked-out source SHA, run ID,
|
||||
attempt, baseline SHA-256, algorithm, fallback, all shard files and chosen shard.
|
||||
E2E also retains both discovery reports and `selected.txt`. Artifacts live for
|
||||
14 days. A rerun of the same source uses the same checked-in baseline rather than
|
||||
mutable timing caches; a GitHub job rerun therefore keeps its assignment.
|
||||
|
||||
For E2E reproduction, check out the recorded source and pass the saved list to the
|
||||
existing command: `pnpm run test:e2e --test-list=/path/to/selected.txt` with the same
|
||||
CI environment/build inputs. For unit reproduction, use the unchanged workflow
|
||||
command and exclusions with `ORCA_BALANCE_UNIT_SHARDS=1` and the recorded
|
||||
`--shard=INDEX/8`. Direct test-file reruns remain supported.
|
||||
|
||||
To refresh the baseline, download `log-JOB_ID.txt` files into one directory from
|
||||
exactly one eight-shard unit run and one fourteen-shard general E2E run. Use the
|
||||
job IDs from the Actions jobs API and fetch each with
|
||||
`gh api repos/stablyai/orca/actions/jobs/JOB_ID/logs`. Do not include dedicated
|
||||
lanes or multiple attempts. Then run:
|
||||
|
||||
```sh
|
||||
node config/scripts/ci-shard-timing-import.mjs LOG_DIRECTORY UNIT_RUN_ID E2E_RUN_ID config/scripts/ci-shard-timings.json
|
||||
```
|
||||
|
||||
The initial source logs are in `/tmp/orca-ci-shard-logs`; two were reused from
|
||||
`/tmp/orca-ci-audit`, and the remaining twenty were fetched read-only. Reimporting
|
||||
those logs reproduced the checked-in JSON byte-for-byte. Review file-count and
|
||||
load projections before adopting a new baseline; no network access is needed to
|
||||
plan or run shards.
|
||||
|
||||
## Validation
|
||||
|
||||
- 74 focused tests passed across the two new test files and existing PR
|
||||
parallelism, E2E gate and release E2E dispatch contracts.
|
||||
- The pinned Playwright CLI selected the real 733-test suite across all fourteen
|
||||
saved test lists with exact-once identity coverage and no missing tests.
|
||||
- A temporary native Playwright fixture checks fourteen shards, serial groups,
|
||||
skipped cases, headful filtering and mismatch rejection without launching UI.
|
||||
- Real Vitest discovery with all workflow exclusions yielded 8,553 files; the
|
||||
sequencer's eight assignments covered each exactly once. An actual opt-in
|
||||
Vitest shard executed successfully and persisted its manifest.
|
||||
- Focused TypeScript checking of `config/vitest.config.ts` and imported modules,
|
||||
oxlint, formatting and baseline reimport checks passed.
|
||||
|
||||
All local tests used `ORCA_BACKGROUND_LAUNCH=1` in background tool sessions. No app
|
||||
windows or full E2E test bodies were launched.
|
||||
@@ -0,0 +1,19 @@
|
||||
import { relative } from 'node:path'
|
||||
import { BaseSequencer } from 'vitest/node'
|
||||
import { balanceFiles, readTimingBaseline, writeAssignment } from './ci-shard-assignment.mjs'
|
||||
|
||||
export default class TimingSequencer extends BaseSequencer {
|
||||
async shard(specs) {
|
||||
const { index, count } = this.ctx.config.shard
|
||||
const key = (spec) => relative(this.ctx.config.root, spec.moduleId).replaceAll('\\', '/')
|
||||
const baseline = readTimingBaseline('unit')
|
||||
const assignment = balanceFiles(specs.map(key), count, baseline.timings, baseline.overheadMs)
|
||||
writeAssignment(process.env.ORCA_SHARD_MANIFEST ?? 'ci-shards/unit-assignment.json', {
|
||||
...assignment,
|
||||
baselineSha256: baseline.baselineSha256,
|
||||
selectedShard: index
|
||||
})
|
||||
const selected = new Set(assignment.shards[index - 1].files)
|
||||
return specs.filter((spec) => selected.has(key(spec)))
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { resolve } from 'node:path'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import TimingSequencer from './scripts/ci-unit-sequencer.mjs'
|
||||
|
||||
const windowsTestWorkerOptions = process.platform === 'win32' ? { maxWorkers: 4 } : {}
|
||||
|
||||
@@ -15,6 +16,9 @@ export default defineConfig({
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
...(process.env.ORCA_BALANCE_UNIT_SHARDS === '1'
|
||||
? { sequence: { sequencer: TimingSequencer } }
|
||||
: {}),
|
||||
// Why: Node 26's undefined Web Storage globals prevent Vitest from installing happy-dom's.
|
||||
// Why --expose-gc: retention tests need a deterministic collection point to measure what a queue really holds.
|
||||
execArgv: ['--no-experimental-webstorage', '--expose-gc'],
|
||||
|
||||
Reference in New Issue
Block a user