@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 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;
@@ -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();
@@ -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(testPath).replace(/\.(yml|yaml|json)$/, '');
283
- const args = ['run', testPath];
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') || fullPath.includes('/web/')) {
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') || fullPath.includes('/api/')) {
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 id="connectionStatus"></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">Connected</span>'; };
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
- info.textContent = '(' + workersData.workers.length + ' workers, ' + totalCapacity + ' total capacity)';
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;
@@ -4,6 +4,7 @@ export interface NativeRecorderOptions {
4
4
  viewport?: string;
5
5
  baseUrl?: string;
6
6
  device?: string;
7
+ browser?: 'chromium' | 'chrome' | 'msedge' | 'firefox' | 'webkit';
7
8
  }
8
9
  /**
9
10
  * Native Playwright Recorder
@@ -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: 'chromium', headless: false },
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('chromium', {
482
+ .withBrowser('${this.browserType}', {
470
483
  headless: process.env.HEADLESS !== 'false',
471
484
  viewport: { width: 1920, height: 1080 }
472
485
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testsmith/perfornium",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Flexible performance testing framework for REST, SOAP, and web applications",
5
5
  "author": "TestSmith",
6
6
  "license": "MIT",