@dev-blinq/bvt-playwright-js 1.0.0-dev.4.latest.149.1 → 1.0.0-dev.4.latest.172.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
 
@@ -5170,7 +5170,8 @@ const ASSERTION_OPERATORS = [
5170
5170
  "contains",
5171
5171
  "lessThan",
5172
5172
  "greaterThan",
5173
- "exists"
5173
+ "exists",
5174
+ "regex"
5174
5175
  ];
5175
5176
 
5176
5177
  //#endregion
@@ -7068,12 +7069,24 @@ const ExecResultSchema = discriminatedUnion("type", [
7068
7069
  }).strict(),
7069
7070
  object({ type: literal$1("skipped") }).strict()
7070
7071
  ]);
7072
+ /**
7073
+ * S3 keys for the before/after viewport screenshots captured around a single
7074
+ * command. Keys are deterministic (see the screenshot key convention in the
7075
+ * presigned-urls router) and are populated even before the batched upload runs;
7076
+ * the testcase-level {@link TestCaseSchema.shape.screenshotsAvailable} flag
7077
+ * indicates whether the objects were actually uploaded to S3.
7078
+ */
7079
+ const CommandScreenshotsSchema = object({
7080
+ before: string().min(1).max(1024).optional(),
7081
+ after: string().min(1).max(1024).optional()
7082
+ }).strict();
7071
7083
  const CommandResultSchema = object({
7072
7084
  commandId: string().min(1).max(255),
7073
7085
  startedAt: date().optional(),
7074
7086
  completedAt: date().optional(),
7075
7087
  result: ExecResultSchema,
7076
- recovery: RecoveryMetadataSchema.optional()
7088
+ recovery: RecoveryMetadataSchema.optional(),
7089
+ screenshots: CommandScreenshotsSchema.optional()
7077
7090
  }).strict();
