@testsmith/perfornium 0.6.5 → 0.6.6

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
@@ -10,6 +10,7 @@ const init_1 = require("./commands/init");
10
10
  const mock_1 = require("./commands/mock");
11
11
  const dashboard_1 = require("./commands/dashboard");
12
12
  const native_recorder_1 = require("../recorder/native-recorder");
13
+ const continue_recorder_1 = require("../recorder/continue-recorder");
13
14
  const distributed_1 = require("./commands/distributed");
14
15
  // Add new import commands
15
16
  const import_1 = require("./commands/import");
@@ -77,13 +78,27 @@ program
77
78
  program
78
79
  .command('record')
79
80
  .description('Record web interactions for test creation (Ctrl+W to add wait points)')
80
- .argument('<url>', 'Starting URL for recording')
81
+ .argument('[url]', 'Starting URL for recording (required unless using --continue)')
82
+ .option('-c, --continue <file>', 'Continue recording from last step of existing scenario file')
81
83
  .option('-o, --output <file>', 'Output file for recorded scenario')
82
84
  .option('--viewport <viewport>', 'Browser viewport size (e.g., 1920x1080)')
83
85
  .option('--base-url <url>', 'Base URL to relativize recorded URLs')
84
86
  .option('-b, --browser <browser>', 'Browser to use: chromium, chrome, msedge, firefox, webkit', 'chromium')
85
87
  .option('-f, --format <format>', 'Output format: yaml, json, or typescript', 'yaml')
