Files
orca/config/scripts/headless-detector-compiler-cache.test.mjs
T
Neil a824fb74ab Reuse the headless detector compiler without installing full dependencies (#24895)
* Reuse the headless detector compiler without full dependency setup

* Keep optional compiler-cache saves from failing cache warming
2026-10-02 16:56:08 -07:00

205 lines
7.6 KiB
JavaScript

import {
appendFileSync,
cpSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
symlinkSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it } from 'vitest'
import { parse } from 'yaml'
import {
activateCompilerCache,
compilerCacheIdentity,
packCompilerCache
} from './headless-detector-compiler-cache.mjs'
import { collectNodeServerInputs } from './node-server-change-scope.mjs'
import { runProcessSync } from './script-child-process.mjs'
const temporary = []
afterEach(() => {
for (const dir of temporary.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
})
function fixture() {
const directory = mkdtempSync(join(tmpdir(), 'headless-compiler-cache-'))
temporary.push(directory)
const root = join(directory, 'checkout')
mkdirSync(root)
writeFileSync(join(root, 'package.json'), '{"type":"module"}')
const identity = compilerCacheIdentity({ policyHash: 'policy', cacheRoot: directory })
packCompilerCache({ identity })
return { root, identity }
}
function changeManifest(identity, update) {
const path = join(identity.path, 'manifest.json')
const manifest = JSON.parse(readFileSync(path, 'utf8'))
update(manifest)
writeFileSync(path, JSON.stringify(manifest))
}
it('separates policy, Node, platform and architecture identities with the same archive path', () => {
const options = {
policyHash: 'policy',
cacheRoot: '/cache',
platform: 'linux',
arch: 'x64',
node: 'v24.21.0'
}
const original = compilerCacheIdentity(options)
for (const override of [
{ policyHash: 'changed' },
{ node: 'v24.22.0' },
{ platform: 'darwin' },
{ arch: 'arm64' }
]) {
const other = compilerCacheIdentity({ ...options, ...override })
expect(other.key).not.toBe(original.key)
expect(other.path).toBe(original.path)
}
})
it('activates only the actual compiler packages and preserves import graph behavior', async () => {
const { root, identity } = fixture()
expect(await activateCompilerCache({ root, identity })).toEqual({ available: true })
expect(readdirSync(join(root, 'node_modules')).sort()).toEqual(['@esbuild', 'esbuild'])
for (const name of [
'node-server-change-scope',
'node-server-test-paths',
'node-server-qualification',
'orcad-entry-build'
]) {
mkdirSync(join(root, 'config', 'scripts'), { recursive: true })
cpSync(
new URL(`./${name}.mjs`, import.meta.url),
join(root, 'config', 'scripts', `${name}.mjs`)
)
}
writeFileSync(
join(root, 'entry.ts'),
"import './first'; export * from './exports'; import('./dynamic'); require('./required'); import 'external-package'; import './native.node'"
)
for (const name of ['first', 'exports', 'dynamic', 'required']) {
writeFileSync(join(root, `${name}.ts`), 'export const value = 1')
}
writeFileSync(
join(root, 'probe.mjs'),
"import { collectNodeServerInputs } from './config/scripts/node-server-change-scope.mjs'; console.log(JSON.stringify([...(await collectNodeServerInputs({ root: process.cwd(), entryPoints: ['entry.ts'] }))].sort()))"
)
const baseline = [...(await collectNodeServerInputs({ root, entryPoints: ['entry.ts'] }))].sort()
const candidate = runProcessSync({
program: process.execPath,
args: ['probe.mjs'],
cwd: root,
timeoutMs: 10_000
})
expect(candidate.code, candidate.stderr).toBe(0)
expect(JSON.parse(candidate.stdout.trim())).toEqual(baseline)
}, 20_000)
it.each(['key', 'node', 'version', 'files'])(
'falls back on invalid manifest %s and cleans partial activation',
async (field) => {
const { root, identity } = fixture()
changeManifest(identity, (manifest) => {
manifest[field] = 'wrong'
})
expect((await activateCompilerCache({ root, identity })).available).toBe(false)
expect(existsSync(join(root, 'node_modules'))).toBe(false)
}
)
it.each(['modified', 'missing', 'extra', 'symlink', 'malformed'])(
'falls back on %s cache contents before loading code',
async (kind) => {
const { root, identity } = fixture()
const compiler = join(identity.path, 'node_modules', 'esbuild', 'lib', 'main.js')
if (kind === 'modified') {
appendFileSync(compiler, '\nthrow Error("must not load")')
}
if (kind === 'missing') {
rmSync(compiler)
}
if (kind === 'extra') {
writeFileSync(join(identity.path, 'unexpected'), 'extra')
}
if (kind === 'symlink') {
rmSync(compiler)
symlinkSync(join(root, 'package.json'), compiler)
}
if (kind === 'malformed') {
writeFileSync(join(identity.path, 'manifest.json'), '{')
}
expect((await activateCompilerCache({ root, identity })).available).toBe(false)
expect(existsSync(join(root, 'node_modules'))).toBe(false)
}
)
it('leaves existing dependencies alone and falls back on an absent archive', async () => {
const { root, identity } = fixture()
rmSync(identity.path, { recursive: true })
expect((await activateCompilerCache({ root, identity })).available).toBe(false)
mkdirSync(join(root, 'node_modules'))
writeFileSync(join(root, 'node_modules', 'retained'), 'retained')
expect((await activateCompilerCache({ root, identity })).available).toBe(false)
expect(readFileSync(join(root, 'node_modules', 'retained'), 'utf8')).toBe('retained')
})
it('uses exact optional restores, seeds only main and retains full dependency fallback', () => {
const action = parse(readFileSync('.github/actions/prepare-headless-compiler/action.yml', 'utf8'))
const steps = action.runs.steps
const restore = steps.find((step) => step.id === 'cache')
expect(restore.uses).toBe('actions/cache/restore@v5')
expect(restore['continue-on-error']).toBe(true)
expect(restore.with['restore-keys']).toBeUndefined()
const save = steps.find((step) => step.uses === 'actions/cache/save@v5')
expect(save.if).toContain("github.ref == 'refs/heads/main'")
expect(save.if).toContain("github.event_name != 'pull_request'")
expect(save['continue-on-error']).toBe(true)
expect(save.with).toEqual(restore.with)
const workflow = parse(readFileSync('.github/workflows/node-server-tests.yml', 'utf8'))
const detector = workflow.jobs.changes.steps
const cached = detector.find((step) => step.id === 'compiler')
expect(cached.if).toBe("steps.scope.outputs.graph_required == 'true'")
expect(cached['continue-on-error']).toBe(true)
expect(
detector.find((step) => step.uses === './.github/actions/install-node-dependencies').if
).toBe(
"steps.scope.outputs.graph_required == 'true' && steps.compiler.outputs.available != 'true'"
)
for (const event of ['push', 'pull_request']) {
expect(workflow.on[event].paths).toContain('.github/actions/prepare-headless-compiler/**')
}
const warmer = parse(readFileSync('.github/workflows/ci-cache-warmup.yml', 'utf8'))
const warmSteps = warmer.jobs.warm.steps
expect(
warmSteps.findIndex((step) => step.uses === './.github/actions/prepare-headless-compiler')
).toBeGreaterThan(
warmSteps.findIndex((step) => step.uses === './.github/actions/install-node-dependencies')
)
for (const event of ['push', 'pull_request']) {
expect(warmer.on[event].paths).toContain('.github/actions/prepare-headless-compiler/**')
}
const inputs = [
...steps.find((step) => step.id === 'identity').env.COMPILER_POLICY_HASH.matchAll(/'([^']+)'/g)
].map((match) => match[1])
for (const input of inputs) {
expect(
warmer.on.push.paths.some(
(pattern) =>
pattern === input ||
(pattern.endsWith('/**') && input.startsWith(pattern.slice(0, -2))) ||
(pattern.endsWith('*') && input.startsWith(pattern.slice(0, -1)))
),
input
).toBe(true)
}
})