Source: utils/exportFormats.js

/**
 * @module utils/exportFormats
 * @description Enterprise test export format builders.
 *
 * Converts test objects into industry-standard formats for import into
 * external test management and CI tools.
 *
 * ### Supported formats
 * | Format       | Use case                                          |
 * |--------------|---------------------------------------------------|
 * | Zephyr CSV   | Zephyr Scale / Zephyr Squad test management       |
 * | TestRail CSV | TestRail bulk import                              |
 *
 * ### Exports
 * - {@link buildZephyrCsv} — Generate Zephyr Scale CSV from test array.
 * - {@link buildTestRailCsv} — Generate TestRail CSV from test array.
 */

// ── Zephyr Scale CSV ─────────────────────────────────────────────────────────
// Zephyr Scale (formerly TM4J) CSV import format for Jira.
// See: https://support.smartbear.com/zephyr-scale-cloud/docs/test-management/import-export/

/**
 * buildZephyrCsv(tests) → string (CSV)
 *
 * Produces a CSV compatible with Zephyr Scale's "Import Test Cases from CSV"
 * feature. Columns match the standard Zephyr Scale import mapping.
 *
 * @param {object[]} tests — array of test objects
 * @returns {string} CSV content ready for Zephyr Scale import
 */
export function buildZephyrCsv(tests) {
  function esc(v) { return `"${String(v ?? "").replace(/"/g, '""')}"`; }

  const headers = [
    "Name", "Objective", "Precondition", "Folder",
    "Status", "Priority", "Component", "Labels",
    "Test Script (Step-by-Step) - Step", "Test Script (Step-by-Step) - Test Data",
    "Test Script (Step-by-Step) - Expected Result",
    "Issue Links",
  ];

  const rows = [];
  for (const t of tests) {
    const steps = t.steps || [];
    const priorityMap = { high: "High", medium: "Normal", low: "Low" };
    const labels = [
      t.type || "functional",
      t.scenario || "positive",
      ...(t.tags || []),
      ...(t.isJourneyTest ? ["journey"] : []),
    ].join(" ");
    const folder = t.type
      ? `/${t.type.charAt(0).toUpperCase() + t.type.slice(1)}`
      : "/Functional";
    const status = t.reviewStatus === "approved" ? "Approved" : "Draft";

    if (steps.length === 0) {
      // Single row with no steps
      rows.push([
        esc(t.name),
        esc(t.description || ""),
        esc(t.preconditions || ""),
        esc(folder),
        esc(status),
        esc(priorityMap[t.priority] || "Normal"),
        esc(""),
        esc(labels),
        esc(""),
        esc(""),
        esc(""),
        esc(t.linkedIssueKey || ""),
      ].join(","));
    } else {
      // One row per step — Zephyr maps multiple rows with the same Name as one test case
      steps.forEach((step, idx) => {
        rows.push([
          esc(idx === 0 ? t.name : ""),
          esc(idx === 0 ? (t.description || "") : ""),
          esc(idx === 0 ? (t.preconditions || "") : ""),
          esc(idx === 0 ? folder : ""),
          esc(idx === 0 ? status : ""),
          esc(idx === 0 ? (priorityMap[t.priority] || "Normal") : ""),
          esc(""),
          esc(idx === 0 ? labels : ""),
          esc(step),
          esc(t.testData && idx === 0 ? JSON.stringify(t.testData) : ""),
          esc(idx === steps.length - 1 ? "Test completes successfully" : ""),
          esc(idx === 0 ? (t.linkedIssueKey || "") : ""),
        ].join(","));
      });
    }
  }

  return [headers.map(esc).join(","), ...rows].join("\n");
}

// ── TestRail CSV ─────────────────────────────────────────────────────────────
// TestRail bulk import expects a specific CSV format.
// See: https://www.gurock.com/testrail/docs/user-guide/howto/import-csv

/**
 * buildTestRailCsv(tests) → string (CSV)
 *
 * @param {object[]} tests
 * @returns {string} CSV content ready for TestRail import
 */
