src/lib/e2e-export/framework/playwright-generator.ts

Total Symbols
11
Lines of Code
394
Avg Complexity
11.0
Avg Coverage
97.4%

File Relationships

graph LR actionLine["actionLine"] escapeString["escapeString"] resolveSelector["resolveSelector"] assertionLine["assertionLine"] generatePOM["generatePOM"] toCamelCase["toCamelCase"] generateNegativeTest["generateNegativeTest"] generate["generate"] recordedStepLine["recordedStepLine"] generateFromRecording["generateFromRecording"] shouldInsertDelay["shouldInsertDelay"] actionLine -->|calls| escapeString actionLine -->|calls| resolveSelector assertionLine -->|calls| escapeString generatePOM -->|calls| escapeString generatePOM -->|calls| resolveSelector generatePOM -->|calls| toCamelCase generateNegativeTest -->|calls| escapeString generateNegativeTest -->|calls| resolveSelector generate -->|calls| escapeString generate -->|calls| actionLine generate -->|calls| generateNegativeTest generate -->|calls| generatePOM recordedStepLine -->|calls| escapeString recordedStepLine -->|calls| assertionLine generateFromRecording -->|calls| shouldInsertDelay generateFromRecording -->|calls| recordedStepLine generateFromRecording -->|calls| escapeString click actionLine "../symbols/1971834c4f9bc9c3.html" click escapeString "../symbols/5c2f25e3b4548c2d.html" click resolveSelector "../symbols/361a4021ea67e76c.html" click assertionLine "../symbols/0f28f576e085ec59.html" click generatePOM "../symbols/7423c874e7c3dfa3.html" click toCamelCase "../symbols/d9182ff869942bb8.html" click generateNegativeTest "../symbols/764ef404a760ebd4.html" click generate "../symbols/a1352a91ca2859ed.html" click recordedStepLine "../symbols/b05ab4bbb41f5984.html" click generateFromRecording "../symbols/bc12aadd5ae3e7fb.html" click shouldInsertDelay "../symbols/7d9c1d11ee65d3a9.html"

Architecture violations

View all

  • [warning] max-cyclomatic-complexity: 'assertionLine' has cyclomatic complexity 25 (max 10)
  • [warning] max-cyclomatic-complexity: 'generatePOM' has cyclomatic complexity 11 (max 10)
  • [warning] max-cyclomatic-complexity: 'generate' has cyclomatic complexity 13 (max 10)
  • [warning] max-cyclomatic-complexity: 'recordedStepLine' has cyclomatic complexity 31 (max 10)
  • [warning] max-cyclomatic-complexity: 'generateFromRecording' has cyclomatic complexity 18 (max 10)

Symbols by Kind

function 11

All Symbols

Name Kind Visibility Status Lines Signature
escapeString function - 22-24 escapeString(value: string): : string
resolveSelector function - 26-34 resolveSelector( action: CapturedAction, useSmartSelectors: boolean, ): : string
actionLine function - 36-61 actionLine( action: CapturedAction, useSmartSelectors: boolean, ): : string
assertionLine function - 63-98 assertionLine(assertion: E2EAssertion): : string
generatePOM function - 100-155 generatePOM( actions: CapturedAction[], useSmartSelectors: boolean, ): : string
generateNegativeTest function - 157-194 generateNegativeTest( actions: CapturedAction[], options: E2EGenerateOptions, useSmartSelectors: boolean, ): : string
toCamelCase function - 196-201 toCamelCase(text: string): : string
generate function - 203-265 generate( actions: CapturedAction[], options?: E2EGenerateOptions, ): : string
recordedStepLine function - 271-320 recordedStepLine( step: RecordedStep, useSmartSelectors: boolean, ): : string
shouldInsertDelay function - 322-329 shouldInsertDelay( current: RecordedStep, previous: RecordedStep, threshold: number, ): : number | null
generateFromRecording function - 331-386 generateFromRecording( steps: RecordedStep[], options?: RecordingGenerateOptions, ): : string

Full Source

/**
 * Playwright E2E code generator.
 *
 * Converts captured form-fill actions into a Playwright test script with:
 *   - Smart selectors (data-testid > aria-label > role > name > css)
 *   - Submit button click
 *   - Assertions (URL change, success elements, redirects)
 *   - Negative test generation (empty required fields)
 *   - Page Object Model (POM) class generation
 */

