@aexol/spectral 0.9.85 → 0.9.86
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/dist/extensions/browser/browser-service.d.ts +1 -0
- package/dist/extensions/browser/browser-service.d.ts.map +1 -1
- package/dist/extensions/browser/browser-service.js +47 -40
- package/dist/extensions/browser/scenario/scenario-tool.d.ts.map +1 -1
- package/dist/extensions/browser/scenario/scenario-tool.js +198 -173
- package/package.json +1 -1
|
@@ -23,6 +23,7 @@ export declare class BrowserService {
|
|
|
23
23
|
private nextNetworkIndex;
|
|
24
24
|
private pendingDialogs;
|
|
25
25
|
private _recording;
|
|
26
|
+
private closePromise;
|
|
26
27
|
constructor(config?: Partial<BrowserConfig>);
|
|
27
28
|
static getInstance(config?: Partial<BrowserConfig>): BrowserService;
|
|
28
29
|
static resetInstance(): void;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"browser-service.d.ts","sourceRoot":"","sources":["../../../src/extensions/browser/browser-service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,OAAO,EAAE,cAAc,EAAE,IAAI,EAAkB,MAAM,YAAY,CAAC;AAChF,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAsBjD,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACxC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;CACnB;
|
|
1
|
+
{"version":3,"file":"browser-service.d.ts","sourceRoot":"","sources":["../../../src/extensions/browser/browser-service.ts"],"names":[],"mappings":"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"}
|
|
@@ -25,6 +25,7 @@ let unregisterInstanceCleanup = null;
|
|
|
25
25
|
// that closeAll() can still await it even though cleanupSessionResources()
|
|
26
26
|
// cannot itself await (it is invoked synchronously from session.dispose()).
|
|
27
27
|
let pendingClose = null;
|
|
28
|
+
let closeAllPromise = null;
|
|
28
29
|
export class BrowserService {
|
|
29
30
|
browser = null;
|
|
30
31
|
context = null;
|
|
@@ -36,6 +37,7 @@ export class BrowserService {
|
|
|
36
37
|
nextNetworkIndex = 0;
|
|
37
38
|
pendingDialogs = [];
|
|
38
39
|
_recording = null;
|
|
40
|
+
closePromise = null;
|
|
39
41
|
constructor(config) {
|
|
40
42
|
this.config = { ...getBrowserConfig(), ...config };
|
|
41
43
|
}
|
|
@@ -68,6 +70,7 @@ export class BrowserService {
|
|
|
68
70
|
unregisterInstanceCleanup = null;
|
|
69
71
|
instance = null;
|
|
70
72
|
pendingClose = null;
|
|
73
|
+
closeAllPromise = null;
|
|
71
74
|
}
|
|
72
75
|
/**
|
|
73
76
|
* Close the active browser singleton (if any) and await completion. Unlike
|
|
@@ -77,17 +80,21 @@ export class BrowserService {
|
|
|
77
80
|
* server shutdown so the Chromium process is actually torn down before exit.
|
|
78
81
|
*/
|
|
79
82
|
static async closeAll() {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
83
|
+
if (closeAllPromise)
|
|
84
|
+
return closeAllPromise;
|
|
85
|
+
closeAllPromise = (async () => {
|
|
86
|
+
const current = instance;
|
|
87
|
+
instance = null;
|
|
88
|
+
unregisterInstanceCleanup?.();
|
|
89
|
+
unregisterInstanceCleanup = null;
|
|
90
|
+
if (current)
|
|
91
|
+
await current.close().catch(() => { });
|
|
92
|
+
if (pendingClose)
|
|
93
|
+
await pendingClose;
|
|
94
|
+
})().finally(() => {
|
|
95
|
+
closeAllPromise = null;
|
|
96
|
+
});
|
|
97
|
+
return closeAllPromise;
|
|
91
98
|
}
|
|
92
99
|
static isAvailable() {
|
|
93
100
|
try {
|
|
@@ -322,39 +329,39 @@ export class BrowserService {
|
|
|
322
329
|
this.nextNetworkIndex = 0;
|
|
323
330
|
}
|
|
324
331
|
async close() {
|
|
325
|
-
//
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
// Close the browser context.
|
|
335
|
-
if (this.context) {
|
|
336
|
-
try {
|
|
337
|
-
await Promise.race([
|
|
338
|
-
this.context.close(),
|
|
339
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error("context.close timed out")), 5_000)),
|
|
340
|
-
]);
|
|
332
|
+
// Share one close operation with concurrent callers; cleanup is fully awaited.
|
|
333
|
+
if (this.closePromise)
|
|
334
|
+
return this.closePromise;
|
|
335
|
+
this.closePromise = (async () => {
|
|
336
|
+
for (const page of this.pages) {
|
|
337
|
+
try {
|
|
338
|
+
await page.close();
|
|
339
|
+
}
|
|
340
|
+
catch { /* already closed */ }
|
|
341
341
|
}
|
|
342
|
-
|
|
342
|
+
this.pages = [];
|
|
343
|
+
this.activePageIndex = 0;
|
|
344
|
+
const context = this.context;
|
|
343
345
|
this.context = null;
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
await Promise.race([
|
|
350
|
-
this.browser.close(),
|
|
351
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error("browser.close timed out")), 5_000)),
|
|
352
|
-
]);
|
|
346
|
+
if (context) {
|
|
347
|
+
try {
|
|
348
|
+
await context.close();
|
|
349
|
+
}
|
|
350
|
+
catch { /* already closed */ }
|
|
353
351
|
}
|
|
354
|
-
|
|
352
|
+
const browser = this.browser;
|
|
355
353
|
this.browser = null;
|
|
356
|
-
|
|
357
|
-
|
|
354
|
+
if (browser) {
|
|
355
|
+
try {
|
|
356
|
+
await browser.close();
|
|
357
|
+
}
|
|
358
|
+
catch { /* already closed */ }
|
|
359
|
+
}
|
|
360
|
+
this._recording = null;
|
|
361
|
+
})().finally(() => {
|
|
362
|
+
this.closePromise = null;
|
|
363
|
+
});
|
|
364
|
+
return this.closePromise;
|
|
358
365
|
}
|
|
359
366
|
// ── Recording ──────────────────────────────────────────────
|
|
360
367
|
async startRecording(opts) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scenario-tool.d.ts","sourceRoot":"","sources":["../../../../src/extensions/browser/scenario/scenario-tool.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAkB,MAAM,oCAAoC,CAAC;AACvF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAsC5D,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,YAAY,EAAE,EAAE,EAAE,cAAc,GAAG,IAAI,
|
|
1
|
+
{"version":3,"file":"scenario-tool.d.ts","sourceRoot":"","sources":["../../../../src/extensions/browser/scenario/scenario-tool.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAkB,MAAM,oCAAoC,CAAC;AACvF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAsC5D,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,YAAY,EAAE,EAAE,EAAE,cAAc,GAAG,IAAI,CA+VjF"}
|
|
@@ -75,6 +75,10 @@ export function registerScenarioTools(ext, bs) {
|
|
|
75
75
|
type: "number",
|
|
76
76
|
description: "Default pause (ms) after each step for readable video. Default: 800. Set 0 to disable.",
|
|
77
77
|
},
|
|
78
|
+
keepBrowserOpen: {
|
|
79
|
+
type: "boolean",
|
|
80
|
+
description: "Keep the browser open after the scenario. Default: false.",
|
|
81
|
+
},
|
|
78
82
|
},
|
|
79
83
|
required: ["url"],
|
|
80
84
|
},
|
|
@@ -128,207 +132,228 @@ export function registerScenarioTools(ext, bs) {
|
|
|
128
132
|
const record = p.record === true;
|
|
129
133
|
const stepDelay = typeof p.stepDelayMs === "number" ? p.stepDelayMs : 800;
|
|
130
134
|
const results = [];
|
|
131
|
-
|
|
135
|
+
const keepBrowserOpen = p.keepBrowserOpen === true;
|
|
132
136
|
let videoPath;
|
|
133
137
|
let durationSeconds;
|
|
134
|
-
if (record) {
|
|
135
|
-
try {
|
|
136
|
-
const rec = await bs.startRecording({ filename: p.filename });
|
|
137
|
-
videoPath = rec.outputPath;
|
|
138
|
-
}
|
|
139
|
-
catch (err) {
|
|
140
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
141
|
-
return {
|
|
142
|
-
content: [{ type: "text", text: `Failed to start recording: ${msg}` }],
|
|
143
|
-
details: { isError: true },
|
|
144
|
-
};
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
// Navigate to the start URL.
|
|
148
138
|
try {
|
|
149
|
-
|
|
150
|
-
await sleep(stepDelay);
|
|
151
|
-
}
|
|
152
|
-
catch (err) {
|
|
153
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
154
|
-
// If we can't even navigate, fail everything.
|
|
155
|
-
for (let i = 0; i < steps.length; i++) {
|
|
156
|
-
results.push({
|
|
157
|
-
step: i + 1,
|
|
158
|
-
action: steps[i].action,
|
|
159
|
-
status: "skipped",
|
|
160
|
-
detail: `Blocked: navigation failed (${msg})`,
|
|
161
|
-
durationMs: 0,
|
|
162
|
-
});
|
|
163
|
-
}
|
|
139
|
+
// Optional recording start.
|
|
164
140
|
if (record) {
|
|
165
141
|
try {
|
|
166
|
-
const
|
|
167
|
-
videoPath =
|
|
168
|
-
|
|
142
|
+
const rec = await bs.startRecording({ filename: p.filename });
|
|
143
|
+
videoPath = rec.outputPath;
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
147
|
+
return {
|
|
148
|
+
content: [{ type: "text", text: `Failed to start recording: ${msg}` }],
|
|
149
|
+
details: { isError: true },
|
|
150
|
+
};
|
|
169
151
|
}
|
|
170
|
-
catch { /* ignore */ }
|
|
171
152
|
}
|
|
172
|
-
|
|
173
|
-
}
|
|
174
|
-
// Execute steps.
|
|
175
|
-
for (let i = 0; i < steps.length; i++) {
|
|
176
|
-
const step = steps[i];
|
|
177
|
-
const start = Date.now();
|
|
178
|
-
let status = "pass";
|
|
179
|
-
let detail = "";
|
|
153
|
+
// Navigate to the start URL.
|
|
180
154
|
try {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
detail = "No target";
|
|
202
|
-
}
|
|
203
|
-
break;
|
|
204
|
-
}
|
|
205
|
-
case "hover": {
|
|
206
|
-
if (step.target) {
|
|
207
|
-
await page.hover(step.target, { timeout: bs.getConfig().actionTimeout || 5000 });
|
|
208
|
-
detail = `Hovered ${step.target}`;
|
|
209
|
-
}
|
|
210
|
-
else {
|
|
211
|
-
status = "skipped";
|
|
212
|
-
detail = "No target";
|
|
213
|
-
}
|
|
214
|
-
break;
|
|
215
|
-
}
|
|
216
|
-
case "type": {
|
|
217
|
-
if (step.target && step.value !== undefined) {
|
|
218
|
-
await page.fill(step.target, step.value, { timeout: bs.getConfig().actionTimeout || 5000 });
|
|
219
|
-
detail = `Typed into ${step.target}`;
|
|
220
|
-
}
|
|
221
|
-
else {
|
|
222
|
-
status = "skipped";
|
|
223
|
-
detail = "Missing target or value";
|
|
224
|
-
}
|
|
225
|
-
break;
|
|
155
|
+
await bs.navigate(p.url);
|
|
156
|
+
await sleep(stepDelay);
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
160
|
+
// If we can't even navigate, fail everything.
|
|
161
|
+
for (let i = 0; i < steps.length; i++) {
|
|
162
|
+
results.push({
|
|
163
|
+
step: i + 1,
|
|
164
|
+
action: steps[i].action,
|
|
165
|
+
status: "skipped",
|
|
166
|
+
detail: `Blocked: navigation failed (${msg})`,
|
|
167
|
+
durationMs: 0,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
if (record) {
|
|
171
|
+
try {
|
|
172
|
+
const stop = await bs.stopRecording();
|
|
173
|
+
videoPath = stop.outputPath;
|
|
174
|
+
durationSeconds = stop.durationSeconds;
|
|
226
175
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
176
|
+
catch { /* ignore */ }
|
|
177
|
+
}
|
|
178
|
+
return finalize(results, videoPath, durationSeconds, false);
|
|
179
|
+
}
|
|
180
|
+
// Execute steps.
|
|
181
|
+
for (let i = 0; i < steps.length; i++) {
|
|
182
|
+
const step = steps[i];
|
|
183
|
+
const start = Date.now();
|
|
184
|
+
let status = "pass";
|
|
185
|
+
let detail = "";
|
|
186
|
+
try {
|
|
187
|
+
const page = bs.getActivePage();
|
|
188
|
+
switch (step.action) {
|
|
189
|
+
case "navigate": {
|
|
190
|
+
if (step.target) {
|
|
191
|
+
await bs.navigate(step.target);
|
|
192
|
+
detail = `Navigated to ${step.target}`;
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
status = "skipped";
|
|
196
|
+
detail = "No target URL";
|
|
197
|
+
}
|
|
198
|
+
break;
|
|
232
199
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
200
|
+
case "click": {
|
|
201
|
+
if (step.target) {
|
|
202
|
+
await page.click(step.target, { timeout: bs.getConfig().actionTimeout || 5000 });
|
|
203
|
+
detail = `Clicked ${step.target}`;
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
status = "skipped";
|
|
207
|
+
detail = "No target";
|
|
208
|
+
}
|
|
209
|
+
break;
|
|
236
210
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
211
|
+
case "hover": {
|
|
212
|
+
if (step.target) {
|
|
213
|
+
await page.hover(step.target, { timeout: bs.getConfig().actionTimeout || 5000 });
|
|
214
|
+
detail = `Hovered ${step.target}`;
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
status = "skipped";
|
|
218
|
+
detail = "No target";
|
|
219
|
+
}
|
|
220
|
+
break;
|
|
243
221
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
222
|
+
case "type": {
|
|
223
|
+
if (step.target && step.value !== undefined) {
|
|
224
|
+
await page.fill(step.target, step.value, { timeout: bs.getConfig().actionTimeout || 5000 });
|
|
225
|
+
detail = `Typed into ${step.target}`;
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
status = "skipped";
|
|
229
|
+
detail = "Missing target or value";
|
|
230
|
+
}
|
|
231
|
+
break;
|
|
247
232
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
233
|
+
case "press": {
|
|
234
|
+
const key = step.value ?? step.target;
|
|
235
|
+
if (key) {
|
|
236
|
+
await page.keyboard.press(key);
|
|
237
|
+
detail = `Pressed ${key}`;
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
status = "skipped";
|
|
241
|
+
detail = "No key";
|
|
242
|
+
}
|
|
243
|
+
break;
|
|
255
244
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
245
|
+
case "select": {
|
|
246
|
+
if (step.target && step.value !== undefined) {
|
|
247
|
+
await page.selectOption(step.target, step.value);
|
|
248
|
+
detail = `Selected ${step.value} in ${step.target}`;
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
status = "skipped";
|
|
252
|
+
detail = "Missing target or value";
|
|
253
|
+
}
|
|
254
|
+
break;
|
|
259
255
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
256
|
+
case "wait": {
|
|
257
|
+
const ms = step.waitMs ?? (step.value && /^\d+$/.test(step.value) ? Number(step.value) : null);
|
|
258
|
+
if (ms !== null) {
|
|
259
|
+
await page.waitForTimeout(ms);
|
|
260
|
+
detail = `Waited ${ms}ms`;
|
|
261
|
+
}
|
|
262
|
+
else if (step.value) {
|
|
263
|
+
await page.waitForSelector(`text=${step.value}`, { timeout: 30_000 });
|
|
264
|
+
detail = `Waited for text "${step.value}"`;
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
await page.waitForTimeout(1000);
|
|
268
|
+
detail = "Waited 1000ms (default)";
|
|
269
|
+
}
|
|
270
|
+
break;
|
|
263
271
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
detail = `Screenshot: ${filepath}`;
|
|
274
|
-
break;
|
|
275
|
-
}
|
|
276
|
-
case "evaluate": {
|
|
277
|
-
const expr = step.target ?? step.value;
|
|
278
|
-
if (expr) {
|
|
279
|
-
const res = await page.evaluate(expr);
|
|
280
|
-
detail = `Evaluated: ${expr}${res !== undefined ? ` → ${JSON.stringify(res).slice(0, 120)}` : ""}`;
|
|
272
|
+
case "screenshot": {
|
|
273
|
+
const config = bs.getConfig();
|
|
274
|
+
const { mkdirSync } = await import("node:fs");
|
|
275
|
+
mkdirSync(config.outputDir, { recursive: true });
|
|
276
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
277
|
+
const filepath = `${config.outputDir}/scenario-step-${i + 1}-${ts}.png`;
|
|
278
|
+
await page.screenshot({ path: filepath });
|
|
279
|
+
detail = `Screenshot: ${filepath}`;
|
|
280
|
+
break;
|
|
281
281
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
282
|
+
case "evaluate": {
|
|
283
|
+
const expr = step.target ?? step.value;
|
|
284
|
+
if (expr) {
|
|
285
|
+
const res = await page.evaluate(expr);
|
|
286
|
+
detail = `Evaluated: ${expr}${res !== undefined ? ` → ${JSON.stringify(res).slice(0, 120)}` : ""}`;
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
status = "skipped";
|
|
290
|
+
detail = "No expression";
|
|
291
|
+
}
|
|
292
|
+
break;
|
|
285
293
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
294
|
+
case "fill_form": {
|
|
295
|
+
if (step.fields && step.fields.length > 0) {
|
|
296
|
+
for (const f of step.fields) {
|
|
297
|
+
await page.fill(f.name, f.value, { timeout: bs.getConfig().actionTimeout || 5000 });
|
|
298
|
+
}
|
|
299
|
+
detail = `Filled ${step.fields.length} field(s)`;
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
status = "skipped";
|
|
303
|
+
detail = "No fields";
|
|
292
304
|
}
|
|
293
|
-
|
|
305
|
+
break;
|
|
294
306
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
detail =
|
|
307
|
+
case "note":
|
|
308
|
+
default: {
|
|
309
|
+
detail = `Note: ${step.raw}`;
|
|
310
|
+
break;
|
|
298
311
|
}
|
|
299
|
-
break;
|
|
300
|
-
}
|
|
301
|
-
case "note":
|
|
302
|
-
default: {
|
|
303
|
-
detail = `Note: ${step.raw}`;
|
|
304
|
-
break;
|
|
305
312
|
}
|
|
306
313
|
}
|
|
314
|
+
catch (err) {
|
|
315
|
+
status = "fail";
|
|
316
|
+
detail = err instanceof Error ? err.message : String(err);
|
|
317
|
+
}
|
|
318
|
+
const durationMs = Date.now() - start;
|
|
319
|
+
results.push({ step: i + 1, action: step.action, status, detail, durationMs });
|
|
320
|
+
// Per-step pause for readable video (skip for wait actions already paused).
|
|
321
|
+
if (step.action !== "wait") {
|
|
322
|
+
await sleep(step.waitMs ?? stepDelay);
|
|
323
|
+
}
|
|
307
324
|
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
325
|
+
// Optional recording stop.
|
|
326
|
+
if (record) {
|
|
327
|
+
try {
|
|
328
|
+
const stop = await bs.stopRecording();
|
|
329
|
+
videoPath = stop.outputPath;
|
|
330
|
+
durationSeconds = stop.durationSeconds;
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
334
|
+
return finalize(results, videoPath, durationSeconds, true, `Recording stop failed: ${msg}`);
|
|
335
|
+
}
|
|
317
336
|
}
|
|
337
|
+
return finalize(results, videoPath, durationSeconds, true);
|
|
318
338
|
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
339
|
+
finally {
|
|
340
|
+
// Finalize recording before closing the browser so Playwright can flush
|
|
341
|
+
// the video. The explicit stop paths above handle normal recording;
|
|
342
|
+
// this also covers cancellation and unexpected step errors.
|
|
343
|
+
if (!keepBrowserOpen) {
|
|
344
|
+
if (record && bs.getRecordStatus().active) {
|
|
345
|
+
try {
|
|
346
|
+
const stop = await bs.stopRecording();
|
|
347
|
+
videoPath = stop.outputPath;
|
|
348
|
+
durationSeconds = stop.durationSeconds;
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
// Preserve the original scenario error/result while still closing.
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
await bs.close();
|
|
329
355
|
}
|
|
330
356
|
}
|
|
331
|
-
return finalize(results, videoPath, durationSeconds, true);
|
|
332
357
|
},
|
|
333
358
|
};
|
|
334
359
|
try {
|