mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
* fix(test): discover UI unit tests with browser in their names Use the native full-basename selector while preserving the dependency-free planning matcher. Compare registered root and package inventories so browser-import unit tests keep their Node owner. * fix(test): align project inventory with native discovery
212 lines
6.9 KiB
TypeScript
212 lines
6.9 KiB
TypeScript
import path from "node:path";
|
|
|
|
type GlobMatcher = (value: string, pattern: string) => boolean;
|
|
|
|
export const nonBrowserTestBasenamePattern = "!(*.browser.test.ts|!(*.test.ts))";
|
|
|
|
export function resolveNonBrowserTestPattern(pattern: string): string | null {
|
|
if (!pattern.endsWith(nonBrowserTestBasenamePattern)) {
|
|
return null;
|
|
}
|
|
const prefix = pattern.slice(0, -nonBrowserTestBasenamePattern.length);
|
|
if (prefix && !prefix.endsWith("/") && !prefix.endsWith("\\")) {
|
|
return null;
|
|
}
|
|
return prefix + "!(*.browser).test.ts";
|
|
}
|
|
|
|
export function filterFilesByPatterns(
|
|
files: readonly string[],
|
|
include: readonly string[],
|
|
exclude: readonly string[],
|
|
matchesGlob: GlobMatcher,
|
|
): string[] {
|
|
const selected = new Set<string>();
|
|
// Finish each pattern before advancing so large inventories do not churn
|
|
// the runtime's bounded compiled-glob cache for every candidate file.
|
|
for (const pattern of include) {
|
|
for (const file of files) {
|
|
if (!selected.has(file) && matchesGlob(file, pattern)) {
|
|
selected.add(file);
|
|
}
|
|
}
|
|
}
|
|
for (const pattern of exclude) {
|
|
for (const file of selected) {
|
|
if (matchesGlob(file, pattern)) {
|
|
selected.delete(file);
|
|
}
|
|
}
|
|
}
|
|
return files.filter((file) => selected.has(file));
|
|
}
|
|
|
|
function literalPrefixForGlobPattern(value: string): string {
|
|
const normalized = value.replaceAll("\\", "/");
|
|
const globIndex = normalized.search(/[?*[\]{}]/u);
|
|
if (globIndex === -1) {
|
|
return normalized;
|
|
}
|
|
const slashIndex = normalized.lastIndexOf("/", globIndex);
|
|
return slashIndex === -1 ? "" : normalized.slice(0, slashIndex + 1);
|
|
}
|
|
|
|
function patternsCouldOverlap(value: string, pattern: string, matchesGlob: GlobMatcher): boolean {
|
|
if (matchesGlob(value, pattern) || matchesGlob(pattern, value)) {
|
|
return true;
|
|
}
|
|
|
|
const valuePrefix = literalPrefixForGlobPattern(value);
|
|
const patternPrefix = literalPrefixForGlobPattern(pattern);
|
|
return (
|
|
patternPrefix === "" ||
|
|
valuePrefix === "" ||
|
|
valuePrefix.startsWith(patternPrefix) ||
|
|
patternPrefix.startsWith(valuePrefix)
|
|
);
|
|
}
|
|
|
|
export function narrowIncludePatterns(
|
|
includePatterns: string[],
|
|
candidatePatterns: string[] | null,
|
|
matchesGlob: GlobMatcher,
|
|
): string[] | null {
|
|
if (!candidatePatterns) {
|
|
return null;
|
|
}
|
|
|
|
// Prefix overlap alone cannot prove containment. Only narrow literal files
|
|
// and plain directory test patterns; preserve more complex owner globs.
|
|
const narrowed = new Set<string>();
|
|
for (const candidate of candidatePatterns) {
|
|
const isLiteral = !/[?*[\]{}]/u.test(candidate);
|
|
const directoryCandidate = candidate.replace(/\.test\.\*$/u, ".test.ts");
|
|
const candidateRoot = directoryTestPatternRoot(directoryCandidate);
|
|
for (const laneScope of includePatterns) {
|
|
if (isLiteral) {
|
|
if (matchesGlob(candidate, laneScope)) {
|
|
narrowed.add(candidate);
|
|
}
|
|
} else if (patternsCouldOverlap(candidate, laneScope, matchesGlob)) {
|
|
const ownerRoot = directoryTestPatternRoot(laneScope);
|
|
narrowed.add(
|
|
candidateRoot !== null && ownerRoot !== null && isAtOrUnder(candidateRoot, ownerRoot)
|
|
? directoryCandidate
|
|
: laneScope,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
return [...narrowed];
|
|
}
|
|
|
|
export function isPlainRepoRelativePath(value: string): boolean {
|
|
if (!/^[A-Za-z0-9_./-]+$/u.test(value) || path.isAbsolute(value)) {
|
|
return false;
|
|
}
|
|
return value.split("/").every((segment) => segment !== "" && segment !== "." && segment !== "..");
|
|
}
|
|
|
|
function directoryTestPatternRoot(value: string): string | null {
|
|
const normalized = value.trim().replaceAll("\\", "/").replace(/^\.\//u, "");
|
|
if (normalized === "**/*.test.ts") {
|
|
return "";
|
|
}
|
|
const suffix = "/**/*.test.ts";
|
|
if (!normalized.endsWith(suffix)) {
|
|
return null;
|
|
}
|
|
const root = normalized.slice(0, -suffix.length);
|
|
return isPlainRepoRelativePath(root) ? root : null;
|
|
}
|
|
|
|
function isAtOrUnder(value: string, root: string): boolean {
|
|
return root === "" || value === root || value.startsWith(`${root}/`);
|
|
}
|
|
|
|
function patternIsFullyUnderDirectory(pattern: string, root: string): boolean {
|
|
const normalized = pattern.trim().replaceAll("\\", "/").replace(/^\.\//u, "");
|
|
const testPattern = resolveNonBrowserTestPattern(normalized) ?? normalized;
|
|
if (!testPattern.endsWith(".test.ts")) {
|
|
return false;
|
|
}
|
|
const literalPrefix = literalPrefixForGlobPattern(testPattern).replace(/\/+$/u, "");
|
|
return isAtOrUnder(literalPrefix, root);
|
|
}
|
|
|
|
function intersectDirectoryTestPattern(
|
|
includePatterns: string[],
|
|
candidatePattern: string,
|
|
matchesGlob: GlobMatcher,
|
|
): string[] | null {
|
|
const candidateRoot = directoryTestPatternRoot(candidatePattern);
|
|
if (candidateRoot === null) {
|
|
return includePatterns.some((pattern) => {
|
|
const includeRoot = directoryTestPatternRoot(pattern);
|
|
return includeRoot !== null && patternIsFullyUnderDirectory(candidatePattern, includeRoot);
|
|
})
|
|
? [candidatePattern]
|
|
: null;
|
|
}
|
|
|
|
const result: string[] = [];
|
|
let hasAmbiguousOverlap = false;
|
|
for (const includePattern of includePatterns) {
|
|
const includeRoot = directoryTestPatternRoot(includePattern);
|
|
if (includeRoot !== null && isAtOrUnder(candidateRoot, includeRoot)) {
|
|
return [candidatePattern];
|
|
} else if (patternIsFullyUnderDirectory(includePattern, candidateRoot)) {
|
|
result.push(includePattern);
|
|
} else if (patternsCouldOverlap(candidatePattern, includePattern, matchesGlob)) {
|
|
hasAmbiguousOverlap = true;
|
|
}
|
|
}
|
|
if (hasAmbiguousOverlap) {
|
|
return null;
|
|
}
|
|
return [...new Set(result)];
|
|
}
|
|
|
|
export function intersectIncludePatterns(
|
|
includePatterns: string[],
|
|
candidatePatterns: string[] | null,
|
|
matchesGlob: GlobMatcher,
|
|
): string[] | null {
|
|
if (!candidatePatterns) {
|
|
return null;
|
|
}
|
|
|
|
const literalIncludes = includePatterns.every(isPlainRepoRelativePath)
|
|
? new Set(includePatterns)
|
|
: null;
|
|
const result: string[] = [];
|
|
for (const candidate of candidatePatterns) {
|
|
if (!isPlainRepoRelativePath(candidate)) {
|
|
if (literalIncludes) {
|
|
result.push(...includePatterns.filter((include) => matchesGlob(include, candidate)));
|
|
continue;
|
|
}
|
|
if (includePatterns.includes(candidate)) {
|
|
result.push(candidate);
|
|
continue;
|
|
}
|
|
// Watch directory targets retain their glob so newly added tests appear.
|
|
// Only generated directory globs have a provable ownership intersection.
|
|
const intersection = intersectDirectoryTestPattern(includePatterns, candidate, matchesGlob);
|
|
if (!intersection) {
|
|
throw new Error(`cannot safely intersect non-literal include path: ${candidate}`);
|
|
}
|
|
result.push(...intersection);
|
|
continue;
|
|
}
|
|
if (
|
|
literalIncludes
|
|
? literalIncludes.has(candidate)
|
|
: includePatterns.some((include) => matchesGlob(candidate, include))
|
|
) {
|
|
result.push(candidate);
|
|
}
|
|
}
|
|
|
|
return [...new Set(result)];
|
|
}
|