@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,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
+ }