export function buildTestRailCsv(tests) {
  function esc(v) { return `"${String(v ?? "").replace(/"/g, '""')}"`; }

  const headers = ["Title", "Section", "Type", "Priority", "Preconditions", "Steps", "Expected Result", "References"];
  const rows = tests.map(t => {
    const steps = (t.steps || []).map((s, i) => `${i + 1}. ${s}`).join("\n");
    const expectedResult = t.steps?.length > 0 ? t.steps[t.steps.length - 1] : "";
    return [
      esc(t.name),
      esc(t.type || "Functional"),
      esc(t.isJourneyTest ? "End-to-End" : "Functional"),
      esc(t.priority === "high" ? "Critical" : t.priority === "low" ? "Low" : "Medium"),
      esc(t.preconditions || ""),
      esc(steps),
      esc(expectedResult),
      esc(t.linkedIssueKey || ""),
    ].join(",");
  });

  return [headers.map(esc).join(","), ...rows].join("\n");
}

/**
 * @typedef {Object} PlaywrightExportProject
 * @property {string} name
 * @property {string} [url]
 */

/**
 * buildPlaywrightZip(project, tests) → Promise<Buffer> (ZIP)
 *
 * @param {PlaywrightExportProject} project
 * @param {object[]} tests
 * @returns {Promise<Buffer>}
 */
