Source: routes/tests.js

/**
 * @module routes/tests
 * @description Test CRUD, AI generation, single-test run, review, bulk actions, and export. Mounted at `/api/v1` (INF-005).
 *
 * REFACTOR-NOTE (post-AUTO-003b): this file mixes 8 concerns — CRUD, AI
 * generation, single-test runs, review actions, approvals (revoke +
 * approval-stats), interactive recorder, visual baselines, and exports.
 * Splitting along those lines (`routes/approvals.js`, `routes/recorder.js`,
 * `routes/baselines.js`, `routes/exports.js`) is sensible but should land
 * in a dedicated PR rather than bundled with feature work — the route-
 * order constraints below ("bulk must be declared BEFORE :testId
 * wildcards") are easy to regress in a mechanical move. Tracked as a
 * follow-up MNT item.
 *
 * ### Endpoints
 * | Method   | Path                                             | Description                         |
 * |----------|--------------------------------------------------|-------------------------------------|
 * | `GET`    | `/api/v1/projects/:id/tests`                     | List tests for a project            |
 * | `GET`    | `/api/v1/tests`                                  | List all tests                      |
 * | `GET`    | `/api/v1/tests/:testId`                          | Get a single test                   |
 * | `PATCH`  | `/api/v1/tests/:testId`                          | Edit test (steps, name, code, etc.) |
 * | `POST`   | `/api/v1/projects/:id/tests`                     | Create a manual test (Draft)        |
 * | `DELETE` | `/api/v1/projects/:id/tests/:testId`             | Delete a test                       |
 * | `POST`   | `/api/v1/projects/:id/tests/generate`            | AI-generate test(s) from description|
 * | `POST`   | `/api/v1/tests/:testId/run`                      | Run a single test                   |
 * | `PATCH`  | `/api/v1/projects/:id/tests/:testId/approve`     | Approve (Draft → Approved)          |
 * | `PATCH`  | `/api/v1/projects/:id/tests/:testId/reject`      | Reject                              |
 * | `PATCH`  | `/api/v1/projects/:id/tests/:testId/restore`     | Restore to Draft                    |
 * | `POST`   | `/api/v1/projects/:id/tests/bulk`                | Bulk approve/reject/restore/delete  |
 * | `GET`    | `/api/v1/projects/:id/tests/counts`              | Per-status test counts              |
 * | `GET`    | `/api/v1/projects/:id/tests/export/zephyr`       | Zephyr Scale CSV export             |
 * | `GET`    | `/api/v1/projects/:id/tests/export/testrail`     | TestRail CSV export                 |
 * | `GET`    | `/api/v1/projects/:id/tests/traceability`        | Traceability matrix                 |
 * | `GET`    | `/api/v1/projects/:id/export/playwright`         | Export approved tests as Playwright ZIP |
 */

import { Router } from "express";
import * as projectRepo from "../database/repositories/projectRepo.js";
import * as testRepo from "../database/repositories/testRepo.js";
import * as runRepo from "../database/repositories/runRepo.js";
import { PROVENANCE_CLEAR, humanApproval, computeStats, APPROVAL_SOURCE } from "../services/approvalService.js";
import { ACTIVITY_TYPES } from "../constants/activityTypes.js";
import { generateTestId, generateRunId } from "../utils/idGenerator.js";
import { logActivity } from "../utils/activityLogger.js";
import { runWithAbort } from "../utils/runWithAbort.js";
import { classifyError } from "../utils/errorClassifier.js";
import { hasProvider, isLocalProvider } from "../aiProvider.js";
import { resolveDialsPrompt, resolveDialsConfig } from "../testDials.js";
import { generateFromUserDescription } from "../crawler.js";
import { runTests } from "../testRunner.js"; // thin orchestrator — delegates to runner/ modules
import { buildZephyrCsv, buildTestRailCsv, buildPlaywrightZip } from "../utils/exportFormats.js";
import { validateTestPayload, validateTestUpdate, validateBulkAction } from "../utils/validate.js";
import { isApiTest } from "../runner/codeParsing.js";
import { formatLogLine } from "../utils/logFormatter.js";
import { trackTelemetry } from "../utils/telemetry.js";
import { aiGenerationLimiter, expensiveOpLimiter } from "../middleware/appSetup.js";
import { demoQuota } from "../middleware/demoQuota.js";
import { actor } from "../utils/actor.js";
import { requireRole } from "../middleware/requireRole.js";
import * as baselineRepo from "../database/repositories/baselineRepo.js";
import * as testFixtureRepo from "../database/repositories/testFixtureRepo.js";
import { acceptBaseline } from "../runner/visualDiff.js";
import { SHOTS_DIR, BASELINES_DIR, resolveBrowser, VIEWPORT_WIDTH, VIEWPORT_HEIGHT } from "../runner/config.js";
import path from "path";
import fs from "fs";
import { startRecording, stopRecording, getRecording, takeCompletedRecording, actionsToPlaywrightCode, forwardInput, recordedActionToStepText, addAssertionAction, filterEmittableActions, pauseRecording, resumeRecording, popLastRecordingAction, switchDevice, probeAtPoint } from "../runner/recorder.js";
import { DEVICE_PRESETS } from "../runner/config.js";
/**
 * DIF-015c Gap 5 — allowlist of device names accepted at the route
 * layer. Built once at module load from the same `DEVICE_PRESETS` the
 * `RunRegressionModal` dropdown surfaces, so the recorder's accepted
 * inputs stay byte-aligned with the rest of the regression suite.
 */
const RECORDER_DEVICE_VALUES = new Set(DEVICE_PRESETS.map((d) => d.value));
import { randomUUID } from "crypto";
import * as environmentRepo from "../database/repositories/environmentRepo.js";
import { envScopedProject } from "../utils/envScope.js"; // DIF-012 — shared helper, see module doc.

/**
 * DIF-012: Resolve and validate an optional `environmentId` against the
 * given project, returning the env row when valid. Returns `null` when no
 * envId was supplied; throws an `Error` with `httpStatus` and `message`
 * fields when the envId is invalid (unknown or belongs to a different
 * project) so callers can `return res.status(httpStatus).json({error})`.
 *
 * Mirrors the validation contract in `routes/runs.js` and
 * `routes/trigger.js` so all four entry points (crawl, run, generate,
 * record) share one source of truth.
 *
 * @param {string|null|undefined} environmentId
 * @param {Object} project — already-resolved, workspace-scoped project row.
 * @returns {Object|null}
 */
function resolveEnvOrThrow(environmentId, project) {
  if (!environmentId) return null;
  const env = environmentRepo.getById(environmentId);
  if (!env || env.projectId !== project.id) {
    const err = new Error("invalid environmentId");
    err.httpStatus = 400;
    throw err;
  }
  return env;
}

const router = Router();

/**
 * Normalise a `tags` query-string value into a clean string[] suitable for
 * `filters.tags` on the testRepo paged/count helpers. Accepts either a
 * repeated query param (Express parses `?tags=a&tags=b` as an array) or a
 * single comma-joined string (`?tags=a,b`). Empty strings and whitespace-
 * only entries are dropped. Returns `undefined` when there's nothing to
 * filter on so callers can omit the key from the filters object entirely
 * (the repo treats a missing key and an empty array differently — empty
 * array would match nothing).
 */
const parseTags = (raw) => {
  if (!raw) return undefined;
  const arr = Array.isArray(raw) ? raw : String(raw).split(",");
  const cleaned = arr.map((s) => String(s).trim()).filter(Boolean);
  return cleaned.length ? cleaned : undefined;
};

/**
 * Return `desiredName` if it's free within the project, otherwise append
 * ` (2)`, ` (3)`, … until a non-colliding name is found. Compares
 * case-insensitively (manual testers expect "Login" and "login" to be
 * treated as the same name) and ignores soft-deleted tests because
 * `testRepo.getByProjectId()` already filters them out.
 *
 * Used by the recorder stop handler so two recordings saved with the same
 * name (or two no-name saves whose default ISO-timestamp collides at the
 * same millisecond) don't produce two indistinguishable rows in the Tests
 * list. Also covers the recorded-test path's MAX_RECORDING_MS auto-timeout
 * recovery branch — both routes funnel through `dedupeTestName`.
 *
 * Hot-path consideration: `getByProjectId` does one indexed
 * `SELECT * WHERE projectId = ?`. Recorder stop is a low-frequency event
 * (one per user save), so the in-process scan is fine — we don't want to
 * add a `(projectId, name)` UNIQUE index because the AI pipeline already
 * suffixes its own names and adding a hard constraint would break
 * legacy rows where duplicates already exist.
 *
 * @param {string} projectId
 * @param {string} desiredName
 * @returns {string} A name that doesn't collide with any existing test.
 */


/**
 * CAP-001: parse RFC 4180-flavoured CSV into row objects keyed by the first
 * line's headers. Supports double-quoted fields with embedded commas, CRLF
 * newlines inside quoted fields, and `""` as an escaped double-quote.
 *
 * Intentionally not a full RFC 4180 implementation — we drop trailing blank
 * lines and unquoted whitespace around delimiters because fixture CSVs are
 * typically hand-edited or exported from spreadsheets where those quirks are
 * the norm. Pulling in a CSV dependency would be overkill for this scope
 * (see AGENT.md "Do not add large dependencies").
 *
 * @param {string} text
 * @returns {Array<Object>} Rows; empty array when text has fewer than 2
 *   non-empty logical lines (header + at least one data row).
 */
function parseCsvRows(text) {
  const src = String(text || "");
  if (!src.trim()) return [];

  // Tokenise into fields/rows respecting quoted segments.
  const rows = [];
  let field = "";
  let row = [];
  let inQuotes = false;
  for (let i = 0; i < src.length; i++) {
    const ch = src[i];
    if (inQuotes) {
      if (ch === '"') {
        if (src[i + 1] === '"') { field += '"'; i++; }
        else { inQuotes = false; }
      } else {
        field += ch;
      }
      continue;
    }
    if (ch === '"') { inQuotes = true; continue; }
    if (ch === ",") { row.push(field); field = ""; continue; }
    if (ch === "\n" || ch === "\r") {
      // Swallow paired \r\n as a single record separator
      if (ch === "\r" && src[i + 1] === "\n") i++;
      row.push(field);
      rows.push(row);
      row = []; field = "";
      continue;
    }
    field += ch;
  }
  // Flush the trailing record if the file didn't end with a newline.
  if (field.length > 0 || row.length > 0) {
    row.push(field);
    rows.push(row);
  }

  // Drop fully-empty rows (blank trailing lines, double newlines).
  const cleaned = rows.filter((r) => r.some((c) => String(c).trim() !== ""));
  if (cleaned.length < 2) return [];

  const headers = cleaned[0].map((h) => String(h).trim());
  return cleaned.slice(1).map((cols) => {
    const obj = {};
    headers.forEach((h, idx) => {
      const raw = cols[idx];
      obj[h] = raw === undefined ? "" : String(raw).trim();
    });
    return obj;
  });
}

