@dev-blinq/bvt-playwright-js 1.0.0-dev.4.staging.135.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 +135 -5
- package/index.mjs +626 -32
- 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
|
|
|
@@ -5162,6 +5162,17 @@ const primitiveOrRegex = discriminatedUnion("type", [
|
|
|
5162
5162
|
}).strict()
|
|
5163
5163
|
]);
|
|
5164
5164
|
|
|
5165
|
+
//#endregion
|
|
5166
|
+
//#region ../../core/schemas/src/assertions/assertion-operators.ts
|
|
5167
|
+
const ASSERTION_OPERATORS = [
|
|
5168
|
+
"equals",
|
|
5169
|
+
"notEquals",
|
|
5170
|
+
"contains",
|
|
5171
|
+
"lessThan",
|
|
5172
|
+
"greaterThan",
|
|
5173
|
+
"exists"
|
|
5174
|
+
];
|
|
5175
|
+
|
|
5165
5176
|
//#endregion
|
|
5166
5177
|
//#region ../../core/schemas/src/api/api.schema.ts
|
|
5167
5178
|
const numberAssertion = object({
|
|
@@ -5393,6 +5404,7 @@ const elementAssertionSchema = discriminatedUnion("type", [
|
|
|
5393
5404
|
object({
|
|
5394
5405
|
type: literal$1("toHaveProperty"),
|
|
5395
5406
|
name: string(),
|
|
5407
|
+
operator: _enum(ASSERTION_OPERATORS).optional(),
|
|
5396
5408
|
value: primitiveOrRegex.optional(),
|
|
5397
5409
|
options: object({ timeout: number().optional() }).optional()
|
|
5398
5410
|
}),
|
|
@@ -6550,6 +6562,7 @@ const RichElementTargetDetailsSchema = object({
|
|
|
6550
6562
|
}).passthrough();
|
|
6551
6563
|
const elementTargetSchema = object({
|
|
6552
6564
|
name: string(),
|
|
6565
|
+
isElementHidden: boolean().optional(),
|
|
6553
6566
|
uniqueSelectors: array(UniqueSelectorSchema),
|
|
6554
6567
|
frames: array(object({
|
|
6555
6568
|
name: string(),
|
|
@@ -6918,6 +6931,23 @@ const ProjectBrowserLaunchConfigurationSchema = object({
|
|
|
6918
6931
|
}).strict();
|
|
6919
6932
|
const ProjectGitCodegenFormats = ["playwright", "gherkin"];
|
|
6920
6933
|
const ProjectGitCodegenFormatSchema = _enum(ProjectGitCodegenFormats).default("playwright");
|
|
6934
|
+
/**
|
|
6935
|
+
* Personalisation inputs for the analytics dashboard.
|
|
6936
|
+
*
|
|
6937
|
+
* These values drive the "Budget Saved", "Time Saved", and
|
|
6938
|
+
* "AI Work Time Distribution" widgets. All hours are in human-hours
|
|
6939
|
+
* (the unit the modal collects). The defaults match the legacy
|
|
6940
|
+
* Metabase queries' `$ifNull` fallbacks — projects that never
|
|
6941
|
+
* customise these still get sensible numbers on day one.
|
|
6942
|
+
*/
|
|
6943
|
+
const DashboardPersonalizationSchema = object({
|
|
6944
|
+
hourlyRateForTestEngineer: number().nonnegative().default(50),
|
|
6945
|
+
avgTimeToAutomateScenario: number().nonnegative().default(4),
|
|
6946
|
+
avgTimeToAutomateScenarioUsingBlinqIO: number().nonnegative().default(.25),
|
|
6947
|
+
avgTimeToMaintainScenario: number().nonnegative().default(2),
|
|
6948
|
+
avgTimeToAnalyzeFailedScenario: number().nonnegative().default(2)
|
|
6949
|
+
}).strict();
|
|
6950
|
+
const DEFAULT_DASHBOARD_PERSONALIZATION = DashboardPersonalizationSchema.parse({});
|
|
6921
6951
|
const ProjectSettingsSchema = object({
|
|
6922
6952
|
_id: EntityIdSchema,
|
|
6923
6953
|
projectId: EntityIdSchema,
|
|
@@ -6928,11 +6958,13 @@ const ProjectSettingsSchema = object({
|
|
|
6928
6958
|
width: 1280,
|
|
6929
6959
|
height: 900
|
|
6930
6960
|
} }
|
|
6931
|
-
})
|
|
6961
|
+
}),
|
|
6962
|
+
dashboardPersonalization: DashboardPersonalizationSchema.default(DEFAULT_DASHBOARD_PERSONALIZATION)
|
|
6932
6963
|
}).strict();
|
|
6933
6964
|
const ProjectSettingsUpdatableFieldsSchema = ProjectSettingsSchema.omit({
|
|
6934
6965
|
_id: true,
|
|
6935
|
-
projectId: true
|
|
6966
|
+
projectId: true,
|
|
6967
|
+
dashboardPersonalization: true
|
|
6936
6968
|
});
|
|
6937
6969
|
const DEFAULT_PROJECT_SETTINGS = ProjectSettingsUpdatableFieldsSchema.parse({});
|
|
6938
6970
|
const GetProjectSettingsInputSchema = object({ projectId: EntityIdSchema }).strict();
|
|
@@ -7036,12 +7068,24 @@ const ExecResultSchema = discriminatedUnion("type", [
|
|
|
7036
7068
|
}).strict(),
|
|
7037
7069
|
object({ type: literal$1("skipped") }).strict()
|
|
7038
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();
|
|
7039
7082
|
const CommandResultSchema = object({
|
|
7040
7083
|
commandId: string().min(1).max(255),
|
|
7041
7084
|
startedAt: date().optional(),
|
|
7042
7085
|
completedAt: date().optional(),
|
|
7043
7086
|
result: ExecResultSchema,
|
|
7044
|
-
recovery: RecoveryMetadataSchema.optional()
|
|
7087
|
+
recovery: RecoveryMetadataSchema.optional(),
|
|
7088
|
+
screenshots: CommandScreenshotsSchema.optional()
|
|
7045
7089
|
}).strict();
|
|
7046
7090
|
const StepResultSchema = object({
|
|
7047
7091
|
stepId: string().min(1).max(255),
|
|
@@ -7069,6 +7113,7 @@ const TestCaseAiRecoverySchema = object({
|
|
|
7069
7113
|
}).strict();
|
|
7070
7114
|
const TestCaseWarningReasonSchema = _enum([
|
|
7071
7115
|
"TraceUploadFailed",
|
|
7116
|
+
"ScreenshotUploadFailed",
|
|
7072
7117
|
"ScenarioEmpty",
|
|
7073
7118
|
"AIRecoveryPendingApproval",
|
|
7074
7119
|
"AIRecoveryArtifactFailed",
|
|
@@ -7089,6 +7134,7 @@ const TestCaseSchema = object({
|
|
|
7089
7134
|
error: TestCaseErrorSchema.optional(),
|
|
7090
7135
|
aiRecovery: TestCaseAiRecoverySchema.optional(),
|
|
7091
7136
|
warnings: array(TestCaseWarningSchema).optional(),
|
|
7137
|
+
screenshotsAvailable: boolean().optional(),
|
|
7092
7138
|
startedAt: date(),
|
|
7093
7139
|
completedAt: date().nullable(),
|
|
7094
7140
|
stepResults: array(StepResultSchema),
|
|
@@ -26243,11 +26289,37 @@ function getResolvedChosenSelectorIndex(result) {
|
|
|
26243
26289
|
const candidate = result.resolvedChosenSelectorIndex;
|
|
26244
26290
|
return Number.isInteger(candidate) ? candidate : void 0;
|
|
26245
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
|
+
}
|
|
26246
26311
|
const browserTypesMap = {
|
|
26247
26312
|
chromium,
|
|
26248
26313
|
firefox,
|
|
26249
26314
|
webkit
|
|
26250
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
|
+
};
|
|
26251
26323
|
const unconfiguredGetAPIClient = () => {
|
|
26252
26324
|
throw new Error("Tester was created without an API client.");
|
|
26253
26325
|
};
|
|
@@ -26281,6 +26353,19 @@ var Tester = class {
|
|
|
26281
26353
|
sessionToken = "";
|
|
26282
26354
|
activeBrowserContext = null;
|
|
26283
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;
|
|
26284
26369
|
constructor(getAPIClient = unconfiguredGetAPIClient, observabilityOrOptions, optionsArg = {}) {
|
|
26285
26370
|
this.getAPIClient = getAPIClient;
|
|
26286
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);
|
|
@@ -26357,6 +26442,87 @@ var Tester = class {
|
|
|
26357
26442
|
default: return `Unknown Command`;
|
|
26358
26443
|
}
|
|
26359
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
|
+
}
|
|
26360
26526
|
async onCommandStart(command, stepDefinitionId, session) {
|
|
26361
26527
|
this.obs.logger.log("Starting execution of command", {
|
|
26362
26528
|
commandId: command._id,
|
|
@@ -26423,10 +26589,11 @@ var Tester = class {
|
|
|
26423
26589
|
completedAt,
|
|
26424
26590
|
result: { type: "success" },
|
|
26425
26591
|
...typeof metadata?.resolvedChosenSelectorIndex === "number" ? { resolvedChosenSelectorIndex: metadata.resolvedChosenSelectorIndex } : {},
|
|
26426
|
-
...metadata?.recovery ? { recovery: metadata.recovery } : {}
|
|
26592
|
+
...metadata?.recovery ? { recovery: metadata.recovery } : {},
|
|
26593
|
+
...metadata?.screenshots ? { screenshots: metadata.screenshots } : {}
|
|
26427
26594
|
};
|
|
26428
26595
|
}
|
|
26429
|
-
async onCommandFail(command, stepDefinitionId, error, session, recovery) {
|
|
26596
|
+
async onCommandFail(command, stepDefinitionId, error, session, recovery, screenshots) {
|
|
26430
26597
|
this.obs.logger.error(`Error executing command ${command._id}:`, { error: this.summarizeErrorForExecutionLog(error) });
|
|
26431
26598
|
if (session?.type === "run") {
|
|
26432
26599
|
this.obs.logger.log(`Ending Playwright tracing group for command: ${command._id}`);
|
|
@@ -26443,7 +26610,8 @@ var Tester = class {
|
|
|
26443
26610
|
stepDefinitionId,
|
|
26444
26611
|
completedAt,
|
|
26445
26612
|
result: this.toFailureResult(error),
|
|
26446
|
-
recovery
|
|
26613
|
+
recovery,
|
|
26614
|
+
...screenshots ? { screenshots } : {}
|
|
26447
26615
|
};
|
|
26448
26616
|
}
|
|
26449
26617
|
async onStepStart(step, session) {
|
|
@@ -26496,6 +26664,10 @@ var Tester = class {
|
|
|
26496
26664
|
}
|
|
26497
26665
|
async onTestCaseStart(session) {
|
|
26498
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;
|
|
26499
26671
|
if (this.isRunSession(session)) {
|
|
26500
26672
|
if (session.token) this.sessionToken = session.token;
|
|
26501
26673
|
this.obs.logger.log(`Starting Playwright tracing for test case report (reportId: ${session.reportId}, testCaseId: ${session.testCaseId})...`);
|
|
@@ -26520,6 +26692,178 @@ var Tester = class {
|
|
|
26520
26692
|
shouldAttemptAiRecovery(session) {
|
|
26521
26693
|
return !session || this.isRecordingReplaySession(session) || session.type === "run" && session.runWithAiRecovery === true;
|
|
26522
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
|
+
}
|
|
26523
26867
|
isSupportedRepairContext(decision, session) {
|
|
26524
26868
|
if (decision.kind !== "step-repair") return false;
|
|
26525
26869
|
if (this.isRecordingReplaySession(session) || !session) return decision.repairContext === "recorder-replay";
|
|
@@ -26602,12 +26946,12 @@ var Tester = class {
|
|
|
26602
26946
|
setTimeout(resolve, ms);
|
|
26603
26947
|
});
|
|
26604
26948
|
}
|
|
26605
|
-
async uploadFile(presignedUrl, filePath) {
|
|
26949
|
+
async uploadFile(presignedUrl, filePath, contentType = "application/zip") {
|
|
26606
26950
|
const fileData = readFileSync$1(filePath);
|
|
26607
26951
|
this.obs.logger.log(`Uploading file from ${filePath} (size=${fileData.byteLength} bytes) via PUT`);
|
|
26608
26952
|
const response = await fetch(presignedUrl, {
|
|
26609
26953
|
method: "PUT",
|
|
26610
|
-
headers: { "Content-Type":
|
|
26954
|
+
headers: { "Content-Type": contentType },
|
|
26611
26955
|
body: fileData
|
|
26612
26956
|
});
|
|
26613
26957
|
if (!response.ok) throw new Error(`Failed to upload file. Status: ${response.status}: ${await response.text()}`);
|
|
@@ -26637,6 +26981,146 @@ var Tester = class {
|
|
|
26637
26981
|
}
|
|
26638
26982
|
return false;
|
|
26639
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
|
+
}
|
|
26640
27124
|
async onTestCaseFail(error, session) {
|
|
26641
27125
|
this.obs.logger.error("Test case execution failed", {
|
|
26642
27126
|
session: this.summarizeSessionForExecutionLog(session),
|
|
@@ -26741,8 +27225,11 @@ var Tester = class {
|
|
|
26741
27225
|
commandId: command._id,
|
|
26742
27226
|
commandType: command.type
|
|
26743
27227
|
});
|
|
27228
|
+
let beforeScreenshotKey;
|
|
27229
|
+
if (this.screenshotsEnabled(session)) this.screenshotCommandsConsidered += 1;
|
|
26744
27230
|
try {
|
|
26745
27231
|
yield await this.onCommandStart(command, stepDefinitionId, session);
|
|
27232
|
+
beforeScreenshotKey = await this.captureCommandScreenshot(session, "before", recorderStep.step._id, command._id);
|
|
26746
27233
|
const commandResult = await this.executeCommand({
|
|
26747
27234
|
page: this.lastActivePage,
|
|
26748
27235
|
command,
|
|
@@ -26750,12 +27237,34 @@ var Tester = class {
|
|
|
26750
27237
|
});
|
|
26751
27238
|
if ((command.type === "custom" || command.type === "custom.code") && commandResult && isFailedCustomCommandResult(commandResult)) throw new Error(commandResult.error || `Custom command "${command._id}" failed.`);
|
|
26752
27239
|
this.obs.logger.log(`Finished executing command: ${command}`);
|
|
26753
|
-
|
|
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
|
+
});
|
|
26754
27245
|
} catch (error) {
|
|
26755
27246
|
const executionError = this.toExecutionError(error);
|
|
26756
27247
|
let terminalExecutionError = executionError;
|
|
26757
27248
|
let failedRecoveryMeta;
|
|
26758
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
|
+
}
|
|
26759
27268
|
if (this.shouldAttemptAiRecovery(session)) yield {
|
|
26760
27269
|
type: "recovery_status",
|
|
26761
27270
|
recoveryAttemptId: crypto.randomUUID(),
|
|
@@ -26878,7 +27387,10 @@ var Tester = class {
|
|
|
26878
27387
|
};
|
|
26879
27388
|
emittedTerminalRecoveryStatus = true;
|
|
26880
27389
|
this.obs.logger.log(`Recovered command ${command._id} with whole-step repair`);
|
|
26881
|
-
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
|
+
});
|
|
26882
27394
|
commandIndex = recorderStep.definition.commands.length - 1;
|
|
26883
27395
|
continue;
|
|
26884
27396
|
} catch (retryError) {
|
|
@@ -27017,7 +27529,10 @@ var Tester = class {
|
|
|
27017
27529
|
};
|
|
27018
27530
|
emittedTerminalRecoveryStatus = true;
|
|
27019
27531
|
this.obs.logger.log(`Recovered command ${command._id} with step repair`);
|
|
27020
|
-
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
|
+
});
|
|
27021
27536
|
if (decision.stepRepairPlan.type === "command-operations" || decision.stepRepairPlan.preserveSuffix) commandIndex = recorderStep.definition.commands.length - 1;
|
|
27022
27537
|
continue;
|
|
27023
27538
|
} catch (retryError) {
|
|
@@ -27091,7 +27606,8 @@ var Tester = class {
|
|
|
27091
27606
|
evidenceSignalNames: decision.evidenceSignalNames
|
|
27092
27607
|
};
|
|
27093
27608
|
}
|
|
27094
|
-
|
|
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));
|
|
27095
27611
|
throw terminalExecutionError;
|
|
27096
27612
|
}
|
|
27097
27613
|
}
|
|
@@ -27257,7 +27773,7 @@ var Tester = class {
|
|
|
27257
27773
|
if (!frameElementDescriptors || frameElementDescriptors.length === 0) return scope.mainFrame();
|
|
27258
27774
|
let currentFrame = scope.mainFrame();
|
|
27259
27775
|
for (const frameDescriptor of frameElementDescriptors) for (const selectorInfo of frameDescriptor.uniqueSelectors) {
|
|
27260
|
-
const { target } = this.getLocator(currentFrame, selectorInfo);
|
|
27776
|
+
const { target } = this.getLocator(currentFrame, selectorInfo, { isElementHidden: false });
|
|
27261
27777
|
try {
|
|
27262
27778
|
if (!await target.elementHandle()) throw new Error(`Could not find element for frame selector: ${JSON.stringify(selectorInfo)}`);
|
|
27263
27779
|
const selector = target._selector;
|
|
@@ -27269,9 +27785,14 @@ var Tester = class {
|
|
|
27269
27785
|
}
|
|
27270
27786
|
return currentFrame;
|
|
27271
27787
|
}
|
|
27272
|
-
getLocator(scope, uniqueSelector) {
|
|
27788
|
+
getLocator(scope, uniqueSelector, options) {
|
|
27273
27789
|
switch (uniqueSelector.type) {
|
|
27274
|
-
case "pw.selectorString":
|
|
27790
|
+
case "pw.selectorString": {
|
|
27791
|
+
const visibleSuffix = options?.isElementHidden === true ? "" : ` >> visible=true`;
|
|
27792
|
+
const finalSelector = uniqueSelector.selectorString + visibleSuffix;
|
|
27793
|
+
this.obs.logger.info(`getLocator(pw.selectorString): isElementHidden=${options?.isElementHidden}, visibleAppended=${visibleSuffix !== ""}, finalSelector=${finalSelector}`);
|
|
27794
|
+
return { target: scope.locator(finalSelector) };
|
|
27795
|
+
}
|
|
27275
27796
|
case "bvt.selectorObject": throw new Error(`Selector type "bvt.selectorObject" is not yet supported in getLocator`);
|
|
27276
27797
|
default:
|
|
27277
27798
|
const _exhaustiveCheck = uniqueSelector;
|
|
@@ -27390,15 +27911,82 @@ var Tester = class {
|
|
|
27390
27911
|
else await asserter.toHaveAttribute(assertion.name, value, assertion.options);
|
|
27391
27912
|
break;
|
|
27392
27913
|
}
|
|
27393
|
-
case "toHaveProperty":
|
|
27394
|
-
|
|
27395
|
-
|
|
27396
|
-
|
|
27397
|
-
|
|
27398
|
-
|
|
27399
|
-
|
|
27914
|
+
case "toHaveProperty": {
|
|
27915
|
+
const operator = assertion.operator ?? "equals";
|
|
27916
|
+
this.obs.logger.info(`Checking property "${assertion.name}" [operator: ${operator}]...`);
|
|
27917
|
+
switch (operator) {
|
|
27918
|
+
case "equals":
|
|
27919
|
+
if (assertion.value === void 0) await asserter.toHaveJSProperty(assertion.name, assertion.options);
|
|
27920
|
+
else {
|
|
27921
|
+
const value = getValueFromPrimitiveOrRegex(assertion.value);
|
|
27922
|
+
this.obs.logger.info(`Expected: "${value}" (timeout: ${assertion.options?.timeout ?? "default"}ms)`);
|
|
27923
|
+
await asserter.toHaveJSProperty(assertion.name, value, assertion.options);
|
|
27924
|
+
}
|
|
27925
|
+
break;
|
|
27926
|
+
case "notEquals":
|
|
27927
|
+
if (assertion.value !== void 0) {
|
|
27928
|
+
const value = getValueFromPrimitiveOrRegex(assertion.value);
|
|
27929
|
+
this.obs.logger.info(`Expected NOT: "${value}"`);
|
|
27930
|
+
await expect(locator).not.toHaveJSProperty(assertion.name, value, assertion.options);
|
|
27931
|
+
}
|
|
27932
|
+
break;
|
|
27933
|
+
case "contains":
|
|
27934
|
+
if (assertion.value !== void 0) {
|
|
27935
|
+
const raw = getValueFromPrimitiveOrRegex(assertion.value);
|
|
27936
|
+
const stringValue = typeof raw === "string" ? raw : String(raw);
|
|
27937
|
+
this.obs.logger.info(`Expected to contain: "${stringValue}"`);
|
|
27938
|
+
const propName = assertion.name;
|
|
27939
|
+
const isTextProp = propName === "textContent" || propName === "innerText";
|
|
27940
|
+
const isHtmlAttr = [
|
|
27941
|
+
"href",
|
|
27942
|
+
"src",
|
|
27943
|
+
"class",
|
|
27944
|
+
"id",
|
|
27945
|
+
"placeholder",
|
|
27946
|
+
"title",
|
|
27947
|
+
"alt",
|
|
27948
|
+
"name",
|
|
27949
|
+
"type",
|
|
27950
|
+
"action",
|
|
27951
|
+
"target",
|
|
27952
|
+
"rel",
|
|
27953
|
+
"value"
|
|
27954
|
+
].includes(propName) || propName.startsWith("data-") || propName.startsWith("aria-");
|
|
27955
|
+
if (isTextProp) await expect(locator).toContainText(stringValue, assertion.options);
|
|
27956
|
+
else if (isHtmlAttr) {
|
|
27957
|
+
const attrPattern = raw instanceof RegExp ? raw : new RegExp(stringValue.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
27958
|
+
await expect(locator).toHaveAttribute(propName, attrPattern, assertion.options);
|
|
27959
|
+
} else {
|
|
27960
|
+
const actual = await locator.evaluate((el, name) => String(el[name] ?? ""), propName);
|
|
27961
|
+
if (!actual.includes(stringValue)) throw new Error(`Property "${propName}" expected to contain "${stringValue}", but got "${actual}"`);
|
|
27962
|
+
}
|
|
27963
|
+
}
|
|
27964
|
+
break;
|
|
27965
|
+
case "lessThan":
|
|
27966
|
+
if (assertion.value !== void 0) {
|
|
27967
|
+
const threshold = Number(getValueFromPrimitiveOrRegex(assertion.value));
|
|
27968
|
+
const actual = Number(await locator.evaluate((el, name) => el[name], assertion.name));
|
|
27969
|
+
this.obs.logger.info(`Expected "${assertion.name}" (${actual}) < ${threshold}`);
|
|
27970
|
+
if (!(actual < threshold)) throw new Error(`Property "${assertion.name}" expected to be less than ${threshold}, but got ${actual}`);
|
|
27971
|
+
}
|
|
27972
|
+
break;
|
|
27973
|
+
case "greaterThan":
|
|
27974
|
+
if (assertion.value !== void 0) {
|
|
27975
|
+
const threshold = Number(getValueFromPrimitiveOrRegex(assertion.value));
|
|
27976
|
+
const actual = Number(await locator.evaluate((el, name) => el[name], assertion.name));
|
|
27977
|
+
this.obs.logger.info(`Expected "${assertion.name}" (${actual}) > ${threshold}`);
|
|
27978
|
+
if (!(actual > threshold)) throw new Error(`Property "${assertion.name}" expected to be greater than ${threshold}, but got ${actual}`);
|
|
27979
|
+
}
|
|
27980
|
+
break;
|
|
27981
|
+
case "exists": {
|
|
27982
|
+
const exists = await locator.evaluate((el, name) => el[name] !== void 0, assertion.name);
|
|
27983
|
+
this.obs.logger.info(`Expected property "${assertion.name}" to exist: ${exists}`);
|
|
27984
|
+
if (!exists) throw new Error(`Property "${assertion.name}" does not exist on element`);
|
|
27985
|
+
break;
|
|
27986
|
+
}
|
|
27400
27987
|
}
|
|
27401
27988
|
break;
|
|
27989
|
+
}
|
|
27402
27990
|
case "toHaveValue": {
|
|
27403
27991
|
const value = getValueFromStringOrRegex(assertion.value);
|
|
27404
27992
|
await asserter.toHaveValue(value, assertion.options);
|
|
@@ -27585,7 +28173,7 @@ var Tester = class {
|
|
|
27585
28173
|
const selectorInfo = target.uniqueSelectors[chosenSelectorIndex];
|
|
27586
28174
|
if (!selectorInfo) throw new Error(`No selector found for target "${target.name}" at chosenSelectorIndex ${chosenSelectorIndex}. Available selectors: ${JSON.stringify(target.uniqueSelectors)}`);
|
|
27587
28175
|
const frame = await this.getFrame(input.page, target.frames);
|
|
27588
|
-
const locator = this.getLocator(frame, selectorInfo);
|
|
28176
|
+
const locator = this.getLocator(frame, selectorInfo, target);
|
|
27589
28177
|
try {
|
|
27590
28178
|
const dataWithDefaultTimeout = {
|
|
27591
28179
|
...data,
|
|
@@ -27606,7 +28194,7 @@ var Tester = class {
|
|
|
27606
28194
|
for (let i = 0; i < target.uniqueSelectors.length; i++) {
|
|
27607
28195
|
if (i === chosenSelectorIndex) continue;
|
|
27608
28196
|
const selectorInfo = target.uniqueSelectors[i];
|
|
27609
|
-
const locator = this.getLocator(frame, selectorInfo);
|
|
28197
|
+
const locator = this.getLocator(frame, selectorInfo, target);
|
|
27610
28198
|
try {
|
|
27611
28199
|
this.obs.logger.info(`Retrying action "${data.type}" using next selector: ${JSON.stringify(selectorInfo)}`);
|
|
27612
28200
|
const dataWithModifiedTimeout = {
|
|
@@ -27637,7 +28225,7 @@ var Tester = class {
|
|
|
27637
28225
|
const chosenSelectorIndex = target.chosenSelectorIndex ?? 0;
|
|
27638
28226
|
const selectorInfo = target.uniqueSelectors[chosenSelectorIndex];
|
|
27639
28227
|
const frame = await this.getFrame(input.page, target.frames);
|
|
27640
|
-
const locator = this.getLocator(frame, selectorInfo);
|
|
28228
|
+
const locator = this.getLocator(frame, selectorInfo, target);
|
|
27641
28229
|
try {
|
|
27642
28230
|
this.obs.logger.info(`Executing assertion "${data.type}" using selector: ${JSON.stringify(selectorInfo)}`);
|
|
27643
28231
|
const dataWithDefaultTimeout = {
|
|
@@ -27657,7 +28245,7 @@ var Tester = class {
|
|
|
27657
28245
|
for (let i = 0; i < target.uniqueSelectors.length; i++) {
|
|
27658
28246
|
if (i === chosenSelectorIndex) continue;
|
|
27659
28247
|
const selectorInfo = target.uniqueSelectors[i];
|
|
27660
|
-
const locator = this.getLocator(frame, selectorInfo);
|
|
28248
|
+
const locator = this.getLocator(frame, selectorInfo, target);
|
|
27661
28249
|
const dataWithModifiedTimeout = {
|
|
27662
28250
|
...data,
|
|
27663
28251
|
options: {
|
|
@@ -27690,7 +28278,7 @@ var Tester = class {
|
|
|
27690
28278
|
const selectorInfo = target.uniqueSelectors[chosenSelectorIndex];
|
|
27691
28279
|
if (!selectorInfo) throw new Error(`No selector found for target "${target.name}" at chosenSelectorIndex ${chosenSelectorIndex}. Available selectors: ${JSON.stringify(target.uniqueSelectors)}`);
|
|
27692
28280
|
const frame = await this.getFrame(input.page, target.frames);
|
|
27693
|
-
const locator = this.getLocator(frame, selectorInfo);
|
|
28281
|
+
const locator = this.getLocator(frame, selectorInfo, target);
|
|
27694
28282
|
try {
|
|
27695
28283
|
this.obs.logger.info(`Executing extraction "${extract.type}" using selector: ${JSON.stringify(selectorInfo)}`);
|
|
27696
28284
|
await this.executeElementExtraction(locator.target, extract, storageDetails);
|
|
@@ -27703,7 +28291,7 @@ var Tester = class {
|
|
|
27703
28291
|
for (let i = 0; i < target.uniqueSelectors.length; i++) {
|
|
27704
28292
|
if (i === chosenSelectorIndex) continue;
|
|
27705
28293
|
const fallbackSelectorInfo = target.uniqueSelectors[i];
|
|
27706
|
-
const fallbackLocator = this.getLocator(frame, fallbackSelectorInfo);
|
|
28294
|
+
const fallbackLocator = this.getLocator(frame, fallbackSelectorInfo, target);
|
|
27707
28295
|
try {
|
|
27708
28296
|
this.obs.logger.info(`Retrying extraction "${extract.type}" using next selector: ${JSON.stringify(fallbackSelectorInfo)}`);
|
|
27709
28297
|
await this.executeElementExtraction(fallbackLocator.target, extract, storageDetails);
|
|
@@ -27919,14 +28507,17 @@ var Tester = class {
|
|
|
27919
28507
|
for (const recorderStep of input.recorderSteps) yield* this.executeStep(recorderStep, input, options);
|
|
27920
28508
|
await options.onFinish?.();
|
|
27921
28509
|
this.obs.logger.log("Finished executing all steps");
|
|
28510
|
+
let screenshotsAvailable = false;
|
|
27922
28511
|
if (this.isRunSession(session)) {
|
|
27923
28512
|
this.obs.logger.log(`Stopping Playwright tracing for reportId: ${session.reportId}, testCaseId: ${session.testCaseId}...`);
|
|
27924
28513
|
await this.stopTracingAndHandleTrace(session);
|
|
28514
|
+
screenshotsAvailable = await this.uploadScreenshots(session);
|
|
27925
28515
|
}
|
|
27926
28516
|
this.obs.logger.log(`Test case execution completed successfully with session: ${JSON.stringify(session)}`);
|
|
27927
28517
|
return {
|
|
27928
28518
|
type: "execution_completed",
|
|
27929
|
-
result: { type: "success" }
|
|
28519
|
+
result: { type: "success" },
|
|
28520
|
+
...screenshotsAvailable ? { screenshotsAvailable: true } : {}
|
|
27930
28521
|
};
|
|
27931
28522
|
} catch (error) {
|
|
27932
28523
|
const executionError = this.toExecutionError(error);
|
|
@@ -27936,13 +28527,16 @@ var Tester = class {
|
|
|
27936
28527
|
session: this.summarizeSessionForExecutionLog(session),
|
|
27937
28528
|
error: this.summarizeErrorForExecutionLog(executionError)
|
|
27938
28529
|
});
|
|
28530
|
+
let screenshotsAvailable = false;
|
|
27939
28531
|
if (this.isRunSession(session)) {
|
|
27940
28532
|
this.obs.logger.log(`Stopping Playwright tracing for reportId: ${session.reportId}, testCaseId: ${session.testCaseId}...`);
|
|
27941
28533
|
await this.stopTracingAndHandleTrace(session);
|
|
28534
|
+
screenshotsAvailable = await this.uploadScreenshots(session);
|
|
27942
28535
|
}
|
|
27943
28536
|
return {
|
|
27944
28537
|
type: "execution_completed",
|
|
27945
|
-
result: this.toFailureResult(executionError)
|
|
28538
|
+
result: this.toFailureResult(executionError),
|
|
28539
|
+
...screenshotsAvailable ? { screenshotsAvailable: true } : {}
|
|
27946
28540
|
};
|
|
27947
28541
|
} finally {
|
|
27948
28542
|
this.obs.metrics.count("bvt_agent.test_case.execute.completed.total", 1, {
|