@probolabs/playwright 1.5.0-rc.3 → 1.5.0-rc.5

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/index.cjs CHANGED
@@ -1446,23 +1446,23 @@ const highlighterCode = "(function (global, factory) {\n typeof exports === '
1446
1446
  if (interaction.action === exports.PlaywrightAction.EXTRACT_VALUE) {
1447
1447
  const extractVarName = getReturnValueParameterName(interaction);
1448
1448
  return `${comment}
1449
- await test.step("${escapedStepName}", async () => {
1450
- const ${extractVarName} = await probo.runStep(page, '${escapedPrompt}', null, { stepId: ${interaction.stepId} });
1451
- param['${extractVarName}'] = ${extractVarName};
1452
- });`;
1449
+ await test.step("${escapedStepName}", async () => {
1450
+ const ${extractVarName} = await probo.runStep(page, '${escapedPrompt}', null, { stepId: ${interaction.stepId} });
1451
+ param['${extractVarName}'] = ${extractVarName};
1452
+ });`;
1453
1453
  }
1454
1454
  else if (interaction.action === exports.PlaywrightAction.ASSERT_CONTAINS_VALUE ||
1455
1455
  interaction.action === exports.PlaywrightAction.ASSERT_EXACT_VALUE) {
1456
1456
  return `${comment}
1457
- await test.step("${escapedStepName}", async () => {
1458
- await probo.runStep(page, '${escapedPrompt}', ${argument}, { stepId: ${interaction.stepId} });
1459
- });`;
1457
+ await test.step("${escapedStepName}", async () => {
1458
+ await probo.runStep(page, '${escapedPrompt}', ${argument}, { stepId: ${interaction.stepId} });
1459
+ });`;
1460
1460
  }
1461
1461
  else if (interaction.action === exports.PlaywrightAction.VISIT_URL) {
1462
1462
  return `${comment}
1463
- await test.step("${escapedStepName}", async () => {
1464
- await probo.runStep(page, '${interaction.nativeDescription}', ${argument}, { stepId: ${interaction.stepId} });
1465
- });`;
1463
+ await test.step("${escapedStepName}", async () => {
1464
+ await probo.runStep(page, '${interaction.nativeDescription}', ${argument}, { stepId: ${interaction.stepId} });
1465
+ });`;
1466
1466
  }
1467
1467
  else if (interaction.action === exports.PlaywrightAction.ASK_AI) {
1468
1468
  const escapedQuestion = interaction.nativeDescription.replace(/'/g, "\\'");
@@ -1480,9 +1480,9 @@ const highlighterCode = "(function (global, factory) {\n typeof exports === '
1480
1480
  }
1481
1481
  else {
1482
1482
  return `${comment}
1483
- await test.step("${escapedStepName}", async () => {
1484
- ${argument ? `await probo.runStep(page, '${escapedPrompt}', ${argument}, { stepId: ${interaction.stepId} });` : `await probo.runStep(page, '${escapedPrompt}', null, { stepId: ${interaction.stepId} });`}
1485
- });`;
1483
+ await test.step("${escapedStepName}", async () => {
1484
+ ${argument ? `await probo.runStep(page, '${escapedPrompt}', ${argument}, { stepId: ${interaction.stepId} });` : `await probo.runStep(page, '${escapedPrompt}', null, { stepId: ${interaction.stepId} });`}
1485
+ });`;
1486
1486
  }
1487
1487
  }