/**
 * CAP-001: clamp the fixture iteration cap to the [1, 100] range. Default of
 * 10 mirrors the per-project default documented in NEXT.md so a project
 * without an explicit `iterationCap` row still gets bounded dispatch.
 */
function clampIterationCap(raw) {
  const n = Number(raw);
  if (!Number.isFinite(n) || n <= 0) return 10;
  return Math.max(1, Math.min(100, Math.floor(n)));
}

function dedupeTestName(projectId, desiredName) {
  const base = String(desiredName || "").trim();
  if (!base) return base; // caller is responsible for never passing empty
  const existing = testRepo.getByProjectId(projectId);
  const taken = new Set(existing.map((t) => String(t.name || "").trim().toLowerCase()));
  if (!taken.has(base.toLowerCase())) return base;
  // Walk suffix counters until we find a free slot. Cap at 999 to avoid an
  // infinite loop on a pathological project — beyond that, fall through to
  // the timestamped form which is effectively guaranteed unique.
  for (let i = 2; i <= 999; i++) {
    const candidate = `${base} (${i})`;
    if (!taken.has(candidate.toLowerCase())) return candidate;
  }
  return `${base} (${new Date().toISOString()})`;
}

// ─── Test CRUD ────────────────────────────────────────────────────────────────

router.get("/projects/:id/tests", (req, res) => {
  // Verify the project belongs to the user's workspace (ACL-001)
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "project not found" });

  const { page, pageSize, reviewStatus, category, search, stale, tags } = req.query;
  if (page !== undefined || pageSize !== undefined) {
    const filters = {};
    if (reviewStatus && reviewStatus !== "all") filters.reviewStatus = reviewStatus;
    if (category && category !== "all") filters.category = category;
    if (search) filters.search = search;
    if (stale === "true") filters.stale = true;
    const parsedTags = parseTags(tags);
    if (parsedTags) filters.tags = parsedTags;
    return res.json(testRepo.getByProjectIdPaged(req.params.id, page, pageSize, filters));
  }
  res.json(testRepo.getByProjectId(req.params.id));
});

router.get("/tests", (req, res) => {
  // Scope to the user's workspace by fetching workspace project IDs (ACL-001)
  const wsProjects = projectRepo.getAll(req.workspaceId);
  const projectIds = wsProjects.map(p => p.id);

  const { page, pageSize, reviewStatus, category, search, stale, projectId, sortBy, tags } = req.query;
  if (page !== undefined || pageSize !== undefined) {
    const filters = {};
    if (reviewStatus && reviewStatus !== "all") filters.reviewStatus = reviewStatus;
    if (category && category !== "all") filters.category = category;
    if (search) filters.search = search;
    if (stale === "true") filters.stale = true;
    // `projectId` is honoured by the repo only if it falls inside the
    // workspace-scoped set, so a malicious client cannot use it to escape ACL.
    if (projectId && projectId !== "all") filters.projectId = projectId;
    // `sortBy` is whitelisted in the repo (SORT_BY_CLAUSES); unknown values
    // fall back to "newest" — we still pass it through unchanged so the
    // frontend's UI sort dropdown drives the SQL ORDER BY directly.
    if (sortBy) filters.sortBy = sortBy;
    const parsedTags = parseTags(tags);
    if (parsedTags) filters.tags = parsedTags;
    return res.json(testRepo.getAllPagedByProjectIds(projectIds, page, pageSize, filters));
  }
  res.json(testRepo.getAllByProjectIds(projectIds));
});

// GET /api/v1/tests/counts — workspace-wide review-queue tab counts.
//
// Powers the Review Queue's Draft/Approved/Rejected badges in a single
// round-trip. Previously the page fired three `pageSize: 1` paginated
// requests (one per status) on every filter / page change; this aggregate
// returns all three in one query.
//
// Accepts the same filter params as `GET /tests` minus `reviewStatus`
// (which is what we're partitioning) and `sortBy` (irrelevant for COUNT).
// `projectId` is ACL-narrowed inside the repo.
//
// Declared BEFORE `/tests/:testId` so the literal "counts" path doesn't
// get captured by the wildcard.
router.get("/tests/counts", (req, res) => {
  const wsProjects = projectRepo.getAll(req.workspaceId);
  const projectIds = wsProjects.map(p => p.id);

  const { category, search, stale, projectId, tags } = req.query;
  const filters = {};
  if (category && category !== "all") filters.category = category;
  if (search) filters.search = search;
  if (stale === "true") filters.stale = true;
  if (projectId && projectId !== "all") filters.projectId = projectId;
  const parsedTags = parseTags(tags);
  if (parsedTags) filters.tags = parsedTags;

  res.json(testRepo.countReviewQueueByProjectIds(projectIds, filters));
});

router.get("/tests/:testId", (req, res) => {
  const test = testRepo.getById(req.params.testId);
  if (!test) return res.status(404).json({ error: "not found" });
  // Verify the test's project belongs to the user's workspace (ACL-001)
  const project = projectRepo.getByIdInWorkspace(test.projectId, req.workspaceId);
  if (!project) return res.status(404).json({ error: "not found" });
  res.json(test);
});

// PATCH /api/tests/:testId — persist user-edited steps (and optionally other fields)

router.get("/tests/:testId/fixtures", (req, res) => {
  const test = testRepo.getById(req.params.testId);
  if (!test) return res.status(404).json({ error: "not found" });
  const project = projectRepo.getByIdInWorkspace(test.projectId, req.workspaceId);
  if (!project) return res.status(404).json({ error: "not found" });
  res.json(testFixtureRepo.listFixtures(test.id));
});

router.post("/tests/:testId/fixtures", requireRole("qa_lead"), (req, res) => {
  const test = testRepo.getById(req.params.testId);
  if (!test) return res.status(404).json({ error: "not found" });
  const project = projectRepo.getByIdInWorkspace(test.projectId, req.workspaceId);
  if (!project) return res.status(404).json({ error: "not found" });
  const { format, rows, csvText, iterationCap } = req.body || {};
  // Format must be in the same allowlist as the migration's CHECK constraint
  // so a malformed write can't desync the persisted shape from the validator.
  if (format !== "csv" && format !== "json") {
    return res.status(400).json({ error: "format must be 'csv' or 'json'" });
  }
  // Cap resolution order: per-request override → per-project setting → default
  // 10. `clampIterationCap` enforces the [1, 100] hard ceiling regardless of
  // source so a malformed row in `projects.iterationCap` can't exhaust the
  // worker pool.
  const cap = clampIterationCap(iterationCap ?? project.iterationCap);
  let parsedRows = [];
  if (format === "json") parsedRows = Array.isArray(rows) ? rows : [];
  if (format === "csv") parsedRows = parseCsvRows(csvText);
  if (!Array.isArray(parsedRows) || parsedRows.length === 0) {
    return res.status(400).json({ error: "fixture rows required" });
  }
  const clampedRows = parsedRows.slice(0, cap);
  // Keep fixture versions aligned with `test.codeVersion` so an AI fix that
  // bumps the test body invalidates stale fixtures (the runner reads the
  // fixture for the new version, finds nothing, and falls back to single
  // iteration — zero regression for fixture-less tests).
  const version = Number(test.codeVersion || 1);
  const fixture = testFixtureRepo.upsertFixture({ testId: test.id, version, format, rows: clampedRows });
  res.status(201).json({
    ...fixture,
    capApplied: cap,
    truncated: parsedRows.length > clampedRows.length,
  });
});

// Exported for backend/tests/fixture-iteration.test.js so the CSV parser and
// cap clamp can be exercised without spinning up an HTTP server.
export const __testables = { parseCsvRows, clampIterationCap };