7078
7091
  const StepResultSchema = object({
7079
7092
  stepId: string().min(1).max(255),
@@ -7101,6 +7114,7 @@ const TestCaseAiRecoverySchema = object({
7101
7114
  }).strict();
7102
7115
  const TestCaseWarningReasonSchema = _enum([
7103
7116
  "TraceUploadFailed",
7117
+ "ScreenshotUploadFailed",
7104
7118
  "ScenarioEmpty",
7105
7119
  "AIRecoveryPendingApproval",
7106
7120
  "AIRecoveryArtifactFailed",
@@ -7121,6 +7135,7 @@ const TestCaseSchema = object({
7121
7135
  error: TestCaseErrorSchema.optional(),
7122
7136
  aiRecovery: TestCaseAiRecoverySchema.optional(),
7123
7137
  warnings: array(TestCaseWarningSchema).optional(),
7138
+ screenshotsAvailable: boolean().optional(),
7124
7139
  startedAt: date(),
7125
7140
  completedAt: date().nullable(),
7126
7141
  stepResults: array(StepResultSchema),
@@ -10922,6 +10937,7 @@ const AiChatDataExecutionStatusPartSchema = object({
10922
10937
  * from the call sites in `apps/ai-server/src/mastra/agents/`:
10923
10938
  * - `observe-act-loop` (observe-act-completion happy path)
10924
10939
  * - `observe-act-loop-cap-reached` (loop hit iteration cap mid-run)
10940
+ * - `form-fill-batch` (on-mode form-fill fast-path success)
10925
10941
  * - `segment-followup` (apps/ai-server/src/trpc/router.ts segment-followup path)
10926
10942
  * Plus the AiStepDraftMode values used by client-side fixtures that did not
10927
10943
  * originate from the observe-act loop (legacy: `new-ai-step`, `edit-ai-step`).
@@ -10932,6 +10948,7 @@ const AiChatDataExecutionStatusPartSchema = object({
10932
10948
  const AiChatDataCompleteModeSchema = _enum([
10933
10949
  "observe-act-loop",
10934
10950
  "observe-act-loop-cap-reached",
10951
+ "form-fill-batch",
10935
10952
  "segment-followup",
10936
10953
  "new-ai-step",
10937
10954
  "edit-ai-step"
@@ -26275,11 +26292,40 @@ function getResolvedChosenSelectorIndex(result) {
26275
26292
  const candidate = result.resolvedChosenSelectorIndex;
26276
26293
  return Number.isInteger(candidate) ? candidate : void 0;
26277
26294
  }
26295
+ async function delayNewTourMaintenanceRecovery(signal) {
26296
+ if (signal?.aborted) throw new Error("Execution aborted");
26297
+ await new Promise((resolve, reject) => {
26298
+ let timeout;
26299
+ function cleanup() {
26300
+ signal?.removeEventListener("abort", handleAbort);
26301
+ }
26302
+ function handleAbort() {
26303
+ clearTimeout(timeout);
26304
+ cleanup();
26305
+ reject(/* @__PURE__ */ new Error("Execution aborted"));
26306
+ }
26307
+ timeout = setTimeout(() => {
26308
+ cleanup();
26309
+ resolve();
26310
+ }, NEW_TOUR_MAINTENANCE_RECOVERY_DELAY_MS);
26311
+ signal?.addEventListener("abort", handleAbort, { once: true });
26312
+ });
26313
+ }
26278
26314
  const browserTypesMap = {
26279
26315
  chromium,
26280
26316
  firefox,
26281
26317
  webkit
26282
26318
  };
26319
+ const NEW_TOUR_MAINTENANCE_RECOVERY_MODE = "new-tour-maintenance";
26320
+ const NEW_TOUR_MAINTENANCE_RECOVERY_DELAY_MS = 600;
26321
+ const NEW_TOUR_MAINTENANCE_RECOVERY_ARTIFACT = {
26322
+ rootCauseAnalysis: "The application changed the Gender field from a dropdown in \"Old Version\" to radio buttons in \"New Version\".",
26323
+ aiLog: "BlinqIO matched the original gender-selection intent to the new radio button UI and rewrote the stale command.",
26324
+ description: "The repaired step selects the gender radio option, then continues the registration flow successfully."
26325
+ };
26326
+ function isAbortError(error) {
26327
+ return error instanceof Error && error.name === "AbortError";
26328
+ }
26283
26329
  const unconfiguredGetAPIClient = () => {
26284
26330
  throw new Error("Tester was created without an API client.");
26285
26331
  };
@@ -26313,6 +26359,19 @@ var Tester = class {
26313
26359
  sessionToken = "";
26314
26360
  activeBrowserContext = null;
26315
26361
  reportId = ulid();
26362
+ tempPathForAssets = "";
26363
+ /**
26364
+ * Per-testcase log of before/after command screenshots captured on disk,
26365
+ * pending batch upload to S3 at the end of the run. Reset in onTestCaseStart.
26366
+ */
26367
+ capturedScreenshots = [];
26368
+ /**
26369
+ * Per-testcase screenshot counters (reset in onTestCaseStart) used purely for
26370
+ * observability — how many commands were eligible for capture and how many
26371
+ * capture attempts failed. Upload counters are derived at upload time.
26372
+ */
26373
+ screenshotCommandsConsidered = 0;
26374
+ screenshotCaptureFailures = 0;
26316
26375
  constructor(getAPIClient = unconfiguredGetAPIClient, observabilityOrOptions, optionsArg = {}) {
26317
26376
  this.getAPIClient = getAPIClient;
26318
26377
  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 +26448,87 @@ var Tester = class {
26389
26448
  default: return `Unknown Command`;
26390
26449
  }
26391
26450
  }
26451
+ screenshotsEnabled(session) {
26452
+ return this.isRunSession(session) && session.screenshots?.enabled === true;
26453
+ }
26454
+ /**
26455
+ * Deterministic S3 key for a command screenshot. Must match the convention
26456
+ * used by the server's `getPresignedUrlsForScreenshotUpload` route so the
26457
+ * keys recorded on the command report point at the uploaded objects.
26458
+ */
26459
+ buildScreenshotKey(session, stepId, commandId, phase) {
26460
+ return `${this.dataContext?.projectId ?? ""}/${session.reportId}/testcases/${session.testCaseId}/resources/screenshots/${stepId}-${commandId}-${phase}.jpg`;
26461
+ }
26462
+ getScreenshotTempDir(session) {
26463
+ if (!this.tempPathForAssets) this.tempPathForAssets = join("/tmp", `blinq-screenshots-${session.reportId}-${session.testCaseId}`);
26464
+ return this.tempPathForAssets;
26465
+ }
26466
+ /**
26467
+ * Captures a viewport JPEG of the active page for the given command/phase,
26468
+ * writes it to the per-testcase temp dir, and records it for later upload.
26469
+ * Best-effort: any failure (no page, screenshot error) is logged and yields
26470
+ * `undefined` so command execution is never affected.
26471
+ */
26472
+ async captureCommandScreenshot(session, phase, stepId, commandId) {
26473
+ if (!this.screenshotsEnabled(session) || !this.isRunSession(session)) return;
26474
+ const page = this.lastActivePage;
26475
+ if (!page) {
26476
+ this.obs.metrics.count("bvt_agent.screenshots.capture.skipped.total", 1, {
26477
+ phase,
26478
+ reason: "no-active-page"
26479
+ });
26480
+ this.obs.logger.warn("Skipping command screenshot capture: no active page", {
26481
+ stepId,
26482
+ commandId,
26483
+ phase
26484
+ });
26485
+ return;
26486
+ }
26487
+ const startMs = Date.now();
26488
+ try {
26489
+ const key = this.buildScreenshotKey(session, stepId, commandId, phase);
26490
+ const dir = this.getScreenshotTempDir(session);
26491
+ mkdirSync(dir, { recursive: true });
26492
+ const localPath = join(dir, `${stepId}-${commandId}-${phase}.jpg`);
26493
+ await page.screenshot({
26494
+ path: localPath,
26495
+ type: "jpeg",
26496
+ quality: 70
26497
+ });
26498
+ this.capturedScreenshots.push({
26499
+ stepId,
26500
+ commandId,
26501
+ phase,
26502
+ localPath
26503
+ });
26504
+ this.obs.metrics.count("bvt_agent.screenshots.capture.succeeded.total", 1, { phase });
26505
+ this.obs.metrics.latency("bvt_agent.screenshots.capture.duration", startMs, { phase });
26506
+ this.obs.logger.log("Captured command screenshot", {
26507
+ stepId,
26508
+ commandId,
26509
+ phase,
26510
+ key
26511
+ });
26512
+ return key;
26513
+ } catch (error) {
26514
+ this.screenshotCaptureFailures += 1;
26515
+ this.obs.metrics.count("bvt_agent.screenshots.capture.failed.total", 1, { phase });
26516
+ this.obs.logger.warn("Command screenshot capture failed", {
26517
+ stepId,
26518
+ commandId,
26519
+ phase,
26520
+ error: this.summarizeErrorForExecutionLog(error)
26521
+ });
26522
+ return;
26523
+ }
26524
+ }
26525
+ toScreenshotRefs(before, after) {
26526
+ if (!before && !after) return;
26527
+ return {
26528
+ ...before ? { before } : {},
26529
+ ...after ? { after } : {}
26530
+ };
26531
+ }
26392
26532
  async onCommandStart(command, stepDefinitionId, session) {
26393
26533
  this.obs.logger.log("Starting execution of command", {
26394
26534
  commandId: command._id,
@@ -26455,10 +26595,11 @@ var Tester = class {
26455
26595
  completedAt,
26456
26596
  result: { type: "success" },
26457
26597
  ...typeof metadata?.resolvedChosenSelectorIndex === "number" ? { resolvedChosenSelectorIndex: metadata.resolvedChosenSelectorIndex } : {},
26458
- ...metadata?.recovery ? { recovery: metadata.recovery } : {}
26598
+ ...metadata?.recovery ? { recovery: metadata.recovery } : {},
26599
+ ...metadata?.screenshots ? { screenshots: metadata.screenshots } : {}
26459
26600
  };
26460
26601
  }
26461
- async onCommandFail(command, stepDefinitionId, error, session, recovery) {
26602
+ async onCommandFail(command, stepDefinitionId, error, session, recovery, screenshots) {
26462
26603
  this.obs.logger.error(`Error executing command ${command._id}:`, { error: this.summarizeErrorForExecutionLog(error) });
26463
26604
  if (session?.type === "run") {
26464
26605
  this.obs.logger.log(`Ending Playwright tracing group for command: ${command._id}`);
@@ -26475,7 +26616,8 @@ var Tester = class {
26475
26616
  stepDefinitionId,
26476
26617
  completedAt,
26477
26618
  result: this.toFailureResult(error),
26478
- recovery
26619
+ recovery,
26620
+ ...screenshots ? { screenshots } : {}
26479
26621
  };
26480
26622
  }
26481
26623
  async onStepStart(step, session) {
@@ -26528,6 +26670,10 @@ var Tester = class {
26528
26670
  }
26529
26671
  async onTestCaseStart(session) {
26530
26672
  this.obs.logger.log(`Starting test case execution with session: ${JSON.stringify(session)}`);
26673
+ this.capturedScreenshots = [];
26674
+ this.tempPathForAssets = "";
26675
+ this.screenshotCommandsConsidered = 0;
26676
+ this.screenshotCaptureFailures = 0;
26531
26677
  if (this.isRunSession(session)) {
26532
26678
  if (session.token) this.sessionToken = session.token;
26533
26679
  this.obs.logger.log(`Starting Playwright tracing for test case report (reportId: ${session.reportId}, testCaseId: ${session.testCaseId})...`);
@@ -26552,6 +26698,178 @@ var Tester = class {
26552
26698
  shouldAttemptAiRecovery(session) {
26553
26699
  return !session || this.isRecordingReplaySession(session) || session.type === "run" && session.runWithAiRecovery === true;
26554
26700
  }
26701
+ isNewTourMaintenanceRecoverySession(session) {
26702
+ return session?.type === "run" && session.runWithAiRecovery === true && session.deterministicRecoveryMode === NEW_TOUR_MAINTENANCE_RECOVERY_MODE;
26703
+ }
26704
+ shouldUseNewTourMaintenanceRecovery(input) {
26705
+ if (!this.isNewTourMaintenanceRecoverySession(input.session)) return false;
26706
+ if (input.command.type !== "element.action" || input.commandIndex !== 0) return false;
26707
+ const stepText = `${input.recorderStep.step.text} ${input.recorderStep.definition.displayName ?? ""}`;
26708
+ 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);
26709
+ }
26710
+ inferNewTourGenderOption(recorderStep, failedCommandIndex) {
26711
+ const nextCommand = recorderStep.definition.commands[failedCommandIndex + 1];
26712
+ if (nextCommand?.type === "element.action" && (nextCommand.target.name === "Male" || nextCommand.target.name === "Female")) return nextCommand.target.name;
26713
+ return "Male";
26714
+ }
26715
+ buildNewTourGenderRadioCommand(input) {
26716
+ const optionSlug = input.option.toLowerCase();
26717
+ return {
26718
+ ...input.originalCommand,
26719
+ _id: ulid(),
26720
+ data: {
26721
+ type: "click",
26722
+ options: {
26723
+ force: true,
26724
+ timeout: 15e3
26725
+ }
26726
+ },
26727
+ target: {
26728
+ ...input.originalCommand.target,
26729
+ name: input.option,
26730
+ chosenSelectorIndex: 0,
26731
+ uniqueSelectors: [
26732
+ {
26733
+ type: "pw.selectorString",
26734
+ selectorString: `internal:label="${input.option}"s`,
26735
+ strategy: "text",
26736
+ description: `The ${input.option} radio option`
26737
+ },
26738
+ {
26739
+ type: "pw.selectorString",
26740
+ selectorString: `internal:role=radio[name="${input.option}"s]`,
26741
+ strategy: "label",
26742
+ description: `The radio button labeled "${input.option}"`
26743
+ },
26744
+ {
26745
+ type: "pw.selectorString",
26746
+ selectorString: `internal:testid=[data-testid="register-gender-${optionSlug}"s]`,
26747
+ strategy: "css",
26748
+ description: `The ${input.option} gender option`
26749
+ },
26750
+ {
26751
+ type: "pw.selectorString",
26752
+ selectorString: `input[type="radio"][value="${optionSlug}"]`,
26753
+ strategy: "css",
26754
+ description: `The ${input.option} gender radio input`
26755
+ }
26756
+ ]
26757
+ }
26758
+ };
26759
+ }
26760
+ buildNewTourGenderRepairPlan(input) {
26761
+ const nextCommand = input.recorderStep.definition.commands[input.failedCommandIndex + 1];
26762
+ if (!(nextCommand?.type === "element.action" && (nextCommand.target.name === "Male" || nextCommand.target.name === "Female"))) return {
26763
+ type: "single-command",
26764
+ commandIndex: input.failedCommandIndex,
26765
+ replacementCommand: input.replacementCommand,
26766
+ preserveSuffix: true
26767
+ };
26768
+ return {
26769
+ type: "command-operations",
26770
+ operations: [{
26771
+ type: "update",
26772
+ commandIndex: input.failedCommandIndex,
26773
+ replacementCommand: input.replacementCommand
26774
+ }, {
26775
+ type: "delete",
26776
+ commandIndex: input.failedCommandIndex + 1
26777
+ }],
26778
+ preserveSuffix: true
26779
+ };
26780
+ }
26781
+ async *recoverNewTourMaintenanceGenderCommand(input) {
26782
+ const recoveryAttemptId = crypto.randomUUID();
26783
+ const { recorderStep, command, commandIndex, commandContext, executionError, options } = input;
26784
+ const option = this.inferNewTourGenderOption(recorderStep, commandIndex);
26785
+ const replacementCommand = this.buildNewTourGenderRadioCommand({
26786
+ originalCommand: command,
26787
+ option
26788
+ });
26789
+ yield {
26790
+ type: "recovery_status",
26791
+ recoveryAttemptId,
26792
+ stepId: recorderStep.step._id,
26793
+ commandId: command._id,
26794
+ phase: "analyzing",
26795
+ message: "Analyzing failure",
26796
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
26797
+ };
26798
+ await delayNewTourMaintenanceRecovery(options.abortController?.signal);
26799
+ yield {
26800
+ type: "recovery_status",
26801
+ recoveryAttemptId,
26802
+ stepId: recorderStep.step._id,
26803
+ commandId: command._id,
26804
+ phase: "trying_fix",
26805
+ message: "AI Test Engineer is rewriting the gender selection step",
26806
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
26807
+ };
26808
+ await delayNewTourMaintenanceRecovery(options.abortController?.signal);
26809
+ yield {
26810
+ type: "recovery_status",
26811
+ recoveryAttemptId,
26812
+ stepId: recorderStep.step._id,
26813
+ commandId: command._id,
26814
+ phase: "checking_fix",
26815
+ message: "AI Test Engineer is checking the fix",
26816
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
26817
+ };
26818
+ await this.executeCommand({
26819
+ page: this.lastActivePage,
26820
+ command: replacementCommand,
26821
+ context: commandContext
26822
+ });
26823
+ const persistResult = persistStepRepair({
26824
+ stepDefinition: recorderStep.definition,
26825
+ plan: this.buildNewTourGenderRepairPlan({
26826
+ recorderStep,
26827
+ failedCommandIndex: commandIndex,
26828
+ replacementCommand
26829
+ })
26830
+ });
26831
+ if (!persistResult.success) throw new Error(persistResult.error);
26832
+ const recoveryMeta = {
26833
+ recoveryAttemptId,
26834
+ originalCommandId: command._id,
26835
+ stepId: recorderStep.step._id,
26836
+ originalErrorMessage: executionError.message,
26837
+ originalErrorStack: executionError.stack,
26838
+ rcaLabel: "demo-gender-control-changed",
26839
+ rcaConfidence: 1,
26840
+ rootCauseAnalysis: NEW_TOUR_MAINTENANCE_RECOVERY_ARTIFACT.rootCauseAnalysis,
26841
+ evidenceSignalNames: ["new_tour_maintenance_gender_radio_demo"],
26842
+ targetElementSnapshot: {
26843
+ accessibleName: option,
26844
+ role: "radio",
26845
+ text: option,
26846
+ labelText: option
26847
+ },
26848
+ actionVerb: "click",
26849
+ commandPatchSummary: "Updated the gender selection step from the old dropdown to the new radio button UI",
26850
+ retryOutcome: "passed",
26851
+ recoveredAt: (/* @__PURE__ */ new Date()).toISOString(),
26852
+ healedLocatorPersistedAt: persistResult.persistedAt,
26853
+ stepDefinitionPatch: {
26854
+ stepDefinitionId: recorderStep.definition._id,
26855
+ commands: persistResult.commands,
26856
+ changedCommandIds: persistResult.changedCommandIds,
26857
+ preservedCommandIds: persistResult.preservedCommandIds,
26858
+ persistedAt: persistResult.persistedAt
26859
+ }
26860
+ };
26861
+ yield {
26862
+ type: "recovery_status",
26863
+ recoveryAttemptId,
26864
+ stepId: recorderStep.step._id,
26865
+ commandId: command._id,
26866
+ phase: "fixed",
26867
+ message: "AI recovered the gender selection step",
26868
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
26869
+ artifact: NEW_TOUR_MAINTENANCE_RECOVERY_ARTIFACT
26870
+ };
26871
+ return recoveryMeta;
26872
+ }
26555
26873
  isSupportedRepairContext(decision, session) {
26556
26874
  if (decision.kind !== "step-repair") return false;
26557
26875
  if (this.isRecordingReplaySession(session) || !session) return decision.repairContext === "recorder-replay";
@@ -26634,12 +26952,12 @@ var Tester = class {
26634
26952
  setTimeout(resolve, ms);
26635
26953
  });
26636
26954
  }
26637
- async uploadFile(presignedUrl, filePath) {
26955
+ async uploadFile(presignedUrl, filePath, contentType = "application/zip") {
26638
26956
  const fileData = readFileSync$1(filePath);
26639
26957
  this.obs.logger.log(`Uploading file from ${filePath} (size=${fileData.byteLength} bytes) via PUT`);
26640
26958
  const response = await fetch(presignedUrl, {
26641
26959
  method: "PUT",
26642
- headers: { "Content-Type": "application/zip" },
26960
+ headers: { "Content-Type": contentType },
26643
26961
  body: fileData
26644
26962
  });
26645
26963
  if (!response.ok) throw new Error(`Failed to upload file. Status: ${response.status}: ${await response.text()}`);
@@ -26669,6 +26987,146 @@ var Tester = class {
26669
26987
  }
26670
26988
  return false;
26671
26989
  }
26990
+ async putFileWithRetries(input) {
26991
+ const maxAttempts = 3;
26992
+ const baseDelayMs = 250;
26993
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) try {
26994
+ await this.uploadFile(input.presignedUrl, input.filePath, input.contentType);
26995
+ return true;
26996
+ } catch (error) {
26997
+ if (attempt === maxAttempts) {
26998
+ this.obs.logger.error(`${input.label} upload failed after ${maxAttempts} attempts`, error);
26999
+ return false;
27000
+ }
27001
+ const delayMs = baseDelayMs * 2 ** (attempt - 1);
27002
+ this.obs.logger.warn(`${input.label} upload failed, retrying`, {
27003
+ attempt,
27004
+ maxAttempts,
27005
+ delayMs,
27006
+ error: this.summarizeErrorForExecutionLog(error)
27007
+ });
27008
+ await this.wait(delayMs);
27009
+ }
27010
+ return false;
27011
+ }
27012
+ cleanupScreenshotTempDir() {
27013
+ if (!this.tempPathForAssets) return;
27014
+ try {
27015
+ rmSync(this.tempPathForAssets, {
27016
+ recursive: true,
27017
+ force: true
27018
+ });
27019
+ } catch (error) {
27020
+ this.obs.logger.warn("Failed to clean up screenshot temp dir", {
27021
+ dir: this.tempPathForAssets,
27022
+ error: this.summarizeErrorForExecutionLog(error)
27023
+ });
27024
+ } finally {
27025
+ this.tempPathForAssets = "";
27026
+ }
27027
+ }
27028
+ /**
27029
+ * Batch-uploads all command screenshots captured during the run to S3.
27030
+ * Fetches a presigned PUT URL per file in a single round-trip, then uploads
27031
+ * each with retries. Best-effort: returns true when at least one screenshot
27032
+ * was uploaded, false otherwise (or when there is nothing/no token). Always
27033
+ * cleans up the on-disk temp files. Never throws.
27034
+ */
27035
+ async uploadScreenshots(session) {
27036
+ const screenshots = this.capturedScreenshots;
27037
+ const capturedCount = screenshots.length;
27038
+ const distinctCommandsCaptured = new Set(screenshots.map((shot) => shot.commandId)).size;
27039
+ const commandsConsidered = this.screenshotCommandsConsidered;
27040
+ const captureFailures = this.screenshotCaptureFailures;
27041
+ this.obs.metrics.gauge("bvt_agent.screenshots.commands_considered", commandsConsidered);
27042
+ this.obs.metrics.gauge("bvt_agent.screenshots.captured", capturedCount);
27043
+ this.obs.metrics.gauge("bvt_agent.screenshots.distinct_commands_captured", distinctCommandsCaptured);
27044
+ if (capturedCount === 0) {
27045
+ this.obs.logger.log("No command screenshots captured; skipping upload", {
27046
+ reportId: session.reportId,
27047
+ testCaseId: session.testCaseId,
27048
+ commandsConsidered,
27049
+ captureFailures
27050
+ });
27051
+ return false;
27052
+ }
27053
+ if (!session.token) {
27054
+ this.obs.metrics.count("bvt_agent.screenshots.upload.skipped.total", 1, { reason: "no-session-token" });
27055
+ this.obs.logger.warn(`Screenshot upload skipped because no session token was provided for reportId: ${session.reportId}, testCaseId: ${session.testCaseId}.`);
27056
+ this.cleanupScreenshotTempDir();
27057
+ this.capturedScreenshots = [];
27058
+ return false;
27059
+ }
27060
+ const uploadStartMs = Date.now();
27061
+ try {
27062
+ this.obs.logger.log(`Requesting ${screenshots.length} presigned URL(s) for screenshot upload`, {
27063
+ reportId: session.reportId,
27064
+ testCaseId: session.testCaseId
27065
+ });
27066
+ const targets = await this.getAPIClient(this.sessionToken).presignedUrls.getPresignedUrlsForScreenshotUpload.query({
27067
+ reportId: session.reportId,
27068
+ testCaseId: session.testCaseId,
27069
+ projectId: this.dataContext?.projectId ?? "",
27070
+ files: screenshots.map(({ stepId, commandId, phase }) => ({
27071
+ stepId,
27072
+ commandId,
27073
+ phase
27074
+ }))
27075
+ });
27076
+ const urlByTargetKey = new Map(targets.map((target) => [`${target.stepId}-${target.commandId}-${target.phase}`, target.url]));
27077
+ let uploadedCount = 0;
27078
+ let failedCount = 0;
27079
+ for (const shot of screenshots) {
27080
+ const presignedUrl = urlByTargetKey.get(`${shot.stepId}-${shot.commandId}-${shot.phase}`);
27081
+ if (!presignedUrl) {
27082
+ failedCount += 1;
27083
+ this.obs.metrics.count("bvt_agent.screenshots.upload.failed.total", 1, { reason: "no-presigned-url" });
27084
+ this.obs.logger.warn("No presigned URL returned for screenshot", {
27085
+ stepId: shot.stepId,
27086
+ commandId: shot.commandId,
27087
+ phase: shot.phase
27088
+ });
27089
+ continue;
27090
+ }
27091
+ if (await this.putFileWithRetries({
27092
+ presignedUrl,
27093
+ filePath: shot.localPath,
27094
+ contentType: "image/jpeg",
27095
+ label: "Screenshot"
27096
+ })) {
27097
+ uploadedCount += 1;
27098
+ this.obs.metrics.count("bvt_agent.screenshots.upload.succeeded.total", 1, { phase: shot.phase });
27099
+ } else {
27100
+ failedCount += 1;
27101
+ this.obs.metrics.count("bvt_agent.screenshots.upload.failed.total", 1, { reason: "put-failed" });
27102
+ }
27103
+ }
27104
+ this.obs.metrics.latency("bvt_agent.screenshots.upload.duration", uploadStartMs, { outcome: uploadedCount > 0 ? "success" : "failure" });
27105
+ this.obs.logger.log(`Screenshot upload finished: ${uploadedCount}/${capturedCount} uploaded, ${failedCount} failed`, {
27106
+ reportId: session.reportId,
27107
+ testCaseId: session.testCaseId,
27108
+ commandsConsidered,
27109
+ distinctCommandsCaptured,
27110
+ capturedCount,
27111
+ uploadedCount,
27112
+ failedCount,
27113
+ captureFailures
27114
+ });
27115
+ return uploadedCount > 0;
27116
+ } catch (error) {
27117
+ this.obs.metrics.count("bvt_agent.screenshots.upload.failed.total", capturedCount, { reason: "batch-error" });
27118
+ this.obs.metrics.latency("bvt_agent.screenshots.upload.duration", uploadStartMs, { outcome: "error" });
27119
+ this.obs.logger.warn(`Screenshot upload failed for reportId: ${session.reportId}, testCaseId: ${session.testCaseId}. Continuing without failing execution result.`, {
27120
+ error: this.summarizeErrorForExecutionLog(error),
27121
+ capturedCount,
27122
+ commandsConsidered
27123
+ });
27124
+ return false;
27125
+ } finally {
27126
+ this.cleanupScreenshotTempDir();
27127
+ this.capturedScreenshots = [];
27128
+ }
27129
+ }
26672
27130
  async onTestCaseFail(error, session) {
26673
27131
  this.obs.logger.error("Test case execution failed", {
26674
27132
  session: this.summarizeSessionForExecutionLog(session),
@@ -26773,8 +27231,11 @@ var Tester = class {
26773
27231
  commandId: command._id,
26774
27232
  commandType: command.type
26775
27233
  });
27234
+ let beforeScreenshotKey;
27235
+ if (this.screenshotsEnabled(session)) this.screenshotCommandsConsidered += 1;
26776
27236
  try {
26777
27237
  yield await this.onCommandStart(command, stepDefinitionId, session);
27238
+ beforeScreenshotKey = await this.captureCommandScreenshot(session, "before", recorderStep.step._id, command._id);
26778
27239
  const commandResult = await this.executeCommand({
26779
27240
  page: this.lastActivePage,
26780
27241
  command,
@@ -26782,12 +27243,34 @@ var Tester = class {
26782
27243
  });
26783
27244
  if ((command.type === "custom" || command.type === "custom.code") && commandResult && isFailedCustomCommandResult(commandResult)) throw new Error(commandResult.error || `Custom command "${command._id}" failed.`);
26784
27245
  this.obs.logger.log(`Finished executing command: ${command}`);
26785
- yield await this.onCommandPass(command, stepDefinitionId, session, { resolvedChosenSelectorIndex: getResolvedChosenSelectorIndex(commandResult) });
27246
+ const afterScreenshotKey = await this.captureCommandScreenshot(session, "after", recorderStep.step._id, command._id);
27247
+ yield await this.onCommandPass(command, stepDefinitionId, session, {
27248
+ resolvedChosenSelectorIndex: getResolvedChosenSelectorIndex(commandResult),
27249
+ screenshots: this.toScreenshotRefs(beforeScreenshotKey, afterScreenshotKey)
27250
+ });
26786
27251
  } catch (error) {
26787
27252
  const executionError = this.toExecutionError(error);
26788
27253
  let terminalExecutionError = executionError;
26789
27254
  let failedRecoveryMeta;
26790
27255
  if (this.isBrowserDerivedCommand(command)) {
27256
+ if (command.type === "element.action" && this.shouldUseNewTourMaintenanceRecovery({
27257
+ session,
27258
+ recorderStep,
27259
+ command,
27260
+ commandIndex
27261
+ })) {
27262
+ const recoveryMeta = yield* this.recoverNewTourMaintenanceGenderCommand({
27263
+ recorderStep,
27264
+ command,
27265
+ commandIndex,
27266
+ commandContext,
27267
+ executionError,
27268
+ options
27269
+ });
27270
+ stepRecovery = recoveryMeta;
27271
+ yield await this.onCommandPass(command, stepDefinitionId, session, { recovery: recoveryMeta });
27272
+ continue;
27273
+ }
26791
27274
  if (this.shouldAttemptAiRecovery(session)) yield {
26792
27275
  type: "recovery_status",
26793
27276
  recoveryAttemptId: crypto.randomUUID(),
@@ -26798,7 +27281,21 @@ var Tester = class {
26798
27281
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
26799
27282
  };
26800
27283
  const failureContext = this.buildFailureContext(command, recorderStep, stepDefinitionId, commandIndex, commandContext, executionError);
26801
- const recoveryResult = await this.recoveryController.analyzeAndPropose(failureContext);
27284
+ const recoveryContext = {
27285
+ ...failureContext,
27286
+ abortSignal: options.recoveryAbortController?.signal ?? failureContext.abortSignal
27287
+ };
27288
+ let recoveryResult;
27289
+ try {
27290
+ recoveryResult = await this.recoveryController.analyzeAndPropose(recoveryContext);
27291
+ } catch (recoveryError) {
27292
+ if (isAbortError(recoveryError)) {
27293
+ this.obs.logger.log("AI recovery cancelled; preserving original command failure", { commandId: command._id });
27294
+ yield await this.onCommandFail(command, stepDefinitionId, executionError, session);
27295
+ throw executionError;
27296
+ }
27297
+ throw recoveryError;
27298
+ }
26802
27299
  const { decision } = recoveryResult;
26803
27300
  let artifact = recoveryResult.artifact;
26804
27301
  if (this.shouldAttemptAiRecovery(session)) {
@@ -26867,7 +27364,7 @@ var Tester = class {
26867
27364
  if (!persistResult.success) throw new Error(persistResult.error);
26868
27365
  artifact = await this.generateRecoveryArtifactForOutcome({
26869
27366
  decision,
26870
- failureContext,
27367
+ failureContext: recoveryContext,
26871
27368
  recoverySucceeded: true,
26872
27369
  stoppedReason: "accept-repair",
26873
27370
  attemptLog: this.buildStepRepairAttemptLog(decision, "succeeded")
@@ -26910,7 +27407,10 @@ var Tester = class {
26910
27407
  };
26911
27408
  emittedTerminalRecoveryStatus = true;
26912
27409
  this.obs.logger.log(`Recovered command ${command._id} with whole-step repair`);
26913
- yield await this.onCommandPass(command, stepDefinitionId, session, { recovery: recoveryMeta });
27410
+ yield await this.onCommandPass(command, stepDefinitionId, session, {
27411
+ recovery: recoveryMeta,
27412
+ screenshots: this.toScreenshotRefs(beforeScreenshotKey, await this.captureCommandScreenshot(session, "after", recorderStep.step._id, command._id))
27413
+ });
26914
27414
  commandIndex = recorderStep.definition.commands.length - 1;
26915
27415
  continue;
26916
27416
  } catch (retryError) {
@@ -26923,7 +27423,7 @@ var Tester = class {
26923
27423
  });
26924
27424
  artifact = await this.generateRecoveryArtifactForOutcome({
26925
27425
  decision,
26926
- failureContext,
27426
+ failureContext: recoveryContext,
26927
27427
  recoverySucceeded: false,
26928
27428
  stoppedReason: "retry_failed",
26929
27429
  attemptLog: this.buildStepRepairAttemptLog(decision, "failed")
@@ -26944,7 +27444,7 @@ var Tester = class {
26944
27444
  terminalExecutionError = /* @__PURE__ */ new Error(`${decision.userFacingMessages.failed}. Repair application failure: ${appliedRepair.error}. Original failure: ${executionError.message}`);
26945
27445
  artifact = await this.generateRecoveryArtifactForOutcome({
26946
27446
  decision,
26947
- failureContext,
27447
+ failureContext: recoveryContext,
26948
27448
  recoverySucceeded: false,
26949
27449
  stoppedReason: "repair_application_failed",
26950
27450
  attemptLog: this.buildStepRepairAttemptLog(decision, "failed")
@@ -27005,7 +27505,7 @@ var Tester = class {
27005
27505
  if (!persistResult.success) throw new Error(persistResult.error);
27006
27506
  artifact = await this.generateRecoveryArtifactForOutcome({
27007
27507
  decision,
27008
- failureContext,
27508
+ failureContext: recoveryContext,
27009
27509
  recoverySucceeded: true,
27010
27510
  stoppedReason: "accept-repair",
27011
27511
  attemptLog: this.buildStepRepairAttemptLog(decision, "succeeded")
@@ -27049,7 +27549,10 @@ var Tester = class {
27049
27549
  };
27050
27550
  emittedTerminalRecoveryStatus = true;
27051
27551
  this.obs.logger.log(`Recovered command ${command._id} with step repair`);
27052
- yield await this.onCommandPass(command, stepDefinitionId, session, { recovery: recoveryMeta });
27552
+ yield await this.onCommandPass(command, stepDefinitionId, session, {
27553
+ recovery: recoveryMeta,
27554
+ screenshots: this.toScreenshotRefs(beforeScreenshotKey, await this.captureCommandScreenshot(session, "after", recorderStep.step._id, command._id))
27555
+ });
27053
27556
  if (decision.stepRepairPlan.type === "command-operations" || decision.stepRepairPlan.preserveSuffix) commandIndex = recorderStep.definition.commands.length - 1;
27054
27557
  continue;
27055
27558
  } catch (retryError) {
@@ -27062,7 +27565,7 @@ var Tester = class {
27062
27565
  });
27063
27566
  artifact = await this.generateRecoveryArtifactForOutcome({
27064
27567
  decision,
27065
- failureContext,
27568
+ failureContext: recoveryContext,
27066
27569
  recoverySucceeded: false,
27067
27570
  stoppedReason: "retry_failed",
27068
27571
  attemptLog: this.buildStepRepairAttemptLog(decision, "failed")
@@ -27082,7 +27585,7 @@ var Tester = class {
27082
27585
  else {
27083
27586
  artifact = await this.generateRecoveryArtifactForOutcome({
27084
27587
  decision,
27085
- failureContext,
27588
+ failureContext: recoveryContext,
27086
27589
  recoverySucceeded: false,
27087
27590
  stoppedReason: "repair_application_failed",
27088
27591
  attemptLog: this.buildStepRepairAttemptLog(decision, "failed")
@@ -27123,7 +27626,8 @@ var Tester = class {
27123
27626
  evidenceSignalNames: decision.evidenceSignalNames
27124
27627
  };
27125
27628
  }
27126
- yield await this.onCommandFail(command, stepDefinitionId, terminalExecutionError, session, failedRecoveryMeta);
27629
+ const afterScreenshotKey = await this.captureCommandScreenshot(session, "after", recorderStep.step._id, command._id);
27630
+ yield await this.onCommandFail(command, stepDefinitionId, terminalExecutionError, session, failedRecoveryMeta, this.toScreenshotRefs(beforeScreenshotKey, afterScreenshotKey));
27127
27631
  throw terminalExecutionError;
27128
27632
  }
27129
27633
  }
@@ -27494,6 +27998,33 @@ var Tester = class {
27494
27998
  if (!(actual > threshold)) throw new Error(`Property "${assertion.name}" expected to be greater than ${threshold}, but got ${actual}`);
27495
27999
  }
27496
28000
  break;
28001
+ case "regex":
28002
+ if (assertion.value !== void 0) {
28003
+ const raw = getValueFromPrimitiveOrRegex(assertion.value);
28004
+ const pattern = raw instanceof RegExp ? raw : new RegExp(String(raw));
28005
+ this.obs.logger.info(`Expected property "${assertion.name}" to match pattern: ${pattern}`);
28006
+ const propName = assertion.name;
28007
+ const isTextProp = propName === "textContent" || propName === "innerText";
28008
+ const isHtmlAttr = [
28009
+ "href",
28010
+ "src",
28011
+ "class",
28012
+ "id",
28013
+ "placeholder",
28014
+ "title",
28015
+ "alt",
28016
+ "name",
28017
+ "type",
28018
+ "action",
28019
+ "target",
28020
+ "rel",
28021
+ "value"
28022
+ ].includes(propName) || propName.startsWith("data-") || propName.startsWith("aria-");
28023
+ if (isTextProp) await expect(locator).toHaveText(pattern, assertion.options);
28024
+ else if (isHtmlAttr) await expect(locator).toHaveAttribute(propName, pattern, assertion.options);
28025
+ else await expect.poll(async () => locator.evaluate((el, name) => String(el[name] ?? ""), propName), { timeout: assertion.options?.timeout }).toMatch(pattern);
28026
+ }
28027
+ break;
27497
28028
  case "exists": {
27498
28029
  const exists = await locator.evaluate((el, name) => el[name] !== void 0, assertion.name);
27499
28030
  this.obs.logger.info(`Expected property "${assertion.name}" to exist: ${exists}`);
@@ -27549,7 +28080,9 @@ var Tester = class {
27549
28080
  const frame = await this.getFrameObjectFromFrameScope(frameScope);
27550
28081
  this.obs.logger.info(`Checking for absence of element with text ${value} using "${locator.first()?._selector}" `);
27551
28082
  this.obs.logger.info(`Waiting for page to load and stabilize before checking for absence of text to reduce flakiness...`);
27552
- await frame.waitForLoadState("networkidle", { timeout: 3e4 });
28083
+ await frame.waitForLoadState("networkidle", { timeout: 3e4 }).catch((error) => {
28084
+ this.obs.logger.error(`Frame load timeout, the page may not be in the desired state yet`, error);
28085
+ });
27553
28086
  this.obs.logger.info(`frame load detected. Waiting an additional 500ms for stabilization...`);
27554
28087
  await frame.waitForTimeout(500);
27555
28088
  this.obs.logger.info(`Checking for element with text "${value}" after page load and stabilization delay...`);
@@ -28023,14 +28556,17 @@ var Tester = class {
28023
28556
  for (const recorderStep of input.recorderSteps) yield* this.executeStep(recorderStep, input, options);
28024
28557
  await options.onFinish?.();
28025
28558
  this.obs.logger.log("Finished executing all steps");
28559
+ let screenshotsAvailable = false;
28026
28560
  if (this.isRunSession(session)) {
28027
28561
  this.obs.logger.log(`Stopping Playwright tracing for reportId: ${session.reportId}, testCaseId: ${session.testCaseId}...`);
28028
28562
  await this.stopTracingAndHandleTrace(session);
28563
+ screenshotsAvailable = await this.uploadScreenshots(session);
28029
28564
  }
28030
28565
  this.obs.logger.log(`Test case execution completed successfully with session: ${JSON.stringify(session)}`);
28031
28566
  return {
28032
28567
  type: "execution_completed",
28033
- result: { type: "success" }
28568
+ result: { type: "success" },
28569
+ ...screenshotsAvailable ? { screenshotsAvailable: true } : {}
28034
28570
  };
28035
28571
  } catch (error) {
28036
28572
  const executionError = this.toExecutionError(error);
@@ -28040,13 +28576,16 @@ var Tester = class {
28040
28576
  session: this.summarizeSessionForExecutionLog(session),
28041
28577
  error: this.summarizeErrorForExecutionLog(executionError)
28042
28578
  });
28579
+ let screenshotsAvailable = false;
28043
28580
  if (this.isRunSession(session)) {
28044
28581
  this.obs.logger.log(`Stopping Playwright tracing for reportId: ${session.reportId}, testCaseId: ${session.testCaseId}...`);
28045
28582
  await this.stopTracingAndHandleTrace(session);
28583
+ screenshotsAvailable = await this.uploadScreenshots(session);
28046
28584
  }
28047
28585
  return {
28048
28586
  type: "execution_completed",
28049
- result: this.toFailureResult(executionError)
28587
+ result: this.toFailureResult(executionError),
28588
+ ...screenshotsAvailable ? { screenshotsAvailable: true } : {}
28050
28589
  };
28051
28590
  } finally {
28052
28591
  this.obs.metrics.count("bvt_agent.test_case.execute.completed.total", 1, {