Source: pipeline/journeyGenerator.js

/**
 * journeyGenerator.js — Layer 7: Generate user journey tests (multi-step flows)
 *
 * Thin orchestration layer that delegates to:
 *   - prompts/journeyPrompt.js      — multi-page journey prompt
 *   - prompts/intentPrompt.js       — single-page intent prompt
 *   - prompts/userRequestedPrompt.js — user-described test prompt
 *   - promptHelpers.js              — resolveTestCountInstruction, withDials
 *   - stepSanitiser.js              — sanitiseSteps, extractTestsArray
 */

import { generateText, streamText, parseJSON, isRateLimitError } from "../aiProvider.js";
import { throwIfAborted } from "../utils/abortHelper.js";
import { formatLogLine } from "../utils/logFormatter.js";
import { withDials } from "./promptHelpers.js";
import { extractTestsArray, sanitiseSteps } from "./stepSanitiser.js";
import { buildJourneyPrompt } from "./prompts/journeyPrompt.js";
import { buildIntentPrompt } from "./prompts/intentPrompt.js";
import { buildUserRequestedPrompt } from "./prompts/userRequestedPrompt.js";
import { buildApiTestPrompt } from "./prompts/apiTestPrompt.js";
import { parseOpenApiSpec } from "./openApiParser.js";

// ── API intent detection ──────────────────────────────────────────────────────
// Heuristic: if the user's name + description mention API-specific keywords,
// route to the API test prompt instead of the UI test prompt. This lets users
// generate Playwright `request` API tests from the "Generate Test" modal
// without needing a crawl + HAR capture.

const API_INTENT_PATTERNS = [
  /\bAPI\b/,                          // explicit "API"
  /\bREST\b/i,                        // REST API
  /\bGraphQL\b/i,                     // GraphQL
  /\bendpoint/i,                      // "endpoint", "endpoints"
  /\b(GET|POST|PUT|PATCH|DELETE)\s+\//,// "GET /api/users", "POST /login"
  /\bstatus\s*code/i,                 // "status code 200"
  /\brequest\s*body/i,                // "request body"
  /\bresponse\s*(body|shape|schema)/i,// "response body", "response shape"
  /\bjson\s*(response|payload|body)/i,// "JSON response"
  /\bcontract\s*test/i,              // "contract test"
  /\/api\//i,                         // URL path like "/api/users"
];

/**
 * Detect whether the user's test name + description indicate API test intent.
 * @param {string} name
 * @param {string} description
 * @returns {boolean}
 */
function isApiIntent(name, description) {
  const combined = `${name} ${description}`;
  return API_INTENT_PATTERNS.some(re => re.test(combined));
}

/**
 * Parse lightweight endpoint hints from the user's description.
 * Extracts patterns like "GET /api/users", "POST /login" and builds
 * minimal ApiEndpoint-shaped objects for buildApiTestPrompt.
 *
 * @param {string} description
 * @param {string} appUrl
 * @returns {ApiEndpoint[]}
 */
