@testsmith/perfornium 0.4.0 → 0.6.0
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/cli/cli.js +3 -1
- package/dist/config/types/global-config.d.ts +10 -1
- package/dist/core/test-runner.js +2 -1
- package/dist/dashboard/server.js +26 -11
- package/dist/protocols/web/handler.d.ts +4 -0
- package/dist/protocols/web/handler.js +90 -2
- package/dist/recorder/native-recorder.d.ts +1 -0
- package/dist/recorder/native-recorder.js +17 -4
- package/package.json +1 -1
package/dist/cli/cli.js
CHANGED
|
@@ -81,6 +81,7 @@ program
|
|
|
81
81
|
.option('-o, --output <file>', 'Output file for recorded scenario')
|
|
82
82
|
.option('--viewport <viewport>', 'Browser viewport size (e.g., 1920x1080)')
|
|
83
83
|
.option('--base-url <url>', 'Base URL to relativize recorded URLs')
|
|
84
|
+
.option('-b, --browser <browser>', 'Browser to use: chromium, chrome, msedge, firefox, webkit', 'chromium')
|
|
84
85
|
.option('-f, --format <format>', 'Output format: yaml, json, or typescript', 'yaml')
|
|
85
86
|
.action(async (url, options) => {
|
|
86
87
|
// Auto-determine file extension if output not specified
|
|
@@ -97,7 +98,8 @@ program
|
|
|
97
98
|
output: options.output,
|
|
98
99
|
format: options.format,
|
|
99
100
|
viewport: options.viewport,
|
|
100
|
-
baseUrl: options.baseUrl
|
|
101
|
+
baseUrl: options.baseUrl,
|
|
102
|
+
browser: options.browser
|
|
101
103
|
});
|
|
102
104
|
});
|
|
103
105
|
// ============================================
|
|
@@ -40,7 +40,7 @@ export interface FakerConfig {
|
|
|
40
40
|
seed?: number;
|
|
41
41
|
}
|
|
42
42
|
export interface BrowserConfig {
|
|
43
|
-
type: 'chromium' | 'firefox' | 'webkit';
|
|
43
|
+
type: 'chromium' | 'firefox' | 'webkit' | 'msedge' | 'chrome';
|
|
44
44
|
headless?: boolean;
|
|
45
45
|
viewport?: {
|
|
46
46
|
width: number;
|
|
@@ -50,6 +50,15 @@ export interface BrowserConfig {
|
|
|
50
50
|
base_url?: string;
|
|
51
51
|
highlight?: boolean | HighlightConfig;
|
|
52
52
|
clear_storage?: boolean | ClearStorageConfig;
|
|
53
|
+
/** Capture screenshot on test failure */
|
|
54
|
+
screenshot_on_failure?: boolean | ScreenshotConfig;
|
|
55
|
+
}
|
|
56
|
+
export interface ScreenshotConfig {
|
|
57
|
+
enabled: boolean;
|
|
58
|
+
/** Directory to save screenshots (default: 'screenshots') */
|
|
59
|
+
output_dir?: string;
|
|
60
|
+
/** Capture full page screenshot (default: true) */
|
|
61
|
+
full_page?: boolean;
|
|
53
62
|
}
|
|
54
63
|
export interface ClearStorageConfig {
|
|
55
64
|
local_storage?: boolean;
|
package/dist/core/test-runner.js
CHANGED
|
@@ -290,7 +290,8 @@ class TestRunner {
|
|
|
290
290
|
viewport: browserConfig.viewport,
|
|
291
291
|
slow_mo: browserConfig.slow_mo,
|
|
292
292
|
highlight: browserConfig.highlight,
|
|
293
|
-
clear_storage: browserConfig.clear_storage
|
|
293
|
+
clear_storage: browserConfig.clear_storage,
|
|
294
|
+
screenshot_on_failure: browserConfig.screenshot_on_failure
|
|
294
295
|
};
|
|
295
296
|
const handler = new handler_3.WebHandler(webConfig);
|
|
296
297
|
await handler.initialize();
|
package/dist/dashboard/server.js
CHANGED
|
@@ -278,9 +278,11 @@ class DashboardServer {
|
|
|
278
278
|
async handleRunTest(req, res) {
|
|
279
279
|
const body = await this.readBody(req);
|
|
280
280
|
const { testPath, options } = JSON.parse(body);
|
|
281
|
+
// Normalize the test path to use native separators for the OS
|
|
282
|
+
const normalizedTestPath = path.normalize(testPath);
|
|
281
283
|
const testId = `run-${Date.now()}`;
|
|
282
|
-
const testName = path.basename(
|
|
283
|
-
const args = ['run',
|
|
284
|
+
const testName = path.basename(normalizedTestPath).replace(/\.(yml|yaml|json)$/, '');
|
|
285
|
+
const args = ['run', normalizedTestPath];
|
|
284
286
|
if (options?.verbose)
|
|
285
287
|
args.push('-v');
|
|
286
288
|
if (options?.report)
|
|
@@ -574,18 +576,21 @@ class DashboardServer {
|
|
|
574
576
|
}
|
|
575
577
|
const stat = await fs.stat(fullPath);
|
|
576
578
|
const relativePath = path.relative(baseDir, fullPath);
|
|
577
|
-
// Detect test type from content or path
|
|
579
|
+
// Detect test type from content or path (use forward slashes for cross-platform matching)
|
|
580
|
+
const normalizedPath = fullPath.replace(/\\/g, '/');
|
|
578
581
|
let testType = 'api';
|
|
579
|
-
if (content.includes('protocol: web') || content.includes('playwright') ||
|
|
582
|
+
if (content.includes('protocol: web') || content.includes('playwright') || normalizedPath.includes('/web/')) {
|
|
580
583
|
testType = 'web';
|
|
581
584
|
}
|
|
582
|
-
else if (content.includes('protocol: http') || content.includes('protocol: https') ||
|
|
585
|
+
else if (content.includes('protocol: http') || content.includes('protocol: https') || normalizedPath.includes('/api/')) {
|
|
583
586
|
testType = 'api';
|
|
584
587
|
}
|
|
588
|
+
// Normalize paths to forward slashes for cross-platform compatibility
|
|
589
|
+
const normalizedRelativePath = relativePath.replace(/\\/g, '/');
|
|
585
590
|
tests.push({
|
|
586
591
|
name: entry.name.replace(/\.(yml|yaml|json)$/, ''),
|
|
587
|
-
path: fullPath,
|
|
588
|
-
relativePath,
|
|
592
|
+
path: fullPath, // Keep native path for filesystem operations
|
|
593
|
+
relativePath: normalizedRelativePath, // Use forward slashes for display/URLs
|
|
589
594
|
type: testType,
|
|
590
595
|
lastModified: stat.mtime.toISOString()
|
|
591
596
|
});
|
|
@@ -926,7 +931,10 @@ class DashboardServer {
|
|
|
926
931
|
<svg viewBox="0 0 128 128"><defs><linearGradient id="lg1" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#00d4ff"/><stop offset="100%" stop-color="#9c40ff"/></linearGradient><linearGradient id="lg2" x1="0%" y1="100%" x2="100%" y2="0%"><stop offset="0%" stop-color="#00d4ff"/><stop offset="100%" stop-color="#9c40ff"/></linearGradient></defs><rect x="4" y="4" width="120" height="120" rx="24" fill="#0f0f23"/><rect x="32" y="28" width="12" height="72" rx="6" fill="url(#lg1)"/><path d="M 38 28 L 62 28 C 88 28 88 60 62 60 L 38 60" fill="none" stroke="url(#lg1)" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/><rect x="76" y="68" width="8" height="32" rx="4" fill="url(#lg2)" opacity="0.9"/><rect x="88" y="54" width="8" height="46" rx="4" fill="url(#lg1)" opacity="0.9"/></svg>
|
|
927
932
|
Perfornium Dashboard
|
|
928
933
|
</div>
|
|
929
|
-
<div
|
|
934
|
+
<div style="display: flex; align-items: center; gap: 16px;">
|
|
935
|
+
<div id="workersStatus"></div>
|
|
936
|
+
<div id="connectionStatus"></div>
|
|
937
|
+
</div>
|
|
930
938
|
</div>
|
|
931
939
|
|
|
932
940
|
<div class="container">
|
|
@@ -1037,8 +1045,8 @@ class DashboardServer {
|
|
|
1037
1045
|
function initWebSocket() {
|
|
1038
1046
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
1039
1047
|
ws = new WebSocket(protocol + '//' + location.host);
|
|
1040
|
-
ws.onopen = () => { document.getElementById('connectionStatus').innerHTML = '<span class="live-badge">
|
|
1041
|
-
ws.onclose = () => { document.getElementById('connectionStatus').innerHTML = ''; setTimeout(initWebSocket, 3000); };
|
|
1048
|
+
ws.onopen = () => { document.getElementById('connectionStatus').innerHTML = '<span class="live-badge">Dashboard</span>'; };
|
|
1049
|
+
ws.onclose = () => { document.getElementById('connectionStatus').innerHTML = '<span style="color: var(--text-secondary); font-size: 12px;">Reconnecting...</span>'; setTimeout(initWebSocket, 3000); };
|
|
1042
1050
|
ws.onmessage = (e) => handleMessage(JSON.parse(e.data));
|
|
1043
1051
|
}
|
|
1044
1052
|
|
|
@@ -1067,10 +1075,17 @@ class DashboardServer {
|
|
|
1067
1075
|
workersData = await res.json();
|
|
1068
1076
|
const section = document.getElementById('workersSection');
|
|
1069
1077
|
const info = document.getElementById('workersInfo');
|
|
1078
|
+
const headerStatus = document.getElementById('workersStatus');
|
|
1070
1079
|
if (workersData.available && workersData.workers.length > 0) {
|
|
1071
1080
|
section.style.display = 'block';
|
|
1072
1081
|
const totalCapacity = workersData.workers.reduce((sum, w) => sum + (w.capacity || 0), 0);
|
|
1073
|
-
|
|
1082
|
+
const workerCount = workersData.workers.length;
|
|
1083
|
+
info.textContent = '(' + workerCount + ' workers, ' + totalCapacity + ' total capacity)';
|
|
1084
|
+
// Show workers info in header
|
|
1085
|
+
const workerNames = workersData.workers.map(w => w.name || (w.host + ':' + w.port)).join(', ');
|
|
1086
|
+
headerStatus.innerHTML = '<span style="display: inline-flex; align-items: center; gap: 6px; padding: 4px 12px; background: linear-gradient(135deg, #9c40ff 0%, #00d4ff 100%); border-radius: 20px; font-size: 12px; color: white; font-weight: 500; cursor: help;" title="' + workerNames + '"><span style="width: 8px; height: 8px; background: white; border-radius: 50%; animation: pulse 1.5s infinite;"></span>' + workerCount + ' Worker' + (workerCount > 1 ? 's' : '') + '</span>';
|
|
1087
|
+
} else {
|
|
1088
|
+
headerStatus.innerHTML = '';
|
|
1074
1089
|
}
|
|
1075
1090
|
} catch (e) { console.error('Failed to load workers:', e); }
|
|
1076
1091
|
}
|
|
@@ -47,4 +47,8 @@ export declare class WebHandler implements ProtocolHandler {
|
|
|
47
47
|
* Highlight an element before interacting with it (for debugging)
|
|
48
48
|
*/
|
|
49
49
|
private highlightElement;
|
|
50
|
+
/**
|
|
51
|
+
* Capture a screenshot when a test step fails
|
|
52
|
+
*/
|
|
53
|
+
private captureFailureScreenshot;
|
|
50
54
|
}
|
|
@@ -1,8 +1,43 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
36
|
exports.WebHandler = void 0;
|
|
4
37
|
const playwright_1 = require("playwright");
|
|
5
38
|
const logger_1 = require("../../utils/logger");
|
|
39
|
+
const path = __importStar(require("path"));
|
|
40
|
+
const fs = __importStar(require("fs"));
|
|
6
41
|
const core_web_vitals_1 = require("./core-web-vitals");
|
|
7
42
|
class WebHandler {
|
|
8
43
|
constructor(config) {
|
|
@@ -249,6 +284,23 @@ class WebHandler {
|
|
|
249
284
|
const shouldRecordError = measurableCommands.includes(action.command);
|
|
250
285
|
// Get verification metrics from error if available (attached by measureVerificationStep)
|
|
251
286
|
const verificationMetrics = error.verificationMetrics;
|
|
287
|
+
// Capture screenshot on failure if enabled
|
|
288
|
+
let screenshotPath;
|
|
289
|
+
if (this.config.screenshot_on_failure) {
|
|
290
|
+
logger_1.logger.debug(`Screenshot on failure enabled, attempting capture for VU ${context.vu_id}`);
|
|
291
|
+
try {
|
|
292
|
+
const page = this.pages.get(context.vu_id);
|
|
293
|
+
if (page && !page.isClosed()) {
|
|
294
|
+
screenshotPath = await this.captureFailureScreenshot(page, context.vu_id, action.command);
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
logger_1.logger.warn(`Cannot capture screenshot: page is ${page ? 'closed' : 'not found'} for VU ${context.vu_id}`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
catch (screenshotError) {
|
|
301
|
+
logger_1.logger.warn(`Failed to capture failure screenshot: ${screenshotError.message}`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
252
304
|
return {
|
|
253
305
|
success: false,
|
|
254
306
|
error: error.message,
|
|
@@ -259,7 +311,8 @@ class WebHandler {
|
|
|
259
311
|
command: action.command,
|
|
260
312
|
error_type: error.constructor.name,
|
|
261
313
|
error_stack: error.stack?.split('\n').slice(0, 3).join('; '),
|
|
262
|
-
verification_metrics: verificationMetrics
|
|
314
|
+
verification_metrics: verificationMetrics,
|
|
315
|
+
screenshot: screenshotPath
|
|
263
316
|
}
|
|
264
317
|
};
|
|
265
318
|
}
|
|
@@ -500,6 +553,12 @@ class WebHandler {
|
|
|
500
553
|
case 'chromium':
|
|
501
554
|
browser = await playwright_1.chromium.launch(launchOptions);
|
|
502
555
|
break;
|
|
556
|
+
case 'chrome':
|
|
557
|
+
browser = await playwright_1.chromium.launch({ ...launchOptions, channel: 'chrome' });
|
|
558
|
+
break;
|
|
559
|
+
case 'msedge':
|
|
560
|
+
browser = await playwright_1.chromium.launch({ ...launchOptions, channel: 'msedge' });
|
|
561
|
+
break;
|
|
503
562
|
case 'firefox':
|
|
504
563
|
browser = await playwright_1.firefox.launch(launchOptions);
|
|
505
564
|
break;
|
|
@@ -507,7 +566,7 @@ class WebHandler {
|
|
|
507
566
|
browser = await playwright_1.webkit.launch(launchOptions);
|
|
508
567
|
break;
|
|
509
568
|
default:
|
|
510
|
-
throw new Error(`Unsupported browser type: ${browserType}`);
|
|
569
|
+
throw new Error(`Unsupported browser type: ${browserType}. Supported: chromium, chrome, msedge, firefox, webkit`);
|
|
511
570
|
}
|
|
512
571
|
logger_1.logger.debug(`VU ${vuId}: Launched enhanced ${browserType} browser with Web Vitals support`);
|
|
513
572
|
return browser;
|
|
@@ -737,5 +796,34 @@ class WebHandler {
|
|
|
737
796
|
logger_1.logger.debug(`Failed to highlight element ${selector}:`, error);
|
|
738
797
|
}
|
|
739
798
|
}
|
|
799
|
+
/**
|
|
800
|
+
* Capture a screenshot when a test step fails
|
|
801
|
+
*/
|
|
802
|
+
async captureFailureScreenshot(page, vuId, command) {
|
|
803
|
+
const screenshotConfig = this.config.screenshot_on_failure;
|
|
804
|
+
// Parse config - can be boolean or ScreenshotConfig object
|
|
805
|
+
let outputDir = 'screenshots';
|
|
806
|
+
let fullPage = true;
|
|
807
|
+
if (typeof screenshotConfig === 'object') {
|
|
808
|
+
outputDir = screenshotConfig.output_dir || 'screenshots';
|
|
809
|
+
fullPage = screenshotConfig.full_page !== false;
|
|
810
|
+
}
|
|
811
|
+
// Ensure directory exists
|
|
812
|
+
if (!fs.existsSync(outputDir)) {
|
|
813
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
814
|
+
}
|
|
815
|
+
// Generate unique filename with timestamp
|
|
816
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
817
|
+
const sanitizedCommand = command.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
818
|
+
const filename = `failure_vu${vuId}_${sanitizedCommand}_${timestamp}.png`;
|
|
819
|
+
const screenshotPath = path.join(outputDir, filename);
|
|
820
|
+
// Capture the screenshot
|
|
821
|
+
await page.screenshot({
|
|
822
|
+
path: screenshotPath,
|
|
823
|
+
fullPage
|
|
824
|
+
});
|
|
825
|
+
logger_1.logger.info(`📸 Screenshot captured: ${screenshotPath}`);
|
|
826
|
+
return screenshotPath;
|
|
827
|
+
}
|
|
740
828
|
}
|
|
741
829
|
exports.WebHandler = WebHandler;
|
|
@@ -78,6 +78,18 @@ async function startNativeRecording(url, options = {}) {
|
|
|
78
78
|
logger_1.logger.info('');
|
|
79
79
|
// Build playwright codegen arguments
|
|
80
80
|
const args = ['codegen', '--output', tempFile];
|
|
81
|
+
const browserType = options.browser || 'chromium';
|
|
82
|
+
// Add browser option - playwright codegen uses --browser for channel selection
|
|
83
|
+
if (browserType === 'msedge') {
|
|
84
|
+
args.push('--browser', 'chromium', '--channel', 'msedge');
|
|
85
|
+
}
|
|
86
|
+
else if (browserType === 'chrome') {
|
|
87
|
+
args.push('--browser', 'chromium', '--channel', 'chrome');
|
|
88
|
+
}
|
|
89
|
+
else if (browserType === 'firefox' || browserType === 'webkit') {
|
|
90
|
+
args.push('--browser', browserType);
|
|
91
|
+
}
|
|
92
|
+
// chromium is the default, no need to specify
|
|
81
93
|
if (options.viewport) {
|
|
82
94
|
const [width, height] = options.viewport.split('x');
|
|
83
95
|
args.push('--viewport-size', `${width},${height}`);
|
|
@@ -196,7 +208,7 @@ async function startNativeRecording(url, options = {}) {
|
|
|
196
208
|
const outputFile = options.output || getDefaultOutputFile(format);
|
|
197
209
|
const outputPath = path.resolve(outputFile);
|
|
198
210
|
logger_1.logger.info(`\nConverting recording to ${format.toUpperCase()} format...`);
|
|
199
|
-
const converter = new PlaywrightToPerfornium(playwrightCode, savedWaitPoints, url, options.baseUrl);
|
|
211
|
+
const converter = new PlaywrightToPerfornium(playwrightCode, savedWaitPoints, url, options.baseUrl, options.browser);
|
|
200
212
|
switch (format) {
|
|
201
213
|
case 'typescript':
|
|
202
214
|
fs.writeFileSync(outputPath, converter.toTypeScript());
|
|
@@ -236,10 +248,11 @@ function getDefaultOutputFile(format) {
|
|
|
236
248
|
* Converts Playwright codegen output to Perfornium formats
|
|
237
249
|
*/
|
|
238
250
|
class PlaywrightToPerfornium {
|
|
239
|
-
constructor(playwrightCode, waitPoints, startUrl, baseUrl) {
|
|
251
|
+
constructor(playwrightCode, waitPoints, startUrl, baseUrl, browserType) {
|
|
240
252
|
this.actions = [];
|
|
241
253
|
this.waitPoints = waitPoints;
|
|
242
254
|
this.baseUrl = baseUrl || new URL(startUrl).origin;
|
|
255
|
+
this.browserType = browserType || 'chromium';
|
|
243
256
|
this.parsePlaywrightCode(playwrightCode);
|
|
244
257
|
}
|
|
245
258
|
parsePlaywrightCode(code) {
|
|
@@ -416,7 +429,7 @@ class PlaywrightToPerfornium {
|
|
|
416
429
|
description: `Recorded on ${new Date().toISOString()} using Playwright codegen`,
|
|
417
430
|
global: {
|
|
418
431
|
base_url: this.baseUrl,
|
|
419
|
-
browser: { type:
|
|
432
|
+
browser: { type: this.browserType, headless: false },
|
|
420
433
|
think_time: '1-3'
|
|
421
434
|
},
|
|
422
435
|
load: {
|
|
@@ -466,7 +479,7 @@ class PlaywrightToPerfornium {
|
|
|
466
479
|
|
|
467
480
|
const testConfig = test('Recorded Web Scenario')
|
|
468
481
|
.baseUrl('${this.baseUrl}')
|
|
469
|
-
.withBrowser('
|
|
482
|
+
.withBrowser('${this.browserType}', {
|
|
470
483
|
headless: process.env.HEADLESS !== 'false',
|
|
471
484
|
viewport: { width: 1920, height: 1080 }
|
|
472
485
|
})
|