mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 17:47:40 +00:00
295 lines
10 KiB
JavaScript
295 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
// Static gate: a checked-in test file that no runner collects passes vacuously, so every one must be collected.
|
|
|
|
import { execFileSync } from 'node:child_process'
|
|
import { readFileSync } from 'node:fs'
|
|
import path from 'node:path'
|
|
import process from 'node:process'
|
|
import { pathToFileURL } from 'node:url'
|
|
import ts from 'typescript-api'
|
|
import { defaultExclude } from 'vitest/config'
|
|
import { UNIT_EXCLUDE, UNIT_INCLUDE } from './ci-unit-files.mjs'
|
|
|
|
const VITEST_DEFAULT_INCLUDE = '**/*.{test,spec}.?(c|m)[jt]s?(x)'
|
|
|
|
const TEST_FILE_NAME = /\.(?:test|spec|e2e|bench)\.[cm]?[jt]sx?$/
|
|
|
|
// Mirror literals must appear in runner sources; list manual rows each run so their lack of CI remains visible.
|
|
const NON_UNIT_TEST_RUNNERS = [
|
|
{
|
|
name: 'Playwright e2e',
|
|
include: ['tests/e2e/**/*.spec.ts'],
|
|
sources: ['tests/playwright.config.ts'],
|
|
mirrors: ["testDir: './e2e'", "testMatch: '**/*.spec.ts'"]
|
|
},
|
|
{
|
|
name: 'computer-use e2e',
|
|
include: ['tests/e2e/computer-*.e2e.ts'],
|
|
sources: ['tests/e2e/vitest.config.ts'],
|
|
mirrors: ["'tests/e2e/computer-*.e2e.ts'"]
|
|
},
|
|
{
|
|
name: 'performance benchmarks (pnpm test:perf:benchmarks)',
|
|
manual: true,
|
|
include: [
|
|
'src/renderer/src/components/editor/monaco-content-sync.bench.ts',
|
|
'src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts'
|
|
],
|
|
sources: ['config/vitest.performance.config.ts'],
|
|
mirrors: [
|
|
"'src/renderer/src/components/editor/monaco-content-sync.bench.ts'",
|
|
"'src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts'",
|
|
'benchmark: { include: benchmarks }'
|
|
]
|
|
},
|
|
{
|
|
name: 'agent-status benchmark',
|
|
manual: true,
|
|
include: ['config/scripts/agent-status-hot-path-benchmark.ts'],
|
|
sources: ['config/vitest.agent-status-benchmark.config.ts'],
|
|
mirrors: ["'config/scripts/agent-status-hot-path-benchmark.ts'"]
|
|
},
|
|
{
|
|
name: 'mobile',
|
|
include: ['mobile/src/**/*.test.ts', 'mobile/src/**/*.test.tsx', 'mobile/scripts/**/*.test.ts'],
|
|
sources: ['mobile/vitest.config.ts'],
|
|
mirrors: [
|
|
'root: import.meta.dirname',
|
|
"'src/**/*.test.ts', 'src/**/*.test.tsx', 'scripts/**/*.test.ts'"
|
|
]
|
|
},
|
|
...['connector', 'push', 'relay'].map((app) => ({
|
|
name: `cloud ${app}`,
|
|
include: [`cloud/apps/${app}/src/**/*.test.ts`],
|
|
sources: [`cloud/apps/${app}/vitest.config.ts`],
|
|
mirrors: ["include: ['src/**/*.test.ts']"]
|
|
})),
|
|
...[
|
|
'cloud/apps/relay-fence-broker',
|
|
'cloud/packages/cloud-database',
|
|
'cloud/packages/connector-contract',
|
|
'cloud/packages/postgres-schema',
|
|
'cloud/packages/push-contract',
|
|
'cloud/packages/relay-contract'
|
|
].map((dir) => ({
|
|
name: dir,
|
|
include: [`${dir}/${VITEST_DEFAULT_INCLUDE}`],
|
|
sources: [`${dir}/package.json`],
|
|
mirrors: ['vitest run']
|
|
})),
|
|
{
|
|
name: 'cloud node:test',
|
|
include: [
|
|
'cloud/dev/scripts/run-relay-tests.test.mjs',
|
|
'.github/actions/cloud-sql-rollout-lease/*.test.mjs'
|
|
],
|
|
sources: ['cloud/package.json'],
|
|
mirrors: [
|
|
'dev/scripts/run-relay-tests.test.mjs',
|
|
'../.github/actions/cloud-sql-rollout-lease/*.test.mjs'
|
|
]
|
|
},
|
|
{
|
|
name: 'docs site',
|
|
include: ['docs/site/tests/*.test.mjs'],
|
|
sources: ['docs/site/package.json'],
|
|
mirrors: ['node --test tests/*.test.mjs']
|
|
}
|
|
]
|
|
|
|
const DEDICATED_UNIT_FILES = UNIT_EXCLUDE.filter((glob) => !defaultExclude.includes(glob))
|
|
|
|
export const TEST_RUNNERS = [
|
|
{ name: 'unit (config/vitest.config.ts)', include: UNIT_INCLUDE, exclude: UNIT_EXCLUDE },
|
|
{
|
|
// Why mirrors: an UNIT_EXCLUDE entry no workflow names would otherwise leave CI silently.
|
|
name: 'dedicated unit jobs (UNIT_EXCLUDE)',
|
|
include: DEDICATED_UNIT_FILES,
|
|
// Those jobs run config/vitest.config.ts, so a file there still needs a UNIT_INCLUDE name.
|
|
within: UNIT_INCLUDE,
|
|
sources: ['.github/workflows/pr.yml', '.github/workflows/unit-tests.yml'],
|
|
mirrors: DEDICATED_UNIT_FILES.map((glob) => glob.replace(/\*\*$/, ''))
|
|
},
|
|
...NON_UNIT_TEST_RUNNERS
|
|
]
|
|
|
|
const CODE_FILE = /\.[cm]?[jt]sx?$/
|
|
const TEST_FRAMEWORK_IMPORT = /\bfrom\s+['"](?:vitest|node:test|@playwright\/test|bun:test)['"]/
|
|
const TEST_REGISTRATION =
|
|
/^\s*(?:describe|it|test|suite|bench)(?:\.\w+)*(?:\([^)]*\))?\s*\(\s*[`'"]/m
|
|
|
|
export function isTestLike(file, source) {
|
|
return (
|
|
TEST_FILE_NAME.test(file) ||
|
|
(TEST_FRAMEWORK_IMPORT.test(source) && TEST_REGISTRATION.test(source))
|
|
)
|
|
}
|
|
|
|
export function findRunnerDrift(runners, readSource) {
|
|
const problems = []
|
|
for (const { name, sources = [], mirrors = [] } of runners) {
|
|
const texts = sources.map((source) => [source, readSource(source)])
|
|
for (const [source, text] of texts) {
|
|
if (text === null) {
|
|
problems.push(`${name}: ${source} is missing`)
|
|
}
|
|
}
|
|
const combined = texts.map(([, text]) => text ?? '').join('\n')
|
|
for (const literal of mirrors) {
|
|
if (!combined.includes(literal)) {
|
|
problems.push(`${name}: ${sources.join(', ')} do not contain ${JSON.stringify(literal)}`)
|
|
}
|
|
}
|
|
}
|
|
return problems
|
|
}
|
|
|
|
function collects({ include, within = include, exclude = [] }, file) {
|
|
const matches = (glob) => path.matchesGlob(file, glob)
|
|
return include.some(matches) && within.some(matches) && !exclude.some(matches)
|
|
}
|
|
|
|
// Vite's default resolve.extensions, in its order; the unit config's aliases.
|
|
const RESOLVE_EXTENSIONS = ['.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx', '.json']
|
|
const TS_SOURCE_TWINS = {
|
|
'.js': ['.ts', '.tsx'],
|
|
'.jsx': ['.tsx'],
|
|
'.mjs': ['.mts'],
|
|
'.cjs': ['.cts']
|
|
}
|
|
const IMPORT_ALIASES = [
|
|
['@renderer/', 'src/renderer/src/'],
|
|
['@/', 'src/renderer/src/']
|
|
]
|
|
|
|
// Why not ci-unit-dependency-graph: it keeps every plausible candidate, which cannot prove a run.
|
|
export function resolveRuntimeImport(importer, specifier, files) {
|
|
const alias = IMPORT_ALIASES.find(([prefix]) => specifier.startsWith(prefix))
|
|
const base = alias
|
|
? alias[1] + specifier.slice(alias[0].length)
|
|
: specifier.startsWith('.')
|
|
? path.posix.join(path.posix.dirname(importer), specifier)
|
|
: null
|
|
if (base === null) {
|
|
return null
|
|
}
|
|
const extension = path.posix.extname(base)
|
|
const stem = base.slice(0, base.length - extension.length)
|
|
const candidates = [
|
|
base,
|
|
...(TS_SOURCE_TWINS[extension] ?? []).map((twin) => stem + twin),
|
|
...RESOLVE_EXTENSIONS.map((suffix) => base + suffix),
|
|
...RESOLVE_EXTENSIONS.map((suffix) => `${base}/index${suffix}`)
|
|
]
|
|
return candidates.find((candidate) => files.has(candidate)) ?? null
|
|
}
|
|
|
|
const TYPESCRIPT_FILE = /\.[cm]?tsx?$/
|
|
const TRANSPILE_OPTIONS = {
|
|
module: ts.ModuleKind.ESNext,
|
|
target: ts.ScriptTarget.ESNext,
|
|
jsx: ts.JsxEmit.Preserve
|
|
}
|
|
|
|
function importsOf(file, source, files) {
|
|
return ts
|
|
.preProcessFile(source, true, true)
|
|
.importedFiles.map(({ fileName }) => resolveRuntimeImport(file, fileName, files))
|
|
.filter((resolved) => resolved !== null)
|
|
}
|
|
|
|
// Transpile to match Vitest erasure of type-only/unused/typeof-import edges; verbatimModuleSyntax would add conservative false failures.
|
|
function runtimeImportsOf(file, source, files) {
|
|
if (!TYPESCRIPT_FILE.test(file)) {
|
|
return importsOf(file, source, files)
|
|
}
|
|
const { outputText } = ts.transpileModule(source, {
|
|
fileName: file,
|
|
compilerOptions: TRANSPILE_OPTIONS,
|
|
reportDiagnostics: false
|
|
})
|
|
return importsOf(file, outputText, files)
|
|
}
|
|
|
|
// A suite module (e.g. `Foo.case.spec.tsx`) is collected when a selected entry file imports it.
|
|
export function findUncollectedTestFiles(sources, runners) {
|
|
const files = new Set(sources.keys())
|
|
const selected = new Set(
|
|
[...files].filter((file) => runners.some((runner) => collects(runner, file)))
|
|
)
|
|
const candidates = [...files].filter(
|
|
(file) => !selected.has(file) && isTestLike(file, sources.get(file))
|
|
)
|
|
// Only source edges reaching candidate tests need slower transpilation; other edges cannot collect them.
|
|
const importers = new Map()
|
|
for (const file of files) {
|
|
for (const imported of importsOf(file, sources.get(file), files)) {
|
|
importers.set(imported, (importers.get(imported) ?? new Set()).add(file))
|
|
}
|
|
}
|
|
const reachesCandidate = new Set(candidates)
|
|
for (const file of reachesCandidate) {
|
|
for (const importer of importers.get(file) ?? []) {
|
|
reachesCandidate.add(importer)
|
|
}
|
|
}
|
|
const collected = new Set([...selected].filter((file) => reachesCandidate.has(file)))
|
|
for (const file of collected) {
|
|
for (const imported of runtimeImportsOf(file, sources.get(file), files)) {
|
|
if (reachesCandidate.has(imported)) {
|
|
collected.add(imported)
|
|
}
|
|
}
|
|
}
|
|
return candidates.filter((file) => !collected.has(file))
|
|
}
|
|
|
|
function trackedCodeSources(root) {
|
|
// Why git: worktrees and build output nest under the checkout, and only committed files matter.
|
|
const stdout = execFileSync('git', ['ls-files', '-z'], {
|
|
cwd: root,
|
|
encoding: 'utf8',
|
|
maxBuffer: 256 * 1024 * 1024
|
|
})
|
|
return new Map(
|
|
stdout
|
|
.split('\0')
|
|
.filter((file) => CODE_FILE.test(file) && !file.split('/').includes('node_modules'))
|
|
.map((file) => [file, readFileSync(path.join(root, file), 'utf8')])
|
|
)
|
|
}
|
|
|
|
function main(root) {
|
|
const drift = findRunnerDrift(TEST_RUNNERS, (file) => {
|
|
try {
|
|
return readFileSync(path.join(root, file), 'utf8')
|
|
} catch {
|
|
return null
|
|
}
|
|
})
|
|
const sources = trackedCodeSources(root)
|
|
const uncollected = findUncollectedTestFiles(sources, TEST_RUNNERS)
|
|
for (const problem of drift) {
|
|
console.error(`Runner list is out of date: ${problem}`)
|
|
}
|
|
for (const file of uncollected) {
|
|
console.error(`No test runner collects ${file}`)
|
|
}
|
|
if (drift.length > 0 || uncollected.length > 0) {
|
|
console.error(
|
|
'Rename the file to a collected pattern, import it from a collected entry, or add its runner to TEST_RUNNERS in config/scripts/check-test-file-runners.mjs.'
|
|
)
|
|
return 1
|
|
}
|
|
for (const runner of TEST_RUNNERS.filter(({ manual }) => manual)) {
|
|
const files = [...sources.keys()].filter((file) => collects(runner, file))
|
|
console.log(`Manual only, no CI job: ${runner.name}: ${files.join(', ')}`)
|
|
}
|
|
console.log('Every checked-in test file is collected by a test runner.')
|
|
return 0
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
process.exit(main(process.cwd()))
|
|
}
|