@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 +16 -1
- package/dist/config/types/step-types.d.ts +1 -1
- package/dist/protocols/web/commands/verification.d.ts +1 -0
- package/dist/protocols/web/commands/verification.js +20 -0
- package/dist/protocols/web/handler.js +5 -2
- package/dist/recorder/continue-recorder.d.ts +11 -0
- package/dist/recorder/continue-recorder.js +872 -0
- package/package.json +1 -1
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('
|
|
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
|
+
}
|