export async function buildPlaywrightZip(project, tests) {
  const { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } = await import("fs");
  const { tmpdir } = await import("os");
  const path = await import("path");
  const { execFileSync } = await import("child_process");

  const tmpRoot = mkdtempSync(path.join(tmpdir(), "sentri-playwright-export-"));
  const projectRoot = path.join(tmpRoot, "project");
  const testsDir = path.join(projectRoot, "tests");
  const outPath = path.join(tmpRoot, "playwright-export.zip");
  mkdirSync(testsDir, { recursive: true });
  const baseUrl = project?.url || "http://localhost:3000";

  try {
    writeFileSync(path.join(projectRoot, "package.json"), JSON.stringify({
      name: "sentri-playwright-export",
      private: true,
      version: "1.0.0",
      scripts: { test: "playwright test" },
      devDependencies: { "@playwright/test": "^1.58.2" },
    }, null, 2));

    writeFileSync(path.join(projectRoot, "playwright.config.ts"), `import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  use: {
    baseURL: ${JSON.stringify(baseUrl)},
    trace: 'on-first-retry',
  },
});
`);

    writeFileSync(path.join(projectRoot, "README.md"), `# Playwright export from Sentri

## Run tests

\`\`\`bash
npm install
npx playwright test
\`\`\`
`);

    // Track filenames to disambiguate collisions when two tests normalize
    // to the same slug (e.g. "Login Test!" and "Login Test?"). Without this
    // the second writeFileSync silently overwrites the first.
    const usedNames = new Set();
    tests.forEach((testCase, idx) => {
      const baseSlug = String(testCase?.name || `test-${idx + 1}`)
        .toLowerCase()
        .replace(/[^a-z0-9]+/g, "-")
        .replace(/^-+|-+$/g, "") || `test-${idx + 1}`;
      let safeName = baseSlug;
      let suffix = 2;
      while (usedNames.has(safeName)) {
        safeName = `${baseSlug}-${suffix}`;
        suffix += 1;
      }
      usedNames.add(safeName);
      const rawCode = String(testCase?.playwrightCode || "").trim();
      // Accept any destructured fixture object that includes `page` — e.g.
      // `async ({ page })`, `async ({ page, context })`, `async ({ context, page, request })`.
      // The previous regex only matched the bare `{ page }` signature, which
      // caused any test recorded/generated with a broader fixture set to fall
      // through to the raw-source branch below, producing invalid nested
      // `test(...)` wrappers (and inlined `import` lines) in the output.
      const bodyMatch = rawCode.match(/test(?:\.only|\.skip)?\s*\([^]*?async\s*\(\s*\{[^}]*\bpage\b[^}]*\}\s*(?:,\s*[^)]*)?\)\s*=>\s*\{([^]*)\}\s*\)\s*;?\s*$/m);

      // Detect "already a complete Playwright test file" — both an `import …
      // from '@playwright/test'` line AND a `test(…)` call. If the extraction
      // regex couldn't pull a body but the raw code IS a full spec file (e.g.
      // unusual test wrapper syntax the regex doesn't handle: `async function`,
      // `test.describe` block, trailing comments after the closing paren), we
      // must NOT wrap it again — that produces an invalid .spec.ts with nested
      // `import` lines and a `test()` call inside another `test()`. Write the
      // raw source directly and let Playwright's own parser handle it.
      const hasPlaywrightImport = /import\s*\{[^}]*\b(?:test|expect)\b[^}]*\}\s*from\s*['"]@playwright\/test['"]/.test(rawCode);
      const hasTestCall = /\btest(?:\.only|\.skip|\.describe)?\s*\(/.test(rawCode);
      const isCompleteSpec = hasPlaywrightImport && hasTestCall;

      let fileContents;
      if (bodyMatch) {
        // Standard path: extract the body from a recognised `test(…, async ({ page, … }) => { … })`
        // wrapper and re-wrap with a canonical `{ page }` fixture. The canonical
        // wrapper is fine here because the body only references what it captured
        // from its own closure — `page` is always available, other fixtures
        // (context, request) pass through Playwright's test runner if referenced.
        const testBody = bodyMatch[1].trimEnd();
        const indentedBody = testBody.split("\n").map(line => `  ${line}`).join("\n");
        fileContents = `import { test, expect } from '@playwright/test';

test(${JSON.stringify(testCase?.name || `Test ${idx + 1}`)}, async ({ page }) => {
${indentedBody}
});
`;
      } else if (isCompleteSpec) {
        // Edge-case path: regex failed but rawCode is already a full spec file
        // (regex didn't recognise the wrapper shape — e.g. `async function`
        // expression, `test.describe` block, non-standard formatting). Ship
        // the file verbatim. Playwright's own parser is strictly more capable
        // than our regex; if the spec runs under `npx playwright test` in the
        // source project, it will run in the exported ZIP too.
        fileContents = rawCode.endsWith("\n") ? rawCode : `${rawCode}\n`;
      } else {
        // Raw-body path: rawCode is a naked body (no `import`, no `test()`
        // wrapper) or empty. Wrap it in the canonical shell so the exported
        // file is runnable.
        const testBody = rawCode || "  // No Playwright code available for this test.";
        const indentedBody = testBody.split("\n").map(line => `  ${line}`).join("\n");
        fileContents = `import { test, expect } from '@playwright/test';

test(${JSON.stringify(testCase?.name || `Test ${idx + 1}`)}, async ({ page }) => {
${indentedBody}
});
`;
      }
      writeFileSync(path.join(testsDir, `${safeName}.spec.ts`), fileContents);
    });

    try {
      execFileSync("zip", ["-rq", outPath, "."], { cwd: projectRoot });
    } catch (zipErr) {
      // Distinguish "zip binary not installed" from "zip ran but failed" so
      // the route handler can surface an actionable error. Without this,
      // both cases bubble up as opaque 500s and operators on minimal Docker
      // bases / Windows dev boxes have no way to know they're missing the
      // zip binary (documented in docs/api/tests.md but not self-evident).
      // ENOENT is what Node's execFileSync throws when the binary isn't on
      // $PATH. Every other failure is a genuine runtime error and keeps its
      // original message.
      if (zipErr.code === "ENOENT") {
        const err = new Error(
          "System `zip` binary not found on PATH. Install it (apt: `apt-get install zip`; alpine: `apk add zip`; macOS: included) or use a Docker image that ships it. See docs/api/tests.md § Standalone Playwright project ZIP for details."
        );
        err.code = "ZIP_BINARY_MISSING";
        throw err;
      }
      throw zipErr;
    }
    return readFileSync(outPath);
  } finally {
    // Always clean up the temp directory, even if zip/readFile threw.
    // Without this, every failed export leaks a temp dir under the OS tmp folder.
    rmSync(tmpRoot, { recursive: true, force: true });
  }
}