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