Source: pipeline/prompts/apiTestPrompt.js

/**
 * @module pipeline/prompts/apiTestPrompt
 * @description Builds the AI prompt for generating Playwright API tests from
 * captured HAR endpoint summaries.
 *
 * Unlike UI tests (which use `page.click()`, `page.fill()`), API tests use
 * Playwright's `request` API context (`apiRequestContext`) to make HTTP calls
 * directly and assert on status codes, response shapes, and headers.
 *
 * ### Exports
 * - {@link formatJsonExample} — `(raw, maxChars?) → string`
 * - {@link buildApiTestPrompt} — `(endpoints, appUrl) → { system, user }`
 */

import { isLocalProvider } from "../../aiProvider.js";
import { resolveTestCountInstruction } from "../promptHelpers.js";
import { PROMPT_VERSION } from "./outputSchema.js";
import { buildCapabilityCoverageBlock } from "./playwrightCapabilityGuide.js";

/**
 * Truncate a JSON request/response body example to fit within a token budget.
 *
 * For JSON arrays, incrementally includes elements until `maxChars` is reached.
 * For JSON objects, incrementally includes keys. Always preserves at least one
 * element/key so the shape is visible. Non-JSON payloads are sliced with a
 * `[truncated]` marker.
 *
 * @param {string|*} raw           — raw body string (or falsy/non-string passthrough)
 * @param {number}   [maxChars=1800] — approximate character budget
 * @returns {string} Truncated JSON or sliced string
 */
export function formatJsonExample(raw, maxChars = 1800) {
  if (!raw || typeof raw !== "string") return raw || "";
  if (raw.length <= maxChars) return raw;
  try {
    const parsed = JSON.parse(raw);
    if (Array.isArray(parsed)) {
      // Incrementally include elements until we exceed maxChars
      const compact = [];
      for (const item of parsed) {
        compact.push(item);
        if (JSON.stringify(compact, null, 2).length > maxChars) {
          compact.pop();
          break;
        }
      }
      // Always include at least one element so the shape is visible
      if (compact.length === 0 && parsed.length > 0) compact.push(parsed[0]);
      return JSON.stringify(compact, null, 2);
    }
    if (parsed && typeof parsed === "object") {
      const compact = {};
      for (const [k, v] of Object.entries(parsed)) {
        compact[k] = v;
        const next = JSON.stringify(compact, null, 2);
        if (next.length > maxChars) {
          delete compact[k];
          break;
        }
      }
      // Always include at least one key so the shape is visible
      if (Object.keys(compact).length === 0 && Object.keys(parsed).length > 0) {
        const [firstKey, firstVal] = Object.entries(parsed)[0];
        compact[firstKey] = firstVal;
      }
      return JSON.stringify(compact, null, 2);
    }
  } catch {
    // non-JSON payload; fall through
  }
  return `${raw.slice(0, maxChars)}\n... [truncated]`;
}

/**
 * Build the system + user prompt for API test generation.
 *
 * @param {ApiEndpoint[]} endpoints — from summariseApiEndpoints()
 * @param {string}        appUrl    — project base URL
 * @param {object}        [opts]
 * @param {string}        [opts.testCount] — dial value
 * @returns {{ system: string, user: string }}
 */
