@dev-blinq/bvt-playwright-js 1.0.0-dev.4.staging.146.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
 
@@ -7068,12 +7068,24 @@ const ExecResultSchema = discriminatedUnion("type", [
7068
7068
  }).strict(),
7069
7069
  object({ type: literal$1("skipped") }).strict()
7070
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();
7071
7082
  const CommandResultSchema = object({
7072
7083
  commandId: string().min(1).max(255),
7073
7084
  startedAt: date().optional(),
7074
7085
  completedAt: date().optional(),
7075
7086
  result: ExecResultSchema,
7076
- recovery: RecoveryMetadataSchema.optional()
7087
+ recovery: RecoveryMetadataSchema.optional(),
7088
+ screenshots: CommandScreenshotsSchema.optional()
7077
7089
  }).strict();
7078
7090
  const StepResultSchema = object({
7079
7091
  stepId: string().min(1).max(255),
@@ -7101,6 +7113,7 @@ const TestCaseAiRecoverySchema = object({
7101
7113
  }).strict();
7102
7114
  const TestCaseWarningReasonSchema = _enum([
7103
7115
  "TraceUploadFailed",
7116
+ "ScreenshotUploadFailed",
7104
7117
  "ScenarioEmpty",
7105
7118
  "AIRecoveryPendingApproval",
7106
7119
  "AIRecoveryArtifactFailed",
@@ -7121,6 +7134,7 @@ const TestCaseSchema = object({
7121
7134
  error: TestCaseErrorSchema.optional(),
7122
7135
  aiRecovery: TestCaseAiRecoverySchema.optional(),
7123
7136
  warnings: array(TestCaseWarningSchema).optional(),
7137
+ screenshotsAvailable: boolean().optional(),
7124
7138
  startedAt: date(),
7125
7139
  completedAt: date().nullable(),
7126
7140
  stepResults: array(StepResultSchema),
@@ -26275,11 +26289,37 @@ function getResolvedChosenSelectorIndex(result) {
26275
26289
  const candidate = result.resolvedChosenSelectorIndex;
26276
26290
  return Number.isInteger(candidate) ? candidate : void 0;
26277
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
+ }
26278
26311
  const browserTypesMap = {
26279
26312
  chromium,
26280
26313
  firefox,
26281
26314
  webkit
26282
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
+ };
26283
26323
  const unconfiguredGetAPIClient = () => {
26284
26324
  throw new Error("Tester was created without an API client.");
26285
26325
  };
@@ -26313,6 +26353,19 @@ var Tester = class {
26313
26353
  sessionToken = "";
26314
26354
  activeBrowserContext = null;
26315
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;
26316
26369
  constructor(getAPIClient = unconfiguredGetAPIClient, observabilityOrOptions, optionsArg = {}) {
26317
26370
  this.getAPIClient = getAPIClient;
26318
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);
@@ -26389,6 +26442,87 @@ var Tester = class {
26389
26442
  default: return `Unknown Command`;
26390
26443
  }
26391
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
+ }
26392
26526
  async onCommandStart(command, stepDefinitionId, session) {
26393
26527
  this.obs.logger.log("Starting execution of command", {
26394
26528
  commandId: command._id,
@@ -26455,10 +26589,11 @@ var Tester = class {
26455
26589
  completedAt,
26456
26590
  result: { type: "success" },
26457
26591
  ...typeof metadata?.resolvedChosenSelectorIndex === "number" ? { resolvedChosenSelectorIndex: metadata.resolvedChosenSelectorIndex } : {},
26458
- ...metadata?.recovery ? { recovery: metadata.recovery } : {}
26592
+ ...metadata?.recovery ? { recovery: metadata.recovery } : {},
26593
+ ...metadata?.screenshots ? { screenshots: metadata.screenshots } : {}
26459
26594
  };
26460
26595
  }
26461
- async onCommandFail(command, stepDefinitionId, error, session, recovery) {
26596
+ async onCommandFail(command, stepDefinitionId, error, session, recovery, screenshots) {
26462
26597
  this.obs.logger.error(`Error executing command ${command._id}:`, { error: this.summarizeErrorForExecutionLog(error) });
26463
26598
  if (session?.type === "run") {
26464
26599
  this.obs.logger.log(`Ending Playwright tracing group for command: ${command._id}`);
@@ -26475,7 +26610,8 @@ var Tester = class {
26475
26610
  stepDefinitionId,
26476
26611
  completedAt,
26477
26612
  result: this.toFailureResult(error),
26478
- recovery
26613
+ recovery,
26614
+ ...screenshots ? { screenshots } : {}
26479
26615
  };
26480
26616
  }
26481
26617
  async onStepStart(step, session) {
@@ -26528,6 +26664,10 @@ var Tester = class {
26528
26664
  }
26529
26665
  async onTestCaseStart(session) {
26530
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;
26531
26671
  if (this.isRunSession(session)) {
26532
26672
  if (session.token) this.sessionToken = session.token;
26533
26673
  this.obs.logger.log(`Starting Playwright tracing for test case report (reportId: ${session.reportId}, testCaseId: ${session.testCaseId})...`);
@@ -26552,6 +26692,178 @@ var Tester = class {
26552
26692
  shouldAttemptAiRecovery(session) {
26553
26693
  return !session || this.isRecordingReplaySession(session) || session.type === "run" && session.runWithAiRecovery === true;
26554
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
+ }
26555
26867
  isSupportedRepairContext(decision, session) {
26556
26868
  if (decision.kind !== "step-repair") return false;
26557
26869
  if (this.isRecordingReplaySession(session) || !session) return decision.repairContext === "recorder-replay";
@@ -26634,12 +26946,12 @@ var Tester = class {
26634
26946
  setTimeout(resolve, ms);
26635
26947
  });
26636
26948
  }
26637
- async uploadFile(presignedUrl, filePath) {
26949
+ async uploadFile(presignedUrl, filePath, contentType = "application/zip") {
26638
26950
  const fileData = readFileSync$1(filePath);
26639
26951
  this.obs.logger.log(`Uploading file from ${filePath} (size=${fileData.byteLength} bytes) via PUT`);
26640
26952
  const response = await fetch(presignedUrl, {
26641
26953
  method: "PUT",
26642
- headers: { "Content-Type": "application/zip" },
26954
+ headers: { "Content-Type": contentType },
26643
26955
  body: fileData
26644
26956
  });
26645
26957
  if (!response.ok) throw new Error(`Failed to upload file. Status: ${response.status}: ${await response.text()}`);
@@ -26669,6 +26981,146 @@ var Tester = class {
26669
26981
  }
26670
26982
  return false;
26671
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
+ }
26672
27124
  async onTestCaseFail(error, session) {
26673
27125
  this.obs.logger.error("Test case execution failed", {
26674
27126
  session: this.summarizeSessionForExecutionLog(session),
@@ -26773,8 +27225,11 @@ var Tester = class {
26773
27225
  commandId: command._id,
26774
27226
  commandType: command.type
26775
27227
  });
27228
+ let beforeScreenshotKey;
27229
+ if (this.screenshotsEnabled(session)) this.screenshotCommandsConsidered += 1;
26776
27230
  try {
26777
27231
  yield await this.onCommandStart(command, stepDefinitionId, session);
27232
+ beforeScreenshotKey = await this.captureCommandScreenshot(session, "before", recorderStep.step._id, command._id);
26778
27233
  const commandResult = await this.executeCommand({
26779
27234
  page: this.lastActivePage,
26780
27235
  command,
@@ -26782,12 +27237,34 @@ var Tester = class {
26782
27237
  });
26783
27238
  if ((command.type === "custom" || command.type === "custom.code") && commandResult && isFailedCustomCommandResult(commandResult)) throw new Error(commandResult.error || `Custom command "${command._id}" failed.`);
26784
27239
  this.obs.logger.log(`Finished executing command: ${command}`);
26785
- 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
+ });
26786
27245
  } catch (error) {
26787
27246
  const executionError = this.toExecutionError(error);
26788
27247
  let terminalExecutionError = executionError;
26789
27248
  let failedRecoveryMeta;
26790
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
+ }
26791
27268
  if (this.shouldAttemptAiRecovery(session)) yield {
26792
27269
  type: "recovery_status",
26793
27270
  recoveryAttemptId: crypto.randomUUID(),
@@ -26910,7 +27387,10 @@ var Tester = class {
26910
27387
  };
26911
27388
  emittedTerminalRecoveryStatus = true;
26912
27389
  this.obs.logger.log(`Recovered command ${command._id} with whole-step repair`);
26913
- 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
+ });
26914
27394
  commandIndex = recorderStep.definition.commands.length - 1;
26915
27395
  continue;
26916
27396
  } catch (retryError) {
@@ -27049,7 +27529,10 @@ var Tester = class {
27049
27529
  };
27050
27530
  emittedTerminalRecoveryStatus = true;
27051
27531
  this.obs.logger.log(`Recovered command ${command._id} with step repair`);
27052
- 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
+ });
27053
27536
  if (decision.stepRepairPlan.type === "command-operations" || decision.stepRepairPlan.preserveSuffix) commandIndex = recorderStep.definition.commands.length - 1;
27054
27537
  continue;
27055
27538
  } catch (retryError) {
@@ -27123,7 +27606,8 @@ var Tester = class {
27123
27606
  evidenceSignalNames: decision.evidenceSignalNames
27124
27607
  };
27125
27608
  }
27126
- 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));
27127
27611
  throw terminalExecutionError;
27128
27612
  }
27129
27613
  }
@@ -28023,14 +28507,17 @@ var Tester = class {
28023
28507
  for (const recorderStep of input.recorderSteps) yield* this.executeStep(recorderStep, input, options);
28024
28508
  await options.onFinish?.();
28025
28509
  this.obs.logger.log("Finished executing all steps");
28510
+ let screenshotsAvailable = false;
28026
28511
  if (this.isRunSession(session)) {
28027
28512
  this.obs.logger.log(`Stopping Playwright tracing for reportId: ${session.reportId}, testCaseId: ${session.testCaseId}...`);
28028
28513
  await this.stopTracingAndHandleTrace(session);
28514
+ screenshotsAvailable = await this.uploadScreenshots(session);
28029
28515
  }
28030
28516
  this.obs.logger.log(`Test case execution completed successfully with session: ${JSON.stringify(session)}`);
28031
28517
  return {
28032
28518
  type: "execution_completed",
28033
- result: { type: "success" }
28519
+ result: { type: "success" },
28520
+ ...screenshotsAvailable ? { screenshotsAvailable: true } : {}
28034
28521
  };
28035
28522
  } catch (error) {
28036
28523
  const executionError = this.toExecutionError(error);
@@ -28040,13 +28527,16 @@ var Tester = class {
28040
28527
  session: this.summarizeSessionForExecutionLog(session),
28041
28528
  error: this.summarizeErrorForExecutionLog(executionError)
28042
28529
  });
28530
+ let screenshotsAvailable = false;
28043
28531
  if (this.isRunSession(session)) {
28044
28532
  this.obs.logger.log(`Stopping Playwright tracing for reportId: ${session.reportId}, testCaseId: ${session.testCaseId}...`);
28045
28533
  await this.stopTracingAndHandleTrace(session);
28534
+ screenshotsAvailable = await this.uploadScreenshots(session);
28046
28535
  }
28047
28536
  return {
28048
28537
  type: "execution_completed",
28049
- result: this.toFailureResult(executionError)
28538
+ result: this.toFailureResult(executionError),
28539
+ ...screenshotsAvailable ? { screenshotsAvailable: true } : {}
28050
28540
  };
28051
28541
  } finally {
28052
28542
  this.obs.metrics.count("bvt_agent.test_case.execute.completed.total", 1, {