1488
1488
  function generateExecuteScriptCode(interaction, comment, escapedStepName) {
@@ -1509,17 +1509,79 @@ const highlighterCode = "(function (global, factory) {\n typeof exports === '
1509
1509
  param['${scriptVarName}'] = ${scriptVarName};
1510
1510
  });`;
1511
1511
  }
1512
+ /** Base indentation for steps inside the test body (8 spaces) */
1513
+ const STEP_BASE_INDENT = 8;
1514
+ /** Base indentation for steps nested inside a sequence step (10 spaces) */
1515
+ const SEQUENCE_INNER_INDENT = 10;
1516
+ /**
1517
+ * Re-indent a block so its minimum leading whitespace becomes targetSpaces.
1518
+ * Normalizes mixed indentation from interactionToNativeCode (8 spaces) and interactionToProboLib (6 spaces).
1519
+ */
1520
+ function indentBlockTo(block, targetSpaces) {
1521
+ const lines = block.split('\n');
1522
+ const nonEmpty = lines.filter((l) => l.trim().length > 0);
1523
+ const minIndent = nonEmpty.length > 0
1524
+ ? Math.min(...nonEmpty.map((l) => { var _a, _b; return ((_b = (_a = l.match(/^\s*/)) === null || _a === void 0 ? void 0 : _a[0]) !== null && _b !== void 0 ? _b : '').length; }))
1525
+ : 0;
1526
+ const toAdd = Math.max(0, targetSpaces - minIndent);
1527
+ return lines
1528
+ .map((l) => (l.trim() ? ' '.repeat(toAdd) + l : l))
1529
+ .join('\n');
1530
+ }
1531
+ /**
1532
+ * Generates steps string with sequence-aware test.step wrapping.
1533
+ * When SEQUENCE_START is encountered, collects interactions until SEQUENCE_END
1534
+ * and wraps them in test.step(<sequence name>, async () => { ... }).
1535
+ */
1536
+ function generateStepsWithSequences(interactions, _hasAiInteractions) {
1537
+ const result = [];
1538
+ let i = 0;
1539
+ const baseIndent = ' '.repeat(STEP_BASE_INDENT);
1540
+ while (i < interactions.length) {
1541
+ const interaction = interactions[i];
1542
+ if (interaction.action === exports.PlaywrightAction.SEQUENCE_START) {
1543
+ const sequenceName = interaction.nativeName ||
1544
+ interaction.nativeDescription ||
1545
+ 'Unnamed sequence';
1546
+ const escapedName = sequenceName
1547
+ .replace(/\\/g, '\\\\')
1548
+ .replace(/"/g, '\\"');
1549
+ i++;
1550
+ const innerSteps = [];
1551
+ while (i < interactions.length &&
1552
+ interactions[i].action !== exports.PlaywrightAction.SEQUENCE_END) {
1553
+ const code = isAI(interactions[i])
1554
+ ? interactionToProboLib(interactions[i])
1555
+ : interactionToNativeCode(interactions[i]);
1556
+ innerSteps.push(code);
1557
+ i++;
1558
+ }
1559
+ if (i < interactions.length)
1560
+ i++; // skip SEQUENCE_END
1561
+ const indented = innerSteps
1562
+ .map((block) => indentBlockTo(block, SEQUENCE_INNER_INDENT))
1563
+ .join('\n');
1564
+ result.push(`${baseIndent}await test.step("${escapedName}", async () => {\n${indented}\n${baseIndent}});`);
1565
+ }
1566
+ else if (interaction.action === exports.PlaywrightAction.SEQUENCE_END) {
1567
+ i++; // orphan END, skip
1568
+ }
1569
+ else {
1570
+ result.push(isAI(interaction)
1571
+ ? interactionToProboLib(interaction)
1572
+ : interactionToNativeCode(interaction));
1573
+ i++;
1574
+ }
1575
+ }
1576
+ return result.join('\n');
1577
+ }
1512
1578
  function scriptTemplate(options, settings, viewPort) {
1513
1579
  var _a, _b, _c, _d;
1514
1580
  const areActionsParameterized = options.rows.length > 0;
1515
1581
  const hasAiInteractions = options.interactions.some((interaction) => isAI(interaction));
1516
1582
  const hasScriptInteractions = options.interactions.some((interaction) => interaction.action === exports.PlaywrightAction.EXECUTE_SCRIPT);
1517
1583
  // const uniquelyParameterizedInteractions = uniquifyInteractionParameters(options.interactions);
1518
- const steps = options.interactions.map((interaction) => {
1519
- if (isAI(interaction))
1520
- return interactionToProboLib(interaction);
1521
- return interactionToNativeCode(interaction);
1522
- }).join('\n');
1584
+ const steps = generateStepsWithSequences(options.interactions);
1523
1585
  // Get list of all extracted value parameter names to filter them out from the parameter table
1524
1586
  const extractedValueNames = new Set();
1525
1587
  options.interactions.forEach((interaction) => {
@@ -4983,8 +5045,11 @@ export default class ProboReporter implements Reporter {
4983
5045
  }
4984
5046
 
4985
5047
  class ProboPlaywright {
4986
- constructor({ enableSmartSelectors = false, timeoutConfig = {}, debugLevel = exports.ProboLogLevel.INFO, isCanceled }, page = null) {
5048
+ constructor({ enableSmartSelectors = false, timeoutConfig = {}, debugLevel = exports.ProboLogLevel.INFO, isCanceled }, page = null, context) {
4987
5049
  this.page = null;
5050
+ this.context = null;
5051
+ this.pagesByTabIndex = new Map();
5052
+ this.nextTabIndex = 1;
4988
5053
  this.params = {};
4989
5054
  this.isCanceled = null;
4990
5055
  this.enableSmartSelectors = enableSmartSelectors;
@@ -4994,6 +5059,7 @@ export default class ProboReporter implements Reporter {
4994
5059
  };
4995
5060
  this.isCanceled = isCanceled || null;
4996
5061
  this.highlighter = new Highlighter(enableSmartSelectors, false, debugLevel);
5062
+ this.context = context !== null && context !== void 0 ? context : null;
4997
5063
  this.setPage(page);
4998
5064
  proboLogger.setLogLevel(debugLevel);
4999
5065
  }
@@ -5008,8 +5074,36 @@ export default class ProboReporter implements Reporter {
5008
5074
  if (this.page) {
5009
5075
  this.page.setDefaultNavigationTimeout(this.timeoutConfig.playwrightNavigationTimeout);
5010
5076
  this.page.setDefaultTimeout(this.timeoutConfig.playwrightActionTimeout);
5077
+ this.pagesByTabIndex.set(0, this.page);
5011
5078
  }
5012
5079
  }
5080
+ /**
5081
+ * Sets the BrowserContext for multi-tab replay. Required for opensNewTab support.
5082
+ */
5083
+ setContext(context) {
5084
+ this.context = context;
5085
+ }
5086
+ /**
5087
+ * Resets multi-tab state for a new replay. Clears captured tabs (keeps tab 0) and resets nextTabIndex.
5088
+ * Call at the start of replay before running interactions.
5089
+ */
5090
+ resetMultiTabState() {
5091
+ // Clear captured tabs (indices > 0), keep tab 0
5092
+ for (const idx of this.pagesByTabIndex.keys()) {
5093
+ if (idx > 0) {
5094
+ this.pagesByTabIndex.delete(idx);
5095
+ }
5096
+ }
5097
+ this.nextTabIndex = 1;
5098
+ }
5099
+ /**
5100
+ * Gets the page for the given tab index. Tab 0 = initial page; higher indices = new tabs.
5101
+ */
5102
+ getPageForTabIndex(tabIndex) {
5103
+ var _a;
5104
+ const page = (_a = this.pagesByTabIndex.get(tabIndex)) !== null && _a !== void 0 ? _a : this.page;
5105
+ return page !== null && page !== void 0 ? page : null;
5106
+ }
5013
5107
  /**
5014
5108
  * Sets the parameters object for template literal interpolation
5015
5109
  * Stores a reference to the params object so mutations are automatically reflected
@@ -5036,417 +5130,441 @@ export default class ProboReporter implements Reporter {
5036
5130
  * @throws Error if element is not found or validation fails
5037
5131
  */
5038
5132
  async runStep(params) {
5039
- let { action, argument = '', iframeSelector = '', elementSelector = '', smartSelector = null, smartIFrameSelector = null, annotation = '', } = params;
5040
- // 0. Check that page is set
5041
- if (!this.page) {
5042
- throw new Error('Page is not set');
5043
- }
5044
- // Interpolate argument if it's a string with template literals
5045
- if (typeof argument === 'string' && argument.includes('${')) {
5046
- argument = this.interpolate(argument);
5047
- }
5048
- else if (Array.isArray(argument)) {
5049
- // Handle array arguments (e.g., for UPLOAD_FILES or SELECT_DROPDOWN)
5050
- argument = argument.map((arg) => {
5051
- if (typeof arg === 'string' && arg.includes('${')) {
5052
- return this.interpolate(arg);
5053
- }
5054
- return arg;
5055
- });
5056
- }
5057
- //trace message to help with debug
5058
- proboLogger.info(`runStep(${JSON.stringify({ ...params, argument })})`);
5059
- // 1. Check if we need to visit a url
5060
- if (action === exports.PlaywrightAction.VISIT_URL || action === exports.PlaywrightAction.VISIT_BASE_URL) {
5061
- try {
5062
- await this.page.goto(argument, { timeout: this.timeoutConfig.playwrightNavigationTimeout });
5063
- const navTracker = await NavTracker.getInstance(this.page, {
5064
- waitForStabilityQuietTimeout: this.timeoutConfig.waitForStabilityQuietTimeout,
5065
- waitForStabilityInitialDelay: this.timeoutConfig.waitForStabilityInitialDelay,
5066
- waitForStabilityGlobalTimeout: this.timeoutConfig.waitForStabilityGlobalTimeout,
5067
- waitForStabilityVerbose: this.timeoutConfig.waitForStabilityVerbose
5068
- });
5069
- await navTracker.waitForNavigationToStabilize();
5070
- }
5071
- catch (e) {
5072
- throw new Error(`Failed to navigate to ${argument}`);
5073
- }
5074
- return;
5075
- }
5076
- // 2. Check if we need to assert the url
5077
- if (action === exports.PlaywrightAction.ASSERT_URL) {
5078
- // wait for page to stabilize
5079
- const navTracker = await NavTracker.getInstance(this.page, { waitForStabilityQuietTimeout: this.timeoutConfig.playwrightNavigationTimeout });
5080
- await navTracker.waitForNavigationToStabilize();
5081
- const currentUrl = await this.page.url();
5082
- if (currentUrl !== argument) {
5083
- throw new Error(`Assertion failed: Expected URL "${argument}" but got "${currentUrl}".`);
5133
+ var _a;
5134
+ let { action, argument = '', iframeSelector = '', elementSelector = '', smartSelector = null, smartIFrameSelector = null, annotation = '', tabIndex = 0, opensNewTab = false, } = params;
5135
+ // 0. Resolve page for this step (multi-tab: tabIndex selects which page to use)
5136
+ const stepPage = (_a = this.getPageForTabIndex(tabIndex)) !== null && _a !== void 0 ? _a : this.page;
5137
+ if (!stepPage) {
5138
+ throw new Error(`Page is not set for tabIndex ${tabIndex}`);
5139
+ }
5140
+ const originalPage = this.page;
5141
+ this.page = stepPage;
5142
+ // Bring the active tab to front so the user sees the page being interacted with
5143
+ await stepPage.bringToFront();
5144
+ try {
5145
+ // Interpolate argument if it's a string with template literals
5146
+ if (typeof argument === 'string' && argument.includes('${')) {
5147
+ argument = this.interpolate(argument);
5084
5148
  }
5085
- return;
5086
- }
5087
- // 3. Check if we need to type keys
5088
- if (action === exports.PlaywrightAction.TYPE_KEYS) {
5089
- await this.robustTypeKeys(argument);
5090
- return;
5091
- }
5092
- // Capture base screenshot before element interaction (if screenshot function is provided)
5093
- // Only capture base screenshot during apply-ai, skip during replay
5094
- let baseScreenshotUrl = null;
5095
- if (params.takeScreenshot && params.isApplyAIContext) {
5096
- try {
5097
- proboLogger.debug('Capturing base screenshot before element interaction');
5098
- baseScreenshotUrl = await params.takeScreenshot(this.page, 'base');
5099
- proboLogger.debug(`Base screenshot uploaded: ${baseScreenshotUrl}`);
5149
+ else if (Array.isArray(argument)) {
5150
+ // Handle array arguments (e.g., for UPLOAD_FILES or SELECT_DROPDOWN)
5151
+ argument = argument.map((arg) => {
5152
+ if (typeof arg === 'string' && arg.includes('${')) {
5153
+ return this.interpolate(arg);
5154
+ }
5155
+ return arg;
5156
+ });
5100
5157
  }
5101
- catch (baseError) {
5102
- proboLogger.warn(`Failed to capture base screenshot: ${baseError}`);
5103
- // Continue even if base screenshot fails
5158
+ //trace message to help with debug
5159
+ proboLogger.info(`runStep(${JSON.stringify({ ...params, argument })})`);
5160
+ // 1. Check if we need to visit a url
5161
+ if (action === exports.PlaywrightAction.VISIT_URL || action === exports.PlaywrightAction.VISIT_BASE_URL) {
5162
+ try {
5163
+ await this.page.goto(argument, { timeout: this.timeoutConfig.playwrightNavigationTimeout });
5164
+ const navTracker = await NavTracker.getInstance(this.page, {
5165
+ waitForStabilityQuietTimeout: this.timeoutConfig.waitForStabilityQuietTimeout,
5166
+ waitForStabilityInitialDelay: this.timeoutConfig.waitForStabilityInitialDelay,
5167
+ waitForStabilityGlobalTimeout: this.timeoutConfig.waitForStabilityGlobalTimeout,
5168
+ waitForStabilityVerbose: this.timeoutConfig.waitForStabilityVerbose
5169
+ });
5170
+ await navTracker.waitForNavigationToStabilize();
5171
+ }
5172
+ catch (e) {
5173
+ throw new Error(`Failed to navigate to ${argument}`);
5174
+ }
5175
+ return;
5104
5176
  }
5105
- }
5106
- let locator;
5107
- if (iframeSelector && iframeSelector.length > 0) {
5108
- const frameLocator = await this.getLocator(iframeSelector, smartIFrameSelector, true);
5109
- locator = await this.getLocator(elementSelector, smartSelector, false, frameLocator);
5110
- }
5111
- else {
5112
- locator = await this.getLocator(elementSelector, smartSelector);
5113
- }
5114
- // Fail fast: immediately validate that the element exists for non-wait actions
5115
- // Use Promise.race() for all actions to enable cancellation without sacrificing precision
5116
- const locator_timeout = (action === exports.PlaywrightAction.WAIT_FOR) ? params.timeout || 10000 : this.timeoutConfig.playwrightLocatorTimeout;
5117
- // Create a cancellation promise that checks for cancellation every 100ms
5118
- // This allows precise waits while still enabling cancellation for all actions
5119
- const cancellationCheckInterval = 100; // Check for cancellation every 100ms
5120
- // Create a promise that rejects when cancellation is detected
5121
- const cancellationPromise = new Promise((_, reject) => {
5122
- const checkCancellation = () => {
5123
- if (this.isCanceled && this.isCanceled()) {
5124
- reject(new Error('Replay execution was cancelled'));
5125
- return;
5177
+ // 2. Check if we need to assert the url
5178
+ if (action === exports.PlaywrightAction.ASSERT_URL) {
5179
+ // wait for page to stabilize
5180
+ const navTracker = await NavTracker.getInstance(this.page, { waitForStabilityQuietTimeout: this.timeoutConfig.playwrightNavigationTimeout });
5181
+ await navTracker.waitForNavigationToStabilize();
5182
+ const currentUrl = await this.page.url();
5183
+ if (currentUrl !== argument) {
5184
+ throw new Error(`Assertion failed: Expected URL "${argument}" but got "${currentUrl}".`);
5126
5185
  }
5127
- // Continue checking until the wait completes or times out
5128
- setTimeout(checkCancellation, cancellationCheckInterval);
5129
- };
5130
- checkCancellation();
5131
- });
5132
- // Helper function to wait for locator with cancellation support
5133
- const waitForLocatorWithCancellation = async (targetLocator, timeout) => {
5134
- return Promise.race([
5135
- targetLocator.waitFor({ state: 'attached', timeout }),
5136
- cancellationPromise
5137
- ]);
5138
- };
5139
- try {
5140
- // Race between the actual wait and cancellation check for all actions
5141
- // This allows precise waits while still enabling cancellation
5142
- await waitForLocatorWithCancellation(locator, locator_timeout);
5143
- }
5144
- catch (e) {
5145
- // If it's a cancellation error, re-throw it
5146
- if (e instanceof Error && e.message === 'Replay execution was cancelled') {
5147
- throw e;
5186
+ return;
5187
+ }
5188
+ // 3. Check if we need to type keys
5189
+ if (action === exports.PlaywrightAction.TYPE_KEYS) {
5190
+ await this.robustTypeKeys(argument);
5191
+ return;
5148
5192
  }
5149
- // Timeout reached, try fallback if smart selectors are enabled
5150
- if (this.enableSmartSelectors) {
5193
+ // Capture base screenshot before element interaction (if screenshot function is provided)
5194
+ // Only capture base screenshot during apply-ai, skip during replay
5195
+ let baseScreenshotUrl = null;
5196
+ if (params.takeScreenshot && params.isApplyAIContext) {
5151
5197
  try {
5152
- proboLogger.warn(`Element not found with smart selector: ${JSON.stringify(smartSelector)} ${smartIFrameSelector ? `with iframe smart selector: ${JSON.stringify(smartIFrameSelector)}` : ''}. Falling back to CSS selector`, e);
5153
- if (iframeSelector && iframeSelector.length > 0) {
5154
- const frameLocator = await this.getLocatorOrFrame(iframeSelector, true);
5155
- locator = await this.getLocatorOrFrame(elementSelector, false, frameLocator);
5156
- }
5157
- else {
5158
- locator = await this.getLocatorOrFrame(elementSelector, false);
5159
- }
5160
- // Race the fallback wait with cancellation as well
5161
- await waitForLocatorWithCancellation(locator, 200);
5198
+ proboLogger.debug('Capturing base screenshot before element interaction');
5199
+ baseScreenshotUrl = await params.takeScreenshot(this.page, 'base');
5200
+ proboLogger.debug(`Base screenshot uploaded: ${baseScreenshotUrl}`);
5162
5201
  }
5163
- catch (e) {
5164
- // If it's a cancellation error, re-throw it
5165
- if (e instanceof Error && e.message === 'Replay execution was cancelled') {
5166
- throw e;
5167
- }
5168
- throw new Error(`Element not found with CSS selector: ${elementSelector} ${iframeSelector ? `in iframe: ${iframeSelector}` : ''} after ${locator_timeout}ms`);
5202
+ catch (baseError) {
5203
+ proboLogger.warn(`Failed to capture base screenshot: ${baseError}`);
5204
+ // Continue even if base screenshot fails
5169
5205
  }
5170
5206
  }
5171
- else {
5172
- throw new Error(`Element not found with CSS selector: ${elementSelector} ${iframeSelector ? `in iframe: ${iframeSelector}` : ''} after ${locator_timeout}ms`);
5207
+ // 4. Get the locator (iframe or not)
5208
+ const startTime = Date.now();
5209
+ let locator;
5210
+ if (iframeSelector && iframeSelector.length > 0) {
5211
+ const frameLocator = await this.getLocator(iframeSelector, smartIFrameSelector, true);
5212
+ locator = await this.getLocator(elementSelector, smartSelector, false, frameLocator);
5173
5213
  }
5174
- }
5175
- if (action === exports.PlaywrightAction.HOVER) {
5176
- const visibleLocator = await findClosestVisibleElement(locator);
5177
- if (visibleLocator) {
5178
- locator = visibleLocator;
5214
+ else {
5215
+ locator = await this.getLocator(elementSelector, smartSelector);
5179
5216
  }
5180
- }
5181
- // 5. Capture screenshots if screenshot function is provided
5182
- const screenshotUrls = {
5183
- base_screenshot_url: baseScreenshotUrl
5184
- };
5185
- if (params.takeScreenshot) {
5217
+ // Fail fast: immediately validate that the element exists for non-wait actions
5218
+ // Use Promise.race() for all actions to enable cancellation without sacrificing precision
5219
+ const locator_timeout = (action === exports.PlaywrightAction.WAIT_FOR) ? params.timeout || 10000 : this.timeoutConfig.playwrightLocatorTimeout;
5220
+ // Create a cancellation promise that checks for cancellation every 100ms
5221
+ // This allows precise waits while still enabling cancellation for all actions
5222
+ const cancellationCheckInterval = 100; // Check for cancellation every 100ms
5223
+ // Create a promise that rejects when cancellation is detected
5224
+ const cancellationPromise = new Promise((_, reject) => {
5225
+ const checkCancellation = () => {
5226
+ if (this.isCanceled && this.isCanceled()) {
5227
+ reject(new Error('Replay execution was cancelled'));
5228
+ return;
5229
+ }
5230
+ // Continue checking until the wait completes or times out
5231
+ setTimeout(checkCancellation, cancellationCheckInterval);
5232
+ };
5233
+ checkCancellation();
5234
+ });
5235
+ // Helper function to wait for locator with cancellation support
5236
+ const waitForLocatorWithCancellation = async (targetLocator, timeout) => {
5237
+ return Promise.race([
5238
+ targetLocator.waitFor({ state: 'attached', timeout }),
5239
+ cancellationPromise
5240
+ ]);
5241
+ };
5186
5242
  try {
5187
- // Candidate elements screenshot: find and highlight candidates based on action type
5188
- // Only capture candidate screenshot during apply-ai, skip during replay
5189
- if (params.isApplyAIContext) {
5243
+ // Race between the actual wait and cancellation check for all actions
5244
+ // This allows precise waits while still enabling cancellation
5245
+ await waitForLocatorWithCancellation(locator, locator_timeout);
5246
+ }
5247
+ catch (e) {
5248
+ // If it's a cancellation error, re-throw it
5249
+ if (e instanceof Error && e.message === 'Replay execution was cancelled') {
5250
+ throw e;
5251
+ }
5252
+ // Timeout reached, try fallback if smart selectors are enabled
5253
+ if (this.enableSmartSelectors) {
5190
5254
  try {
5191
- const elementTags = resolveElementTag(action);
5192
- proboLogger.debug(`Finding candidate elements for action ${action} with tags: ${elementTags}`);
5193
- await this.highlighter.findAndCacheCandidateElements(this.page, elementTags);
5194
- await this.highlighter.highlightCachedElements(this.page, 'candidates');
5195
- // Wait for highlight timeout to ensure highlights are visible
5196
- // if (this.timeoutConfig.highlightTimeout > 0) {
5197
- // await this.page.waitForTimeout(this.timeoutConfig.highlightTimeout);
5198
- // }
5199
- screenshotUrls.candidates_screenshot_url = await params.takeScreenshot(this.page, 'candidates');
5200
- proboLogger.debug(`Candidates screenshot uploaded: ${screenshotUrls.candidates_screenshot_url}`);
5201
- // Unhighlight candidates
5202
- await this.highlighter.unhighlightCached(this.page);
5255
+ proboLogger.warn(`Element not found with smart selector: ${JSON.stringify(smartSelector)} ${smartIFrameSelector ? `with iframe smart selector: ${JSON.stringify(smartIFrameSelector)}` : ''}. Falling back to CSS selector`, e);
5256
+ if (iframeSelector && iframeSelector.length > 0) {
5257
+ const frameLocator = await this.getLocatorOrFrame(iframeSelector, true);
5258
+ locator = await this.getLocatorOrFrame(elementSelector, false, frameLocator);
5259
+ }
5260
+ else {
5261
+ locator = await this.getLocatorOrFrame(elementSelector, false);
5262
+ }
5263
+ // Race the fallback wait with cancellation as well
5264
+ await waitForLocatorWithCancellation(locator, 200);
5203
5265
  }
5204
- catch (candidateError) {
5205
- proboLogger.warn(`Failed to capture candidates screenshot: ${candidateError}`);
5206
- // Continue even if candidate screenshot fails
5266
+ catch (e) {
5267
+ // If it's a cancellation error, re-throw it
5268
+ if (e instanceof Error && e.message === 'Replay execution was cancelled') {
5269
+ throw e;
5270
+ }
5271
+ throw new Error(`Element not found with CSS selector: ${elementSelector} ${iframeSelector ? `in iframe: ${iframeSelector}` : ''} after ${locator_timeout}ms`);
5207
5272
  }
5208
5273
  }
5209
- // Actual element screenshot: highlight the actual element
5210
- // Always capture actual screenshot for both apply-ai and replay
5274
+ else {
5275
+ throw new Error(`Element not found with CSS selector: ${elementSelector} ${iframeSelector ? `in iframe: ${iframeSelector}` : ''} after ${locator_timeout}ms`);
5276
+ }
5277
+ }
5278
+ if (action === exports.PlaywrightAction.HOVER) {
5279
+ const visibleLocator = await findClosestVisibleElement(locator);
5280
+ if (visibleLocator) {
5281
+ locator = visibleLocator;
5282
+ }
5283
+ }
5284
+ // 5. Capture screenshots if screenshot function is provided
5285
+ const screenshotUrls = {
5286
+ base_screenshot_url: baseScreenshotUrl
5287
+ };
5288
+ if (params.takeScreenshot) {
5211
5289
  try {
5212
- await this.highlight(locator, annotation);
5213
- // Wait for highlight timeout to ensure highlights are visible
5214
- if (this.timeoutConfig.highlightTimeout > 0) {
5215
- await this.page.waitForTimeout(this.timeoutConfig.highlightTimeout);
5290
+ // Candidate elements screenshot: find and highlight candidates based on action type
5291
+ // Only capture candidate screenshot during apply-ai, skip during replay
5292
+ if (params.isApplyAIContext) {
5293
+ try {
5294
+ const elementTags = resolveElementTag(action);
5295
+ proboLogger.debug(`Finding candidate elements for action ${action} with tags: ${elementTags}`);
5296
+ await this.highlighter.findAndCacheCandidateElements(this.page, elementTags);
5297
+ await this.highlighter.highlightCachedElements(this.page, 'candidates');
5298
+ // Wait for highlight timeout to ensure highlights are visible
5299
+ // if (this.timeoutConfig.highlightTimeout > 0) {
5300
+ // await this.page.waitForTimeout(this.timeoutConfig.highlightTimeout);
5301
+ // }
5302
+ screenshotUrls.candidates_screenshot_url = await params.takeScreenshot(this.page, 'candidates');
5303
+ proboLogger.debug(`Candidates screenshot uploaded: ${screenshotUrls.candidates_screenshot_url}`);
5304
+ // Unhighlight candidates
5305
+ await this.highlighter.unhighlightCached(this.page);
5306
+ }
5307
+ catch (candidateError) {
5308
+ proboLogger.warn(`Failed to capture candidates screenshot: ${candidateError}`);
5309
+ // Continue even if candidate screenshot fails
5310
+ }
5216
5311
  }
5217
- screenshotUrls.actual_interaction_screenshot_url = await params.takeScreenshot(this.page, 'actual');
5218
- proboLogger.debug(`Actual element screenshot uploaded: ${screenshotUrls.actual_interaction_screenshot_url}`);
5219
- // Unhighlight before performing action
5220
- await this.unhighlight(locator);
5221
- }
5222
- catch (actualError) {
5223
- proboLogger.warn(`Failed to capture actual element screenshot: ${actualError}`);
5224
- // Try to unhighlight even if screenshot failed
5312
+ // Actual element screenshot: highlight the actual element
5313
+ // Always capture actual screenshot for both apply-ai and replay
5225
5314
  try {
5315
+ await this.highlight(locator, annotation);
5316
+ // Wait for highlight timeout to ensure highlights are visible
5317
+ if (this.timeoutConfig.highlightTimeout > 0) {
5318
+ await this.page.waitForTimeout(this.timeoutConfig.highlightTimeout);
5319
+ }
5320
+ screenshotUrls.actual_interaction_screenshot_url = await params.takeScreenshot(this.page, 'actual');
5321
+ proboLogger.debug(`Actual element screenshot uploaded: ${screenshotUrls.actual_interaction_screenshot_url}`);
5322
+ // Unhighlight before performing action
5226
5323
  await this.unhighlight(locator);
5227
5324
  }
5228
- catch (unhighlightError) {
5229
- proboLogger.warn(`Failed to unhighlight: ${unhighlightError}`);
5325
+ catch (actualError) {
5326
+ proboLogger.warn(`Failed to capture actual element screenshot: ${actualError}`);
5327
+ // Try to unhighlight even if screenshot failed
5328
+ try {
5329
+ await this.unhighlight(locator);
5330
+ }
5331
+ catch (unhighlightError) {
5332
+ proboLogger.warn(`Failed to unhighlight: ${unhighlightError}`);
5333
+ }
5334
+ }
5335
+ // Call the callback with screenshot URLs
5336
+ if (params.onScreenshots) {
5337
+ params.onScreenshots(screenshotUrls);
5230
5338
  }
5231
5339
  }
5232
- // Call the callback with screenshot URLs
5233
- if (params.onScreenshots) {
5234
- params.onScreenshots(screenshotUrls);
5340
+ catch (screenshotError) {
5341
+ proboLogger.warn(`Screenshot capture failed: ${screenshotError}`);
5342
+ // Continue with action even if screenshots fail
5235
5343
  }
5236
5344
  }
5237
- catch (screenshotError) {
5238
- proboLogger.warn(`Screenshot capture failed: ${screenshotError}`);
5239
- // Continue with action even if screenshots fail
5240
- }
5241
- }
5242
- else {
5243
- // 5. Highlight, wait, unhighlight if highlightTimeout > 0 (original behavior when no screenshots)
5244
- if (this.timeoutConfig.highlightTimeout > 0) {
5245
- await this.highlight(locator, annotation);
5246
- await this.page.waitForTimeout(this.timeoutConfig.highlightTimeout);
5247
- await this.unhighlight(locator);
5345
+ else {
5346
+ // 5. Highlight, wait, unhighlight if highlightTimeout > 0 (original behavior when no screenshots)
5347
+ if (this.timeoutConfig.highlightTimeout > 0) {
5348
+ await this.highlight(locator, annotation);
5349
+ await this.page.waitForTimeout(this.timeoutConfig.highlightTimeout);
5350
+ await this.unhighlight(locator);
5351
+ }
5248
5352
  }
5249
- }
5250
- // 6. Action logic
5251
- switch (action) {
5252
- case exports.PlaywrightAction.CLICK:
5253
- case exports.PlaywrightAction.CHECK_CHECKBOX:
5254
- case exports.PlaywrightAction.SELECT_RADIO:
5255
- await this.robustMouseAction(locator, 'click');
5256
- break;
5257
- case exports.PlaywrightAction.FILL_IN:
5258
- await this.robustFill(locator, argument);
5259
- break;
5260
- case exports.PlaywrightAction.SELECT_DROPDOWN:
5261
- await locator.selectOption(argument, { timeout: this.timeoutConfig.playwrightActionTimeout });
5262
- break;
5263
- case exports.PlaywrightAction.SET_SLIDER:
5264
- await this.setSliderValue(locator, argument);
5265
- break;
5266
- case exports.PlaywrightAction.WAIT_FOR_OTP:
5267
- {
5268
- const waitForOtpArgs = params;
5269
- const otpInbox = typeof argument === 'string' && argument.trim() ? argument.trim() : undefined;
5270
- const otpTimeout = waitForOtpArgs === null || waitForOtpArgs === void 0 ? void 0 : waitForOtpArgs.timeout;
5271
- const otpCheckInterval = waitForOtpArgs === null || waitForOtpArgs === void 0 ? void 0 : waitForOtpArgs.checkInterval;
5272
- const otpLookback = waitForOtpArgs === null || waitForOtpArgs === void 0 ? void 0 : waitForOtpArgs.checkRecentMessagesSinceMs;
5273
- const otp = await OTP.waitForOTP({
5274
- inbox: otpInbox,
5275
- timeout: otpTimeout,
5276
- checkInterval: otpCheckInterval,
5277
- checkRecentMessagesSinceMs: otpLookback !== null && otpLookback !== void 0 ? otpLookback : 600000,
5278
- });
5279
- if (otp) {
5280
- proboLogger.log(`✅ OTP found: ${otp}`);
5281
- await locator.fill(otp, { timeout: this.timeoutConfig.playwrightActionTimeout });
5353
+ // 6. Action logic
5354
+ switch (action) {
5355
+ case exports.PlaywrightAction.CLICK:
5356
+ case exports.PlaywrightAction.CHECK_CHECKBOX:
5357
+ case exports.PlaywrightAction.SELECT_RADIO:
5358
+ if (opensNewTab && this.context) {
5359
+ const [newPage] = await Promise.all([
5360
+ this.context.waitForEvent('page'),
5361
+ this.robustMouseAction(locator, 'click')
5362
+ ]);
5363
+ const newTabIndex = this.nextTabIndex++;
5364
+ this.pagesByTabIndex.set(newTabIndex, newPage);
5365
+ proboLogger.info(`Multi-tab: captured new page for tabIndex ${newTabIndex}`);
5282
5366
  }
5283
5367
  else {
5284
- proboLogger.log(`❌ OTP not found`);
5368
+ await this.robustMouseAction(locator, 'click');
5285
5369
  }
5286
- }
5287
- break;
5288
- case exports.PlaywrightAction.GEN_TOTP:
5289
- // Use secret from argument and auxiliary config (digits/algorithm) from totpConfig
5290
- // If totpConfig is not provided, use defaults (digits: 6, algorithm: 'SHA1')
5291
- const totpAux = params.totpConfig;
5292
- const secretArg = params.argument;
5293
- if (secretArg) {
5294
- try {
5295
- // Use provided config or defaults
5296
- const digits = (totpAux === null || totpAux === void 0 ? void 0 : totpAux.digits) || 6;
5297
- const algorithm = (totpAux === null || totpAux === void 0 ? void 0 : totpAux.algorithm) || 'SHA1';
5298
- const totpCode = this.generateOTP(secretArg, digits, algorithm);
5299
- proboLogger.log(`✅ TOTP generated (digits: ${digits}, algorithm: ${algorithm})`);
5300
- await locator.fill(totpCode, { timeout: this.timeoutConfig.playwrightActionTimeout });
5301
- }
5302
- catch (error) {
5303
- proboLogger.error(`❌ TOTP generation failed: ${error}`);
5304
- throw new Error(`TOTP generation failed: ${error}`);
5305
- }
5306
- }
5307
- else {
5308
- proboLogger.log(`❌ Missing TOTP argument`);
5309
- throw new Error(`Missing TOTP argument`);
5310
- }
5311
- break;
5312
- case exports.PlaywrightAction.ASSERT_CONTAINS_VALUE:
5313
- const containerText = await this.getTextValue(locator);
5314
- if (!matchRegex(containerText, argument)) {
5315
- throw new Error(`Validation failed. Expected text "${containerText}" to match "${argument}".`);
5316
- }
5317
- break;
5318
- case exports.PlaywrightAction.ASSERT_EXACT_VALUE:
5319
- const actualText = await this.getTextValue(locator);
5320
- if (actualText !== argument) {
5321
- throw new Error(`Validation failed. Expected text "${argument}", but got "${actualText}".`);
5322
- }
5323
- break;
5324
- case exports.PlaywrightAction.HOVER:
5325
- if (locator) {
5326
- await this.robustMouseAction(locator, 'hover');
5327
- }
5328
- else {
5329
- throw new Error('not executing HOVER because no visible ancestor found');
5330
- }
5331
- break;
5332
- case exports.PlaywrightAction.SCROLL_TO_ELEMENT:
5333
- // Restore exact scroll positions from recording
5334
- const scrollData = JSON.parse(argument);
5335
- const desiredScrollTop = scrollData.scrollTop;
5336
- const desiredScrollLeft = scrollData.scrollLeft;
5337
- const maxAttempts = 100;
5338
- try {
5339
- proboLogger.log('🔄 Restoring scroll position for container:', locator, 'scrollTop:', desiredScrollTop, 'scrollLeft:', desiredScrollLeft);
5340
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
5341
- // Perform scroll
5342
- await locator.evaluate((el, scrollData) => {
5343
- el.scrollTo({ left: scrollData.scrollLeft, top: scrollData.scrollTop, behavior: 'smooth' });
5344
- }, { scrollTop: desiredScrollTop, scrollLeft: desiredScrollLeft }, { timeout: this.timeoutConfig.playwrightActionTimeout });
5345
- // Wait for scroll to complete
5346
- await this.page.waitForTimeout(50);
5347
- // Get actual scroll positions
5348
- const actualScroll = await locator.evaluate((el) => {
5349
- return { scrollTop: el.scrollTop, scrollLeft: el.scrollLeft };
5350
- }, { timeout: this.timeoutConfig.playwrightActionTimeout });
5351
- // Compare actual vs desired
5352
- const scrollTopMatch = Math.abs(actualScroll.scrollTop - desiredScrollTop) < 1;
5353
- const scrollLeftMatch = Math.abs(actualScroll.scrollLeft - desiredScrollLeft) < 1;
5354
- if (scrollTopMatch && scrollLeftMatch) {
5355
- proboLogger.log(`🔄 Scroll position restored successfully on attempt ${attempt}`);
5356
- break;
5357
- }
5358
- if (attempt < maxAttempts) {
5359
- proboLogger.log(`🔄 Scroll position mismatch on attempt ${attempt}. Desired: (${desiredScrollTop}, ${desiredScrollLeft}), Actual: (${actualScroll.scrollTop}, ${actualScroll.scrollLeft}). Retrying...`);
5370
+ break;
5371
+ case exports.PlaywrightAction.FILL_IN:
5372
+ await this.robustFill(locator, argument);
5373
+ break;
5374
+ case exports.PlaywrightAction.SELECT_DROPDOWN:
5375
+ await locator.selectOption(argument, { timeout: this.timeoutConfig.playwrightActionTimeout });
5376
+ break;
5377
+ case exports.PlaywrightAction.SET_SLIDER:
5378
+ await this.setSliderValue(locator, argument);
5379
+ break;
5380
+ case exports.PlaywrightAction.WAIT_FOR_OTP:
5381
+ {
5382
+ const waitForOtpArgs = params;
5383
+ const otpInbox = typeof argument === 'string' && argument.trim() ? argument.trim() : undefined;
5384
+ const otpTimeout = waitForOtpArgs === null || waitForOtpArgs === void 0 ? void 0 : waitForOtpArgs.timeout;
5385
+ const otpCheckInterval = waitForOtpArgs === null || waitForOtpArgs === void 0 ? void 0 : waitForOtpArgs.checkInterval;
5386
+ const otpLookback = waitForOtpArgs === null || waitForOtpArgs === void 0 ? void 0 : waitForOtpArgs.checkRecentMessagesSinceMs;
5387
+ const otp = await OTP.waitForOTP({
5388
+ inbox: otpInbox,
5389
+ timeout: otpTimeout,
5390
+ checkInterval: otpCheckInterval,
5391
+ checkRecentMessagesSinceMs: otpLookback !== null && otpLookback !== void 0 ? otpLookback : 600000,
5392
+ });
5393
+ if (otp) {
5394
+ proboLogger.log(`✅ OTP found: ${otp}`);
5395
+ await locator.fill(otp, { timeout: this.timeoutConfig.playwrightActionTimeout });
5360
5396
  }
5361
5397
  else {
5362
- proboLogger.warn(`🔄 Scroll position mismatch after ${maxAttempts} attempts. Desired: (${desiredScrollTop}, ${desiredScrollLeft}), Final: (${actualScroll.scrollTop}, ${actualScroll.scrollLeft})`);
5398
+ proboLogger.log(`❌ OTP not found`);
5363
5399
  }
5364
5400
  }
5365
- }
5366
- catch (e) {
5367
- proboLogger.error('🔄 Failed to restore scroll position for container:', locator, 'scrollTop:', desiredScrollTop, 'scrollLeft:', desiredScrollLeft, 'error:', e);
5368
- }
5369
- break;
5370
- case exports.PlaywrightAction.UPLOAD_FILES:
5371
- await locator.setInputFiles(argument, { timeout: this.timeoutConfig.playwrightActionTimeout });
5372
- break;
5373
- case exports.PlaywrightAction.EXTRACT_VALUE:
5374
- let extractedText = await this.getTextValue(locator);
5375
- return extractedText;
5376
- case exports.PlaywrightAction.WAIT_FOR:
5377
- const expectedText = argument;
5378
- // Use smaller polling interval for better precision (can detect changes sooner)
5379
- const pollingInterval = params.pollingInterval || 100; // Default 100ms for better precision
5380
- const timeout = params.timeout || 10000; // Default 10 seconds
5381
- // Create a cancellation promise that rejects when cancellation is detected
5382
- // This allows us to cancel the text polling loop at any time
5383
- const createCancellationPromise = () => {
5384
- const cancellationCheckInterval = 50; // Check for cancellation every 50ms
5385
- return new Promise((_, reject) => {
5386
- const checkCancellation = () => {
5387
- if (this.isCanceled && this.isCanceled()) {
5388
- reject(new Error('Replay execution was cancelled'));
5389
- return;
5390
- }
5391
- setTimeout(checkCancellation, cancellationCheckInterval);
5392
- };
5393
- checkCancellation();
5394
- });
5395
- };
5396
- let textMatches = false;
5397
- let currentText = '';
5398
- const waitStartTime = Date.now();
5399
- const cancellationPromise = createCancellationPromise();
5400
- while (!textMatches && (Date.now() - waitStartTime) < timeout) {
5401
+ break;
5402
+ case exports.PlaywrightAction.GEN_TOTP:
5403
+ // Use secret from argument (interpolated above) and auxiliary config (digits/algorithm) from totpConfig
5404
+ // If totpConfig is not provided, use defaults (digits: 6, algorithm: 'SHA1')
5405
+ const totpAux = params.totpConfig;
5406
+ const secretArg = argument;
5407
+ if (secretArg) {
5408
+ try {
5409
+ // Use provided config or defaults
5410
+ const digits = (totpAux === null || totpAux === void 0 ? void 0 : totpAux.digits) || 6;
5411
+ const algorithm = (totpAux === null || totpAux === void 0 ? void 0 : totpAux.algorithm) || 'SHA1';
5412
+ const totpCode = this.generateOTP(secretArg, digits, algorithm);
5413
+ proboLogger.log(`✅ TOTP generated (digits: ${digits}, algorithm: ${algorithm})`);
5414
+ await locator.fill(totpCode, { timeout: this.timeoutConfig.playwrightActionTimeout });
5415
+ }
5416
+ catch (error) {
5417
+ proboLogger.error(`❌ TOTP generation failed: ${error}`);
5418
+ throw new Error(`TOTP generation failed: ${error}`);
5419
+ }
5420
+ }
5421
+ else {
5422
+ proboLogger.log(`❌ Missing TOTP argument`);
5423
+ throw new Error(`Missing TOTP argument`);
5424
+ }
5425
+ break;
5426
+ case exports.PlaywrightAction.ASSERT_CONTAINS_VALUE:
5427
+ const containerText = await this.getTextValue(locator);
5428
+ if (!matchRegex(containerText, argument)) {
5429
+ throw new Error(`Validation failed. Expected text "${containerText}" to match "${argument}".`);
5430
+ }
5431
+ break;
5432
+ case exports.PlaywrightAction.ASSERT_EXACT_VALUE:
5433
+ const actualText = await this.getTextValue(locator);
5434
+ if (actualText !== argument) {
5435
+ throw new Error(`Validation failed. Expected text "${argument}", but got "${actualText}".`);
5436
+ }
5437
+ break;
5438
+ case exports.PlaywrightAction.HOVER:
5439
+ if (locator) {
5440
+ await this.robustMouseAction(locator, 'hover');
5441
+ }
5442
+ else {
5443
+ throw new Error('not executing HOVER because no visible ancestor found');
5444
+ }
5445
+ break;
5446
+ case exports.PlaywrightAction.SCROLL_TO_ELEMENT:
5447
+ // Restore exact scroll positions from recording
5448
+ const scrollData = JSON.parse(argument);
5449
+ const desiredScrollTop = scrollData.scrollTop;
5450
+ const desiredScrollLeft = scrollData.scrollLeft;
5451
+ const maxAttempts = 100;
5401
5452
  try {
5402
- // Check if element is visible first
5403
- const isVisible = await locator.isVisible();
5404
- if (isVisible) {
5405
- // Get the current text content only if element is visible
5406
- currentText = await this.getTextValue(locator);
5407
- // Check if the text matches (using the same logic as ASSERT_CONTAINS_VALUE)
5408
- if (matchRegex(currentText, expectedText)) {
5409
- textMatches = true;
5410
- proboLogger.log(`✅ Wait for text completed successfully. Found: "${currentText}"`);
5453
+ proboLogger.log('🔄 Restoring scroll position for container:', locator, 'scrollTop:', desiredScrollTop, 'scrollLeft:', desiredScrollLeft);
5454
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
5455
+ // Perform scroll
5456
+ await locator.evaluate((el, scrollData) => {
5457
+ el.scrollTo({ left: scrollData.scrollLeft, top: scrollData.scrollTop, behavior: 'smooth' });
5458
+ }, { scrollTop: desiredScrollTop, scrollLeft: desiredScrollLeft }, { timeout: this.timeoutConfig.playwrightActionTimeout });
5459
+ // Wait for scroll to complete
5460
+ await this.page.waitForTimeout(50);
5461
+ // Get actual scroll positions
5462
+ const actualScroll = await locator.evaluate((el) => {
5463
+ return { scrollTop: el.scrollTop, scrollLeft: el.scrollLeft };
5464
+ }, { timeout: this.timeoutConfig.playwrightActionTimeout });
5465
+ // Compare actual vs desired
5466
+ const scrollTopMatch = Math.abs(actualScroll.scrollTop - desiredScrollTop) < 1;
5467
+ const scrollLeftMatch = Math.abs(actualScroll.scrollLeft - desiredScrollLeft) < 1;
5468
+ if (scrollTopMatch && scrollLeftMatch) {
5469
+ proboLogger.log(`🔄 Scroll position restored successfully on attempt ${attempt}`);
5411
5470
  break;
5412
5471
  }
5413
- }
5414
- // Text doesn't match yet or element not visible, wait for the polling interval
5415
- // Use Promise.race() to allow cancellation during the wait (consistent with element attachment logic)
5416
- if ((Date.now() - waitStartTime) < timeout) {
5417
- const remainingTime = timeout - (Date.now() - waitStartTime);
5418
- const actualPollInterval = Math.min(pollingInterval, remainingTime);
5419
- if (actualPollInterval > 0) {
5420
- if (!this.page) {
5421
- throw new Error('Page is not set');
5422
- }
5423
- // Race between the timeout and cancellation check
5424
- await Promise.race([
5425
- this.page.waitForTimeout(actualPollInterval),
5426
- cancellationPromise
5427
- ]);
5472
+ if (attempt < maxAttempts) {
5473
+ proboLogger.log(`🔄 Scroll position mismatch on attempt ${attempt}. Desired: (${desiredScrollTop}, ${desiredScrollLeft}), Actual: (${actualScroll.scrollTop}, ${actualScroll.scrollLeft}). Retrying...`);
5474
+ }
5475
+ else {
5476
+ proboLogger.warn(`🔄 Scroll position mismatch after ${maxAttempts} attempts. Desired: (${desiredScrollTop}, ${desiredScrollLeft}), Final: (${actualScroll.scrollTop}, ${actualScroll.scrollLeft})`);
5428
5477
  }
5429
5478
  }
5430
5479
  }
5431
5480
  catch (e) {
5432
- // If it's a cancellation error, re-throw it
5433
- if (e instanceof Error && e.message === 'Replay execution was cancelled') {
5434
- throw e;
5481
+ proboLogger.error('🔄 Failed to restore scroll position for container:', locator, 'scrollTop:', desiredScrollTop, 'scrollLeft:', desiredScrollLeft, 'error:', e);
5482
+ }
5483
+ break;
5484
+ case exports.PlaywrightAction.UPLOAD_FILES:
5485
+ await locator.setInputFiles(argument, { timeout: this.timeoutConfig.playwrightActionTimeout });
5486
+ break;
5487
+ case exports.PlaywrightAction.EXTRACT_VALUE:
5488
+ let extractedText = await this.getTextValue(locator);
5489
+ return extractedText;
5490
+ case exports.PlaywrightAction.WAIT_FOR:
5491
+ const expectedText = argument;
5492
+ // Use smaller polling interval for better precision (can detect changes sooner)
5493
+ const pollingInterval = params.pollingInterval || 100; // Default 100ms for better precision
5494
+ const timeout = params.timeout || 10000; // Default 10 seconds
5495
+ // Create a cancellation promise that rejects when cancellation is detected
5496
+ // This allows us to cancel the text polling loop at any time
5497
+ const createCancellationPromise = () => {
5498
+ const cancellationCheckInterval = 50; // Check for cancellation every 50ms
5499
+ return new Promise((_, reject) => {
5500
+ const checkCancellation = () => {
5501
+ if (this.isCanceled && this.isCanceled()) {
5502
+ reject(new Error('Replay execution was cancelled'));
5503
+ return;
5504
+ }
5505
+ setTimeout(checkCancellation, cancellationCheckInterval);
5506
+ };
5507
+ checkCancellation();
5508
+ });
5509
+ };
5510
+ let textMatches = false;
5511
+ let currentText = '';
5512
+ const waitStartTime = Date.now();
5513
+ const cancellationPromise = createCancellationPromise();
5514
+ while (!textMatches && (Date.now() - waitStartTime) < timeout) {
5515
+ try {
5516
+ // Check if element is visible first
5517
+ const isVisible = await locator.isVisible();
5518
+ if (isVisible) {
5519
+ // Get the current text content only if element is visible
5520
+ currentText = await this.getTextValue(locator);
5521
+ // Check if the text matches (using the same logic as ASSERT_CONTAINS_VALUE)
5522
+ if (matchRegex(currentText, expectedText)) {
5523
+ textMatches = true;
5524
+ proboLogger.log(`✅ Wait for text completed successfully. Found: "${currentText}"`);
5525
+ break;
5526
+ }
5527
+ }
5528
+ // Text doesn't match yet or element not visible, wait for the polling interval
5529
+ // Use Promise.race() to allow cancellation during the wait (consistent with element attachment logic)
5530
+ if ((Date.now() - waitStartTime) < timeout) {
5531
+ const remainingTime = timeout - (Date.now() - waitStartTime);
5532
+ const actualPollInterval = Math.min(pollingInterval, remainingTime);
5533
+ if (actualPollInterval > 0) {
5534
+ if (!this.page) {
5535
+ throw new Error('Page is not set');
5536
+ }
5537
+ // Race between the timeout and cancellation check
5538
+ await Promise.race([
5539
+ this.page.waitForTimeout(actualPollInterval),
5540
+ cancellationPromise
5541
+ ]);
5542
+ }
5543
+ }
5544
+ }
5545
+ catch (e) {
5546
+ // If it's a cancellation error, re-throw it
5547
+ if (e instanceof Error && e.message === 'Replay execution was cancelled') {
5548
+ throw e;
5549
+ }
5550
+ throw new Error(`Wait for text failed while trying to extract text from selector: ${elementSelector}${iframeSelector ? ` in iframe: ${iframeSelector}` : ''}`);
5435
5551
  }
5436
- throw new Error(`Wait for text failed while trying to extract text from selector: ${elementSelector}${iframeSelector ? ` in iframe: ${iframeSelector}` : ''}`);
5437
5552
  }
5438
- }
5439
- // Final cancellation check
5440
- if (this.isCanceled && this.isCanceled()) {
5441
- throw new Error('Replay execution was cancelled');
5442
- }
5443
- // Timeout reached without a match
5444
- if (!textMatches) {
5445
- throw new Error(`Wait for text failed. Expected "${expectedText}" to match "${currentText}" after ${timeout}ms of polling every ${pollingInterval}ms`);
5446
- }
5447
- break;
5448
- default:
5449
- throw new Error(`Unhandled action: ${action}`);
5553
+ // Final cancellation check
5554
+ if (this.isCanceled && this.isCanceled()) {
5555
+ throw new Error('Replay execution was cancelled');
5556
+ }
5557
+ // Timeout reached without a match
5558
+ if (!textMatches) {
5559
+ throw new Error(`Wait for text failed. Expected "${expectedText}" to match "${currentText}" after ${timeout}ms of polling every ${pollingInterval}ms`);
5560
+ }
5561
+ break;
5562
+ default:
5563
+ throw new Error(`Unhandled action: ${action}`);
5564
+ }
5565
+ }
5566
+ finally {
5567
+ this.page = originalPage;
5450
5568
  }
5451
5569
  }
5452
5570
  /**