mirror of
https://github.com/stablyai/orca.git
synced 2026-10-06 16:02:25 +00:00
* Reuse the headless detector compiler without full dependency setup * Keep optional compiler-cache saves from failing cache warming
205 lines
7.6 KiB
JavaScript
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)
|
|
}
|
|
})
|