import { parseAgency } from "../parser.js";
import { getNodesOfType } from "../utils/node.js";
import fs from "fs";
import prompts from "prompts";
import { execFileAsync, executeJudgeAsync, executeNode, executeNodeAsync, findRecursively, parseTarget, pickANode, promptForArgs, promptForTarget, } from "./util.js";
import { color } from "../utils/termcolors.js";
import { formatDiff } from "../utils/diff.js";
import path from "path";
import { compile, loadConfig } from "./commands.js";
// ── Test runner timeout limits ──
// Pattern mirrors LIMIT_CEILINGS in lib/runtime/ipc.ts: hardcoded ceilings
// clamp any user-supplied value so a stray `"timeoutMs": 9999999999` in a
// fixture cannot exceed our per-test or suite-wide caps.
const TIMEOUT_CEILINGS = {
    // A single test cannot consume more than 5 minutes — keeps a single
    // misconfigured test from monopolizing the suite budget. The default
    // (DEFAULT_PER_TEST_MS) sits well under this so most fixtures are
    // untouched.
    perTestMs: 5 * 60 * 1000, // 5 minutes
    // Suite-wide ceiling. Not currently user-configurable.
    suiteMs: 30 * 60 * 1000, // 30 minutes
};
const DEFAULT_PER_TEST_MS = 2 * 60 * 1000; // 2 minutes
function resolveTimeoutMs(testCase, fileDefaults) {
    const requested = testCase.timeoutMs ?? fileDefaults.defaultTimeoutMs ?? DEFAULT_PER_TEST_MS;
    // Clamp to >= 1ms: Node treats `timeout: 0` (and negative) as "no
    // timeout", which would defeat the entire defensive-timeout goal.
    // Clamp to <= ceiling so a stray huge value can't escape the cap.
    return Math.min(Math.max(1, requested), TIMEOUT_CEILINGS.perTestMs);
}
// Format a millisecond duration as the largest unit that yields an integer.
// 120000 → "2 minutes", 90000 → "90 seconds", 500 → "500 milliseconds".
function formatTimeout(ms) {
    if (ms % 60000 === 0) {
        const m = ms / 60000;
        return `${m} minute${m === 1 ? "" : "s"}`;
    }
    if (ms % 1000 === 0) {
        const s = ms / 1000;
        return `${s} second${s === 1 ? "" : "s"}`;
    }
    return `${ms} millisecond${ms === 1 ? "" : "s"}`;
}
// Detect the rejection shape produced when execFile kills the child due to
// our `timeout` option. Promisified execFile attaches `killed` and `signal`
// to the error.
function isTimeoutError(e) {
    if (!(e instanceof Error))
        return false;
    const err = e;
    return err.killed === true && err.signal === "SIGKILL";
}
// Detect the rejection shape produced when execFile is killed via the
// AbortSignal (suite ceiling or SIGINT). Node sets `code === "ABORT_ERR"`
// or surfaces the abort via `signal`.
function isAbortError(e) {
    if (!(e instanceof Error))
        return false;
    const err = e;
    return err.name === "AbortError" || err.code === "ABORT_ERR";
}
function createSuiteContext(allFiles) {
    return {
        aborted: false,
        abortReason: null,
        abortController: new AbortController(),
        completed: [],
        inFlight: new Set(),
        pending: new Set(allFiles),
        inFlightAtAbort: [],
    };
}
function triggerSuiteAbort(suite, reason) {
    if (suite.aborted)
        return;
    suite.aborted = true;
    suite.abortReason = reason;
    // Snapshot in-flight files NOW, before workers' `finally` blocks drain.
    suite.inFlightAtAbort = [...suite.inFlight];
    suite.abortController.abort();
}
function readFile(filename) {
    console.log("Trying to read file", filename, "...");
    const data = fs.readFileSync(filename);
    const contents = data.toString("utf8");
    return contents;
}
function writeTestCase(agencyFilename, nodeName, input, expectedOutput, evaluationCriteria, interruptHandlers) {
    const testFilePath = agencyFilename.replace(".agency", ".test.json");
    let tests;
    if (fs.existsSync(testFilePath)) {
        tests = JSON.parse(fs.readFileSync(testFilePath, "utf-8"));
    }
    else {
        tests = { tests: [] };
    }
    const testCase = {
        nodeName,
        input,
        expectedOutput,
        evaluationCriteria,
    };
    if (interruptHandlers && interruptHandlers.length > 0) {
        testCase.interruptHandlers = interruptHandlers;
    }
    tests.tests.push(testCase);
    fs.writeFileSync(testFilePath, JSON.stringify(tests, null, 2));
    return testFilePath;
}
const onCancel = () => {
    process.exit(0);
};
function exitIfSignal(e) {
    if (e instanceof Error &&
        "signal" in e &&
        (e.signal === "SIGINT" || e.signal === "SIGTERM")) {
        process.exit(1);
    }
}
// eslint-disable-next-line max-lines-per-function -- legacy CLI entrypoint slated for incremental refactor
export async function fixtures(config, target) {
    const resolved = target ? parseTarget(target) : await promptForTarget();
    const { filename } = resolved;
    let { nodeName } = resolved;
    const contents = readFile(filename);
    const parsed = parseAgency(contents);
    if (!parsed.success) {
        console.error("Could not parse agency code in file", filename, "error:", parsed.message);
        return;
    }
    const agencyProgram = parsed.result;
    const body = agencyProgram.nodes;
    const nodes = getNodesOfType(body, "graphNode");
    if (nodes.length === 0) {
        console.log("No graph nodes found in the program. At least one graph node is required as an entrypoint.");
        return;
    }
    if (!nodeName) {
        nodeName = await pickANode(nodes);
    }
    // Find the selected node and prompt for args
    const selectedNode = nodes.find((n) => n.nodeName === nodeName);
    const { hasArgs, argsString } = await promptForArgs(selectedNode);
    console.log("Running program from entrypoint", nodeName);
    let json = executeNode({
        config,
        agencyFile: filename,
        nodeName,
        hasArgs,
        argsString,
    });
    // Handle interrupt discovery
    const interruptHandlers = [];
    while (json.data &&
        typeof json.data === "object" &&
        json.data.type === "interrupt") {
        console.log(`\n⚠️  Interrupt detected: "${json.data.data}"`);
        const actionResponse = await prompts({
            type: "select",
            name: "action",
            message: "How should the test handle this interrupt?",
            choices: [
                { title: "Approve", value: "approve" },
                { title: "Reject", value: "reject" },
                { title: "Modify arguments", value: "modify" },
                { title: "Resolve (provide value)", value: "resolve" },
            ],
        }, { onCancel });
        if (!actionResponse.action) {
            console.log("Interrupt handling cancelled.");
            return;
        }
        const handler = {
            action: actionResponse.action,
            expectedMessage: json.data.data, // Capture the actual message
        };
        if (actionResponse.action === "resolve") {
            const resolveResponse = await prompts({
                type: "text",
                name: "value",
                message: "Enter the resolved value (JSON or plain string):",
            }, { onCancel });
            if (resolveResponse.value === undefined) {
                console.log("Interrupt handling cancelled.");
                return;
            }
            try {
                handler.resolvedValue = JSON.parse(resolveResponse.value);
            }
            catch {
                handler.resolvedValue = resolveResponse.value;
            }
        }
        else if (actionResponse.action === "modify") {
            let invalidJSON = true;
            while (invalidJSON) {
                const modifyResponse = await prompts({
                    type: "text",
                    name: "args",
                    message: "Enter modified arguments as JSON object:",
                }, { onCancel });
                if (!modifyResponse.args) {
                    console.log("Interrupt handling cancelled.");
                    return;
                }
                try {
                    handler.modifiedArgs = JSON.parse(modifyResponse.args);
                    invalidJSON = false;
                }
                catch (e) {
                    console.error("Invalid JSON:", e);
                    return;
                }
            }
        }
        interruptHandlers.push(handler);
        // Continue execution with this handler to see if there are more interrupts
        json = executeNode({
            config,
            agencyFile: filename,
            nodeName,
            hasArgs,
            argsString,
            interruptHandlers,
        });
    }
    console.log("\nFinal Output:");
    console.log(JSON.stringify(json.data, null, 2));
    const correctResponse = await prompts({
        type: "confirm",
        name: "correct",
        message: "Does this output look correct?",
        initial: true,
    }, { onCancel });
    let expectedOutput;
    if (correctResponse.correct) {
        expectedOutput = JSON.stringify(json.data);
    }
    else {
        const expectedResponse = await prompts({
            type: "text",
            name: "expected",
            message: "What should the correct output look like?",
        }, { onCancel });
        expectedOutput = expectedResponse.expected;
    }
    const criteriaResponse = await prompts({
        type: "select",
        name: "criteria",
        message: "Select evaluation criteria:",
        choices: [
            { title: "Exact match", value: "exact" },
            { title: "LLM Judge", value: "llmJudge" },
        ],
    }, { onCancel });
    let criteria;
    if (criteriaResponse.criteria === "exact") {
        criteria = [{ type: "exact" }];
    }
    else {
        const judgeResponse = await prompts([
            {
                type: "text",
                name: "judgePrompt",
                message: "Enter the judge prompt (what should the LLM evaluate?):",
            },
            {
                type: "number",
                name: "desiredAccuracy",
                message: "Desired accuracy (0-100):",
                initial: 80,
            },
        ], { onCancel });
        criteria = [
            {
                type: "llmJudge",
                judgePrompt: judgeResponse.judgePrompt,
                desiredAccuracy: judgeResponse.desiredAccuracy,
            },
        ];
    }
    const inputStr = hasArgs ? argsString : "";
    const testFilePath = writeTestCase(filename, nodeName, inputStr, expectedOutput, criteria, interruptHandlers.length > 0 ? interruptHandlers : undefined);
    console.log(`Test case saved to ${testFilePath}`);
}
function emptyStats() {
    return {
        passed: 0,
        failed: 0,
        filesPassed: 0,
        filesFailed: 0,
        failedFiles: [],
        slowTests: [],
    };
}
export function mergeStats(a, b) {
    return {
        passed: a.passed + b.passed,
        failed: a.failed + b.failed,
        filesPassed: a.filesPassed + b.filesPassed,
        filesFailed: a.filesFailed + b.filesFailed,
        failedFiles: [...a.failedFiles, ...b.failedFiles],
        slowTests: [...a.slowTests, ...b.slowTests],
    };
}
function createBufferedLogger() {
    const lines = [];
    return {
        log: (msg, stream = "stdout") => lines.push({ msg, stream }),
        flush: () => {
            for (const line of lines) {
                if (line.stream === "stderr") {
                    console.error(line.msg);
                }
                else {
                    console.log(line.msg);
                }
            }
        },
    };
}
function sanitizeParallel(parallel) {
    return Number.isFinite(parallel) && Number.isInteger(parallel) && parallel > 0
        ? parallel
        : 1;
}
// Returns `(R | undefined)[]` (not `R[]`) because workers can exit
// early when `shouldAbort()` becomes true, leaving holes in the result
// array. Callers must handle `undefined` entries (typically: skip them
// during result aggregation).
async function runWithConcurrency(items, concurrency, fn, onError, shouldAbort) {
    const results = new Array(items.length);
    let nextIndex = 0;
    async function worker() {
        while (nextIndex < items.length) {
            // Safe: nextIndex++ is synchronous and JS is single-threaded,
            // so no two workers can read the same value before the increment.
            const index = nextIndex++;
            // Suite abort: stop pulling new items. Items already in flight
            // continue to settle (via their own AbortSignal-killed children),
            // but no new files start.
            if (shouldAbort && shouldAbort())
                return;
            try {
                results[index] = await fn(items[index]);
            }
            catch (e) {
                results[index] = onError(items[index], e);
            }
        }
    }
    const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker());
    await Promise.all(workers);
    return results;
}
async function runSingleTest(config, testFile, testCase, timeoutMs, signal, log) {
    const hasArgs = testCase.input !== "";
    const relativeSourceFilePath = testFile.replace(".test.json", ".agency");
    let result;
    try {
        result = await executeNodeAsync({
            config,
            agencyFile: relativeSourceFilePath,
            nodeName: testCase.nodeName,
            hasArgs,
            argsString: testCase.input,
            interruptHandlers: testCase.interruptHandlers,
            timeoutMs,
            signal,
            llmMocks: testCase.llmMocks,
            useTestLLMProvider: testCase.useTestLLMProvider,
        });
        if (result.stdout)
            log(result.stdout.trimEnd());
        if (result.stderr)
            log(result.stderr.trimEnd(), "stderr");
    }
    catch (e) {
        if (isAbortError(e)) {
            // Don't print a failure — the suite-abort summary will explain.
            return "aborted";
        }
        if (isTimeoutError(e)) {
            log(color.red(`  ✗ Test exceeded ${formatTimeout(timeoutMs)} timeout`));
            return "failed";
        }
        exitIfSignal(e);
        log(color.red(`  ✗ Test execution error: ${e}`));
        return "failed";
    }
    let testPassed = true;
    const baseName = relativeSourceFilePath.replace(".agency", "");
    for (const criterion of testCase.evaluationCriteria) {
        if (criterion.type === "exact") {
            const actual = JSON.stringify(result.data);
            if (actual === testCase.expectedOutput) {
                log(color.green("  ✓ Exact match passed"));
            }
            else {
                log(color.red("  ✗ Exact match failed"));
                log(formatDiff(testCase.expectedOutput, actual));
                testPassed = false;
            }
        }
        else if (criterion.type === "llmJudge") {
            const actual = JSON.stringify(result.data) || "undefined";
            try {
                const judgeResult = await executeJudgeAsync(baseName, {
                    actualOutput: actual,
                    expectedOutput: testCase.expectedOutput,
                    judgePrompt: criterion.judgePrompt,
                });
                if (judgeResult.stdout)
                    log(judgeResult.stdout.trimEnd());
                if (judgeResult.stderr)
                    log(judgeResult.stderr.trimEnd(), "stderr");
                const passed = judgeResult.score >= criterion.desiredAccuracy;
                if (passed) {
                    log(color.green(`  ✓ LLM Judge passed (score: ${judgeResult.score}/${criterion.desiredAccuracy})`));
                    log(`    Reasoning: ${judgeResult.reasoning}`);
                }
                else {
                    log(color.red(`  ✗ LLM Judge failed (score: ${judgeResult.score}/${criterion.desiredAccuracy})`));
                    log(`    Reasoning: ${judgeResult.reasoning}`);
                    log(`    Actual Output:\n${actual}`);
                    testPassed = false;
                }
            }
            catch (e) {
                log(color.red(`  ✗ LLM Judge error: ${e}`));
                testPassed = false;
            }
        }
    }
    return testPassed ? "passed" : "failed";
}
function collectTestFiles(inputPath) {
    const fileStats = fs.statSync(inputPath);
    if (!fileStats.isDirectory()) {
        if (inputPath.endsWith(".agency")) {
            const testFile = inputPath.replace(/\.agency$/, ".test.json");
            if (!fs.existsSync(testFile)) {
                console.error(`Error: No test file found for '${inputPath}' (expected '${testFile}')`);
                process.exit(1);
            }
            return [testFile];
        }
        return [inputPath];
    }
    const files = [];
    for (const { path: filePath } of findRecursively(inputPath, ".test.json")) {
        files.push(filePath);
    }
    return files;
}
// Run a single test through its retry loop. Returns the final outcome.
// "aborted" short-circuits the retry loop — no point retrying when the
// suite is shutting down.
async function runTestWithRetries(config, testFile, testCase, timeoutMs, signal, log) {
    const maxAttempts = (testCase.retry ?? 0) + 1;
    let outcome = "failed";
    for (let attempt = 1; attempt <= maxAttempts; attempt++) {
        if (attempt > 1) {
            log(color.yellow(`  Retry ${attempt - 1}/${testCase.retry}...`));
        }
        try {
            outcome = await runSingleTest(config, testFile, testCase, timeoutMs, signal, log);
            if (outcome === "passed" || outcome === "aborted")
                break;
        }
        catch (e) {
            exitIfSignal(e);
            log(color.red(`  ✗ Test error: ${e}`));
            outcome = "failed";
        }
    }
    return outcome;
}
async function runTestFile(config, testFile, suite) {
    const logger = createBufferedLogger();
    const log = logger.log;
    suite.inFlight.add(testFile);
    suite.pending.delete(testFile);
    try {
        log(color.yellow(`Running tests for ${testFile}...`));
        const tests = JSON.parse(fs.readFileSync(testFile, "utf-8"));
        // Merge a sibling agency.json (next to the .test.json) on top of the
        // project-level config. Keeps default behavior intact for tests that
        // don't ship one, but lets fixtures opt into config-driven features
        // like the memory layer with a directory-scoped override.
        const localConfigPath = path.join(path.dirname(testFile), "agency.json");
        if (fs.existsSync(localConfigPath)) {
            config = { ...config, ...loadConfig(localConfigPath) };
        }
        let passed = 0;
        const total = tests.tests.length;
        // File-level skip: if the .test.json has `"skip": true` at the top
        // level, skip every test in the file. This makes top-level skip work
        // the way authors typically expect (skip the whole file). The same
        // `skip` field can also be set per test case for finer-grained control.
        if (tests.skip || (tests.skipOnCI && process.env.CI)) {
            const skipKind = tests.skip ? "" : " on CI";
            const reasonStr = tests.skipReason ? ` (${tests.skipReason})` : "";
            log(color.yellow(`  ⊘ Skipped${skipKind} ${total} test(s) in ${testFile}${reasonStr}`));
            return {
                passed: 0,
                failed: 0,
                filesPassed: 1,
                filesFailed: 0,
                failedFiles: [],
                slowTests: [],
            };
        }
        let skipped = 0;
        const slowTests = [];
        let aborted = false;
        for (let i = 0; i < total; i++) {
            // Bail between test cases if the suite is aborting. The currently
            // in-flight execFile (if any) is killed via its AbortSignal.
            if (suite.aborted) {
                aborted = true;
                break;
            }
            const testCase = tests.tests[i];
            const interruptInfo = testCase.interruptHandlers
                ? ` interrupts=${testCase.interruptHandlers.length}`
                : "";
            const testNum = color.cyan(`Test ${i + 1}/${total}:`);
            log(`\n${testNum} node=${testCase.nodeName} input=${testCase.input || "(none)"}${interruptInfo}`);
            if (testCase.description) {
                log(color.cyan("Description:", testCase.description) + "\n");
            }
            if (testCase.skip) {
                log(color.yellow(`  ⊘ Skipped`));
                skipped++;
                continue;
            }
            if (testCase.skipOnCI && process.env.CI) {
                log(color.yellow(`  ⊘ Skipped on CI`));
                skipped++;
                continue;
            }
            // Under deterministic LLM mode, llmJudge tests cannot run because
            // the judge itself is an LLM call without a mock. Skip these so CI
            // gets a clear `skipped` rather than a confusing "no mock provided"
            // failure.
            if ((process.env.AGENCY_USE_TEST_LLM_PROVIDER ||
                testCase.useTestLLMProvider) &&
                testCase.evaluationCriteria.some((c) => c.type === "llmJudge")) {
                log(color.yellow(`  ⊘ Skipped (LLM-as-judge requires real client)`));
                skipped++;
                continue;
            }
            const timeoutMs = resolveTimeoutMs(testCase, tests);
            const startTime = performance.now();
            const outcome = await runTestWithRetries(config, testFile, testCase, timeoutMs, suite.abortController.signal, log);
            const durationMs = performance.now() - startTime;
            if (outcome === "aborted") {
                aborted = true;
                break;
            }
            const testName = testCase.description
                ? `${testFile} > ${testCase.nodeName} > ${testCase.description}`
                : `${testFile} > ${testCase.nodeName}(${testCase.input || ""})`;
            slowTests.push({ name: testName, durationMs });
            if (outcome === "passed")
                passed++;
        }
        const ran = total - skipped;
        const failed = ran - passed;
        const skipMsg = skipped > 0 ? ` (${skipped} skipped)` : "";
        if (!aborted) {
            log(`\n${passed}/${ran} tests passed${skipMsg}`);
            suite.completed.push(testFile);
        }
        else {
            log(color.yellow(`\n  ⚠️  ${testFile} aborted (${passed}/${ran} tests passed before abort)`));
        }
        return {
            passed,
            failed,
            filesPassed: failed === 0 ? 1 : 0,
            filesFailed: failed === 0 ? 0 : 1,
            failedFiles: failed > 0 ? [testFile] : [],
            slowTests,
        };
    }
    finally {
        suite.inFlight.delete(testFile);
        logger.flush();
    }
}
// Print the suite-abort summary after the runner has drained.
// Three categories: completed, in-flight when abort fired, never started.
function printSuiteAbortSummary(suite) {
    const header = suite.abortReason === "sigint"
        ? "⚠️  Interrupted by user"
        : `⚠️  Suite-wide timeout of ${formatTimeout(TIMEOUT_CEILINGS.suiteMs)} exceeded`;
    console.log(color.yellow(`\n${header}. Aborting.\n`));
    if (suite.completed.length > 0) {
        console.log("Completed test files:");
        for (const f of suite.completed)
            console.log(`  ✓ ${f}`);
    }
    if (suite.inFlightAtAbort.length > 0) {
        console.log("\nTest files in flight when aborted:");
        for (const f of suite.inFlightAtAbort)
            console.log(color.yellow(`  - ${f}`));
    }
    if (suite.pending.size > 0) {
        console.log("\nTest files that did not start:");
        for (const f of suite.pending)
            console.log(color.yellow(`  - ${f}`));
    }
}
export async function test(config, inputPaths, parallel = 1) {
    const testFiles = [];
    for (const inputPath of inputPaths) {
        testFiles.push(...collectTestFiles(inputPath));
    }
    const suite = createSuiteContext(testFiles);
    // Suite-wide ceiling. When fired, flips the abort flag, which causes:
    //   - workers to stop pulling new files (runWithConcurrency check)
    //   - in-flight execFile children to be SIGKILL'd (AbortSignal)
    //   - runTestFile loops to break between cases
    const ceilingTimer = setTimeout(() => {
        triggerSuiteAbort(suite, "ceiling");
    }, TIMEOUT_CEILINGS.suiteMs);
    // Don't keep the process alive just for this timer.
    ceilingTimer.unref?.();
    // Graceful Ctrl+C: first SIGINT triggers the same drain-and-summarize
    // flow as the ceiling. A SECOND SIGINT falls through to Node's default
    // handler (immediate exit) — critical safety valve so the user can
    // always escape if our drain logic itself hangs.
    const sigintHandler = () => {
        triggerSuiteAbort(suite, "sigint");
    };
    process.once("SIGINT", sigintHandler);
    let results = [];
    try {
        results = await runWithConcurrency(testFiles, sanitizeParallel(parallel), (testFile) => runTestFile(config, testFile, suite), (testFile, error) => {
            console.error(color.red(`  ✗ Test file error: ${testFile}: ${error}`));
            return {
                passed: 0,
                failed: 1,
                filesPassed: 0,
                filesFailed: 1,
                failedFiles: [testFile],
                slowTests: [],
            };
        }, () => suite.aborted);
    }
    finally {
        clearTimeout(ceilingTimer);
        process.removeListener("SIGINT", sigintHandler);
    }
    let stats = emptyStats();
    for (const result of results) {
        if (result)
            stats = mergeStats(stats, result);
    }
    if (suite.aborted)
        printSuiteAbortSummary(suite);
    return stats;
}
function findTsTestDirs(_inputPath) {
    const stats = fs.statSync(_inputPath);
    let inputPath = _inputPath;
    if (!stats.isDirectory()) {
        if (inputPath.endsWith("test.js")) {
            inputPath = path.dirname(inputPath);
        }
        else {
            console.error(`Error: ${inputPath} is not a directory or a test.js file`);
            process.exit(1);
        }
    }
    // Check if inputPath itself is a test dir (contains test.js)
    if (fs.existsSync(path.join(inputPath, "test.js"))) {
        return [inputPath];
    }
    // Otherwise find subdirectories containing test.js
    const dirs = [];
    for (const entry of fs.readdirSync(inputPath)) {
        const fullPath = path.join(inputPath, entry);
        if (fs.statSync(fullPath).isDirectory()) {
            dirs.push(...findTsTestDirs(fullPath));
        }
    }
    return dirs;
}
function findAgencyFile(dir) {
    const files = fs.readdirSync(dir).filter((f) => f.endsWith(".agency"));
    return files.length > 0 ? files[0] : null;
}
async function runTsTestDir(config, dir) {
    const logger = createBufferedLogger();
    const log = logger.log;
    try {
        const dirName = path.basename(dir);
        if (fs.existsSync(path.join(dir, "skip"))) {
            log(color.yellow(`\nSkipping JS test: ${dirName}`));
            return { success: true, dir };
        }
        log(color.yellow(`\nRunning JS test: ${dirName}`));
        const agencyFile = findAgencyFile(dir);
        if (!agencyFile) {
            log(color.red(`  ✗ No .agency file found in ${dir}`));
            return { success: false, dir };
        }
        const agencyPath = path.join(dir, agencyFile);
        const localConfigPath = path.join(dir, "agency.json");
        let mergedConfig = config;
        if (fs.existsSync(localConfigPath)) {
            mergedConfig = { ...config, ...loadConfig(localConfigPath) };
        }
        try {
            compile(mergedConfig, agencyPath);
        }
        catch (e) {
            log(color.red(`  ✗ Compilation failed: ${e}`));
            return { success: false, dir };
        }
        // Remove stale result file from previous runs
        const resultFile = path.join(dir, "__result.json");
        try {
            fs.unlinkSync(resultFile);
        }
        catch { }
        // Under deterministic mode, look for an optional llmMocks.json next
        // to fixture.json. Pass its contents to the subprocess via the
        // AGENCY_LLM_MOCKS env var; the imports template auto-activates the
        // DeterministicClient. When the file is absent under deterministic
        // mode we still set the env var to "[]" so any llm() call fails
        // cleanly instead of falling through to the real OpenAI client.
        //
        // Activate the deterministic client whenever EITHER:
        //   - the suite-wide AGENCY_USE_TEST_LLM_PROVIDER env var is set
        //     (typical for PR CI), OR
        //   - a `useTestLLMProvider` marker file sits next to test.js
        //     (mirrors the per-test flag in .test.json — lets a single
        //     agency-js test pin itself to the deterministic provider even
        //     on push-to-main CI where the env var is unset).
        const mocksFile = path.join(dir, "llmMocks.json");
        const useDeterministic = !!process.env.AGENCY_USE_TEST_LLM_PROVIDER ||
            fs.existsSync(path.join(dir, "useTestLLMProvider"));
        let mocksEnv = {};
        if (useDeterministic) {
            const mocks = fs.existsSync(mocksFile)
                ? fs.readFileSync(mocksFile, "utf-8")
                : "[]";
            mocksEnv = { AGENCY_LLM_MOCKS: mocks };
        }
        const testFile = "test.js";
        try {
            const { stdout, stderr } = await execFileAsync("node", [testFile], {
                cwd: dir,
                maxBuffer: 10 * 1024 * 1024,
                env: { ...process.env, ...mocksEnv },
            });
            if (stdout)
                log(stdout.trimEnd());
            if (stderr)
                log(stderr.trimEnd(), "stderr");
        }
        catch (e) {
            exitIfSignal(e);
            log(color.red(`  ✗ Test script execution failed: ${e}`));
            return { success: false, dir };
        }
        if (!fs.existsSync(resultFile)) {
            log(color.red(`  ✗ Test script did not produce __result.json`));
            return { success: false, dir };
        }
        const result = fs.readFileSync(resultFile, "utf-8");
        const fixtureFile = path.join(dir, "fixture.json");
        if (!fs.existsSync(fixtureFile)) {
            log(color.yellow(`  No fixture.json found. Result:`));
            log(result);
            log(color.red(`  ✗ No fixture to compare against`));
            return { success: false, dir };
        }
        const expected = fs.readFileSync(fixtureFile, "utf-8");
        const resultParsed = JSON.parse(result);
        const expectedParsed = JSON.parse(expected);
        if (JSON.stringify(resultParsed) === JSON.stringify(expectedParsed)) {
            log(color.green(`  ✓ Fixture match passed`));
            return { success: true, dir };
        }
        else {
            log(color.red(`  ✗ Fixture match failed`));
            log(formatDiff(JSON.stringify(expectedParsed, null, 2), JSON.stringify(resultParsed, null, 2)));
            return { success: false, dir };
        }
    }
    finally {
        logger.flush();
    }
}
export async function testTs(config, inputPaths, parallel = 1) {
    const allDirs = [];
    for (const inputPath of inputPaths) {
        const testDirs = findTsTestDirs(inputPath);
        if (testDirs.length === 0) {
            console.log(color.yellow(`No TypeScript test directories found in ${inputPath}`));
        }
        else {
            allDirs.push(...testDirs);
        }
    }
    // testTs doesn't pass `shouldAbort`, so no entries can be undefined,
    // but `runWithConcurrency`'s type allows for it — drop them defensively.
    const rawResults = await runWithConcurrency(allDirs, sanitizeParallel(parallel), (dir) => runTsTestDir(config, dir), (dir, error) => {
        console.error(color.red(`  ✗ JS test error: ${dir}: ${error}`));
        return { success: false, dir };
    });
    const results = rawResults.filter((r) => r !== undefined);
    const successes = results.filter((r) => r.success);
    const failures = results.filter((r) => !r.success);
    if (failures.length > 0) {
        console.log("");
        for (const { dir } of failures) {
            console.log(color.red(` FAIL  ${dir}`));
        }
    }
    console.log(`\n${successes.length}/${results.length} TS tests passed`);
    if (failures.length > 0) {
        process.exit(1);
    }
}
