Source: pipeline/stepSanitiser.js

/**
 * stepSanitiser.js — Converts Playwright code lines to human-readable steps
 *
 * Smaller LLMs (e.g. Mistral 7B) sometimes put Playwright code into the
 * "steps" array instead of plain-English descriptions.  This module detects
 * that and converts them.
 *
 * Exports:
 *   sanitiseSteps(tests) — mutates tests in-place, returns the array
 *   extractTestsArray(parsed) — normalises AI response shapes → array
 */

// ── Code detection ───────────────────────────────────────────────────────────

const CODE_PATTERNS = [
  /^\s*await\s+/,
  /^\s*page\./,
  /^\s*expect\s*\(/,
  /^\s*const\s+/,
  /^\s*let\s+/,
  /^\s*import\s+/,
  /^\s*test\s*\(/,
  /^\s*\/\//,
  /^\s*}\s*\)\s*;?\s*$/,
];

function looksLikeCode(step) {
  if (!step || typeof step !== "string") return false;
  return CODE_PATTERNS.some(re => re.test(step));
}

// ── Label extraction (for human-readable step conversion) ────────────────────

function extractLabel(code) {
  // getByRole('button', { name: 'Submit' })
  const roleMatch = code.match(/getByRole\s*\(\s*['"`][^'"`]*['"`]\s*,\s*\{[^}]*name\s*:\s*['"`]([^'"`]+)['"`]/);
  if (roleMatch) return roleMatch[1];
  // getByText('...')
  const textMatch = code.match(/getByText\s*\(\s*['"`]([^'"`]+)['"`]/);
  if (textMatch) return textMatch[1];
  // getByLabel('...')
  const labelMatch = code.match(/getByLabel\s*\(\s*['"`]([^'"`]+)['"`]/);
  if (labelMatch) return labelMatch[1];
  // getByPlaceholder('...')
  const phMatch = code.match(/getByPlaceholder\s*\(\s*['"`]([^'"`]+)['"`]/);
  if (phMatch) return phMatch[1];
  return null;
}

// ── Code → human step conversion ─────────────────────────────────────────────

/**
 * Convert a Playwright code line into a human-readable step description.
 * e.g. "await page.goto('https://example.com')" → "Navigate to https://example.com"
 */
function codeToHumanStep(code) {
  const s = code.trim();

  // page.goto
  const gotoMatch = s.match(/page\.goto\s*\(\s*['"`]([^'"`]+)['"`]/);
  if (gotoMatch) return `Navigate to ${gotoMatch[1]}`;

  // page.click / page.getByRole(...).click
  const clickMatch = s.match(/\.click\s*\(/);
  if (clickMatch) {
    const label = extractLabel(s);
    return label ? `Click "${label}"` : "Click element";
  }

  // page.fill / .fill
  const fillMatch = s.match(/\.fill\s*\(\s*['"`]?([^'"`),]*)['"`]?\s*,\s*['"`]([^'"`]*)['"`]/);
  if (fillMatch) return `Enter "${fillMatch[2]}" into ${fillMatch[1] || "field"}`;

  // expect(...).toBeVisible
  if (/toBeVisible/.test(s)) {
    const label = extractLabel(s);
    return label ? `Verify "${label}" is visible` : "Verify element is visible";
  }

  // expect(...).toHaveURL
  const urlMatch = s.match(/toHaveURL\s*\(\s*['"`]([^'"`]+)['"`]/);
  if (urlMatch) return `Verify URL is ${urlMatch[1]}`;

  // expect(...).toContainText
  const textMatch = s.match(/toContainText\s*\(\s*['"`]([^'"`]+)['"`]/);
  if (textMatch) return `Verify text "${textMatch[1]}" is present`;

  // page.waitForLoadState
  if (/waitForLoadState/.test(s)) return "Wait for page to load";

  // Generic fallback — strip await/page prefix and camelCase → words
  const stripped = s.replace(/^await\s+/, "").replace(/^page\./, "");
  const words = stripped.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/[()'"`;{}]/g, "").trim();
  return words.length > 80 ? words.slice(0, 77) + "…" : words || "Perform action";
}

// ── Public API ───────────────────────────────────────────────────────────────

/**
 * extractTestsArray(parsed) — normalise the 3 common AI response shapes into
 * a plain array of test objects:
 *   1. Already an array       → return as-is
 *   2. { tests: [...] }       → unwrap
 *   3. Single object { name } → wrap in array
 *   4. Anything else           → empty array
 */
export function extractTestsArray(parsed) {
  if (Array.isArray(parsed)) return parsed;
  if (parsed && Array.isArray(parsed.tests)) return parsed.tests;
  if (parsed && parsed.name) return [parsed];
  return [];
}

/**
 * sanitiseSteps(tests)
 * If a test's steps array contains Playwright code instead of human-readable
 * descriptions (common with smaller LLMs like Mistral 7B), convert them.
 */
export function sanitiseSteps(tests) {
  for (const t of tests) {
    if (!Array.isArray(t.steps) || t.steps.length === 0) continue;
    const codeCount = t.steps.filter(looksLikeCode).length;
    // If more than half the steps look like code, convert all of them
    if (codeCount > t.steps.length / 2) {
      t.steps = t.steps
        .filter(s => s && typeof s === "string" && s.trim())
        .filter(s => !/^\s*}\s*\)\s*;?\s*$/.test(s))           // drop closing braces
        .filter(s => !/^\s*import\s+/.test(s))                  // drop import lines
        .filter(s => !/^\s*test\s*\(/.test(s))                  // drop test(...) wrappers
        .map(s => looksLikeCode(s) ? codeToHumanStep(s) : s);
    }
  }
  return tests;
}