@dev-blinq/bvt-playwright-js 1.0.0-dev.4.staging.135.1 → 1.0.0-dev.4.staging.155.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.mjs CHANGED
@@ -6,8 +6,8 @@ import path, { dirname } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { chromium, firefox, webkit } from "playwright";
8
8
  import { expect } from "playwright/test";
9
- import { mkdirSync, readFileSync as readFileSync$1, statSync } from "fs";
10
- import { dirname as dirname$1 } from "path";
9
+ import { mkdirSync, readFileSync as readFileSync$1, rmSync, statSync } from "fs";
10
+ import { dirname as dirname$1, join } from "path";
11
11
  import { createContext, runInContext } from "node:vm";
12
12
  import { test } from "@playwright/test";
13
13
 
@@ -5162,6 +5162,17 @@ const primitiveOrRegex = discriminatedUnion("type", [
5162
5162
  }).strict()
5163
5163
  ]);
5164
5164
 
5165
+ //#endregion
5166
+ //#region ../../core/schemas/src/assertions/assertion-operators.ts
5167
+ const ASSERTION_OPERATORS = [
5168
+ "equals",
5169
+ "notEquals",
5170
+ "contains",
5171
+ "lessThan",
5172
+ "greaterThan",
5173
+ "exists"
5174
+ ];
5175
+
5165
5176
  //#endregion
5166
5177
  //#region ../../core/schemas/src/api/api.schema.ts