export function buildApiTestPrompt(endpoints, appUrl, { testCount = "ai_decides" } = {}) {
  const local = isLocalProvider();
  const testCountInstr = resolveTestCountInstruction(testCount, local);

  // Group endpoints by category for the prompt
  const gets    = endpoints.filter(e => e.method === "GET");
  const mutates = endpoints.filter(e => ["POST", "PUT", "PATCH", "DELETE"].includes(e.method));

  const system = `You are a senior API test automation engineer generating Playwright API tests.

PERSONA RULES:
- Generate tests that call HTTP endpoints directly using Playwright's request API context.
- Tests verify status codes, response JSON shapes, content-type headers, and error handling.
- Tests must be independent — no shared state between tests.
- Use REAL endpoint URLs and request bodies from the captured traffic below.
- For NEGATIVE tests: send malformed payloads, missing required fields, or wrong HTTP methods and assert the error response.

CODE REQUIREMENTS:
- Use \`request.newContext()\` for API calls, NOT \`page.goto()\`.
- Pattern: \`const api = await request.newContext({ baseURL: '${appUrl}' });\`
- For GET: \`const res = await api.get('/path');\`
- For POST: \`const res = await api.post('/path', { data: { ... } });\`
- Always assert: \`expect(res.status()).toBe(200);\`
- For JSON responses: \`const body = await res.json(); expect(body).toHaveProperty('key');\`
- When checking field types, use the ACTUAL type the API returns. If the API returns a JSON object for a field, check for \`"object"\`, not \`"string"\`. Only assert \`"string"\` when the API genuinely returns a string value.
- Always call \`await api.dispose();\` at the end of each test to clean up the API context.
- playwrightCode must be fully self-contained and executable.
- Import pattern: \`import { test, expect } from '@playwright/test';\`

FORBIDDEN — NEVER generate any of these in API tests:
- \`page.goto()\`, \`page.click()\`, or any \`page.*\` method — there is NO browser page in API tests.
- \`expect(page)\` or \`expect(page).toHaveURL()\` — the \`page\` object does not exist.
- \`page.waitForLoadState()\` or \`page.waitForSelector()\` — these are browser-only.
- Any reference to \`page\`, \`context\`, or \`browser\` variables — API tests only use \`request\`.

${buildCapabilityCoverageBlock({ mode: "api", tier: local ? "local" : "cloud" })}

PROMPT VERSION: ${PROMPT_VERSION}`;

  // Build endpoint descriptions for the user message
  const endpointBlocks = endpoints.slice(0, local ? 10 : 20).map(ep => {
    const lines = [
      `  ${ep.method} ${ep.pathPattern}`,
      `    Observed ${ep.callCount}x | Status codes: ${ep.statuses.join(", ")} | Avg: ${ep.avgDurationMs}ms`,
      `    Example URL: ${ep.exampleUrls[0] || "N/A"}`,
    ];
    if (ep.requestBodyExample) {
      lines.push(`    Request body: ${formatJsonExample(ep.requestBodyExample)}`);
    }
    if (ep.responseBodyExample) {
      lines.push(`    Response body: ${formatJsonExample(ep.responseBodyExample)}`);
    }
    if (ep.pageUrls.length > 0) {
      lines.push(`    Triggered from: ${ep.pageUrls.join(", ")}`);
    }
    return lines.join("\n");
  }).join("\n\n");

  const user = `APPLICATION: ${appUrl}

DISCOVERED API ENDPOINTS (captured during live crawl):
${endpointBlocks || "  No API endpoints discovered."}

SUMMARY:
- ${gets.length} GET endpoints (data fetching)
- ${mutates.length} mutation endpoints (POST/PUT/PATCH/DELETE)
- ${endpoints.length} total unique endpoint patterns

REQUIRED TEST COVERAGE:
${testCountInstr} covering:
- POSITIVE: Each GET endpoint returns expected status (200) and valid JSON shape
- POSITIVE: POST/PUT endpoints accept valid payloads and return success
- NEGATIVE: Endpoints return appropriate error codes (400/401/404) for invalid requests
- ERROR PAYLOADS: Some APIs return HTTP 200 with error bodies instead of proper status codes — for POSITIVE tests assert the response body does NOT contain "error" or "message" failure indicators; for NEGATIVE tests assert the error payload structure (error field present, message is a non-empty string)
- CONTRACT: Response bodies match the observed JSON structure (required fields present)
- EDGE: Empty request bodies, missing content-type headers, wrong HTTP methods

STRICT RULES:
1. ${testCountInstr}
2. Use Playwright request API context — NOT page.goto() or browser navigation
3. Base URL: '${appUrl}' — use REAL paths from the discovered endpoints above
4. Every test must have at least 2 assertions (status code + response body/shape)
5. For POST/PUT tests, use the observed request body as a template
6. For POSITIVE tests, always verify the response body has NO error indicators: expect(body.error).toBeUndefined()
7. When example response bodies are provided, assert the EXACT structure — verify all top-level keys exist using toHaveProperty()
8. NEVER use \`page\`, \`expect(page)\`, \`page.goto()\`, or any browser API — this is an API-only test
9. When asserting typeof on response fields, match the ACTUAL type from the example response (object, string, number, boolean) — do NOT assume string for fields that are objects

Return ONLY valid JSON (no markdown, no code fences):
{
  "tests": [
    {
      "name": "API: descriptive name including endpoint and scenario",
      "description": "what this API test validates",
      "priority": "high|medium",
      "type": "integration",
      "scenario": "positive|negative|edge_case",
      "steps": [
        "Send GET request to /api/endpoint",
        "Verify response status is 200",
        "Verify response body contains expected fields"
      ],
      "playwrightCode": "import { test, expect } from '@playwright/test';\\n\\ntest('API: ...', async ({ request }) => {\\n  const api = await request.newContext({ baseURL: '${appUrl}' });\\n  const res = await api.get('/path');\\n  expect(res.status()).toBe(200);\\n  const body = await res.json();\\n  expect(body).toHaveProperty('key');\\n  await api.dispose();\\n});"
    }
  ]
}`;

  return { system, user };
}