UNPKG

pi-lens

Version:

Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo

1,288 lines • 56.4 kB
/**
 * Test Runner Client for pi-lens
 *
 * Detects test files and runs them on write/edit to provide
 * immediate test feedback to the AI agent.
 *
 * Supports: vitest, jest, pytest, go, cargo, dotnet, gradle, maven, rspec,
 * minitest, phpunit, mix (extensible to more)
 *
 * Design: File-level targeted testing — only runs tests for the
 * specific file being edited, not the entire suite.
 */
import * as fs from "node:fs";
import * as path from "node:path";
import { minimatch } from "./deps/minimatch.js";
import { detectFileRole } from "./file-role.js";
import { findGlobalBinary } from "./package-manager.js";
import { safeSpawn, safeSpawnAsync } from "./safe-spawn.js";
// Source file → test file patterns (reverse lookup)
const SOURCE_TO_TEST_PATTERNS = [
    {
        ext: ".ts",
        testExts: [".test.ts", ".spec.ts"],
        dirs: ["__tests__", "tests", ".", "__tests__"],
    },
    {
        ext: ".tsx",
        testExts: [".test.tsx", ".spec.tsx"],
        dirs: ["__tests__", "tests", ".", "__tests__"],
    },
    {
        ext: ".js",
        testExts: [".test.js", ".spec.js"],
        dirs: ["__tests__", "tests", ".", "__tests__"],
    },
    {
        ext: ".jsx",
        testExts: [".test.jsx", ".spec.jsx"],
        dirs: ["__tests__", "tests", ".", "__tests__"],
    },
    {
        ext: ".py",
        testExts: ["test_*.py", "*_test.py"],
        dirs: ["tests", "test", ".", "."],
    },
    { ext: ".go", testExts: ["_test.go"], dirs: [".", ".", ".", "."] }, // Go tests are co-located
    { ext: ".rs", testExts: [".rs"], dirs: ["tests", "tests", "src", "."] }, // Rust: tests/ or #[test] in src
    // PHPUnit convention: tests/ mirrors src/ with ClassNameTest.php naming
    // (e.g. src/Foo/Bar.php -> tests/Foo/BarTest.php). Basename is already the
    // class name (PHP files are named after their class), so no case transform
    // is needed — the mirrored-directory search below handles the tests/ root.
    { ext: ".php", testExts: ["Test.php"], dirs: ["tests"] },
    // ExUnit convention: test/ mirrors lib/ with a _test.exs suffix on the same
    // basename (e.g. lib/accounts/user.ex -> test/accounts/user_test.exs).
    { ext: ".ex", testExts: ["_test.exs"], dirs: ["test"] },
];
// Bound for walking up parent directories to find a hoisted node_modules
// (monorepo workspaces) — deep enough for realistic nesting
// (repo/packages/scope/pkg-name), never unbounded to the filesystem root.
const MAX_NODE_MODULES_WALK_UP = 5;
// Bound for recursive descent into a Python test directory when the exact
// same-relative-subdir mirror doesn't match (e.g. tests/unit/ grouping by
// test type rather than mirroring source layout) — capped depth, never an
// unbounded walk of the whole tests tree.
const MAX_PYTEST_RECURSE_DEPTH = 3;
// --- Runner Detection ---
const RUNNERS = {
    vitest: {
        configFiles: ["vitest.config.ts", "vitest.config.js", "vitest.config.mjs"],
        command: "npx",
        binName: "vitest",
        args: (testFile, _cwd) => [
            "vitest",
            "run",
            testFile,
            "--reporter=json",
            "--passWithNoTests",
        ],
        parseJson: true,
    },
    jest: {
        configFiles: [
            "jest.config.ts",
            "jest.config.js",
            "jest.config.json",
            ".jestrc.js",
        ],
        command: "npx",
        binName: "jest",
        args: (testFile, _cwd) => [
            "jest",
            testFile,
            "--json",
            "--passWithNoTests",
            "--forceExit",
        ],
        parseJson: true,
    },
    pytest: {
        configFiles: ["pytest.ini", "pyproject.toml", "setup.cfg", "tox.ini"],
        command: "python",
        args: (testFile, _cwd) => ["-m", "pytest", testFile, "--tb=short", "-q"],
        parseJson: false, // pytest JSON requires plugin, use text parsing
    },
    go: {
        configFiles: ["go.mod"],
        command: "go",
        args: (testFile, cwd) => {
            // Convert file path to package path
            const relPath = path.relative(cwd, testFile);
            const pkgDir = path.dirname(relPath);
            return ["test", `-run`, ".", `./${pkgDir === "." ? "." : pkgDir}`];
        },
        parseJson: false, // Go test output is text-based
    },
    cargo: {
        configFiles: ["Cargo.toml"],
        command: "cargo",
        args: (_testFile, _cwd) => ["test", "--no-fail-fast"],
        parseJson: false, // cargo test output is text-based
    },
    dotnet: {
        configFiles: ["*.csproj", "*.sln"],
        command: "dotnet",
        args: (_testFile, _cwd) => ["test", "--no-build"],
        parseJson: false,
    },
    gradle: {
        configFiles: ["build.gradle", "build.gradle.kts", "settings.gradle"],
        command: process.platform === "win32" ? "gradlew.bat" : "./gradlew",
        args: (_testFile, _cwd) => ["test", "--no-daemon"],
        parseJson: false,
    },
    maven: {
        configFiles: ["pom.xml"],
        command: "mvn",
        args: (_testFile, _cwd) => ["test", "-q"],
        parseJson: false,
    },
    rspec: {
        configFiles: [".rspec", "spec/spec_helper.rb"],
        command: "bundle",
        args: (testFile, _cwd) => ["exec", "rspec", testFile],
        parseJson: false,
    },
    minitest: {
        configFiles: ["Gemfile"],
        command: "ruby",
        args: (testFile, _cwd) => ["-Itest", testFile],
        parseJson: false,
    },
    phpunit: {
        // phpunit.xml(.dist) is the strong signal; composer.json is checked for
        // a require-dev dependency on phpunit/phpunit (see the special case in
        // detectRunner's Priority-1 loop, mirroring the pytest/pyproject.toml
        // handling above).
        configFiles: ["phpunit.xml", "phpunit.xml.dist", "composer.json"],
        command: "phpunit",
        args: (testFile, _cwd) => [testFile],
        parseJson: false, // PHPUnit's default CLI output is text-based
    },
    mix: {
        configFiles: ["mix.exs"],
        command: "mix",
        args: (testFile, _cwd) => ["test", testFile],
        parseJson: false, // mix test's default output is text-based
    },
};
// --- Client ---
export class TestRunnerClient {
    log;
    availableRunners = new Map();
    failedTestsByRunner = new Map();
    // Best-effort vitest config `test.include`/`test.exclude` globs, scraped as
    // plain text (never executed) and cached per cwd so the config file is
    // only read/parsed once, not on every edit. `null` means "no config found
    // or it couldn't be parsed in the simple shape we look for" — callers
    // treat that as "no additional signal" and fall back to naming-convention
    // detection only.
    vitestTestGlobsCache = new Map();
    constructor(verbose = false) {
        this.log = verbose
            ? (msg) => console.error(`[test-runner] ${msg}`)
            : () => { };
    }
    /**
     * Check if a test runner is available in the project
     * Detection order:
     * 1. Config files (vitest.config.ts, jest.config.js, etc.)
     * 2. package.json dependencies
     * 3. node_modules presence
     */
    detectRunner(cwd, sourceFilePath) {
        // Priority 1: Config files
        for (const [name, config] of Object.entries(RUNNERS)) {
            const cacheKey = `${cwd}:${name}:config`;
            if (this.availableRunners.has(cacheKey)) {
                if (this.availableRunners.get(cacheKey)) {
                    return { runner: name, config };
                }
                continue;
            }
            const found = config.configFiles.some((cf) => {
                if (name === "pytest" && cf === "pyproject.toml") {
                    const pyprojectPath = path.join(cwd, cf);
                    if (!fs.existsSync(pyprojectPath))
                        return false;
                    try {
                        const pyproject = fs.readFileSync(pyprojectPath, "utf-8");
                        return pyproject.includes("[tool.pytest.ini_options]");
                    }
                    catch {
                        return false;
                    }
                }
                if (name === "phpunit" && cf === "composer.json") {
                    const composerPath = path.join(cwd, cf);
                    if (!fs.existsSync(composerPath))
                        return false;
                    try {
                        const composer = JSON.parse(fs.readFileSync(composerPath, "utf-8"));
                        const allDeps = {
                            ...composer.require,
                            ...composer["require-dev"],
                        };
                        return Boolean(allDeps["phpunit/phpunit"]);
                    }
                    catch {
                        return false;
                    }
                }
                return fs.existsSync(path.join(cwd, cf));
            });
            this.availableRunners.set(cacheKey, found);
            if (found) {
                this.log(`Detected runner via config: ${name}`);
                return { runner: name, config };
            }
        }
        const packageJsonPath = path.join(cwd, "package.json");
        try {
            const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
            const allDeps = {
                ...pkg.dependencies,
                ...pkg.devDependencies,
            };
            // Check for vitest first (more specific than jest)
            if (allDeps.vitest) {
                this.log("Detected vitest in package.json");
                this.availableRunners.set(`${cwd}:vitest:config`, true);
                return { runner: "vitest", config: RUNNERS.vitest };
            }
            if (allDeps.jest) {
                this.log("Detected jest in package.json");
                this.availableRunners.set(`${cwd}:jest:config`, true);
                return { runner: "jest", config: RUNNERS.jest };
            }
            if (allDeps.pytest || allDeps["pytest-cov"]) {
                this.log("Detected pytest in package.json (unusual)");
                this.availableRunners.set(`${cwd}:pytest:config`, true);
                return { runner: "pytest", config: RUNNERS.pytest };
            }
        }
        catch (err) {
            void err;
            // package.json parse error or file not found
        }
        // Priority 3: Check node_modules for installed packages, including a
        // hoisted monorepo layout where cwd is a workspace package (e.g.
        // packages/foo) but the runner only lives in node_modules at the
        // workspace root (npm/yarn/pnpm workspace hoisting). Walk up a
        // bounded number of parent directories looking for a node_modules
        // containing the package — never an unbounded walk to the
        // filesystem root.
        const hoistedVitest = this.findHoistedNodeModulesPackage(cwd, "vitest");
        if (hoistedVitest) {
            this.log(`Detected vitest in node_modules (${hoistedVitest})`);
            return { runner: "vitest", config: RUNNERS.vitest };
        }
        const hoistedJest = this.findHoistedNodeModulesPackage(cwd, "jest");
        if (hoistedJest) {
            this.log(`Detected jest in node_modules (${hoistedJest})`);
            return { runner: "jest", config: RUNNERS.jest };
        }
        for (const name of ["go", "cargo", "dotnet", "gradle", "maven"]) {
            const config = RUNNERS[name];
            const found = config.configFiles.some((cf) => {
                // Handle glob patterns like *.csproj
                if (cf.includes("*")) {
                    try {
                        const files = fs.readdirSync(cwd);
                        return files.some((f) => new RegExp(cf.replace(/\*/g, ".*")).test(f));
                    }
                    catch {
                        return false;
                    }
                }
                return fs.existsSync(path.join(cwd, cf));
            });
            if (found) {
                this.log(`Detected ${name} from config file`);
                return { runner: name, config };
            }
        }
        // Priority 5: Check if pytest is available globally (Python files only)
        const isPythonSource = typeof sourceFilePath === "string" && sourceFilePath.endsWith(".py");
        if (!isPythonSource)
            return null;
        try {
            const whichCmd = process.platform === "win32" ? "where" : "which";
            const result = safeSpawn(whichCmd, ["pytest"], {
                timeout: 2000,
            });
            if (result.status === 0) {
                this.log("Detected pytest globally");
                return { runner: "pytest", config: RUNNERS.pytest };
            }
        }
        catch (err) {
            void err;
        }
        return null;
    }
    /**
     * Walk up from `cwd` through parent directories looking for a
     * `node_modules/<packageName>` — handles monorepo workspace hoisting
     * (npm/yarn/pnpm), where a workspace package's own `node_modules` may
     * not exist at all, with dependencies hoisted to the workspace root
     * several directories up. Bounded by `MAX_NODE_MODULES_WALK_UP` levels
     * and stops at the filesystem root — never an unbounded walk.
     * Returns the `node_modules` directory where the package was found, or
     * null if not found within the bound.
     */
    findHoistedNodeModulesPackage(cwd, packageName) {
        let dir = path.resolve(cwd);
        for (let level = 0; level <= MAX_NODE_MODULES_WALK_UP; level++) {
            const nodeModulesPath = path.join(dir, "node_modules");
            if (fs.existsSync(path.join(nodeModulesPath, packageName))) {
                return nodeModulesPath;
            }
            const parent = path.dirname(dir);
            if (parent === dir)
                break; // reached filesystem root
            dir = parent;
        }
        return null;
    }
    /**
     * Depth-bounded breadth-first search under `rootDir` for a pytest-style
     * test file matching `pattern` (exact, e.g. `test_foo.py`) or the
     * looser `test_*<basename>*.py` convention. Used as a last-resort
     * fallback when a Python test suite groups tests by kind
     * (`tests/unit/`, `tests/integration/`) instead of mirroring the
     * source directory layout, so the exact-mirror candidates in
     * `findTestFile` don't match. Bounded by `maxDepth` levels below
     * `rootDir` and skips hidden directories and `__pycache__` — never an
     * unbounded walk of the whole tests tree.
     */
    findPytestMatchRecursive(rootDir, pattern, basename, maxDepth) {
        const queue = [
            { dir: rootDir, depth: 0 },
        ];
        while (queue.length > 0) {
            const next = queue.shift();
            if (!next)
                break;
            const { dir, depth } = next;
            let entries;
            try {
                entries = fs.readdirSync(dir, { withFileTypes: true });
            }
            catch {
                continue;
            }
            for (const entry of entries) {
                const fullPath = path.join(dir, entry.name);
                if (entry.isFile()) {
                    if (entry.name === pattern ||
                        (entry.name.startsWith("test_") &&
                            entry.name.endsWith(".py") &&
                            entry.name.includes(basename))) {
                        return fullPath;
                    }
                }
                else if (entry.isDirectory() && depth < maxDepth) {
                    if (entry.name === "__pycache__" || entry.name.startsWith("."))
                        continue;
                    queue.push({ dir: fullPath, depth: depth + 1 });
                }
            }
        }
        return null;
    }
    /**
     * Path of `dir` relative to `cwd`, using forward slashes, or null if `dir`
     * is not inside `cwd` (e.g. resolves to `..` or an absolute path).
     * Used to compute a mirrored test-tree subdirectory (e.g. `clients` for
     * `clients/knip-client.ts`, so `tests/clients/knip-client.test.ts` is
     * checked alongside the flat `tests/knip-client.test.ts` candidate).
     */
    relativeSourceDir(sourceFilePath, cwd) {
        const dir = path.dirname(sourceFilePath);
        const relDir = path.relative(cwd, path.resolve(cwd, dir));
        if (!relDir || relDir === "." || relDir.startsWith("..") || path.isAbsolute(relDir)) {
            return null;
        }
        return relDir;
    }
    /**
     * Best-effort, text-only scrape of a vitest config's `test.include` /
     * `test.exclude` arrays. This deliberately does NOT execute the config
     * file (that would mean loading arbitrary ESM/TS via Vite's config
     * loader — too heavy for a per-edit hot path). It just looks for a
     * simple `include: [ ... ]` / `exclude: [ ... ]` shape with string
     * literals inside and pulls those out with a regex.
     *
     * Returns `null` (never throws) when there's no vitest config file, it
     * can't be read, or the include/exclude shape isn't a plain array of
     * string literals (e.g. it's built from a function call, spread, or
     * template expression) — anything more dynamic than that is out of
     * scope for this heuristic.
     *
     * Cached per `cwd` so the file is only read/parsed once per project,
     * not on every edit.
     */
    parseVitestTestGlobs(cwd) {
        if (this.vitestTestGlobsCache.has(cwd)) {
            return this.vitestTestGlobsCache.get(cwd) ?? null;
        }
        // .mts isn't in RUNNERS.vitest.configFiles (that list drives runner
        // *detection* priority) but is a legal vitest config extension, so it's
        // included here for the scrape even though detectRunner doesn't check it.
        const candidates = [...RUNNERS.vitest.configFiles, "vitest.config.mts"];
        let content = null;
        for (const cf of candidates) {
            try {
                content = fs.readFileSync(path.join(cwd, cf), "utf-8");
                break;
            }
            catch {
                continue;
            }
        }
        let result = null;
        if (content !== null) {
            const include = this.extractGlobArrayLiteral(content, "include");
            const exclude = this.extractGlobArrayLiteral(content, "exclude");
            if (include || exclude) {
                result = {};
                if (include)
                    result.include = include;
                if (exclude)
                    result.exclude = exclude;
            }
        }
        this.vitestTestGlobsCache.set(cwd, result);
        return result;
    }
    /**
     * Extract `<key>: [ 'a', "b", `c` ]` as a plain string array from raw
     * config text. Returns undefined if the key isn't present, or if the
     * array body contains anything besides string literals and commas/
     * whitespace (a function call, spread, variable reference, etc.) —
     * that's a sign the value is dynamic and this best-effort scrape can't
     * safely interpret it.
     */
    extractGlobArrayLiteral(content, key) {
        const arrayMatch = content.match(new RegExp(`\\b${key}\\s*:\\s*\\[([^\\]]*)\\]`));
        if (!arrayMatch)
            return undefined;
        const body = arrayMatch[1];
        const literalPattern = /'([^'\\]*)'|"([^"\\]*)"|`([^`\\]*)`/g;
        const literals = [];
        let lastEnd = 0;
        let match;
        while ((match = literalPattern.exec(body)) !== null) {
            const between = body.slice(lastEnd, match.index).trim();
            // Only whitespace/commas may appear between literals — anything
            // else (identifiers, parens, spreads) means the array isn't a
            // plain list of string literals.
            if (between !== "" && !/^,$/.test(between))
                return undefined;
            literals.push(match[1] ?? match[2] ?? match[3] ?? "");
            lastEnd = literalPattern.lastIndex;
        }
        const trailing = body.slice(lastEnd).trim();
        if (trailing !== "" && trailing !== ",")
            return undefined;
        return literals.length > 0 ? literals : undefined;
    }
    /**
     * Whether `sourceFilePath` is itself a test file (as opposed to a source
     * file whose *related* test file needs to be discovered).
     *
     * Primary signal: `detectFileRole` (naming convention: `.test.`/`.spec.`
     * basenames, `test_`/`spec_` prefixes, `__tests__/`/`tests/`/`spec/`
     * directories — shared with the rest of the codebase, not a second
     * parallel detector).
     *
     * Secondary signal (vitest only): the project's own `test.include` /
     * `test.exclude` globs, best-effort scraped by `parseVitestTestGlobs`.
     * This can correct the naming-convention answer in both directions —
     * an `exclude` glob can rule out a path that looks like a test by name,
     * and an `include` glob can catch a project that puts tests somewhere
     * unconventional. When no config is found or it can't be parsed, this
     * is a no-op and behavior is unchanged.
     *
     * #628: a positive `include` override is only trusted when the glob is a
     * *narrow* test signal (see `isNarrowTestGlob`) — a bare "any file with
     * this extension" include (e.g. `src/**\/*.ts`) is common in real vitest
     * configs and matches ordinary source files, so treating any match as
     * "this is a test" produced vacuous `0p/0f` self-runs on plain source
     * files (background-review.ts, index.ts, …). The `exclude` direction is
     * left as a plain match: over-excluding only causes discovery to run on a
     * file that's actually a test (falls back to `findTestFile`, not a false
     * "self" positive), which is the safe failure mode.
     */
    isTestFile(sourceFilePath, cwd, runner) {
        let result = detectFileRole(sourceFilePath) === "test";
        if (runner === "vitest") {
            const globs = this.parseVitestTestGlobs(cwd);
            if (globs) {
                const rel = path
                    .relative(cwd, path.resolve(cwd, sourceFilePath))
                    .replace(/\\/g, "/");
                const matches = (globs_, filter) => !!globs_?.some((g) => (!filter || filter(g)) && minimatch(rel, g, { dot: true }));
                if (matches(globs.exclude)) {
                    result = false;
                }
                else if (!result &&
                    matches(globs.include, (g) => this.isNarrowTestGlob(g))) {
                    result = true;
                }
            }
        }
        return result;
    }
    /**
     * Whether an `include` glob is a specific enough signal to override a
     * plain "this is source, not a test" naming-convention verdict (#628).
     *
     * Trusted when either:
     *  - a literal (non-wildcard) path segment before the first wildcard
     *    names a conventional test location (`tests/`, `test/`, `spec/`,
     *    `specs/`, `__tests__/`) — the real case this override exists for:
     *    a project whose test files live in such a directory without a
     *    `.test.`/`.spec.` name (e.g. `tests/**\/*.ts`).
     *  - the static suffix after the last wildcard encodes more than the
     *    bare language extension (e.g. `.check.ts`, `.flow.ts`) — an explicit
     *    project-specific naming convention, not "any file with this
     *    extension" (e.g. `**\/*.check.ts`).
     *
     * Rejected for a bare extension glob with no test-ish directory (e.g.
     * `src/**\/*.ts`, `**\/*.ts`) — that shape matches every source file in
     * the tree and is exactly what produced vacuous self-runs in practice.
     */
    isNarrowTestGlob(glob) {
        const testDirPattern = /^(tests?|specs?|__tests__)$/i;
        for (const segment of glob.split("/")) {
            if (segment.includes("*") || segment.includes("?"))
                break;
            if (testDirPattern.test(segment))
                return true;
        }
        const lastWildcard = Math.max(glob.lastIndexOf("*"), glob.lastIndexOf("?"));
        const suffix = lastWildcard >= 0 ? glob.slice(lastWildcard + 1) : glob;
        const dotSegments = suffix.split(".").filter(Boolean);
        return dotSegments.length >= 2;
    }
    /**
     * Find test file for a given source file
     * Returns the test file path if it exists, null otherwise
     */
    findTestFile(sourceFilePath, cwd, runnerOverride) {
        const ext = path.extname(sourceFilePath);
        const basename = path.basename(sourceFilePath, ext);
        const dir = path.dirname(sourceFilePath);
        const patterns = SOURCE_TO_TEST_PATTERNS.find((p) => p.ext === ext);
        if (!patterns)
            return null;
        const detected = runnerOverride
            ? { runner: runnerOverride, config: RUNNERS[runnerOverride] }
            : this.detectRunner(cwd, sourceFilePath);
        if (!detected)
            return null;
        // Relative subdirectory of the source file, used to check a mirrored
        // test-tree layout (tests/<same-subdir>/<basename><testExt>), on top of
        // the flat tests/<basename><testExt> layout already checked below.
        // Null when the source file sits at the project root (dir === ".") or
        // falls outside cwd — in that case there is no subdir to mirror.
        const relDir = this.relativeSourceDir(sourceFilePath, cwd);
        // Check each potential test file location
        for (let i = 0; i < patterns.testExts.length; i++) {
            const testExt = patterns.testExts[i];
            const testDir = patterns.dirs[i];
            // Handle glob patterns (pytest style: test_*.py)
            if (testExt.includes("*")) {
                const pattern = testExt.replace(/\*/g, basename);
                const searchDirs = testDir === "."
                    ? [dir]
                    : relDir
                        ? [path.join(cwd, testDir, relDir), path.join(cwd, testDir)]
                        : [path.join(cwd, testDir)];
                for (const searchDir of searchDirs) {
                    let files;
                    try {
                        files = fs.readdirSync(searchDir);
                    }
                    catch (err) {
                        void err;
                        continue;
                    }
                    const match = files.find((f) => f === pattern ||
                        (f.startsWith("test_") &&
                            f.endsWith(".py") &&
                            f.includes(basename)));
                    if (match) {
                        const testPath = path.join(searchDir, match);
                        this.log(`Found test file: ${testPath}`);
                        return { testFile: testPath, runner: detected.runner };
                    }
                }
                // None of the exact-mirror candidates matched. Python test
                // suites commonly group tests by kind (tests/unit/,
                // tests/integration/) rather than mirroring the source tree,
                // so do a depth-bounded recursive search under the test
                // root as a last resort before falling back to import
                // scanning — bounded so a large repo can't turn this into
                // an unbounded directory walk.
                if (testDir !== ".") {
                    const recursiveMatch = this.findPytestMatchRecursive(path.join(cwd, testDir), pattern, basename, MAX_PYTEST_RECURSE_DEPTH);
                    if (recursiveMatch) {
                        this.log(`Found test file (recursive): ${recursiveMatch}`);
                        return { testFile: recursiveMatch, runner: detected.runner };
                    }
                }
            }
            else {
                // Exact pattern match (jest/vitest style)
                const testFilename = basename + testExt;
                const searchPaths = [
                    path.join(dir, testFilename), // same directory
                    path.join(dir, "__tests__", testFilename), // __tests__ subdirectory
                    ...(relDir
                        ? [
                            path.join(cwd, "tests", relDir, testFilename), // mirrored tests/<subdir>/
                            path.join(cwd, "__tests__", relDir, testFilename), // mirrored __tests__/<subdir>/
                        ]
                        : []),
                    path.join(cwd, "tests", testFilename), // top-level tests/
                    path.join(cwd, "__tests__", testFilename), // top-level __tests__/
                    // PHP/Elixir-style source-root mirroring (e.g. src/Foo/Bar.php ->
                    // tests/Foo/BarTest.php, lib/accounts/user.ex ->
                    // test/accounts/user_test.exs): strips a conventional source-root
                    // segment and mirrors under this pattern's OWN configured test
                    // root (testDir), not the hardcoded "tests"/"__tests__" above —
                    // ExUnit's root is "test" (singular), which those don't cover.
                    ...this.sourceRootMirroredCandidates(dir, cwd, testDir, testFilename),
                ];
                for (const testPath of searchPaths) {
                    if (fs.existsSync(testPath)) {
                        this.log(`Found test file: ${testPath}`);
                        return { testFile: testPath, runner: detected.runner };
                    }
                }
            }
        }
        // Basename lookup found nothing — try import scanning as a fallback.
        const importMatch = this.findTestFileByImport(sourceFilePath, cwd);
        if (importMatch) {
            return { testFile: importMatch, runner: detected.runner };
        }
        return null;
    }
    /**
     * Select the most useful test target for this edit.
     *
     * Strategy:
     * 1) If there are known failing tests, rerun those first (fast feedback loop).
     * 2) Otherwise run related tests for the edited file.
     */
    getTestRunTarget(sourceFilePath, cwd) {
        const detected = this.detectRunner(cwd, sourceFilePath);
        if (!detected)
            return null;
        const key = this.failedKey(cwd, detected.runner);
        const failedSet = this.failedTestsByRunner.get(key);
        // If the edited file is itself a test file, there's no "related test"
        // to discover — running findTestFile on it would strip its own
        // extension and search for nonsense like foo.test.test.ts. Skip
        // discovery entirely and treat the file as its own target.
        const selfIsTest = this.isTestFile(sourceFilePath, cwd, detected.runner);
        const related = selfIsTest
            ? null
            : this.findTestFile(sourceFilePath, cwd, detected.runner);
        if (failedSet && failedSet.size > 0) {
            if (related) {
                const relatedAbs = path.resolve(related.testFile);
                if (failedSet.has(relatedAbs)) {
                    return {
                        testFile: relatedAbs,
                        runner: detected.runner,
                        config: detected.config,
                        strategy: "failed-first",
                    };
                }
            }
            if (selfIsTest) {
                const selfAbs = path.resolve(sourceFilePath);
                if (failedSet.has(selfAbs)) {
                    return {
                        testFile: selfAbs,
                        runner: detected.runner,
                        config: detected.config,
                        strategy: "failed-first",
                    };
                }
            }
            return {
                testFile: [...failedSet][0],
                runner: detected.runner,
                config: detected.config,
                strategy: "failed-first",
            };
        }
        if (selfIsTest) {
            return {
                testFile: path.resolve(sourceFilePath),
                runner: detected.runner,
                config: detected.config,
                strategy: "self",
            };
        }
        if (!related)
            return null;
        return {
            testFile: path.resolve(related.testFile),
            runner: detected.runner,
            config: detected.config,
            strategy: "related",
        };
    }
    /**
     * Run tests for a specific file without blocking the event loop, so LSP
     * messages, other file writes, and all async operations continue while
     * tests run.
     */
    async runTestFileAsync(testFile, cwd, runner, config) {
        const absoluteTestFile = path.resolve(testFile);
        if (!fs.existsSync(absoluteTestFile)) {
            return this.emptyResult(absoluteTestFile, "", runner, "Test file not found");
        }
        try {
            const { command, args } = await this.resolveExec(runner, config, absoluteTestFile, cwd);
            this.log(`Running (async): ${command} ${args.join(" ")}`);
            const result = await safeSpawnAsync(command, args, {
                cwd,
                timeout: 60000,
            });
            const stdout = result.stdout || "";
            const stderr = result.stderr || "";
            if (result.error) {
                this.log(`Runner error: ${result.error.message}`);
                return this.emptyResult(absoluteTestFile, "", runner, `Runner error: ${result.error.message}`);
            }
            let parsed;
            switch (runner) {
                case "vitest":
                    parsed = this.parseVitestOutput(stdout, stderr, absoluteTestFile, cwd, runner);
                    break;
                case "jest":
                    parsed = this.parseJestOutput(stdout, stderr, absoluteTestFile, cwd, runner);
                    break;
                case "pytest":
                    parsed = this.parsePytestOutput(stdout, stderr, result.status ?? 0, absoluteTestFile, cwd, runner);
                    break;
                case "phpunit":
                    parsed = this.parsePhpunitOutput(stdout, stderr, result.status ?? 0, absoluteTestFile, runner);
                    break;
                case "mix":
                    parsed = this.parseMixTestOutput(stdout, stderr, result.status ?? 0, absoluteTestFile, runner);
                    break;
                default:
                    parsed = this.parseGenericRunnerOutput(stdout, stderr, result.status ?? 0, absoluteTestFile, runner);
                    break;
            }
            this.recordResult(cwd, runner, absoluteTestFile, parsed);
            return parsed;
        }
        catch (err) {
            this.log(`Run error: ${err.message}`);
            return this.emptyResult(absoluteTestFile, "", runner, err.message);
        }
    }
    /**
     * Check if a source file has corresponding tests (without running them)
     */
    hasTestFile(sourceFilePath, cwd) {
        return this.findTestFile(sourceFilePath, cwd) !== null;
    }
    /**
     * Suggest test files for a list of source files.
     * Returns deduplicated test file paths with their corresponding source file.
     */
    suggestTestFiles(sourceFiles, cwd) {
        const seen = new Set();
        const results = [];
        for (const sourceFile of sourceFiles) {
            const found = this.findTestFile(sourceFile, cwd);
            if (!found)
                continue;
            const abs = path.resolve(found.testFile);
            if (seen.has(abs))
                continue;
            seen.add(abs);
            results.push({ testFile: abs, sourceFile, runner: found.runner });
        }
        return results;
    }
    // --- Shared JSON test output parser (Vitest + Jest share the same structure) ---
    parseJsonTestOutput(stdout, stderr, testFile, cwd, runner) {
        try {
            const json = JSON.parse(stdout);
            const failures = [];
            for (const suite of json.testResults || []) {
                if (suite.status === "failed" && suite.assertionResults) {
                    for (const test of suite.assertionResults) {
                        if (test.status === "failed") {
                            failures.push({
                                name: test.title,
                                message: test.failureMessages?.[0] || suite.message || "Test failed",
                                location: test.location
                                    ? `${path.relative(cwd, testFile)}:${test.location.line}`
                                    : undefined,
                                stack: this.truncateStack(test.failureMessages?.join("\n")),
                            });
                        }
                    }
                }
            }
            return {
                file: testFile,
                sourceFile: "",
                runner,
                passed: json.numPassedTests || 0,
                failed: json.numFailedTests || 0,
                skipped: json.numSkippedTests || 0,
                failures,
                duration: 0,
            };
        }
        catch (err) {
            void err;
            const failed = stdout.includes("FAIL") || stderr.includes("FAIL");
            return this.emptyResult(testFile, "", runner, failed ? "Tests failed (could not parse output)" : undefined);
        }
    }
    // --- Vitest Parser ---
    parseVitestOutput(stdout, stderr, testFile, cwd, runner) {
        return this.parseJsonTestOutput(stdout, stderr, testFile, cwd, runner);
    }
    // --- Jest Parser ---
    parseJestOutput(stdout, stderr, testFile, cwd, runner) {
        return this.parseJsonTestOutput(stdout, stderr, testFile, cwd, runner);
    }
    // --- Pytest Parser (text-based, no JSON dependency) ---
    parsePytestOutput(stdout, stderr, exitCode, testFile, _cwd, runner) {
        const failures = [];
        const output = `${stdout}\n${stderr}`;
        // Parse summary line: "5 passed, 2 failed, 1 skipped in 0.23s"
        const summaryMatch = output.match(/(\d+)\s+passed?.*?(\d+)\s+failed.*?in\s+([\d.]+)s/i) ||
            output.match(/(\d+)\s+passed.*?in\s+([\d.]+)s/i);
        let passed = 0;
        let failed = 0;
        let skipped = 0;
        let duration = 0;
        if (summaryMatch) {
            // Extract numbers from various patterns
            const passedMatch = output.match(/(\d+)\s+passed/);
            const failedMatch = output.match(/(\d+)\s+failed/);
            const skippedMatch = output.match(/(\d+)\s+skipped/);
            const durationMatch = output.match(/in\s+([\d.]+)s/);
            passed = passedMatch ? parseInt(passedMatch[1], 10) : 0;
            failed = failedMatch ? parseInt(failedMatch[1], 10) : 0;
            skipped = skippedMatch ? parseInt(skippedMatch[1], 10) : 0;
            duration = durationMatch ? parseFloat(durationMatch[1]) * 1000 : 0;
        }
        // Parse individual failures: "FAILED tests/test_foo.py::test_something - AssertionError: ..."
        const failureRegex = /FAILED\s+(\S+::\S+)\s*-\s*(.+?)(?:\n|$)/g;
        let match;
        while ((match = failureRegex.exec(output)) !== null) {
            failures.push({
                name: match[1],
                message: match[2].trim().slice(0, 500),
                location: match[1].replace("::", ":"),
            });
        }
        // Also look for assertion errors with traceback
        const tracebackRegex = /_{10,}\s*\n\s*(\w+Error:\s*.+?)(?:\n|$)/gs;
        while ((match = tracebackRegex.exec(output)) !== null) {
            // Add to last failure if exists, or create generic
            if (failures.length > 0 && !failures[failures.length - 1].stack) {
                failures[failures.length - 1].stack = match[1].trim().slice(0, 1000);
            }
        }
        return {
            file: testFile,
            sourceFile: "",
            runner,
            passed,
            failed,
            skipped,
            failures,
            duration,
            error: exitCode === 2 ? "Pytest configuration error" : undefined,
        };
    }
    // --- PHPUnit Parser (text-based, default CLI output) ---
    parsePhpunitOutput(stdout, stderr, exitCode, testFile, runner) {
        const output = `${stdout}\n${stderr}`;
        let passed = 0;
        let failed = 0;
        let skipped = 0;
        // Success (or success-with-incomplete/skipped): "OK (12 tests, 34 assertions)"
        const okMatch = output.match(/OK\s*\((\d+)\s+tests?,\s*\d+\s+assertions?\)/i);
        if (okMatch) {
            passed = Number.parseInt(okMatch[1], 10);
        }
        else {
            // Failure summary: "Tests: 12, Assertions: 34, Errors: 1, Failures: 2, Skipped: 1."
            const testsMatch = output.match(/Tests:\s*(\d+)/i);
            const failuresMatch = output.match(/Failures:\s*(\d+)/i);
            const errorsMatch = output.match(/Errors:\s*(\d+)/i);
            const skippedMatch = output.match(/Skipped:\s*(\d+)/i);
            const total = testsMatch ? Number.parseInt(testsMatch[1], 10) : 0;
            const failures = failuresMatch ? Number.parseInt(failuresMatch[1], 10) : 0;
            const errors = errorsMatch ? Number.parseInt(errorsMatch[1], 10) : 0;
            skipped = skippedMatch ? Number.parseInt(skippedMatch[1], 10) : 0;
            failed = failures + errors;
            passed = Math.max(0, total - failed - skipped);
        }
        // Individual failures: "1) Foo\BarTest::testSomething"
        const failures = [];
        const failureRegex = /^\d+\)\s+(\S+)/gm;
        let match;
        while ((match = failureRegex.exec(output)) !== null) {
            failures.push({ name: match[1], message: match[1] });
        }
        return {
            file: testFile,
            sourceFile: "",
            runner,
            passed,
            failed,
            skipped,
            failures,
            duration: 0,
            error: exitCode !== 0 && passed === 0 && failed === 0
                ? "PHPUnit runner error"
                : undefined,
        };
    }
    // --- mix test Parser (ExUnit, text-based, default CLI output) ---
    parseMixTestOutput(stdout, stderr, exitCode, testFile, runner) {
        const output = `${stdout}\n${stderr}`;
        let passed = 0;
        let failed = 0;
        let skipped = 0;
        let duration = 0;
        // Summary: "3 tests, 1 failure" (optionally ", N excluded" / ", N skipped")
        const summaryMatch = output.match(/(\d+)\s+tests?,\s*(\d+)\s+failures?(?:,\s*(\d+)\s+excluded)?(?:,\s*(\d+)\s+skipped)?/i);
        if (summaryMatch) {
            const total = Number.parseInt(summaryMatch[1], 10);
            failed = Number.parseInt(summaryMatch[2], 10);
            const excluded = summaryMatch[3] ? Number.parseInt(summaryMatch[3], 10) : 0;
            const skippedCount = summaryMatch[4]
                ? Number.parseInt(summaryMatch[4], 10)
                : 0;
            skipped = excluded + skippedCount;
            passed = Math.max(0, total - failed - skipped);
        }
        const durationMatch = output.match(/Finished in\s+([\d.]+)\s+seconds?/i);
        if (durationMatch) {
            duration = Number.parseFloat(durationMatch[1]) * 1000;
        }
        // Individual failures: "  1) test some behavior (MyModuleTest)"
        const failures = [];
        const failureRegex = /^\s*\d+\)\s+(.+?)\s*\(([^)]+)\)\s*$/gm;
        let match;
        while ((match = failureRegex.exec(output)) !== null) {
            failures.push({
                name: match[1].trim(),
                message: match[1].trim(),
                location: match[2].trim(),
            });
        }
        return {
            file: testFile,
            sourceFile: "",
            runner,
            passed,
            failed,
            skipped,
            failures,
            duration,
            error: exitCode !== 0 && passed === 0 && failed === 0
                ? "mix test runner error"
                : undefined,
        };
    }
    // --- Generic text parser for non-JSON runners ---
    parseGenericRunnerOutput(stdout, stderr, exitCode, testFile, runner) {
        const output = `${stdout}\n${stderr}`;
        const lower = output.toLowerCase();
        let passed = 0;
        let failed = exitCode === 0 ? 0 : 1;
        let skipped = 0;
        let duration = 0;
        const goSummary = output.match(/ok\s+\S+\s+([\d.]+)s/m);
        if (goSummary) {
            duration = Number.parseFloat(goSummary[1]) * 1000;
        }
        const cargoSummary = output.match(/test result:\s+\w+\.\s+(\d+)\s+passed;\s+(\d+)\s+failed;\s+(\d+)\s+ignored;/i);
        if (cargoSummary) {
            passed = Number.parseInt(cargoSummary[1], 10);
            failed = Number.parseInt(cargoSummary[2], 10);
            skipped = Number.parseInt(cargoSummary[3], 10);
        }
        const dotnetSummary = output.match(/Failed:\s*(\d+),\s*Passed:\s*(\d+),\s*Skipped:\s*(\d+)/i);
        if (dotnetSummary) {
            failed = Number.parseInt(dotnetSummary[1], 10);
            passed = Number.parseInt(dotnetSummary[2], 10);
            skipped = Number.parseInt(dotnetSummary[3], 10);
        }
        const mavenSummary = output.match(/Tests run:\s*(\d+),\s*Failures:\s*(\d+),\s*Errors:\s*(\d+),\s*Skipped:\s*(\d+)/i);
        if (mavenSummary) {
            const total = Number.parseInt(mavenSummary[1], 10);
            const failures = Number.parseInt(mavenSummary[2], 10);
            const errors = Number.parseInt(mavenSummary[3], 10);
            skipped = Number.parseInt(mavenSummary[4], 10);
            failed = failures + errors;
            passed = Math.max(0, total - failed - skipped);
        }
        const rspecSummary = output.match(/(\d+)\s+examples?,\s+(\d+)\s+failures?/i);
        if (rspecSummary) {
            const total = Number.parseInt(rspecSummary[1], 10);
            failed = Number.parseInt(rspecSummary[2], 10);
            passed = Math.max(0, total - failed);
        }
        const minitestSummary = output.match(/(\d+)\s+runs?,\s+\d+\s+assertions?,\s+(\d+)\s+failures?,\s+(\d+)\s+errors?/i);
        if (minitestSummary) {
            const total = Number.parseInt(minitestSummary[1], 10);
            const failures = Number.parseInt(minitestSummary[2], 10);
            const errors = Number.parseInt(minitestSummary[3], 10);
            failed = failures + errors;
            passed = Math.max(0, total - failed);
        }
        const gradleSummary = output.match(/(\d+)\s+tests? completed,\s+(\d+)\s+failed/i);
        if (gradleSummary) {
            const total = Number.parseInt(gradleSummary[1], 10);
            failed = Number.parseInt(gradleSummary[2], 10);
            passed = Math.max(0, total - failed);
        }
        if (passed === 0 && failed === 0 && skipped === 0 && exitCode === 0) {
            passed = 1;
        }
        const failures = [];
        const names = [
            ...output.matchAll(/--- FAIL:\s+([^\s(]+)/g),
            ...output.matchAll(/\bFAILED\s+([^\n]+)/g),
            ...output.matchAll(/Failure:\s+([^\n]+)/g),
        ];
        for (const m of names.slice(0, 5)) {
            failures.push({ name: m[1].trim(), message: m[1].trim() });
        }
        if (failures.length === 0 && failed > 0) {
            const firstLine = output
                .split("\n")
                .find((l) => /fail|error|exception/i.test(l))
                ?.trim()
                .slice(0, 300) || `Tests failed for runner ${runner}`;
            failures.push({ name: `${runner} failure`, message: firstLine });
        }
        return {
            file: testFile,
            sourceFile: "",
            runner,
            passed,
            failed,
            skipped,
            failures,
            duration,
            error: exitCode !== 0 && failed === 0 && lower.includes("error")
                ? `Runner ${runner} exited with ${exitCode}`
                : undefined,
        };
    }
    // --- Formatting ---
    /**
     * Format test result for LLM consumption
     */
    formatResult(result) {
        if (result.error && result.passed === 0 && result.failed === 0) {
            // Runner error, not test failure
            return `[Tests] ⚠ Could not run tests: ${result.error}`;
        }
        const total = result.passed + result.failed + result.skipped;
        if (total === 0) {
            return ""; // No tests to report
        }
        const durationStr = result.duration > 0 ? ` (${(result.duration / 1000).toFixed(2)}s)` : "";
        if (result.failed === 0) {
            return `[Tests] ✓ ${result.passed}/${total} passed${durationStr} — ${result.runner}`;
        }
        // Has failures
        let output = `[Tests] ✗ ${result.failed}/${total} failed, ${result.passed} passed${durationStr} — ${result.runner}\n`;
        for (const failure of result.failures.slice(0, 5)) {
            output += `  ✗ ${failure.name}\n`;
            const msg = failure.message.split("\n")[0].slice(0, 200); // First line, truncated
            output += `    ${msg}\n`;
            if (failure.location) {
                output += `    at ${failure.location}\n`;
            }
        }
        if (result.failures.length > 5) {
            output += `  ... and ${result.failures.length - 5} more failure(s)\n`;
        }
        output += `  → Fix failing tests before proceeding\n`;
        return output.trimEnd();
    }
    // --- Helpers ---
    /**
     * Additional mirrored-directory candidate for source trees whose test
     * tree mirrors the source tree under a *different*, conventional
     * source-root segment rather than the source file's full relative
     * directory — e.g. PHPUnit's `src/Foo/Bar.php` -> `tests/Foo/BarTest.php`
     * (strips `src`) or ExUnit's `lib/accounts/user.ex` ->
     * `test/accounts/user_test.exs` (strips `lib`).
     *
     * Unlike the `relDir`-based candidates above (which mirror under the
     * hardcoded "tests"/"__tests__" roots), this uses `testDir` — the
     * pattern's own configured test root from `SOURCE_TO_TEST_PATTERNS`
     * (e.g. "tests" for PHP, "test" for Elixir) — since ExUnit's root is
     * singular and wouldn't otherwise be checked.
     *
     * Returns an empty array when the source directory doesn't start with a
     * known source-root segment (src/lib/app) followed by at least one more
     * path segment — i.e. this is a no-op for languages/layouts that don't
     * use this convention.
     */
    sourceRootMirroredCandidates(dir, cwd, testDir, testFilename) {
        const knownSourceRoots = new Set(["src", "lib", "app"]);
        const relDir = path.relative(cwd, dir);
        const segments = relDir.split(path.sep).filter(Boolean);
        if (segments.length > 1 && knownSourceRoots.has(segments[0])) {
            return [
                path.join(cwd, testDir, ...segments.slice(1), testFilename),
            ];
        }
        return [];
    }
    /**
     * Fallback discovery: scan known test directories for a file that imports
     * the source module. Catches cases where the test file name doesn't match
     * the source basename (e.g. cline.test.ts testing cline-auth.ts).
     *
     * Checks for the basename appearing in a quoted import/require path:
     *   from "../providers/cline/cline-auth"   → /cline-auth"  ✓
     *   from "./cline-auth.js"                 → /cline-auth.  ✓
     *   import("cline-auth")                   → "cline-auth"  ✓
     */
    findTestFileByImport(sourceFilePath, cwd) {
        const ext = path.extname(sourceFilePath);
        const basename = path.basename(sourceFilePath, ext);
        const testPattern = /\.(test|spec)\.(ts|tsx|js|jsx|mjs)$/;
        const searchDirs = [
            path.join(cwd, "tests"),
            path.join(cwd, "__tests__"),
            path.dirname(sourceFilePath),
        ];
        for (const dir of searchDirs) {
            let entries;
            try {
                entries = fs.readdirSync(dir);
            }
            catch {
                continue;
            }
            for (const entry of entries) {
                if (!testPattern.test(entry))
                    continue;
                const testPath = path.join(dir, entry);
                let content;
                try {
                    content = fs.readFileSync(testPath, "utf-8");
                }
                catch {
                    continue;
                }
                if (content.includes(`/${basename}"`) ||
                    content.includes(`/${basename}'`) ||
                    content.includes(`/${basename}.`) ||
                    content.includes(`"${basename}"`) ||
                    content.includes(`'${basename}'`)) {
                    this.log(`Found test file via import scan: ${testPath}`);
                    return testPath;
                }
            }
        }
        return null;
    }
    /**
     * Resolve the executable and args for a runner, preferring a local
     * node_modules/.bin binary over npx to avoid the ~150ms npx startup cost.
     *
     * When a local binary is used, args()[0] (the runner name that npx needs)
     * is dropped since it becomes the command itself.
     */
    async resolveExec(runner, config, testFile, cwd) {
        // PHPUnit has no npx-style automatic local-binary resolution — Composer's
        // standard local-install location is vendor/bin/phpunit, so check that
        // explicitly before falling back to a global `phpunit` on PATH.
        if (runner === "phpunit") {
            const suffix = process.platform === "win32" ? ".bat" : "";
            const vendorBin = path.join(cwd, "vendor", "bin", `phpunit${suffix}`);
            if (fs.existsSync(vendorBin)) {
                return { command: vendorBin, args: config.args(testFile, cwd) };
            }
            return { command: "phpunit", args: config.args(testFile, cwd) };
        }
        const binName = config.binName ?? runner;
        const suffix = process.platform === "win32" ? ".cmd" : "";
        const localBin = path.join(cwd, "node_modules", ".bin", binName + suffix);
        // A resolved binary (local, or any manager's global bin) becomes the command
        // itself, so the leading runner-name arg (e.g. "vitest") that npx needs is
        // dropped from args().
        if (fs.existsSync(localBin)) {
            return { command: localBin, args: config.args(testFile, cwd).slice(1) };
        }
        // Any package manager's global bin dir (npm/pnpm/yarn/bun) before npx (#375).
        const globalBin = await findGlobalBinary(binName);
        if (globalBin) {
            return { command: globalBin, args: config.args(testFile, cwd).slice(1) };
        }
        return { command: config.command, args: config.args(testFile, cwd) };
    }
    emptyResult(testFile, sourceFile, runner, error) {
        return {
            file: testFile,
            sourceFile,
            runner,
            passed: 0,
            failed: 0,
            skipped: 0,
            failures: [],
            duration: 0,
            error,
        };
    }
    truncateStack(stack) {
        if (!stack)
            return undefined;
        // Keep first 3 lines of stack trace
        const lines = stack.split("\n").slice(0, 3);
        return lines.join("\n").slice(0, 500);
    }
    failedKey(cwd, runner) {
        return `${path.resolve(cwd)}:${runner}`;
    }
    recordResult(cwd, runner, testFile, result) {
        const key = this.failedKey(cwd, runner);
        const abs = path.resolve(testFile);
        const set = this.failedTestsByRunner.get(key) ?? new Set();
        if (result.failed > 0) {
            set.add(abs);
            this.failedTestsByRunner.set(key, set);
            return;
        }
        if (set.has(abs)) {
            set.delete(abs);
            if (set.size === 0)
                this.failedTestsByRunner.delete(key);
            else
                this.failedTestsByRunner.set(key, set);
        }
    }
}