router.patch("/tests/:testId", requireRole("qa_lead"), async (req, res) => {
  const validationErr = validateTestUpdate(req.body);
  if (validationErr) return res.status(400).json({ error: validationErr });

  const test = testRepo.getById(req.params.testId);
  if (!test) return res.status(404).json({ error: "not found" });
  // Verify the test's project belongs to the user's workspace (ACL-001)
  const ownerProject = projectRepo.getByIdInWorkspace(test.projectId, req.workspaceId);
  if (!ownerProject) return res.status(404).json({ error: "not found" });

  const { steps, name, description, priority, regenerateCode, previewCode, playwrightCode, linkedIssueKey, tags } = req.body;

  const updates = {};

  if (typeof name === "string")        updates.name        = name.trim();
  if (typeof description === "string") updates.description = description.trim();
  if (typeof priority === "string")    updates.priority    = priority;
  if (typeof linkedIssueKey === "string") updates.linkedIssueKey = linkedIssueKey.trim() || null;
  if (Array.isArray(tags)) updates.tags = tags.map(t => String(t).trim()).filter(Boolean);
  if (typeof playwrightCode === "string") {
    if (test.playwrightCode && test.playwrightCode !== playwrightCode) {
      updates.playwrightCodePrev = test.playwrightCode;
    }
    updates.playwrightCode = playwrightCode;
  }

  const stepsChanged = Array.isArray(steps) &&
    JSON.stringify(steps) !== JSON.stringify(test.steps);

  if (Array.isArray(steps)) updates.steps = steps;

  updates.updatedAt = new Date().toISOString();

  // Any content change (steps, name, description, code, priority) reverts
  // the test to draft so it requires re-approval after editing.
  const contentChanged = stepsChanged
    || (typeof name === "string" && name.trim() !== test.name)
    || (typeof description === "string" && description.trim() !== test.description)
    || (typeof playwrightCode === "string" && playwrightCode !== test.playwrightCode)
    || (typeof priority === "string" && priority !== test.priority);
  if (contentChanged && test.reviewStatus !== "draft") {
    updates.reviewStatus = "draft";
    updates.reviewedAt = null;
  }

  if (typeof playwrightCode === "string") {
    updates.isApiTest = !!(playwrightCode && isApiTest(playwrightCode));
  }

  let codeRegeneratedNow = false;
  let regenerationError = null; // transient — not persisted, only returned in the response
  const currentSteps = updates.steps || test.steps;
  const currentName = updates.name || test.name;

  const shouldRegenerate = (regenerateCode || previewCode) && hasProvider() && Array.isArray(currentSteps) && currentSteps.length > 0;
  let previewResult = null;

  if (shouldRegenerate) {
    try {
      const project = projectRepo.getById(test.projectId);
      const appUrl = project?.url || test.sourceUrl || "";
      const { generateText, parseJSON } = await import("../aiProvider.js");

      // If existing code is available, ask the AI to adapt it to the new steps
      // instead of generating from scratch. This preserves self-healing helpers,
      // comments, and structure — only the changed/removed steps are affected.
      const existingCode = updates.playwrightCode || test.playwrightCode;
      const local = isLocalProvider();

      // Local models (7B) struggle with verbose prompts and JSON output.
      // Use a shorter prompt and request plain code (no JSON wrapper) for Ollama.
      let codePrompt;
      if (existingCode && !local) {
        codePrompt = `You are a Playwright automation expert. The user has edited the test steps. Update the existing Playwright test code to match the new steps.

Test Name: ${currentName}
Application URL: ${appUrl}

PREVIOUS steps:
${(test.steps || []).map((s, i) => `${i + 1}. ${s}`).join("\n")}

UPDATED steps:
${currentSteps.map((s, i) => `${i + 1}. ${s}`).join("\n")}

EXISTING Playwright code:
\`\`\`javascript
${existingCode}
\`\`\`

Requirements:
- Make MINIMAL changes to the existing code — only add, remove, or modify the code sections that correspond to changed or removed steps.
- Keep ALL unchanged step code, comments (// Step N:), helpers (safeClick, safeFill, safeExpect), and structure exactly as-is.
- If a step was removed, remove ONLY its corresponding code block and renumber the remaining "// Step N:" comments.
- If a step was added, insert code for it in the correct position.
- If a step was reworded, update only the affected line(s).
- Do NOT rewrite the entire test from scratch.
- Do NOT include import statements at the top — test/expect are provided externally.

Return ONLY valid JSON with no markdown fences:
{
  "playwrightCode": "test('${currentName}', async ({ page }) => {\\n  // updated test implementation\\n});"
}`;
      } else if (existingCode && local) {
        // Shorter prompt for local models — skip JSON wrapper, request plain code
        codePrompt = `Update this Playwright test to match the new steps. Only change what's needed.

Steps:
${currentSteps.map((s, i) => `${i + 1}. ${s}`).join("\n")}

Current code:
${existingCode}

Return ONLY the updated test code, no explanation.`;
      } else if (!local) {
        codePrompt = `You are a Playwright automation expert. Convert the following QA test steps into a complete, runnable Playwright test.

Test Name: ${currentName}
Application URL: ${appUrl}
Test Steps:
${currentSteps.map((s, i) => `${i + 1}. ${s}`).join("\n")}

Requirements:
- MUST start with: await page.goto('${appUrl}')
- Use role-based selectors: getByRole(), getByLabel(), getByText(), getByPlaceholder()
- Add page.waitForLoadState() after each navigation
- Include at least 3 meaningful expect() assertions
- Do NOT include import statements at the top — test/expect are provided externally

Return ONLY valid JSON with no markdown fences:
{
  "playwrightCode": "test('${currentName}', async ({ page }) => {\\n  // full test implementation\\n});"
}`;
      } else {
        // Shorter prompt for local models — skip JSON wrapper
        codePrompt = `Write a Playwright test for these steps. Start with page.goto('${appUrl}').

Test: ${currentName}
Steps:
${currentSteps.map((s, i) => `${i + 1}. ${s}`).join("\n")}

Return ONLY the test code starting with test('${currentName}', async ({ page }) => {
No imports, no explanation.`;
      }

      const genOpts = local
        ? { maxTokens: 4096, responseFormat: "text" }
        : {};
      const codeRaw = await generateText(codePrompt, genOpts);
      let pwCode = null;
      try {
        const parsed = parseJSON(codeRaw);
        pwCode = typeof parsed.playwrightCode === "string" ? parsed.playwrightCode : null;
      } catch {
        if (codeRaw.includes("test(") && codeRaw.includes("async")) {
          pwCode = codeRaw.trim();
        }
      }
      if (pwCode) {
        if (previewCode) {
          // Preview mode: return generated code without persisting it.
          // The frontend shows a diff panel for the user to accept/edit/discard.
          previewResult = { generatedCode: pwCode, originalCode: existingCode || null };
        } else {
          const currentCode = updates.playwrightCode || test.playwrightCode;
          if (currentCode && currentCode !== pwCode) {
            updates.playwrightCodePrev = currentCode;
          }
          updates.playwrightCode = pwCode;
          updates.isApiTest = !!(pwCode && isApiTest(pwCode));
          updates.codeRegeneratedAt = new Date().toISOString();
          codeRegeneratedNow = true;
        }
      } else {
        // AI returned output that didn't parse as valid code — surface to user
        regenerationError = "Code regeneration produced invalid output. Please try again or edit the code directly via the Source tab.";
      }
    } catch (err) {
      console.error(formatLogLine("error", null, `[PATCH test] code regeneration failed: ${err.message}`));
      // Surface a user-friendly message for timeout errors (common with Ollama)
      if (err.message?.includes("timed out") || err.message?.includes("ECONNREFUSED")) {
        regenerationError = isLocalProvider()
          ? "Code regeneration timed out. Local models may need more time for large tests. Try editing the code directly via the Source tab."
          : "Code regeneration failed. Please try again or edit the code directly via the Source tab.";
      } else {
        regenerationError = "Code regeneration failed. Please try again or edit the code directly via the Source tab.";
      }
    }
  }

  // Persist all updates to SQLite
  testRepo.update(test.id, updates);

  const project = projectRepo.getById(test.projectId);
  logActivity({ ...actor(req),
    type: stepsChanged && (regenerateCode || previewCode) ? "test.regenerate" : "test.edit",
    projectId: test.projectId,
    projectName: project?.name || null,
    testId: test.id,
    testName: updates.name || test.name,
    detail: stepsChanged
      ? `Steps updated (${(updates.steps || test.steps).length} steps)${codeRegeneratedNow ? " — Playwright code regenerated" : ""}`
      : "Test metadata updated",
  });

  // Re-read the updated test from SQLite for the response
  const updatedTest = testRepo.getById(test.id);
  const response = { ...updatedTest };
  if (regenerateCode && !codeRegeneratedNow && !previewCode) {
    response._codeStale = true;
  }
  if (previewResult) {
    response._codePreview = previewResult;
  }
  if (regenerationError) {
    response._regenerationError = regenerationError;
  }

  res.json(response);
});

// ── Manual test creation ──────────────────────────────────────────────────────
router.post("/projects/:id/tests", requireRole("qa_lead"), (req, res) => {
  const validationErr = validateTestPayload(req.body);
  if (validationErr) return res.status(400).json({ error: validationErr });

  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "project not found" });

  const { name, description, steps, playwrightCode, priority, type } = req.body;

  const testId = generateTestId();
  const test = {
    id: testId,
    projectId: project.id,
    name: name.trim(),
    description: description?.trim() || "",
    steps: Array.isArray(steps) ? steps : [],
    playwrightCode: playwrightCode || null,
    priority: priority || "medium",
    type: type || "manual",
    sourceUrl: project.url,
    pageTitle: project.name,
    createdAt: new Date().toISOString(),
    lastResult: null,
    lastRunAt: null,
    qualityScore: null,
    isJourneyTest: false,
    reviewStatus: "draft",
    reviewedAt: null,
    promptVersion: null,
    modelUsed: null,
    linkedIssueKey: null,
    tags: [],
    workspaceId: project.workspaceId || null,
  };

  testRepo.create(test);

  logActivity({ ...actor(req),
    type: "test.create", projectId: project.id, projectName: project.name,
    testId, testName: test.name,
    detail: `Manual test created — "${test.name}"`,
  });

  res.status(201).json(test);
});

router.delete("/projects/:id/tests/:testId", requireRole("qa_lead"), (req, res) => {
  // Verify the project belongs to the user's workspace (ACL-001)
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "not found" });
  const test = testRepo.getById(req.params.testId);
  if (!test || test.projectId !== req.params.id)
    return res.status(404).json({ error: "not found" });
  logActivity({ ...actor(req),
    type: "test.delete", projectId: req.params.id, projectName: project?.name || null,
    testId: req.params.testId, testName: test.name,
    detail: `Test moved to recycle bin — "${test.name}"`,
  });
  testRepo.deleteById(req.params.testId);
  res.json({ ok: true });
});

// ─── AI-powered test generation (pipeline-based) ──────────────────────────────