function parseEndpointHints(description, appUrl) {
  const endpoints = [];
  const seen = new Set();

  function addEndpoint(method, pathPattern) {
    const key = `${method} ${pathPattern}`;
    if (seen.has(key)) return;
    seen.add(key);
    endpoints.push({
      method,
      pathPattern,
      exampleUrls: [`${appUrl.replace(/\/$/, "")}${pathPattern}`],
      statuses: method === "GET" ? [200] : [200, 201],
      contentType: "application/json",
      requestBodyExample: null,
      responseBodyExample: null,
      callCount: 1,
      avgDurationMs: 0,
      pageUrls: [],
    });
  }

  // 1. Match "METHOD /path" patterns (e.g. "GET /api/users", "POST /api/auth/login")
  const methodPathRe = /\b(GET|POST|PUT|PATCH|DELETE)\s+(\/\S+)/gi;
  let match;
  while ((match = methodPathRe.exec(description)) !== null) {
    const method = match[1].toUpperCase();
    const path = match[2].replace(/[.,;:!?)]+$/, "");
    addEndpoint(method, path);
  }

  // 2. Match full URLs containing /api/ (e.g. "https://reqres.in/api/register")
  //    When no METHOD is specified, default to POST for mutation-like paths
  //    (register, login, create, update, delete) and GET for everything else.
  const urlRe = /https?:\/\/[^\s,]+\/api\/[^\s,]*/gi;
  while ((match = urlRe.exec(description)) !== null) {
    try {
      const url = new URL(match[0].replace(/[.,;:!?)]+$/, ""));
      const path = url.pathname;
      const lastSeg = path.split("/").filter(Boolean).pop() || "";
      const isMutation = /register|login|signin|signup|create|update|delete|reset|send|submit/i.test(lastSeg);
      addEndpoint(isMutation ? "POST" : "GET", path);
    } catch { /* invalid URL — skip */ }
  }

  // 3. Match bare /api/ paths not preceded by a METHOD (e.g. "/api/users/:id")
  const barePathRe = /(?<!\w)(\/api\/\S+)/gi;
  while ((match = barePathRe.exec(description)) !== null) {
    const path = match[1].replace(/[.,;:!?)]+$/, "");
    if (!seen.has(`GET ${path}`) && !seen.has(`POST ${path}`)) {
      addEndpoint("GET", path);
    }
  }

  // 4. Extract inline JSON examples that follow endpoint mentions
  //    Patterns: "Request: { ... }" or "Response: { ... }" after a METHOD /path line
  //    Attaches them to the most recently added endpoint.
  const jsonLabelRe = /\b(request|response)\s*(?:body)?:\s*(\{[\s\S]*?\})\s*(?:\n|$)/gi;
  while ((match = jsonLabelRe.exec(description)) !== null) {
    const label = match[1].toLowerCase();
    const jsonStr = match[2].trim();
    // Validate it's actually JSON
    try { JSON.parse(jsonStr); } catch { continue; }
    // Attach to the last endpoint added (most likely the one this example belongs to)
    const target = endpoints[endpoints.length - 1];
    if (!target) continue;
    if (label === "request" && !target.requestBodyExample) {
      target.requestBodyExample = jsonStr;
    } else if (label === "response" && !target.responseBodyExample) {
      target.responseBodyExample = jsonStr;
    }
  }

  return endpoints;
}

/**
 * generateFromDescription(name, description, appUrl) → Array of test objects
 *
 * Generates test(s) focused on the user's provided name + description.
 * The number of tests is controlled by the `testCount` dial (1–20).
 * Used by the POST /api/projects/:id/tests/generate endpoint instead of the
 * generic generateIntentTests which produces crawl-oriented tests.
 *
 * When the description indicates API test intent (mentions endpoints, HTTP
 * methods, status codes, etc.), automatically routes to the API test prompt
 * which generates Playwright `request` API tests instead of UI tests.
 */
export async function generateFromDescription(name, description, appUrl, onToken, { dialsPrompt = "", testCount = "ai_decides", signal } = {}) {
  const apiIntent = isApiIntent(name, description);

  let prompt;
  if (apiIntent) {
    // Try OpenAPI spec parsing first (user may have pasted/attached a spec).
    // Falls back to text-based endpoint hint extraction if not a valid spec.
    let endpointHints = parseOpenApiSpec(description);
    if (endpointHints.length === 0) {
      endpointHints = parseEndpointHints(description, appUrl);
    }
    const apiPrompt = buildApiTestPrompt(endpointHints, appUrl, { testCount });
    // Inject the user's original name + description so the AI has full context
    // beyond just the parsed endpoint hints (e.g. "write API tests for register
    // endpoint with valid and invalid payloads" gives intent the parser can't capture).
    apiPrompt.user = `USER REQUEST: ${name}\n${description ? `USER DESCRIPTION: ${description}\n\n` : "\n"}${apiPrompt.user}`;
    prompt = withDials(apiPrompt, dialsPrompt);
  } else {
    prompt = withDials(buildUserRequestedPrompt(name, description, appUrl, { testCount }), dialsPrompt);
  }

  const text = onToken
    ? await streamText(prompt, onToken, { signal })
    : await generateText(prompt, { signal });
  const parsed = parseJSON(text);
  const tests = extractTestsArray(parsed);

  // Ensure the test name matches the user's input (AI sometimes renames)
  for (const t of tests) {
    t.sourceUrl = appUrl;
    if (!t.name || t.name === "descriptive name") t.name = name;
    if (apiIntent) {
      t.type = t.type || "integration";
      t._generatedFrom = "api_user_described";
      if (t.name && !t.name.startsWith("API:") && !t.name.startsWith("API ")) {
        t.name = `API: ${t.name}`;
      }
    }
  }

  // Convert Playwright code steps to human-readable descriptions (Mistral/small LLMs)
  sanitiseSteps(tests);

  return tests;
}

