Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
f927a84
feat: implement createFlow function and FlowConfig interface
kriptonian1 Jan 17, 2026
ae7344c
feat: refactor clickonFlow to use createFlow
kriptonian1 Jan 17, 2026
7e6a2bd
feat: refactor clickOnFlow to extract clickOnLocator function
kriptonian1 Jan 17, 2026
6dbc866
feat: update FlowConfig interface comments for clarity on error and s…
kriptonian1 Jan 17, 2026
6391b7e
feat: refactor clickOnDescription and isDisableDescription for improv…
kriptonian1 Jan 17, 2026
24ac0f4
feat: refactor isTitleFlow to use createFlow for improved structure a…
kriptonian1 Jan 17, 2026
8ad9d46
feat: refactor isURLFlow to utilize createFlow for improved structure…
kriptonian1 Jan 17, 2026
8803fa6
feat: refactor isVisibleFlow to improve structure and readability
kriptonian1 Jan 17, 2026
678cb9e
feat: rename getMessage to setLoadingMessage for consistency across f…
kriptonian1 Jan 17, 2026
5a03252
feat: rename getSuccessMessage and getErrorMessage to setSuccessMessa…
kriptonian1 Jan 17, 2026
77509e3
feat: refactor keyboardFlow to use createFlow for improved structure …
kriptonian1 Jan 17, 2026
4309ca1
feat: refactor waitforFlow to use createFlow for improved structure a…
kriptonian1 Jan 18, 2026
6437691
feat: refactor inputFlow to improve structure and readability
kriptonian1 Jan 18, 2026
ad2b575
feat: update error handling in flows to return errors instead of thro…
kriptonian1 Jan 18, 2026
5683c13
feat: fix typo in isVisibleDescription function name and update refer…
kriptonian1 Jan 18, 2026
eae818b
feat: update error message in setErrorMessage for clarity
kriptonian1 Jan 18, 2026
245acfe
feat: refactor isNotVisibleFlow for improved structure and readability
kriptonian1 Jan 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 46 additions & 25 deletions src/execution-flow/flows/clickon-flow.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,59 @@
import { spinner } from "@clack/prompts";
import { ElementNotFoundError } from "@src/errors/workflow-error";
import type { BaseFlowParam } from "@type/base-flow.types";
import type { ClickAction } from "@type/workflow-config.types";
import chalk from "chalk";
import type { Locator } from "playwright";
import { createFlow } from "./create-flow";