router.post("/projects/:id/tests/generate", requireRole("qa_lead"), demoQuota("generation"), aiGenerationLimiter, async (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "project not found" });

  const { name, description, dialsConfig } = req.body;
  if (!name || !name.trim()) return res.status(400).json({ error: "name is required" });

  // DIF-012: optional per-run environment override. Validated up-front so a
  // bad envId fails fast before any AI calls or audit-row creation. The
  // override flows into `generateFromUserDescription` via the scoped
  // project (matches runs.js + trigger.js — same contract everywhere).
  let environment;
  try {
    environment = resolveEnvOrThrow(req.body?.environmentId, project);
  } catch (err) {
    return res.status(err.httpStatus || 400).json({ error: err.message });
  }

  // Sanitise name: strip prompt-injection markers (same regex as description/customInstructions)
  const cleanName = name.trim()
    .replace(/^(SYSTEM|ASSISTANT|USER|HUMAN|AI)\s*:/gim, "")
    .replace(/```/g, "")
    .trim();
  if (!cleanName) return res.status(400).json({ error: "name is required" });

  // ── Prompt guardrails ────────────────────────────────────────────────────
  // Cap description at 50 KB to prevent context window overflow.
  // The frontend caps total attachments at 45 KB, leaving headroom for the
  // user's typed description. 50 KB of text is ~12K tokens.
  const MAX_DESCRIPTION_LENGTH = 50_000;
  const rawDescription = (description || "").trim();
  if (rawDescription.length > MAX_DESCRIPTION_LENGTH) {
    return res.status(400).json({
      error: `Description is too long (${Math.round(rawDescription.length / 1000)}KB). Maximum is ${MAX_DESCRIPTION_LENGTH / 1000}KB. Try removing large attachments.`,
    });
  }

  // Sanitise description: strip prompt-injection markers the same way
  // testDials.js sanitises customInstructions. Attachment content from the
  // frontend is concatenated into this field, so it's the main free-text vector.
  const cleanDescription = rawDescription
    .replace(/^(SYSTEM|ASSISTANT|USER|HUMAN|AI)\s*:/gim, "")
    .replace(/```/g, "")
    .trim();
  const dialsPrompt = resolveDialsPrompt(dialsConfig);
  const validatedGenDials = resolveDialsConfig(dialsConfig);
  // Default to "one" for the description-based generate endpoint so users
  // who don't touch Test Dials get 1 focused test (original behaviour).
  // When the user explicitly selects a testCount dial, that value is used instead.
  // The crawl endpoint defaults to "ai_decides" which generates multiple tests per page.
  // Use strict equality — "ai_decides" is truthy so `|| "one"` would never trigger.
  const rawTestCount = validatedGenDials?.testCount;
  const testCount = (rawTestCount && rawTestCount !== "ai_decides") ? rawTestCount : "one";

  if (!hasProvider()) {
    return res.status(503).json({
      error: "No AI provider configured. Add an API key in Settings to use AI test generation.",
    });
  }

  const runId = generateRunId();
  const run = {
    id: runId,
    projectId: project.id,
    type: "generate",
    status: "running",
    startedAt: new Date().toISOString(),
    logs: [],
    tests: [],
    pagesFound: 0,
    generateInput: { name: cleanName, description: cleanDescription, dialsConfig: validatedGenDials || undefined },
    promptAudit: {
      descriptionLength: cleanDescription.length,
      dialsConfigSummary: validatedGenDials ? {
        approach: validatedGenDials.approach,
        testCount: validatedGenDials.testCount,
        format: validatedGenDials.format,
        perspectives: validatedGenDials.perspectives?.length || 0,
        quality: validatedGenDials.quality?.length || 0,
        hasCustomInstructions: !!(validatedGenDials.customInstructions),
      } : null,
      requestedAt: new Date().toISOString(),
    },
    workspaceId: project.workspaceId || null,
    // DIF-012: persist on the run record so the audit trail records which
    // environment this generation targeted (consistent with crawl/run paths).
    environmentId: environment?.id || null,
  };
  runRepo.create(run);
  logActivity({ ...actor(req),
    type: "test.generate", projectId: project.id, projectName: project.name,
    detail: `Test generation pipeline started for "${cleanName}"`, status: "running",
  });

  res.status(202).json({ runId });

  runWithAbort(runId, run,
    // DIF-012: scope the project (url + credentials) to the selected env
    // for this generation run only — `project.url` is preserved as
    // `canonicalUrl` so the AUTO-015 baseline guard treats the run as
    // preview-style.
    (signal) => generateFromUserDescription(envScopedProject(project, environment), run, {
      name: cleanName,
      description: cleanDescription,
      dialsPrompt,
      testCount,
      signal,
    }),
    {
      onSuccess: (createdTestIds) => logActivity({ ...actor(req),
        type: "test.generate", projectId: project.id, projectName: project.name,
        detail: `Test generation completed — ${createdTestIds.length} test(s) created for "${cleanName}"`,
      }),
      onFailActivity: (err) => ({
        type: "test.generate", projectId: project.id, projectName: project.name,
        detail: `Test generation failed for "${cleanName}" — ${classifyError(err, "crawl").message}`,
      }),
      actorInfo: actor(req),
    },
  );
});

// ── Run a single test by ID ───────────────────────────────────────────────────
router.post("/tests/:testId/run", requireRole("qa_lead"), demoQuota("run"), expensiveOpLimiter, 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: "project not found" });

  const runId = generateRunId();
  const run = {
    id: runId,
    projectId: project.id,
    type: "test_run",
    status: "running",
    startedAt: new Date().toISOString(),
    logs: [],
    results: [],
    passed: 0,
    failed: 0,
    total: 1,
    testQueue: [{ id: test.id, name: test.name, steps: test.steps || [] }],
    workspaceId: project.workspaceId || null,
  };
  runRepo.create(run);
  logActivity({ ...actor(req),
    type: "test_run.start", projectId: project.id, projectName: project.name,
    testId: test.id, testName: test.name,
    detail: `Single test run started — "${test.name}"`, status: "running",
  });

  runWithAbort(runId, run,
    (signal) => runTests(project, [test], run, { signal }),
    {
      onSuccess: () => logActivity({ ...actor(req),
        type: "test_run.complete", projectId: project.id, projectName: project.name,
        testId: test.id, testName: test.name,
        detail: `Single test completed — ${run.passed || 0} passed, ${run.failed || 0} failed`,
      }),
      onFailActivity: (err) => ({
        type: "test_run.fail", projectId: project.id, projectName: project.name,
        testId: test.id, testName: test.name,
        detail: `Test run failed for "${test.name}" — ${classifyError(err, "run").message}`,
      }),
      actorInfo: actor(req),
    },
  );

  res.json({ runId });
});

// ─── Test Review: Approve / Reject / Restore / Bulk ──────────────────────────

router.patch("/projects/:id/tests/:testId/approve", requireRole("qa_lead"), (req, res) => {
  // Verify the project belongs to the user's workspace (ACL-001)
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "not found" });
  const test = testRepo.getById(req.params.testId);
  if (!test || test.projectId !== req.params.id)
    return res.status(404).json({ error: "not found" });
  const reviewedAt = new Date().toISOString();
  // AUTO-003b: populate provenance columns on human approvals too so the
  // approval-stats counter and audit trail carry full decision-time data.
  // `humanApproval()` returns the four provenance fields keyed to this
  // actor — see backend/src/services/approvalService.js for the contract.
  const actorInfo = actor(req);
  testRepo.update(test.id, {
    reviewStatus: "approved",
    reviewedAt,
    ...humanApproval(actorInfo),
  });
  logActivity({ ...actorInfo,
    type: ACTIVITY_TYPES.TEST_APPROVE, projectId: req.params.id, projectName: project.name,
    testId: test.id, testName: test.name,
    detail: `Test approved — "${test.name}"`,
  });
  // DIF-013: approval/rejection rate telemetry. `generatedFrom` tells us
  // whether AI-generated, recorded, or manual tests are more likely to be
  // approved — useful for measuring pipeline quality over time.
  trackTelemetry("test.review", {
    projectId: req.params.id,
    decision: "approved",
    generatedFrom: test.generatedFrom || null,
    isBulk: false,
  });
  res.json(testRepo.getById(test.id));
});

router.patch("/projects/:id/tests/:testId/reject", requireRole("qa_lead"), (req, res) => {
  // Verify the project belongs to the user's workspace (ACL-001)
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "not found" });
  const test = testRepo.getById(req.params.testId);
  if (!test || test.projectId !== req.params.id)
    return res.status(404).json({ error: "not found" });
  const reviewedAt = new Date().toISOString();
  // AUTO-003b: clear the four provenance columns alongside `reviewStatus`
  // so a rejected auto-approved test doesn't keep stale `approvalSource:
  // "auto"` / `approvedBy: "auto-approver"` — the response from GET
  // `/tests/:id` would otherwise show a rejected test that still looks
  // auto-approved, which is a confusing audit-trail lie. Matches the
  // restore / revoke / bulk-restore paths that also clear provenance.
  testRepo.update(test.id, { reviewStatus: "rejected", reviewedAt, ...PROVENANCE_CLEAR });
  logActivity({ ...actor(req),
    type: ACTIVITY_TYPES.TEST_REJECT, projectId: req.params.id, projectName: project.name,
    testId: test.id, testName: test.name,
    detail: `Test rejected — "${test.name}"`,
  });
  // DIF-013: see approve handler above for rationale.
  trackTelemetry("test.review", {
    projectId: req.params.id,
    decision: "rejected",
    generatedFrom: test.generatedFrom || null,
    isBulk: false,
  });
  res.json(testRepo.getById(test.id));
});

router.patch("/projects/:id/tests/:testId/restore", requireRole("qa_lead"), (req, res) => {
  // Verify the project belongs to the user's workspace (ACL-001)
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "not found" });
  const test = testRepo.getById(req.params.testId);
  if (!test || test.projectId !== req.params.id)
    return res.status(404).json({ error: "not found" });
  // AUTO-003b: clear the four provenance columns alongside reviewStatus/reviewedAt
  // via the shared `PROVENANCE_CLEAR` shape so this stays in lock-step with
  // bulk restore + revoke (see backend/src/services/approvalService.js).
  testRepo.update(test.id, {
    reviewStatus: "draft",
    reviewedAt: null,
    ...PROVENANCE_CLEAR,
  });
  logActivity({ ...actor(req),
    type: ACTIVITY_TYPES.TEST_RESTORE, projectId: req.params.id, projectName: project.name,
    testId: test.id, testName: test.name,
    detail: `Test restored to draft — "${test.name}"`,
  });
  res.json(testRepo.getById(test.id));
});

// POST /api/v1/tests/:testId/revoke (AUTO-003b)
//
// Revoke an approved test (auto- or human-approved) back to draft. Clears
// the provenance columns so a future approval writes a fresh decision-time
// snapshot, and emits an activity row so the audit trail records who pulled
// the test back. Workspace-scoped via the test's parent project (ACL-001).
router.post("/tests/:testId/revoke", requireRole("qa_lead"), (req, res) => {
  const test = testRepo.getById(req.params.testId);
  if (!test) return res.status(404).json({ error: "not found" });
  const project = projectRepo.getByIdInWorkspace(test.projectId, req.workspaceId);
  if (!project) return res.status(404).json({ error: "not found" });
  // Capture the previous source from the snapshot read above for the audit
  // row's `meta.wasAutoApproved` flag. By the time the UPDATE runs the row
  // has been cleared, so we can't read provenance off the post-state.
  const previousSource = test.approvalSource;
  // AUTO-003b: atomic check-and-update — `revokeApprovalIfApproved` bakes
  // the `reviewStatus = 'approved'` predicate into the UPDATE's WHERE clause
  // so two concurrent revokes can't both succeed. Returns `false` when the
  // row was already in a different state (e.g. another reviewer revoked
  // first); we map that to the same 400 the previous read-then-check path
  // produced. This stays correct on PostgreSQL pools where read snapshots
  // could otherwise let both requests pass the read-side guard.
  if (!testRepo.revokeApprovalIfApproved(test.id)) {
    return res.status(400).json({ error: "only approved tests can be revoked" });
  }
  logActivity({ ...actor(req),
    type: ACTIVITY_TYPES.TEST_REVOKE, projectId: project.id, projectName: project.name,
    testId: test.id, testName: test.name,
    detail: `Approval revoked — "${test.name}" (was ${previousSource === APPROVAL_SOURCE.AUTO ? "auto-approved" : "human-approved"})`,
    // `wasAutoApproved` lets the project approval-stats handler compute the
    // 7-day revert rate without correlating testIds across activity types
    // — see GET /api/v1/projects/:id/approval-stats below.
    meta: { wasAutoApproved: previousSource === APPROVAL_SOURCE.AUTO },
  });
  res.json(testRepo.getById(test.id));
});