86
88
  .action(async (url, options) => {
89
+ // Handle --continue mode
90
+ if (options.continue) {
91
+ await (0, continue_recorder_1.startContinueRecording)(options.continue, {
92
+ format: options.format,
93
+ browser: options.browser
94
+ });
95
+ return;
96
+ }
97
+ // Normal recording mode requires URL
98
+ if (!url) {
99
+ console.error('Error: URL is required for recording. Use --continue <file> to continue from an existing scenario.');
100
+ process.exit(1);
101
+ }
87
102
  // Auto-determine file extension if output not specified
88
103
  // Save to tests/web directory by default
89
104
  if (!options.output) {
@@ -97,7 +97,7 @@ export interface RendezvousStep extends BaseStep {
97
97
  export interface WebAction {
98
98
  name?: string;
99
99
  expected_text?: string;
100
- command: 'goto' | 'click' | 'fill' | 'press' | 'select' | 'hover' | 'screenshot' | 'wait_for_selector' | 'wait_for_text' | 'verify_text' | 'verify_contains' | 'verify_not_exists' | 'verify_exists' | 'verify_visible' | 'evaluate' | 'measure_web_vitals' | 'measure_verification' | 'performance_audit' | 'accessibility_audit' | 'wait_for_load_state' | 'network_idle' | 'dom_ready';
100
+ command: 'goto' | 'click' | 'fill' | 'press' | 'select' | 'hover' | 'screenshot' | 'wait_for_selector' | 'wait_for_text' | 'verify_text' | 'verify_contains' | 'verify_not_exists' | 'verify_exists' | 'verify_visible' | 'verify_value' | 'evaluate' | 'measure_web_vitals' | 'measure_verification' | 'performance_audit' | 'accessibility_audit' | 'wait_for_load_state' | 'network_idle' | 'dom_ready';
101
101
  selector?: string;
102
102
  url?: string;
103
103
  value?: string | string[];
@@ -8,4 +8,5 @@ export declare class VerificationCommands {
8
8
  handleVerifyText(page: Page, action: WebAction): Promise<CommandResult>;
9
9
  handleVerifyContains(page: Page, action: WebAction): Promise<CommandResult>;
10
10
  handleVerifyNotExists(page: Page, action: WebAction): Promise<CommandResult>;
11
+ handleVerifyValue(page: Page, action: WebAction): Promise<CommandResult>;
11
12
  }
@@ -94,5 +94,25 @@ class VerificationCommands {
94
94
  found_elements: 0
95
95
  };
96
96
  }
97
+ async handleVerifyValue(page, action) {
98
+ await page.waitForSelector(action.selector, {
99
+ state: 'attached',
100
+ timeout: action.timeout || 30000
101
+ });
102
+ const locator = page.locator(action.selector);
103
+ const actualValue = await locator.inputValue();
104
+ const expectedValue = action.value;
105
+ if (actualValue !== expectedValue) {
106
+ throw new Error(`Verification failed: Element "${action.selector}" value "${actualValue}" does not match expected value "${expectedValue}"${action.name ? ` (${action.name})` : ''}`);
107
+ }
108
+ return {
109
+ verified: 'value',
110
+ selector: action.selector,
111
+ name: action.name,
112
+ expected_value: expectedValue,
113
+ actual_value: actualValue,
114
+ value_match: true
115
+ };
116
+ }
97
117
  }
98
118
  exports.VerificationCommands = VerificationCommands;
@@ -86,6 +86,9 @@ class WebHandler {
86
86
  case 'verify_not_exists':
87
87
  ({ result, verificationMetrics } = await this.executeVerification(page, action, () => this.verificationCommands.handleVerifyNotExists(page, action)));
88
88
  break;
89
+ case 'verify_value':
90
+ ({ result, verificationMetrics } = await this.executeVerification(page, action, () => this.verificationCommands.handleVerifyValue(page, action)));
91
+ break;
89
92
  case 'measure_web_vitals':
90
93
  result = await this.measurementCommands.handleMeasureWebVitals(page, action);
91
94
  webVitals = result.web_vitals;
@@ -135,7 +138,7 @@ class WebHandler {
135
138
  const responseTime = result?.action_time || verificationMetrics?.duration;
136
139
  // Check for measurable commands
137
140
  const measurableCommands = [
138
- 'verify_exists', 'verify_visible', 'verify_text', 'verify_contains', 'verify_not_exists',
141
+ 'verify_exists', 'verify_visible', 'verify_text', 'verify_contains', 'verify_not_exists', 'verify_value',
139
142
  'wait_for_selector', 'wait_for_text',
140
143
  'measure_web_vitals', 'performance_audit'
141
144
  ];
@@ -235,7 +238,7 @@ class WebHandler {
235
238
  }
236
239
  async handleExecutionError(error, action, context) {
237
240
  const measurableCommands = [
238
- 'verify_exists', 'verify_visible', 'verify_text', 'verify_contains', 'verify_not_exists',
241
+ 'verify_exists', 'verify_visible', 'verify_text', 'verify_contains', 'verify_not_exists', 'verify_value',
239
242
  'wait_for_selector', 'wait_for_text',
240
243
  'measure_web_vitals', 'performance_audit'
241
244
  ];
@@ -0,0 +1,11 @@
1
+ export interface ContinueRecorderOptions {
2
+ format?: 'yaml' | 'typescript' | 'json';
3
+ browser?: 'chromium' | 'chrome' | 'msedge' | 'firefox' | 'webkit';
4
+ }
5
+ /**
6
+ * Continue Recorder
7
+ *
8
+ * Executes existing steps from a scenario file in a visible browser,
9
+ * then injects a recording script to capture additional user interactions.
10
+ */
11
+ export declare function startContinueRecording(scenarioFile: string, options?: ContinueRecorderOptions): Promise<void>;
@@ -0,0 +1,872 @@
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
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.startContinueRecording = startContinueRecording;
37
+ const fs = __importStar(require("fs"));
38
+ const path = __importStar(require("path"));
39
+ const yaml = __importStar(require("yaml"));
40
+ const readline = __importStar(require("readline"));
41
+ const playwright_1 = require("playwright");
42
+ const logger_1 = require("../utils/logger");
43
+ /**
44
+ * Continue Recorder
45
+ *
46
+ * Executes existing steps from a scenario file in a visible browser,
47
+ * then injects a recording script to capture additional user interactions.
48
+ */
49
+ async function startContinueRecording(scenarioFile, options = {}) {
50
+ // Verify file exists
51
+ if (!fs.existsSync(scenarioFile)) {
52
+ throw new Error(`Scenario file not found: ${scenarioFile}`);
53
+ }
54
+ // Parse the scenario file
55
+ const scenario = parseScenarioFile(scenarioFile);
56
+ logger_1.logger.info(`Loaded scenario: ${scenario.name}`);
57
+ logger_1.logger.info(`Found ${scenario.steps.length} existing steps`);
58
+ // Display instructions
59
+ logger_1.logger.info('');
60
+ logger_1.logger.info('╔══════════════════════════════════════════════════════════════╗');
61
+ logger_1.logger.info('║ Continue Recording from Last Step ║');
62
+ logger_1.logger.info('╠══════════════════════════════════════════════════════════════╣');
63
+ logger_1.logger.info('║ Phase 1: Executing existing steps in visible browser ║');
64
+ logger_1.logger.info('║ Phase 2: Recording new interactions ║');
65
+ logger_1.logger.info('║ ║');
66
+ logger_1.logger.info('║ IN THE BROWSER: ║');
67
+ logger_1.logger.info('║ • Left-click - Records clicks and inputs automatically ║');
68
+ logger_1.logger.info('║ • Right-click - Opens assertion menu (verify visible/text) ║');
69
+ logger_1.logger.info('║ ║');
70
+ logger_1.logger.info('║ IN THIS TERMINAL: ║');
71
+ logger_1.logger.info('║ • Press W + Enter to add a wait point after last action ║');
72
+ logger_1.logger.info('║ • Press Q + Enter to finish and save ║');
73
+ logger_1.logger.info('║ ║');
74
+ logger_1.logger.info('║ NOTE: Closing the browser also saves the recording ║');
75
+ logger_1.logger.info('╚══════════════════════════════════════════════════════════════╝');
76
+ logger_1.logger.info('');
77
+ // Launch browser (visible, not headless)
78
+ const browserType = options.browser || scenario.browser.type || 'chromium';
79
+ logger_1.logger.info('Phase 1: Executing existing steps...');
80
+ const browser = await launchBrowser(browserType, false); // visible browser
81
+ const context = await browser.newContext({
82
+ viewport: { width: 1280, height: 720 },
83
+ ignoreHTTPSErrors: true
84
+ });
85
+ const page = await context.newPage();
86
+ try {
87
+ // Execute existing steps
88
+ await executeSteps(page, scenario.steps, scenario.baseUrl);
89
+ logger_1.logger.info(`✓ Executed ${scenario.steps.length} steps successfully`);
90
+ logger_1.logger.info('');
91
+ // Phase 2: Start recording
92
+ logger_1.logger.info('Phase 2: Recording mode active');
93
+ logger_1.logger.info('Interact with the browser. Press Q + Enter when done.\n');
94
+ const newActions = await recordInteractions(page, scenario.baseUrl);
95
+ if (newActions.length > 0) {
96
+ // Append new steps to the scenario file
97
+ appendStepsToFile(scenarioFile, newActions, scenario);
98
+ logger_1.logger.info(`\n✓ Added ${newActions.length} new steps to: ${scenarioFile}`);
99
+ logger_1.logger.info(` Total steps now: ${scenario.steps.length + newActions.length}`);
100
+ }
101
+ else {
102
+ logger_1.logger.info('\nNo new actions recorded.');
103
+ }
104
+ }
105
+ finally {
106
+ await browser.close();
107
+ }
108
+ }
109
+ function parseScenarioFile(filePath) {
110
+ const content = fs.readFileSync(filePath, 'utf-8');
111
+ const ext = path.extname(filePath).toLowerCase();
112
+ let config;
113
+ if (ext === '.json') {
114
+ config = JSON.parse(content);
115
+ }
116
+ else if (ext === '.yml' || ext === '.yaml') {
117
+ config = yaml.parse(content);
118
+ }
119
+ else {
120
+ throw new Error(`Unsupported file format: ${ext}. Use .yml, .yaml, or .json`);
121
+ }
122
+ const baseUrl = config.global?.base_url || '';
123
+ const browserConfig = config.global?.browser || {};
124
+ const scenarios = config.scenarios || [];
125
+ if (scenarios.length === 0) {
126
+ throw new Error('No scenarios found in configuration file');
127
+ }
128
+ const firstScenario = scenarios[0];
129
+ const steps = (firstScenario.steps || []).map((step) => ({
130
+ name: step.name,
131
+ type: step.type,
132
+ action: step.action,
133
+ wait: step.wait
134
+ }));
135
+ return {
136
+ name: config.name || firstScenario.name || 'Recorded Scenario',
137
+ baseUrl,
138
+ browser: {
139
+ type: browserConfig.type || 'chromium',
140
+ headless: browserConfig.headless !== undefined ? browserConfig.headless : true
141
+ },
142
+ steps,
143
+ rawConfig: config
144
+ };
145
+ }
146
+ async function launchBrowser(browserType, headless) {
147
+ const launchOptions = {
148
+ headless,
149
+ args: ['--no-sandbox', '--disable-dev-shm-usage']
150
+ };
151
+ switch (browserType) {
152
+ case 'chromium':
153
+ return playwright_1.chromium.launch(launchOptions);
154
+ case 'chrome':
155
+ return playwright_1.chromium.launch({ ...launchOptions, channel: 'chrome' });
156
+ case 'msedge':
157
+ return playwright_1.chromium.launch({ ...launchOptions, channel: 'msedge' });
158
+ case 'firefox':
159
+ return playwright_1.firefox.launch(launchOptions);
160
+ case 'webkit':
161
+ return playwright_1.webkit.launch(launchOptions);
162
+ default:
163
+ return playwright_1.chromium.launch(launchOptions);
164
+ }
165
+ }
166
+ async function executeSteps(page, steps, baseUrl) {
167
+ for (let i = 0; i < steps.length; i++) {
168
+ const step = steps[i];
169
+ logger_1.logger.info(` [${i + 1}/${steps.length}] ${step.name || step.action?.command || 'unknown'}`);
170
+ if (step.type !== 'web' || !step.action) {
171
+ logger_1.logger.warn(` Skipping non-web step: ${step.type}`);
172
+ continue;
173
+ }
174
+ const action = step.action;
175
+ try {
176
+ switch (action.command) {
177
+ case 'goto': {
178
+ const url = action.url?.startsWith('http') ? action.url : `${baseUrl}${action.url}`;
179
+ await page.goto(url, { waitUntil: 'load' });
180
+ break;
181
+ }
182
+ case 'click':
183
+ await page.click(convertSelector(action.selector || ''));
184
+ break;
185
+ case 'fill':
186
+ await page.fill(convertSelector(action.selector || ''), action.value || '');
187
+ break;
188
+ case 'press':
189
+ await page.press(convertSelector(action.selector || ''), action.key || '');
190
+ break;
191
+ case 'select':
192
+ await page.selectOption(convertSelector(action.selector || ''), action.value || '');
193
+ break;
194
+ case 'hover':
195
+ await page.hover(convertSelector(action.selector || ''));
196
+ break;
197
+ case 'dblclick':
198
+ await page.dblclick(convertSelector(action.selector || ''));
199
+ break;
200
+ case 'check':
201
+ await page.check(convertSelector(action.selector || ''));
202
+ break;
203
+ case 'uncheck':
204
+ await page.uncheck(convertSelector(action.selector || ''));
205
+ break;
206
+ case 'verify_visible':
207
+ await page.waitForSelector(convertSelector(action.selector || ''), { state: 'visible' });
208
+ break;
209
+ case 'verify_text': {
210
+ const element = await page.waitForSelector(convertSelector(action.selector || ''));
211
+ if (element && action.value) {
212
+ const text = await element.textContent();
213
+ if (text?.trim() !== action.value) {
214
+ logger_1.logger.warn(` Text mismatch: expected "${action.value}", got "${text?.trim()}"`);
215
+ }
216
+ }
217
+ break;
218
+ }
219
+ case 'verify_contains': {
220
+ const el = await page.waitForSelector(convertSelector(action.selector || ''));
221
+ if (el && action.value) {
222
+ const text = await el.textContent();
223
+ if (!text?.includes(action.value)) {
224
+ logger_1.logger.warn(` Text does not contain "${action.value}"`);
225
+ }
226
+ }
227
+ break;
228
+ }
229
+ case 'verify_value': {
230
+ const inputEl = await page.waitForSelector(convertSelector(action.selector || ''));
231
+ if (inputEl && action.value) {
232
+ const value = await inputEl.inputValue();
233
+ if (value !== action.value) {
234
+ logger_1.logger.warn(` Value mismatch: expected "${action.value}", got "${value}"`);
235
+ }
236
+ }
237
+ break;
238
+ }
239
+ case 'wait_for_load_state':
240
+ await page.waitForLoadState('load');
241
+ break;
242
+ case 'network_idle':
243
+ // Try networkidle with shorter timeout, fall back to load
244
+ try {
245
+ await page.waitForLoadState('networkidle', { timeout: 5000 });
246
+ }
247
+ catch {
248
+ await page.waitForLoadState('load');
249
+ }
250
+ break;
251
+ case 'dom_ready':
252
+ await page.waitForLoadState('domcontentloaded');
253
+ break;
254
+ default:
255
+ logger_1.logger.warn(` Unknown command: ${action.command}`);
256
+ }
257
+ // Handle wait if specified
258
+ if (step.wait) {
259
+ const waitMs = parseWaitDuration(step.wait);
260
+ await page.waitForTimeout(waitMs);
261
+ }
262
+ }
263
+ catch (error) {
264
+ logger_1.logger.error(` Failed to execute step: ${error.message}`);
265
+ throw error;
266
+ }
267
+ }
268
+ }
269
+ function convertSelector(selector) {
270
+ if (selector.startsWith('role=')) {
271
+ return selector;
272
+ }
273
+ if (selector.startsWith('text=')) {
274
+ return selector;
275
+ }
276
+ if (selector.startsWith('placeholder=')) {
277
+ return `[placeholder="${selector.substring(12)}"]`;
278
+ }
279
+ return selector;
280
+ }
281
+ function parseWaitDuration(duration) {
282
+ const match = duration.match(/^(\d+)(ms|s|m)?$/);
283
+ if (!match)
284
+ return 1000;
285
+ const value = parseInt(match[1], 10);
286
+ const unit = match[2] || 's';
287
+ switch (unit) {
288
+ case 'ms': return value;
289
+ case 's': return value * 1000;
290
+ case 'm': return value * 60000;
291
+ default: return value * 1000;
292
+ }
293
+ }
294
+ /**
295
+ * Recording script to inject into the page.
296
+ * Captures user interactions and generates selectors.
297
+ * Right-click context menu for assertions.
298
+ */
299
+ const RECORDING_SCRIPT = `
300
+ (function() {
301
+ // Prevent double injection
302
+ if (window.__perforniumRecorderActive) return;
303
+ window.__perforniumRecorderActive = true;
304
+
305
+ // Recording indicator
306
+ const indicator = document.createElement('div');
307
+ indicator.id = '__perfornium-recording-indicator';
308
+ indicator.style.cssText = 'position:fixed;top:10px;right:10px;background:rgba(0,0,0,0.9);color:white;padding:10px 16px;border-radius:8px;font-family:system-ui,sans-serif;font-size:13px;z-index:999999;display:flex;align-items:center;gap:8px;box-shadow:0 4px 12px rgba(0,0,0,0.3);';
309
+ indicator.innerHTML = '<span style="display:inline-block;width:12px;height:12px;background:#ef4444;border-radius:50%;animation:pulse 1s infinite;"></span><span>Recording</span>';
310
+
311
+ const style = document.createElement('style');
312
+ style.textContent = \`
313
+ @keyframes pulse{0%,100%{opacity:1}50%{opacity:0.5}}
314
+ .__perfornium-highlight{outline:2px solid #22c55e !important;outline-offset:2px;}
315
+ .__perfornium-hover{outline:2px dashed #00d4ff !important;outline-offset:2px;}
316
+ #__perfornium-context-menu {
317
+ position: fixed;
318
+ background: #1a1a2e;
319
+ border: 1px solid rgba(255,255,255,0.15);
320
+ border-radius: 8px;
321
+ padding: 4px 0;
322
+ min-width: 180px;
323
+ box-shadow: 0 8px 24px rgba(0,0,0,0.4);
324
+ font-family: system-ui, sans-serif;
325
+ font-size: 13px;
326
+ z-index: 1000000;
327
+ display: none;
328
+ }
329
+ #__perfornium-context-menu.visible { display: block; }
330
+ .__perfornium-menu-item {
331
+ padding: 8px 12px;
332
+ color: #e2e8f0;
333
+ cursor: pointer;
334
+ display: flex;
335
+ align-items: center;
336
+ gap: 10px;
337
+ }
338
+ .__perfornium-menu-item:hover { background: rgba(0,212,255,0.15); }
339
+ .__perfornium-menu-item svg { width: 16px; height: 16px; opacity: 0.7; }
340
+ .__perfornium-menu-separator {
341
+ height: 1px;
342
+ background: rgba(255,255,255,0.1);
343
+ margin: 4px 0;
344
+ }
345
+ .__perfornium-menu-header {
346
+ padding: 6px 12px;
347
+ color: #9ca3af;
348
+ font-size: 11px;
349
+ text-transform: uppercase;
350
+ letter-spacing: 0.5px;
351
+ }
352
+ \`;
353
+ document.head.appendChild(style);
354
+ document.body.appendChild(indicator);
355
+
356
+ // Context menu element
357
+ const contextMenu = document.createElement('div');
358
+ contextMenu.id = '__perfornium-context-menu';
359
+ contextMenu.innerHTML = \`
360
+ <div class="__perfornium-menu-header">Assertions</div>
361
+ <div class="__perfornium-menu-item" data-action="verify_visible">
362
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
363
+ Verify Visible
364
+ </div>
365
+ <div class="__perfornium-menu-item" data-action="verify_contains">
366
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7V4h16v3M9 20h6M12 4v16"/></svg>
367
+ Verify Contains
368
+ </div>
369
+ <div class="__perfornium-menu-item" data-action="verify_value">
370
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M9 12h6M12 9v6"/></svg>
371
+ Verify Value
372
+ </div>
373
+ <div class="__perfornium-menu-separator"></div>
374
+ <div class="__perfornium-menu-item" data-action="cancel" style="color:#9ca3af;">
375
+ Cancel
376
+ </div>
377
+ \`;
378
+ document.body.appendChild(contextMenu);
379
+
380
+ // Action queue
381
+ window.__perforniumActions = [];
382
+ let lastFillSelector = null;
383
+ let lastFillTimeout = null;
384
+ let contextMenuTarget = null;
385
+
386
+ // Helper to check if selector is unique
387
+ function isUnique(selector) {
388
+ try {
389
+ // Handle Playwright-specific selectors
390
+ if (selector.startsWith('role=') || selector.startsWith('text=')) {
391
+ return true; // Can't easily validate these, assume they work
392
+ }
393
+ return document.querySelectorAll(selector).length === 1;
394
+ } catch {
395
+ return false;
396
+ }
397
+ }
398
+
399
+ // Build a unique CSS path for any element
400
+ function buildCssPath(el) {
401
+ const path = [];
402
+ let current = el;
403
+ while (current && current !== document.body && current !== document.documentElement) {
404
+ let selector = current.tagName.toLowerCase();
405
+ if (current.id && !/^[0-9]|[-_][0-9a-f]{8,}|\\d{5,}/.test(current.id)) {
406
+ selector = '#' + CSS.escape(current.id);
407
+ path.unshift(selector);
408
+ break; // ID is unique, stop here
409
+ } else {
410
+ const parent = current.parentElement;
411
+ if (parent) {
412
+ const siblings = Array.from(parent.children).filter(c => c.tagName === current.tagName);
413
+ if (siblings.length > 1) {
414
+ const index = siblings.indexOf(current) + 1;
415
+ selector += ':nth-of-type(' + index + ')';
416
+ }
417
+ }
418
+ path.unshift(selector);
419
+ }
420
+ current = current.parentElement;
421
+ }
422
+ return path.join(' > ');
423
+ }
424
+
425
+ // Generate best selector for an element
426
+ function getSelector(el) {
427
+ if (!el || el === document.body || el === document.documentElement) return null;
428
+
429
+ // Priority 1: data-testid (always unique by convention)
430
+ if (el.dataset.testid) {
431
+ const sel = '[data-testid="' + el.dataset.testid + '"]';
432
+ if (isUnique(sel)) return sel;
433
+ }
434
+ if (el.getAttribute('data-test-id')) {
435
+ const sel = '[data-test-id="' + el.getAttribute('data-test-id') + '"]';
436
+ if (isUnique(sel)) return sel;
437
+ }
438
+ if (el.getAttribute('data-cy')) {
439
+ const sel = '[data-cy="' + el.getAttribute('data-cy') + '"]';
440
+ if (isUnique(sel)) return sel;
441
+ }
442
+
443
+ // Priority 2: id (if not dynamic-looking and unique)
444
+ if (el.id && !/^[0-9]|[-_][0-9a-f]{8,}|\\d{5,}/.test(el.id)) {
445
+ const sel = '#' + CSS.escape(el.id);
446
+ if (isUnique(sel)) return sel;
447
+ }
448
+
449
+ // Priority 3: role + accessible name (check uniqueness)
450
+ const role = el.getAttribute('role') || getImplicitRole(el);
451
+ const name = el.getAttribute('aria-label') ||
452
+ el.getAttribute('title') ||
453
+ el.placeholder;
454
+ if (role && name) {
455
+ const sel = 'role=' + role + '[name="' + name.replace(/"/g, '\\\\"') + '"]';
456
+ return sel; // Playwright selector, assume it works
457
+ }
458
+
459
+ // Priority 4: input by name or placeholder (check uniqueness)
460
+ if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT') {
461
+ if (el.name && !/^[0-9]/.test(el.name)) {
462
+ const sel = el.tagName.toLowerCase() + '[name="' + el.name + '"]';
463
+ if (isUnique(sel)) return sel;
464
+ }
465
+ if (el.placeholder) {
466
+ const sel = '[placeholder="' + el.placeholder + '"]';
467
+ if (isUnique(sel)) return sel;
468
+ }
469
+ }
470
+
471
+ // Priority 5: button/link by text (only short, unique text)
472
+ if (el.tagName === 'BUTTON' || el.tagName === 'A') {
473
+ const text = el.innerText?.trim();
474
+ if (text && text.length <= 40 && text.length >= 1) {
475
+ const sel = 'text=' + text;
476
+ return sel; // Playwright selector
477
+ }
478
+ }
479
+
480
+ // Priority 6: unique class combination
481
+ if (el.className && typeof el.className === 'string') {
482
+ const classes = el.className.trim().split(/\\s+/).filter(c =>
483
+ c && !/^[0-9]|active|selected|hover|focus|disabled|hidden|visible/.test(c)
484
+ ).slice(0, 3);
485
+ if (classes.length > 0) {
486
+ const sel = el.tagName.toLowerCase() + '.' + classes.join('.');
487
+ if (isUnique(sel)) return sel;
488
+ }
489
+ }
490
+
491
+ // Priority 7: Unique attribute selectors
492
+ for (const attr of ['name', 'type', 'value', 'href', 'src', 'alt']) {
493
+ const val = el.getAttribute(attr);
494
+ if (val && val.length < 50) {
495
+ const sel = el.tagName.toLowerCase() + '[' + attr + '="' + val.replace(/"/g, '\\\\"') + '"]';
496
+ if (isUnique(sel)) return sel;
497
+ }
498
+ }
499
+
500
+ // Fallback: build full CSS path (always unique)
501
+ return buildCssPath(el);
502
+ }
503
+
504
+ function getImplicitRole(el) {
505
+ const tag = el.tagName.toLowerCase();
506
+ const type = el.type?.toLowerCase();
507
+ const roles = {
508
+ 'button': 'button',
509
+ 'a': 'link',
510
+ 'input[type=submit]': 'button',
511
+ 'input[type=button]': 'button',
512
+ 'input[type=checkbox]': 'checkbox',
513
+ 'input[type=radio]': 'radio',
514
+ 'input[type=text]': 'textbox',
515
+ 'input[type=email]': 'textbox',
516
+ 'input[type=password]': 'textbox',
517
+ 'input[type=search]': 'searchbox',
518
+ 'textarea': 'textbox',
519
+ 'select': 'combobox',
520
+ 'img': 'img',
521
+ 'nav': 'navigation',
522
+ 'main': 'main',
523
+ 'header': 'banner',
524
+ 'footer': 'contentinfo',
525
+ 'form': 'form'
526
+ };
527
+ if (type) {
528
+ return roles[tag + '[type=' + type + ']'] || roles[tag];
529
+ }
530
+ return roles[tag];
531
+ }
532
+
533
+ function recordAction(action) {
534
+ action.timestamp = Date.now();
535
+ window.__perforniumActions.push(action);
536
+
537
+ // Notify Node.js
538
+ if (window.__perforniumNotify) {
539
+ window.__perforniumNotify(JSON.stringify(action));
540
+ }
541
+ }
542
+
543
+ function hideContextMenu() {
544
+ contextMenu.classList.remove('visible');
545
+ if (contextMenuTarget) {
546
+ contextMenuTarget.classList.remove('__perfornium-hover');
547
+ contextMenuTarget = null;
548
+ }
549
+ }
550
+
551
+ function showContextMenu(x, y, target) {
552
+ contextMenuTarget = target;
553
+ target.classList.add('__perfornium-hover');
554
+
555
+ // Position menu, ensuring it stays within viewport
556
+ const menuRect = contextMenu.getBoundingClientRect();
557
+ const viewportWidth = window.innerWidth;
558
+ const viewportHeight = window.innerHeight;
559
+
560
+ let posX = x;
561
+ let posY = y;
562
+
563
+ // Show temporarily to measure
564
+ contextMenu.style.left = '0px';
565
+ contextMenu.style.top = '0px';
566
+ contextMenu.classList.add('visible');
567
+
568
+ const menuWidth = contextMenu.offsetWidth;
569
+ const menuHeight = contextMenu.offsetHeight;
570
+
571
+ if (x + menuWidth > viewportWidth) {
572
+ posX = viewportWidth - menuWidth - 10;
573
+ }
574
+ if (y + menuHeight > viewportHeight) {
575
+ posY = viewportHeight - menuHeight - 10;
576
+ }
577
+
578
+ contextMenu.style.left = posX + 'px';
579
+ contextMenu.style.top = posY + 'px';
580
+ }
581
+
582
+ // Handle context menu (right-click)
583
+ document.addEventListener('contextmenu', function(e) {
584
+ const el = e.target;
585
+
586
+ // Ignore right-clicks on our UI elements
587
+ if (el.closest('#__perfornium-recording-indicator') || el.closest('#__perfornium-context-menu')) {
588
+ return;
589
+ }
590
+
591
+ const selector = getSelector(el);
592
+ if (!selector) return;
593
+
594
+ e.preventDefault();
595
+ showContextMenu(e.clientX, e.clientY, el);
596
+ }, true);
597
+
598
+ // Handle context menu item clicks
599
+ contextMenu.addEventListener('click', function(e) {
600
+ const item = e.target.closest('.__perfornium-menu-item');
601
+ if (!item || !contextMenuTarget) return;
602
+
603
+ const action = item.dataset.action;
604
+ const selector = getSelector(contextMenuTarget);
605
+
606
+ if (action === 'cancel' || !selector) {
607
+ hideContextMenu();
608
+ return;
609
+ }
610
+
611
+ if (action === 'verify_visible') {
612
+ recordAction({ type: 'verify_visible', selector: selector });
613
+ contextMenuTarget.classList.add('__perfornium-highlight');
614
+ setTimeout(() => contextMenuTarget?.classList.remove('__perfornium-highlight'), 500);
615
+ } else if (action === 'verify_contains') {
616
+ const text = contextMenuTarget.innerText?.trim().substring(0, 100) || '';
617
+ recordAction({ type: 'verify_contains', selector: selector, value: text });
618
+ contextMenuTarget.classList.add('__perfornium-highlight');
619
+ setTimeout(() => contextMenuTarget?.classList.remove('__perfornium-highlight'), 500);
620
+ } else if (action === 'verify_value') {
621
+ const tag = contextMenuTarget.tagName;
622
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
623
+ const value = contextMenuTarget.value || '';
624
+ recordAction({ type: 'verify_value', selector: selector, value: value });
625
+ contextMenuTarget.classList.add('__perfornium-highlight');
626
+ setTimeout(() => contextMenuTarget?.classList.remove('__perfornium-highlight'), 500);
627
+ } else {
628
+ alert('Verify Value is only available for input, textarea, and select elements.');
629
+ }
630
+ }
631
+
632
+ hideContextMenu();
633
+ });
634
+
635
+ // Hide context menu on click elsewhere or Escape
636
+ document.addEventListener('click', function(e) {
637
+ if (!e.target.closest('#__perfornium-context-menu')) {
638
+ hideContextMenu();
639
+ }
640
+ });
641
+
642
+ document.addEventListener('keydown', function(e) {
643
+ if (e.key === 'Escape') {
644
+ hideContextMenu();
645
+ }
646
+ });
647
+
648
+ // Hide context menu on scroll
649
+ document.addEventListener('scroll', hideContextMenu, true);
650
+
651
+ // Capture left clicks for recording
652
+ document.addEventListener('click', function(e) {
653
+ const el = e.target;
654
+
655
+ // Ignore clicks on recording indicator or context menu
656
+ if (el.closest('#__perfornium-recording-indicator') || el.closest('#__perfornium-context-menu')) return;
657
+
658
+ const selector = getSelector(el);
659
+ if (!selector) return;
660
+
661
+ // Record a click
662
+ recordAction({ type: 'click', selector: selector });
663
+ }, true);
664
+
665
+ // Capture form inputs (debounced)
666
+ document.addEventListener('input', function(e) {
667
+ const el = e.target;
668
+ if (el.tagName !== 'INPUT' && el.tagName !== 'TEXTAREA') return;
669
+ if (el.type === 'submit' || el.type === 'button') return;
670
+
671
+ const selector = getSelector(el);
672
+ if (!selector) return;
673
+
674
+ // Debounce: only record final value after user stops typing
675
+ if (lastFillTimeout) clearTimeout(lastFillTimeout);
676
+
677
+ lastFillSelector = selector;
678
+ lastFillTimeout = setTimeout(function() {
679
+ recordAction({ type: 'fill', selector: selector, value: el.value });
680
+ lastFillSelector = null;
681
+ }, 500);
682
+ }, true);
683
+
684
+ // Capture select changes
685
+ document.addEventListener('change', function(e) {
686
+ const el = e.target;
687
+ if (el.tagName !== 'SELECT') return;
688
+
689
+ const selector = getSelector(el);
690
+ if (selector) {
691
+ recordAction({ type: 'select', selector: selector, value: el.value });
692
+ }
693
+ }, true);
694
+
695
+ // Capture checkbox/radio changes
696
+ document.addEventListener('change', function(e) {
697
+ const el = e.target;
698
+ if (el.tagName !== 'INPUT') return;
699
+ if (el.type !== 'checkbox' && el.type !== 'radio') return;
700
+
701
+ const selector = getSelector(el);
702
+ if (selector) {
703
+ recordAction({ type: el.checked ? 'check' : 'uncheck', selector: selector });
704
+ }
705
+ }, true);
706
+
707
+ // Capture key presses (Enter, Tab, Escape)
708
+ document.addEventListener('keydown', function(e) {
709
+ if (!['Enter', 'Tab', 'Escape'].includes(e.key)) return;
710
+ if (e.key === 'Escape' && contextMenu.classList.contains('visible')) return; // Don't record Escape when closing menu
711
+
712
+ const el = e.target;
713
+ const selector = getSelector(el);
714
+ if (selector) {
715
+ recordAction({ type: 'press', selector: selector, key: e.key });
716
+ }
717
+ }, true);
718
+
719
+ console.log('[Perfornium] Recording active - interact with the page, right-click for assertions');
720
+ })();
721
+ `;
722
+ async function recordInteractions(page, baseUrl) {
723
+ const recordedActions = [];
724
+ let isRecording = true;
725
+ const waitPoints = [];
726
+ // Expose function for the injected script to notify us
727
+ await page.exposeFunction('__perforniumNotify', (actionJson) => {
728
+ try {
729
+ const action = JSON.parse(actionJson);
730
+ // Debounce fill actions
731
+ if (action.type === 'fill') {
732
+ const lastAction = recordedActions[recordedActions.length - 1];
733
+ if (lastAction?.type === 'fill' && lastAction.selector === action.selector) {
734
+ lastAction.value = action.value;
735
+ lastAction.timestamp = action.timestamp;
736
+ logger_1.logger.info(` Updated: fill "${action.selector}" = "${action.value?.substring(0, 20)}..."`);
737
+ return;
738
+ }
739
+ }
740
+ recordedActions.push(action);
741
+ // Log the action
742
+ let logMsg = ` Recorded: ${action.type}`;
743
+ if (action.selector)
744
+ logMsg += ` "${action.selector}"`;
745
+ if (action.value)
746
+ logMsg += ` = "${action.value.substring(0, 30)}${action.value.length > 30 ? '...' : ''}"`;
747
+ if (action.key)
748
+ logMsg += ` [${action.key}]`;
749
+ logger_1.logger.info(logMsg);
750
+ }
751
+ catch {
752
+ // Ignore parse errors
753
+ }
754
+ });
755
+ // Inject the recording script
756
+ await page.addInitScript(RECORDING_SCRIPT);
757
+ await page.evaluate(RECORDING_SCRIPT);
758
+ // Track navigation
759
+ page.on('framenavigated', (frame) => {
760
+ if (frame === page.mainFrame() && isRecording) {
761
+ const url = frame.url();
762
+ const relativeUrl = url.startsWith(baseUrl) ? url.substring(baseUrl.length) || '/' : url;
763
+ // Don't record if it's the same as last action
764
+ const lastAction = recordedActions[recordedActions.length - 1];
765
+ if (lastAction?.type === 'goto' && lastAction.url === relativeUrl)
766
+ return;
767
+ recordedActions.push({
768
+ type: 'goto',
769
+ url: relativeUrl,
770
+ timestamp: Date.now()
771
+ });
772
+ logger_1.logger.info(` Recorded: goto "${relativeUrl}"`);
773
+ }
774
+ });
775
+ // Set up readline for user input
776
+ const rl = readline.createInterface({
777
+ input: process.stdin,
778
+ output: process.stdout
779
+ });
780
+ const normalizeDuration = (input) => {
781
+ const trimmed = input.trim();
782
+ if (/^\d+$/.test(trimmed))
783
+ return `${trimmed}s`;
784
+ if (/^\d+\s*(s|ms|m)$/i.test(trimmed))
785
+ return trimmed.replace(/\s+/g, '');
786
+ return trimmed;
787
+ };
788
+ return new Promise((resolve) => {
789
+ const cleanup = () => {
790
+ isRecording = false;
791
+ rl.close();
792
+ // Apply wait points
793
+ waitPoints.forEach(wp => {
794
+ if (recordedActions[wp.afterIndex]) {
795
+ recordedActions[wp.afterIndex].waitAfter = wp.duration;
796
+ }
797
+ });
798
+ resolve(recordedActions);
799
+ };
800
+ const promptInput = () => {
801
+ rl.question('', async (input) => {
802
+ const cmd = input.toLowerCase().trim();
803
+ if (cmd === 'q' || cmd === 'quit' || cmd === 'exit' || cmd === 'done') {
804
+ cleanup();
805
+ return;
806
+ }
807
+ if (cmd === 'w' || cmd === 'wait') {
808
+ rl.question('Enter wait duration (e.g., 2s, 500ms): ', (duration) => {
809
+ if (duration.trim()) {
810
+ const normalizedDuration = normalizeDuration(duration);
811
+ waitPoints.push({ afterIndex: recordedActions.length - 1, duration: normalizedDuration });
812
+ logger_1.logger.info(` ✓ Wait point added: ${normalizedDuration}`);
813
+ }
814
+ if (isRecording)
815
+ promptInput();
816
+ });
817
+ return;
818
+ }
819
+ if (isRecording)
820
+ promptInput();
821
+ });
822
+ };
823
+ promptInput();
824
+ // Handle browser close
825
+ page.on('close', () => {
826
+ if (isRecording) {
827
+ logger_1.logger.info('\nBrowser closed - saving recording...');
828
+ cleanup();
829
+ }
830
+ });
831
+ // Handle Ctrl+C
832
+ process.on('SIGINT', () => {
833
+ if (isRecording) {
834
+ logger_1.logger.info('\nInterrupted - saving recording...');
835
+ cleanup();
836
+ }
837
+ });
838
+ });
839
+ }
840
+ function appendStepsToFile(filePath, newActions, scenario) {
841
+ const ext = path.extname(filePath).toLowerCase();
842
+ // Convert actions to steps
843
+ const newSteps = newActions.map((action, idx) => {
844
+ const step = {
845
+ name: `${action.type}_${scenario.steps.length + idx + 1}`,
846
+ type: 'web',
847
+ action: {
848
+ command: action.type,
849
+ ...(action.selector && { selector: action.selector }),
850
+ ...(action.url && { url: action.url }),
851
+ ...(action.value && { value: action.value }),
852
+ ...(action.key && { key: action.key })
853
+ }
854
+ };
855
+ if (action.waitAfter) {
856
+ step.wait = action.waitAfter;
857
+ }
858
+ return step;
859
+ });
860
+ // Update the config
861
+ const config = scenario.rawConfig;
862
+ if (config.scenarios && config.scenarios[0]) {
863
+ config.scenarios[0].steps = [...(config.scenarios[0].steps || []), ...newSteps];
864
+ }
865
+ // Write back to file
866
+ if (ext === '.json') {
867
+ fs.writeFileSync(filePath, JSON.stringify(config, null, 2));
868
+ }
869
+ else {
870
+ fs.writeFileSync(filePath, yaml.stringify(config, { indent: 2, lineWidth: 120 }));
871
+ }
872
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testsmith/perfornium",
3
- "version": "0.6.5",
3
+ "version": "0.6.6",
4
4
  "description": "Flexible performance testing framework for REST, SOAP, and web applications",
5
5
  "author": "TestSmith",
6
6
  "license": "MIT",