// ── Main generators ───────────────────────────────────────────────────────────

/**
 * generateJourneyTest(journey, snapshotsByUrl) → array of test objects or []
 */
export async function generateJourneyTest(journey, snapshotsByUrl, { dialsPrompt = "", testCount = "ai_decides", signal } = {}) {
  try {
    const prompt = withDials(buildJourneyPrompt(journey, snapshotsByUrl, { testCount }), dialsPrompt);
    const text = await generateText(prompt, { signal });
    const result = parseJSON(text);
    const tests = extractTestsArray(result);
    if (tests.length === 0) return [];

    sanitiseSteps(tests);
    return tests;
  } catch (err) {
    if (err.name === "AbortError" || signal?.aborted) throw err;
    // Propagate rate limit errors so the caller can short-circuit
    if (isRateLimitError(err)) throw err;
    console.error(formatLogLine("error", null, `[journeyGenerator] Journey test generation failed: ${err.message?.slice(0, 300)}`));
    return [];
  }
}

/**
 * generateIntentTests(classifiedPage, snapshot) → Array of test objects
 */
export async function generateIntentTests(classifiedPage, snapshot, { dialsPrompt = "", testCount = "ai_decides", signal } = {}) {
  try {
    const prompt = withDials(buildIntentPrompt(classifiedPage, snapshot, { testCount }), dialsPrompt);
    const text = await generateText(prompt, { signal });
    const parsed = parseJSON(text);
    const tests = extractTestsArray(parsed);
    if (tests.length === 0) return [];

    sanitiseSteps(tests);
    return tests;
  } catch (err) {
    if (err.name === "AbortError" || signal?.aborted) throw err;
    // Propagate rate limit errors so the caller can short-circuit
    if (isRateLimitError(err)) throw err;
    console.error(formatLogLine("error", null, `[journeyGenerator] Intent test generation failed for ${classifiedPage?.url || "unknown"}: ${err.message?.slice(0, 300)}`));
    return [];
  }
}

/**
 * generateAllTests(classifiedPages, journeys, snapshotsByUrl) → { tests, rateLimitHit, rateLimitError }
 *
 * Orchestrates full test generation: journeys first, then per-page intent tests.
 * ALL pages get comprehensive tests — not just high-priority ones.
 *
 * @returns {{ tests: object[], rateLimitHit: boolean, rateLimitError: string|null }}
 */
