@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.
- package/README.md +465 -0
- package/dist/cli.js +873 -0
- package/dist/executor.js +420 -0
- package/dist/mcp-executor.js +308 -0
- package/package.json +61 -0
package/dist/executor.js
ADDED
|
@@ -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
|