import type {
  CapturedAction,
  E2EAssertion,
  E2EGenerateOptions,
  E2EGenerator,
  RecordedStep,
  RecordingGenerateOptions,
} from "../e2e-export.types";
import { pickBestSelector } from "../smart-selector";

function escapeString(value: string): string {
  return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
}

function resolveSelector(
  action: CapturedAction,
  useSmartSelectors: boolean,
): string {
  if (useSmartSelectors && action.smartSelectors?.length) {
    return pickBestSelector(action.smartSelectors, action.selector);
  }
  return action.selector;
}

function actionLine(
  action: CapturedAction,
  useSmartSelectors: boolean,
): string {
  const sel = escapeString(resolveSelector(action, useSmartSelectors));
  const val = escapeString(action.value);
  const comment = action.label ? `  // ${action.label}` : "";

  switch (action.actionType) {
    case "fill":
      return `  await page.locator('${sel}').fill('${val}');${comment}`;
    case "check":
      return `  await page.locator('${sel}').check();${comment}`;
    case "uncheck":
      return `  await page.locator('${sel}').uncheck();${comment}`;
    case "select":
      return `  await page.locator('${sel}').selectOption('${val}');${comment}`;
    case "radio":
      return `  await page.locator('${sel}').check();${comment}`;
    case "clear":
      return `  await page.locator('${sel}').clear();${comment}`;
    case "click":
    case "submit":
      return `  await page.locator('${sel}').click();${comment}`;
  }
}

function assertionLine(assertion: E2EAssertion): string {
  switch (assertion.type) {
    case "url-changed":
      return `  await expect(page).not.toHaveURL('${escapeString(assertion.expected ?? "")}');`;
    case "url-contains":
      return `  await expect(page).toHaveURL(new RegExp('${escapeString(assertion.expected ?? "")}'));`;
    case "visible-text":
      return assertion.expected
        ? `  await expect(page.getByText('${escapeString(assertion.expected)}')).toBeVisible();`
        : `  // Expect visible validation feedback`;
    case "element-visible":
      return `  await expect(page.locator('${escapeString(assertion.selector ?? "")}')).toBeVisible();`;
    case "element-hidden":
      return `  await expect(page.locator('${escapeString(assertion.selector ?? "")}')).toBeHidden();`;
    case "toast-message":
      return `  await expect(page.locator('${escapeString(assertion.selector ?? "[role=\\'alert\\']")}')).toBeVisible();`;
    case "field-value":
      return `  await expect(page.locator('${escapeString(assertion.selector ?? "")}')).toHaveValue('${escapeString(assertion.expected ?? "")}');`;
    case "field-error":
      return `  await expect(page.locator('${escapeString(assertion.selector ?? "")}')).toBeVisible();`;
    case "redirect":
      return `  await expect(page).toHaveURL(new RegExp('${escapeString(assertion.expected ?? "")}'));`;
    case "response-ok": {
      const url = escapeString(assertion.selector ?? "");
      const status = assertion.expected ?? "200";
      const urlFragment = url.split("/").pop() ?? url;
      return [
        `  // HTTP response assertion: ${assertion.description ?? `${url} → ${status}`}`,
        `  // To assert strictly, add before the submit action:`,
        `  //   const responsePromise = page.waitForResponse(r => r.url().includes('${urlFragment}') && r.status() === ${status});`,
        `  //   const response = await responsePromise;`,
        `  //   expect(response.status()).toBe(${status});`,
      ].join("\n");
    }
  }
}

function generatePOM(
  actions: CapturedAction[],
  useSmartSelectors: boolean,
): string {
  const fieldLines = actions
    .filter((a) => a.actionType !== "click" && a.actionType !== "submit")
    .map((a) => {
      const sel = escapeString(resolveSelector(a, useSmartSelectors));
      const propName = toCamelCase(a.label ?? a.fieldType ?? "field");
      return `  get ${propName}() { return this.page.locator('${sel}'); }`;
    });

  const submitAction = actions.find(
    (a) => a.actionType === "click" || a.actionType === "submit",
  );
  const submitLine = submitAction
    ? `  get submitButton() { return this.page.locator('${escapeString(resolveSelector(submitAction, useSmartSelectors))}'); }`
    : "";

  const fillLines = actions
    .filter((a) => a.actionType === "fill")
    .map((a) => {
      const propName = toCamelCase(a.label ?? a.fieldType ?? "field");
      return `    await this.${propName}.fill(${propName}Value);`;
    });

  const fillParams = actions
    .filter((a) => a.actionType === "fill")
    .map((a) => {
      const propName = toCamelCase(a.label ?? a.fieldType ?? "field");
      return `${propName}Value: string`;
    })
    .join(", ");

  return [
    `import type { Page } from '@playwright/test';`,
    ``,
    `export class FormPage {`,
    `  constructor(private readonly page: Page) {}`,
    ``,
    ...fieldLines,
    submitLine,
    ``,
    `  async fillForm(${fillParams}) {`,
    ...fillLines,
    `  }`,
    ``,
    submitAction
      ? `  async submit() {\n    await this.submitButton.click();\n  }`
      : "",
    `}`,
    ``,
  ]
    .filter(Boolean)
    .join("\n");
}

