@cutleryapp/agent 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,420 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TestExecutor = void 0;
4
+ const fs_1 = require("fs");
5
+ const path_1 = require("path");
6
+ const playwright_1 = require("playwright");
7
+ class TestExecutor {
8
+ options;
9
+ browser = null;
10
+ context = null;
11
+ page = null;
12
+ screenshots = [];
13
+ constructor(options) {
14
+ this.options = options;
15
+ }
16
+ async execute(testCase) {
17
+ const result = {
18
+ success: true,
19
+ steps: [],
20
+ screenshots: [],
21
+ };
22
+ try {
23
+ // Ensure output directory exists
24
+ if (!(0, fs_1.existsSync)(this.options.outputDir)) {
25
+ (0, fs_1.mkdirSync)(this.options.outputDir, { recursive: true });
26
+ }
27
+ // Launch browser
28
+ await this.launchBrowser();
29
+ // Process test variables
30
+ const variables = testCase.test_variables || {};
31
+ // Execute each step
32
+ for (let i = 0; i < testCase.automated_steps.length; i++) {
33
+ let step = testCase.automated_steps[i];
34
+ const startTime = Date.now();
35
+ // Replace variables in step
36
+ Object.entries(variables).forEach(([key, value]) => {
37
+ step = step.replace(new RegExp(`{{${key}}}`, "g"), value);
38
+ step = step.replace(new RegExp(`\\$\\{${key}\\}`, "g"), value);
39
+ });
40
+ const stepResult = {
41
+ step,
42
+ action: step,
43
+ };
44
+ try {
45
+ // Notify step start
46
+ if (this.options.onProgress) {
47
+ this.options.onProgress({
48
+ type: "step-start",
49
+ step: i + 1,
50
+ total: testCase.automated_steps.length,
51
+ message: step,
52
+ });
53
+ }
54
+ await this.executeStep(step, i + 1);
55
+ stepResult.duration = Date.now() - startTime;
56
+ result.steps.push(stepResult);
57
+ // Take screenshot after successful step
58
+ const screenshot = await this.takeScreenshot(`step-${i + 1}`);
59
+ // Notify step complete with screenshot
60
+ if (this.options.onProgress) {
61
+ this.options.onProgress({
62
+ type: "step-complete",
63
+ step: i + 1,
64
+ total: testCase.automated_steps.length,
65
+ message: step,
66
+ screenshot: screenshot || undefined,
67
+ });
68
+ }
69
+ if (this.options.verbose) {
70
+ console.log(` ✅ Step ${i + 1}: ${step}`);
71
+ }
72
+ }
73
+ catch (error) {
74
+ stepResult.error = error.message;
75
+ stepResult.duration = Date.now() - startTime;
76
+ result.steps.push(stepResult);
77
+ result.success = false;
78
+ // Take screenshot on error
79
+ const screenshot = await this.takeScreenshot(`error-step-${i + 1}`);
80
+ // Notify step error with screenshot
81
+ if (this.options.onProgress) {
82
+ this.options.onProgress({
83
+ type: "step-error",
84
+ step: i + 1,
85
+ total: testCase.automated_steps.length,
86
+ message: step,
87
+ error: error.message,
88
+ screenshot: screenshot || undefined,
89
+ });
90
+ }
91
+ if (this.options.verbose) {
92
+ console.log(` ❌ Step ${i + 1}: ${step}`);
93
+ console.log(` Error: ${error.message}`);
94
+ }
95
+ }
96
+ }
97
+ // Take final screenshot
98
+ await this.takeScreenshot("final");
99
+ result.screenshots = this.screenshots;
100
+ }
101
+ catch (error) {
102
+ result.success = false;
103
+ result.error = error.message;
104
+ }
105
+ finally {
106
+ await this.cleanup();
107
+ }
108
+ return result;
109
+ }
110
+ async launchBrowser() {
111
+ const browsers = { chromium: playwright_1.chromium, firefox: playwright_1.firefox, webkit: playwright_1.webkit };
112
+ const browserType = browsers[this.options.browserType];
113
+ this.browser = await browserType.launch({
114
+ headless: this.options.headless,
115
+ });
116
+ this.context = await this.browser.newContext({
117
+ viewport: { width: 1280, height: 720 },
118
+ });
119
+ this.page = await this.context.newPage();
120
+ }
121
+ async executeStep(step, stepNumber) {
122
+ if (!this.page)
123
+ throw new Error("Browser not initialized");
124
+ const lowerStep = step.toLowerCase();
125
+ // Navigate
126
+ if (lowerStep.includes("navigate") || lowerStep.includes("go to")) {
127
+ const urlMatch = step.match(/https?:\/\/[^\s"']+/) ||
128
+ step.match(/navigate\s+to\s+(.+)/i);
129
+ if (urlMatch) {
130
+ const url = urlMatch[0] || urlMatch[1];
131
+ await this.page.goto(url.trim().replace(/["',;]+$/, ""));
132
+ await this.page.waitForLoadState("networkidle");
133
+ return;
134
+ }
135
+ }
136
+ // Wait
137
+ if (lowerStep.includes("wait")) {
138
+ const secondsMatch = step.match(/(\d+)\s*seconds?/i);
139
+ if (secondsMatch) {
140
+ await this.page.waitForTimeout(parseInt(secondsMatch[1]) * 1000);
141
+ return;
142
+ }
143
+ const selectorMatch = step.match(/wait\s+for\s+["']?(.+?)["']?\s+to\s+be\s+visible/i);
144
+ if (selectorMatch) {
145
+ await this.page.waitForSelector(selectorMatch[1], {
146
+ state: "visible",
147
+ timeout: 30000,
148
+ });
149
+ return;
150
+ }
151
+ }
152
+ // Fill (supports both formats: "Fill X in Y" and "Fill X with Y")
153
+ let fillMatch = step.match(/fill\s+["']?(.+?)["']?\s+with\s+["']?(.+?)["']?$/i);
154
+ let text;
155
+ let selector;
156
+ // Strip a single layer of matched outer quotes (straight, smart, or
157
+ // backtick) so values like `"foo"` or `“foo”` aren't typed verbatim.
158
+ const stripQuotes = (raw) => {
159
+ const t = raw.trim();
160
+ if (t.length < 2)
161
+ return t;
162
+ const pairs = [
163
+ ['"', '"'],
164
+ ["'", "'"],
165
+ ["“", "”"],
166
+ ["‘", "’"],
167
+ ["`", "`"],
168
+ ];
169
+ const f = t[0];
170
+ const l = t[t.length - 1];
171
+ for (const [o, c] of pairs)
172
+ if (f === o && l === c)
173
+ return t.slice(1, -1);
174
+ return t;
175
+ };
176
+ if (fillMatch) {
177
+ // Format: Fill "selector" with "value"
178
+ selector = stripQuotes(fillMatch[1]);
179
+ text = stripQuotes(fillMatch[2]);
180
+ }
181
+ else {
182
+ // Try alternate format: Fill "value" in "selector"
183
+ fillMatch = step.match(/fill\s+["']?(.+?)["']?\s+in\s+["']?(.+?)["']?$/i);
184
+ if (fillMatch) {
185
+ text = stripQuotes(fillMatch[1]);
186
+ selector = stripQuotes(fillMatch[2]);
187
+ }
188
+ }
189
+ if (fillMatch && selector && text !== undefined) {
190
+ // If the captured selector looks like a real CSS selector, trust it.
191
+ if (/[#.\[\]:>]/.test(selector)) {
192
+ await this.page.fill(selector, text);
193
+ return;
194
+ }
195
+ // Otherwise treat it as a human-readable label and try a battery of
196
+ // strategies until one resolves a unique, visible element. This makes
197
+ // steps like `Fill "standard_user" in "Username"` work against pages
198
+ // where the actual field is `<input name="user-name" placeholder="Username">`.
199
+ await this.fillByLabel(selector, text);
200
+ return;
201
+ }
202
+ // Click
203
+ const clickMatch = step.match(/click\s+["']?(.+?)["']?$/i);
204
+ if (clickMatch) {
205
+ let selector = clickMatch[1].trim().replace(/^["']|["']$/g, "");
206
+ // Smart button selector
207
+ if (!/[#.\[\]:>]/.test(selector)) {
208
+ selector = `button:has-text("${selector}"), a:has-text("${selector}"), [role="button"]:has-text("${selector}")`;
209
+ }
210
+ await this.page.click(selector);
211
+ await this.page.waitForTimeout(500);
212
+ return;
213
+ }
214
+ // Verify text is present in element
215
+ // Format: Verify "text" is present in "selector" OR Check if "text" is present
216
+ const verifyMatch = step.match(/(?:verify|check\s+if)\s+.*?["'](.+?)["'].*?(?:is\s+present|visible)(?:\s+in\s+["']?(.+?)["']?)?/i);
217
+ if (verifyMatch) {
218
+ const text = verifyMatch[1].trim();
219
+ let selector = verifyMatch[2]?.trim().replace(/^["']|["']$/g, "");
220
+ // If selector contains "locator(", extract the actual selector
221
+ if (selector && selector.includes("locator(")) {
222
+ const locatorMatch = selector.match(/locator\(['"](.+?)['"]\)/);
223
+ if (locatorMatch) {
224
+ selector = locatorMatch[1];
225
+ }
226
+ }
227
+ if (selector) {
228
+ // Check if element with selector contains the text
229
+ await this.page.waitForSelector(selector, {
230
+ state: "visible",
231
+ timeout: 10000,
232
+ });
233
+ const element = await this.page.locator(selector);
234
+ const content = await element.textContent();
235
+ if (!content?.includes(text)) {
236
+ throw new Error(`Expected "${text}" to be present in element "${selector}", but got "${content}"`);
237
+ }
238
+ }
239
+ else {
240
+ // Check if text is visible anywhere on the page
241
+ await this.page.waitForSelector(`text="${text}"`, {
242
+ state: "visible",
243
+ timeout: 10000,
244
+ });
245
+ }
246
+ return;
247
+ }
248
+ // Check visibility (legacy format)
249
+ const visibleMatch = step.match(/check\s+if\s+["']?(.+?)["']?\s+is\s+visible/i);
250
+ if (visibleMatch) {
251
+ const text = visibleMatch[1].trim();
252
+ await this.page.waitForSelector(`text="${text}"`, {
253
+ state: "visible",
254
+ timeout: 10000,
255
+ });
256
+ return;
257
+ }
258
+ // Screenshot
259
+ if (lowerStep.includes("screenshot") || lowerStep.includes("capture")) {
260
+ await this.takeScreenshot(`step-${stepNumber}`);
261
+ return;
262
+ }
263
+ throw new Error(`Could not interpret step: ${step}`);
264
+ }
265
+ /**
266
+ * Resolve a human-readable field label to a real input and fill it.
267
+ *
268
+ * Tries strategies in order from most specific to most fuzzy. Each strategy
269
+ * is given a short probe timeout so a missing match falls through quickly.
270
+ * Stops on the first strategy that finds exactly one visible element.
271
+ */
272
+ async fillByLabel(label, value) {
273
+ if (!this.page)
274
+ throw new Error("Page not initialised");
275
+ const page = this.page;
276
+ const probeMs = 1500;
277
+ const variants = labelVariants(label); // e.g. ["Username", "username", "user-name", "user_name", "userName"]
278
+ const labelRe = new RegExp(escapeRegex(label), "i");
279
+ // Build CSS attribute selectors with case-insensitive flag (` i`).
280
+ // [attr*=value i] matches when attr CONTAINS value, case-insensitive.
281
+ const attrContains = (attr) => variants.map((v) => `input[${attr}*="${cssEscape(v)}" i], textarea[${attr}*="${cssEscape(v)}" i], [contenteditable="true"][${attr}*="${cssEscape(v)}" i]`).join(", ");
282
+ const strategies = [
283
+ // 1. Playwright accessibility — best signal when present.
284
+ { name: "getByLabel", run: () => page.getByLabel(labelRe).first().fill(value, { timeout: probeMs }) },
285
+ { name: "getByPlaceholder", run: () => page.getByPlaceholder(labelRe).first().fill(value, { timeout: probeMs }) },
286
+ { name: "getByRole(textbox,name)", run: () => page.getByRole("textbox", { name: labelRe }).first().fill(value, { timeout: probeMs }) },
287
+ // 2. Common automation hooks.
288
+ { name: "data-test", run: () => page.locator(attrContains("data-test")).first().fill(value, { timeout: probeMs }) },
289
+ { name: "data-testid", run: () => page.locator(attrContains("data-testid")).first().fill(value, { timeout: probeMs }) },
290
+ // 3. Native attributes — exact then contains, with variant expansion.
291
+ { name: "name/id exact (variants)", run: async () => {
292
+ const exact = variants.flatMap((v) => [
293
+ `input[name="${cssEscape(v)}" i]`,
294
+ `input[id="${cssEscape(v)}" i]`,
295
+ `textarea[name="${cssEscape(v)}" i]`,
296
+ `textarea[id="${cssEscape(v)}" i]`,
297
+ ]).join(", ");
298
+ await page.locator(exact).first().fill(value, { timeout: probeMs });
299
+ } },
300
+ { name: "name/id contains (variants)", run: () => page.locator(`${attrContains("name")}, ${attrContains("id")}`).first().fill(value, { timeout: probeMs }) },
301
+ // 4. ARIA / placeholder / autocomplete fallbacks via attribute contains.
302
+ { name: "aria-label contains", run: () => page.locator(attrContains("aria-label")).first().fill(value, { timeout: probeMs }) },
303
+ { name: "placeholder contains", run: () => page.locator(attrContains("placeholder")).first().fill(value, { timeout: probeMs }) },
304
+ // 5. Last resort: fuzzy "label-near-input" — a label/legend/span
305
+ // containing the text whose nearest input we then fill.
306
+ { name: "label-text → following input", run: async () => {
307
+ const handle = await page.locator(`label:has-text("${escapeForCssContains(label)}")`).first().elementHandle({ timeout: probeMs });
308
+ if (!handle)
309
+ throw new Error("no label");
310
+ // Try `for` attribute first.
311
+ const forAttr = await handle.getAttribute("for");
312
+ if (forAttr) {
313
+ await page.locator(`#${cssEscape(forAttr)}`).first().fill(value, { timeout: probeMs });
314
+ return;
315
+ }
316
+ // Otherwise nearest descendant or following input.
317
+ const nested = handle.asElement()?.$$('input, textarea, [contenteditable="true"]');
318
+ const inputs = await nested;
319
+ if (inputs && inputs.length > 0) {
320
+ await inputs[0].fill(value, { timeout: probeMs });
321
+ return;
322
+ }
323
+ throw new Error("no input near label");
324
+ } },
325
+ ];
326
+ const errors = [];
327
+ for (const s of strategies) {
328
+ try {
329
+ await s.run();
330
+ if (this.options.verbose) {
331
+ console.log(`[fill] resolved "${label}" via ${s.name}`);
332
+ }
333
+ return;
334
+ }
335
+ catch (e) {
336
+ errors.push(`${s.name}: ${e?.message?.split("\n")[0] || String(e)}`);
337
+ }
338
+ }
339
+ throw new Error(`Could not find input field for label "${label}". Tried ${strategies.length} strategies:\n - ${errors.slice(0, 5).join("\n - ")}${errors.length > 5 ? "\n - …" : ""}`);
340
+ }
341
+ async takeScreenshot(name) {
342
+ if (!this.page)
343
+ return null;
344
+ try {
345
+ const timestamp = Date.now();
346
+ const filename = `${name}-${timestamp}.png`;
347
+ const filepath = (0, path_1.join)(this.options.outputDir, filename);
348
+ // Take screenshot and get buffer for base64 encoding
349
+ const buffer = await this.page.screenshot({
350
+ path: filepath,
351
+ fullPage: false,
352
+ });
353
+ this.screenshots.push(filepath);
354
+ // Return base64 encoded screenshot
355
+ return buffer.toString("base64");
356
+ }
357
+ catch (error) {
358
+ // Ignore screenshot errors
359
+ return null;
360
+ }
361
+ }
362
+ async cleanup() {
363
+ try {
364
+ if (this.context)
365
+ await this.context.close();
366
+ if (this.browser)
367
+ await this.browser.close();
368
+ }
369
+ catch (error) {
370
+ // Ignore cleanup errors
371
+ }
372
+ }
373
+ }
374
+ exports.TestExecutor = TestExecutor;
375
+ /**
376
+ * Generate plausible attribute-value variants of a label so we can match
377
+ * common naming conventions used in real apps. e.g.:
378
+ * "Username" → ["Username", "username", "user name", "user-name", "user_name", "userName", "UserName"]
379
+ * "Email Address" → ["Email Address", "email address", "email-address", "email_address", "emailAddress", "EmailAddress"]
380
+ * "First Name" → ["First Name", "first name", "first-name", "first_name", "firstName", "FirstName"]
381
+ * "Confirm-Password" → ["Confirm-Password", "confirm-password", "confirm password", "confirm_password", "confirmPassword", "ConfirmPassword"]
382
+ */
383
+ function labelVariants(label) {
384
+ const cleaned = label.trim();
385
+ if (!cleaned)
386
+ return [];
387
+ // Tokenise on whitespace, hyphens, underscores, and camelCase boundaries.
388
+ const tokens = cleaned
389
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
390
+ .split(/[\s\-_]+/)
391
+ .filter(Boolean);
392
+ if (tokens.length === 0)
393
+ return [cleaned];
394
+ const lowerTokens = tokens.map((t) => t.toLowerCase());
395
+ const variants = new Set();
396
+ variants.add(cleaned);
397
+ variants.add(cleaned.toLowerCase());
398
+ variants.add(lowerTokens.join(" "));
399
+ variants.add(lowerTokens.join("-"));
400
+ variants.add(lowerTokens.join("_"));
401
+ variants.add(lowerTokens.join("")); // smashed: "username"
402
+ // camelCase
403
+ variants.add(lowerTokens[0] + lowerTokens.slice(1).map((t) => t[0].toUpperCase() + t.slice(1)).join(""));
404
+ // PascalCase
405
+ variants.add(lowerTokens.map((t) => t[0].toUpperCase() + t.slice(1)).join(""));
406
+ return Array.from(variants).filter((v) => v.length > 0);
407
+ }
408
+ /** Escape characters that have special meaning in CSS attribute string values. */
409
+ function cssEscape(value) {
410
+ return value.replace(/[\\"]/g, "\\$&");
411
+ }
412
+ /** Escape for use inside Playwright `:has-text("...")`. */
413
+ function escapeForCssContains(value) {
414
+ return value.replace(/["\\]/g, "\\$&");
415
+ }
416
+ /** Escape regex metacharacters in a label that becomes a RegExp. */
417
+ function escapeRegex(value) {
418
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
419
+ }
420
+ //# sourceMappingURL=executor.js.map