256 lines
8.8 KiB
TypeScript
256 lines
8.8 KiB
TypeScript
// Builds the affected-tests plan from raw inputs: computes the rules and usage
|
|
// affected-module sets and selects, per suite, which specs to run under each.
|
|
import { getFeatureModules } from "../../frontend/lint/module-boundaries.mjs";
|
|
|
|
import {
|
|
type FileDependency,
|
|
type ModuleDef,
|
|
type ModuleNode,
|
|
type Rule,
|
|
buildModuleGraph,
|
|
buildUsageModuleGraph,
|
|
getAffectedModules,
|
|
getChangedModules,
|
|
getTransitiveDependencies,
|
|
mapFileToModule,
|
|
} from "./affected-modules";
|
|
|
|
export type TestPlanStats = {
|
|
fe_files_changed: number;
|
|
fe_files_total: number;
|
|
be_files_changed: number;
|
|
be_files_total: number;
|
|
unit_infra_touched: boolean;
|
|
loki_infra_touched: boolean;
|
|
loki_preview_touched: boolean;
|
|
shared_sources_touched: boolean;
|
|
fe_modules_total: number;
|
|
fe_modules_changed: number;
|
|
fe_modules_affected_rules: number;
|
|
fe_modules_affected_usage: number;
|
|
unit_specs_all: number;
|
|
unit_specs_to_run_rules: number;
|
|
unit_specs_to_run_usage: number;
|
|
loki_stories_all: number;
|
|
loki_stories_to_run_rules: number;
|
|
loki_stories_to_run_usage: number;
|
|
e2e_specs_all: number;
|
|
e2e_specs_to_run_rules: number;
|
|
e2e_specs_to_run_usage: number;
|
|
};
|
|
|
|
export type TestPlan = {
|
|
stats: TestPlanStats;
|
|
fe_unit_specs_to_run: string[];
|
|
loki_stories_to_run: string[];
|
|
e2e_specs_to_run: string[];
|
|
};
|
|
|
|
export type CreateTestPlanInput = {
|
|
elements: ModuleDef[];
|
|
rules: Rule[];
|
|
changedFiles: string[];
|
|
// Loads the dependency-cruiser edges, called only when some suite can be narrowed.
|
|
// Null falls back to the rules graph.
|
|
loadFileDependencies: () => FileDependency[] | null;
|
|
testFilesBySuite: { unit: string[]; loki: string[]; e2e: string[] };
|
|
// Storybook's preview file, whose imports wrap every story.
|
|
lokiPreviewFile: string;
|
|
e2eSpecFiles: Record<string, string[]> | null;
|
|
unitInfraTouched: boolean;
|
|
lokiInfraTouched: boolean;
|
|
e2eInfraTouched: boolean;
|
|
sharedSourcesTouched: boolean;
|
|
feFilesChanged: number;
|
|
beFilesChanged: number;
|
|
feFilesTotal: number;
|
|
beFilesTotal: number;
|
|
};
|
|
|
|
// Tests whose owning module is affected.
|
|
// A spec outside the module tree always runs, because the graph cannot scope it.
|
|
export function filterAffectedTests(
|
|
nodes: ModuleNode[],
|
|
affected: Set<string>,
|
|
testFiles: string[],
|
|
): string[] {
|
|
return testFiles.filter((file) => {
|
|
const module = mapFileToModule(nodes, file);
|
|
return module === null || affected.has(module);
|
|
});
|
|
}
|
|
|
|
// Collapse each spec's covered files to the *feature* modules it exercises,
|
|
// once, for reuse across the rules/usage selects. Non-feature tiers
|
|
// (shared/lib/...) are intentionally dropped: the manifest is a stale nightly
|
|
// artifact, so its coupling to the module graph stays minimal ("does this spec
|
|
// touch feature X?"). Accuracy comes from the fresh `affected` set, which
|
|
// already expands a shared/infra change into the feature modules that depend on
|
|
// it.
|
|
export function specFeatureModules(
|
|
nodes: ModuleNode[],
|
|
featureModules: Set<string>,
|
|
specFiles: Record<string, string[]>,
|
|
): Map<string, Set<string>> {
|
|
const moduleCache = new Map<string, string | null>();
|
|
const moduleOf = (file: string): string | null => {
|
|
let module = moduleCache.get(file);
|
|
if (module === undefined) {
|
|
module = mapFileToModule(nodes, file);
|
|
moduleCache.set(file, module);
|
|
}
|
|
return module;
|
|
};
|
|
|
|
const result = new Map<string, Set<string>>();
|
|
for (const [spec, files] of Object.entries(specFiles)) {
|
|
const features = new Set<string>();
|
|
for (const file of files) {
|
|
const module = moduleOf(file);
|
|
if (module !== null && featureModules.has(module)) {
|
|
features.add(module);
|
|
}
|
|
}
|
|
result.set(spec, features);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// A spec runs if any feature module it covers is affected.
|
|
// A spec doesn't map to any feature module always runs because its scope is unknown
|
|
export function filterAffectedE2eSpecs(
|
|
specFeatures: Map<string, Set<string>>,
|
|
affected: Set<string>,
|
|
e2eSpecs: string[],
|
|
): string[] {
|
|
return e2eSpecs.filter((spec) => {
|
|
const features = specFeatures.get(spec);
|
|
if (!features || features.size === 0) {
|
|
return true;
|
|
}
|
|
return [...features].some((module) => affected.has(module));
|
|
});
|
|
}
|
|
|
|
export function createTestPlan({
|
|
elements,
|
|
rules,
|
|
changedFiles,
|
|
loadFileDependencies,
|
|
testFilesBySuite,
|
|
lokiPreviewFile,
|
|
e2eSpecFiles,
|
|
unitInfraTouched,
|
|
lokiInfraTouched,
|
|
e2eInfraTouched,
|
|
sharedSourcesTouched,
|
|
feFilesChanged,
|
|
beFilesChanged,
|
|
feFilesTotal,
|
|
beFilesTotal,
|
|
}: CreateTestPlanInput): TestPlan {
|
|
const rulesGraph = buildModuleGraph(elements, rules);
|
|
const nodes = rulesGraph.nodes;
|
|
// Distinct module types (an element type can span several patterns).
|
|
const totalModules = new Set(elements.map((el) => el.type)).size;
|
|
const changedModules = getChangedModules(nodes, changedFiles);
|
|
const rulesAffected = getAffectedModules(rulesGraph, changedFiles);
|
|
|
|
// The coarse "feature" tier is the only set the e2e manifest is ever
|
|
// collapsed to (see filterAffectedE2eSpecs).
|
|
const featureModules = new Set(getFeatureModules(elements));
|
|
|
|
// cljc/cljs compile into the FE bundle, so they force a full run that module
|
|
// selection can't narrow — same effect as a suite's own infra changing.
|
|
const unitForceAll = unitInfraTouched || sharedSourcesTouched;
|
|
const lokiInfraForceAll = lokiInfraTouched || sharedSourcesTouched;
|
|
// e2e is integration-level, so anything the FE-coverage manifest can't see
|
|
// forces a full run: cljc/cljs in the bundle (sharedSourcesTouched), a backend
|
|
// change that can break the UI (beFilesChanged), an e2e harness/support change
|
|
// (e2eInfraTouched), or no manifest at all.
|
|
const e2eForceAll =
|
|
sharedSourcesTouched ||
|
|
e2eInfraTouched ||
|
|
beFilesChanged > 0 ||
|
|
e2eSpecFiles === null;
|
|
|
|
// The cruise takes seconds, so it is skipped when every suite runs in full anyway.
|
|
const edges =
|
|
unitForceAll && lokiInfraForceAll && e2eForceAll
|
|
? null
|
|
: loadFileDependencies();
|
|
const usageGraph = edges
|
|
? buildUsageModuleGraph(elements, edges)
|
|
: rulesGraph;
|
|
const usageAffected = getAffectedModules(usageGraph, changedFiles);
|
|
|
|
// The preview wraps every story, so a change to a file it imports can change any screenshot.
|
|
const previewDependencies = edges
|
|
? getTransitiveDependencies(edges, lokiPreviewFile)
|
|
: new Set<string>();
|
|
const lokiPreviewTouched = changedFiles.some((file) =>
|
|
previewDependencies.has(file),
|
|
);
|
|
// Without the cruise, the rules graph cannot account for the preview's
|
|
// global dependencies, so it cannot safely narrow Loki.
|
|
const lokiForceAll =
|
|
lokiInfraForceAll || lokiPreviewTouched || edges === null;
|
|
|
|
const select = (forceAll: boolean, affected: Set<string>, files: string[]) =>
|
|
forceAll ? files : filterAffectedTests(nodes, affected, files);
|
|
|
|
const { unit, loki, e2e } = testFilesBySuite;
|
|
const unitRules = select(unitForceAll, rulesAffected, unit);
|
|
const unitUsage = select(unitForceAll, usageAffected, unit);
|
|
const lokiRules = select(lokiForceAll, rulesAffected, loki);
|
|
const lokiUsage = select(lokiForceAll, usageAffected, loki);
|
|
|
|
// Precompute spec -> feature modules once (null when e2e runs in full).
|
|
const specFeatures =
|
|
e2eForceAll || e2eSpecFiles === null
|
|
? null
|
|
: specFeatureModules(nodes, featureModules, e2eSpecFiles);
|
|
// A spec that was itself edited always runs, even when no app module changed.
|
|
const changedSet = new Set(changedFiles);
|
|
const selectE2e = (affected: Set<string>): string[] => {
|
|
if (specFeatures === null) {
|
|
return e2e;
|
|
}
|
|
const narrowed = new Set(
|
|
filterAffectedE2eSpecs(specFeatures, affected, e2e),
|
|
);
|
|
return e2e.filter((spec) => narrowed.has(spec) || changedSet.has(spec));
|
|
};
|
|
const e2eRules = selectE2e(rulesAffected);
|
|
const e2eUsage = selectE2e(usageAffected);
|
|
|
|
return {
|
|
stats: {
|
|
fe_files_changed: feFilesChanged,
|
|
fe_files_total: feFilesTotal,
|
|
be_files_changed: beFilesChanged,
|
|
be_files_total: beFilesTotal,
|
|
unit_infra_touched: unitInfraTouched,
|
|
loki_infra_touched: lokiInfraTouched,
|
|
loki_preview_touched: lokiPreviewTouched,
|
|
shared_sources_touched: sharedSourcesTouched,
|
|
fe_modules_total: totalModules,
|
|
fe_modules_changed: changedModules.size,
|
|
fe_modules_affected_rules: rulesAffected.size,
|
|
fe_modules_affected_usage: usageAffected.size,
|
|
unit_specs_all: unit.length,
|
|
unit_specs_to_run_rules: unitRules.length,
|
|
unit_specs_to_run_usage: unitUsage.length,
|
|
loki_stories_all: loki.length,
|
|
loki_stories_to_run_rules: lokiRules.length,
|
|
loki_stories_to_run_usage: lokiUsage.length,
|
|
e2e_specs_all: e2e.length,
|
|
e2e_specs_to_run_rules: e2eRules.length,
|
|
e2e_specs_to_run_usage: e2eUsage.length,
|
|
},
|
|
fe_unit_specs_to_run: unitUsage,
|
|
loki_stories_to_run: lokiUsage,
|
|
e2e_specs_to_run: e2eUsage,
|
|
};
|
|
}
|