@auto-wiz/puppeteer 1.2.2 → 1.2.4

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/dist/runner.d.ts CHANGED
@@ -7,6 +7,6 @@ export declare class PuppeteerFlowRunner implements FlowRunner<Page> {
7
7
  * Resolve placeholders in text (e.g., {{username}} → variables.username)
8
8
  */
9
9
  private resolveText;
10
- private getSelector;
11
10
  private getElement;
11
+ private pickBestCandidate;
12
12
  }
package/dist/runner.js CHANGED
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.PuppeteerFlowRunner = void 0;
4
+ const MIN_CONFIDENCE_SCORE = 80;
5
+ const LOCATOR_POLL_INTERVAL_MS = 100;
4
6
  class PuppeteerFlowRunner {
5
7
  async run(flow, page, options = {}) {
6
8
  const extractedData = {};
@@ -62,35 +64,63 @@ class PuppeteerFlowRunner {
62
64
  break;
63
65
  }
64
66
  case "select": {
65
- const selector = this.getSelector(step);
66
- // Puppeteer select uses selector string, not element handle usually
67
+ const el = await this.getElement(page, step, timeout);
67
68
  if (step.value) {
68
- // Need to wait for it first
69
- await page.waitForSelector(selector, { timeout });
70
- await page.select(selector, step.value);
69
+ await el.evaluate((node, value) => {
70
+ if (!(node instanceof HTMLSelectElement)) {
71
+ throw new Error("Element is not a select element");
72
+ }
73
+ node.value = value;
74
+ node.dispatchEvent(new Event("input", { bubbles: true }));
75
+ node.dispatchEvent(new Event("change", { bubbles: true }));
76
+ }, step.value);
71
77
  }
72
78
  break;
73
79
  }
74
80
  case "extract": {
75
- const selector = this.getSelector(step);
76
- await page.waitForSelector(selector, { timeout });
77
- // Extract text
78
- const text = await page.$eval(selector, (el) => el.textContent);
81
+ const el = await this.getElement(page, step, timeout);
82
+ const text = await el.evaluate((node) => node.textContent);
79
83
  return { success: true, extractedData: text?.trim() };
80
84
  }
81
85
  case "waitFor": {
82
86
  if (step.locator) {
83
- const selector = this.getSelector(step);
84
- await page.waitForSelector(selector, {
85
- visible: true,
86
- timeout: step.timeoutMs || timeout,
87
- });
87
+ const handle = await this.getElement(page, step, step.timeoutMs || timeout);
88
+ await handle.dispose();
88
89
  }
89
90
  else if (step.timeoutMs) {
90
91
  await new Promise((r) => setTimeout(r, step.timeoutMs));
91
92
  }
92
93
  break;
93
94
  }
95
+ case "keyboard": {
96
+ const key = step.key;
97
+ if (!key) {
98
+ return { success: false, error: "Keyboard step requires key" };
99
+ }
100
+ if (step.locator) {
101
+ const el = await this.getElement(page, step, timeout);
102
+ await el.focus();
103
+ }
104
+ await page.keyboard.press(key);
105
+ break;
106
+ }
107
+ case "waitForNavigation": {
108
+ const navTimeout = step.timeoutMs || timeout;
109
+ await page.waitForNavigation({
110
+ waitUntil: "domcontentloaded",
111
+ timeout: navTimeout,
112
+ });
113
+ break;
114
+ }
115
+ case "screenshot": {
116
+ if (step.locator) {
117
+ const el = await this.getElement(page, step, timeout);
118
+ const base64 = await el.screenshot({ encoding: "base64" });
119
+ return { success: true, extractedData: base64 };
120
+ }
121
+ const base64 = await page.screenshot({ encoding: "base64" });
122
+ return { success: true, extractedData: base64 };
123
+ }
94
124
  }
95
125
  return { success: true };
96
126
  }
@@ -106,22 +136,259 @@ class PuppeteerFlowRunner {
106
136
  return text;
107
137
  return text.replace(/\{\{(\w+)\}\}/g, (_, key) => variables[key] ?? "");
108
138
  }
