-
-
Notifications
You must be signed in to change notification settings - Fork 91
feat(goto): add actions runner with locators and auto-batching #885
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| 'use strict' | ||
|
|
||
| /** | ||
| * Parallel-safe batch key, or null for barrier actions. | ||
| * | ||
| * `pdf` is a barrier: it toggles print media emulation on the shared page. | ||
| * | ||
| * @param {string} type | ||
| * @returns {'inject'|'screenshot'|null} | ||
| */ | ||
| const batchKey = type => { | ||
| if (type === 'inject') return 'inject' | ||
| if (type === 'screenshot') return 'screenshot' | ||
| return null | ||
| } | ||
|
|
||
| /** | ||
| * Group consecutive parallel-safe actions into waves. | ||
| * | ||
| * @param {Array<{ type: string }>} actions | ||
| * @returns {Array<{ key: string|null, actions: object[], startIndex: number }>} | ||
| */ | ||
| const batchActions = actions => { | ||
| const waves = [] | ||
| let current = null | ||
|
|
||
| for (let i = 0; i < actions.length; i++) { | ||
| const action = actions[i] | ||
| const key = batchKey(action.type) | ||
|
|
||
| if (key && current && current.key === key) { | ||
| current.actions.push(action) | ||
| continue | ||
| } | ||
|
|
||
| if (key) { | ||
| current = { key, actions: [action], startIndex: i } | ||
| waves.push(current) | ||
| continue | ||
| } | ||
|
|
||
| current = null | ||
| waves.push({ key: null, actions: [action], startIndex: i }) | ||
| } | ||
|
|
||
| return waves | ||
| } | ||
|
|
||
| module.exports = { batchActions, batchKey } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,164 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'use strict' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const { setTimeout } = require('node:timers/promises') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const { toSelector, hasElementLocator, isSet } = require('./locator') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Clamp a per-action timeout to the remaining request budget. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * @param {string|number|undefined} value | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * @param {number} budget | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * @returns {number} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const clampTimeout = (value, budget) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (value == null || value === '') return budget | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let ms = value | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (typeof value === 'string') { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const match = value.trim().match(/^(\d+(?:\.\d+)?)\s*(ms|s)?$/i) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!match) return budget | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ms = match[2] && match[2].toLowerCase() === 's' ? Number(match[1]) * 1000 : Number(match[1]) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!Number.isFinite(ms) || ms < 0) return budget | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return Math.min(ms, budget) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const MAX_GLOB_LENGTH = 512 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const globToRegExp = pattern => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const raw = String(pattern) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (raw.length > MAX_GLOB_LENGTH) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw new Error(`wait: request pattern exceeds ${MAX_GLOB_LENGTH} characters`) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const source = raw | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .split('*') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .join('[\\s\\S]*') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return new RegExp(`^${source}$`) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+28
to
+38
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Bound the wildcard count to prevent catastrophic backtracking.
Add a wildcard limit, or collapse consecutive wildcards and use a lazy quantifier. 🛡️ Proposed fix to cap wildcards const MAX_GLOB_LENGTH = 512
+const MAX_GLOB_WILDCARDS = 16
const globToRegExp = pattern => {
const raw = String(pattern)
if (raw.length > MAX_GLOB_LENGTH) {
throw new Error(`wait: request pattern exceeds ${MAX_GLOB_LENGTH} characters`)
}
- const source = raw
+ // collapse runs of `*` so `**` does not add a redundant quantifier
+ const normalized = raw.replace(/\*{2,}/g, '*')
+ const wildcards = normalized.split('*').length - 1
+ if (wildcards > MAX_GLOB_WILDCARDS) {
+ throw new Error(`wait: request pattern exceeds ${MAX_GLOB_WILDCARDS} wildcards`)
+ }
+ const source = normalized
.split('*')
.map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('[\\s\\S]*')
return new RegExp(`^${source}$`)
}📝 Committable suggestion
Suggested change
🧰 Tools🪛 ast-grep (0.45.1)[warning] 36-36: Detects non-literal values in regular expressions (detect-non-literal-regexp) 🤖 Prompt for AI AgentsSource: Linters/SAST tools |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const waitForText = (page, action, timeout) => | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| page.waitForFunction( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| (text, hidden) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const body = document.body ? document.body.innerText || '' : '' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const present = body.includes(text) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return hidden ? !present : present | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { timeout }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| action.text, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Boolean(action.hidden) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const waitForResponse = async (page, action, { timeout, responseBuffer }) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const pattern = globToRegExp(action.request) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const match = res => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return pattern.test(res.url()) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return false | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const consume = response => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const index = responseBuffer.indexOf(response) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (index !== -1) responseBuffer.splice(index, 1) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return response | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const buffered = responseBuffer.find(match) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (buffered) return consume(buffered) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return consume(await page.waitForResponse(match, { timeout })) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const handlers = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async inject (page, action, { inject, timeout }) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await inject(page, { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| timeout: clampTimeout(action.timeout, timeout), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| styles: action.styles, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| scripts: action.scripts, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| modules: action.modules | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async click (page, action, { timeout }) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await page.locator(toSelector(action)).setTimeout(clampTimeout(action.timeout, timeout)).click() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async wait (page, action, { timeout, responseBuffer }) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const budget = clampTimeout(action.timeout, timeout) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (hasElementLocator(action)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return page.waitForSelector(toSelector(action), { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| timeout: budget, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| visible: action.visible, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| hidden: action.hidden | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (isSet(action.text)) return waitForText(page, action, budget) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (isSet(action.request)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return waitForResponse(page, action, { timeout: budget, responseBuffer }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (isSet(action.timeout)) return setTimeout(budget) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Clamp the sleep below the wave budget. When The test Subtract a small margin so the sleep always settles before the outer timeout. 🐛 Proposed fix for the timer race- if (isSet(action.timeout)) return setTimeout(budget)
+ if (isSet(action.timeout)) return setTimeout(Math.max(0, Math.min(budget - 1, budget)))📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw new Error('wait: no target') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async scroll (page, action, { timeout }) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (hasElementLocator(action)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const selector = toSelector(action) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await page.waitForSelector(selector, { timeout: clampTimeout(action.timeout, timeout) }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await page.$eval(selector, el => el.scrollIntoView()) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const x = action.x || 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const y = action.y || 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await page.evaluate((scrollX, scrollY) => window.scrollBy(scrollX, scrollY), x, y) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async fill (page, action, { timeout }) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await page | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .locator(toSelector(action)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .setTimeout(clampTimeout(action.timeout, timeout)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .fill(String(action.value ?? '')) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async evaluate (page, action) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await page.evaluate(action.expression) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async screenshot (page, action, { actionCaptures, index, timeout }) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const opts = {} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (action.fullPage != null) opts.fullPage = action.fullPage | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (hasElementLocator(action)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const element = await page.waitForSelector(toSelector(action), { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| timeout: clampTimeout(action.timeout, timeout) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (element) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const box = await element.boundingBox() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (box) opts.clip = box | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } finally { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await element.dispose() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const buffer = await page.screenshot(opts) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Element screenshot options conflictMedium Severity The screenshot action can set both Reviewed by Cursor Bugbot for commit a0fe589. Configure here. |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| actionCaptures.screenshots.push({ buffer, opts, index }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return buffer | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+145
to
+147
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Record captures by action index, not by completion order.
Consumers depend on array order. Store the capture at its index, or sort by 🐛 Proposed fix to preserve action order const buffer = await page.screenshot(opts)
- actionCaptures.screenshots.push({ buffer, opts, index })
+ actionCaptures.screenshots.push({ buffer, opts, index })
+ actionCaptures.screenshots.sort((a, b) => a.index - b.index)
return buffer📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async pdf (page, action, { actionCaptures, index }) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const opts = {} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (action.format != null) opts.format = action.format | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (action.scale != null) opts.scale = action.scale | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (action.margin != null) opts.margin = action.margin | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (action.printBackground != null) opts.printBackground = action.printBackground | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const buffer = await page.pdf(opts) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| actionCaptures.pdfs.push({ buffer, opts, index }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return buffer | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| module.exports = handlers | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| module.exports.clampTimeout = clampTimeout | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| module.exports.globToRegExp = globToRegExp | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| 'use strict' | ||
|
|
||
| const debug = require('debug-logfmt')('browserless:goto:actions') | ||
|
|
||
| const { hasElementLocator, isSet } = require('./locator') | ||
| const { batchActions } = require('./batch') | ||
| const handlers = require('./handlers') | ||
|
|
||
| const MAX_BUFFERED_RESPONSES = 100 | ||
|
|
||
| const waitMode = action => { | ||
| if (hasElementLocator(action)) return 'element' | ||
| if (isSet(action.text)) return 'text' | ||
| if (isSet(action.request)) return 'request' | ||
| if (isSet(action.timeout)) return 'timeout' | ||
| return 'unknown' | ||
| } | ||
|
|
||
| /** | ||
| * Run a flat ordered list of browser actions with internal auto-batching. | ||
| * | ||
| * `timeout` is the budget for the whole list: every action draws from the same | ||
| * deadline, so the total run time never grows with the action count. | ||
| * | ||
| * @param {import('puppeteer').Page} page | ||
| * @param {Array<Record<string, *>>} actions | ||
| * @param {object} ctx | ||
| * @param {Function} ctx.inject | ||
| * @param {Function} ctx.run | ||
| * @param {number} ctx.timeout | ||
| * @returns {Promise<{ screenshots: object[], pdfs: object[] }>} | ||
| */ | ||
| const runActions = async (page, actions, { inject, run, timeout }) => { | ||
| const actionCaptures = { screenshots: [], pdfs: [] } | ||
| const responseBuffer = [] | ||
| const deadline = Date.now() + timeout | ||
| const remaining = () => Math.max(0, deadline - Date.now()) | ||
|
|
||
| const onResponse = response => { | ||
| if (responseBuffer.length === MAX_BUFFERED_RESPONSES) responseBuffer.shift() | ||
| responseBuffer.push(response) | ||
| } | ||
| page.on('response', onResponse) | ||
|
|
||
| try { | ||
| const waves = batchActions(actions) | ||
|
|
||
| for (const wave of waves) { | ||
| const runOne = async (action, index) => { | ||
| const handler = handlers[action.type] | ||
| if (!handler) throw new Error(`actions[${index}]: unknown type "${action.type}"`) | ||
|
|
||
| const label = action.type === 'wait' ? `wait:${waitMode(action)}` : action.type | ||
| const budget = remaining() | ||
|
|
||
| // a zero budget disables the timeout in both `run` and Puppeteer | ||
| if (budget === 0) throw new Error(`actions[${index}] (${label}): budget exhausted`) | ||
|
|
||
| const result = await run({ | ||
| fn: handler(page, action, { | ||
| inject, | ||
| timeout: budget, | ||
| responseBuffer, | ||
| actionCaptures, | ||
| index | ||
| }), | ||
| timeout: budget, | ||
| debug: { action: label, index } | ||
| }) | ||
|
|
||
| if (result.isRejected) { | ||
| const message = result.reason?.message || String(result.reason) | ||
| const error = new Error(`actions[${index}] (${label}) failed: ${message}`) | ||
| error.cause = result.reason | ||
| throw error | ||
| } | ||
|
|
||
| return result.value | ||
| } | ||
|
|
||
| if (wave.actions.length === 1) { | ||
| await runOne(wave.actions[0], wave.startIndex) | ||
| continue | ||
| } | ||
|
|
||
| await Promise.all( | ||
| wave.actions.map((action, offset) => runOne(action, wave.startIndex + offset)) | ||
| ) | ||
| } | ||
| } finally { | ||
| page.off('response', onResponse) | ||
| } | ||
|
|
||
| debug('done', { | ||
| count: actions.length, | ||
| screenshots: actionCaptures.screenshots.length, | ||
| pdfs: actionCaptures.pdfs.length | ||
| }) | ||
|
|
||
| return actionCaptures | ||
| } | ||
|
|
||
| module.exports = { runActions, batchActions, handlers, waitMode } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| 'use strict' | ||
|
|
||
| const LOCATOR_KEYS = ['selector', 'role', 'text', 'label', 'placeholder', 'testId', 'alt'] | ||
|
|
||
| const ELEMENT_LOCATOR_KEYS = LOCATOR_KEYS.filter(key => key !== 'text') | ||
|
|
||
| const escape = value => String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"') | ||
|
|
||
| const escapeText = value => | ||
| String(value).replaceAll('\\', '\\\\').replaceAll('(', '\\(').replaceAll(')', '\\)') | ||
|
|
||
| const isSet = value => value != null && value !== '' | ||
|
|
||
| /** | ||
| * Compile an action's locator fields into a Puppeteer P-selector string. | ||
| * | ||
| * @param {Record<string, *>} action | ||
| * @returns {string} | ||
| */ | ||
| const toSelector = action => { | ||
| if (isSet(action.selector)) return action.selector | ||
| if (isSet(action.role)) { | ||
| const name = isSet(action.name) ? `[name="${escape(action.name)}"]` : '' | ||
| return `::-p-aria([role="${escape(action.role)}"]${name})` | ||
| } | ||
| if (isSet(action.text)) return `::-p-text(${escapeText(action.text)})` | ||
| if (isSet(action.label)) return `::-p-aria([name="${escape(action.label)}"])` | ||
| if (isSet(action.placeholder)) return `[placeholder="${escape(action.placeholder)}"]` | ||
| if (isSet(action.testId)) return `[data-testid="${escape(action.testId)}"]` | ||
| if (isSet(action.alt)) return `[alt="${escape(action.alt)}"]` | ||
| throw new Error('locator: no strategy') | ||
| } | ||
|
|
||
| /** | ||
| * Whether the action carries an element-locator strategy (not page-text wait mode). | ||
| * On `wait`, `text` is page-string mode — not an element locator. | ||
| * | ||
| * @param {Record<string, *>} action | ||
| * @returns {boolean} | ||
| */ | ||
| const hasElementLocator = action => { | ||
| const keys = action.type === 'wait' ? ELEMENT_LOCATOR_KEYS : LOCATOR_KEYS | ||
| return keys.some(key => isSet(action[key])) | ||
| } | ||
|
|
||
| module.exports = { | ||
| ELEMENT_LOCATOR_KEYS, | ||
| LOCATOR_KEYS, | ||
| escape, | ||
| escapeText, | ||
| hasElementLocator, | ||
| isSet, | ||
| toSelector | ||
| } |


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Screenshot batching returns wrong buffer
High Severity
screenshotis treated as parallel-safe and consecutive captures run underPromise.all, but each result is only appended when that capture finishes.@browserless/screenshotthen takesactionCaptures.screenshots.at(-1), so the returned image is whichever capture completed last, not the last screenshot action in the list.fullPagecaptures also mutate the shared viewport, so parallel waves can corrupt each other.Additional Locations (2)
packages/screenshot/src/index.js#L287-L293packages/goto/src/actions/index.js#L85-L88Reviewed by Cursor Bugbot for commit a0fe589. Configure here.