function generateNegativeTest(
  actions: CapturedAction[],
  options: E2EGenerateOptions,
  useSmartSelectors: boolean,
): string {
  const requiredActions = actions.filter((a) => a.required);
  if (requiredActions.length === 0) return "";

  const urlLine = options.pageUrl
    ? `  await page.goto('${escapeString(options.pageUrl)}');\n\n`
    : "";

  const submitAction = actions.find(
    (a) => a.actionType === "click" || a.actionType === "submit",
  );

  const submitLine = submitAction
    ? `\n  await page.locator('${escapeString(resolveSelector(submitAction, useSmartSelectors))}').click();`
    : "";

  const assertionLines = (options.assertions ?? [])
    .filter((a) => a.type === "field-error")
    .map(assertionLine);

  return [
    ``,
    `test('should show validation errors for empty required fields', async ({ page }) => {`,
    urlLine + `  // Leave required fields empty and submit` + submitLine,
    ``,
    ...assertionLines,
    `  // Required fields should show validation`,
    ...requiredActions.map((a) => {
      const sel = escapeString(resolveSelector(a, useSmartSelectors));
      return `  await expect(page.locator('${sel}')).toHaveAttribute('required', '');`;
    }),
    `});`,
  ].join("\n");
}

function toCamelCase(text: string): string {
  return text
    .replace(/[^a-zA-Z0-9]+(.)/g, (_, c: string) => c.toUpperCase())
    .replace(/^[A-Z]/, (c) => c.toLowerCase())
    .replace(/[^a-zA-Z0-9]/g, "");
}

function generate(
  actions: CapturedAction[],
  options?: E2EGenerateOptions,
): string {
  const opts = options ?? {};
  const testName = opts.testName ?? "fill form";
  const useSmartSelectors = opts.useSmartSelectors !== false;
  const urlLine = opts.pageUrl
    ? `  await page.goto('${escapeString(opts.pageUrl)}');\n\n`
    : "";

  const fillActions = actions.filter(
    (a) => a.actionType !== "click" && a.actionType !== "submit",
  );
  const submitActions = actions.filter(
    (a) => a.actionType === "click" || a.actionType === "submit",
  );

  const fillLines = fillActions.map((a) => actionLine(a, useSmartSelectors));
  const submitLines = submitActions.map((a) =>
    actionLine(a, useSmartSelectors),
  );

  const assertionLines =
    opts.includeAssertions && opts.assertions?.length
      ? ["\n  // Assertions", ...opts.assertions.map(assertionLine)]
      : [];

  const parts = [
    `import { test, expect } from '@playwright/test';`,
    ``,
    `test('${escapeString(testName)}', async ({ page }) => {`,
    urlLine + fillLines.join("\n"),
  ];

  if (submitLines.length > 0) {
    parts.push("");
    parts.push("  // Submit");
    parts.push(submitLines.join("\n"));
  }

  if (assertionLines.length > 0) {
    parts.push(assertionLines.join("\n"));
  }

  parts.push(`});`);

  // Negative test
  if (opts.includeNegativeTest) {
    const negativeTest = generateNegativeTest(actions, opts, useSmartSelectors);
    if (negativeTest) parts.push(negativeTest);
  }

  // POM
  if (opts.includePOM) {
    parts.push("");
    parts.push("// --- Page Object Model ---");
    parts.push(generatePOM(actions, useSmartSelectors));
  }

  parts.push("");
  return parts.join("\n");
}

// ---------------------------------------------------------------------------
// Recording-based generation
// ---------------------------------------------------------------------------