// GET /api/v1/projects/:id/approval-stats (AUTO-003b)
//
// Returns approval-decision counts (human / auto / draft) plus a 7-day
// revert rate, used by the project-settings calibration line under the
// `autoApproveThreshold` input.
router.get("/projects/:id/approval-stats", requireRole("qa_lead"), (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "project not found" });
  // Aggregation lives in `services/approvalService.js` — keeping the route
  // handler thin lets the service be reused (e.g. a future workspace-wide
  // rollup) and keeps the test surface focused on auth/HTTP shape vs. logic.
  res.json(computeStats(project.id));
});

// NOTE: bulk must be declared BEFORE :testId wildcard routes to avoid conflict
router.post("/projects/:id/tests/bulk", requireRole("qa_lead"), (req, res) => {
  // Verify the project belongs to the user's workspace (ACL-001)
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "project not found" });

  const validationErr = validateBulkAction(req.body);
  if (validationErr) return res.status(400).json({ error: validationErr });

  const { testIds, action } = req.body;

  if (action === "delete") {
    const deleted = [];
    testIds.forEach((tid) => {
      const test = testRepo.getById(tid);
      if (test && test.projectId === req.params.id) {
        deleted.push({ id: test.id, name: test.name });
        testRepo.deleteById(tid);
      }
    });
    if (deleted.length) {
      logActivity({ ...actor(req),
        type: "test.bulk_delete", projectId: req.params.id, projectName: project.name,
        detail: `Bulk delete — ${deleted.length} test${deleted.length !== 1 ? "s" : ""} moved to recycle bin`,
      });
    }
    return res.json({ deleted: deleted.length, tests: deleted });
  }

  const statusMap = { approve: "approved", reject: "rejected", restore: "draft" };
  const reviewedAt = action === "restore" ? null : new Date().toISOString();
  // AUTO-003b: bulk approve must populate provenance, and bulk restore must
  // clear it. Reuse the same `humanApproval()` / `PROVENANCE_CLEAR` shapes
  // the single-test handlers use (services/approvalService.js) so all four
  // approve/restore paths stay byte-identical.
  //
  // Provenance is passed through `bulkUpdateReviewStatus` so it lands in the
  // SAME UPDATE statement as `reviewStatus`/`reviewedAt`, inside the same
  // transaction. A two-phase pattern (status update, then per-row provenance
  // writes) could leave tests approved with null provenance if the request
  // was aborted between phases, miscounting them as human-approved on the
  // approval-stats endpoint. The returned rows are re-read after the UPDATE
  // so the response reflects the persisted provenance.
  // Reject + restore both clear provenance. Reject clears it so a rejected
  // auto-approved test doesn't carry stale `approvalSource: "auto"` on the
  // response (matches the single-test reject handler above); restore clears
  // it because the test is going back to draft for re-review. Only approve
  // writes new provenance.
  const extraFields = action === "approve"
    ? humanApproval(actor(req))
    : (action === "restore" || action === "reject")
      ? PROVENANCE_CLEAR
      : {};
  const updated = testRepo.bulkUpdateReviewStatus(testIds, req.params.id, statusMap[action], reviewedAt, extraFields);

  // Map the action verb onto the canonical ACTIVITY_TYPES values so the
  // bulk path emits the same `type` literals as the single-test handlers
  // (approve at L673, reject at L705, restore at L735). Previously this
  // used `\`test.${action}\`` interpolation which happened to match today
  // but reopened the `"test.approve"` vs `"test.approved"` drift class
  // the constants were introduced to prevent.
  const PER_TEST_TYPES = {
    approve: ACTIVITY_TYPES.TEST_APPROVE,
    reject:  ACTIVITY_TYPES.TEST_REJECT,
    restore: ACTIVITY_TYPES.TEST_RESTORE,
  };
  const BULK_TYPES = {
    approve: ACTIVITY_TYPES.TEST_BULK_APPROVE,
    reject:  ACTIVITY_TYPES.TEST_BULK_REJECT,
    restore: ACTIVITY_TYPES.TEST_BULK_RESTORE,
  };

  if (updated.length) {
    for (const test of updated) {
      logActivity({ ...actor(req),
        type: PER_TEST_TYPES[action], projectId: req.params.id, projectName: project.name,
        testId: test.id, testName: test.name,
        detail: `Test ${action === "approve" ? "approved" : action === "reject" ? "rejected" : "restored to draft"} (bulk) — "${test.name}"`,
      });
    }
    logActivity({ ...actor(req),
      type: BULK_TYPES[action], projectId: req.params.id, projectName: project.name,
      detail: `Bulk ${action} — ${updated.length} test${updated.length !== 1 ? "s" : ""}`,
    });
    // DIF-013: emit ONE bulk event (not N per-test) to keep PostHog volume
    // reasonable. The aggregated count is what we need for approval-rate
    // analytics; per-test granularity would dominate the event stream.
    if (action === "approve" || action === "reject") {
      trackTelemetry("test.review", {
        projectId: req.params.id,
        decision: action === "approve" ? "approved" : "rejected",
        count: updated.length,
        isBulk: true,
      });
    }
  }
  res.json({ updated: updated.length, tests: updated });
});

// ─── Test counts (lightweight — no row data, just per-status totals) ──────────

router.get("/projects/:id/tests/counts", (req, res) => {
  // Verify the project belongs to the user's workspace (ACL-001)
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "project not found" });
  const counts = testRepo.countByReviewStatus(req.params.id);
  res.json({ ...counts, total: counts.draft + counts.approved + counts.rejected });
});

// ─── Export endpoints — enterprise test management integration ────────────────

// GET /api/projects/:id/tests/export/zephyr — Zephyr Scale CSV for test management import
router.get("/projects/:id/tests/export/zephyr", (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "project not found" });

  const tests = testRepo.getByProjectId(req.params.id);
  const status = req.query.status;
  const filtered = status ? tests.filter(t => t.reviewStatus === status) : tests;

  const csv = buildZephyrCsv(filtered);
  res.setHeader("Content-Type", "text/csv");
  res.setHeader("Content-Disposition", `attachment; filename="sentri-${project.name.replace(/[^a-z0-9]+/gi, "-")}-zephyr.csv"`);
  res.send(csv);
});

// GET /api/projects/:id/tests/export/testrail — TestRail CSV for bulk import
router.get("/projects/:id/tests/export/testrail", (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "project not found" });

  const tests = testRepo.getByProjectId(req.params.id);
  const status = req.query.status;
  const filtered = status ? tests.filter(t => t.reviewStatus === status) : tests;

  const csv = buildTestRailCsv(filtered);
  res.setHeader("Content-Type", "text/csv");
  res.setHeader("Content-Disposition", `attachment; filename="sentri-${project.name.replace(/[^a-z0-9]+/gi, "-")}-testrail.csv"`);
  res.send(csv);
});

// GET /api/projects/:id/export/playwright — runnable Playwright project ZIP (DIF-006)
//
// Note on access control: matches the convention used by every other route
// in this file — `getByIdInWorkspace` returns null for both "doesn't exist"
// and "not a workspace member", and we collapse both into 404 to avoid
// leaking project existence across workspace boundaries (ACL-001).
router.get("/projects/:id/export/playwright", async (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "project not found" });

  try {
    const allTests = testRepo.getByProjectId(req.params.id);
    const approvedTests = allTests.filter(t => t.reviewStatus === "approved");

    const zipBuffer = await buildPlaywrightZip(project, approvedTests);
    const safeProjectName = project.name.replace(/[^a-z0-9]+/gi, "-").toLowerCase();
    res.setHeader("Content-Type", "application/zip");
    res.setHeader("Content-Disposition", `attachment; filename="sentri-${safeProjectName}-playwright.zip"`);
    res.send(zipBuffer);
  } catch (err) {
    // Async route handlers in Express 4 do NOT auto-catch rejected promises;
    // without this try/catch the request hangs indefinitely on any failure
    // (e.g. system `zip` binary missing). Match the error-handling style of
    // the recorder and PATCH handlers above — log internally, return generic.
    console.error(formatLogLine("error", null, `[GET projects/${req.params.id}/export/playwright] export failed: ${err.message}`));
    // ZIP_BINARY_MISSING is an operator-fixable deployment issue, not an
    // internal bug — surface it as 503 with the actionable message so the
    // user can install `zip` or switch to a base image that ships it.
    // Every other failure stays a generic 500 (no internal detail leaked).
    if (err.code === "ZIP_BINARY_MISSING") {
      return res.status(503).json({
        error: "Playwright export unavailable: system `zip` binary not installed on this deployment.",
        code: "ZIP_BINARY_MISSING",
        hint: "Install `zip` on the backend host (apt-get install zip / apk add zip) or use a Docker image that ships it.",
      });
    }
    res.status(500).json({ error: "Internal server error" });
  }
});

// GET /api/projects/:id/tests/traceability — traceability matrix (requirement → test → result)
router.get("/projects/:id/tests/traceability", (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "project not found" });

  const tests = testRepo.getByProjectId(req.params.id);

  // Group tests by linked issue key
  const byIssue = {};
  const unlinked = [];
  for (const t of tests) {
    const entry = {
      testId: t.id,
      name: t.name,
      type: t.type,
      priority: t.priority,
      scenario: t.scenario,
      reviewStatus: t.reviewStatus,
      lastResult: t.lastResult,
      lastRunAt: t.lastRunAt,
      promptVersion: t.promptVersion,
      tags: t.tags || [],
    };
    if (t.linkedIssueKey) {
      if (!byIssue[t.linkedIssueKey]) byIssue[t.linkedIssueKey] = [];
      byIssue[t.linkedIssueKey].push(entry);
    } else {
      unlinked.push(entry);
    }
  }

  res.json({
    projectId: project.id,
    projectName: project.name,
    totalTests: tests.length,
    linkedIssues: Object.keys(byIssue).length,
    unlinkedTests: unlinked.length,
    matrix: byIssue,
    unlinked,
  });
});

