/**
* @module routes/testFix
* @description AI-powered test auto-fix from failure context.
* Mounted at `/api/v1` (INF-005).
*
* ### Endpoints
* | Method | Path | Description |
* |--------|----------------------------------|--------------------------------------------|
* | `POST` | `/api/v1/tests/:testId/fix` | Stream an AI-generated fix for a failing test (SSE) |
* | `POST` | `/api/v1/tests/:testId/apply-fix` | Apply the fixed code to the test record |
*/
import { Router } from "express";
import * as testRepo from "../database/repositories/testRepo.js";
import * as projectRepo from "../database/repositories/projectRepo.js";
import * as runRepo from "../database/repositories/runRepo.js";
import { streamText, hasProvider, isLocalProvider } from "../aiProvider.js";
import { classifyError } from "../utils/errorClassifier.js";
import { logActivity } from "../utils/activityLogger.js";
import { formatLogLine } from "../utils/logFormatter.js";
import { getPromptRules } from "../selfHealing.js";
import { getTier } from "../pipeline/prompts/promptTiers.js";
import { buildCapabilityCoverageBlock } from "../pipeline/prompts/playwrightCapabilityGuide.js";
import { actor } from "../utils/actor.js";
import { requireRole } from "../middleware/requireRole.js";
const router = Router();
/**
* Build the system prompt for the test-fix AI call.
* Uses tier-aware prompt rules (MNT-009) — local models get compact rules.
*
* @returns {string} The system prompt.
*/
function buildFixSystemPrompt() {
const tier = getTier();
const rules = getPromptRules(tier);
return `You are a Playwright test expert. Your job is to apply a MINIMAL, TARGETED fix to a failing Playwright test.
${buildCapabilityCoverageBlock({ mode: "debug", tier })}
CRITICAL — MINIMAL CHANGES ONLY:
- Identify the SPECIFIC line(s) that caused the failure based on the error message.
- Fix ONLY those lines. Do NOT rewrite, reorganise, rename, or restyle any other part of the test.
- Every line of the original test that is NOT related to the failure MUST appear UNCHANGED in your output — same indentation, same comments, same helpers, same order.
- If the original code uses safeClick/safeFill/safeExpect, your fix MUST also use them. Do NOT replace self-healing helpers with raw Playwright calls (page.click, page.fill, page.getByRole, etc.).
- If a step comment (// Step N:) exists, keep it exactly as-is.
Rules:
- Start your response with a single line beginning with "FIX: " that summarises in plain English what you changed and why (e.g. "FIX: Step 3 — replaced incorrect button label 'Submit' with 'Sign In' in safeClick."). Keep it under 120 characters.
- After the FIX: line, output a blank line, then the complete fixed Playwright test code — no markdown fences, no other explanation text, no JSON wrapper.
- The code must be a complete, runnable test function starting with \`test('...\` and ending with \`});\`.
- Do NOT include import statements — test/expect are provided externally.
- Add page.waitForLoadState() after navigations.
- If a selector is broken, fix the selector. If a timeout occurs, add appropriate waits. If an assertion fails, fix the assertion to match actual behavior.
- Keep the test name the same as the original.
SELF-HEALING HELPERS — the test runtime provides these helpers. You MUST use them instead of raw Playwright selectors:
${rules}`;
}
/**
* Build the user prompt with test code + failure context.
*/
function buildUserPrompt(test, failureResult, project) {
const lines = [];
lines.push("Here is the failing Playwright test:\n");
lines.push("```javascript");
lines.push(test.playwrightCode);
lines.push("```\n");
if (failureResult) {
lines.push("Error message:");
lines.push(failureResult.error || "Unknown error");
lines.push("");
if (failureResult.steps?.length) {
lines.push("Test steps:");
failureResult.steps.forEach((s, i) => lines.push(` ${i + 1}. ${s}`));
lines.push("");
}
const dom = failureResult.domSnapshot;
if (dom) {
lines.push("DOM snapshot excerpt (trimmed):");
lines.push(
typeof dom === "string"
? dom.slice(0, 2500)
: JSON.stringify(dom, null, 2).slice(0, 2500)
);
lines.push("");
}
}
if (test.steps?.length) {
lines.push("Original test steps:");
test.steps.forEach((s, i) => lines.push(` ${i + 1}. ${s}`));
lines.push("");
}
const pageUrl = test.sourceUrl || project?.url || "";
if (pageUrl) {
lines.push(`Page URL: ${pageUrl}`);
lines.push("");
}
lines.push("Fix the test so it passes. Make the SMALLEST possible change — only modify the line(s) that caused the failure. Keep every other line identical to the original. Use self-healing helpers (safeClick, safeFill, safeExpect) — not raw Playwright selectors. Start with a FIX: summary line, then a blank line, then the complete fixed code.");
return lines.join("\n");
}
/**
* Compute a simple line-based diff summary between two code strings.
* Returns a compact string showing added/removed lines.
*/
function computeDiffSummary(before, after) {
const aLines = (before || "").split("\n");
const bLines = (after || "").split("\n");
const diff = [];
let added = 0, removed = 0;
// Simple LCS-based diff (same algorithm as DiffView.jsx on the frontend)
const m = aLines.length, n = bLines.length;
const dp = Array.from({ length: m + 1 }, () => new Int32Array(n + 1));
for (let i = m - 1; i >= 0; i--)
for (let j = n - 1; j >= 0; j--)
dp[i][j] = aLines[i] === bLines[j]
? dp[i + 1][j + 1] + 1
: Math.max(dp[i + 1][j], dp[i][j + 1]);
let i = 0, j = 0;
while (i < m || j < n) {
if (i < m && j < n && aLines[i] === bLines[j]) {
i++; j++;
} else if (j < n && (i >= m || dp[i][j + 1] >= dp[i + 1][j])) {
diff.push(`+ ${bLines[j]}`);
added++;
j++;
} else {
diff.push(`- ${aLines[i]}`);
removed++;
i++;
}
}
return { diff: diff.join("\n"), added, removed };
}
// ── POST /api/tests/:testId/fix — SSE stream of AI-generated fix ─────────────
router.post("/tests/:testId/fix", requireRole("qa_lead"), async (req, res) => {
const test = testRepo.getById(req.params.testId);
if (!test) return res.status(404).json({ error: "Test not found" });
const project = projectRepo.getByIdInWorkspace(test.projectId, req.workspaceId);
if (!project) return res.status(404).json({ error: "Test not found" });
if (!test.playwrightCode) {
return res.status(400).json({ error: "Test has no Playwright code to fix." });
}
if (!hasProvider()) {
return res.status(503).json({
error: "No AI provider configured. Go to Settings to add an API key.",
});
}
const failureResult = runRepo.findLatestResultForTest(test.id);
console.log(formatLogLine("info", null, `[testFix] starting AI fix for ${test.id} ("${test.name}") — provider: ${isLocalProvider() ? "local/Ollama" : "cloud"}, hasFailureContext: ${!!failureResult}`));
const userPrompt = buildUserPrompt(test, failureResult, project);
// Set up SSE
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders();
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 120_000);
res.on("close", () => {
if (!res.writableEnded) {
console.log(formatLogLine("info", null, `[testFix] client disconnected mid-stream`));
controller.abort();
}
clearTimeout(timeout);
});
const streamOpts = { signal: controller.signal, responseFormat: "text" };
if (isLocalProvider()) streamOpts.maxTokens = 4096;
let fixedCode = "";
// Heartbeat is declared here so the finally block can always clear it,
// even if the try block is never entered (e.g. synchronous throw).
let heartbeat = null;
try {
// Start heartbeat inside try so it's always paired with the finally cleanup
heartbeat = setInterval(() => {
if (!res.writableEnded) {
try { res.write(": heartbeat\n\n"); } catch { clearInterval(heartbeat); }
} else {
clearInterval(heartbeat);
}
}, 5000);
const startMs = Date.now();
await streamText(
{ system: buildFixSystemPrompt(), user: userPrompt },
(token) => {
fixedCode += token;
if (!res.writableEnded) {
res.write(`data: ${JSON.stringify({ token })}\n\n`);
}
},
streamOpts,
);
// Extract the "FIX: ..." explanation line the AI prepends (per system prompt).
// This must happen BEFORE fence stripping: when the AI outputs
// FIX: ...\n\n```js\ncode\n```
// the opening fence isn't at ^ while the FIX: line is still present, so
// stripping fences first would leave an orphaned "```javascript" line.
fixedCode = fixedCode.trim();
let explanation = null;
const fixLineMatch = fixedCode.match(/^FIX:\s*(.+?)(?:\n|$)/i);
if (fixLineMatch) {
explanation = fixLineMatch[1].trim();
// Strip the FIX: line (and optional blank line) from the code body
fixedCode = fixedCode.replace(/^FIX:\s*.+?(?:\n\n?|$)/i, "").trim();
}
// Clean the response — strip markdown fences if the model added them.
// Runs after FIX: extraction so the opening fence is always at ^.
fixedCode = fixedCode
.replace(/^```(?:javascript|js|typescript|ts)?\s*\n?/i, "")
.replace(/\n?\s*```\s*$/i, "")
.trim();
// Build diff summary
const { diff, added, removed } = computeDiffSummary(test.playwrightCode, fixedCode);
if (!explanation) {
explanation = `AI applied a fix: ${added} line${added !== 1 ? "s" : ""} added, ${removed} line${removed !== 1 ? "s" : ""} removed. Review the diff below to see exactly what changed.`;
}
console.log(formatLogLine("info", null, `[testFix] completed for ${test.id} in ${((Date.now() - startMs) / 1000).toFixed(1)}s — ${added}+ ${removed}-`));
if (!res.writableEnded) {
res.write(`data: ${JSON.stringify({ done: true, fixedCode, explanation, diff })}\n\n`);
res.end();
}
} catch (err) {
if (err.name === "AbortError" && req.socket?.destroyed) {
console.log(formatLogLine("info", null, `[testFix] aborted (client gone)`));
} else {
console.error(formatLogLine("error", null, `[testFix] failed for ${test.id}: ${err.message}`));
if (!res.writableEnded) {
const { message } = classifyError(err, "chat");
res.write(`data: ${JSON.stringify({ error: message })}\n\n`);
res.end();
}
}
} finally {
clearInterval(heartbeat);
clearTimeout(timeout);
}
});
// ── POST /api/tests/:testId/apply-fix — persist the AI-generated fix ─────────
router.post("/tests/:testId/apply-fix", requireRole("qa_lead"), (req, res) => {
const test = testRepo.getById(req.params.testId);
if (!test) return res.status(404).json({ error: "Test not found" });
const project = projectRepo.getByIdInWorkspace(test.projectId, req.workspaceId);
if (!project) return res.status(404).json({ error: "Test not found" });
const { code } = req.body;
if (!code || typeof code !== "string" || !code.trim()) {
return res.status(400).json({ error: "code is required" });
}
// Strip markdown fences in case the AI response was not cleaned upstream
const sanitizedCode = code.trim()
.replace(/^```(?:javascript|js|typescript|ts)?\s*\n?/i, "")
.replace(/\n?\s*```\s*$/i, "")
.trim();
// Basic structural validation — must look like a Playwright test function.
// Accept test(), test.only(), test.skip(), test.fixme(), test.describe(), etc.
const looksLikeTest = /\btest\s*(\.\s*\w+\s*)?\(/.test(sanitizedCode);
if (!looksLikeTest || !sanitizedCode.includes("async")) {
return res.status(400).json({
error: "Code does not appear to be a valid Playwright test. Must contain a test() call and async.",
});
}
const updates = {};
if (test.playwrightCode && test.playwrightCode !== sanitizedCode) {
updates.playwrightCodePrev = test.playwrightCode;
}
updates.playwrightCode = sanitizedCode;
updates.updatedAt = new Date().toISOString();
updates.aiFixAppliedAt = new Date().toISOString();
updates.codeVersion = (test.codeVersion || 0) + 1;
testRepo.update(test.id, updates);
logActivity({ ...actor(req),
type: "test.ai_fix",
projectId: test.projectId,
projectName: project?.name || null,
testId: test.id,
testName: test.name,
detail: `AI fix applied — code version ${updates.codeVersion}`,
});
res.json(testRepo.getById(test.id));
});
export default router;