|
1 | 1 | import type { BrowserAgent } from "./browser-agent.js"; |
2 | | -import type { TaskStep } from "../shared/types.js"; |
| 2 | +import { resolveStepValue } from "./fixtures.js"; |
| 3 | +import { oneAttempt, retryAssertion, runCypressCommand } from "./cypress-runtime.js"; |
| 4 | +import type { QaTask, TaskStep } from "../shared/types.js"; |
3 | 5 |
|
4 | | -export async function runTaskStep(browser: BrowserAgent, step: TaskStep): Promise<string | undefined> { |
| 6 | +export async function runTaskStep(browser: BrowserAgent, step: TaskStep, task?: QaTask): Promise<string | undefined> { |
5 | 7 | switch (step.action) { |
6 | 8 | case "open": |
7 | | - await browser.openUrl(step.url || ""); |
8 | | - return undefined; |
| 9 | + return runCypressCommand(browser, task, step, { kind: "action", name: "open", target: required(step.url, "url") }, async () => { |
| 10 | + await browser.openUrl(required(step.url, "url")); |
| 11 | + return oneAttempt(undefined); |
| 12 | + }); |
9 | 13 | case "click": |
10 | | - await browser.click(required(step.selector, "selector")); |
11 | | - return undefined; |
| 14 | + return runCypressCommand(browser, task, step, { kind: "action", name: "click" }, async () => { |
| 15 | + await browser.click(required(step.selector, "selector")); |
| 16 | + return oneAttempt(undefined); |
| 17 | + }); |
12 | 18 | case "click_by_index": |
13 | | - await browser.clickByIndex(requiredNumber(step.index, "index")); |
14 | | - return undefined; |
| 19 | + return runCypressCommand(browser, task, step, { kind: "action", name: "click_by_index", target: String(requiredNumber(step.index, "index")) }, async () => { |
| 20 | + await browser.clickByIndex(requiredNumber(step.index, "index")); |
| 21 | + return oneAttempt(undefined); |
| 22 | + }); |
15 | 23 | case "click_by_text": |
16 | | - await browser.clickByText(required(step.text || step.value, "text")); |
17 | | - return undefined; |
| 24 | + return runCypressCommand(browser, task, step, { kind: "action", name: "click_by_text", target: textValue(step, task, "text") }, async () => { |
| 25 | + await browser.clickByText(textValue(step, task, "text")); |
| 26 | + return oneAttempt(undefined); |
| 27 | + }); |
18 | 28 | case "click_by_role": |
19 | | - await browser.clickByRole(required(step.role, "role"), step.text || step.value); |
20 | | - return undefined; |
| 29 | + return runCypressCommand(browser, task, step, { kind: "action", name: "click_by_role", target: roleTarget(step, task) }, async () => { |
| 30 | + await browser.clickByRole(required(step.role, "role"), optionalTextValue(step, task)); |
| 31 | + return oneAttempt(undefined); |
| 32 | + }); |
21 | 33 | case "fill": |
22 | | - await browser.fill(required(step.selector, "selector"), step.value || ""); |
23 | | - return undefined; |
| 34 | + return runCypressCommand(browser, task, step, { kind: "action", name: "fill" }, async () => { |
| 35 | + await browser.fill(required(step.selector, "selector"), valueFromStep(step, task)); |
| 36 | + return oneAttempt(undefined); |
| 37 | + }); |
24 | 38 | case "fill_by_label": |
25 | | - await browser.fillByLabel(required(step.text || step.selector, "label"), step.value || ""); |
26 | | - return undefined; |
| 39 | + return runCypressCommand(browser, task, step, { kind: "action", name: "fill_by_label", target: required(step.text || step.selector, "label") }, async () => { |
| 40 | + await browser.fillByLabel(required(step.text || step.selector, "label"), valueFromStep(step, task)); |
| 41 | + return oneAttempt(undefined); |
| 42 | + }); |
27 | 43 | case "fill_by_placeholder": |
28 | | - await browser.fillByPlaceholder(required(step.text || step.selector, "placeholder"), step.value || ""); |
29 | | - return undefined; |
| 44 | + return runCypressCommand(browser, task, step, { kind: "action", name: "fill_by_placeholder", target: required(step.text || step.selector, "placeholder") }, async () => { |
| 45 | + await browser.fillByPlaceholder(required(step.text || step.selector, "placeholder"), valueFromStep(step, task)); |
| 46 | + return oneAttempt(undefined); |
| 47 | + }); |
30 | 48 | case "fill_by_name": |
31 | | - await browser.fillByName(required(step.text || step.selector, "name"), step.value || ""); |
32 | | - return undefined; |
| 49 | + return runCypressCommand(browser, task, step, { kind: "action", name: "fill_by_name", target: required(step.text || step.selector, "name") }, async () => { |
| 50 | + await browser.fillByName(required(step.text || step.selector, "name"), valueFromStep(step, task)); |
| 51 | + return oneAttempt(undefined); |
| 52 | + }); |
33 | 53 | case "press": |
34 | | - await browser.press(required(step.selector, "selector"), step.key || "Enter"); |
35 | | - return undefined; |
| 54 | + return runCypressCommand(browser, task, step, { kind: "action", name: "press", target: required(step.selector, "selector") }, async () => { |
| 55 | + await browser.press(required(step.selector, "selector"), step.key || "Enter"); |
| 56 | + return oneAttempt(undefined); |
| 57 | + }); |
36 | 58 | case "wait": |
37 | | - if (step.selector) await browser.waitForSelector(step.selector); |
38 | | - else await browser.wait(Number(step.value || 1000)); |
39 | | - return undefined; |
| 59 | + return runCypressCommand(browser, task, step, { kind: step.selector ? "query" : "system", name: "wait" }, async () => { |
| 60 | + if (step.selector) await browser.waitForSelector(step.selector); |
| 61 | + else await browser.wait(Number(step.value || 1000)); |
| 62 | + return oneAttempt(undefined); |
| 63 | + }); |
40 | 64 | case "screenshot": |
41 | | - return browser.screenshot(step.label || "task"); |
| 65 | + return runCypressCommand(browser, task, step, { kind: "system", name: "screenshot", target: step.label || "task" }, async () => |
| 66 | + oneAttempt(await browser.screenshot(step.label || "task")) |
| 67 | + ); |
42 | 68 | case "analyze": |
43 | | - await browser.saveBrowserState(); |
44 | | - return undefined; |
| 69 | + return runCypressCommand(browser, task, step, { kind: "system", name: "analyze", target: step.label || "browser-state" }, async () => { |
| 70 | + await browser.saveBrowserState(); |
| 71 | + return oneAttempt(undefined); |
| 72 | + }); |
| 73 | + case "assert_visible": |
| 74 | + return runCypressCommand(browser, task, step, { kind: "assertion", name: "assert_visible" }, () => |
| 75 | + retryAssertion(browser, task, step, async () => { |
| 76 | + const visible = await isStepTargetVisible(browser, step, task); |
| 77 | + if (!visible) throw new Error(`Expected target to be visible: ${step.selector || step.text || step.role || step.value || step.label || "unknown"}`); |
| 78 | + return undefined; |
| 79 | + }) |
| 80 | + ); |
| 81 | + case "assert_text": |
| 82 | + return runCypressCommand(browser, task, step, { kind: "assertion", name: "assert_text", target: expectedValue(step, task) }, () => |
| 83 | + retryAssertion(browser, task, step, async () => { |
| 84 | + const expected = expectedValue(step, task); |
| 85 | + const actual = step.selector |
| 86 | + ? await browser.activePage.locator(step.selector).first().innerText({ timeout: 750 }).catch(() => "") |
| 87 | + : await browser.activePage.locator("body").innerText({ timeout: 750 }).catch(() => ""); |
| 88 | + if (!actual.includes(expected)) throw new Error(`Expected page text to include "${expected}".`); |
| 89 | + return undefined; |
| 90 | + }) |
| 91 | + ); |
| 92 | + case "assert_url_includes": |
| 93 | + return runCypressCommand(browser, task, step, { kind: "assertion", name: "assert_url_includes", target: expectedValue(step, task) }, () => |
| 94 | + retryAssertion(browser, task, step, async () => { |
| 95 | + const expected = expectedValue(step, task); |
| 96 | + const currentUrl = browser.getUrl(); |
| 97 | + if (!currentUrl.includes(expected)) throw new Error(`Expected URL "${currentUrl}" to include "${expected}".`); |
| 98 | + return undefined; |
| 99 | + }) |
| 100 | + ); |
| 101 | + case "assert_count": |
| 102 | + return runCypressCommand(browser, task, step, { kind: "assertion", name: "assert_count", target: required(step.selector, "selector") }, () => |
| 103 | + retryAssertion(browser, task, step, async () => { |
| 104 | + const expectedCount = requiredNumber(step.count, "count"); |
| 105 | + const actualCount = await browser.activePage.locator(required(step.selector, "selector")).count(); |
| 106 | + if (actualCount !== expectedCount) throw new Error(`Expected ${expectedCount} element(s), found ${actualCount}.`); |
| 107 | + return undefined; |
| 108 | + }) |
| 109 | + ); |
45 | 110 | } |
46 | 111 | } |
47 | 112 |
|
| 113 | +async function isStepTargetVisible(browser: BrowserAgent, step: TaskStep, task?: QaTask): Promise<boolean> { |
| 114 | + if (step.selector) return browser.activePage.locator(step.selector).first().isVisible().catch(() => false); |
| 115 | + if (step.role) return browser.activePage.getByRole(step.role as never, optionalTextValue(step, task) ? { name: optionalTextValue(step, task) } : undefined).first().isVisible().catch(() => false); |
| 116 | + return browser.activePage.getByText(textValue(step, task, "text"), { exact: false }).first().isVisible().catch(() => false); |
| 117 | +} |
| 118 | + |
| 119 | +function valueFromStep(step: TaskStep, task?: QaTask): string { |
| 120 | + return resolveStepValue(step.value || fixtureRef(step.fixture), task); |
| 121 | +} |
| 122 | + |
| 123 | +function expectedValue(step: TaskStep, task?: QaTask): string { |
| 124 | + return required(resolveStepValue(step.expected || step.text || step.value || step.url || fixtureRef(step.fixture), task), "expected"); |
| 125 | +} |
| 126 | + |
| 127 | +function textValue(step: TaskStep, task: QaTask | undefined, name: string): string { |
| 128 | + return required(resolveStepValue(step.text || step.value || fixtureRef(step.fixture), task), name); |
| 129 | +} |
| 130 | + |
| 131 | +function optionalTextValue(step: TaskStep, task?: QaTask): string | undefined { |
| 132 | + const value = resolveStepValue(step.text || step.value || fixtureRef(step.fixture), task); |
| 133 | + return value || undefined; |
| 134 | +} |
| 135 | + |
| 136 | +function roleTarget(step: TaskStep, task?: QaTask): string { |
| 137 | + const name = optionalTextValue(step, task); |
| 138 | + return `${required(step.role, "role")}${name ? `:${name}` : ""}`; |
| 139 | +} |
| 140 | + |
| 141 | +function fixtureRef(fixture: string | undefined): string | undefined { |
| 142 | + if (!fixture) return undefined; |
| 143 | + return fixture.startsWith("fixture:") ? fixture : `fixture:${fixture}`; |
| 144 | +} |
| 145 | + |
48 | 146 | function required(value: string | undefined, name: string): string { |
49 | 147 | if (!value) throw new Error(`Missing ${name} for task step.`); |
50 | 148 | return value; |
|
0 commit comments