// ─── DIF-001: Visual regression baselines ────────────────────────────────────
//
// Baselines are the "golden" screenshots subsequent runs diff against. They
// are created lazily on the first run that produces a screenshot for a given
// (testId, stepNumber). Users can accept a fresh capture as the new baseline
// (to acknowledge intentional UI changes) or delete a baseline to regenerate
// it from the next run's output.

/**
 * GET /api/v1/tests/:testId/baselines
 * List all baselines for a test.
 */
router.get("/tests/:testId/baselines", (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 requestedBrowser = typeof req.query?.browser === "string" ? req.query.browser : "";
  const browser = requestedBrowser ? resolveBrowser(requestedBrowser).name : "";
  res.json(baselineRepo.getAllByTestId(test.id, browser));
});

/**
 * POST /api/v1/tests/:testId/baselines/:stepNumber/accept
 * Promote a captured screenshot from an earlier run to the new baseline.
 *
 * Body: { runId: string } — the run whose screenshot should become the baseline.
 *   - For stepNumber = 0, the run result's `screenshotPath` is used.
 *   - For stepNumber >= 1, the matching entry in `stepCaptures[]` is used.
 */
router.post("/tests/:testId/baselines/:stepNumber/accept", 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" });

  const stepNumber = parseInt(req.params.stepNumber, 10);
  if (!Number.isFinite(stepNumber) || stepNumber < 0) {
    return res.status(400).json({ error: "invalid stepNumber" });
  }

  const runId = String(req.body?.runId || "");
  if (!runId) return res.status(400).json({ error: "runId is required" });

  const run = runRepo.getById(runId);
  if (!run || run.projectId !== project.id) {
    return res.status(404).json({ error: "run not found" });
  }

  const result = (run.results || []).find(r => r.testId === test.id);
  if (!result) return res.status(404).json({ error: "test result not found on run" });
  const browser = resolveBrowser(req.query?.browser || req.body?.browser || run.browser || "chromium").name;

  // Locate the source screenshot on disk. For step 0 we use the final
  // screenshot; for step N we use the matching stepCaptures entry.
  let relArtifactPath;
  if (stepNumber === 0) {
    relArtifactPath = result.screenshotPath;
  } else {
    const cap = (result.stepCaptures || []).find(c => c.step === stepNumber);
    relArtifactPath = cap?.screenshotPath;
  }
  if (!relArtifactPath) {
    return res.status(404).json({ error: "screenshot not captured for that step" });
  }

  // Strip any signing query params and map /artifacts/screenshots/foo.png →
  // <SHOTS_DIR>/foo.png. Reject anything that escapes the screenshots dir.
  const cleanPath = String(relArtifactPath).split("?")[0];
  const prefix = "/artifacts/screenshots/";
  if (!cleanPath.startsWith(prefix)) {
    return res.status(400).json({ error: "screenshot path is not under /artifacts/screenshots/" });
  }
  const fileName = cleanPath.slice(prefix.length);
  const sourceAbsPath = path.resolve(SHOTS_DIR, fileName);
  if (!sourceAbsPath.startsWith(path.resolve(SHOTS_DIR) + path.sep)) {
    return res.status(400).json({ error: "invalid screenshot path" });
  }
  if (!fs.existsSync(sourceAbsPath)) {
    return res.status(404).json({ error: "screenshot file missing on disk" });
  }

  try {
    const { baselinePath } = await acceptBaseline({ testId: test.id, browser, stepNumber, sourceAbsPath });
    logActivity({ ...actor(req),
      type: "test.baseline_accept", projectId: project.id, projectName: project.name,
      detail: `Accepted visual baseline for ${test.id} [${browser}] step ${stepNumber}`, status: "success",
    });
    res.json({ ok: true, baselinePath, testId: test.id, browser, stepNumber });
  } catch (err) {
    // Log the real error server-side; return a generic message to the client
    // per AGENT.md ("5xx errors never leak internal details").
    console.error(formatLogLine("error", null, `[POST baselines/accept] ${test.id}#${stepNumber}: ${err.message}`));
    res.status(500).json({ error: "Internal server error" });
  }
});

/**
 * DELETE /api/v1/tests/:testId/baselines/:stepNumber
 * Delete a baseline. The next run will create a new baseline from its capture.
 */
router.delete("/tests/:testId/baselines/:stepNumber", 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 stepNumber = parseInt(req.params.stepNumber, 10);
  if (!Number.isFinite(stepNumber) || stepNumber < 0) {
    return res.status(400).json({ error: "invalid stepNumber" });
  }
  const browser = resolveBrowser(
    req.query?.browser || req.body?.browser || "chromium"
  ).name;

  // Remove the on-disk PNG too so the next run definitely rebuilds it.
  const absPath = path.join(BASELINES_DIR, test.id, browser, `step-${stepNumber}.png`);
  try { if (fs.existsSync(absPath)) fs.unlinkSync(absPath); } catch { /* ignore */ }

  const deleted = baselineRepo.deleteOne(test.id, stepNumber, browser);
  res.json({ ok: true, deleted, browser });
});

// ─── DIF-015: Interactive browser recorder ───────────────────────────────────
//
// Opens a Playwright browser at the project URL, streams the live CDP
// screencast to the RecorderModal (via SSE on the session ID), and captures
// raw user interactions. On stop, the captured actions are transformed into
// a Playwright test body and saved as a Draft test.

/**
 * POST /api/v1/projects/:id/record
 * Body: { startUrl?: string } — defaults to the project URL.
 *
 * Returns { sessionId } — the SSE run ID the frontend should subscribe to
 * for live screencast frames while recording.
 */
router.post("/projects/:id/record", requireRole("qa_lead"), expensiveOpLimiter, async (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "project not found" });

  // DIF-012: optional per-session environment override. The recorder is
  // driven interactively by the operator (no auto-login), so the env only
  // affects the default `startUrl` — if the caller didn't supply one, fall
  // back to `environment.baseUrl` instead of `project.url` so the operator
  // lands on the right environment from the first frame.
  let environment;
  try {
    environment = resolveEnvOrThrow(req.body?.environmentId, project);
  } catch (err) {
    return res.status(err.httpStatus || 400).json({ error: err.message });
  }

  const startUrl = String(req.body?.startUrl || environment?.baseUrl || project.url || "").trim();
  if (!startUrl || !/^https?:\/\//i.test(startUrl)) {
    return res.status(400).json({ error: "startUrl must be a valid http(s) URL" });
  }

  // DIF-015c Gap 5: validate the optional device profile UP-FRONT, before
  // `runRepo.create()` inserts the stub `running` row below. A 400 return
  // after the row was created would orphan it — the partial unique index
  // `idx_runs_one_active_per_project` would then block every subsequent
  // run (crawl, test_run, generate, record) on this project until the next
  // recorder-launch orphan sweep. Hoist the check alongside `startUrl`
  // validation so all input rejection happens before any DB side effects.
  const rawDevice = req.body?.device;
  const device = rawDevice == null ? "" : String(rawDevice);
  if (device && !RECORDER_DEVICE_VALUES.has(device)) {
    return res.status(400).json({ error: `Invalid device: ${device}` });
  }
  // DIF-015c Gap 6: optional stealth profile. Coerce to a strict
  // boolean so a stringy `"true"` payload from a misconfigured client
  // doesn't accidentally enable stealth (or vice versa). Only the
  // literal JSON `true` opts in — every other value (false, null,
  // omitted, "true", 1) leaves stealth off so default-mode runs are
  // bit-for-bit unchanged.
  const stealth = req.body?.stealth === true;

  const sessionId = `REC-${randomUUID().slice(0, 8)}`;
  // Visible breadcrumb so the operator can see the recorder reaching the
  // backend even when everything is working — useful for debugging the
  // "canvas stays black" symptom where the request landed but a downstream
  // step (browser launch, screencast attach) silently fails.
  console.log(formatLogLine("info", null, `[recorder] launching session=${sessionId} project=${project.id} url=${startUrl}`));
  try {
    // Defence-in-depth: the partial unique index `idx_runs_one_active_per_project`
    // (migration 002) allows at most one `running` run per project. If a
    // previous recorder attempt crashed between `runRepo.create` and the
    // rollback below, an orphan row blocks every subsequent recorder launch
    // with a UNIQUE constraint error. Sweep any such orphans for THIS
    // project before inserting the new stub so the user isn't permanently
    // locked out of the recorder.
    try {
      // Only sweep orphaned RECORDER rows. Including crawl/test_run/generate
      // here would silently kill a legitimately in-progress regression or
      // crawl run when the user opens the recorder, leading to data loss
      // (the runner process keeps executing in memory unaware that its DB
      // status was overwritten). The partial unique index allows one active
      // run per project across all types — so a concurrent recorder + run is
      // intentionally not supported, and the create() below will surface a
      // UNIQUE constraint error that the outer catch handles cleanly.
      const orphan = runRepo.findActiveByProjectId(project.id, ["record"]);
      if (orphan) {
        runRepo.update(orphan.id, {
          status: "interrupted",
          finishedAt: new Date().toISOString(),
          error: "Cleared by recorder launch — previous recording session was orphaned",
        });
      }
    } catch (sweepErr) {
      // Non-fatal: log and continue. If the orphan really exists the create
      // below will surface the UNIQUE error and the catch handles it.
      console.warn(formatLogLine("warn", null, `[POST projects/${project.id}/record] orphan sweep failed: ${sweepErr.message}`));
    }

    // The frontend opens an SSE stream at /runs/:sessionId/events to receive
    // live screencast frames. That endpoint validates the runId against the
    // `runs` table — without a stub row here the SSE connection 404s and the
    // canvas stays black ("Waiting for browser stream…"). Create a minimal
    // running-row keyed by sessionId so SSE accepts it; stopRecording marks
    // it completed so orphan recovery doesn't flag it as interrupted.
    runRepo.create({
      id: sessionId,
      projectId: project.id,
      type: "record",
      status: "running",
      startedAt: new Date().toISOString(),
      // Persist the starting URL so the Recorder modal's start-URL dropdown
      // (`GET /api/v1/projects/:id/pages`) can surface URLs from past
      // recordings as suggestions for new recordings on the same project.
      // The `runs` table has no dedicated `url` column, but `pages` is a
      // JSON column already used by the crawler to persist discovered URLs
      // — reuse it here with a single `{url, status: "recorded"}` entry so
      // the same /pages aggregator works for both crawl + record sources.
      pages: [{ url: startUrl, title: startUrl, status: "recorded" }],
      workspaceId: project.workspaceId || null,
      // DIF-012: record which environment (if any) the operator picked at
      // session start, for audit consistency with crawl/run/generate paths.
      environmentId: environment?.id || null,
    });
    // `device` + `stealth` were validated/coerced BEFORE `runRepo.create()`
    // above so an invalid payload doesn't orphan a stub `running` row.
    await startRecording({ sessionId, projectId: project.id, startUrl, device, stealth });
    console.log(formatLogLine("info", null, `[recorder] session=${sessionId} ready — browser launched, screencast attached`));
    logActivity({ ...actor(req),
      type: "test.record_start", projectId: project.id, projectName: project.name,
      detail: `Recorder started on ${startUrl}`, status: "running",
    });
    // Return the server-side viewport so the frontend can scale forwarded
    // pointer coordinates correctly on deployments that override the default
    // 1280x720 via VIEWPORT_WIDTH / VIEWPORT_HEIGHT env vars, OR — DIF-015c
    // Gap 5 — when a Playwright device descriptor (e.g. iPhone 14 = 390×844)
    // overrides the desktop default. Read the resolved viewport off the
    // session we just created so the canvas sizes correctly on the first
    // SSE frame.
    const sess = getRecording(sessionId);
    const resolvedViewport = sess?.viewport || { width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT };
    res.status(202).json({
      sessionId,
      startUrl,
      device: sess?.device || "",
      // DIF-015c Gap 6: surface the resolved stealth flag so the
      // frontend can reflect the active state in the recording-stage
      // sidebar (and so an operator who toggled stealth but hit a
      // server-side error sees an explicit `stealth: false` rather
      // than guessing from missing UI).
      stealth: sess?.stealth === true,
      viewport: resolvedViewport,
    });
  } catch (err) {
    // Roll back the stub row so a failed launch doesn't leave an orphaned
    // "running" record that blocks future recordings or trips orphan recovery.
    try { runRepo.update(sessionId, { status: "failed", finishedAt: new Date().toISOString(), error: err.message }); } catch { /* row may not exist */ }
    console.error(formatLogLine("error", null, `[POST projects/${project.id}/record] startRecording failed: ${err.message}`));
    res.status(500).json({ error: "Internal server error" });
  }
});