export async function generateAllTests(classifiedPages, journeys, snapshotsByUrl, onProgress, { dialsPrompt = "", testCount = "ai_decides", signal } = {}) {
  const allTests = [];
  let rateLimitHit = false;
  let rateLimitError = null;

  // How long to wait after exhausted retries before attempting one final
  // recovery call. aiProvider.js already retries with exponential backoff
  // (up to MAX_BACKOFF_MS = 30s). This grace period gives the provider's
  // quota window a chance to partially reset between pages before we
  // permanently abandon the rest of the run.
  const RATE_LIMIT_GRACE_MS = 60_000;

  // Helper: call a generator and handle rate limit short-circuit.
  //
  // When aiProvider.js exhausts all its internal retries (and fallback
  // providers for rate-limit/5xx errors) it surfaces the error here.
  // Rather than immediately aborting every remaining call, we attempt one
  // grace-period wait and retry — only setting rateLimitHit (permanent
  // skip) if that also fails. This prevents a brief quota burst early in
  // the run from silently discarding tests for all subsequent pages.
  async function safeGenerate(label, fn) {
    if (rateLimitHit) return []; // permanently short-circuit after confirmed unrecoverable limit
    try {
      return await fn();
    } catch (err) {
      if (err.name === "AbortError" || signal?.aborted) throw err;
      if (isRateLimitError(err)) {
        // aiProvider.js already exhausted its own retries — wait for the
        // provider's quota window before making one final attempt.
        onProgress?.(`⚠️  AI rate limit reached after all retries: ${err.message.slice(0, 120)}`);
        onProgress?.(`⏳ Waiting ${RATE_LIMIT_GRACE_MS / 1000}s for quota window to reset before retrying…`);
        await new Promise((resolve, reject) => {
          if (signal?.aborted) {
            reject(new DOMException("Aborted", "AbortError"));
            return;
          }
          const timer = setTimeout(resolve, RATE_LIMIT_GRACE_MS);
          signal?.addEventListener("abort", () => {
            clearTimeout(timer);
            reject(new DOMException("Aborted", "AbortError"));
          }, { once: true });
        });
        throwIfAborted(signal);
        try {
          return await fn();
        } catch (retryErr) {
          if (retryErr.name === "AbortError" || signal?.aborted) throw retryErr;
          if (isRateLimitError(retryErr)) {
            // Grace-period retry also hit rate limit — quota is durably exhausted.
            // Stop all remaining calls to avoid hammering the provider.
            rateLimitHit = true;
            rateLimitError = retryErr.message || String(retryErr);
            onProgress?.(`⏭️  Rate limit persists after grace period — skipping remaining AI calls (${allTests.length} tests saved so far)`);
            return [];
          }
          // Non-rate-limit error on retry — log and return empty but don't
          // permanently skip remaining calls since this isn't a quota issue.
          onProgress?.(`⚠️  ${label} retry failed: ${retryErr.message?.slice(0, 100)}`);
          return [];
        }
      }
      onProgress?.(`⚠️  ${label} failed: ${err.message.slice(0, 100)}`);
      return [];
    }
  }

  // Cap journeys to avoid excessive LLM calls on small crawls.
  // Respects the testCount dial: "one" wants minimal output (1 journey),
  // "small" is conservative (2), "large" wants comprehensive (uncapped).
  // Page count provides a secondary cap so 5-page crawls don't generate
  // 4 journeys even when testCount is "ai_decides".
  const testCountCap = testCount === "one" ? 1
    : testCount === "small" ? 2
    : testCount === "medium" ? 4
    : testCount === "large" ? journeys.length
    : null; // ai_decides — use page-count heuristic only
  const pageCountCap = classifiedPages.length <= 5 ? 2
    : classifiedPages.length <= 15 ? 4
    : journeys.length;
  const maxJourneys = testCountCap != null
    ? (testCount === "large" ? testCountCap : Math.min(testCountCap, pageCountCap))
    : pageCountCap;
  const cappedJourneys = journeys.slice(0, maxJourneys);
  if (cappedJourneys.length < journeys.length) {
    onProgress?.(`📊 Capped journeys: ${cappedJourneys.length}/${journeys.length} (${classifiedPages.length} pages, testCount=${testCount})`);
  }

  // 1. Generate journey tests (highest value — multi-page flows)
  for (const journey of cappedJourneys) {
    throwIfAborted(signal);
    onProgress?.(`🗺️  Generating journey tests: ${journey.name}`);
    const journeyTests = await safeGenerate(`Journey "${journey.name}"`, () =>
      generateJourneyTest(journey, snapshotsByUrl, { dialsPrompt, testCount, signal })
    );
    for (const jt of journeyTests) {
      allTests.push({ ...jt, sourceUrl: journey.pages[0]?.url, pageTitle: journey.name });
    }
  }

  // Track which URLs are fully covered by journeys
  const coveredUrls = new Set(cappedJourneys.flatMap(j => j.pages.map(p => p.url)));

  // 2. Comprehensive tests for HIGH-PRIORITY pages not covered by journeys
  for (const classifiedPage of classifiedPages) {
    throwIfAborted(signal);
    if (!classifiedPage.isHighPriority) continue;
    if (coveredUrls.has(classifiedPage.url)) continue;

    onProgress?.(`🤖 Generating intent tests for: ${classifiedPage.url} [${classifiedPage.dominantIntent}]`);
    const snapshot = snapshotsByUrl[classifiedPage.url];
    if (!snapshot) continue;

    const tests = await safeGenerate(`Intent tests for ${classifiedPage.url}`, () =>
      generateIntentTests(classifiedPage, snapshot, { dialsPrompt, testCount, signal })
    );
    for (const t of tests) {
      allTests.push({ ...t, sourceUrl: classifiedPage.url, pageTitle: snapshot.title });
    }
  }

  // 3. Tests for remaining low-priority pages (NAVIGATION, CONTENT, etc.)
  // Skip pages already covered by journeys — they add no value.
  // Only generate for pages that have enough interactive elements to produce
  // meaningful tests (≥3 elements). Static content pages with just links
  // and headings produce low-quality tests that are almost always rejected.
  for (const classifiedPage of classifiedPages) {
    throwIfAborted(signal);
    if (classifiedPage.isHighPriority || coveredUrls.has(classifiedPage.url)) continue;
    const snapshot = snapshotsByUrl[classifiedPage.url];
    if (!snapshot) continue;

    // Skip pages with too few interactive elements — they produce low-quality tests
    const interactiveCount = (snapshot.elements || []).filter(e =>
      e.tag === "button" || e.tag === "input" || e.tag === "select" || e.tag === "textarea"
      || e.role === "button" || e.role === "link" || e.role === "textbox"
    ).length;
    if (interactiveCount < 3) {
      onProgress?.(`⏭️  Skipping ${classifiedPage.url} — only ${interactiveCount} interactive elements`);
      continue;
    }

    onProgress?.(`📄 Generating tests for: ${classifiedPage.url} [${classifiedPage.dominantIntent}]`);
    const tests = await safeGenerate(`Tests for ${classifiedPage.url}`, () =>
      generateIntentTests(classifiedPage, snapshot, { dialsPrompt, testCount, signal })
    );
    for (const t of tests) {
      allTests.push({ ...t, sourceUrl: classifiedPage.url, pageTitle: snapshot.title });
    }
  }

  return {
    tests: allTests,
    rateLimitHit,
    rateLimitError: rateLimitHit ? (rateLimitError || "AI provider rate limit exceeded") : null,
  };
}

