intellitester 0.2.72 → 0.3.1

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 CHANGED
@@ -113,6 +113,79 @@ When execution pauses, you can:
113
113
  - Examine selectors and elements
114
114
  - Continue execution when ready
115
115
 
116
+ ### Log Action
117
+
118
+ Use the `log` action to output debug information during test execution:
119
+
120
+ ```yaml
121
+ steps:
122
+ # Log a static message
123
+ - type: log
124
+ message: "Starting checkout flow"
125
+
126
+ # Log JavaScript expression result
127
+ - type: log
128
+ eval: "document.title"
129
+
130
+ # Log element content
131
+ - type: log
132
+ target: { css: ".error-message" }
133
+ format: html # text (default), html, or json
134
+
135
+ # Log inside iframe
136
+ - type: log
137
+ target: { css: ".stripe-error" }
138
+ frame: { css: "iframe[name='stripe']" }
139
+ ```
140
+
141
+ ## AI-Assisted Test Healing
142
+
143
+ IntelliTester can automatically fix broken selectors using AI when tests fail.
144
+
145
+ ### Configuration
146
+
147
+ Enable AI healing in `intellitester.config.yaml`:
148
+
149
+ ```yaml
150
+ ai:
151
+ provider: groq # anthropic, openai, ollama, groq, openrouter
152
+ model: llama-3.3-70b-versatile
153
+ apiKey: ${GROQ_API_KEY}
154
+ temperature: 0.2
155
+ maxTokens: 4096
156
+
157
+ healing:
158
+ enabled: true
159
+ maxAttempts: 3 # 1-10
160
+ ```
161
+
162
+ ### Supported Providers
163
+
164
+ | Provider | Env Variable | Example Model |
165
+ |----------|--------------|---------------|
166
+ | `anthropic` | `ANTHROPIC_API_KEY` | `claude-3-5-sonnet-20241022` |
167
+ | `openai` | `OPENAI_API_KEY` | `gpt-4o` |
168
+ | `groq` | `GROQ_API_KEY` | `llama-3.3-70b-versatile` |
169
+ | `openrouter` | `OPENROUTER_API_KEY` | `anthropic/claude-3.5-sonnet` |
170
+ | `ollama` | - | `llama3.2` |
171
+
172
+ ### How It Works
173
+
174
+ When an action fails:
175
+ 1. AI analyzes the page HTML and error message
176
+ 2. Suggests a new selector (testId, text, role, or css)
177
+ 3. Validates the suggestion finds an element
178
+ 4. Retries the action with the fixed selector
179
+
180
+ ```
181
+ [FAIL] tap - Element not found: testId="old-button-id"
182
+
183
+ 🔧 Attempting AI-assisted healing (max 3 attempts)...
184
+ ✅ AI found fix: {"text": "Submit Order"}
185
+
186
+ [OK] tap
187
+ ```
188
+
116
189
  ## Iframe Targeting (frame)
117
190
 
118
191
  Target elements inside iframes using the `frame` property. Essential for payment forms (Stripe, PayPal), embedded widgets, and third-party integrations.
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- var chunkCGPHTHY4_cjs = require('./chunk-CGPHTHY4.cjs');
3
+ var chunkN7TZR4A2_cjs = require('./chunk-N7TZR4A2.cjs');
4
4
  var chunkYNHXOSMZ_cjs = require('./chunk-YNHXOSMZ.cjs');
5
5
  var chunk2ZSINOCK_cjs = require('./chunk-2ZSINOCK.cjs');
6
6
  var uniqueNamesGenerator = require('unique-names-generator');
@@ -1039,6 +1039,56 @@ var OllamaProvider = class {
1039
1039
  return typeof content === "string" ? content : JSON.stringify(content);
1040
1040
  }
1041
1041
  };