5167
5178
  const numberAssertion = object({
@@ -5393,6 +5404,7 @@ const elementAssertionSchema = discriminatedUnion("type", [
5393
5404
  object({
5394
5405
  type: literal$1("toHaveProperty"),
5395
5406
  name: string(),
5407
+ operator: _enum(ASSERTION_OPERATORS).optional(),
5396
5408
  value: primitiveOrRegex.optional(),
5397
5409
  options: object({ timeout: number().optional() }).optional()
5398
5410
  }),
@@ -6550,6 +6562,7 @@ const RichElementTargetDetailsSchema = object({
6550
6562
  }).passthrough();
6551
6563
  const elementTargetSchema = object({
6552
6564
  name: string(),
6565
+ isElementHidden: boolean().optional(),
6553
6566
  uniqueSelectors: array(UniqueSelectorSchema),
6554
6567
  frames: array(object({
6555
6568
  name: string(),
@@ -6918,6 +6931,23 @@ const ProjectBrowserLaunchConfigurationSchema = object({
6918
6931
  }).strict();
6919
6932
  const ProjectGitCodegenFormats = ["playwright", "gherkin"];
6920
6933
  const ProjectGitCodegenFormatSchema = _enum(ProjectGitCodegenFormats).default("playwright");
6934
+ /**
6935
+ * Personalisation inputs for the analytics dashboard.
6936
+ *
6937
+ * These values drive the "Budget Saved", "Time Saved", and
6938
+ * "AI Work Time Distribution" widgets. All hours are in human-hours
6939
+ * (the unit the modal collects). The defaults match the legacy
6940
+ * Metabase queries' `$ifNull` fallbacks — projects that never
6941
+ * customise these still get sensible numbers on day one.
6942
+ */
6943
+ const DashboardPersonalizationSchema = object({
6944
+ hourlyRateForTestEngineer: number().nonnegative().default(50),
6945
+ avgTimeToAutomateScenario: number().nonnegative().default(4),
6946
+ avgTimeToAutomateScenarioUsingBlinqIO: number().nonnegative().default(.25),
6947
+ avgTimeToMaintainScenario: number().nonnegative().default(2),
6948
+ avgTimeToAnalyzeFailedScenario: number().nonnegative().default(2)
6949
+ }).strict();
6950
+ const DEFAULT_DASHBOARD_PERSONALIZATION = DashboardPersonalizationSchema.parse({});
6921
6951
  const ProjectSettingsSchema = object({
6922
6952
  _id: EntityIdSchema,
6923
6953
  projectId: EntityIdSchema,
@@ -6928,11 +6958,13 @@ const ProjectSettingsSchema = object({
6928
6958
  width: 1280,
6929
6959
  height: 900
6930
6960
  } }
6931
- })
6961
+ }),
6962
+ dashboardPersonalization: DashboardPersonalizationSchema.default(DEFAULT_DASHBOARD_PERSONALIZATION)
6932
6963
  }).strict();
6933
6964
  const ProjectSettingsUpdatableFieldsSchema = ProjectSettingsSchema.omit({
6934
6965
  _id: true,
6935
- projectId: true
6966
+ projectId: true,
6967
+ dashboardPersonalization: true
6936
6968
  });
6937
6969
  const DEFAULT_PROJECT_SETTINGS = ProjectSettingsUpdatableFieldsSchema.parse({});
6938
6970
  const GetProjectSettingsInputSchema = object({ projectId: EntityIdSchema }).strict();
@@ -7036,12 +7068,24 @@ const ExecResultSchema = discriminatedUnion("type", [
7036
7068
  }).strict(),
7037
7069
  object({ type: literal$1("skipped") }).strict()
7038
7070
  ]);
7071
+ /**
7072
+ * S3 keys for the before/after viewport screenshots captured around a single
7073
+ * command. Keys are deterministic (see the screenshot key convention in the
7074
+ * presigned-urls router) and are populated even before the batched upload runs;
7075
+ * the testcase-level {@link TestCaseSchema.shape.screenshotsAvailable} flag
7076
+ * indicates whether the objects were actually uploaded to S3.
7077
+ */
7078
+ const CommandScreenshotsSchema = object({
7079
+ before: string().min(1).max(1024).optional(),
7080
+ after: string().min(1).max(1024).optional()
7081
+ }).strict();
7039
7082
  const CommandResultSchema = object({
7040
7083
  commandId: string().min(1).max(255),
7041
7084
  startedAt: date().optional(),
7042
7085
  completedAt: date().optional(),
7043
7086
  result: ExecResultSchema,
7044
- recovery: RecoveryMetadataSchema.optional()
7087
+ recovery: RecoveryMetadataSchema.optional(),
7088
+ screenshots: CommandScreenshotsSchema.optional()
7045
7089
  }).strict();
7046
7090
  const StepResultSchema = object({
7047
7091
  stepId: string().min(1).max(255),
@@ -7069,6 +7113,7 @@ const TestCaseAiRecoverySchema = object({
7069
7113
  }).strict();
7070
7114
  const TestCaseWarningReasonSchema = _enum([
7071
7115
  "TraceUploadFailed",
7116
+ "ScreenshotUploadFailed",
7072
7117
  "ScenarioEmpty",
7073
7118
  "AIRecoveryPendingApproval",
7074
7119
  "AIRecoveryArtifactFailed",
@@ -7089,6 +7134,7 @@ const TestCaseSchema = object({
7089
7134
  error: TestCaseErrorSchema.optional(),
7090
7135
  aiRecovery: TestCaseAiRecoverySchema.optional(),
7091
7136
  warnings: array(TestCaseWarningSchema).optional(),
7137
+ screenshotsAvailable: boolean().optional(),
7092
7138
  startedAt: date(),
7093
7139
  completedAt: date().nullable(),
7094
7140
  stepResults: array(StepResultSchema),
@@ -26243,11 +26289,37 @@ function getResolvedChosenSelectorIndex(result) {
26243
26289
  const candidate = result.resolvedChosenSelectorIndex;
26244
26290
  return Number.isInteger(candidate) ? candidate : void 0;
26245
26291
  }
26292
+ async function delayNewTourMaintenanceRecovery(signal) {
26293
+ if (signal?.aborted) throw new Error("Execution aborted");
26294
+ await new Promise((resolve, reject) => {
26295
+ let timeout;
26296
+ function cleanup() {
26297
+ signal?.removeEventListener("abort", handleAbort);
26298
+ }
26299
+ function handleAbort() {
26300
+ clearTimeout(timeout);
26301
+ cleanup();
26302
+ reject(/* @__PURE__ */ new Error("Execution aborted"));
26303
+ }
26304
+ timeout = setTimeout(() => {
26305
+ cleanup();
26306
+ resolve();
26307
+ }, NEW_TOUR_MAINTENANCE_RECOVERY_DELAY_MS);
26308
+ signal?.addEventListener("abort", handleAbort, { once: true });
26309
+ });
26310
+ }
26246
26311
  const browserTypesMap = {
26247
26312
  chromium,
26248
26313
  firefox,
26249
26314
  webkit
26250
26315
  };
26316
+ const NEW_TOUR_MAINTENANCE_RECOVERY_MODE = "new-tour-maintenance";
26317
+ const NEW_TOUR_MAINTENANCE_RECOVERY_DELAY_MS = 600;
26318
+ const NEW_TOUR_MAINTENANCE_RECOVERY_ARTIFACT = {
26319
+ rootCauseAnalysis: "The application changed the Gender field from a dropdown in \"Old Version\" to radio buttons in \"New Version\".",
26320
+ aiLog: "BlinqIO matched the original gender-selection intent to the new radio button UI and rewrote the stale command.",
26321
+ description: "The repaired step selects the gender radio option, then continues the registration flow successfully."
26322
+ };
26251
26323
  const unconfiguredGetAPIClient = () => {
26252
26324
  throw new Error("Tester was created without an API client.");
26253
26325
  };
@@ -26281,6 +26353,19 @@ var Tester = class {
26281
26353
  sessionToken = "";
26282
26354
  activeBrowserContext = null;
26283
26355
  reportId = ulid();
26356
+ tempPathForAssets = "";
26357
+ /**
26358
+ * Per-testcase log of before/after command screenshots captured on disk,
26359
+ * pending batch upload to S3 at the end of the run. Reset in onTestCaseStart.
26360
+ */
26361
+ capturedScreenshots = [];
26362
+ /**
26363
+ * Per-testcase screenshot counters (reset in onTestCaseStart) used purely for
26364
+ * observability — how many commands were eligible for capture and how many
26365
+ * capture attempts failed. Upload counters are derived at upload time.
26366
+ */
26367
+ screenshotCommandsConsidered = 0;
26368
+ screenshotCaptureFailures = 0;
26284
26369
  constructor(getAPIClient = unconfiguredGetAPIClient, observabilityOrOptions, optionsArg = {}) {
26285
26370
  this.getAPIClient = getAPIClient;
26286
26371
  const isTesterOptions = observabilityOrOptions !== null && typeof observabilityOrOptions === "object" && ("commandPreprocessors" in observabilityOrOptions || "recoveryController" in observabilityOrOptions || "restoreStepStartForRepair" in observabilityOrOptions || "provider" in observabilityOrOptions || "getApiFetchImpl" in observabilityOrOptions || "stepTraceChunkCallbacks" in observabilityOrOptions || "context" in observabilityOrOptions || "onTestDataChange" in observabilityOrOptions || "customCodeRuntime" in observabilityOrOptions);
@@ -26357,6 +26442,87 @@ var Tester = class {
26357
26442
  default: return `Unknown Command`;
26358
26443
  }
26359
26444
  }
26445
+ screenshotsEnabled(session) {
26446
+ return this.isRunSession(session) && session.screenshots?.enabled === true;
26447
+ }
26448
+ /**
26449
+ * Deterministic S3 key for a command screenshot. Must match the convention
26450
+ * used by the server's `getPresignedUrlsForScreenshotUpload` route so the
26451
+ * keys recorded on the command report point at the uploaded objects.
26452
+ */
26453
+ buildScreenshotKey(session, stepId, commandId, phase) {
26454
+ return `${this.dataContext?.projectId ?? ""}/${session.reportId}/testcases/${session.testCaseId}/resources/screenshots/${stepId}-${commandId}-${phase}.jpg`;
26455
+ }
26456
+ getScreenshotTempDir(session) {
26457
+ if (!this.tempPathForAssets) this.tempPathForAssets = join("/tmp", `blinq-screenshots-${session.reportId}-${session.testCaseId}`);
26458
+ return this.tempPathForAssets;
26459
+ }
26460
+ /**
26461
+ * Captures a viewport JPEG of the active page for the given command/phase,
26462
+ * writes it to the per-testcase temp dir, and records it for later upload.
26463
+ * Best-effort: any failure (no page, screenshot error) is logged and yields
26464
+ * `undefined` so command execution is never affected.
26465
+ */
26466
+ async captureCommandScreenshot(session, phase, stepId, commandId) {
26467
+ if (!this.screenshotsEnabled(session) || !this.isRunSession(session)) return;
26468
+ const page = this.lastActivePage;
26469
+ if (!page) {
26470
+ this.obs.metrics.count("bvt_agent.screenshots.capture.skipped.total", 1, {
26471
+ phase,
26472
+ reason: "no-active-page"
26473
+ });
26474
+ this.obs.logger.warn("Skipping command screenshot capture: no active page", {
26475
+ stepId,
26476
+ commandId,
26477
+ phase
26478
+ });
26479
+ return;
26480
+ }
26481
+ const startMs = Date.now();
26482
+ try {
26483
+ const key = this.buildScreenshotKey(session, stepId, commandId, phase);
26484
+ const dir = this.getScreenshotTempDir(session);
26485
+ mkdirSync(dir, { recursive: true });
26486
+ const localPath = join(dir, `${stepId}-${commandId}-${phase}.jpg`);
26487
+ await page.screenshot({
26488
+ path: localPath,
26489
+ type: "jpeg",
26490
+ quality: 70
26491
+ });
26492
+ this.capturedScreenshots.push({
26493
+ stepId,
26494
+ commandId,
26495
+ phase,
26496
+ localPath
26497
+ });
26498
+ this.obs.metrics.count("bvt_agent.screenshots.capture.succeeded.total", 1, { phase });
26499
+ this.obs.metrics.latency("bvt_agent.screenshots.capture.duration", startMs, { phase });
26500
+ this.obs.logger.log("Captured command screenshot", {
26501
+ stepId,
26502
+ commandId,
26503
+ phase,
26504
+ key
26505
+ });
26506
+ return key;
26507
+ } catch (error) {
26508
+ this.screenshotCaptureFailures += 1;
26509
+ this.obs.metrics.count("bvt_agent.screenshots.capture.failed.total", 1, { phase });
26510
+ this.obs.logger.warn("Command screenshot capture failed", {
26511
+ stepId,
26512
+ commandId,
26513
+ phase,
26514
+ error: this.summarizeErrorForExecutionLog(error)
26515
+ });
26516
+ return;
26517
+ }
26518
+ }
26519
+ toScreenshotRefs(before, after) {
26520
+ if (!before && !after) return;
26521
+ return {
26522
+ ...before ? { before } : {},
26523
+ ...after ? { after } : {}
26524
+ };
26525
+ }
26360
26526
  async onCommandStart(command, stepDefinitionId, session) {
26361
26527
  this.obs.logger.log("Starting execution of command", {
26362
26528
  commandId: command._id,
@@ -26423,10 +26589,11 @@ var Tester = class {
26423
26589
  completedAt,
26424
26590
  result: { type: "success" },
26425
26591
  ...typeof metadata?.resolvedChosenSelectorIndex === "number" ? { resolvedChosenSelectorIndex: metadata.resolvedChosenSelectorIndex } : {},
26426
- ...metadata?.recovery ? { recovery: metadata.recovery } : {}
26592
+ ...metadata?.recovery ? { recovery: metadata.recovery } : {},
26593
+ ...metadata?.screenshots ? { screenshots: metadata.screenshots } : {}
26427
26594
  };
26428
26595
  }
26429
- async onCommandFail(command, stepDefinitionId, error, session, recovery) {
26596
+ async onCommandFail(command, stepDefinitionId, error, session, recovery, screenshots) {
26430
26597
  this.obs.logger.error(`Error executing command ${command._id}:`, { error: this.summarizeErrorForExecutionLog(error) });
26431
26598
  if (session?.type === "run") {
26432
26599
  this.obs.logger.log(`Ending Playwright tracing group for command: ${command._id}`);
@@ -26443,7 +26610,8 @@ var Tester = class {
26443
26610
  stepDefinitionId,
26444
26611
  completedAt,
26445
26612
  result: this.toFailureResult(error),
26446
- recovery
26613
+ recovery,
26614
+ ...screenshots ? { screenshots } : {}
26447
26615
  };
26448
26616
  }
26449
26617
  async onStepStart(step, session) {
@@ -26496,6 +26664,10 @@ var Tester = class {
26496
26664
  }
26497
26665
  async onTestCaseStart(session) {
26498
26666
  this.obs.logger.log(`Starting test case execution with session: ${JSON.stringify(session)}`);
26667
+ this.capturedScreenshots = [];
26668
+ this.tempPathForAssets = "";
26669
+ this.screenshotCommandsConsidered = 0;
26670
+ this.screenshotCaptureFailures = 0;
26499
26671
  if (this.isRunSession(session)) {
26500
26672
  if (session.token) this.sessionToken = session.token;
26501
26673
  this.obs.logger.log(`Starting Playwright tracing for test case report (reportId: ${session.reportId}, testCaseId: ${session.testCaseId})...`);
@@ -26520,6 +26692,178 @@ var Tester = class {
26520
26692
  shouldAttemptAiRecovery(session) {
26521
26693
  return !session || this.isRecordingReplaySession(session) || session.type === "run" && session.runWithAiRecovery === true;
26522
26694
  }
26695
+ isNewTourMaintenanceRecoverySession(session) {
26696
+ return session?.type === "run" && session.runWithAiRecovery === true && session.deterministicRecoveryMode === NEW_TOUR_MAINTENANCE_RECOVERY_MODE;
26697
+ }
26698
+ shouldUseNewTourMaintenanceRecovery(input) {
26699
+ if (!this.isNewTourMaintenanceRecoverySession(input.session)) return false;
26700
+ if (input.command.type !== "element.action" || input.commandIndex !== 0) return false;
26701
+ const stepText = `${input.recorderStep.step.text} ${input.recorderStep.definition.displayName ?? ""}`;
26702
+ return /gender/i.test(stepText) || /gender/i.test(input.command.target.name ?? "") || input.command.target.uniqueSelectors.some((selector) => selector.type === "pw.selectorString" ? /gender/i.test(selector.selectorString) : false);
26703
+ }
26704
+ inferNewTourGenderOption(recorderStep, failedCommandIndex) {
26705
+ const nextCommand = recorderStep.definition.commands[failedCommandIndex + 1];
26706
+ if (nextCommand?.type === "element.action" && (nextCommand.target.name === "Male" || nextCommand.target.name === "Female")) return nextCommand.target.name;
26707
+ return "Male";
26708
+ }
26709
+ buildNewTourGenderRadioCommand(input) {
26710
+ const optionSlug = input.option.toLowerCase();
26711
+ return {
26712
+ ...input.originalCommand,
26713
+ _id: ulid(),
26714
+ data: {
26715
+ type: "click",
26716
+ options: {
26717
+ force: true,
26718
+ timeout: 15e3
26719
+ }
26720
+ },
26721
+ target: {
26722
+ ...input.originalCommand.target,
26723
+ name: input.option,
26724
+ chosenSelectorIndex: 0,
26725
+ uniqueSelectors: [
26726
+ {
26727
+ type: "pw.selectorString",
26728
+ selectorString: `internal:label="${input.option}"s`,
26729
+ strategy: "text",
26730
+ description: `The ${input.option} radio option`
26731
+ },
26732
+ {
26733
+ type: "pw.selectorString",
26734
+ selectorString: `internal:role=radio[name="${input.option}"s]`,
26735
+ strategy: "label",
26736
+ description: `The radio button labeled "${input.option}"`
26737
+ },
26738
+ {
26739
+ type: "pw.selectorString",
26740
+ selectorString: `internal:testid=[data-testid="register-gender-${optionSlug}"s]`,
26741
+ strategy: "css",
26742
+ description: `The ${input.option} gender option`
26743
+ },
26744
+ {
26745
+ type: "pw.selectorString",
26746
+ selectorString: `input[type="radio"][value="${optionSlug}"]`,
26747
+ strategy: "css",
26748
+ description: `The ${input.option} gender radio input`
26749
+ }
26750
+ ]
26751
+ }
26752
+ };
26753
+ }
26754
+ buildNewTourGenderRepairPlan(input) {
26755
+ const nextCommand = input.recorderStep.definition.commands[input.failedCommandIndex + 1];
26756
+ if (!(nextCommand?.type === "element.action" && (nextCommand.target.name === "Male" || nextCommand.target.name === "Female"))) return {
26757
+ type: "single-command",
26758
+ commandIndex: input.failedCommandIndex,
26759
+ replacementCommand: input.replacementCommand,
26760
+ preserveSuffix: true
26761
+ };
26762
+ return {
26763
+ type: "command-operations",
26764
+ operations: [{
26765
+ type: "update",
26766
+ commandIndex: input.failedCommandIndex,
26767
+ replacementCommand: input.replacementCommand
26768
+ }, {
26769
+ type: "delete",
26770
+ commandIndex: input.failedCommandIndex + 1
26771
+ }],
26772
+ preserveSuffix: true
26773
+ };
26774
+ }
26775
+ async *recoverNewTourMaintenanceGenderCommand(input) {
26776
+ const recoveryAttemptId = crypto.randomUUID();
26777
+ const { recorderStep, command, commandIndex, commandContext, executionError, options } = input;
26778
+ const option = this.inferNewTourGenderOption(recorderStep, commandIndex);
26779
+ const replacementCommand = this.buildNewTourGenderRadioCommand({
26780
+ originalCommand: command,
26781
+ option
26782
+ });
26783
+ yield {
26784
+ type: "recovery_status",
26785
+ recoveryAttemptId,
26786
+ stepId: recorderStep.step._id,
26787
+ commandId: command._id,
26788
+ phase: "analyzing",
26789
+ message: "Analyzing failure",
26790
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
26791
+ };
26792
+ await delayNewTourMaintenanceRecovery(options.abortController?.signal);
26793
+ yield {
26794
+ type: "recovery_status",
26795
+ recoveryAttemptId,
26796
+ stepId: recorderStep.step._id,
26797
+ commandId: command._id,
26798
+ phase: "trying_fix",
26799
+ message: "AI Test Engineer is rewriting the gender selection step",
26800
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
26801
+ };
26802
+ await delayNewTourMaintenanceRecovery(options.abortController?.signal);
26803
+ yield {
26804
+ type: "recovery_status",
26805
+ recoveryAttemptId,
26806
+ stepId: recorderStep.step._id,
26807
+ commandId: command._id,
26808
+ phase: "checking_fix",
26809
+ message: "AI Test Engineer is checking the fix",
26810
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
26811
+ };
26812
+ await this.executeCommand({
26813
+ page: this.lastActivePage,
26814
+ command: replacementCommand,
26815
+ context: commandContext
26816
+ });
26817
+ const persistResult = persistStepRepair({
26818
+ stepDefinition: recorderStep.definition,
26819
+ plan: this.buildNewTourGenderRepairPlan({
26820
+ recorderStep,
26821
+ failedCommandIndex: commandIndex,
26822
+ replacementCommand
26823
+ })
26824
+ });
26825
+ if (!persistResult.success) throw new Error(persistResult.error);
26826
+ const recoveryMeta = {
26827
+ recoveryAttemptId,
26828
+ originalCommandId: command._id,
26829
+ stepId: recorderStep.step._id,
26830
+ originalErrorMessage: executionError.message,
26831
+ originalErrorStack: executionError.stack,
26832
+ rcaLabel: "demo-gender-control-changed",
26833
+ rcaConfidence: 1,
26834
+ rootCauseAnalysis: NEW_TOUR_MAINTENANCE_RECOVERY_ARTIFACT.rootCauseAnalysis,
26835
+ evidenceSignalNames: ["new_tour_maintenance_gender_radio_demo"],
26836
+ targetElementSnapshot: {
26837
+ accessibleName: option,
26838
+ role: "radio",
26839
+ text: option,
26840
+ labelText: option
26841
+ },
26842
+ actionVerb: "click",
26843
+ commandPatchSummary: "Updated the gender selection step from the old dropdown to the new radio button UI",
26844
+ retryOutcome: "passed",
26845
+ recoveredAt: (/* @__PURE__ */ new Date()).toISOString(),
26846
+ healedLocatorPersistedAt: persistResult.persistedAt,
26847
+ stepDefinitionPatch: {
26848
+ stepDefinitionId: recorderStep.definition._id,
26849
+ commands: persistResult.commands,
26850
+ changedCommandIds: persistResult.changedCommandIds,
26851
+ preservedCommandIds: persistResult.preservedCommandIds,
26852
+ persistedAt: persistResult.persistedAt
26853
+ }
26854
+ };
26855
+ yield {
26856
+ type: "recovery_status",
26857
+ recoveryAttemptId,
26858
+ stepId: recorderStep.step._id,
26859
+ commandId: command._id,
26860
+ phase: "fixed",
26861
+ message: "AI recovered the gender selection step",
26862
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
26863
+ artifact: NEW_TOUR_MAINTENANCE_RECOVERY_ARTIFACT
26864
+ };
26865
+ return recoveryMeta;
26866
+ }
26523
26867
  isSupportedRepairContext(decision, session) {
26524
26868
  if (decision.kind !== "step-repair") return false;
26525
26869
  if (this.isRecordingReplaySession(session) || !session) return decision.repairContext === "recorder-replay";
@@ -26602,12 +26946,12 @@ var Tester = class {
26602
26946
  setTimeout(resolve, ms);
26603
26947
  });
26604
26948
  }
26605
- async uploadFile(presignedUrl, filePath) {
26949
+ async uploadFile(presignedUrl, filePath, contentType = "application/zip") {
26606
26950
  const fileData = readFileSync$1(filePath);
26607
26951
  this.obs.logger.log(`Uploading file from ${filePath} (size=${fileData.byteLength} bytes) via PUT`);
26608
26952
  const response = await fetch(presignedUrl, {
26609
26953
  method: "PUT",
26610
- headers: { "Content-Type": "application/zip" },
26954
+ headers: { "Content-Type": contentType },
26611
26955
  body: fileData
26612
26956
  });
26613
26957
  if (!response.ok) throw new Error(`Failed to upload file. Status: ${response.status}: ${await response.text()}`);
@@ -26637,6 +26981,146 @@ var Tester = class {
26637
26981
  }
26638
26982
  return false;
26639
26983
  }
26984
+ async putFileWithRetries(input) {
26985
+ const maxAttempts = 3;
26986
+ const baseDelayMs = 250;
26987
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) try {
26988
+ await this.uploadFile(input.presignedUrl, input.filePath, input.contentType);
26989
+ return true;
26990
+ } catch (error) {
26991
+ if (attempt === maxAttempts) {
26992
+ this.obs.logger.error(`${input.label} upload failed after ${maxAttempts} attempts`, error);
26993
+ return false;
26994
+ }
26995
+ const delayMs = baseDelayMs * 2 ** (attempt - 1);
26996
+ this.obs.logger.warn(`${input.label} upload failed, retrying`, {
26997
+ attempt,
26998
+ maxAttempts,
26999
+ delayMs,
27000
+ error: this.summarizeErrorForExecutionLog(error)
27001
+ });
27002
+ await this.wait(delayMs);
27003
+ }
27004
+ return false;
27005
+ }
27006
+ cleanupScreenshotTempDir() {
27007
+ if (!this.tempPathForAssets) return;
27008
+ try {
27009
+ rmSync(this.tempPathForAssets, {
27010
+ recursive: true,
27011
+ force: true
27012
+ });
27013
+ } catch (error) {
27014
+ this.obs.logger.warn("Failed to clean up screenshot temp dir", {
27015
+ dir: this.tempPathForAssets,
27016
+ error: this.summarizeErrorForExecutionLog(error)
27017
+ });
27018
+ } finally {
27019
+ this.tempPathForAssets = "";
27020
+ }
27021
+ }
27022
+ /**
27023
+ * Batch-uploads all command screenshots captured during the run to S3.
27024
+ * Fetches a presigned PUT URL per file in a single round-trip, then uploads
27025
+ * each with retries. Best-effort: returns true when at least one screenshot
27026
+ * was uploaded, false otherwise (or when there is nothing/no token). Always
27027
+ * cleans up the on-disk temp files. Never throws.
27028
+ */
27029
+ async uploadScreenshots(session) {
27030
+ const screenshots = this.capturedScreenshots;
27031
+ const capturedCount = screenshots.length;
27032
+ const distinctCommandsCaptured = new Set(screenshots.map((shot) => shot.commandId)).size;
27033
+ const commandsConsidered = this.screenshotCommandsConsidered;
27034
+ const captureFailures = this.screenshotCaptureFailures;
27035
+ this.obs.metrics.gauge("bvt_agent.screenshots.commands_considered", commandsConsidered);
27036
+ this.obs.metrics.gauge("bvt_agent.screenshots.captured", capturedCount);
27037
+ this.obs.metrics.gauge("bvt_agent.screenshots.distinct_commands_captured", distinctCommandsCaptured);
27038
+ if (capturedCount === 0) {
27039
+ this.obs.logger.log("No command screenshots captured; skipping upload", {
27040
+ reportId: session.reportId,
27041
+ testCaseId: session.testCaseId,
27042
+ commandsConsidered,
27043
+ captureFailures
27044
+ });
27045
+ return false;
27046
+ }
27047
+ if (!session.token) {
27048
+ this.obs.metrics.count("bvt_agent.screenshots.upload.skipped.total", 1, { reason: "no-session-token" });
27049
+ this.obs.logger.warn(`Screenshot upload skipped because no session token was provided for reportId: ${session.reportId}, testCaseId: ${session.testCaseId}.`);
27050
+ this.cleanupScreenshotTempDir();
27051
+ this.capturedScreenshots = [];
27052
+ return false;
27053
+ }
27054
+ const uploadStartMs = Date.now();
27055
+ try {
27056
+ this.obs.logger.log(`Requesting ${screenshots.length} presigned URL(s) for screenshot upload`, {
27057
+ reportId: session.reportId,
27058
+ testCaseId: session.testCaseId
27059
+ });
27060
+ const targets = await this.getAPIClient(this.sessionToken).presignedUrls.getPresignedUrlsForScreenshotUpload.query({
27061
+ reportId: session.reportId,
27062
+ testCaseId: session.testCaseId,
27063
+ projectId: this.dataContext?.projectId ?? "",
27064
+ files: screenshots.map(({ stepId, commandId, phase }) => ({
27065
+ stepId,
27066
+ commandId,
27067
+ phase
27068
+ }))
27069
+ });
27070
+ const urlByTargetKey = new Map(targets.map((target) => [`${target.stepId}-${target.commandId}-${target.phase}`, target.url]));
27071
+ let uploadedCount = 0;
27072
+ let failedCount = 0;
27073
+ for (const shot of screenshots) {
27074
+ const presignedUrl = urlByTargetKey.get(`${shot.stepId}-${shot.commandId}-${shot.phase}`);
27075
+ if (!presignedUrl) {
27076
+ failedCount += 1;
27077
+ this.obs.metrics.count("bvt_agent.screenshots.upload.failed.total", 1, { reason: "no-presigned-url" });
27078
+ this.obs.logger.warn("No presigned URL returned for screenshot", {
27079
+ stepId: shot.stepId,
27080
+ commandId: shot.commandId,
27081
+ phase: shot.phase
27082
+ });
27083
+ continue;
27084
+ }
27085
+ if (await this.putFileWithRetries({
27086
+ presignedUrl,
27087
+ filePath: shot.localPath,
27088
+ contentType: "image/jpeg",
27089
+ label: "Screenshot"
27090
+ })) {
27091
+ uploadedCount += 1;
27092
+ this.obs.metrics.count("bvt_agent.screenshots.upload.succeeded.total", 1, { phase: shot.phase });
27093
+ } else {
27094
+ failedCount += 1;
27095
+ this.obs.metrics.count("bvt_agent.screenshots.upload.failed.total", 1, { reason: "put-failed" });
27096
+ }
27097
+ }
27098
+ this.obs.metrics.latency("bvt_agent.screenshots.upload.duration", uploadStartMs, { outcome: uploadedCount > 0 ? "success" : "failure" });
27099
+ this.obs.logger.log(`Screenshot upload finished: ${uploadedCount}/${capturedCount} uploaded, ${failedCount} failed`, {
27100
+ reportId: session.reportId,
27101
+ testCaseId: session.testCaseId,
27102
+ commandsConsidered,
27103
+ distinctCommandsCaptured,
27104
+ capturedCount,
27105
+ uploadedCount,
27106
+ failedCount,
27107
+ captureFailures
27108
+ });
27109
+ return uploadedCount > 0;
27110
+ } catch (error) {
27111
+ this.obs.metrics.count("bvt_agent.screenshots.upload.failed.total", capturedCount, { reason: "batch-error" });
27112
+ this.obs.metrics.latency("bvt_agent.screenshots.upload.duration", uploadStartMs, { outcome: "error" });
27113
+ this.obs.logger.warn(`Screenshot upload failed for reportId: ${session.reportId}, testCaseId: ${session.testCaseId}. Continuing without failing execution result.`, {
27114
+ error: this.summarizeErrorForExecutionLog(error),
27115
+ capturedCount,
27116
+ commandsConsidered
27117
+ });
27118
+ return false;
27119
+ } finally {
27120
+ this.cleanupScreenshotTempDir();
27121
+ this.capturedScreenshots = [];
27122
+ }
27123
+ }
26640
27124
  async onTestCaseFail(error, session) {
26641
27125
  this.obs.logger.error("Test case execution failed", {
26642
27126
  session: this.summarizeSessionForExecutionLog(session),
@@ -26741,8 +27225,11 @@ var Tester = class {
26741
27225
  commandId: command._id,
26742
27226
  commandType: command.type
26743
27227
  });
27228
+ let beforeScreenshotKey;
27229
+ if (this.screenshotsEnabled(session)) this.screenshotCommandsConsidered += 1;
26744
27230
  try {
26745
27231
  yield await this.onCommandStart(command, stepDefinitionId, session);
27232
+ beforeScreenshotKey = await this.captureCommandScreenshot(session, "before", recorderStep.step._id, command._id);
26746
27233
  const commandResult = await this.executeCommand({
26747
27234
  page: this.lastActivePage,
26748
27235
  command,
@@ -26750,12 +27237,34 @@ var Tester = class {
26750
27237
  });
26751
27238
  if ((command.type === "custom" || command.type === "custom.code") && commandResult && isFailedCustomCommandResult(commandResult)) throw new Error(commandResult.error || `Custom command "${command._id}" failed.`);
26752
27239
  this.obs.logger.log(`Finished executing command: ${command}`);
26753
- yield await this.onCommandPass(command, stepDefinitionId, session, { resolvedChosenSelectorIndex: getResolvedChosenSelectorIndex(commandResult) });
27240
+ const afterScreenshotKey = await this.captureCommandScreenshot(session, "after", recorderStep.step._id, command._id);
27241
+ yield await this.onCommandPass(command, stepDefinitionId, session, {
27242
+ resolvedChosenSelectorIndex: getResolvedChosenSelectorIndex(commandResult),
27243
+ screenshots: this.toScreenshotRefs(beforeScreenshotKey, afterScreenshotKey)
27244
+ });
26754
27245
  } catch (error) {
26755
27246
  const executionError = this.toExecutionError(error);
26756
27247
  let terminalExecutionError = executionError;
26757
27248
  let failedRecoveryMeta;
26758
27249
  if (this.isBrowserDerivedCommand(command)) {
27250
+ if (command.type === "element.action" && this.shouldUseNewTourMaintenanceRecovery({
27251
+ session,
27252
+ recorderStep,
27253
+ command,
27254
+ commandIndex
27255
+ })) {
27256
+ const recoveryMeta = yield* this.recoverNewTourMaintenanceGenderCommand({
27257
+ recorderStep,
27258
+ command,
27259
+ commandIndex,
27260
+ commandContext,
27261
+ executionError,
27262
+ options
27263
+ });
27264
+ stepRecovery = recoveryMeta;
27265
+ yield await this.onCommandPass(command, stepDefinitionId, session, { recovery: recoveryMeta });
27266
+ continue;
27267
+ }
26759
27268
  if (this.shouldAttemptAiRecovery(session)) yield {
26760
27269
  type: "recovery_status",
26761
27270
  recoveryAttemptId: crypto.randomUUID(),
@@ -26878,7 +27387,10 @@ var Tester = class {
26878
27387
  };
26879
27388
  emittedTerminalRecoveryStatus = true;
26880
27389
  this.obs.logger.log(`Recovered command ${command._id} with whole-step repair`);
26881
- yield await this.onCommandPass(command, stepDefinitionId, session, { recovery: recoveryMeta });
27390
+ yield await this.onCommandPass(command, stepDefinitionId, session, {
27391
+ recovery: recoveryMeta,
27392
+ screenshots: this.toScreenshotRefs(beforeScreenshotKey, await this.captureCommandScreenshot(session, "after", recorderStep.step._id, command._id))
27393
+ });
26882
27394
  commandIndex = recorderStep.definition.commands.length - 1;
26883
27395
  continue;
26884
27396
  } catch (retryError) {
@@ -27017,7 +27529,10 @@ var Tester = class {
27017
27529
  };
27018
27530
  emittedTerminalRecoveryStatus = true;
27019
27531
  this.obs.logger.log(`Recovered command ${command._id} with step repair`);
27020
- yield await this.onCommandPass(command, stepDefinitionId, session, { recovery: recoveryMeta });
27532
+ yield await this.onCommandPass(command, stepDefinitionId, session, {
27533
+ recovery: recoveryMeta,
27534
+ screenshots: this.toScreenshotRefs(beforeScreenshotKey, await this.captureCommandScreenshot(session, "after", recorderStep.step._id, command._id))
27535
+ });
27021
27536
  if (decision.stepRepairPlan.type === "command-operations" || decision.stepRepairPlan.preserveSuffix) commandIndex = recorderStep.definition.commands.length - 1;
27022
27537
  continue;
27023
27538
  } catch (retryError) {
@@ -27091,7 +27606,8 @@ var Tester = class {
27091
27606
  evidenceSignalNames: decision.evidenceSignalNames
27092
27607
  };
27093
27608
  }
27094
- yield await this.onCommandFail(command, stepDefinitionId, terminalExecutionError, session, failedRecoveryMeta);
27609
+ const afterScreenshotKey = await this.captureCommandScreenshot(session, "after", recorderStep.step._id, command._id);
27610
+ yield await this.onCommandFail(command, stepDefinitionId, terminalExecutionError, session, failedRecoveryMeta, this.toScreenshotRefs(beforeScreenshotKey, afterScreenshotKey));
27095
27611
  throw terminalExecutionError;
27096
27612
  }
27097
27613
  }
@@ -27257,7 +27773,7 @@ var Tester = class {
27257
27773
  if (!frameElementDescriptors || frameElementDescriptors.length === 0) return scope.mainFrame();
27258
27774
  let currentFrame = scope.mainFrame();
27259
27775
  for (const frameDescriptor of frameElementDescriptors) for (const selectorInfo of frameDescriptor.uniqueSelectors) {
27260
- const { target } = this.getLocator(currentFrame, selectorInfo);
27776
+ const { target } = this.getLocator(currentFrame, selectorInfo, { isElementHidden: false });
27261
27777
  try {
27262
27778
  if (!await target.elementHandle()) throw new Error(`Could not find element for frame selector: ${JSON.stringify(selectorInfo)}`);
27263
27779
  const selector = target._selector;
@@ -27269,9 +27785,14 @@ var Tester = class {
27269
27785
  }
27270
27786
  return currentFrame;
27271
27787
  }
27272
- getLocator(scope, uniqueSelector) {
27788
+ getLocator(scope, uniqueSelector, options) {
27273
27789
  switch (uniqueSelector.type) {
27274
- case "pw.selectorString": return { target: scope.locator(uniqueSelector.selectorString + ` >> visible=true`) };
27790
+ case "pw.selectorString": {
27791
+ const visibleSuffix = options?.isElementHidden === true ? "" : ` >> visible=true`;
27792
+ const finalSelector = uniqueSelector.selectorString + visibleSuffix;
27793
+ this.obs.logger.info(`getLocator(pw.selectorString): isElementHidden=${options?.isElementHidden}, visibleAppended=${visibleSuffix !== ""}, finalSelector=${finalSelector}`);
27794
+ return { target: scope.locator(finalSelector) };
27795
+ }
27275
27796
  case "bvt.selectorObject": throw new Error(`Selector type "bvt.selectorObject" is not yet supported in getLocator`);
27276
27797
  default:
27277
27798
  const _exhaustiveCheck = uniqueSelector;
@@ -27390,15 +27911,82 @@ var Tester = class {
27390
27911
  else await asserter.toHaveAttribute(assertion.name, value, assertion.options);
27391
27912
  break;
27392
27913
  }
27393
- case "toHaveProperty":
27394
- this.obs.logger.info(`Checking property "${assertion.name}"...`);
27395
- if (assertion.value === void 0) await asserter.toHaveJSProperty(assertion.name, assertion.options);
27396
- else {
27397
- let value = getValueFromPrimitiveOrRegex(assertion.value);
27398
- this.obs.logger.info(`Expected property value: "${value}" (timeout: ${assertion.options?.timeout ?? "default"}ms)`);
27399
- await asserter.toHaveJSProperty(assertion.name, value, assertion.options);
27914
+ case "toHaveProperty": {
27915
+ const operator = assertion.operator ?? "equals";
27916
+ this.obs.logger.info(`Checking property "${assertion.name}" [operator: ${operator}]...`);
27917
+ switch (operator) {
27918
+ case "equals":
27919
+ if (assertion.value === void 0) await asserter.toHaveJSProperty(assertion.name, assertion.options);
27920
+ else {
27921
+ const value = getValueFromPrimitiveOrRegex(assertion.value);
27922
+ this.obs.logger.info(`Expected: "${value}" (timeout: ${assertion.options?.timeout ?? "default"}ms)`);
27923
+ await asserter.toHaveJSProperty(assertion.name, value, assertion.options);
27924
+ }
27925
+ break;
27926
+ case "notEquals":
27927
+ if (assertion.value !== void 0) {
27928
+ const value = getValueFromPrimitiveOrRegex(assertion.value);
27929
+ this.obs.logger.info(`Expected NOT: "${value}"`);
27930
+ await expect(locator).not.toHaveJSProperty(assertion.name, value, assertion.options);
27931
+ }
27932
+ break;
27933
+ case "contains":
27934
+ if (assertion.value !== void 0) {
27935
+ const raw = getValueFromPrimitiveOrRegex(assertion.value);
27936
+ const stringValue = typeof raw === "string" ? raw : String(raw);
27937
+ this.obs.logger.info(`Expected to contain: "${stringValue}"`);
27938
+ const propName = assertion.name;
27939
+ const isTextProp = propName === "textContent" || propName === "innerText";
27940
+ const isHtmlAttr = [
27941
+ "href",
27942
+ "src",
27943
+ "class",
27944
+ "id",
27945
+ "placeholder",
27946
+ "title",
27947
+ "alt",
27948
+ "name",
27949
+ "type",
27950
+ "action",
27951
+ "target",
27952
+ "rel",
27953
+ "value"
27954
+ ].includes(propName) || propName.startsWith("data-") || propName.startsWith("aria-");
27955
+ if (isTextProp) await expect(locator).toContainText(stringValue, assertion.options);
27956
+ else if (isHtmlAttr) {
27957
+ const attrPattern = raw instanceof RegExp ? raw : new RegExp(stringValue.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
27958
+ await expect(locator).toHaveAttribute(propName, attrPattern, assertion.options);
27959
+ } else {
27960
+ const actual = await locator.evaluate((el, name) => String(el[name] ?? ""), propName);
27961
+ if (!actual.includes(stringValue)) throw new Error(`Property "${propName}" expected to contain "${stringValue}", but got "${actual}"`);
27962
+ }
27963
+ }
27964
+ break;
27965
+ case "lessThan":
27966
+ if (assertion.value !== void 0) {
27967
+ const threshold = Number(getValueFromPrimitiveOrRegex(assertion.value));
27968
+ const actual = Number(await locator.evaluate((el, name) => el[name], assertion.name));
27969
+ this.obs.logger.info(`Expected "${assertion.name}" (${actual}) < ${threshold}`);
27970
+ if (!(actual < threshold)) throw new Error(`Property "${assertion.name}" expected to be less than ${threshold}, but got ${actual}`);
27971
+ }
27972
+ break;
27973
+ case "greaterThan":
27974
+ if (assertion.value !== void 0) {
27975
+ const threshold = Number(getValueFromPrimitiveOrRegex(assertion.value));
27976
+ const actual = Number(await locator.evaluate((el, name) => el[name], assertion.name));
27977
+ this.obs.logger.info(`Expected "${assertion.name}" (${actual}) > ${threshold}`);
27978
+ if (!(actual > threshold)) throw new Error(`Property "${assertion.name}" expected to be greater than ${threshold}, but got ${actual}`);
27979
+ }
27980
+ break;
27981
+ case "exists": {
27982
+ const exists = await locator.evaluate((el, name) => el[name] !== void 0, assertion.name);
27983
+ this.obs.logger.info(`Expected property "${assertion.name}" to exist: ${exists}`);
27984
+ if (!exists) throw new Error(`Property "${assertion.name}" does not exist on element`);
27985
+ break;
27986
+ }
27400
27987
  }
27401
27988
  break;
27989
+ }
27402
27990
  case "toHaveValue": {
27403
27991
  const value = getValueFromStringOrRegex(assertion.value);
27404
27992
  await asserter.toHaveValue(value, assertion.options);
@@ -27585,7 +28173,7 @@ var Tester = class {
27585
28173
  const selectorInfo = target.uniqueSelectors[chosenSelectorIndex];
27586
28174
  if (!selectorInfo) throw new Error(`No selector found for target "${target.name}" at chosenSelectorIndex ${chosenSelectorIndex}. Available selectors: ${JSON.stringify(target.uniqueSelectors)}`);
27587
28175
  const frame = await this.getFrame(input.page, target.frames);
27588
- const locator = this.getLocator(frame, selectorInfo);
28176
+ const locator = this.getLocator(frame, selectorInfo, target);
27589
28177
  try {
27590
28178
  const dataWithDefaultTimeout = {
27591
28179
  ...data,
@@ -27606,7 +28194,7 @@ var Tester = class {
27606
28194
  for (let i = 0; i < target.uniqueSelectors.length; i++) {
27607
28195
  if (i === chosenSelectorIndex) continue;
27608
28196
  const selectorInfo = target.uniqueSelectors[i];
27609
- const locator = this.getLocator(frame, selectorInfo);
28197
+ const locator = this.getLocator(frame, selectorInfo, target);
27610
28198
  try {
27611
28199
  this.obs.logger.info(`Retrying action "${data.type}" using next selector: ${JSON.stringify(selectorInfo)}`);
27612
28200
  const dataWithModifiedTimeout = {
@@ -27637,7 +28225,7 @@ var Tester = class {
27637
28225
  const chosenSelectorIndex = target.chosenSelectorIndex ?? 0;
27638
28226
  const selectorInfo = target.uniqueSelectors[chosenSelectorIndex];
27639
28227
  const frame = await this.getFrame(input.page, target.frames);
27640
- const locator = this.getLocator(frame, selectorInfo);
28228
+ const locator = this.getLocator(frame, selectorInfo, target);
27641
28229
  try {
27642
28230
  this.obs.logger.info(`Executing assertion "${data.type}" using selector: ${JSON.stringify(selectorInfo)}`);
27643
28231
  const dataWithDefaultTimeout = {
@@ -27657,7 +28245,7 @@ var Tester = class {
27657
28245
  for (let i = 0; i < target.uniqueSelectors.length; i++) {
27658
28246
  if (i === chosenSelectorIndex) continue;
27659
28247
  const selectorInfo = target.uniqueSelectors[i];
27660
- const locator = this.getLocator(frame, selectorInfo);
28248
+ const locator = this.getLocator(frame, selectorInfo, target);
27661
28249
  const dataWithModifiedTimeout = {
27662
28250
  ...data,
27663
28251
  options: {
@@ -27690,7 +28278,7 @@ var Tester = class {
27690
28278
  const selectorInfo = target.uniqueSelectors[chosenSelectorIndex];
27691
28279
  if (!selectorInfo) throw new Error(`No selector found for target "${target.name}" at chosenSelectorIndex ${chosenSelectorIndex}. Available selectors: ${JSON.stringify(target.uniqueSelectors)}`);
27692
28280
  const frame = await this.getFrame(input.page, target.frames);
27693
- const locator = this.getLocator(frame, selectorInfo);
28281
+ const locator = this.getLocator(frame, selectorInfo, target);
27694
28282
  try {
27695
28283
  this.obs.logger.info(`Executing extraction "${extract.type}" using selector: ${JSON.stringify(selectorInfo)}`);
27696
28284
  await this.executeElementExtraction(locator.target, extract, storageDetails);
@@ -27703,7 +28291,7 @@ var Tester = class {
27703
28291
  for (let i = 0; i < target.uniqueSelectors.length; i++) {
27704
28292
  if (i === chosenSelectorIndex) continue;
27705
28293
  const fallbackSelectorInfo = target.uniqueSelectors[i];
27706
- const fallbackLocator = this.getLocator(frame, fallbackSelectorInfo);
28294
+ const fallbackLocator = this.getLocator(frame, fallbackSelectorInfo, target);
27707
28295
  try {
27708
28296
  this.obs.logger.info(`Retrying extraction "${extract.type}" using next selector: ${JSON.stringify(fallbackSelectorInfo)}`);
27709
28297
  await this.executeElementExtraction(fallbackLocator.target, extract, storageDetails);
@@ -27919,14 +28507,17 @@ var Tester = class {
27919
28507
  for (const recorderStep of input.recorderSteps) yield* this.executeStep(recorderStep, input, options);
27920
28508
  await options.onFinish?.();
27921
28509
  this.obs.logger.log("Finished executing all steps");
28510
+ let screenshotsAvailable = false;
27922
28511
  if (this.isRunSession(session)) {
27923
28512
  this.obs.logger.log(`Stopping Playwright tracing for reportId: ${session.reportId}, testCaseId: ${session.testCaseId}...`);
27924
28513
  await this.stopTracingAndHandleTrace(session);
28514
+ screenshotsAvailable = await this.uploadScreenshots(session);
27925
28515
  }
27926
28516
  this.obs.logger.log(`Test case execution completed successfully with session: ${JSON.stringify(session)}`);
27927
28517
  return {
27928
28518
  type: "execution_completed",
27929
- result: { type: "success" }
28519
+ result: { type: "success" },
28520
+ ...screenshotsAvailable ? { screenshotsAvailable: true } : {}
27930
28521
  };
27931
28522
  } catch (error) {
27932
28523
  const executionError = this.toExecutionError(error);
@@ -27936,13 +28527,16 @@ var Tester = class {
27936
28527
  session: this.summarizeSessionForExecutionLog(session),
27937
28528
  error: this.summarizeErrorForExecutionLog(executionError)
27938
28529
  });
28530
+ let screenshotsAvailable = false;
27939
28531
  if (this.isRunSession(session)) {
27940
28532
  this.obs.logger.log(`Stopping Playwright tracing for reportId: ${session.reportId}, testCaseId: ${session.testCaseId}...`);
27941
28533
  await this.stopTracingAndHandleTrace(session);
28534
+ screenshotsAvailable = await this.uploadScreenshots(session);
27942
28535
  }
27943
28536
  return {
27944
28537
  type: "execution_completed",
27945
- result: this.toFailureResult(executionError)
28538
+ result: this.toFailureResult(executionError),
28539
+ ...screenshotsAvailable ? { screenshotsAvailable: true } : {}
27946
28540
  };
27947
28541
  } finally {
27948
28542
  this.obs.metrics.count("bvt_agent.test_case.execute.completed.total", 1, {