src/lib/e2e-export/framework/pest-generator.ts
Architecture violations
- [warning] max-cyclomatic-complexity: 'assertionLine' has cyclomatic complexity 24 (max 10)
- [warning] max-cyclomatic-complexity: 'generate' has cyclomatic complexity 12 (max 10)
- [warning] max-cyclomatic-complexity: 'recordedStepLine' has cyclomatic complexity 32 (max 10)
- [warning] max-cyclomatic-complexity: 'generateFromRecording' has cyclomatic complexity 18 (max 10)
Symbols by Kind
function
8
All Symbols
| Name | Kind | Visibility | Status | Lines | Signature |
|---|---|---|---|---|---|
| escapeString | function | - | 21-23 | escapeString(value: string): : string |
|
| resolveSelector | function | - | 25-33 | resolveSelector(
action: CapturedAction,
useSmartSelectors: boolean,
): : string |
|
| actionLine | function | - | 35-60 | actionLine(
action: CapturedAction,
useSmartSelectors: boolean,
): : string |
|
| assertionLine | function | - | 62-93 | assertionLine(assertion: E2EAssertion): : string |
|
| generateNegativeTest | function | - | 95-135 | generateNegativeTest(
actions: CapturedAction[],
options: E2EGenerateOptions,
useSmartSelectors: boolean,
): : string |
|
| generate | function | - | 137-197 | generate(
actions: CapturedAction[],
options?: E2EGenerateOptions,
): : string |
|
| recordedStepLine | function | - | 203-250 | recordedStepLine(
step: RecordedStep,
useSmartSelectors: boolean,
): : string |
|
| generateFromRecording | function | - | 252-313 | generateFromRecording(
steps: RecordedStep[],
options?: RecordingGenerateOptions,
): : string |
Full Source
/**
* Pest (Laravel Dusk) E2E code generator.
*
* Converts captured form-fill actions into a Pest 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)
*/
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 ` ->type('${sel}', '${val}')${comment}`;
case "check":
return ` ->check('${sel}')${comment}`;
case "uncheck":
return ` ->uncheck('${sel}')${comment}`;
case "select":
return ` ->select('${sel}', '${val}')${comment}`;
case "radio":
return ` ->radio('${sel}', '${val}')${comment}`;
case "clear":
return ` ->clear('${sel}')${comment}`;
case "click":
case "submit":
return ` ->click('${sel}')${comment}`;
}
}
function assertionLine(assertion: E2EAssertion): string {
switch (assertion.type) {
case "url-changed":
return ` ->assertUrlIsNot('${escapeString(assertion.expected ?? "")}')`;
case "url-contains":
return ` ->assertPathContains('${escapeString(assertion.expected ?? "")}')`;
case "visible-text":
return assertion.expected
? ` ->assertSee('${escapeString(assertion.expected)}')`
: ` // Expect visible validation feedback`;
case "element-visible":
return ` ->assertVisible('${escapeString(assertion.selector ?? "")}')`;
case "element-hidden":
return ` ->assertMissing('${escapeString(assertion.selector ?? "")}')`;
case "toast-message":
return ` ->assertVisible('${escapeString(assertion.selector ?? "[role=\\'alert\\']")}')`;
case "field-value":
return ` ->assertInputValue('${escapeString(assertion.selector ?? "")}', '${escapeString(assertion.expected ?? "")}')`;
case "field-error":
return ` ->assertVisible('${escapeString(assertion.selector ?? "")}')`;
case "redirect":
return ` ->assertPathContains('${escapeString(assertion.expected ?? "")}')`;
case "response-ok": {
const url = escapeString(assertion.selector ?? "");
const status = assertion.expected ?? "200";
return [
` // HTTP response assertion: ${assertion.description ?? `${url} \u2192 ${status}`}`,
` // $browser->assertStatus(${status}); // use after visiting the response URL directly`,
].join("\n");
}
}
}
function generateNegativeTest(
actions: CapturedAction[],
options: E2EGenerateOptions,
useSmartSelectors: boolean,
): string {
const requiredActions = actions.filter((a) => a.required);
if (requiredActions.length === 0) return "";
const visitLine = options.pageUrl
? ` ->visit('${escapeString(options.pageUrl)}')\n`
: "";
const submitAction = actions.find(
(a) => a.actionType === "click" || a.actionType === "submit",
);
const submitLine = submitAction
? ` ->click('${escapeString(resolveSelector(submitAction, useSmartSelectors))}')\n`
: "";
const assertionLines = (options.assertions ?? [])
.filter((a) => a.type === "field-error")
.map(assertionLine);
return [
``,
`test('should show validation errors for empty required fields', function () {`,
` $this->browse(function (Browser $browser) {`,
` $browser`,
visitLine +
` // Leave required fields empty and submit\n` +
submitLine,
...assertionLines,
...requiredActions.map((a) => {
const sel = escapeString(resolveSelector(a, useSmartSelectors));
return ` ->assertAttribute('${sel}', 'required', '')`;
}),
` ;`,
` });`,
`});`,
].join("\n");
}
function generate(
actions: CapturedAction[],
options?: E2EGenerateOptions,
): string {
const opts = options ?? {};
const testName = opts.testName ?? "fill form";
const useSmartSelectors = opts.useSmartSelectors !== false;
const visitLine = opts.pageUrl
? ` ->visit('${escapeString(opts.pageUrl)}')\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 chain = [visitLine + fillLines.join("\n")];
if (submitLines.length > 0) {
chain.push("\n // Submit");
chain.push(submitLines.join("\n"));
}
if (assertionLines.length > 0) {
chain.push(assertionLines.join("\n"));
}
const parts = [
`<?php`,
``,
`use Laravel\\Dusk\\Browser;`,
``,
`test('${escapeString(testName)}', function () {`,
` $this->browse(function (Browser $browser) {`,
` $browser`,
chain.join("\n"),
` ;`,
` });`,
`});`,
];
if (opts.includeNegativeTest) {
const negativeTest = generateNegativeTest(actions, opts, useSmartSelectors);
if (negativeTest) parts.push(negativeTest);
}
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 ` ->visit('${escapeString(step.url ?? "")}')${comment}`;
case "fill":
return ` ->type('${sel}', '${val}')${comment}`;
case "click":
return ` ->click('${sel}')${comment}`;
case "select":
return ` ->select('${sel}', '${val}')${comment}`;
case "check":
return ` ->check('${sel}')${comment}`;
case "uncheck":
return ` ->uncheck('${sel}')${comment}`;
case "clear":
return ` ->clear('${sel}')${comment}`;
case "submit":
return ` ->click('${sel}')${comment}`;
case "hover":
return ` ->mouseover('${sel}')${comment}`;
case "press-key":
return ` ->keys('${sel}', '{${(step.key ?? "").toLowerCase()}}')${comment}`;
case "wait-for-element":
return ` ->waitFor('${sel}', ${Math.round((step.waitTimeout ?? 5000) / 1000)})${comment}`;
case "wait-for-hidden":
return ` ->waitUntilMissing('${sel}', ${Math.round((step.waitTimeout ?? 10000) / 1000)})${comment}`;
case "wait-for-url":
return ` ->waitForLocation('${escapeString(step.url ?? step.value ?? "")}')${comment}`;
case "wait-for-network-idle":
return ` ->pause(1000)${comment}`;
case "scroll":
return ` ->script('window.scrollTo(${step.scrollPosition?.x ?? 0}, ${step.scrollPosition?.y ?? 0})')${comment}`;
case "assert":
return step.assertion
? assertionLine(step.assertion)
: ` // assert${comment}`;
}
}
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];
if (step.type === "scroll" && !opts.includeScrollSteps) continue;
if (step.type === "hover" && !opts.includeHoverSteps) continue;
if (i === 0 && step.type === "navigate" && opts.pageUrl) continue;
if (i > 0) {
const delta = step.timestamp - steps[i - 1].timestamp;
if (delta >= minWait) {
lines.push(
` ->pause(${delta}) // User paused for ~${Math.round(delta / 1000)}s`,
);
}
}
lines.push(recordedStepLine(step, useSmartSelectors));
}
const visitLine = opts.pageUrl
? ` ->visit('${escapeString(opts.pageUrl)}')\n`
: "";
const assertionLines =
opts.includeAssertions && opts.assertions?.length
? ["\n // Assertions", ...opts.assertions.map(assertionLine)]
: [];
const chain = [visitLine + lines.join("\n")];
if (assertionLines.length > 0) {
chain.push(assertionLines.join("\n"));
}
const parts = [
`<?php`,
``,
`use Laravel\\Dusk\\Browser;`,
``,
`test('${escapeString(testName)}', function () {`,
` $this->browse(function (Browser $browser) {`,
` $browser`,
chain.join("\n"),
` ;`,
` });`,
`});`,
``,
];
return parts.join("\n");
}
export const pestGenerator: E2EGenerator = {
name: "pest",
displayName: "Pest (Laravel Dusk)",
generate,
generateFromRecording,
};