@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
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TestExecutor = void 0;
|
|
4
|
+
const playwright_1 = require("playwright");
|
|
5
|
+
const fs_1 = require("fs");
|
|
6
|
+
class TestExecutor {
|
|
7
|
+
options;
|
|
8
|
+
activeBrowser = null;
|
|
9
|
+
constructor(options) {
|
|
10
|
+
this.options = options;
|
|
11
|
+
}
|
|
12
|
+
cancel() {
|
|
13
|
+
this.activeBrowser?.close().catch(() => { });
|
|
14
|
+
this.activeBrowser = null;
|
|
15
|
+
}
|
|
16
|
+
async execute(testCase) {
|
|
17
|
+
const result = { success: true, steps: [], screenshots: [] };
|
|
18
|
+
if (!(0, fs_1.existsSync)(this.options.outputDir)) {
|
|
19
|
+
(0, fs_1.mkdirSync)(this.options.outputDir, { recursive: true });
|
|
20
|
+
}
|
|
21
|
+
const variables = testCase.test_variables || {};
|
|
22
|
+
const baseUrlValue = this.options.baseUrl;
|
|
23
|
+
console.log('[executor] test_variables received:', JSON.stringify(variables));
|
|
24
|
+
console.log('[executor] baseUrl option:', baseUrlValue);
|
|
25
|
+
console.log('[executor] raw steps:', JSON.stringify(testCase.automated_steps));
|
|
26
|
+
// Build a case-insensitive lookup map
|
|
27
|
+
const varMap = {};
|
|
28
|
+
for (const [k, v] of Object.entries(variables)) {
|
|
29
|
+
varMap[k] = v;
|
|
30
|
+
varMap[k.toLowerCase()] = v;
|
|
31
|
+
varMap[k.toUpperCase()] = v;
|
|
32
|
+
}
|
|
33
|
+
// Always resolve BASE_URL from the executor's baseUrl option as fallback
|
|
34
|
+
if (baseUrlValue && !varMap['BASE_URL']) {
|
|
35
|
+
varMap['BASE_URL'] = baseUrlValue;
|
|
36
|
+
varMap['base_url'] = baseUrlValue;
|
|
37
|
+
}
|
|
38
|
+
console.log('[executor] varMap:', JSON.stringify(varMap));
|
|
39
|
+
const resolveVars = (s) => s.replace(/\{\{([^}]+)\}\}/g, (match, key) => varMap[key.trim()] ?? match).trim();
|
|
40
|
+
const steps = testCase.automated_steps.map(resolveVars);
|
|
41
|
+
console.log('[executor] resolved steps:', JSON.stringify(steps));
|
|
42
|
+
const browserLaunchers = { chromium: playwright_1.chromium, firefox: playwright_1.firefox, webkit: playwright_1.webkit };
|
|
43
|
+
const launchFn = browserLaunchers[this.options.browserType] ?? playwright_1.chromium;
|
|
44
|
+
let browser = null;
|
|
45
|
+
let page = null;
|
|
46
|
+
try {
|
|
47
|
+
browser = await launchFn.launch({ headless: this.options.headless !== false });
|
|
48
|
+
this.activeBrowser = browser;
|
|
49
|
+
page = await browser.newPage();
|
|
50
|
+
await page.setViewportSize({ width: 1280, height: 720 });
|
|
51
|
+
for (let i = 0; i < steps.length; i++) {
|
|
52
|
+
const raw = steps[i];
|
|
53
|
+
const lower = raw.toLowerCase();
|
|
54
|
+
const t0 = Date.now();
|
|
55
|
+
this.options.onProgress?.({
|
|
56
|
+
type: "step-start",
|
|
57
|
+
step: i + 1,
|
|
58
|
+
total: steps.length,
|
|
59
|
+
message: raw,
|
|
60
|
+
});
|
|
61
|
+
let stepError;
|
|
62
|
+
try {
|
|
63
|
+
if (lower.includes("navigate to") || lower.includes("go to")) {
|
|
64
|
+
// Extract URL wherever it appears in the step (handles emoji/symbol prefixes)
|
|
65
|
+
const urlMatch = raw.match(/(?:navigate to|go to)\s+(https?:\/\/\S+|\/\S*|\S+\.\S+)/i);
|
|
66
|
+
if (urlMatch) {
|
|
67
|
+
let url = urlMatch[1].trim();
|
|
68
|
+
if (url.startsWith("/") && this.options.baseUrl) {
|
|
69
|
+
url = this.options.baseUrl.replace(/\/$/, "") + url;
|
|
70
|
+
}
|
|
71
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
|
|
72
|
+
}
|
|
73
|
+
else if (this.options.baseUrl) {
|
|
74
|
+
// Step says "navigate to" but no URL found — go to baseUrl
|
|
75
|
+
await page.goto(this.options.baseUrl, { waitUntil: "domcontentloaded", timeout: 30000 });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
else if (lower.includes("click")) {
|
|
79
|
+
const labelMatch = raw.match(/click\s+(?:on\s+)?(?:the\s+)?"?([^"]+?)"?\s*(?:button|link|tab)?$/i);
|
|
80
|
+
const label = labelMatch?.[1]?.trim();
|
|
81
|
+
if (label) {
|
|
82
|
+
const nameRe = new RegExp(label, 'i');
|
|
83
|
+
// Use a short probe timeout so fallbacks are fast
|
|
84
|
+
const clicked = await tryClick(page, nameRe, label);
|
|
85
|
+
if (!clicked)
|
|
86
|
+
throw new Error(`Could not find clickable element: "${label}"`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
else if (lower.includes("fill") || lower.includes("type") || lower.includes("enter")) {
|
|
90
|
+
const quoted = raw.match(/(?:enter|fill|type)\s+"([^"]+)"\s+(?:in|into)\s+(?:the\s+)?(?:"([^"]+)"|(\w[\w\s]*?)\s*(?:field|input|box|area)?)\s*$/i);
|
|
91
|
+
if (quoted) {
|
|
92
|
+
const value = quoted[1];
|
|
93
|
+
const quotedTarget = quoted[2];
|
|
94
|
+
const bareLabel = quoted[3];
|
|
95
|
+
// A token in quotes is usually a human-readable label
|
|
96
|
+
// (e.g. `Fill "standard_user" in "Username"`), not a CSS
|
|
97
|
+
// selector. Only treat it as a CSS selector when it actually
|
|
98
|
+
// looks like one — otherwise resolve via tryFill so the
|
|
99
|
+
// label can match placeholder/aria/data-test/label etc.
|
|
100
|
+
const looksLikeCss = (s) => /[#.\[\]:>]/.test(s);
|
|
101
|
+
if (quotedTarget && looksLikeCss(quotedTarget)) {
|
|
102
|
+
await page.waitForSelector(quotedTarget, { state: "visible", timeout: 5000 });
|
|
103
|
+
await page.fill(quotedTarget, value);
|
|
104
|
+
}
|
|
105
|
+
else if (quotedTarget) {
|
|
106
|
+
await tryFill(page, quotedTarget.trim(), value);
|
|
107
|
+
}
|
|
108
|
+
else if (bareLabel) {
|
|
109
|
+
await tryFill(page, bareLabel.trim(), value);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
else if (lower.includes("wait") && lower.includes("second")) {
|
|
114
|
+
const ms = raw.match(/wait\s+(\d+)\s*sec/i);
|
|
115
|
+
if (ms)
|
|
116
|
+
await page.waitForTimeout(parseInt(ms[1]) * 1000);
|
|
117
|
+
}
|
|
118
|
+
else if (lower.includes("wait for") && !lower.includes("second")) {
|
|
119
|
+
const sel = extractSelector(raw, /wait for\s+"?([^"]+)"?\s+to be/i);
|
|
120
|
+
if (sel)
|
|
121
|
+
await page.waitForSelector(sel, { state: "visible", timeout: 15000 });
|
|
122
|
+
}
|
|
123
|
+
else if (lower.includes("verify") || lower.includes("check") || lower.includes("assert") || lower.includes("should")) {
|
|
124
|
+
// Extract the quoted text to verify it's present on the page
|
|
125
|
+
const textMatch = raw.match(/"([^"]+)"/);
|
|
126
|
+
if (textMatch) {
|
|
127
|
+
const expected = textMatch[1];
|
|
128
|
+
try {
|
|
129
|
+
await page.waitForFunction((text) => document.body.innerText.includes(text), expected, { timeout: 10000 });
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
throw new Error(`Expected text not found: "${expected}"`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
else if (lower.includes("select") || lower.includes("choose")) {
|
|
137
|
+
const selMatch = raw.match(/select\s+"([^"]+)"\s+(?:from|in)\s+"([^"]+)"/i);
|
|
138
|
+
if (selMatch) {
|
|
139
|
+
await page.selectOption(selMatch[2], { label: selMatch[1] });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
console.log(` ⚠️ Unrecognised step (skipped): ${raw}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
stepError = err.message;
|
|
148
|
+
result.success = false;
|
|
149
|
+
}
|
|
150
|
+
// Screenshot after each step
|
|
151
|
+
let screenshotB64 = "";
|
|
152
|
+
try {
|
|
153
|
+
const buf = await page.screenshot({ fullPage: false });
|
|
154
|
+
screenshotB64 = buf.toString("base64");
|
|
155
|
+
result.screenshots.push(screenshotB64);
|
|
156
|
+
}
|
|
157
|
+
catch { /* ignore screenshot errors */ }
|
|
158
|
+
result.steps.push({
|
|
159
|
+
step: raw,
|
|
160
|
+
action: raw,
|
|
161
|
+
duration: Date.now() - t0,
|
|
162
|
+
error: stepError,
|
|
163
|
+
});
|
|
164
|
+
this.options.onProgress?.({
|
|
165
|
+
type: stepError ? "step-error" : "step-complete",
|
|
166
|
+
step: i + 1,
|
|
167
|
+
total: steps.length,
|
|
168
|
+
message: raw,
|
|
169
|
+
error: stepError,
|
|
170
|
+
screenshot: screenshotB64,
|
|
171
|
+
});
|
|
172
|
+
if (this.options.verbose) {
|
|
173
|
+
console.log(` ${stepError ? "✗" : "✓"} Step ${i + 1}/${steps.length}: ${raw}`);
|
|
174
|
+
if (stepError)
|
|
175
|
+
console.log(` Error: ${stepError}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
result.success = false;
|
|
181
|
+
result.error = err.message;
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
await browser?.close().catch(() => { });
|
|
185
|
+
this.activeBrowser = null;
|
|
186
|
+
}
|
|
187
|
+
return result;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
exports.TestExecutor = TestExecutor;
|
|
191
|
+
function extractSelector(step, pattern) {
|
|
192
|
+
const m = step.match(pattern);
|
|
193
|
+
if (!m)
|
|
194
|
+
return null;
|
|
195
|
+
const raw = m[1].trim().replace(/^["']|["']$/g, "");
|
|
196
|
+
if (/^[\w\s-]+$/.test(raw) && !raw.startsWith("#") && !raw.startsWith(".")) {
|
|
197
|
+
return `text=${raw}`;
|
|
198
|
+
}
|
|
199
|
+
return raw;
|
|
200
|
+
}
|
|
201
|
+
// Fast probe: try each locator strategy with a short timeout so fallbacks don't stall
|
|
202
|
+
async function tryClick(page, nameRe, label) {
|
|
203
|
+
const FAST = 1500;
|
|
204
|
+
const strategies = [
|
|
205
|
+
() => page.getByRole('button', { name: nameRe }).first().click({ timeout: FAST }),
|
|
206
|
+
() => page.getByRole('link', { name: nameRe }).first().click({ timeout: FAST }),
|
|
207
|
+
() => page.getByText(nameRe).first().click({ timeout: FAST }),
|
|
208
|
+
() => page.locator(`[value="${label}"], [aria-label="${label}"], [title="${label}"]`).first().click({ timeout: FAST }),
|
|
209
|
+
];
|
|
210
|
+
for (const fn of strategies) {
|
|
211
|
+
try {
|
|
212
|
+
await fn();
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
catch { /* try next */ }
|
|
216
|
+
}
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
async function tryFill(page, label, value) {
|
|
220
|
+
const FAST = 1500;
|
|
221
|
+
const labelRe = new RegExp(escapeRegex(label), "i");
|
|
222
|
+
const variants = labelVariants(label);
|
|
223
|
+
const attrContains = (attr) => variants
|
|
224
|
+
.map((v) => `input[${attr}*="${cssEscape(v)}" i], textarea[${attr}*="${cssEscape(v)}" i], [contenteditable="true"][${attr}*="${cssEscape(v)}" i]`)
|
|
225
|
+
.join(", ");
|
|
226
|
+
// Ordered most-likely-to-resolve first so the success path matches the old
|
|
227
|
+
// 4-strategy implementation in latency. The extra strategies only run when
|
|
228
|
+
// the cheaper ones miss (i.e. the field genuinely needs fuzzier matching).
|
|
229
|
+
const strategies = [
|
|
230
|
+
// 1–2. Same as before — covers labelled inputs and placeholder-only inputs.
|
|
231
|
+
() => page.getByLabel(labelRe).first().fill(value),
|
|
232
|
+
() => page.getByPlaceholder(labelRe).first().fill(value),
|
|
233
|
+
// 3. Accessible role match.
|
|
234
|
+
() => page.getByRole("textbox", { name: labelRe }).first().fill(value),
|
|
235
|
+
// 4. Common automation hooks.
|
|
236
|
+
() => page.locator(attrContains("data-test")).first().fill(value),
|
|
237
|
+
() => page.locator(attrContains("data-testid")).first().fill(value),
|
|
238
|
+
// 5. Native attributes — exact across all variants (kebab/snake/camel/etc.).
|
|
239
|
+
() => page
|
|
240
|
+
.locator(variants
|
|
241
|
+
.flatMap((v) => [
|
|
242
|
+
`input[name="${cssEscape(v)}" i]`,
|
|
243
|
+
`input[id="${cssEscape(v)}" i]`,
|
|
244
|
+
`textarea[name="${cssEscape(v)}" i]`,
|
|
245
|
+
`textarea[id="${cssEscape(v)}" i]`,
|
|
246
|
+
])
|
|
247
|
+
.join(", "))
|
|
248
|
+
.first()
|
|
249
|
+
.fill(value),
|
|
250
|
+
// 6. Native attributes — contains across all variants.
|
|
251
|
+
() => page
|
|
252
|
+
.locator(`${attrContains("name")}, ${attrContains("id")}`)
|
|
253
|
+
.first()
|
|
254
|
+
.fill(value),
|
|
255
|
+
// 7. ARIA / placeholder fallbacks.
|
|
256
|
+
() => page.locator(attrContains("aria-label")).first().fill(value),
|
|
257
|
+
() => page.locator(attrContains("placeholder")).first().fill(value),
|
|
258
|
+
];
|
|
259
|
+
const errors = [];
|
|
260
|
+
for (const fn of strategies) {
|
|
261
|
+
try {
|
|
262
|
+
await Promise.race([
|
|
263
|
+
fn(),
|
|
264
|
+
new Promise((_, r) => setTimeout(() => r(new Error("timeout")), FAST)),
|
|
265
|
+
]);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
catch (e) {
|
|
269
|
+
errors.push(e?.message?.split("\n")[0] || String(e));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
throw new Error(`Could not find input field: "${label}". Tried ${strategies.length} strategies.`);
|
|
273
|
+
}
|
|
274
|
+
/** Token-aware variant generation matching executor.ts/labelVariants. */
|
|
275
|
+
function labelVariants(label) {
|
|
276
|
+
const cleaned = label.trim();
|
|
277
|
+
if (!cleaned)
|
|
278
|
+
return [];
|
|
279
|
+
const tokens = cleaned
|
|
280
|
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
281
|
+
.split(/[\s\-_]+/)
|
|
282
|
+
.filter(Boolean);
|
|
283
|
+
if (tokens.length === 0)
|
|
284
|
+
return [cleaned];
|
|
285
|
+
const lower = tokens.map((t) => t.toLowerCase());
|
|
286
|
+
const out = new Set([
|
|
287
|
+
cleaned,
|
|
288
|
+
cleaned.toLowerCase(),
|
|
289
|
+
lower.join(" "),
|
|
290
|
+
lower.join("-"),
|
|
291
|
+
lower.join("_"),
|
|
292
|
+
lower.join(""),
|
|
293
|
+
lower[0] +
|
|
294
|
+
lower
|
|
295
|
+
.slice(1)
|
|
296
|
+
.map((t) => t[0].toUpperCase() + t.slice(1))
|
|
297
|
+
.join(""),
|
|
298
|
+
lower.map((t) => t[0].toUpperCase() + t.slice(1)).join(""),
|
|
299
|
+
]);
|
|
300
|
+
return Array.from(out).filter((v) => v.length > 0);
|
|
301
|
+
}
|
|
302
|
+
function cssEscape(value) {
|
|
303
|
+
return value.replace(/[\\"]/g, "\\$&");
|
|
304
|
+
}
|
|
305
|
+
function escapeRegex(value) {
|
|
306
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
307
|
+
}
|
|
308
|
+
//# sourceMappingURL=mcp-executor.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cutleryapp/agent",
|
|
3
|
+
"version": "1.0.3",
|
|
4
|
+
"description": "Local agent that connects your machine to the Cutlery QA platform and runs UI tests via Playwright",
|
|
5
|
+
"main": "dist/cli.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cutlery-agent": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist/cli.js",
|
|
11
|
+
"dist/executor.js",
|
|
12
|
+
"dist/mcp-executor.js",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18.0.0"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc && chmod +x dist/cli.js",
|
|
20
|
+
"watch": "tsc --watch",
|
|
21
|
+
"prepublishOnly": "npm run build",
|
|
22
|
+
"package": "npm run build && pkg . --targets node18-macos-x64,node18-linux-x64,node18-win-x64 --output dist/cutlery-agent",
|
|
23
|
+
"package:mac": "npm run build && pkg . --targets node18-macos-x64 --output dist/cutlery-agent-macos",
|
|
24
|
+
"package:linux": "npm run build && pkg . --targets node18-linux-x64 --output dist/cutlery-agent-linux",
|
|
25
|
+
"package:win": "npm run build && pkg . --targets node18-win-x64 --output dist/cutlery-agent-win.exe"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"test-automation",
|
|
29
|
+
"ai-testing",
|
|
30
|
+
"playwright",
|
|
31
|
+
"e2e-testing",
|
|
32
|
+
"cutlery"
|
|
33
|
+
],
|
|
34
|
+
"author": "Cutlery",
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@playwright/test": "^1.48.0",
|
|
41
|
+
"playwright": "^1.48.0",
|
|
42
|
+
"openai": "^4.28.0",
|
|
43
|
+
"commander": "^11.1.0",
|
|
44
|
+
"chalk": "^4.1.2",
|
|
45
|
+
"ora": "^5.4.1",
|
|
46
|
+
"dotenv": "^16.3.1",
|
|
47
|
+
"ws": "^8.16.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^20.10.0",
|
|
51
|
+
"@types/ws": "^8.5.10",
|
|
52
|
+
"typescript": "^5.3.3",
|
|
53
|
+
"pkg": "^5.8.1"
|
|
54
|
+
},
|
|
55
|
+
"pkg": {
|
|
56
|
+
"assets": [
|
|
57
|
+
"node_modules/@playwright/**/*"
|
|
58
|
+
],
|
|
59
|
+
"outputPath": "dist"
|
|
60
|
+
}
|
|
61
|
+
}
|