blob: 72e67c603d833b5c372020ee020f177800ab5de4 [file]
import assert from 'node:assert/strict';
import { readdirSync, readFileSync } from 'node:fs';
import test from 'node:test';
import { formatGitHubOutputs, planTests } from './ci-test-plan.mjs';
const dirs = [
'packages/core',
'packages/storage',
'packages/runtime',
'packages/runtime-host',
'packages/cli',
'packages/ui',
'apps/desktop',
];
const graph = {
dirs,
dependents: new Map([
['packages/core', new Set(['packages/storage', 'packages/runtime'])],
['packages/storage', new Set(['packages/runtime', 'packages/runtime-host'])],
['packages/runtime', new Set(['packages/runtime-host', 'packages/cli', 'apps/desktop'])],
['packages/runtime-host', new Set(['packages/cli', 'apps/desktop'])],
['packages/cli', new Set()],
['packages/ui', new Set(['apps/desktop'])],
['apps/desktop', new Set()],
]),
testDirs: new Set(dirs),
};
test('documentation-only changes do not select code validation', () => {
const plan = planTests(['docs/ci.md'], { graph });
assert.equal(plan.code, false);
assert.equal(plan.asfSource, false);
assert.equal(plan.astryxSurface, false);
assert.deepEqual(plan.workspaces, []);
});
test('the Astryx inventory can run without selecting the code suite', () => {
const plan = planTests(['docs/astryx-surface-file-inventory.md'], { graph });
assert.equal(plan.code, false);
assert.equal(plan.astryxSurface, true);
});
test('desktop renderer changes retain Electron and Storybook coverage', () => {
const plan = planTests(['apps/desktop/src/renderer/app.tsx'], { graph });
assert.equal(plan.code, true);
assert.equal(plan.e2e, true);
assert.equal(plan.storybook, true);
assert.equal(plan.astryxSurface, true);
assert.deepEqual(plan.standardWorkspaces, ['apps/desktop']);
});
test('Storybook catalog changes avoid real-window E2E and workspace tests', () => {
const plan = planTests(['apps/desktop/stories/settings.stories.tsx'], { graph });
assert.equal(plan.code, true);
assert.equal(plan.e2e, false);
assert.equal(plan.storybook, true);
assert.deepEqual(plan.workspaces, []);
});
test('AX audit contract test edits avoid the Storybook browser pipeline', () => {
const plan = planTests(['scripts/ax-tree-audit.test.mjs'], { graph });
assert.equal(plan.storybook, false);
assert.equal(plan.e2e, false);
});
test('runtime changes retain the dedicated Runtime Host lane', () => {
const plan = planTests(['packages/runtime/src/runtime.ts'], { graph });
assert.equal(plan.runtimeHost, true);
assert.equal(plan.runtimeSandbox, true);
assert.deepEqual(plan.standardWorkspaces, ['packages/runtime', 'packages/cli', 'apps/desktop']);
});
test('CLI release inputs select installed-package validation', () => {
const plan = planTests(['packages/runtime/src/runtime.ts'], { graph });
assert.equal(plan.cliPackage, true);
});
test('release metadata selects only the gate that consumes it', () => {
for (const path of ['LICENSE', 'NOTICE']) {
const plan = planTests([path], { graph });
assert.equal(plan.cliPackage, true, path);
assert.equal(plan.releaseContract, true, path);
assert.equal(plan.asfSource, false, path);
}
});
test('release authority changes select their dedicated contract gate', () => {
for (const path of [
'apps/desktop/build/entitlements.mac.plist',
'apps/desktop/electron-builder.config.mjs',
'apps/desktop/package.json',
'.github/workflows/cli-package-validation.yml',
'.github/workflows/release-cli-finalize.yml',
'.github/workflows/release-cli-stage.yml',
'.github/workflows/release.yml',
'scripts/package-macos-arm64.mjs',
'scripts/package-macos-arm64-cli.mjs',
'scripts/package-windows-x64.mjs',
'scripts/prepare-windows-upgrade-baseline.mjs',
'scripts/product-release-artifacts.mjs',
'scripts/product-release-artifacts.test.mjs',
'scripts/product-release-authority.mjs',
'scripts/product-release-authority.test.mjs',
'scripts/product-release-identity.mjs',
'scripts/product-release-tag.mjs',
'scripts/product-release.test.mjs',
'scripts/release-eval-smoke-sitecustomize.py',
'scripts/release-version.mjs',
'scripts/release-cli-publication.test.mjs',
'scripts/verify-macos-arm64-cli.mjs',
'scripts/verify-macos-arm64-dmg.mjs',
'scripts/verify-packaged-app.mjs',
'scripts/verify-windows-x64.mjs',
'scripts/windows-upgrade-baseline.json',
]) {
assert.equal(planTests([path], { graph }).releaseContract, true, path);
}
assert.equal(planTests(['.github/RELEASE_CHECKLIST.md'], { graph }).releaseContract, false);
});
// Both notices are committed generator output. A hand edit or a merge-conflict
// resolution can corrupt either one, and `check:release` is what regenerates
// and diffs them, so both must reach that gate — the desktop notice lives
// outside `packages/cli/**` and would otherwise reach no gate at all.
test('both committed third-party notices reach the release gate', () => {
for (const path of [
'apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt',
'packages/cli/THIRD_PARTY_NOTICES.txt',
]) {
assert.equal(planTests([path], { graph }).releaseContract, true, path);
}
});
// The test only reads the generator, so it belongs to the release contract and
// not to the CLI package gate, whose tarball build and install smoke prove
// nothing about a test-only edit.
test('the notice regression test selects the release gate alone', () => {
const plan = planTests(['scripts/generate-third-party-notices.test.mjs'], { graph });
assert.equal(plan.releaseContract, true);
assert.equal(plan.cliPackage, false);
});
test('ASF source authority changes select their dedicated gate', () => {
for (const path of [
'.gitattributes',
'.github/workflows/asf-source-candidate.yml',
'scripts/asf-source-release.mjs',
'scripts/asf-source-release.test.mjs',
]) {
assert.equal(planTests([path], { graph }).asfSource, true, path);
}
assert.equal(planTests(['.github/ASF_SOURCE_RELEASE.md'], { graph }).asfSource, false);
assert.equal(planTests(['scripts/audit-alignment.mjs'], { graph }).asfSource, false);
});
test('shared CLI validation changes select installed-package validation', () => {
const plan = planTests(['.github/workflows/cli-package-validation.yml'], { graph });
assert.equal(plan.cliPackage, true);
});
test('desktop-only changes skip installed-package validation', () => {
assert.equal(planTests(['apps/desktop/src/main.ts'], { graph }).cliPackage, false);
});
test('full selection covers every live surface', () => {
const plan = planTests([], { graph, forceFull: true });
assert.equal(plan.full, true);
assert.equal(plan.asfSource, true);
assert.equal(plan.cliPackage, true);
assert.equal(plan.code, true);
assert.equal(plan.e2e, true);
assert.equal(plan.storybook, true);
assert.equal(plan.runtimeHost, true);
assert.equal(plan.releaseContract, true);
assert.deepEqual(plan.workspaces, dirs);
});
test('unknown top-level code fails safe to full selection', () => {
assert.equal(planTests(['unknown.config'], { graph }).full, true);
});
test('full-suite authority files select every surface', () => {
for (const path of ['package-lock.json', '.github/workflows/ci.yml']) {
assert.equal(planTests([path], { graph }).full, true, path);
}
});
test('GitHub output matches the selections consumed by CI', () => {
const output = formatGitHubOutputs(planTests([], { graph, forceFull: true }));
const outputKeys = new Set(output.split('\n').map((line) => line.split('=', 1)[0]));
const workflow = readWorkflow('ci.yml');
const consumedKeys = new Set(
[...workflow.matchAll(/steps\.plan\.outputs\.([a-z0-9_]+)/gu)].map((match) => match[1]),
);
assert.deepEqual(outputKeys, consumedKeys);
});
test('core CI validates pull requests and the resulting main branch state', () => {
const workflow = readWorkflow('ci.yml');
assert.match(workflow, /pull_request:\n\s+branches: \[main\]/u);
assert.match(workflow, /push:\n\s+branches: \[main\]/u);
assert.match(
workflow,
/BASE_SHA: \$\{\{ github\.event_name == 'push' && github\.event\.before \|\| github\.event\.pull_request\.base\.sha \}\}/u,
);
assert.match(
workflow,
/HEAD_SHA: \$\{\{ github\.event_name == 'push' && github\.sha \|\| github\.event\.pull_request\.head\.sha \}\}/u,
);
assert.match(workflow, /\[\[ "\$BASE_SHA" =~ \^0\+\$ \]\]/u);
});
test('core CI uses the Windows inventory package-script authority', () => {
const workflow = readWorkflow('ci.yml');
assert.match(workflow, /run: npm run windows:inventory/u);
assert.doesNotMatch(workflow, /run: node scripts\/windows-test-inventory\.mjs --check/u);
});
test('contract checks run before dependency setup and can fail the job', () => {
const workflow = readWorkflow('ci.yml');
const setupNodeStart = workflow.indexOf(' - uses: actions/setup-node@');
// Both contracts need nothing but the checkout, so they run on every change
// rather than behind a surface flag — and a gate that cannot fail the job is
// not a gate.
for (const name of ['Test CI planner', 'Check Windows test inventory']) {
const start = workflow.indexOf(` - name: ${name}\n`);
assert.ok(start >= 0, name);
assert.ok(start < setupNodeStart, name);
const step = workflow.slice(start, workflow.indexOf('\n - ', start + 1));
assert.doesNotMatch(step, /\n\s+if:/u, name);
assert.doesNotMatch(step, /continue-on-error/u, name);
}
});
test('core CI validates affected installed CLI packages on its existing runner', () => {
const workflow = readWorkflow('ci.yml');
const toolchain = workflow.indexOf(
'npm install --global --no-audit --no-fund "$(node -p \'require("./package.json").packageManager\')"',
);
const pack = workflow.indexOf('run: npm run release:cli:pack');
assert.match(workflow, /if: steps\.plan\.outputs\.cli_package == 'true'/u);
assert.ok(toolchain >= 0);
assert.ok(toolchain < pack);
assert.match(workflow, /run: npm run release:cli:smoke/u);
});
test('release contracts run against built CLI outputs', () => {
const workflow = readWorkflow('ci.yml');
const buildIndex = workflow.indexOf(' - name: Build\n');
const buildEnd = workflow.indexOf('\n - ', buildIndex + 1);
const releaseIndex = workflow.indexOf(' - name: Release contracts\n');
assert.ok(buildIndex >= 0);
assert.match(workflow.slice(buildIndex, buildEnd), /release_contract == 'true'/u);
assert.ok(buildIndex < releaseIndex);
assert.match(
workflow.slice(releaseIndex),
/if: steps\.plan\.outputs\.release_contract == 'true'/u,
);
});
test('pull request triggers stay on an explicit allowlist', () => {
// Naming the lanes that must not run on pull requests only covers the ones
// someone remembered to name; W0 kept an unbounded trigger that way.
const onPullRequests = readdirSync(WORKFLOW_DIR).filter(hasPullRequestTrigger).sort();
assert.deepEqual(onPullRequests, [
'ci.yml',
'copilot-auto-review.yml',
'dependency-audit.yml',
'release-windows-check.yml',
'windows-sandbox-w0.yml',
]);
});
test('the sandbox lane pairs its path filter with a nightly run', () => {
const workflow = readWorkflow('windows-sandbox-w0.yml');
// The filter is a pre-filter, not the lane's import closure, so dropping the
// schedule would silently lose every transitive edit it cannot match, and
// dropping the filter would put the whole runtime back on pull requests.
assert.match(workflow, /\n {2}pull_request:\n {4}paths:/u);
assert.match(workflow, /\n {2}schedule:/u);
});
test('specialized platform workflows stay reachable without pull requests', () => {
const cli = readWorkflow('cli-package-validation.yml');
const baseline = readWorkflow('windows-baseline.yml');
const recovery = readWorkflow('windows-recovery.yml');
for (const workflow of [cli, baseline, recovery]) {
assert.match(workflow, /\n workflow_dispatch:/u);
}
assert.match(cli, /\n workflow_call:/u);
assert.match(baseline, /\n schedule:/u);
});
test('workflows never persist the job credential into the checkout', () => {
for (const name of readdirSync(WORKFLOW_DIR)) {
for (const step of checkoutSteps(name)) {
assert.match(step, /persist-credentials: false/u, `${name}: ${step.trim()}`);
}
}
});
const WORKFLOW_DIR = new URL('../.github/workflows/', import.meta.url);
function readWorkflow(name) {
return readFileSync(new URL(name, WORKFLOW_DIR), 'utf8');
}
/**
* Reads the `on:` block only, so a workflow cannot escape a trigger contract by
* writing `on: [pull_request]`, and prose elsewhere in the file cannot fake one.
*/
function hasPullRequestTrigger(name) {
const withoutComments = readWorkflow(name).replaceAll(/^[ \t]*#.*$/gmu, '');
const triggers = withoutComments.match(/^on:(.*(?:\n(?![^\s#]).*)*)/mu)?.[1] ?? '';
return /\bpull_request(_target)?\b/u.test(triggers);
}
/**
* Slices each checkout step from its `uses:` line to the next step, so the
* assertion is per checkout: a bare one cannot be balanced out by a sibling
* step that opts out, or by the string appearing in a comment.
*/
function checkoutSteps(name) {
const withoutComments = readWorkflow(name).replaceAll(/^[ \t]*#.*$/gmu, '');
return (
withoutComments.match(/^[ \t]*- uses: actions\/checkout@.*\n(?:(?![ \t]*- )[ \t]+.*\n)*/gmu) ??
[]
);
}