1042
+ var GroqProvider = class {
1043
+ constructor(config) {
1044
+ this.config = config;
1045
+ const apiKey = config.apiKey ? resolveEnvVars(config.apiKey) : process.env.GROQ_API_KEY;
1046
+ this.client = new openai.OpenAI({
1047
+ apiKey,
1048
+ model: this.config.model,
1049
+ temperature: this.config.temperature,
1050
+ baseURL: "https://api.groq.com/openai/v1"
1051
+ });
1052
+ }
1053
+ async generateCompletion(prompt, systemPrompt) {
1054
+ const messages = [];
1055
+ if (systemPrompt) {
1056
+ messages.push({ role: "system", content: systemPrompt });
1057
+ }
1058
+ messages.push({ role: "user", content: prompt });
1059
+ const response = await this.client.chat({ messages });
1060
+ const content = response.message.content;
1061
+ if (!content) {
1062
+ throw new Error("No content in GROQ response");
1063
+ }
1064
+ return typeof content === "string" ? content : JSON.stringify(content);
1065
+ }
1066
+ };
1067
+ var OpenRouterProvider = class {
1068
+ constructor(config) {
1069
+ this.config = config;
1070
+ const apiKey = config.apiKey ? resolveEnvVars(config.apiKey) : process.env.OPENROUTER_API_KEY;
1071
+ this.client = new openai.OpenAI({
1072
+ apiKey,
1073
+ model: this.config.model,
1074
+ temperature: this.config.temperature,
1075
+ baseURL: "https://openrouter.ai/api/v1"
1076
+ });
1077
+ }
1078
+ async generateCompletion(prompt, systemPrompt) {
1079
+ const messages = [];
1080
+ if (systemPrompt) {
1081
+ messages.push({ role: "system", content: systemPrompt });
1082
+ }
1083
+ messages.push({ role: "user", content: prompt });
1084
+ const response = await this.client.chat({ messages });
1085
+ const content = response.message.content;
1086
+ if (!content) {
1087
+ throw new Error("No content in OpenRouter response");
1088
+ }
1089
+ return typeof content === "string" ? content : JSON.stringify(content);
1090
+ }
1091
+ };
1042
1092
  function createAIProvider(config) {
1043
1093
  switch (config.provider) {
1044
1094
  case "anthropic":
@@ -1047,6 +1097,10 @@ function createAIProvider(config) {
1047
1097
  return new OpenAIProvider(config);
1048
1098
  case "ollama":
1049
1099
  return new OllamaProvider(config);
1100
+ case "groq":
1101
+ return new GroqProvider(config);
1102
+ case "openrouter":
1103
+ return new OpenRouterProvider(config);
1050
1104
  }
1051
1105
  }
1052
1106
 
@@ -1143,6 +1197,178 @@ Return ONLY valid JSON, no additional text.`;
1143
1197
  };
1144
1198
  }
1145
1199
  }
1200
+
1201
+ // src/ai/healingAgent.ts
1202
+ async function checkSelector(page, selector) {
1203
+ try {
1204
+ const count = await page.locator(selector).count();
1205
+ if (count === 0) return { found: false, count: 0 };
1206
+ const texts = await page.locator(selector).allTextContents();
1207
+ return { found: true, count, texts: texts.slice(0, 5) };
1208
+ } catch (e) {
1209
+ return { found: false, count: 0, error: String(e) };
1210
+ }
1211
+ }
1212
+ async function checkByText(page, text) {
1213
+ try {
1214
+ const locator = page.getByText(text, { exact: false });
1215
+ const count = await locator.count();
1216
+ if (count === 0) return { found: false, count: 0 };
1217
+ return { found: true, count };
1218
+ } catch (e) {
1219
+ return { found: false, count: 0, error: String(e) };
1220
+ }
1221
+ }
1222
+ async function checkByRole(page, role, name) {
1223
+ try {
1224
+ const locator = page.getByRole(role, name ? { name } : void 0);
1225
+ const count = await locator.count();
1226
+ if (count === 0) return { found: false, count: 0 };
1227
+ return { found: true, count };
1228
+ } catch (e) {
1229
+ return { found: false, count: 0, error: String(e) };
1230
+ }
1231
+ }
1232
+ async function checkTestId(page, testId) {
1233
+ try {
1234
+ const selector = `[data-testid="${testId}"], #${CSS.escape(testId)}`;
1235
+ const count = await page.locator(selector).count();
1236
+ return { found: count > 0, count };
1237
+ } catch (e) {
1238
+ return { found: false, count: 0, error: String(e) };
1239
+ }
1240
+ }
1241
+ function extractLocatorFromResponse(response) {
1242
+ const jsonMatch = response.match(/\{[\s\S]*?"(?:testId|text|css|role)"[\s\S]*?\}/);
1243
+ if (!jsonMatch) return null;
1244
+ try {
1245
+ const parsed = JSON.parse(jsonMatch[0]);
1246
+ if (parsed.testId || parsed.text || parsed.css || parsed.role) {
1247
+ return parsed;
1248
+ }
1249
+ } catch {
1250
+ const codeMatch = response.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/);
1251
+ if (codeMatch) {
1252
+ try {
1253
+ const parsed = JSON.parse(codeMatch[1]);
1254
+ if (parsed.testId || parsed.text || parsed.css || parsed.role) {
1255
+ return parsed;
1256
+ }
1257
+ } catch {
1258
+ }
1259
+ }
1260
+ }
1261
+ const cssMatch = response.match(/css['":\s]+['"]([^'"]+)['"]/i);
1262
+ if (cssMatch) return { css: cssMatch[1] };
1263
+ const textMatch = response.match(/text['":\s]+['"]([^'"]+)['"]/i);
1264
+ if (textMatch) return { text: textMatch[1] };
1265
+ const testIdMatch = response.match(/testId['":\s]+['"]([^'"]+)['"]/i);
1266
+ if (testIdMatch) return { testId: testIdMatch[1] };
1267
+ return null;
1268
+ }
1269
+ async function runHealingAgent(context, aiConfig, maxAttempts = 3) {
1270
+ const provider = createAIProvider(aiConfig);
1271
+ const currentTarget = "target" in context.action ? context.action.target : null;
1272
+ const systemPrompt = `You are debugging a failing web test action. Your goal is to analyze the page and suggest a working selector.
1273
+
1274
+ When suggesting a selector, respond with a JSON object containing ONE of these fields:
1275
+ - testId: for data-testid attributes (most reliable)
1276
+ - text: for visible text content
1277
+ - css: for CSS selectors
1278
+ - role: for ARIA roles (with optional "name" field)
1279
+
1280
+ Example responses:
1281
+ {"testId": "submit-button"}
1282
+ {"text": "Sign In"}
1283
+ {"css": "button.primary"}
1284
+ {"role": "button", "name": "Submit"}
1285
+
1286
+ Prefer selectors in this order of reliability:
1287
+ 1. testId - most stable, unlikely to change
1288
+ 2. text - good for buttons, links
1289
+ 3. role + name - good for accessible elements
1290
+ 4. css - last resort, more brittle
1291
+
1292
+ Respond ONLY with the JSON selector object, no other text.`;
1293
+ let attempts = 0;
1294
+ let lastExplanation = "";
1295
+ while (attempts < maxAttempts) {
1296
+ attempts++;
1297
+ const validationResults = [];
1298
+ if (currentTarget) {
1299
+ if (currentTarget.testId) {
1300
+ const result = await checkTestId(context.page, currentTarget.testId);
1301
+ validationResults.push(`testId "${currentTarget.testId}": ${result.found ? `found ${result.count} elements` : "NOT FOUND"}`);
1302
+ }
1303
+ if (currentTarget.text) {
1304
+ const result = await checkByText(context.page, currentTarget.text);
1305
+ validationResults.push(`text "${currentTarget.text}": ${result.found ? `found ${result.count} elements` : "NOT FOUND"}`);
1306
+ }
1307
+ if (currentTarget.css) {
1308
+ const result = await checkSelector(context.page, currentTarget.css);
1309
+ validationResults.push(`css "${currentTarget.css}": ${result.found ? `found ${result.count} elements` : "NOT FOUND"}`);
1310
+ }
1311
+ if (currentTarget.role) {
1312
+ const result = await checkByRole(context.page, currentTarget.role, currentTarget.name);
1313
+ validationResults.push(`role "${currentTarget.role}"${currentTarget.name ? ` name="${currentTarget.name}"` : ""}: ${result.found ? `found ${result.count} elements` : "NOT FOUND"}`);
1314
+ }
1315
+ }
1316
+ const prompt = `Action type: ${context.action.type}
1317
+ Error: ${context.error}
1318
+
1319
+ Failed selector: ${JSON.stringify(currentTarget)}
1320
+
1321
+ Validation results:
1322
+ ${validationResults.length > 0 ? validationResults.join("\n") : "No current selector to validate"}
1323
+
1324
+ Page HTML (first 6000 chars):
1325
+ ${context.pageContent.slice(0, 6e3)}
1326
+
1327
+ Based on the page content, suggest a working selector for this action. Respond with a JSON object.`;
1328
+ try {
1329
+ const response = await provider.generateCompletion(prompt, systemPrompt);
1330
+ const suggestedLocator = extractLocatorFromResponse(response);
1331
+ if (!suggestedLocator) {
1332
+ lastExplanation = `Attempt ${attempts}: Could not parse selector from AI response`;
1333
+ continue;
1334
+ }
1335
+ let isValid = false;
1336
+ if (suggestedLocator.testId) {
1337
+ const result = await checkTestId(context.page, suggestedLocator.testId);
1338
+ isValid = result.found;
1339
+ } else if (suggestedLocator.text) {
1340
+ const result = await checkByText(context.page, suggestedLocator.text);
1341
+ isValid = result.found;
1342
+ } else if (suggestedLocator.css) {
1343
+ const result = await checkSelector(context.page, suggestedLocator.css);
1344
+ isValid = result.found;
1345
+ } else if (suggestedLocator.role) {
1346
+ const result = await checkByRole(context.page, suggestedLocator.role, suggestedLocator.name);
1347
+ isValid = result.found;
1348
+ }
1349
+ if (isValid) {
1350
+ const fixedAction = { ...context.action };
1351
+ if ("target" in fixedAction) {
1352
+ fixedAction.target = suggestedLocator;
1353
+ }
1354
+ return {
1355
+ success: true,
1356
+ fixedAction,
1357
+ attempts,
1358
+ explanation: `Found working selector: ${JSON.stringify(suggestedLocator)}`
1359
+ };
1360
+ }
1361
+ lastExplanation = `Attempt ${attempts}: Suggested selector ${JSON.stringify(suggestedLocator)} did not find any elements`;
1362
+ } catch (e) {
1363
+ lastExplanation = `Attempt ${attempts}: AI error - ${e instanceof Error ? e.message : String(e)}`;
1364
+ }
1365
+ }
1366
+ return {
1367
+ success: false,
1368
+ attempts,
1369
+ explanation: lastExplanation || "Could not find a working selector within the allowed attempts"
1370
+ };
1371
+ }
1146
1372
  var SERVER_MARKER_FILE = "server.json";
1147
1373
  var INTELLITESTER_DIR = ".intellitester";
1148
1374
  var getMarkerPath = (cwd) => path4__namespace.join(cwd, INTELLITESTER_DIR, SERVER_MARKER_FILE);
@@ -1818,7 +2044,8 @@ async function handleInteractiveError(page, action, error, screenshotDir, stepIn
1818
2044
  return response.action || "abort";
1819
2045
  }
1820
2046
  async function executeActionWithRetry(page, action, index, options) {
1821
- const { baseUrl, context, screenshotDir, debugMode, interactive, aiConfig, browserName } = options;
2047
+ const { baseUrl, context, screenshotDir, debugMode, interactive, aiConfig, browserName, healing } = options;
2048
+ const extras = {};
1822
2049
  const buildTrackPayload = (stepExtras) => {
1823
2050
  if (!("track" in action)) return null;
1824
2051
  const track2 = action.track;
@@ -2231,6 +2458,50 @@ async function executeActionWithRetry(page, action, index, options) {
2231
2458
  }
2232
2459
  break;
2233
2460
  }
2461
+ case "log": {
2462
+ const logAction = action;
2463
+ const format = logAction.format ?? "text";
2464
+ let logOutput;
2465
+ if (logAction.message) {
2466
+ logOutput = interpolateVariables(logAction.message, context.variables);
2467
+ console.log(`[LOG] ${logOutput}`);
2468
+ } else if (logAction.eval) {
2469
+ const interpolated = interpolateVariables(logAction.eval, context.variables);
2470
+ try {
2471
+ const result = await page.evaluate(interpolated);
2472
+ logOutput = typeof result === "string" ? result : JSON.stringify(result, null, 2);
2473
+ console.log(`[LOG eval] ${logOutput}`);
2474
+ } catch (evalError) {
2475
+ logOutput = `[eval error] ${evalError instanceof Error ? evalError.message : String(evalError)}`;
2476
+ console.log(`[LOG] ${logOutput}`);
2477
+ }
2478
+ } else if (logAction.target) {
2479
+ const frameContext = getActionContext(page, logAction.frame);
2480
+ const handle = resolveLocatorInContext(frameContext, logAction.target);
2481
+ switch (format) {
2482
+ case "html":
2483
+ logOutput = await handle.innerHTML();
2484
+ break;
2485
+ case "json":
2486
+ const text = await handle.textContent() ?? "";
2487
+ try {
2488
+ logOutput = JSON.stringify(JSON.parse(text), null, 2);
2489
+ } catch {
2490
+ logOutput = text;
2491
+ }
2492
+ break;
2493
+ case "text":
2494
+ default:
2495
+ logOutput = await handle.textContent() ?? "";
2496
+ break;
2497
+ }
2498
+ console.log(`[LOG element] ${logOutput}`);
2499
+ } else {
2500
+ logOutput = "(no content)";
2501
+ }
2502
+ extras.logOutput = logOutput;
2503
+ break;
2504
+ }
2234
2505
  case "fail": {
2235
2506
  const failAction = action;
2236
2507
  throw new Error(failAction.message);
@@ -2296,7 +2567,7 @@ async function executeActionWithRetry(page, action, index, options) {
2296
2567
  });
2297
2568
  }
2298
2569
  } else {
2299
- const { loadWorkflowDefinition, loadTestDefinition: loadTestDefinition2 } = await import('./loader-425Z4YO5.cjs');
2570
+ const { loadWorkflowDefinition, loadTestDefinition: loadTestDefinition2 } = await import('./loader-MPGLPS4F.cjs');
2300
2571
  const workflowPath = path4__namespace.default.resolve(process.cwd(), branchToExecute.workflow);
2301
2572
  const workflowDir = path4__namespace.default.dirname(workflowPath);
2302
2573
  if (debugMode) {
@@ -2343,9 +2614,36 @@ async function executeActionWithRetry(page, action, index, options) {
2343
2614
  if (trackedPayload) {
2344
2615
  await chunkYNHXOSMZ_cjs.track(trackedPayload);
2345
2616
  }
2346
- return;
2617
+ return extras;
2347
2618
  } catch (err) {
2348
2619
  const error = err instanceof Error ? err : new Error(String(err));
2620
+ if (healing?.enabled && aiConfig && hasTarget(action)) {
2621
+ const maxAttempts = healing.maxAttempts ?? 3;
2622
+ console.log(`
2623
+ \u{1F527} Attempting AI-assisted healing (max ${maxAttempts} attempts)...`);
2624
+ try {
2625
+ const pageContent = await page.content();
2626
+ const healingContext = {
2627
+ page,
2628
+ action,
2629
+ error: error.message,
2630
+ pageContent
2631
+ };
2632
+ const healingResult = await runHealingAgent(healingContext, aiConfig, maxAttempts);
2633
+ if (healingResult.success && healingResult.fixedAction) {
2634
+ console.log(`\u2705 AI found fix: ${healingResult.explanation}`);
2635
+ const fixedExtras = await executeActionWithRetry(page, healingResult.fixedAction, index, {
2636
+ ...options,
2637
+ healing: { enabled: false }
2638
+ });
2639
+ return fixedExtras;
2640
+ } else {
2641
+ console.log(`\u274C AI healing failed: ${healingResult.explanation}`);
2642
+ }
2643
+ } catch (healingError) {
2644
+ console.log(`\u274C AI healing error: ${healingError instanceof Error ? healingError.message : String(healingError)}`);
2645
+ }
2646
+ }
2349
2647
  if (interactive && aiConfig && hasTarget(action)) {
2350
2648
  const choice = await handleInteractiveError(page, action, error, screenshotDir, index, aiConfig);
2351
2649
  switch (choice) {
@@ -2354,7 +2652,7 @@ async function executeActionWithRetry(page, action, index, options) {
2354
2652
  continue;
2355
2653
  case "skip":
2356
2654
  console.log("Skipping step...\n");
2357
- return;
2655
+ return extras;
2358
2656
  case "debug":
2359
2657
  console.log("Opening Playwright Inspector...\n");
2360
2658
  await page.pause();
@@ -2732,16 +3030,17 @@ var runWebTest = async (test, options = {}) => {
2732
3030
  }
2733
3031
  continue;
2734
3032
  }
2735
- await executeActionWithRetry(page, action, index, {
3033
+ const actionExtras = await executeActionWithRetry(page, action, index, {
2736
3034
  baseUrl: options.baseUrl ?? test.config?.web?.baseUrl,
2737
3035
  context: executionContext,
2738
3036
  screenshotDir,
2739
3037
  debugMode,
2740
3038
  interactive,
2741
3039
  aiConfig: options.aiConfig,
2742
- browserName
3040
+ browserName,
3041
+ healing: options.healing
2743
3042
  });
2744
- sizeResults.push({ action, status: "passed" });
3043
+ sizeResults.push({ action, status: "passed", logOutput: actionExtras.logOutput });
2745
3044
  } catch (error) {
2746
3045
  const message = error instanceof Error ? error.message : String(error);
2747
3046
  sizeResults.push({ action, status: "failed", error: message });
@@ -3350,7 +3649,7 @@ async function runTestInWorkflow(test, page, context, options, _workflowDir, wor
3350
3649
  if (debugMode) {
3351
3650
  console.log(` [DEBUG] waitForBranch: loading workflow from ${workflowPath}`);
3352
3651
  }
3353
- const { loadWorkflowDefinition } = await import('./loader-425Z4YO5.cjs');
3652
+ const { loadWorkflowDefinition } = await import('./loader-MPGLPS4F.cjs');
3354
3653
  const nestedWorkflow = await loadWorkflowDefinition(workflowPath);
3355
3654
  if (branch.variables) {
3356
3655
  for (const [key, value] of Object.entries(branch.variables)) {
@@ -3360,7 +3659,7 @@ async function runTestInWorkflow(test, page, context, options, _workflowDir, wor
3360
3659
  }
3361
3660
  for (const testRef of nestedWorkflow.tests) {
3362
3661
  const testFilePath = path4__namespace.default.resolve(path4__namespace.default.dirname(workflowPath), testRef.file);
3363
- const nestedTest = await chunkCGPHTHY4_cjs.loadTestDefinition(testFilePath);
3662
+ const nestedTest = await chunkN7TZR4A2_cjs.loadTestDefinition(testFilePath);
3364
3663
  if (nestedTest.variables) {
3365
3664
  for (const [key, value] of Object.entries(nestedTest.variables)) {
3366
3665
  const interpolated = interpolateVariables(value, context.variables);
@@ -3622,7 +3921,7 @@ Starting workflow: ${workflow.name}`);
3622
3921
  console.log(` Test ID: ${testRef.id}`);
3623
3922
  }
3624
3923
  try {
3625
- const test = await chunkCGPHTHY4_cjs.loadTestDefinition(testFilePath);
3924
+ const test = await chunkN7TZR4A2_cjs.loadTestDefinition(testFilePath);
3626
3925
  if (testRef.variables) {
3627
3926
  for (const [key, value] of Object.entries(testRef.variables)) {
3628
3927
  const interpolated = interpolateWorkflowVariables(
@@ -4069,5 +4368,5 @@ exports.runWorkflowWithContext = runWorkflowWithContext;
4069
4368
  exports.setupAppwriteTracking = setupAppwriteTracking;
4070
4369
  exports.startTrackingServer = startTrackingServer;
4071
4370
  exports.webServerManager = webServerManager;
4072
- //# sourceMappingURL=chunk-7JQTLV5I.cjs.map
4073
- //# sourceMappingURL=chunk-7JQTLV5I.cjs.map
4371
+ //# sourceMappingURL=chunk-4DYHFFIN.cjs.map
4372
+ //# sourceMappingURL=chunk-4DYHFFIN.cjs.map