@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.d.mts +92 -0
- package/index.mjs +504 -14
- package/index.mjs.map +1 -1
- package/package.json +1 -1
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":
|
|
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
|
-
|
|
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, {
|
|
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, {
|
|
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
|
-
|
|
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, {
|