function recordedStepLine(
  step: RecordedStep,
  useSmartSelectors: boolean,
): string {
  const sel =
    step.smartSelectors?.length && useSmartSelectors
      ? escapeString(pickBestSelector(step.smartSelectors, step.selector ?? ""))
      : escapeString(step.selector ?? "");
  const val = escapeString(step.value ?? "");
  const comment = step.label ? `  // ${step.label}` : "";

  switch (step.type) {
    case "navigate":
      return `  await page.goto('${escapeString(step.url ?? "")}');${comment}`;
    case "fill":
      return `  await page.locator('${sel}').fill('${val}');${comment}`;
    case "click":
      return `  await page.locator('${sel}').click();${comment}`;
    case "select":
      return `  await page.locator('${sel}').selectOption('${val}');${comment}`;
    case "check":
      return `  await page.locator('${sel}').check();${comment}`;
    case "uncheck":
      return `  await page.locator('${sel}').uncheck();${comment}`;
    case "clear":
      return `  await page.locator('${sel}').clear();${comment}`;
    case "submit":
      return `  await page.locator('${sel}').click();${comment}`;
    case "hover":
      return `  await page.locator('${sel}').hover();${comment}`;
    case "press-key":
      return `  await page.keyboard.press('${escapeString(step.key ?? "")}');${comment}`;
    case "wait-for-element":
      return `  await page.locator('${sel}').waitFor({ state: 'visible', timeout: ${step.waitTimeout ?? 5000} });${comment}`;
    case "wait-for-hidden":
      return `  await page.locator('${sel}').waitFor({ state: 'hidden', timeout: ${step.waitTimeout ?? 10000} });${comment}`;
    case "wait-for-url":
      return `  await page.waitForURL('${escapeString(step.url ?? step.value ?? "")}');${comment}`;
    case "wait-for-network-idle":
      return `  await page.waitForLoadState('networkidle');${comment}`;
    case "scroll":
      return step.scrollPosition
        ? `  await page.evaluate(() => window.scrollTo(${step.scrollPosition.x}, ${step.scrollPosition.y}));${comment}`
        : `  // scroll${comment}`;
    case "assert":
      return step.assertion
        ? assertionLine(step.assertion)
        : `  // assert${comment}`;
  }
}

function shouldInsertDelay(
  current: RecordedStep,
  previous: RecordedStep,
  threshold: number,
): number | null {
  const delta = current.timestamp - previous.timestamp;
  return delta >= threshold ? delta : null;
}

function generateFromRecording(
  steps: RecordedStep[],
  options?: RecordingGenerateOptions,
): string {
  const opts = options ?? {};
  const testName = opts.testName ?? "recorded test";
  const useSmartSelectors = opts.useSmartSelectors !== false;
  const minWait = opts.minWaitThreshold ?? 1000;

  const lines: string[] = [];

  for (let i = 0; i < steps.length; i++) {
    const step = steps[i];

    // Skip steps based on options
    if (step.type === "scroll" && !opts.includeScrollSteps) continue;
    if (step.type === "hover" && !opts.includeHoverSteps) continue;
    // Skip the initial navigate if it matches pageUrl (already in goto)
    if (i === 0 && step.type === "navigate" && opts.pageUrl) continue;

    // Insert explicit wait for significant pauses between actions
    if (i > 0) {
      const delay = shouldInsertDelay(step, steps[i - 1], minWait);
      if (delay !== null) {
        lines.push(`  // User paused for ~${Math.round(delay / 1000)}s`);
        lines.push(`  await page.waitForTimeout(${delay});`);
      }
    }

    lines.push(recordedStepLine(step, useSmartSelectors));
  }

  const urlLine = opts.pageUrl
    ? `  await page.goto('${escapeString(opts.pageUrl)}');\n\n`
    : "";

  const assertionLines =
    opts.includeAssertions && opts.assertions?.length
      ? ["\n  // Assertions", ...opts.assertions.map(assertionLine)]
      : [];

  const parts = [
    `import { test, expect } from '@playwright/test';`,
    ``,
    `test('${escapeString(testName)}', async ({ page }) => {`,
    urlLine + lines.join("\n"),
  ];

  if (assertionLines.length > 0) {
    parts.push(assertionLines.join("\n"));
  }

  parts.push(`});`);
  parts.push("");
  return parts.join("\n");
}

export const playwrightGenerator: E2EGenerator = {
  name: "playwright",
  displayName: "Playwright",
  generate,
  generateFromRecording,
};