// ── API test generation ───────────────────────────────────────────────────────

/**
 * generateApiTests(apiEndpoints, appUrl, opts) → Array of test objects
 *
 * Generates Playwright `request` API tests from HAR-captured endpoint summaries.
 * Returns an empty array if no endpoints were captured or the AI call fails.
 *
 * @param {ApiEndpoint[]} apiEndpoints — from summariseApiEndpoints()
 * @param {string}        appUrl       — project base URL
 * @param {object}        [opts]
 * @param {string}        [opts.dialsPrompt]
 * @param {string}        [opts.testCount]
 * @param {AbortSignal}   [opts.signal]
 * @returns {Promise<object[]>}
 */
export async function generateApiTests(apiEndpoints, appUrl, { dialsPrompt = "", testCount = "ai_decides", signal } = {}) {
  if (!apiEndpoints || apiEndpoints.length === 0) return [];

  try {
    throwIfAborted(signal);
    const prompt = withDials(buildApiTestPrompt(apiEndpoints, appUrl, { testCount }), dialsPrompt);
    const text = await generateText(prompt, { signal });
    const parsed = parseJSON(text);
    const tests = extractTestsArray(parsed);
    if (tests.length === 0) return [];

    // Mark all API tests with the correct type and source
    for (const t of tests) {
      t.type = t.type || "integration";
      t.sourceUrl = appUrl;
      t._generatedFrom = "api_har_capture";
      // Prefix name with "API:" if not already
      if (t.name && !t.name.startsWith("API:") && !t.name.startsWith("API ")) {
        t.name = `API: ${t.name}`;
      }
    }

    sanitiseSteps(tests);
    return tests;
  } catch (err) {
    if (err.name === "AbortError" || signal?.aborted) throw err;
    // Propagate rate limit errors so the caller can short-circuit (matches journey/intent generators)
    if (isRateLimitError(err)) throw err;
    console.error(formatLogLine("error", null, `[journeyGenerator] API test generation failed: ${err.message?.slice(0, 300)}`));
    return [];
  }
}