109
- getSelector(step) {
139
+ async getElement(page, step, timeout) {
110
140
  if (!("locator" in step) || !step.locator) {
111
141
  throw new Error(`Step ${step.type} requires a locator`);
112
142
  }
113
- const { primary } = step.locator;
114
- return primary;
143
+ const { primary, fallbacks = [], metadata } = step.locator;
144
+ const selectors = [primary, ...fallbacks];
145
+ if (selectors.length === 0) {
146
+ throw new Error(`Step ${step.type} has no valid selectors`);
147
+ }
148
+ const startedAt = Date.now();
149
+ let lastError = "No candidates found";
150
+ while (Date.now() - startedAt < timeout) {
151
+ const resolved = await this.pickBestCandidate(page, selectors, metadata);
152
+ if (resolved) {
153
+ if (resolved.totalCandidates === 1 ||
154
+ resolved.candidate.score >= MIN_CONFIDENCE_SCORE) {
155
+ const handles = await page.$$(resolved.candidate.selector);
156
+ const element = handles[resolved.candidate.elementIndex];
157
+ if (element)
158
+ return element;
159
+ }
160
+ else {
161
+ lastError = `AMBIGUOUS_LOCATOR: ${resolved.totalCandidates} candidates found, best score=${resolved.candidate.score}, selector=${resolved.candidate.selector}`;
162
+ }
163
+ }
164
+ await new Promise((resolve) => setTimeout(resolve, LOCATOR_POLL_INTERVAL_MS));
165
+ }
166
+ throw new Error(`Failed to resolve locator. Tried selectors=${JSON.stringify(selectors)}, metadata=${JSON.stringify(metadata || {})}, reason=${lastError}`);
115
167
  }
116
- async getElement(page, step, timeout) {
117
- const selector = this.getSelector(step);
118
- const element = await page.waitForSelector(selector, {
119
- visible: true,
120
- timeout,
168
+ async pickBestCandidate(page, selectors, metadata) {
169
+ const rawCandidates = [];
170
+ for (const [selectorOrder, selector] of selectors.entries()) {
171
+ let evaluated = [];
172
+ try {
173
+ evaluated = await page.$$eval(selector, (elements, payload) => {
174
+ const getImplicitRoleForElement = (element) => {
175
+ const tagName = element.tagName.toLowerCase();
176
+ const type = element instanceof HTMLInputElement
177
+ ? element.getAttribute("type")
178
+ : null;
179
+ const roleMap = {
180
+ button: "button",
181
+ input: type === "text" || !type
182
+ ? "textbox"
183
+ : type === "checkbox"
184
+ ? "checkbox"
185
+ : type === "radio"
186
+ ? "radio"
187
+ : type === "button" || type === "submit"
188
+ ? "button"
189
+ : "",
190
+ textarea: "textbox",
191
+ select: "combobox",
192
+ };
193
+ return roleMap[tagName] || null;
194
+ };
195
+ const getAssociatedLabelText = (element) => {
196
+ if (!(element instanceof HTMLInputElement) &&
197
+ !(element instanceof HTMLTextAreaElement) &&
198
+ !(element instanceof HTMLSelectElement)) {
199
+ return null;
200
+ }
201
+ if (element.id) {
202
+ const label = document.querySelector(`label[for="${CSS.escape(element.id)}"]`);
203
+ if (label)
204
+ return label.textContent?.trim() || null;
205
+ }
206
+ const parentLabel = element.closest("label");
207
+ if (parentLabel) {
208
+ const clone = parentLabel.cloneNode(true);
209
+ const input = clone.querySelector("input, textarea, select");
210
+ if (input)
211
+ input.remove();
212
+ const text = clone.textContent?.trim();
213
+ if (text)
214
+ return text;
215
+ }
216
+ const labelledBy = element.getAttribute("aria-labelledby");
217
+ if (labelledBy) {
218
+ const labelEl = document.getElementById(labelledBy);
219
+ if (labelEl)
220
+ return labelEl.textContent?.trim() || null;
221
+ }
222
+ const prevSibling = element.previousElementSibling;
223
+ if (prevSibling && prevSibling.tagName.toLowerCase() === "label") {
224
+ return prevSibling.textContent?.trim() || null;
225
+ }
226
+ const parent = element.parentElement;
227
+ if (parent) {
228
+ const labelInParent = parent.querySelector("label");
229
+ if (labelInParent) {
230
+ const forAttr = labelInParent.getAttribute("for");
231
+ if (!forAttr || forAttr === element.id) {
232
+ return labelInParent.textContent?.trim() || null;
233
+ }
234
+ }
235
+ }
236
+ return null;
237
+ };
238
+ const getFormFieldIndex = (element) => {
239
+ if (!(element instanceof HTMLElement))
240
+ return null;
241
+ const form = element.closest("form");
242
+ if (!form)
243
+ return null;
244
+ const fields = Array.from(form.querySelectorAll("input, textarea, select"));
245
+ const index = fields.indexOf(element);
246
+ return index >= 0 ? index + 1 : null;
247
+ };
248
+ const isVisible = (element) => {
249
+ if (!(element instanceof HTMLElement || element instanceof SVGElement)) {
250
+ return false;
251
+ }
252
+ const style = window.getComputedStyle(element);
253
+ if (style.display === "none" ||
254
+ style.visibility === "hidden" ||
255
+ style.opacity === "0") {
256
+ return false;
257
+ }
258
+ if (element.getClientRects().length === 0) {
259
+ return false;
260
+ }
261
+ return true;
262
+ };
263
+ const isInteractable = (element) => {
264
+ if (!(element instanceof HTMLElement || element instanceof SVGElement)) {
265
+ return false;
266
+ }
267
+ if (!isVisible(element))
268
+ return false;
269
+ if (element instanceof HTMLInputElement ||
270
+ element instanceof HTMLTextAreaElement ||
271
+ element instanceof HTMLSelectElement ||
272
+ element instanceof HTMLButtonElement) {
273
+ if (element.disabled)
274
+ return false;
275
+ }
276
+ const style = window.getComputedStyle(element);
277
+ return style.pointerEvents !== "none";
278
+ };
279
+ const getDomPath = (element) => {
280
+ const parts = [];
281
+ let current = element;
282
+ while (current && current !== document.body) {
283
+ const tag = current.tagName.toLowerCase();
284
+ let siblingIndex = 1;
285
+ let sibling = current.previousElementSibling;
286
+ while (sibling) {
287
+ if (sibling.tagName === current.tagName)
288
+ siblingIndex += 1;
289
+ sibling = sibling.previousElementSibling;
290
+ }
291
+ parts.unshift(`${tag}:nth-of-type(${siblingIndex})`);
292
+ current = current.parentElement;
293
+ }
294
+ return parts.join(">");
295
+ };
296
+ const calculateScore = (element, candidateMetadata) => {
297
+ if (!candidateMetadata)
298
+ return 0;
299
+ let score = 0;
300
+ const elementTag = element.tagName.toLowerCase();
301
+ if (candidateMetadata.testId &&
302
+ element instanceof HTMLElement &&
303
+ (element.getAttribute("data-testid") === candidateMetadata.testId ||
304
+ element.getAttribute("data-test") === candidateMetadata.testId ||
305
+ element.getAttribute("data-cy") === candidateMetadata.testId ||
306
+ element.getAttribute("data-test-id") === candidateMetadata.testId)) {
307
+ score += 120;
308
+ }
309
+ if (candidateMetadata.labelText) {
310
+ const labelText = getAssociatedLabelText(element);
311
+ if (labelText === candidateMetadata.labelText) {
312
+ score += 100;
313
+ }
314
+ else if (labelText &&
315
+ labelText.includes(candidateMetadata.labelText)) {
316
+ score += 50;
317
+ }
318
+ }
319
+ if (candidateMetadata.formContext?.fieldIndex) {
320
+ const index = getFormFieldIndex(element);
321
+ if (index === candidateMetadata.formContext.fieldIndex) {
322
+ score += 90;
323
+ }
324
+ }
325
+ if (candidateMetadata.placeholder &&
326
+ element instanceof HTMLElement &&
327
+ element.getAttribute("placeholder") === candidateMetadata.placeholder) {
328
+ score += 70;
329
+ }
330
+ if (candidateMetadata.ariaLabel &&
331
+ element instanceof HTMLElement &&
332
+ element.getAttribute("aria-label") === candidateMetadata.ariaLabel) {
333
+ score += 60;
334
+ }
335
+ if (candidateMetadata.tagName &&
336
+ elementTag === candidateMetadata.tagName) {
337
+ score += 20;
338
+ }
339
+ if (candidateMetadata.role) {
340
+ const elementRole = element instanceof HTMLElement
341
+ ? element.getAttribute("role") || getImplicitRoleForElement(element)
342
+ : null;
343
+ if (elementRole === candidateMetadata.role) {
344
+ score += 10;
345
+ }
346
+ }
347
+ return score;
348
+ };
349
+ return elements.map((element, elementIndex) => ({
350
+ selector: payload.selector,
351
+ selectorOrder: payload.selectorOrder,
352
+ elementIndex,
353
+ score: calculateScore(element, payload.metadata),
354
+ visible: isVisible(element),
355
+ interactable: isInteractable(element),
356
+ domPath: getDomPath(element),
357
+ }));
358
+ }, { metadata, selector, selectorOrder });
359
+ }
360
+ catch {
361
+ continue;
362
+ }
363
+ rawCandidates.push(...evaluated);
364
+ }
365
+ const filtered = rawCandidates.filter((item) => item.visible && item.interactable);
366
+ if (filtered.length === 0)
367
+ return null;
368
+ const deduped = new Map();
369
+ for (const item of filtered) {
370
+ const prev = deduped.get(item.domPath);
371
+ if (!prev) {
372
+ deduped.set(item.domPath, item);
373
+ continue;
374
+ }
375
+ if (item.score > prev.score ||
376
+ (item.score === prev.score && item.selectorOrder < prev.selectorOrder)) {
377
+ deduped.set(item.domPath, item);
378
+ }
379
+ }
380
+ const resolved = Array.from(deduped.values());
381
+ resolved.sort((a, b) => {
382
+ if (b.score !== a.score)
383
+ return b.score - a.score;
384
+ if (a.selectorOrder !== b.selectorOrder)
385
+ return a.selectorOrder - b.selectorOrder;
386
+ return a.elementIndex - b.elementIndex;
121
387
  });
122
- if (!element)
123
- throw new Error(`Element not found: ${selector}`);
124
- return element;
388
+ return {
389
+ candidate: resolved[0],
390
+ totalCandidates: resolved.length,
391
+ };
125
392
  }
126
393
  }
127
394
  exports.PuppeteerFlowRunner = PuppeteerFlowRunner;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@auto-wiz/puppeteer",
3
- "version": "1.2.2",
3
+ "version": "1.2.4",
4
4
  "license": "MIT",
5
5
  "author": "JaeSang",
6
6
  "repository": {
@@ -23,7 +23,7 @@
23
23
  }
24
24
  },
25
25
  "peerDependencies": {
26
- "@auto-wiz/core": "^1.2.0"
26
+ "@auto-wiz/core": "^1.2.3"
27
27
  },
28
28
  "dependencies": {
29
29
  "puppeteer": "^23.0.0"
@@ -31,7 +31,7 @@
31
31
  "devDependencies": {
32
32
  "typescript": "^5.0.0",
33
33
  "@types/node": "^20.0.0",
34
- "@auto-wiz/core": "1.2.2"
34
+ "@auto-wiz/core": "1.2.3"
35
35
  },
36
36
  "scripts": {
37
37
  "build": "tsc"