@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.d.mts +96 -0
- package/index.mjs +562 -23
- 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
|
|
|
@@ -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":
|
|
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
|
-
|
|
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
|
|
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, {
|
|
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, {
|
|
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
|
-
|
|
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, {
|