/**
 * POST /api/v1/projects/:id/record/:sessionId/stop
 * Body: { name: string } — the name to give the recorded Draft test.
 *
 * Persists the recorded actions as a new Draft test containing the
 * generated Playwright code. Returns the created test.
 */
router.post("/projects/:id/record/:sessionId/stop", requireRole("qa_lead"), async (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "project not found" });

  const sess = getRecording(req.params.sessionId);
  // When the `MAX_RECORDING_MS` safety-net timeout has already torn the
  // session down, `getRecording` returns null but the generated test may
  // still be waiting in the short-lived completed-recordings cache. Fall
  // back to that cache so the user doesn't lose their captured actions to
  // a race with the auto-teardown.
  let autoCompleted = null;
  if (!sess) {
    autoCompleted = takeCompletedRecording(req.params.sessionId);
    if (!autoCompleted || autoCompleted.projectId !== project.id) {
      return res.status(404).json({ error: "recording session not found" });
    }
  } else if (sess.projectId !== project.id) {
    return res.status(404).json({ error: "recording session not found" });
  }

  // `discard: true` tears down the browser without persisting a Draft test.
  // Used by the RecorderModal's Cancel/Discard button so abandoned recordings
  // do not leave junk tests in the DB.
  const discard = req.body?.discard === true;
  const name = String(req.body?.name || "").trim() || `Recorded flow @ ${new Date().toISOString()}`;

  let stopResult;
  let recoveredFromAutoTimeout = false;
  if (autoCompleted) {
    // Session was already torn down by the auto-timeout; regenerate the
    // Playwright body with the requested test name (the cached code was
    // generated with a default name).
    const playwrightCode = actionsToPlaywrightCode(name, autoCompleted.url, autoCompleted.actions);
    stopResult = { actions: autoCompleted.actions, playwrightCode, url: autoCompleted.url };
    recoveredFromAutoTimeout = true;
  } else {
    try {
      stopResult = await stopRecording(req.params.sessionId, { testName: name });
    } catch (err) {
      // Race window between the `getRecording()` guard above and
      // `stopRecording()` — the MAX_RECORDING_MS timeout may have fired in
      // the interim. Try the completed-recordings cache one more time.
      if (/not found/i.test(err.message || "")) {
        const cached = takeCompletedRecording(req.params.sessionId);
        if (cached && cached.projectId === project.id) {
          const playwrightCode = actionsToPlaywrightCode(name, cached.url, cached.actions);
          stopResult = { actions: cached.actions, playwrightCode, url: cached.url };
          recoveredFromAutoTimeout = true;
        }
      }
      if (!stopResult) {
        // Discard is a best-effort cleanup path: if the session is already
        // gone and we have nothing cached, the caller's intent (close the
        // browser, don't persist a test) is already satisfied.
        if (discard && /not found/i.test(err.message || "")) {
          logActivity({ ...actor(req),
            type: "test.record_discard", projectId: project.id, projectName: project.name,
            detail: `Recording discarded after session auto-teardown (${req.params.sessionId})`, status: "success",
          });
          return res.json({ ok: true, discarded: true, alreadyStopped: true });
        }
        console.error(formatLogLine("error", null, `[POST record/${req.params.sessionId}/stop] stopRecording failed: ${err.message}`));
        return res.status(500).json({ error: "Internal server error" });
      }
    }
  }

  // Close out the stub `runs` row created by POST /record so the SSE channel
  // releases its listener and orphan recovery doesn't pick this up later.
  // Also update `pages` to the actual landed URL — the stub row created by
  // POST /record persisted the caller-supplied `startUrl` (pre-redirect),
  // but `startRecording` resolved it to `stopResult.url` after any
  // server-side redirects. Without this update, the Recorder Start-URL
  // dropdown (`GET /api/v1/projects/:id/pages`) would surface the
  // pre-redirect URL on every subsequent recording launch, causing an
  // unnecessary redirect each time (http→https, apex→www, OAuth callbacks).
  try {
    runRepo.update(req.params.sessionId, {
      status: "completed",
      finishedAt: new Date().toISOString(),
      pages: [{ url: stopResult.url, title: stopResult.url, status: "recorded" }],
    });
  } catch { /* row may have been cleaned up already */ }

  if (discard) {
    logActivity({ ...actor(req),
      type: "test.record_discard", projectId: project.id, projectName: project.name,
      detail: `Recording discarded (${stopResult.actions?.length || 0} actions dropped)`, status: "success",
    });
    return res.json({ ok: true, discarded: true, ...(recoveredFromAutoTimeout ? { alreadyStopped: true } : {}) });
  }

  if (!stopResult.actions || stopResult.actions.length === 0) {
    return res.status(400).json({ error: "no actions were captured — nothing to save" });
  }

  // Dedupe consecutive `goto` actions to the same URL before formatting steps.
  // `startRecording` always pushes the initial `{ kind: "goto", url: startUrl }`
  // as actions[0], and the page's `framenavigated` listener echoes another
  // `goto` for the resolved URL right after. Without this filter the Test
  // Details page shows two redundant navigation steps for what is really a
  // single navigation.
  //
  // Match `actionsToPlaywrightCode`'s exact-URL comparison so the persisted
  // human-readable `steps[]` array and the generated `playwrightCode` stay
  // in lock-step — they are rendered side-by-side on the Test Detail page,
  // and any drift in step count between the two is immediately visible to
  // reviewers (and breaks step-based edits/regeneration that index by
  // position). Origin+pathname dedup would silently drop legitimate
  // query-distinct navigations (e.g. `/search?q=iphone` → `/search?q=macbook`,
  // pagination via `?page=N`, OAuth redirects with state tokens), which
  // matters for any flow that exercises query-driven UI state.
  const dedupedActions = [];
  let lastGotoUrl = String(stopResult.url || "");
  for (const a of stopResult.actions) {
    if (a.kind === "goto" && a.url) {
      if (a.url === lastGotoUrl) continue;
      lastGotoUrl = a.url;
    }
    dedupedActions.push(a);
  }

  // Drop actions that `actionsToPlaywrightCode` would silently skip due to
  // missing required fields. `filterEmittableActions` is the shared predicate
  // exported by the recorder module — using it here keeps the persisted
  // `steps[]` array and the generated `playwrightCode` in lock-step (any
  // drift breaks side-by-side rendering on the Test Detail page and
  // step-based edit/regeneration that indexes by position).
  const emittableActions = filterEmittableActions(dedupedActions);

  // Dedupe the requested name against existing tests in the same project so
  // two recordings saved with the same name (or two no-name saves whose
  // default ISO-timestamp default collides at the same millisecond) don't
  // produce two indistinguishable rows in the Tests list. If a collision
  // resolves, the generated `playwrightCode` still carries the *original*
  // name — regenerate the body with the deduped name so the code's `test(...)`
  // label and the persisted `name` column stay in sync (test runners surface
  // the embedded label in failure reports).
  const uniqueName = dedupeTestName(project.id, name);
  const playwrightCode = uniqueName !== name
    ? actionsToPlaywrightCode(uniqueName, stopResult.url, emittableActions)
    : stopResult.playwrightCode;

  const testId = generateTestId();
  const test = {
    id: testId,
    projectId: project.id,
    name: uniqueName,
    description: `Recorded from ${stopResult.url}`,
    // Match the human-readable step convention used by the AI generate/crawl
    // pipeline (`outputSchema.js`) and the manual-test creation path: short
    // English sentences a manual tester can follow ("User clicks the Sign Up
    // button"), NOT raw CDP-event strings like "Step 1: click → #login". The
    // Test Detail page renders all three sources through the same Steps panel,
    // so visual alignment matters — recorder tests previously stuck out as the
    // only ones showing engineer-shaped output.
    steps: emittableActions.map((a) => recordedActionToStepText(a)),
    playwrightCode,
    priority: "medium",
    type: "recorded",
    sourceUrl: stopResult.url,
    pageTitle: project.name,
    createdAt: new Date().toISOString(),
    lastResult: null,
    lastRunAt: null,
    qualityScore: null,
    isJourneyTest: false,
    reviewStatus: "draft",
    reviewedAt: null,
    promptVersion: null,
    modelUsed: null,
    linkedIssueKey: null,
    tags: ["recorded"],
    generatedFrom: "recorder",
    workspaceId: project.workspaceId || null,
  };
  testRepo.create(test);

  logActivity({ ...actor(req),
    type: "test.record_stop", projectId: project.id, projectName: project.name,
    testId, testName: uniqueName,
    detail: `Recorder captured ${stopResult.actions.length} actions → Draft test${uniqueName !== name ? ` (renamed to "${uniqueName}" to avoid duplicate)` : ""}`, status: "success",
  });

  res.status(201).json({
    test,
    actionCount: stopResult.actions.length,
    ...(recoveredFromAutoTimeout ? { recoveredFromAutoTimeout: true } : {}),
  });
});