export default async function clickonFlow({
step: clickOnStep,
page,
}: BaseFlowParam<ClickAction>): Promise<void> {
const clickSpinner = spinner();
function clickOnDescription(step: ClickAction) {
let targetDescription: `text: ${string}` | `selector: ${string}`;
if (typeof step.clickOn === "string") {
targetDescription = `text: ${step.clickOn}`;
} else {
targetDescription = `selector: ${step.clickOn.selector}`;
}
return targetDescription;
}

function clickOnLocator({ page, step }: BaseFlowParam<ClickAction>): Locator {
let targetLocator: Locator;
let targetDescription: `text: ${string}` | `selector: ${string}`;

if (typeof clickOnStep.clickOn === "string") {
targetLocator = page.getByText(clickOnStep.clickOn).first();
targetDescription = `text: ${clickOnStep.clickOn}`;
if (typeof step.clickOn === "string") {
targetLocator = page.getByText(step.clickOn).first();
} else {
targetLocator = page.locator(clickOnStep.clickOn.selector);
targetDescription = `selector: ${clickOnStep.clickOn.selector}`;
targetLocator = page.locator(step.clickOn.selector);
}

clickSpinner.start(`Clicking on element with ${targetDescription}`);
return targetLocator;
}

const clickOnFlow = createFlow<ClickAction>({
action: "clickOn",

setLoadingMessage: (step) => {
const targetDescription = clickOnDescription(step);
return `Clicking on element with ${targetDescription}`;
},

execute: async (params) => {
const targetLocator = clickOnLocator(params);

try {
await targetLocator.click();
clickSpinner.stop(
`${chalk.green("✓")} Clicked on element with ${targetDescription}`,
);
} catch {
clickSpinner.stop(
`${chalk.red("✖")} clickOn: Unable to find element with ${targetDescription}`,
);
throw new ElementNotFoundError(`Element missing: ${targetDescription}`);
}
}
},

setSuccessMessage: (step) => {
const targetDescription = clickOnDescription(step);
return `Clicked on element with ${targetDescription}`;
},

setErrorMessage: (step) => {
const targetDescription = clickOnDescription(step);
return `clickOn: Unable to find element with ${targetDescription}`;
},

onError: (_, step) => {
const targetDescription = clickOnDescription(step);
return new ElementNotFoundError(`Element missing: ${targetDescription}`);
},
});

export default clickOnFlow;
60 changes: 60 additions & 0 deletions src/execution-flow/flows/create-flow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { spinner } from "@clack/prompts";
import type { BaseFlowParam } from "@type/base-flow.types";
import type { FlowStep } from "@type/workflow-config.types";
import chalk from "chalk";
import type { StepKeys } from "../flow-registry";

export interface FlowConfig<T extends FlowStep> {
/** The action name (e.g., 'clickOn', 'input') */
action: StepKeys;

/** Generate descriptive message for spinner */
setLoadingMessage: (step: T) => string;

/** Core execution logic */
execute: (params: BaseFlowParam<T>) => Promise<void>;

/** Custom error handler */
onError?: (error: unknown, step: T) => Error;

/** Custom error message for stop spinner (defaults to getMessage) */
setErrorMessage?: (step: T) => string;

/** Custom success message for stop spinner (defaults to getMessage) */
setSuccessMessage?: (step: T) => string;
}

export function createFlow<T extends FlowStep>({
action,
execute,
setLoadingMessage: getMessage,
setSuccessMessage: getSuccessMessage,
onError,
setErrorMessage: getErrorMessage,
}: FlowConfig<T>) {
return async ({ page, step }: BaseFlowParam<T>): Promise<void> => {
const flowSpinner = spinner();
const message = getMessage(step);

flowSpinner.start(message);

try {
await execute({ step, page });

const successMsg = getSuccessMessage ? getSuccessMessage(step) : message;

flowSpinner.stop(`${chalk.green("✓")} ${successMsg}`);
} catch (error) {
const errorMsg = getErrorMessage
? getErrorMessage(step)
: `${action}: ${message}`;
flowSpinner.stop(`${chalk.red("✖")} ${errorMsg}`);

if (onError) {
throw onError(error, step);
}

throw error;
}
};
}
163 changes: 102 additions & 61 deletions src/execution-flow/flows/input-flow.ts
Original file line number Diff line number Diff line change
@@ -1,68 +1,109 @@
import { spinner } from "@clack/prompts";
import type { BaseFlowParam } from "@type/base-flow.types";
import type { Role } from "@type/role.types";
import type { InputAction } from "@type/workflow-config.types";
import chalk from "chalk";
import type { Locator } from "playwright";
import { createFlow } from "./create-flow";

export default async function inputFlow({
step: inputStep,
page,
}: BaseFlowParam<InputAction>): Promise<void> {
const inputSelector = inputStep.input.selector;
const inputSpinner = spinner();
inputSpinner.start(`Filling input: ${inputSelector}`);
if (inputSelector.startsWith("label:")) {
const labelText = inputSelector.replace("label:", "").trim();
await page
.getByLabel(labelText)
.fill(inputStep.input.value)
.then(() => {
inputSpinner.stop(
`${chalk.green("✓")} Filled input for label: ${labelText}`,
);
});
} else if (inputSelector.startsWith("testID:")) {
const id = inputSelector.replace("testID:", "").trim();
await page
.getByTestId(id)
.fill(inputStep.input.value)
.then(() => {
inputSpinner.stop(`${chalk.green("✓")}Filled input for testID: ${id}`);
});
} else if (inputSelector.startsWith("placeholder:")) {
const placeholderText = inputSelector.replace("placeholder:", "").trim();
await page
.getByPlaceholder(placeholderText)
.fill(inputStep.input.value)
.then(() => {
inputSpinner.stop(
`${chalk.green(
"✓",
)} Filled input for placeholder: ${placeholderText}`,
);
});
} else if (inputSelector.startsWith("role:")) {
const roleAndName = inputSelector.replace("role:", "").trim();
const [role, ...nameParts] = roleAndName.split(" ");
const name = nameParts.join(" ").replace('name="', "").replace('"', "");
await page
.getByRole(role as Role, { name })
.fill(inputStep.input.value)
.then(() => {
inputSpinner.stop(
`${chalk.green(
"✓",
)} Filled input for role: ${role} and name: ${name}`,
);
});
} else {
await page
.locator(inputSelector)
.fill(inputStep.input.value)
.then(() => {
inputSpinner.stop(
`${chalk.green("✓")} Filled input for selector: ${inputSelector}`,
);
type Action = "label" | "testID" | "placeholder" | "role" | "selector";

type InputValueProp = {
action: Action;
step: InputAction;
};

type InputValue =
| { action: Exclude<Action, "role">; value: string }
| {
action: Extract<Action, "role">;
value: { role: string; name: string };
};

function inputValue({ action, step }: InputValueProp): InputValue {
const inputSelector = step.input.selector;

switch (action) {
case "label": {
const labelText = inputSelector.replace("label:", "").trim();
return { action: "label", value: labelText };
}
case "placeholder": {
const placeholderText = inputSelector.replace("placeholder:", "").trim();
return { action: "placeholder", value: placeholderText };
}
case "role": {
const roleAndName = inputSelector.replace("role:", "").trim();
const [role, ...nameParts] = roleAndName.split(" ");
const name = nameParts.join(" ").replace('name="', "").replace('"', "");
return { action: "role", value: { name, role } };
}
case "selector": {
return { action: "selector", value: inputSelector };
}
case "testID": {
const id = inputSelector.replace("testID:", "").trim();
return { action: "testID", value: id };
}
default:
return { action: "selector", value: inputSelector };
}
}

function getActionType(selector: string): Action {
if (selector.startsWith("label:")) return "label";
if (selector.startsWith("testID:")) return "testID";
if (selector.startsWith("placeholder:")) return "placeholder";
if (selector.startsWith("role:")) return "role";
return "selector";
}

function inputDescription(step: InputAction): string {
const actionType = getActionType(step.input.selector);
const result = inputValue({ action: actionType, step });

switch (result.action) {
case "label":
return `label: ${result.value}`;
case "testID":
return `testID: ${result.value}`;
case "placeholder":
return `placeholder: ${result.value}`;
case "role":
return `role: ${result.value.role} and name: ${result.value.name}`;
default:
return `selector: ${result.value}`;
}
}

function inputLocator({ page, step }: BaseFlowParam<InputAction>): Locator {
const actionType = getActionType(step.input.selector);
const result = inputValue({ action: actionType, step });

switch (result.action) {
case "label":
return page.getByLabel(result.value);
case "testID":
return page.getByTestId(result.value);
case "placeholder":
return page.getByPlaceholder(result.value);
case "role":
return page.getByRole(result.value.role as Role, {
name: result.value.name,
});
default:
return page.locator(result.value);
}
}

export const inputFlow = createFlow<InputAction>({
action: "input",
async execute({ page, step }) {
const locator = inputLocator({ page, step });
await locator.fill(step.input.value);
},
setLoadingMessage(step) {
const target = inputDescription(step);
return `Filling input for ${target}`;
},
});

export default inputFlow;
Loading