

















@@ -301,6 +301,11 @@ const GENERATED_CHANGED_TEST_TARGETS = new Set([
301301"src/canvas-host/a2ui/.bundle.hash",
302302"src/canvas-host/a2ui/a2ui.bundle.js",
303303]);
304+const SOURCE_ROOTS_FOR_IMPORT_GRAPH = ["src", "extensions", "packages", "ui/src", "test"];
305+const IMPORTABLE_FILE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts"];
306+const IMPORT_SPECIFIER_PATTERN =
307+/\b(?:import|export)\s+(?:type\s+)?(?:[^'"]*?\s+from\s+)?["']([^"']+)["']|\bimport\s*\(\s*["']([^"']+)["']\s*\)/gu;
308+const FOCUSED_CHANGED_ENV_KEY = "OPENCLAW_TEST_CHANGED_FOCUSED";
304309const VITEST_NO_OUTPUT_TIMEOUT_ENV_KEY = "OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS";
305310const VITEST_NO_OUTPUT_RETRY_ENV_KEY = "OPENCLAW_VITEST_NO_OUTPUT_RETRY";
306311export const DEFAULT_TEST_PROJECTS_VITEST_NO_OUTPUT_TIMEOUT_MS = "180000";
@@ -375,6 +380,10 @@ function isFileLikeTarget(arg) {
375380return /\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(arg);
376381}
377382383+function isTestFileTarget(arg) {
384+return /\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(arg);
385+}
386+378387function isLikelyFileTarget(arg) {
379388return /(?:^|\/)[^/]+\.[A-Za-z0-9]+$/u.test(arg);
380389}
@@ -406,6 +415,128 @@ function toScopedIncludePattern(arg, cwd) {
406415return `${relative.replace(/\/+$/u, "")}/**/*.test.ts`;
407416}
408417418+function isSkippedImportGraphDirectory(name) {
419+return (
420+name === ".git" ||
421+name === "dist" ||
422+name === "node_modules" ||
423+name === "vendor" ||
424+name.startsWith(".openclaw-runtime-deps")
425+);
426+}
427+428+function listImportGraphFiles(cwd, directory, files = []) {
429+let entries;
430+try {
431+entries = fs.readdirSync(path.join(cwd, directory), { withFileTypes: true });
432+} catch {
433+return files;
434+}
435+436+for (const entry of entries) {
437+const relative = normalizePathPattern(path.posix.join(directory, entry.name));
438+if (entry.isDirectory()) {
439+if (!isSkippedImportGraphDirectory(entry.name)) {
440+listImportGraphFiles(cwd, relative, files);
441+}
442+continue;
443+}
444+if (entry.isFile() && IMPORTABLE_FILE_EXTENSIONS.some((ext) => relative.endsWith(ext))) {
445+files.push(relative);
446+}
447+}
448+return files;
449+}
450+451+function resolveImportSpecifier(importer, specifier, fileSet) {
452+if (!specifier.startsWith(".")) {
453+return null;
454+}
455+456+const importerDir = path.posix.dirname(importer);
457+const base = normalizePathPattern(path.posix.normalize(path.posix.join(importerDir, specifier)));
458+const candidates = [];
459+const ext = path.posix.extname(base);
460+if (ext) {
461+candidates.push(base);
462+if ([".js", ".jsx", ".mjs", ".cjs"].includes(ext)) {
463+const withoutExt = base.slice(0, -ext.length);
464+candidates.push(
465+ ...IMPORTABLE_FILE_EXTENSIONS.map((candidateExt) => `${withoutExt}${candidateExt}`),
466+);
467+}
468+} else {
469+candidates.push(
470+ ...IMPORTABLE_FILE_EXTENSIONS.map((candidateExt) => `${base}${candidateExt}`),
471+ ...IMPORTABLE_FILE_EXTENSIONS.map((candidateExt) => `${base}/index${candidateExt}`),
472+);
473+}
474+475+return candidates.find((candidate) => fileSet.has(candidate)) ?? null;
476+}
477+478+let cachedImportGraph = null;
479+let cachedImportGraphCwd = null;
480+481+function getImportGraph(cwd) {
482+if (cachedImportGraph && cachedImportGraphCwd === cwd) {
483+return cachedImportGraph;
484+}
485+486+const files = SOURCE_ROOTS_FOR_IMPORT_GRAPH.flatMap((root) => listImportGraphFiles(cwd, root));
487+const fileSet = new Set(files);
488+const reverseImports = new Map();
489+const testFiles = new Set(
490+files.filter((file) => isTestFileTarget(file) && !file.endsWith(".live.test.ts")),
491+);
492+493+for (const file of files) {
494+let source = "";
495+try {
496+source = fs.readFileSync(path.join(cwd, file), "utf8");
497+} catch {
498+continue;
499+}
500+for (const match of source.matchAll(IMPORT_SPECIFIER_PATTERN)) {
501+const imported = resolveImportSpecifier(file, match[1] ?? match[2] ?? "", fileSet);
502+if (!imported) {
503+continue;
504+}
505+const importers = reverseImports.get(imported) ?? [];
506+importers.push(file);
507+reverseImports.set(imported, importers);
508+}
509+}
510+511+cachedImportGraph = { reverseImports, testFiles };
512+cachedImportGraphCwd = cwd;
513+return cachedImportGraph;
514+}
515+516+function resolveAffectedTestsFromImportGraph(changedPath, cwd) {
517+const normalized = normalizePathPattern(changedPath);
518+const { reverseImports, testFiles } = getImportGraph(cwd);
519+const queue = [normalized];
520+const seen = new Set(queue);
521+const targets = [];
522+523+for (let index = 0; index < queue.length; index += 1) {
524+const current = queue[index];
525+for (const importer of reverseImports.get(current) ?? []) {
526+if (seen.has(importer)) {
527+continue;
528+}
529+seen.add(importer);
530+if (testFiles.has(importer)) {
531+targets.push(importer);
532+}
533+queue.push(importer);
534+}
535+}
536+537+return [...new Set(targets)].toSorted((left, right) => left.localeCompare(right));
538+}
539+409540function resolveVitestConfigTargetKind(relative) {
410541return VITEST_CONFIG_TARGET_KIND_BY_PATH.get(relative) ?? null;
411542}
@@ -554,6 +685,11 @@ function resolveToolingTestTargets(changedPath) {
554685return TOOLING_SOURCE_TEST_TARGETS.get(changedPath) ?? TOOLING_TEST_TARGETS.get(changedPath);
555686}
556687688+function shouldUseFocusedChangedTargets(env = process.env) {
689+const value = env[FOCUSED_CHANGED_ENV_KEY]?.trim().toLowerCase();
690+return ["1", "true", "yes", "on"].includes(value ?? "");
691+}
692+557693function isRoutableChangedTarget(changedPath) {
558694if (GENERATED_CHANGED_TEST_TARGETS.has(changedPath)) {
559695return false;
@@ -564,30 +700,69 @@ function isRoutableChangedTarget(changedPath) {
564700return /^(?:src|test|extensions|ui|packages)(?:\/|$)/u.test(changedPath);
565701}
566702567-export function resolveChangedTestTargetPlan(changedPaths) {
703+function resolveSiblingTestTarget(changedPath, cwd) {
704+if (!/\.[cm]?tsx?$/u.test(changedPath) || isTestFileTarget(changedPath)) {
705+return null;
706+}
707+const withoutExtension = changedPath.replace(/\.[cm]?tsx?$/u, "");
708+const sibling = `${withoutExtension}.test.ts`;
709+return fs.existsSync(path.join(cwd, sibling)) ? sibling : null;
710+}
711+712+function resolvePreciseChangedTestTargets(changedPath, options) {
713+const cwd = options.cwd ?? process.cwd();
714+const mappedTargets =
715+resolveToolingTestTargets(changedPath) ?? SOURCE_TEST_TARGETS.get(changedPath);
716+if (mappedTargets) {
717+return mappedTargets;
718+}
719+if (isRoutableChangedTarget(changedPath) && isTestFileTarget(changedPath)) {
720+return [changedPath];
721+}
722+const siblingTest = resolveSiblingTestTarget(changedPath, cwd);
723+if (siblingTest) {
724+return [siblingTest];
725+}
726+if (/^(?:src|test\/helpers|extensions|packages|ui\/src)\//u.test(changedPath)) {
727+const affectedTests = resolveAffectedTestsFromImportGraph(changedPath, cwd);
728+if (affectedTests.length > 0) {
729+return affectedTests;
730+}
731+}
732+return null;
733+}
734+735+export function resolveChangedTestTargetPlan(changedPaths, options = {}) {
568736if (changedPaths.length === 0) {
569737return { mode: "none", targets: [] };
570738}
571739const toolingTargets = resolveToolingChangedTestTargets(changedPaths);
572740if (toolingTargets) {
573741return { mode: "targets", targets: toolingTargets };
574742}
575-if (shouldKeepBroadChangedRun(changedPaths)) {
576-return { mode: "broad", targets: [] };
577-}
578743const changedLanes = detectChangedLanes(changedPaths);
579-if (changedLanes.lanes.all) {
744+const focused = options.focused ?? shouldUseFocusedChangedTargets(options.env ?? {});
745+const targets = [];
746+for (const changedPath of changedPaths) {
747+const preciseTargets = resolvePreciseChangedTestTargets(changedPath, options);
748+if (preciseTargets) {
749+targets.push(...preciseTargets);
750+continue;
751+}
752+if (focused) {
753+continue;
754+}
755+if (shouldKeepBroadChangedRun([changedPath]) || changedLanes.lanes.all) {
756+return { mode: "broad", targets: [] };
757+}
758+if (isRoutableChangedTarget(changedPath)) {
759+targets.push(changedPath);
760+}
761+}
762+if (!focused && changedLanes.lanes.all) {
580763return { mode: "broad", targets: [] };
581764}
582-const targets = changedPaths.flatMap((changedPath) => {
583-const mappedTargets =
584-resolveToolingTestTargets(changedPath) ?? SOURCE_TEST_TARGETS.get(changedPath);
585-if (mappedTargets) {
586-return mappedTargets;
587-}
588-return isRoutableChangedTarget(changedPath) ? [changedPath] : [];
589-});
590-if (changedLanes.extensionImpactFromCore) {
765+if (!focused && changedLanes.extensionImpactFromCore) {
591766targets.push("extensions");
592767}
593768return { mode: "targets", targets: [...new Set(targets)] };
@@ -604,13 +779,17 @@ export function resolveChangedTargetArgs(
604779args,
605780cwd = process.cwd(),
606781listChangedPaths = listChangedPathsFromGit,
782+options = {},
607783) {
608784const baseRef = extractChangedBaseRef(args);
609785if (!baseRef) {
610786return null;
611787}
612788const changedPaths = listChangedPaths(baseRef, cwd);
613-const plan = resolveChangedTestTargetPlan(changedPaths);
789+const plan = resolveChangedTestTargetPlan(changedPaths, {
790+ cwd,
791+ ...options,
792+});
614793if (plan.mode === "broad") {
615794return null;
616795}
@@ -877,10 +1056,11 @@ export function buildVitestRunPlans(
8771056args,
8781057cwd = process.cwd(),
8791058listChangedPaths = listChangedPathsFromGit,
1059+options = {},
8801060) {
8811061const { forwardedArgs, targetArgs, watchMode } = parseTestProjectsArgs(args, cwd);
8821062const changedTargetArgs =
883-targetArgs.length === 0 ? resolveChangedTargetArgs(args, cwd, listChangedPaths) : null;
1063+targetArgs.length === 0 ? resolveChangedTargetArgs(args, cwd, listChangedPaths, options) : null;
8841064const activeTargetArgs = changedTargetArgs ?? targetArgs;
8851065const activeForwardedArgs =
8861066changedTargetArgs !== null ? stripChangedArgs(forwardedArgs) : forwardedArgs;
@@ -1187,7 +1367,10 @@ export function shouldRetryVitestNoOutputTimeout(env = process.env) {
11871367export function createVitestRunSpecs(args, params = {}) {
11881368const cwd = params.cwd ?? process.cwd();
11891369const baseEnv = params.baseEnv ?? process.env;
1190-const plans = filterPlansForContractIncludeFile(buildVitestRunPlans(args, cwd), baseEnv);
1370+const plans = filterPlansForContractIncludeFile(
1371+buildVitestRunPlans(args, cwd, listChangedPathsFromGit, { env: baseEnv }),
1372+baseEnv,
1373+);
11911374return plans.map((plan, index) => {
11921375const includeFilePath = plan.includePatterns
11931376 ? path.join(
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。