@dev-blinq/bvt-playwright-js 1.0.0-dev.4.latest.142.1 → 1.0.0-dev.4.latest.158.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
 
@@ -6562,6 +6562,7 @@ const RichElementTargetDetailsSchema = object({
6562
6562
  }).passthrough();
6563
6563
  const elementTargetSchema = object({
6564
6564
  name: string(),
6565
+ isElementHidden: boolean().optional(),
6565
6566
  uniqueSelectors: array(UniqueSelectorSchema),
6566
6567
  frames: array(object({
6567
6568
  name: string(),
@@ -7067,12 +7068,24 @@ const ExecResultSchema = discriminatedUnion("type", [
7067
7068
  }).strict(),
7068
7069
  object({ type: literal$1("skipped") }).strict()
7069
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();
7070
7082
  const CommandResultSchema = object({
7071
7083
  commandId: string().min(1).max(255),
7072
7084
  startedAt: date().optional(),
7073
7085
  completedAt: date().optional(),
7074
7086
  result: ExecResultSchema,
7075
- recovery: RecoveryMetadataSchema.optional()
7087
+ recovery: RecoveryMetadataSchema.optional(),
7088
+ screenshots: CommandScreenshotsSchema.optional()
7076
7089
  }).strict();
7077
7090
  const StepResultSchema = object({
7078
7091
  stepId: string().min(1).max(255),
@@ -7100,6 +7113,7 @@ const TestCaseAiRecoverySchema = object({
7100
7113
  }).strict();
7101
7114
  const TestCaseWarningReasonSchema = _enum([
7102
7115
  "TraceUploadFailed",
7116
+ "ScreenshotUploadFailed",
7103
7117
  "ScenarioEmpty",
7104
7118
  "AIRecoveryPendingApproval",
7105
7119
  "AIRecoveryArtifactFailed",
@@ -7120,6 +7134,7 @@ const TestCaseSchema = object({
7120
7134
  error: TestCaseErrorSchema.optional(),
7121
7135
  aiRecovery: TestCaseAiRecoverySchema.optional(),
7122
7136
  warnings: array(TestCaseWarningSchema).optional(),
7137
+ screenshotsAvailable: boolean().optional(),
7123
7138
  startedAt: date(),
7124
7139
  completedAt: date().nullable(),
7125
7140
  stepResults: array(StepResultSchema),
@@ -26274,11 +26289,37 @@ function getResolvedChosenSelectorIndex(result) {
26274
26289
  const candidate = result.resolvedChosenSelectorIndex;
26275
26290
  return Number.isInteger(candidate) ? candidate : void 0;
26276
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
+ }
26277
26311
  const browserTypesMap = {
26278
26312
  chromium,
26279
26313
  firefox,
26280
26314
  webkit
26281
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
+ };
26282
26323
  const unconfiguredGetAPIClient = () => {
26283
26324
  throw new Error("Tester was created without an API client.");
26284
26325
  };
@@ -26312,6 +26353,19 @@ var Tester = class {
26312
26353
  sessionToken = "";
26313
26354
  activeBrowserContext = null;
26314
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;
26315
26369
  constructor(getAPIClient = unconfiguredGetAPIClient, observabilityOrOptions, optionsArg = {}) {
26316
26370
  this.getAPIClient = getAPIClient;
26317
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);
@@ -26388,6 +26442,87 @@ var Tester = class {
26388
26442
  default: return `Unknown Command`;
26389
26443
  }
26390
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
+ }
26391
26526
  async onCommandStart(command, stepDefinitionId, session) {
26392
26527
  this.obs.logger.log("Starting execution of command", {
26393
26528
  commandId: command._id,
@@ -26454,10 +26589,11 @@ var Tester = class {
26454
26589
  completedAt,
26455
26590
  result: { type: "success" },
26456
26591
  ...typeof metadata?.resolvedChosenSelectorIndex === "number" ? { resolvedChosenSelectorIndex: metadata.resolvedChosenSelectorIndex } : {},
26457
- ...metadata?.recovery ? { recovery: metadata.recovery } : {}
26592
+ ...metadata?.recovery ? { recovery: metadata.recovery } : {},
26593
+ ...metadata?.screenshots ? { screenshots: metadata.screenshots } : {}
26458
26594
  };
26459
26595
  }
26460
- async onCommandFail(command, stepDefinitionId, error, session, recovery) {
26596
+ async onCommandFail(command, stepDefinitionId, error, session, recovery, screenshots) {
26461
26597
  this.obs.logger.error(`Error executing command ${command._id}:`, { error: this.summarizeErrorForExecutionLog(error) });
26462
26598
  if (session?.type === "run") {
26463
26599
  this.obs.logger.log(`Ending Playwright tracing group for command: ${command._id}`);
@@ -26474,7 +26610,8 @@ var Tester = class {
26474
26610
  stepDefinitionId,
26475
26611
  completedAt,
26476
26612
  result: this.toFailureResult(error),
26477
- recovery
26613
+ recovery,
26614
+ ...screenshots ? { screenshots } : {}
26478
26615
  };
26479
26616
  }
26480
26617
  async onStepStart(step, session) {
@@ -26527,6 +26664,10 @@ var Tester = class {
26527
26664
  }
26528
26665
  async onTestCaseStart(session) {
26529
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;
26530
26671
  if (this.isRunSession(session)) {
26531
26672
  if (session.token) this.sessionToken = session.token;
26532
26673
  this.obs.logger.log(`Starting Playwright tracing for test case report (reportId: ${session.reportId}, testCaseId: ${session.testCaseId})...`);
@@ -26551,6 +26692,178 @@ var Tester = class {
26551
26692
  shouldAttemptAiRecovery(session) {
26552
26693
  return !session || this.isRecordingReplaySession(session) || session.type === "run" && session.runWithAiRecovery === true;
26553
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
+ }
26554
26867
  isSupportedRepairContext(decision, session) {
26555
26868
  if (decision.kind !== "step-repair") return false;
26556
26869
  if (this.isRecordingReplaySession(session) || !session) return decision.repairContext === "recorder-replay";
@@ -26633,12 +26946,12 @@ var Tester = class {
26633
26946
  setTimeout(resolve, ms);
26634
26947
  });
26635
26948
  }
26636
- async uploadFile(presignedUrl, filePath) {
26949
+ async uploadFile(presignedUrl, filePath, contentType = "application/zip") {
26637
26950
  const fileData = readFileSync$1(filePath);
26638
26951
  this.obs.logger.log(`Uploading file from ${filePath} (size=${fileData.byteLength} bytes) via PUT`);
26639
26952
  const response = await fetch(presignedUrl, {
26640
26953
  method: "PUT",
26641
- headers: { "Content-Type": "application/zip" },
26954
+ headers: { "Content-Type": contentType },
26642
26955
  body: fileData
26643
26956
  });
26644
26957
  if (!response.ok) throw new Error(`Failed to upload file. Status: ${response.status}: ${await response.text()}`);
@@ -26668,6 +26981,146 @@ var Tester = class {
26668
26981
  }
26669
26982
  return false;
26670
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
+ }
26671
27124
  async onTestCaseFail(error, session) {
26672
27125
  this.obs.logger.error("Test case execution failed", {
26673
27126
  session: this.summarizeSessionForExecutionLog(session),
@@ -26772,8 +27225,11 @@ var Tester = class {
26772
27225
  commandId: command._id,
26773
27226
  commandType: command.type
26774
27227
  });
27228
+ let beforeScreenshotKey;
27229
+ if (this.screenshotsEnabled(session)) this.screenshotCommandsConsidered += 1;
26775
27230
  try {
26776
27231
  yield await this.onCommandStart(command, stepDefinitionId, session);
27232
+ beforeScreenshotKey = await this.captureCommandScreenshot(session, "before", recorderStep.step._id, command._id);
26777
27233
  const commandResult = await this.executeCommand({
26778
27234
  page: this.lastActivePage,
26779
27235
  command,
@@ -26781,12 +27237,34 @@ var Tester = class {
26781
27237
  });
26782
27238
  if ((command.type === "custom" || command.type === "custom.code") && commandResult && isFailedCustomCommandResult(commandResult)) throw new Error(commandResult.error || `Custom command "${command._id}" failed.`);
26783
27239
  this.obs.logger.log(`Finished executing command: ${command}`);
26784
- 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
+ });
26785
27245
  } catch (error) {
26786
27246
  const executionError = this.toExecutionError(error);
26787
27247
  let terminalExecutionError = executionError;
26788
27248
  let failedRecoveryMeta;
26789
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
+ }
26790
27268
  if (this.shouldAttemptAiRecovery(session)) yield {
26791
27269
  type: "recovery_status",
26792
27270
  recoveryAttemptId: crypto.randomUUID(),
@@ -26909,7 +27387,10 @@ var Tester = class {
26909
27387
  };
26910
27388
  emittedTerminalRecoveryStatus = true;
26911
27389
  this.obs.logger.log(`Recovered command ${command._id} with whole-step repair`);
26912
- 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
+ });
26913
27394
  commandIndex = recorderStep.definition.commands.length - 1;
26914
27395
  continue;
26915
27396
  } catch (retryError) {
@@ -27048,7 +27529,10 @@ var Tester = class {
27048
27529
  };
27049
27530
  emittedTerminalRecoveryStatus = true;
27050
27531
  this.obs.logger.log(`Recovered command ${command._id} with step repair`);
27051
- 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
+ });
27052
27536
  if (decision.stepRepairPlan.type === "command-operations" || decision.stepRepairPlan.preserveSuffix) commandIndex = recorderStep.definition.commands.length - 1;
27053
27537
  continue;
27054
27538
  } catch (retryError) {
@@ -27122,7 +27606,8 @@ var Tester = class {
27122
27606
  evidenceSignalNames: decision.evidenceSignalNames
27123
27607
  };
27124
27608
  }
27125
- 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));
27126
27611
  throw terminalExecutionError;
27127
27612
  }
27128
27613
  }
@@ -27288,7 +27773,7 @@ var Tester = class {
27288
27773
  if (!frameElementDescriptors || frameElementDescriptors.length === 0) return scope.mainFrame();
27289
27774
  let currentFrame = scope.mainFrame();
27290
27775
  for (const frameDescriptor of frameElementDescriptors) for (const selectorInfo of frameDescriptor.uniqueSelectors) {
27291
- const { target } = this.getLocator(currentFrame, selectorInfo);
27776
+ const { target } = this.getLocator(currentFrame, selectorInfo, { isElementHidden: false });
27292
27777
  try {
27293
27778
  if (!await target.elementHandle()) throw new Error(`Could not find element for frame selector: ${JSON.stringify(selectorInfo)}`);
27294
27779
  const selector = target._selector;
@@ -27300,9 +27785,14 @@ var Tester = class {
27300
27785
  }
27301
27786
  return currentFrame;
27302
27787
  }
27303
- getLocator(scope, uniqueSelector) {
27788
+ getLocator(scope, uniqueSelector, options) {
27304
27789
  switch (uniqueSelector.type) {
27305
- 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
+ }
27306
27796
  case "bvt.selectorObject": throw new Error(`Selector type "bvt.selectorObject" is not yet supported in getLocator`);
27307
27797
  default:
27308
27798
  const _exhaustiveCheck = uniqueSelector;
@@ -27683,7 +28173,7 @@ var Tester = class {
27683
28173
  const selectorInfo = target.uniqueSelectors[chosenSelectorIndex];
27684
28174
  if (!selectorInfo) throw new Error(`No selector found for target "${target.name}" at chosenSelectorIndex ${chosenSelectorIndex}. Available selectors: ${JSON.stringify(target.uniqueSelectors)}`);
27685
28175
  const frame = await this.getFrame(input.page, target.frames);
27686
- const locator = this.getLocator(frame, selectorInfo);
28176
+ const locator = this.getLocator(frame, selectorInfo, target);
27687
28177
  try {
27688
28178
  const dataWithDefaultTimeout = {
27689
28179
  ...data,
@@ -27704,7 +28194,7 @@ var Tester = class {
27704
28194
  for (let i = 0; i < target.uniqueSelectors.length; i++) {
27705
28195
  if (i === chosenSelectorIndex) continue;
27706
28196
  const selectorInfo = target.uniqueSelectors[i];
27707
- const locator = this.getLocator(frame, selectorInfo);
28197
+ const locator = this.getLocator(frame, selectorInfo, target);
27708
28198
  try {
27709
28199
  this.obs.logger.info(`Retrying action "${data.type}" using next selector: ${JSON.stringify(selectorInfo)}`);
27710
28200
  const dataWithModifiedTimeout = {
@@ -27735,7 +28225,7 @@ var Tester = class {
27735
28225
  const chosenSelectorIndex = target.chosenSelectorIndex ?? 0;
27736
28226
  const selectorInfo = target.uniqueSelectors[chosenSelectorIndex];
27737
28227
  const frame = await this.getFrame(input.page, target.frames);
27738
- const locator = this.getLocator(frame, selectorInfo);
28228
+ const locator = this.getLocator(frame, selectorInfo, target);
27739
28229
  try {
27740
28230
  this.obs.logger.info(`Executing assertion "${data.type}" using selector: ${JSON.stringify(selectorInfo)}`);
27741
28231
  const dataWithDefaultTimeout = {
@@ -27755,7 +28245,7 @@ var Tester = class {
27755
28245
  for (let i = 0; i < target.uniqueSelectors.length; i++) {
27756
28246
  if (i === chosenSelectorIndex) continue;
27757
28247
  const selectorInfo = target.uniqueSelectors[i];
27758
- const locator = this.getLocator(frame, selectorInfo);
28248
+ const locator = this.getLocator(frame, selectorInfo, target);
27759
28249
  const dataWithModifiedTimeout = {
27760
28250
  ...data,
27761
28251
  options: {
@@ -27788,7 +28278,7 @@ var Tester = class {
27788
28278
  const selectorInfo = target.uniqueSelectors[chosenSelectorIndex];
27789
28279
  if (!selectorInfo) throw new Error(`No selector found for target "${target.name}" at chosenSelectorIndex ${chosenSelectorIndex}. Available selectors: ${JSON.stringify(target.uniqueSelectors)}`);
27790
28280
  const frame = await this.getFrame(input.page, target.frames);
27791
- const locator = this.getLocator(frame, selectorInfo);
28281
+ const locator = this.getLocator(frame, selectorInfo, target);
27792
28282
  try {
27793
28283
  this.obs.logger.info(`Executing extraction "${extract.type}" using selector: ${JSON.stringify(selectorInfo)}`);
27794
28284
  await this.executeElementExtraction(locator.target, extract, storageDetails);
@@ -27801,7 +28291,7 @@ var Tester = class {
27801
28291
  for (let i = 0; i < target.uniqueSelectors.length; i++) {
27802
28292
  if (i === chosenSelectorIndex) continue;
27803
28293
  const fallbackSelectorInfo = target.uniqueSelectors[i];
27804
- const fallbackLocator = this.getLocator(frame, fallbackSelectorInfo);
28294
+ const fallbackLocator = this.getLocator(frame, fallbackSelectorInfo, target);
27805
28295
  try {
27806
28296
  this.obs.logger.info(`Retrying extraction "${extract.type}" using next selector: ${JSON.stringify(fallbackSelectorInfo)}`);
27807
28297
  await this.executeElementExtraction(fallbackLocator.target, extract, storageDetails);
@@ -28017,14 +28507,17 @@ var Tester = class {
28017
28507
  for (const recorderStep of input.recorderSteps) yield* this.executeStep(recorderStep, input, options);
28018
28508
  await options.onFinish?.();
28019
28509
  this.obs.logger.log("Finished executing all steps");
28510
+ let screenshotsAvailable = false;
28020
28511
  if (this.isRunSession(session)) {
28021
28512
  this.obs.logger.log(`Stopping Playwright tracing for reportId: ${session.reportId}, testCaseId: ${session.testCaseId}...`);
28022
28513
  await this.stopTracingAndHandleTrace(session);
28514
+ screenshotsAvailable = await this.uploadScreenshots(session);
28023
28515
  }
28024
28516
  this.obs.logger.log(`Test case execution completed successfully with session: ${JSON.stringify(session)}`);
28025
28517
  return {
28026
28518
  type: "execution_completed",
28027
- result: { type: "success" }
28519
+ result: { type: "success" },
28520
+ ...screenshotsAvailable ? { screenshotsAvailable: true } : {}
28028
28521
  };
28029
28522
  } catch (error) {
28030
28523
  const executionError = this.toExecutionError(error);
@@ -28034,13 +28527,16 @@ var Tester = class {
28034
28527
  session: this.summarizeSessionForExecutionLog(session),
28035
28528
  error: this.summarizeErrorForExecutionLog(executionError)
28036
28529
  });
28530
+ let screenshotsAvailable = false;
28037
28531
  if (this.isRunSession(session)) {
28038
28532
  this.obs.logger.log(`Stopping Playwright tracing for reportId: ${session.reportId}, testCaseId: ${session.testCaseId}...`);
28039
28533
  await this.stopTracingAndHandleTrace(session);
28534
+ screenshotsAvailable = await this.uploadScreenshots(session);
28040
28535
  }
28041
28536
  return {
28042
28537
  type: "execution_completed",
28043
- result: this.toFailureResult(executionError)
28538
+ result: this.toFailureResult(executionError),
28539
+ ...screenshotsAvailable ? { screenshotsAvailable: true } : {}
28044
28540
  };
28045
28541
  } finally {
28046
28542
  this.obs.metrics.count("bvt_agent.test_case.execute.completed.total", 1, {