/**
 * POST /api/v1/projects/:id/record/:sessionId/input
 *
 * Forwards a single input event (mouse click/move, keyboard, scroll) from
 * the browser-in-browser canvas in RecorderModal to the headless Playwright
 * page via CDP. This is what makes the recorder interactive — without this
 * route the canvas is a read-only screencast and the user can never produce
 * any recorded actions.
 *
 * Intentionally no rate-limiter here: input events arrive at ~60fps during
 * active use. The route is cheap (one async CDP send) and already gated
 * behind requireRole("qa_lead") + workspace scope.
 *
 * @route POST /api/v1/projects/:id/record/:sessionId/input
 * @auth requireRole("qa_lead")
 * @body {{ type: string, x?: number, y?: number, button?: number,
 *           clickCount?: number, key?: string, code?: string,
 *           text?: string, modifiers?: number,
 *           deltaX?: number, deltaY?: number }}
 * @returns {200} { ok: true }
 * @returns {400} { error: string } — missing/invalid event type
 * @returns {404} { error: string } — session not found
 */
router.post("/projects/:id/record/:sessionId/input", requireRole("qa_lead"), async (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "project not found" });

  const sess = getRecording(req.params.sessionId);
  if (!sess || sess.projectId !== project.id) {
    return res.status(404).json({ error: "recording session not found" });
  }

  const VALID_TYPES = new Set(["mousePressed", "mouseReleased", "mouseMoved", "keyDown", "keyUp", "char", "scroll", "shortcutCapture"]);
  const { type } = req.body || {};
  if (!type || !VALID_TYPES.has(type)) {
    return res.status(400).json({ error: `Invalid event type. Must be one of: ${[...VALID_TYPES].join(", ")}` });
  }

  try {
    await forwardInput(req.params.sessionId, req.body);
    res.json({ ok: true });
  } catch (err) {
    // Session gone mid-flight (auto-timeout race) — treat as 404 not 500
    if (/not found/i.test(err.message || "")) {
      return res.status(404).json({ error: "recording session not found" });
    }
    res.status(500).json({ error: "Internal server error" });
  }
});

/**
 * POST /api/v1/projects/:id/record/:sessionId/assertion
 * Add a manual assertion step while recording.
 */
router.post("/projects/:id/record/:sessionId/assertion", requireRole("qa_lead"), (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "project not found" });

  const sess = getRecording(req.params.sessionId);
  if (!sess || sess.projectId !== project.id) {
    return res.status(404).json({ error: "recording session not found" });
  }
  try {
    const action = addAssertionAction(req.params.sessionId, req.body || {});
    res.status(201).json({ ok: true, action });
  } catch (err) {
    if (/Invalid assertion/i.test(err.message || "")) {
      return res.status(400).json({ error: err.message });
    }
    if (/not found|not recording/i.test(err.message || "")) {
      return res.status(404).json({ error: "recording session not found" });
    }
    return res.status(500).json({ error: "Internal server error" });
  }
});

/**
 * POST /api/v1/projects/:id/record/:sessionId/pause
 * Pause action capture for an in-flight recording. The headless browser
 * stays open and the screencast keeps streaming, but `forwardInput` and
 * the `__sentriRecord` binding short-circuit while `session.paused` is
 * true so user clicks/keystrokes during the pause window are not
 * persisted as recorded actions.
 */
router.post("/projects/:id/record/:sessionId/pause", requireRole("qa_lead"), (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "project not found" });
  const sess = getRecording(req.params.sessionId);
  if (!sess || sess.projectId !== project.id) {
    return res.status(404).json({ error: "recording session not found" });
  }
  try {
    const result = pauseRecording(req.params.sessionId);
    res.json({ ok: true, ...result });
  } catch (err) {
    if (/not found|not recording/i.test(err.message || "")) return res.status(404).json({ error: "recording session not found" });
    return res.status(500).json({ error: "Internal server error" });
  }
});

/**
 * POST /api/v1/projects/:id/record/:sessionId/resume
 * Resume action capture after a pause. Idempotent — calling resume on a
 * session that was never paused is a no-op.
 */
router.post("/projects/:id/record/:sessionId/resume", requireRole("qa_lead"), (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "project not found" });
  const sess = getRecording(req.params.sessionId);
  if (!sess || sess.projectId !== project.id) {
    return res.status(404).json({ error: "recording session not found" });
  }
  try {
    const result = resumeRecording(req.params.sessionId);
    res.json({ ok: true, ...result });
  } catch (err) {
    if (/not found|not recording/i.test(err.message || "")) return res.status(404).json({ error: "recording session not found" });
    return res.status(500).json({ error: "Internal server error" });
  }
});

/**
 * POST /api/v1/projects/:id/record/:sessionId/probe
 * DIF-015c Gap 2 (point-and-click assert UX) — read-only probe that
 * returns the `{selector, label, rect}` for an arbitrary viewport
 * coordinate. The frontend uses this to highlight the hovered element
 * inside `LiveBrowserView` and pre-fill the "Add verification" form on
 * click, matching how Playwright codegen's inspector behaves. NOT a
 * mutation — does not record an action; safe to call at hover frequency.
 *
 * Returns `{ probe: null }` when no interactive ancestor is found under
 * the cursor (page background, missing recorder script) so the frontend
 * can drop the highlight rather than show a stale overlay.
 */
router.post("/projects/:id/record/:sessionId/probe", requireRole("qa_lead"), async (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "project not found" });
  const sess = getRecording(req.params.sessionId);
  if (!sess || sess.projectId !== project.id) {
    return res.status(404).json({ error: "recording session not found" });
  }
  const { x, y } = req.body || {};
  // Reject `null` / `undefined` / missing-key payloads explicitly — `Number(null)`
  // coerces to 0 (finite), which would otherwise let `{ "x": null, "y": null }`
  // slip past as a probe at viewport (0, 0). `probeAtPoint` clamps gracefully
  // downstream, but the operator would see whatever happens to be at the
  // top-left corner returned as the "hovered" element, which is confusing.
  if (x == null || y == null || !Number.isFinite(Number(x)) || !Number.isFinite(Number(y))) {
    return res.status(400).json({ error: "x and y must be finite numbers" });
  }
  try {
    const probe = await probeAtPoint(req.params.sessionId, { x, y });
    res.json({ probe });
  } catch (err) {
    if (/not found|not recording|no active page/i.test(err.message || "")) {
      return res.status(404).json({ error: "recording session not found" });
    }
    return res.status(500).json({ error: "Internal server error" });
  }
});

/**
 * POST /api/v1/projects/:id/record/:sessionId/device
 * DIF-015c Gap 5 — switch the active device profile mid-recording. The
 * server tears down the current page + Playwright context, rebuilds them
 * under the new descriptor against the same browser process, and
 * restarts the CDP screencast at the new viewport. Captured
 * `session.actions[]` survive the switch; page state (cookies, form
 * values) does not. The frontend gates the call behind a confirmation
 * modal so operators understand the trade-off.
 */
router.post("/projects/:id/record/:sessionId/device", requireRole("qa_lead"), async (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "project not found" });
  const sess = getRecording(req.params.sessionId);
  if (!sess || sess.projectId !== project.id) {
    return res.status(404).json({ error: "recording session not found" });
  }
  const rawDevice = req.body?.device;
  const device = rawDevice == null ? "" : String(rawDevice);
  if (device && !RECORDER_DEVICE_VALUES.has(device)) {
    return res.status(400).json({ error: `Invalid device: ${device}` });
  }
  try {
    const result = await switchDevice(req.params.sessionId, device);
    res.json({ ok: true, ...result });
  } catch (err) {
    if (/not found|not recording/i.test(err.message || "")) return res.status(404).json({ error: "recording session not found" });
    if (/Invalid device/i.test(err.message || "")) return res.status(400).json({ error: err.message });
    if (/Device switch failed/i.test(err.message || "")) {
      console.error(formatLogLine("error", null, `[POST record/${req.params.sessionId}/device] ${err.message}`));
      return res.status(500).json({ error: "Device switch failed — recorder torn down. Re-launch the recorder to continue." });
    }
    return res.status(500).json({ error: "Internal server error" });
  }
});

/**
 * POST /api/v1/projects/:id/record/:sessionId/pop-last
 * Undo the most recent captured action. Idempotent on an empty
 * `session.actions[]` — returns `{ removed: null, actionCount: 0 }`
 * rather than 4xx so the UI can fire the button without first checking
 * step count.
 */
router.post("/projects/:id/record/:sessionId/pop-last", requireRole("qa_lead"), (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "project not found" });
  const sess = getRecording(req.params.sessionId);
  if (!sess || sess.projectId !== project.id) {
    return res.status(404).json({ error: "recording session not found" });
  }
  try {
    const result = popLastRecordingAction(req.params.sessionId);
    res.json({ ok: true, ...result });
  } catch (err) {
    if (/not found|not recording/i.test(err.message || "")) return res.status(404).json({ error: "recording session not found" });
    return res.status(500).json({ error: "Internal server error" });
  }
});

/**
 * GET /api/v1/projects/:id/record/:sessionId
 * Inspect an in-flight recording (action count, status). Used by the modal
 * to poll for captured actions while the browser is still open.
 */
router.get("/projects/:id/record/:sessionId", (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "project not found" });
  const sess = getRecording(req.params.sessionId);
  if (!sess || sess.projectId !== project.id) {
    return res.status(404).json({ error: "recording session not found" });
  }
  res.json({
    sessionId: sess.id,
    status: sess.status,
    url: sess.url,
    startedAt: sess.startedAt,
    actionCount: sess.actions.length,
    actions: sess.actions.map(a => ({ kind: a.kind, selector: a.selector, label: a.label, value: a.value, key: a.key, url: a.url, ts: a.ts })),
  });
});

export default router;