blob: c7f28e99e655095d602329bfb569760147586df0 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Behaviour of the test planner itself: which lanes a set of changed files
* selects. Assertions about the workflows that consume those selections live in
* `ci-workflow-policy.test.mjs`.
*
* This suite runs before `npm ci` installs anything, so it may import only
* `node:` builtins and repository modules that do the same.
* `ci-workflow-policy.test.mjs` asserts that constraint for every suite the
* install-free steps run.
*/
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import test from 'node:test';
import { changedFilesBetween, formatGitHubOutputs, planTests } from './ci-test-plan.mjs';
/**
* Every surface a plan selected, read off the plan itself rather than from a
* list of the ones worth naming. `assert.deepEqual(selections(plan), [])` is
* therefore the whole "this change costs nothing" claim, and a selection added
* later joins it without anyone editing these tests.
*/
function selections(plan) {
return Object.entries(plan)
.filter(([key]) => key !== 'workspaces')
.filter(([, value]) => (Array.isArray(value) ? value.length > 0 : Boolean(value)))
.map(([key]) => key)
.sort();
}
const dirs = [
'packages/core',
'packages/storage',
'packages/runtime',
'packages/runtime-host',
'packages/cli',
'packages/ui',
'apps/desktop',
'website',
];
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()],
['website', new Set()],
]),
testDirs: new Set(dirs),
};
test('documentation-only changes select nothing at all', () => {
const plan = planTests(['docs/ci.md'], { graph });
assert.deepEqual(selections(plan), []);
assert.deepEqual(plan.workspaces, []);
});
test('documentation inside workspaces selects nothing at all', () => {
for (const path of ['packages/runtime/README.md', 'apps/desktop/README.md']) {
const plan = planTests([path], { graph });
assert.deepEqual(selections(plan), [], path);
assert.deepEqual(plan.workspaces, [], path);
}
});
test('the READMEs run the website tests that check their opening sentence', () => {
for (const path of ['README.md', 'README.zh-CN.md']) {
const plan = planTests([path], { graph });
assert.equal(plan.code, true, path);
assert.deepEqual(plan.workspaces, ['website'], path);
}
});
test('mixed documentation and code changes still select code validation', () => {
const plan = planTests(['README.md', 'packages/core/src/index.ts'], { graph });
assert.equal(plan.code, true);
});
test('documentation with a dedicated contract still selects that contract', () => {
for (const path of [
'LICENSE',
'docs/astryx-surface-file-inventory.md',
'apps/desktop/resources/licenses/renderer/SIMPLE_ICONS_LICENSE.md',
]) {
const plan = planTests([path], { graph });
assert.equal(plan.code, false, path);
assert.notDeepEqual(selections(plan), [], path);
}
});
test('DeepSeek Harness toolchain inputs rebuild its pinned fingerprint', () => {
for (const path of [
'packages/eval/harbor/deepseek-harness-toolchain/package.json',
'packages/eval/harbor/deepseek-harness-toolchain/package-lock.json',
'packages/eval/harbor/deepseek-harness-toolchain/patch-subprocess-local.mjs',
'packages/eval/src/toolchain-verification.ts',
'scripts/prepare-deepseek-harness-toolchain.mjs',
]) {
assert.equal(planTests([path], { graph }).deepseekHarnessToolchain, true, path);
}
assert.equal(planTests(['packages/eval/README.md'], { graph }).deepseekHarnessToolchain, false);
});
test('changed files are derived from the PR merge base', () => {
const calls = [];
const exec = (_command, args) => {
calls.push(args);
if (args[0] === 'merge-base') return 'fork-point\n';
if (args.at(-2) === 'fork-point') return 'packages/runtime/README.md\n';
if (args.at(-2) === 'main-now') {
return 'packages/core/src/main-only.ts\npackages/runtime/README.md\n';
}
throw new Error(`Unexpected git invocation: ${args.join(' ')}`);
};
const changedFiles = changedFilesBetween('main-now', 'pr-head', exec);
assert.deepEqual(changedFiles, ['packages/runtime/README.md']);
assert.deepEqual(selections(planTests(changedFiles, { graph })), []);
assert.deepEqual(calls, [
['merge-base', 'main-now', 'pr-head'],
['diff', '--no-renames', '--name-only', '--diff-filter=ACMRDT', 'fork-point', 'pr-head'],
]);
});
test('type changes remain in the PR-owned delta', () => {
const exec = (_command, args) => {
if (args[0] === 'merge-base') return 'fork-point\n';
assert.ok(args.includes('--diff-filter=ACMRDT'));
return 'packages/runtime/src/runtime.ts\n';
};
const changedFiles = changedFilesBetween('main-now', 'pr-head', exec);
assert.deepEqual(changedFiles, ['packages/runtime/src/runtime.ts']);
assert.equal(planTests(changedFiles, { graph }).code, true);
});
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, true, path);
}
});
test('source legal authority and generated provenance select the ASF source gate', () => {
for (const path of [
'DISCLAIMER-WIP',
'biome.jsonc',
'patches/node-pty+1.2.0-beta.15.patch',
'apps/desktop/src/renderer/assets/provider-brands/example.svg',
'apps/desktop/resources/licenses/renderer/SIMPLE_ICONS_LICENSE.md',
'packages/eval/harbor/deepseek-harness-profile/cordis.patch.yml',
'scripts/model-metadata/models-dev-api.snapshot.json',
'scripts/sync-model-metadata.mjs',
]) {
assert.equal(planTests([path], { graph }).asfSource, true, path);
}
});
test('release authority changes select their dedicated contract gate', () => {
for (const path of [
'apps/desktop/src/main/app-update-test-context.ts',
'apps/desktop/build/entitlements.mac.plist',
'apps/desktop/electron-builder.config.mjs',
'apps/desktop/package.json',
'.github/workflows/cli-package-validation.yml',
'.github/workflows/desktop-nightly.yml',
'.github/workflows/npm-publication.yml',
'.github/workflows/release-cli-finalize.yml',
'.github/workflows/release-cli-stage.yml',
'.github/workflows/release.yml',
'scripts/audit-shipped-dependencies.mjs',
'scripts/package-macos.mjs',
'scripts/package-macos-autoupdate-next.mjs',
'scripts/package-macos-arm64-cli.mjs',
'scripts/package-linux.mjs',
'scripts/package-windows-x64.mjs',
'scripts/prepare-windows-upgrade-baseline.mjs',
'scripts/product-release-artifacts.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-dmg.mjs',
'scripts/verify-macos-autoupdate.mjs',
'scripts/verify-linux.mjs',
'scripts/desktop-release-targets.mjs',
'scripts/desktop-release-targets.test.mjs',
'scripts/desktop-update-contract.mjs',
'scripts/verify-packaged-app.mjs',
'scripts/verify-windows-x64.mjs',
'scripts/windows-upgrade-baseline.json',
'scripts/windows-package-source-closure.mjs',
'scripts/windows-package-source-closure.test.mjs',
]) {
assert.equal(planTests([path], { graph }).releaseContract, true, path);
}
assert.equal(planTests(['.github/RELEASE_CHECKLIST.md'], { graph }).releaseContract, false);
});
// Derived from the gate scripts themselves, because the sets above are hand
// maintained and drift silently in both directions: a test the gate runs but
// no lane selects can be edited green, and a listed path that no longer exists
// is dead weight nothing reports. Both had happened — three of the release
// gate's own tests reached no lane, and the set named a
// `prepare-windows-upgrade-baseline.test.mjs` that never existed.
test('every test a gate script runs reaches a lane that runs that gate', () => {
const { scripts } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
const lanesByTest = new Map();
for (const [script, lane] of [
['check:release', 'releaseContract'],
['check:asf-source', 'asfSource'],
]) {
for (const file of scripts[script].match(/scripts\/[\w.-]+\.test\.mjs/gu) ?? []) {
lanesByTest.set(file, (lanesByTest.get(file) ?? new Set()).add(lane));
}
}
assert.ok(lanesByTest.size > 0, 'no gate script names a test file');
// A test two gates share needs only one of them: either run executes it.
for (const [file, lanes] of lanesByTest) {
const plan = planTests([file], { graph });
assert.ok(
[...lanes].some((lane) => plan[lane]),
`${file} reaches no ${[...lanes].join('/')}`,
);
}
});
// The other direction of the same drift. Every literal path in the planner is
// matched against a changed file, so one that no longer exists can never match
// and nothing reports it: the phantom baseline test sat here for a month, and
// `agent-run-store.test.ts` stayed in the storage stress set for a month after
// #1994 deleted it.
test('the planner names no path that no longer exists', () => {
const source = readFileSync(new URL('ci-test-plan.mjs', import.meta.url), 'utf8');
const paths = [...source.matchAll(/^ {2}'([\w.-]+(?:\/[\w.-]+)+)',$/gmu)].map(([, path]) => path);
assert.ok(paths.length > 0, 'the planner names no paths');
for (const path of paths) {
assert.ok(existsSync(new URL(`../${path}`, import.meta.url)), path);
}
});
test('Product Nightly authority changes select the release contract gate', () => {
for (const path of [
'.github/workflows/desktop-nightly.yml',
'.github/workflows/npm-publication.yml',
'scripts/desktop-nightly.mjs',
'scripts/desktop-nightly.test.mjs',
'scripts/desktop-nightly-stage.test.mjs',
'scripts/desktop-nightly-workflow-policy.test.mjs',
'scripts/product-nightly.mjs',
'scripts/product-nightly.test.mjs',
]) {
assert.equal(planTests([path], { graph }).releaseContract, true, path);
}
});
// 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',
'docs/code-origin-audit.md',
'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);
});
// Everything below reached that fail-safe until now. `native/` alone was the
// largest single source of full-suite runs — 14 of the last 300 first-parent
// commits, ahead of `package-lock.json` — because the classifier had no opinion
// about a directory that three dedicated lanes already own.
test('a Rust crate with its own admission lane selects no JavaScript surface', () => {
// `gitoxide-helper-admission.yml` owns `cargo fmt`, `cargo test`, and the
// JavaScript invocation contract for this crate on three operating systems.
// No step in `ci.yml` reads it, so the plan has nothing to select.
for (const path of ['native/gitoxide-helper/src/main.rs', 'native/gitoxide-helper/Cargo.toml']) {
const plan = planTests([path], { graph });
assert.equal(plan.full, false, path);
assert.equal(plan.code, false, path);
assert.equal(plan.cliPackage, false, path);
assert.deepEqual(plan.workspaces, [], path);
}
});
test('the direct-peer crate and its lint policy select CLI packaging alone', () => {
// `release:cli:pack` builds this addon into the tarball and runs `cargo deny`
// against that policy, so CLI packaging is the one JavaScript gate with a
// stake here. Lint, typecheck, Storybook, and a real window have none.
for (const path of [
'native/runtime-host-peer/src/engine.rs',
'native/runtime-host-peer/Cargo.lock',
'deny.toml',
]) {
const plan = planTests([path], { graph });
assert.equal(plan.full, false, path);
assert.equal(plan.cliPackage, true, path);
assert.equal(plan.releaseContract, true, path);
assert.equal(plan.e2e, false, path);
assert.equal(plan.storybook, false, path);
assert.equal(plan.appIcons, false, path);
assert.deepEqual(plan.workspaces, [], path);
}
});
test('a native root without an admission lane still fails safe to full', () => {
// The exemption above is owned by a lane, not by the language. A crate added
// under `native/` has no lane on the commit that introduces it, so nothing
// but this fallback would compile or test it at all.
for (const path of ['native/new-helper/src/main.rs', 'native/new-helper/Cargo.toml']) {
assert.equal(planTests([path], { graph }).full, true, path);
}
});
test('branch protection reaches the suite that parses it', () => {
// `.asf.yaml` names the required contexts and the release-environment
// admission rules. `product-release.test.mjs` is the only suite that reads
// it, and it runs behind `check:release` — so selecting the release contract
// is what proves a change to the merge gate still passes its own policy test.
const plan = planTests(['.asf.yaml'], { graph });
assert.equal(plan.full, false);
assert.equal(plan.releaseContract, true);
assert.equal(plan.code, false);
assert.deepEqual(plan.workspaces, []);
});
test('a dependency patch keeps the full suite until a consumer map exists', () => {
// A patch rewrites a dependency's behaviour, and which suite proves that
// behaviour is a property of the patch rather than of the directory. The
// node-pty patch is regressed by
// `packages/runtime/src/__tests__/node-pty-write-lifecycle.test.ts`, while
// the packaging smoke that a build-shaped selection would run only asserts
// that a method name still appears in the tarball. Narrowing this bucket
// needs an explicit patch-to-consumer mapping; until then it stays here,
// where the cost is runner minutes rather than a silent regression.
assert.equal(planTests(['patches/node-pty+1.2.0-beta.15.patch'], { 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);
}
});
// The SessionTodo cutover (#4351) retired an operation and stranded every
// workspace holding a credential issued before it (#4420). It changed the
// vocabulary, not the decoders — so a trigger listing only decoders stays green
// on the exact change shape this guard exists to catch.
test('retiring an operation selects the released forward roll', () => {
const plan = planTests(
[
'packages/runtime-host/src/protocol/operations.ts',
'apps/desktop/src/renderer/features/workbar/tools/tasks/use-session-todo.ts',
],
{ graph },
);
assert.equal(plan.stateRootCompat, true);
assert.equal(plan.full, false);
});
test('a durable-state decoder selects the released forward roll', () => {
const plan = planTests(['packages/runtime-host/src/server/access-credential-store.ts'], {
graph,
});
assert.equal(plan.stateRootCompat, true);
});
test('a core durable-state decoder selects the released forward roll', () => {
const plan = planTests(['packages/core/src/goal.ts'], { graph });
assert.equal(plan.stateRootCompat, true);
});
test('ordinary changes do not pay for the released forward roll', () => {
const plan = planTests(['apps/desktop/src/renderer/features/workbar/ports.ts'], { graph });
assert.equal(plan.stateRootCompat, false);
});
// Which files select the app icon gate is derived in `ci-workflow-policy.test.mjs`
// from the suites the step runs. What stays here is the complement: regenerating
// the artwork costs about a minute, and ordinary product code must stop paying it.
test('ordinary product code does not pay for app icon drift', () => {
for (const path of [
'apps/desktop/src/renderer/app-shell.tsx',
'packages/core/src/artifacts.ts',
'packages/runtime/src/edit-replace.ts',
]) {
assert.equal(planTests([path], { graph }).appIcons, false, path);
}
});