@probolabs/playwright 1.2.0 → 1.4.0-rc.1

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.js ADDED
@@ -0,0 +1,1893 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as os from 'os';
4
+ import { exec, spawn } from 'child_process';
5
+ import { promisify } from 'util';
6
+ import 'adm-zip';
7
+ import fetch$1 from 'node-fetch';
8
+
9
+ var ApplyAIStatus;
10
+ (function (ApplyAIStatus) {
11
+ ApplyAIStatus["PREPARE_START"] = "PREPARE_START";
12
+ ApplyAIStatus["PREPARE_SUCCESS"] = "PREPARE_SUCCESS";
13
+ ApplyAIStatus["PREPARE_ERROR"] = "PREPARE_ERROR";
14
+ ApplyAIStatus["SEND_START"] = "SEND_START";
15
+ ApplyAIStatus["SEND_SUCCESS"] = "SEND_SUCCESS";
16
+ ApplyAIStatus["SEND_ERROR"] = "SEND_ERROR";
17
+ ApplyAIStatus["APPLY_AI_ERROR"] = "APPLY_AI_ERROR";
18
+ ApplyAIStatus["APPLY_AI_CANCELLED"] = "APPLY_AI_CANCELLED";
19
+ ApplyAIStatus["SUMMARY_COMPLETED"] = "SUMMARY_COMPLETED";
20
+ ApplyAIStatus["SUMMARY_ERROR"] = "SUMMARY_ERROR";
21
+ })(ApplyAIStatus || (ApplyAIStatus = {}));
22
+ var ReplayStatus;
23
+ (function (ReplayStatus) {
24
+ ReplayStatus["REPLAY_START"] = "REPLAY_START";
25
+ ReplayStatus["REPLAY_SUCCESS"] = "REPLAY_SUCCESS";
26
+ ReplayStatus["REPLAY_ERROR"] = "REPLAY_ERROR";
27
+ ReplayStatus["REPLAY_CANCELLED"] = "REPLAY_CANCELLED";
28
+ })(ReplayStatus || (ReplayStatus = {}));
29
+
30
+ // Action constants
31
+ var PlaywrightAction;
32
+ (function (PlaywrightAction) {
33
+ PlaywrightAction["VISIT_BASE_URL"] = "VISIT_BASE_URL";
34
+ PlaywrightAction["VISIT_URL"] = "VISIT_URL";
35
+ PlaywrightAction["CLICK"] = "CLICK";
36
+ PlaywrightAction["FILL_IN"] = "FILL_IN";
37
+ PlaywrightAction["SELECT_DROPDOWN"] = "SELECT_DROPDOWN";
38
+ PlaywrightAction["SELECT_MULTIPLE_DROPDOWN"] = "SELECT_MULTIPLE_DROPDOWN";
39
+ PlaywrightAction["CHECK_CHECKBOX"] = "CHECK_CHECKBOX";
40
+ PlaywrightAction["SELECT_RADIO"] = "SELECT_RADIO";
41
+ PlaywrightAction["TOGGLE_SWITCH"] = "TOGGLE_SWITCH";
42
+ PlaywrightAction["SET_SLIDER"] = "SET_SLIDER";
43
+ PlaywrightAction["TYPE_KEYS"] = "TYPE_KEYS";
44
+ PlaywrightAction["HOVER"] = "HOVER";
45
+ PlaywrightAction["ASSERT_EXACT_VALUE"] = "ASSERT_EXACT_VALUE";
46
+ PlaywrightAction["ASSERT_CONTAINS_VALUE"] = "ASSERT_CONTAINS_VALUE";
47
+ PlaywrightAction["ASSERT_URL"] = "ASSERT_URL";
48
+ PlaywrightAction["SCROLL_TO_ELEMENT"] = "SCROLL_TO_ELEMENT";
49
+ PlaywrightAction["EXTRACT_VALUE"] = "EXTRACT_VALUE";
50
+ PlaywrightAction["ASK_AI"] = "ASK_AI";
51
+ PlaywrightAction["EXECUTE_SCRIPT"] = "EXECUTE_SCRIPT";
52
+ PlaywrightAction["UPLOAD_FILES"] = "UPLOAD_FILES";
53
+ PlaywrightAction["WAIT_FOR"] = "WAIT_FOR";
54
+ PlaywrightAction["WAIT_FOR_OTP"] = "WAIT_FOR_OTP";
55
+ PlaywrightAction["GEN_TOTP"] = "GEN_TOTP";
56
+ // Sequence marker actions (not real interactions, used only for grouping)
57
+ PlaywrightAction["SEQUENCE_START"] = "SEQUENCE_START";
58
+ PlaywrightAction["SEQUENCE_END"] = "SEQUENCE_END";
59
+ })(PlaywrightAction || (PlaywrightAction = {}));
60
+
61
+ // clickable element detection result
62
+ var IsClickable;
63
+ (function (IsClickable) {
64
+ IsClickable["YES"] = "YES";
65
+ IsClickable["NO"] = "NO";
66
+ IsClickable["MAYBE"] = "MAYBE";
67
+ })(IsClickable || (IsClickable = {}));
68
+
69
+ // WebSocketsMessageType enum for WebSocket and event message types shared across the app
70
+ var WebSocketsMessageType;
71
+ (function (WebSocketsMessageType) {
72
+ WebSocketsMessageType["INTERACTION_APPLY_AI_PREPARE_START"] = "INTERACTION_APPLY_AI_PREPARE_START";
73
+ WebSocketsMessageType["INTERACTION_APPLY_AI_PREPARE_SUCCESS"] = "INTERACTION_APPLY_AI_PREPARE_SUCCESS";
74
+ WebSocketsMessageType["INTERACTION_APPLY_AI_PREPARE_ERROR"] = "INTERACTION_APPLY_AI_PREPARE_ERROR";
75
+ WebSocketsMessageType["INTERACTION_APPLY_AI_SEND_TO_LLM_START"] = "INTERACTION_APPLY_AI_SEND_TO_LLM_START";
76
+ WebSocketsMessageType["INTERACTION_APPLY_AI_SEND_TO_LLM_SUCCESS"] = "INTERACTION_APPLY_AI_SEND_TO_LLM_SUCCESS";
77
+ WebSocketsMessageType["INTERACTION_APPLY_AI_SEND_TO_LLM_ERROR"] = "INTERACTION_APPLY_AI_SEND_TO_LLM_ERROR";
78
+ WebSocketsMessageType["INTERACTION_REPLAY_START"] = "INTERACTION_REPLAY_START";
79
+ WebSocketsMessageType["INTERACTION_REPLAY_SUCCESS"] = "INTERACTION_REPLAY_SUCCESS";
80
+ WebSocketsMessageType["INTERACTION_REPLAY_ERROR"] = "INTERACTION_REPLAY_ERROR";
81
+ WebSocketsMessageType["INTERACTION_REPLAY_CANCELLED"] = "INTERACTION_REPLAY_CANCELLED";
82
+ WebSocketsMessageType["INTERACTION_APPLY_AI_CANCELLED"] = "INTERACTION_APPLY_AI_CANCELLED";
83
+ WebSocketsMessageType["INTERACTION_APPLY_AI_ERROR"] = "INTERACTION_APPLY_AI_ERROR";
84
+ WebSocketsMessageType["INTERACTION_STEP_CREATED"] = "INTERACTION_STEP_CREATED";
85
+ WebSocketsMessageType["INTERACTION_APPLY_AI_SUMMARY_COMPLETED"] = "INTERACTION_APPLY_AI_SUMMARY_COMPLETED";
86
+ WebSocketsMessageType["INTERACTION_APPLY_AI_SUMMARY_ERROR"] = "INTERACTION_APPLY_AI_SUMMARY_ERROR";
87
+ WebSocketsMessageType["OTP_RETRIEVED"] = "OTP_RETRIEVED";
88
+ WebSocketsMessageType["TEST_SUITE_RUN_START"] = "TEST_SUITE_RUN_START";
89
+ WebSocketsMessageType["TEST_SUITE_RUN_LOG"] = "TEST_SUITE_RUN_LOG";
90
+ WebSocketsMessageType["TEST_SUITE_RUN_COMPLETE"] = "TEST_SUITE_RUN_COMPLETE";
91
+ WebSocketsMessageType["TEST_SUITE_RUN_ERROR"] = "TEST_SUITE_RUN_ERROR";
92
+ WebSocketsMessageType["TEST_SUITE_RUN_REPORTER_EVENT"] = "TEST_SUITE_RUN_REPORTER_EVENT";
93
+ })(WebSocketsMessageType || (WebSocketsMessageType = {}));
94
+
95
+ /**
96
+ * Logging levels for Probo
97
+ */
98
+ var ProboLogLevel;
99
+ (function (ProboLogLevel) {
100
+ ProboLogLevel["DEBUG"] = "DEBUG";
101
+ ProboLogLevel["INFO"] = "INFO";
102
+ ProboLogLevel["LOG"] = "LOG";
103
+ ProboLogLevel["WARN"] = "WARN";
104
+ ProboLogLevel["ERROR"] = "ERROR";
105
+ })(ProboLogLevel || (ProboLogLevel = {}));
106
+ const logLevelOrder = {
107
+ [ProboLogLevel.DEBUG]: 0,
108
+ [ProboLogLevel.INFO]: 1,
109
+ [ProboLogLevel.LOG]: 2,
110
+ [ProboLogLevel.WARN]: 3,
111
+ [ProboLogLevel.ERROR]: 4,
112
+ };
113
+ class ProboLogger {
114
+ constructor(prefix, level = ProboLogLevel.INFO) {
115
+ this.prefix = prefix;
116
+ this.level = level;
117
+ }
118
+ setLogLevel(level) {
119
+ console.log(`[${this.prefix}] Setting log level to: ${level} (was: ${this.level})`);
120
+ this.level = level;
121
+ }
122
+ shouldLog(level) {
123
+ return logLevelOrder[level] >= logLevelOrder[this.level];
124
+ }
125
+ preamble(level) {
126
+ const now = new Date();
127
+ const hours = String(now.getHours()).padStart(2, '0');
128
+ const minutes = String(now.getMinutes()).padStart(2, '0');
129
+ const seconds = String(now.getSeconds()).padStart(2, '0');
130
+ const milliseconds = String(now.getMilliseconds()).padStart(3, '0');
131
+ return `[${hours}:${minutes}:${seconds}.${milliseconds}] [${this.prefix}] [${level}]`;
132
+ }
133
+ debug(...args) { if (this.shouldLog(ProboLogLevel.DEBUG))
134
+ console.debug(this.preamble(ProboLogLevel.DEBUG), ...args); }
135
+ info(...args) { if (this.shouldLog(ProboLogLevel.INFO))
136
+ console.info(this.preamble(ProboLogLevel.INFO), ...args); }
137
+ log(...args) { if (this.shouldLog(ProboLogLevel.LOG))
138
+ console.log(this.preamble(ProboLogLevel.LOG), ...args); }
139
+ warn(...args) { if (this.shouldLog(ProboLogLevel.WARN))
140
+ console.warn(this.preamble(ProboLogLevel.WARN), ...args); }
141
+ error(...args) { if (this.shouldLog(ProboLogLevel.ERROR))
142
+ console.error(this.preamble(ProboLogLevel.ERROR), ...args); }
143
+ }
144
+ // Element cleaner logging
145
+ // const elementLogger = new ProboLogger('element-cleaner');
146
+ /**
147
+ * Cleans and returns a minimal element info structure.
148
+ */
149
+ //TODO: is this needed?
150
+ /* export function cleanupElementInfo(elementInfo: ElementInfo): CleanElementInfo {
151
+ elementLogger.debug(
152
+ `Cleaning up element info for ${elementInfo.tag} at index ${elementInfo.index}`
153
+ );
154
+ const depth = elementInfo.depth ?? elementInfo.getDepth();
155
+ const cleanEl = {
156
+ index: elementInfo.index,
157
+ tag: elementInfo.tag,
158
+ type: elementInfo.type,
159
+ text: elementInfo.text,
160
+ html: elementInfo.html,
161
+ xpath: elementInfo.xpath,
162
+ css_selector: elementInfo.css_selector,
163
+ iframe_selector: elementInfo.iframe_selector,
164
+ bounding_box: elementInfo.bounding_box,
165
+ depth
166
+ };
167
+ elementLogger.debug(`Cleaned element: ${JSON.stringify(cleanEl)}`);
168
+ return cleanEl;
169
+ } */
170
+ /**
171
+ * Cleans highlighted elements in an instruction payload.
172
+ */
173
+ /* export function cleanupInstructionElements(instruction: any): any {
174
+ if (!instruction?.result?.highlighted_elements) {
175
+ elementLogger.debug('No highlighted elements to clean');
176
+ return instruction;
177
+ }
178
+ elementLogger.debug(
179
+ `Cleaning ${instruction.result.highlighted_elements.length} highlighted elements`
180
+ );
181
+ const cleaned = {
182
+ ...instruction,
183
+ result: {
184
+ ...instruction.result,
185
+ highlighted_elements: instruction.result.highlighted_elements.map(
186
+ (el: ElementInfo) => cleanupElementInfo(el)
187
+ )
188
+ }
189
+ };
190
+ elementLogger.debug('Instruction cleaning completed');
191
+ return cleaned;
192
+ } */
193
+ // Determine whether an interaction can return a value
194
+ const hasReturnValue = (i) => {
195
+ return [
196
+ PlaywrightAction.EXTRACT_VALUE,
197
+ PlaywrightAction.ASK_AI,
198
+ PlaywrightAction.EXECUTE_SCRIPT
199
+ ].includes(i.action);
200
+ };
201
+ const getReturnValueParameterName = (i) => {
202
+ switch (i.action) {
203
+ case PlaywrightAction.EXTRACT_VALUE:
204
+ case PlaywrightAction.EXECUTE_SCRIPT:
205
+ return i.parameterName;
206
+ case PlaywrightAction.ASK_AI:
207
+ return i.parameterName.replace(/^assert_/, '');
208
+ default:
209
+ console.error(`Action ${i.action} has no return value`);
210
+ return '';
211
+ }
212
+ };
213
+ // Determine whether an interaction can be parameterized
214
+ const isParameterizable = (i) => {
215
+ const parameterizableActions = [
216
+ PlaywrightAction.FILL_IN,
217
+ PlaywrightAction.SELECT_DROPDOWN,
218
+ PlaywrightAction.SET_SLIDER,
219
+ PlaywrightAction.ASSERT_CONTAINS_VALUE,
220
+ PlaywrightAction.ASSERT_EXACT_VALUE,
221
+ PlaywrightAction.VISIT_URL,
222
+ PlaywrightAction.ASSERT_URL,
223
+ PlaywrightAction.UPLOAD_FILES,
224
+ PlaywrightAction.WAIT_FOR,
225
+ PlaywrightAction.GEN_TOTP,
226
+ PlaywrightAction.WAIT_FOR_OTP
227
+ ];
228
+ return parameterizableActions.includes(i.action) || (i.action === PlaywrightAction.ASK_AI && i.argument);
229
+ };
230
+ // Determine whether an interaction is AI-related
231
+ const isAI = (i) => {
232
+ var _a, _b, _c, _d, _e, _f;
233
+ return !['TYPE_KEYS', 'VISIT_URL', 'EXECUTE_SCRIPT'].includes(i.action) &&
234
+ (i.action === PlaywrightAction.ASK_AI ||
235
+ (((_b = (_a = i.serverResponse) === null || _a === void 0 ? void 0 : _a.result) === null || _b === void 0 ? void 0 : _b.prompt) && (((_d = (_c = i.serverResponse) === null || _c === void 0 ? void 0 : _c.result) === null || _d === void 0 ? void 0 : _d.error) === "" || !((_f = (_e = i.serverResponse) === null || _e === void 0 ? void 0 : _e.result) === null || _f === void 0 ? void 0 : _f.error))));
236
+ };
237
+ function singleQuoteString(str) {
238
+ if (!str)
239
+ return '';
240
+ return `'${str.replace(/'/g, "\\'")}'`;
241
+ }
242
+ /**
243
+ * Converts a string to a filesystem-safe slug.
244
+ * Used for filenames/package names (not URL parsing).
245
+ */
246
+ function slugify(text) {
247
+ if (!text)
248
+ return 'scenario';
249
+ return text
250
+ .toLowerCase()
251
+ .trim()
252
+ .replace(/[^a-zA-Z0-9-_]/g, '-') // Replace non-alphanumeric chars with hyphens
253
+ .replace(/-+/g, '-') // Replace multiple hyphens with single hyphen
254
+ .replace(/^-|-$/g, '') // Remove leading/trailing hyphens
255
+ .substring(0, 100); // Limit length to 100 chars
256
+ }
257
+
258
+ new ProboLogger('apiclient');
259
+
260
+ /**
261
+ * Available AI models for LLM operations
262
+ */
263
+ var AIModel;
264
+ (function (AIModel) {
265
+ AIModel["AZURE_GPT4"] = "azure-gpt4";
266
+ AIModel["AZURE_GPT4_MINI"] = "azure-gpt4-mini";
267
+ AIModel["GEMINI_1_5_FLASH"] = "gemini-1.5-flash";
268
+ AIModel["GEMINI_2_5_FLASH"] = "gemini-2.5-flash";
269
+ AIModel["GPT4"] = "gpt4";
270
+ AIModel["GPT4_MINI"] = "gpt4-mini";
271
+ AIModel["CLAUDE_3_5"] = "claude-3.5";
272
+ AIModel["CLAUDE_SONNET_4_5"] = "claude-sonnet-4.5";
273
+ AIModel["CLAUDE_HAIKU_4_5"] = "claude-haiku-4.5";
274
+ AIModel["CLAUDE_OPUS_4_1"] = "claude-opus-4.1";
275
+ AIModel["GROK_2"] = "grok-2";
276
+ AIModel["LLAMA_4_SCOUT"] = "llama-4-scout";
277
+ AIModel["DEEPSEEK_V3"] = "deepseek-v3";
278
+ })(AIModel || (AIModel = {}));
279
+
280
+ const DEFAULT_PLAYWRIGHT_TIMEOUT_CONFIG = {
281
+ highlightTimeout: 300,
282
+ playwrightActionTimeout: 5000,
283
+ playwrightNavigationTimeout: 10000,
284
+ playwrightLocatorTimeout: 2000,
285
+ // mutation observer
286
+ mutationsTimeout: 500,
287
+ mutationsInitTimeout: 1000,
288
+ // wait for navigation
289
+ waitForNavigationInitialTimeout: 2000,
290
+ waitForNavigationNavigationTimeout: 7000,
291
+ waitForNavigationGlobalTimeout: 15000,
292
+ // wait for stability
293
+ waitForStabilityQuietTimeout: 2000,
294
+ waitForStabilityInitialDelay: 100,
295
+ waitForStabilityGlobalTimeout: 15000,
296
+ waitForStabilityVerbose: false,
297
+ scriptTimeout: 120000, // 2 minutes
298
+ };
299
+
300
+ const DEFAULT_RECORDER_SETTINGS = {
301
+ // API Configuration
302
+ apiKey: '',
303
+ apiEndPoint: 'https://api.probolabs.ai',
304
+ frontendUrl: 'https://app.probolabs.ai',
305
+ baseUrl: undefined,
306
+ // Scenario Configuration
307
+ scenarioName: 'new recording',
308
+ scenarioId: undefined,
309
+ aiModel: 'azure-gpt4-mini',
310
+ activeParamSet: 0,
311
+ // Browser Configuration
312
+ resetBrowserBeforeReplay: true,
313
+ enableSmartSelectors: true, // Smart selectors always enabled
314
+ // Script Configuration
315
+ scriptTimeout: DEFAULT_PLAYWRIGHT_TIMEOUT_CONFIG.scriptTimeout,
316
+ // Logging Configuration
317
+ enableConsoleLogs: true,
318
+ debugLevel: 'DEBUG',
319
+ // Timeout Configuration (spread from PlaywrightTimeoutConfig)
320
+ ...DEFAULT_PLAYWRIGHT_TIMEOUT_CONFIG,
321
+ };
322
+
323
+ // --- Code generation utilities for Probo Labs Playwright scripts ---
324
+ /**
325
+ * Extracts environment variable names from parameter table rows
326
+ * by parsing ${process.env.VAR_NAME} patterns from parameter values
327
+ *
328
+ * @param rows - Array of parameter table rows (Record<string, string>)
329
+ * @returns Set of unique environment variable names (in uppercase)
330
+ */
331
+ function extractRequiredEnvVars(rows) {
332
+ const envVars = new Set();
333
+ // Return empty set if rows are not provided or empty
334
+ if (!rows || !Array.isArray(rows) || rows.length === 0) {
335
+ return envVars;
336
+ }
337
+ // Regex to match both escaped and unescaped process.env patterns
338
+ // Matches: ${process.env.VAR_NAME} or \${process.env.VAR_NAME}
339
+ // Pattern: optional backslash (\\), then ${ (escaped as \$\{), then process.env., then variable name
340
+ const envVarPattern = /\\?\$\{process\.env\.([a-zA-Z_][a-zA-Z0-9_]*)\}/g;
341
+ // Scan all parameter values in all rows for env var patterns
342
+ rows.forEach((row) => {
343
+ if (row && typeof row === 'object') {
344
+ Object.values(row).forEach((value) => {
345
+ if (typeof value === 'string') {
346
+ let match;
347
+ while ((match = envVarPattern.exec(value)) !== null) {
348
+ const varName = match[1].toUpperCase(); // Ensure uppercase for consistency
349
+ envVars.add(varName);
350
+ }
351
+ // Reset regex lastIndex for next iteration
352
+ envVarPattern.lastIndex = 0;
353
+ }
354
+ });
355
+ }
356
+ });
357
+ return envVars;
358
+ }
359
+ /**
360
+ * Generates Playwright native code for a given interaction.
361
+ *
362
+ * @param interaction - The interaction object containing action, element info, and other metadata.
363
+ * @returns A string of Playwright code that performs the specified interaction.
364
+ */
365
+ function interactionToNativeCode(interaction) {
366
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
367
+ const selector = ((_a = interaction.elementInfo) === null || _a === void 0 ? void 0 : _a.css_selector) || '';
368
+ const iframe = ((_b = interaction.elementInfo) === null || _b === void 0 ? void 0 : _b.iframe_selector) || '';
369
+ const smartSelector = ((_c = interaction.elementInfo) === null || _c === void 0 ? void 0 : _c.smart_selector) || null;
370
+ const smartIFrameSelector = ((_d = interaction.elementInfo) === null || _d === void 0 ? void 0 : _d.smart_iframe_selector) || null;
371
+ const argument = isParameterizable(interaction) ? `param.${interaction.parameterName}` : singleQuoteString(interaction.argument);
372
+ // Escape the nativeDescription for use in test.step() string literal
373
+ const escapedStepName = interaction.nativeDescription.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
374
+ const comment = `
375
+ // ${interaction.nativeDescription}${interaction.annotation ? `\n // annotation: ${interaction.annotation}` : ''}`;
376
+ switch (interaction.action) {
377
+ case PlaywrightAction.CLICK:
378
+ case PlaywrightAction.CHECK_CHECKBOX:
379
+ case PlaywrightAction.SELECT_RADIO:
380
+ case PlaywrightAction.FILL_IN:
381
+ case PlaywrightAction.TYPE_KEYS:
382
+ case PlaywrightAction.SET_SLIDER:
383
+ case PlaywrightAction.SELECT_DROPDOWN:
384
+ case PlaywrightAction.ASSERT_CONTAINS_VALUE:
385
+ case PlaywrightAction.ASSERT_EXACT_VALUE:
386
+ case PlaywrightAction.HOVER:
387
+ case PlaywrightAction.SCROLL_TO_ELEMENT:
388
+ case PlaywrightAction.UPLOAD_FILES:
389
+ case PlaywrightAction.VISIT_URL:
390
+ case PlaywrightAction.VISIT_BASE_URL:
391
+ case PlaywrightAction.ASSERT_URL:
392
+ case PlaywrightAction.WAIT_FOR:
393
+ case PlaywrightAction.WAIT_FOR_OTP:
394
+ case PlaywrightAction.GEN_TOTP:
395
+ const args = [
396
+ `action: PlaywrightAction.${interaction.action}`,
397
+ ...(argument ? [`argument: ${argument}`] : []),
398
+ ...(iframe ? [`iframeSelector: '${iframe}'`] : []),
399
+ ...(selector ? [`elementSelector: '${selector}'`] : []),
400
+ ...(smartSelector ? [`smartSelector: ${JSON.stringify(smartSelector)}`] : []),
401
+ ...(smartIFrameSelector ? [`smartIFrameSelector: ${JSON.stringify(smartIFrameSelector)}`] : []),
402
+ ...(interaction.annotation ? [`annotation: '${interaction.annotation}'`] : []),
403
+ ...(((_e = interaction.waitForConfig) === null || _e === void 0 ? void 0 : _e.pollingInterval) ? [`pollingInterval: ${(_f = interaction.waitForConfig) === null || _f === void 0 ? void 0 : _f.pollingInterval}`] : []),
404
+ ...(((_g = interaction.waitForConfig) === null || _g === void 0 ? void 0 : _g.timeout) ? [`timeout: ${(_h = interaction.waitForConfig) === null || _h === void 0 ? void 0 : _h.timeout}`] : []),
405
+ // Always include totpConfig for GEN_TOTP actions, using defaults if not present in interaction
406
+ ...(interaction.action === PlaywrightAction.GEN_TOTP ? [`totpConfig: { digits: ${((_j = interaction.totpConfig) === null || _j === void 0 ? void 0 : _j.digits) || 6}, algorithm: '${((_k = interaction.totpConfig) === null || _k === void 0 ? void 0 : _k.algorithm) || 'SHA1'}' }`] : [])
407
+ ];
408
+ return `${comment}
409
+ await test.step("${escapedStepName}", async () => {
410
+ await ppw.runStep({
411
+ ${args.join(',\n ')}
412
+ });
413
+ });`;
414
+ case PlaywrightAction.EXTRACT_VALUE:
415
+ const extractVarName = getReturnValueParameterName(interaction);
416
+ return `${comment}
417
+ await test.step("${escapedStepName}", async () => {
418
+ const ${extractVarName} = await ppw.runStep({
419
+ iframeSelector: '${iframe}',
420
+ elementSelector: '${selector}',
421
+ smartSelector: ${JSON.stringify(smartSelector)},
422
+ smartIFrameSelector: ${JSON.stringify(smartIFrameSelector)},
423
+ action: '${interaction.action}',
424
+ annotation: '${(_l = interaction.annotation) !== null && _l !== void 0 ? _l : ""}',
425
+ });
426
+ param['${extractVarName}'] = ${extractVarName};
427
+ });`;
428
+ case PlaywrightAction.EXECUTE_SCRIPT:
429
+ return generateExecuteScriptCode(interaction, comment, escapedStepName);
430
+ default:
431
+ return `// Unhandled action: ${interaction.action}!!!`;
432
+ }
433
+ }
434
+ function interactionToProboLib(interaction) {
435
+ var _a, _b, _c;
436
+ const escapedPrompt = (_c = (_b = (_a = interaction.serverResponse) === null || _a === void 0 ? void 0 : _a.result) === null || _b === void 0 ? void 0 : _b.prompt) === null || _c === void 0 ? void 0 : _c.replace(/'/g, "\\'");
437
+ const argument = isParameterizable(interaction) ? `param.${interaction.parameterName}` : `'${interaction.argument}'`;
438
+ // Escape the nativeDescription for use in test.step() string literal
439
+ const escapedStepName = interaction.nativeDescription.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
440
+ const comment = `
441
+ // ${interaction.nativeDescription}${interaction.annotation ? `\n // annotation: ${interaction.annotation}` : ''}`;
442
+ if (interaction.action === PlaywrightAction.EXTRACT_VALUE) {
443
+ const extractVarName = getReturnValueParameterName(interaction);
444
+ return `${comment}
445
+ await test.step("${escapedStepName}", async () => {
446
+ const ${extractVarName} = await probo.runStep(page, '${escapedPrompt}', null, { stepId: ${interaction.stepId} });
447
+ param['${extractVarName}'] = ${extractVarName};
448
+ });`;
449
+ }
450
+ else if (interaction.action === PlaywrightAction.ASSERT_CONTAINS_VALUE ||
451
+ interaction.action === PlaywrightAction.ASSERT_EXACT_VALUE) {
452
+ return `${comment}
453
+ await test.step("${escapedStepName}", async () => {
454
+ await probo.runStep(page, '${escapedPrompt}', ${argument}, { stepId: ${interaction.stepId} });
455
+ });`;
456
+ }
457
+ else if (interaction.action === PlaywrightAction.VISIT_URL) {
458
+ return `${comment}
459
+ await test.step("${escapedStepName}", async () => {
460
+ await probo.runStep(page, '${interaction.nativeDescription}', ${argument}, { stepId: ${interaction.stepId} });
461
+ });`;
462
+ }
463
+ else if (interaction.action === PlaywrightAction.ASK_AI) {
464
+ const escapedQuestion = interaction.nativeDescription.replace(/'/g, "\\'");
465
+ const aiVarName = getReturnValueParameterName(interaction);
466
+ // const assertion = interaction.argument ? `
467
+ const askAIOptions = interaction.stepId ? `{ stepId: ${interaction.stepId} }` : '{}';
468
+ return `${comment}
469
+ await test.step("${escapedStepName}", async () => {
470
+ const ${aiVarName} = await probo.askAI(page, '${escapedQuestion}', ${askAIOptions}${argument ? `, ${argument}` : ''});
471
+ param['${aiVarName}'] = ${aiVarName};
472
+ });`;
473
+ }
474
+ else if (interaction.action === PlaywrightAction.EXECUTE_SCRIPT) {
475
+ return generateExecuteScriptCode(interaction, comment, escapedStepName);
476
+ }
477
+ else {
478
+ return `${comment}
479
+ await test.step("${escapedStepName}", async () => {
480
+ ${argument ? `await probo.runStep(page, '${escapedPrompt}', ${argument}, { stepId: ${interaction.stepId} });` : `await probo.runStep(page, '${escapedPrompt}', null, { stepId: ${interaction.stepId} });`}
481
+ });`;
482
+ }
483
+ }
484
+ function generateExecuteScriptCode(interaction, comment, escapedStepName) {
485
+ let scriptCode = Array.isArray(interaction.argument) ? interaction.argument[0] : interaction.argument;
486
+ const scriptVarName = getReturnValueParameterName(interaction);
487
+ // Replace returnValue(x) with 'const param = x;'
488
+ scriptCode = scriptCode.replace(/returnValue\((.*)\);?/, `${scriptVarName} = $1;`);
489
+ // Trim trailing whitespace
490
+ scriptCode = scriptCode.trimEnd();
491
+ // If scriptCode doesn't end with a semicolon add one
492
+ if (!/\s*;\s*$/.test(scriptCode))
493
+ scriptCode = scriptCode + ';';
494
+ // Indent the code (extra indent for test.step wrapper)
495
+ const indentedCode = scriptCode.split('\n').map((line, idx) => idx === 0 ? line : ` ${line}`).join('\n');
496
+ return `${comment}
497
+ await test.step("${escapedStepName}", async () => {
498
+ let ${scriptVarName} = null;
499
+ try {
500
+ ${indentedCode}
501
+ } catch (error) {
502
+ console.error('❌ Workspace script execution failed:', error.message);
503
+ throw error;
504
+ }
505
+ param['${scriptVarName}'] = ${scriptVarName};
506
+ });`;
507
+ }
508
+ function scriptTemplate(options, settings, viewPort) {
509
+ var _a, _b, _c, _d;
510
+ const areActionsParameterized = options.rows.length > 0;
511
+ const hasAiInteractions = options.interactions.some((interaction) => isAI(interaction));
512
+ const hasScriptInteractions = options.interactions.some((interaction) => interaction.action === PlaywrightAction.EXECUTE_SCRIPT);
513
+ // const uniquelyParameterizedInteractions = uniquifyInteractionParameters(options.interactions);
514
+ const steps = options.interactions.map((interaction) => {
515
+ if (isAI(interaction))
516
+ return interactionToProboLib(interaction);
517
+ return interactionToNativeCode(interaction);
518
+ }).join('\n');
519
+ // Get list of all extracted value parameter names to filter them out from the parameter table
520
+ const extractedValueNames = new Set();
521
+ options.interactions.forEach((interaction) => {
522
+ if (hasReturnValue(interaction)) {
523
+ const retName = getReturnValueParameterName(interaction);
524
+ if (retName) {
525
+ extractedValueNames.add(retName);
526
+ }
527
+ }
528
+ });
529
+ // Escape apostrophes and backslashes for single-quoted strings
530
+ const escapeForSingleQuotes = (value) => {
531
+ if (!value || typeof value !== 'string')
532
+ return ''; //check for null, undefined, or non-string values
533
+ //escape backslashes and single quotes
534
+ return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
535
+ };
536
+ // Lookup value from settings
537
+ const aiModelKey = Object.keys(AIModel).find(key => AIModel[key] === settings.aiModel);
538
+ // Check if any interactions have the isSecret flag set
539
+ const hasSecrets = options.interactions.some(interaction => interaction.isSecret === true);
540
+ // Extract required environment variable names from parameter table rows
541
+ const requiredEnvVars = extractRequiredEnvVars(options.rows);
542
+ const requiredEnvVarsArray = Array.from(requiredEnvVars).sort();
543
+ // generate the parameter table (exclude extracted values as they are declared as const in the steps)
544
+ const parameterTable = areActionsParameterized ? `
545
+ const parameterTable: Record<string, any>[] = [
546
+ ${options.rows.map((row) => {
547
+ // Filter out extracted value columns from the parameter table
548
+ const filteredRow = Object.fromEntries(Object.entries(row).filter(([key]) => !extractedValueNames.has(key)));
549
+ return `{ ${Object.entries(filteredRow).map(([key, value]) => `${key}: '${escapeForSingleQuotes(value)}'`).join(', ')} }`;
550
+ }).join(',\n ')}
551
+ ];
552
+ ` : '';
553
+ const proboConstructor = hasAiInteractions ? `
554
+ const probo = new Probo({
555
+ scenarioName: '${options.scenarioName}',
556
+ enableSmartSelectors: ${settings.enableSmartSelectors},
557
+ debugLevel: ProboLogLevel.${settings.debugLevel},
558
+ aiModel: AIModel.${aiModelKey},
559
+ timeoutConfig: {
560
+ waitForStabilityQuietTimeout: ${(_a = settings.waitForStabilityQuietTimeout) !== null && _a !== void 0 ? _a : 2000},
561
+ waitForStabilityInitialDelay: ${(_b = settings.waitForStabilityInitialDelay) !== null && _b !== void 0 ? _b : 500},
562
+ waitForStabilityGlobalTimeout: ${(_c = settings.waitForStabilityGlobalTimeout) !== null && _c !== void 0 ? _c : 15000},
563
+ waitForStabilityVerbose: ${(_d = settings.waitForStabilityVerbose) !== null && _d !== void 0 ? _d : false}
564
+ }
565
+ });` : '';
566
+ return `
567
+ /*
568
+ * Probo Labs Playwright Script
569
+ * Scenario: ${options.scenarioName}
570
+ * Auto generated on ${new Date().toLocaleDateString()} ${new Date().toLocaleTimeString()}
571
+ *
572
+ * HOW TO INTEGRATE THIS SCRIPT INTO YOUR EXISTING PROJECT:
573
+ * 1. Install dependencies: npm install @playwright/test @probolabs/playwright@latest${hasSecrets ? ' dotenv' : ''}
574
+ * 2. copy and paste the code below into your test file${hasSecrets ? '\n * 3. Ensure a .env file exists in your project root (or ~/.probium/.env) or set environment variables manually' : ''}
575
+ */
576
+
577
+ // launch chromium with pre-installed extensions
578
+ import { test, expect} from '@probolabs/playwright/fixtures';
579
+ import { ProboPlaywright, Probo, PlaywrightAction, ProboLogLevel, AIModel } from '@probolabs/playwright';${hasScriptInteractions ? `
580
+ import { execSync } from 'child_process';
581
+ import * as fs from 'fs';
582
+ import * as path from 'path';
583
+ import * as os from 'os';` : ''}${hasSecrets && !hasScriptInteractions ? `
584
+ import * as path from 'path';
585
+ import * as os from 'os';
586
+ import * as fs from 'fs';` : ''}
587
+
588
+ ${hasSecrets ? `import { config } from 'dotenv';
589
+ // Load secrets: first try local .env (project root), then fallback to ~/.probium/.env
590
+ const localEnvPath = path.join(process.cwd(), '.env');
591
+ const homeEnvPath = path.join(os.homedir(), '.probium', '.env');
592
+
593
+ if (fs.existsSync(localEnvPath)) {
594
+ config({ path: localEnvPath });
595
+ console.log('✅ Loaded env vars from project .env:', localEnvPath);
596
+ } else if (fs.existsSync(homeEnvPath)) {
597
+ config({ path: homeEnvPath });
598
+ console.log('✅ Loaded env vars from home .env:', homeEnvPath);
599
+ } else {
600
+ console.warn('⚠️ .env not found at', localEnvPath, 'or', homeEnvPath, '- ensure environment variables are set before running tests');
601
+ }
602
+
603
+ // Validate that all required secrets are set
604
+ const requiredSecrets: string[] = ${JSON.stringify(requiredEnvVarsArray)};
605
+ const missingSecrets = requiredSecrets.filter(varName => !process.env[varName] || process.env[varName].trim() === '');
606
+ if (missingSecrets.length > 0) {
607
+ throw new Error(\`Missing required environment variables: \${missingSecrets.join(', ')}. Please set them in your .env file or as environment variables.\`);
608
+ }
609
+ console.log(\`✅ All required secrets are set: \${requiredSecrets.join(', ')}\`);
610
+ ` : ''}
611
+
612
+ ${parameterTable}
613
+ ${proboConstructor}
614
+ const ppw = new ProboPlaywright({
615
+ enableSmartSelectors: ${settings.enableSmartSelectors},
616
+ debugLevel: ProboLogLevel.${settings.debugLevel},
617
+ timeoutConfig: {
618
+ waitForStabilityQuietTimeout: ${settings.waitForStabilityQuietTimeout},
619
+ waitForStabilityInitialDelay: ${settings.waitForStabilityInitialDelay},
620
+ waitForStabilityGlobalTimeout: ${settings.waitForStabilityGlobalTimeout},
621
+ waitForStabilityVerbose: ${settings.waitForStabilityVerbose},
622
+ highlightTimeout: ${settings.highlightTimeout},
623
+ playwrightActionTimeout: ${settings.playwrightActionTimeout},
624
+ playwrightNavigationTimeout: ${settings.playwrightNavigationTimeout},
625
+ playwrightLocatorTimeout: ${settings.playwrightLocatorTimeout}
626
+ }
627
+ });
628
+
629
+ test.describe('${options.scenarioName}', () => {
630
+ test.beforeEach(async ({ page }) => {
631
+ // set the ProboPlaywright instance to the current page
632
+ ppw.setPage(page);
633
+ // set the viewport dimensions to be identical to their values during recording
634
+ await page.setViewportSize({ width: ${viewPort.width}, height: ${viewPort.height} });
635
+ });
636
+
637
+ for (const param of parameterTable) {
638
+ test(param.testName, async ({ page }) => {
639
+ ${hasAiInteractions ? `probo.setParams(param);` : ''}
640
+ ppw.setParams(param);
641
+ ${steps}
642
+ });
643
+ }
644
+ });
645
+ `;
646
+ }
647
+ /**
648
+ * Generates Playwright code for a scenario
649
+ * @param options - Code generation options (scenarioName, interactions, rows)
650
+ * @param settings - Recorder settings (timeouts, AI model, etc.)
651
+ * @param viewPort - Viewport dimensions {width, height}
652
+ * @returns Generated Playwright test code as a string
653
+ */
654
+ function generateCode(options, settings, viewPort) {
655
+ return scriptTemplate(options, settings, viewPort);
656
+ }
657
+ /**
658
+ * Generate package.json content for a Playwright test project
659
+ */
660
+ function generatePackageJson(options) {
661
+ const { name, hasSecrets = false, testScript = 'npx playwright test' } = options;
662
+ return `{
663
+ "name": "${name}",
664
+ "version": "1.0.0",
665
+ "description": "Probo Labs Playwright Script",
666
+ "type": "module",
667
+ "scripts": {
668
+ "test": "${testScript}"
669
+ },
670
+ "dependencies": {
671
+ "@playwright/test": "^1.40.0",
672
+ "@probolabs/playwright": "latest"${hasSecrets ? ',\n "dotenv": "latest"' : ''}
673
+ }
674
+ }`;
675
+ }
676
+ /**
677
+ * Generate playwright.config.ts content
678
+ */
679
+ function generatePlaywrightConfig(includeReporter = true, runId) {
680
+ const reporters = [
681
+ ['list'],
682
+ ['html', { open: 'never' }],
683
+ ];
684
+ // Configure run-specific output directories if runId is provided
685
+ if (runId !== undefined) {
686
+ reporters[1] = ['html', { open: 'never', outputFolder: `playwright-report/run-${runId}` }];
687
+ }
688
+ if (includeReporter) {
689
+ reporters.push(['./probo-reporter.ts']);
690
+ }
691
+ // Configure outputDir for run-specific test results if runId is provided
692
+ const outputDirConfig = runId !== undefined ? `\n outputDir: 'test-results/run-${runId}',` : '';
693
+ return `import { defineConfig } from '@playwright/test';
694
+
695
+ export default defineConfig({
696
+ testDir: 'tests',
697
+ testMatch: /.*\\.(ts|js)$/,
698
+ timeout: 600000,${outputDirConfig}
699
+ // Keep Playwright's default console output, plus HTML report${includeReporter ? ', plus Probo live progress reporter' : ''}.
700
+ reporter: ${JSON.stringify(reporters)},
701
+ use: {
702
+ headless: true,
703
+ ignoreHTTPSErrors: true,
704
+ actionTimeout: 30000,
705
+ navigationTimeout: 30000,
706
+ video: 'off',
707
+ screenshot: 'on',
708
+ trace: 'on',
709
+ },
710
+ retries: 0,
711
+ workers: 1,
712
+ });
713
+ `;
714
+ }
715
+ /**
716
+ * Generate a Playwright reporter that emits structured events to stdout.
717
+ *
718
+ * The recorder app runner can parse these lines and broadcast them to the UI.
719
+ */
720
+ function generateProboReporter() {
721
+ // NOTE: Keep this file dependency-free; it runs inside the generated test suite project.
722
+ // It prints one JSON payload per line, prefixed for easy extraction from stdout.
723
+ return `import type { Reporter, FullConfig, Suite, TestCase, TestResult, TestStep } from '@playwright/test/reporter';
724
+
725
+ const PREFIX = '__PROBO_REPORTER_EVENT__';
726
+
727
+ type ProboReporterEvent =
728
+ | {
729
+ v: 1;
730
+ ts: number;
731
+ eventType: 'runBegin';
732
+ config: { workers: number; retries: number; projects: string[] };
733
+ }
734
+ | {
735
+ v: 1;
736
+ ts: number;
737
+ eventType: 'runEnd';
738
+ status: 'passed' | 'failed' | 'timedout' | 'interrupted';
739
+ }
740
+ | {
741
+ v: 1;
742
+ ts: number;
743
+ eventType: 'testBegin';
744
+ test: ProboTestRef;
745
+ }
746
+ | {
747
+ v: 1;
748
+ ts: number;
749
+ eventType: 'testEnd';
750
+ test: ProboTestRef;
751
+ result: {
752
+ status: 'passed' | 'failed' | 'timedout' | 'skipped' | 'interrupted';
753
+ expectedStatus: 'passed' | 'failed' | 'timedout' | 'skipped';
754
+ duration: number;
755
+ errors: string[];
756
+ };
757
+ }
758
+ | {
759
+ v: 1;
760
+ ts: number;
761
+ eventType: 'stepBegin';
762
+ test: ProboTestRef;
763
+ step: ProboStepRef & { title: string; category?: string; depth: number };
764
+ }
765
+ | {
766
+ v: 1;
767
+ ts: number;
768
+ eventType: 'stepEnd';
769
+ test: ProboTestRef;
770
+ step: ProboStepRef & { duration?: number; error?: string | null };
771
+ };
772
+
773
+ type ProboLocation = { file?: string; line?: number; column?: number };
774
+ type ProboTestRef = {
775
+ id?: string;
776
+ title: string;
777
+ titlePath: string[];
778
+ location?: ProboLocation;
779
+ };
780
+ type ProboStepRef = {
781
+ id: number;
782
+ location?: ProboLocation;
783
+ };
784
+
785
+ function safeLocation(loc: any): ProboLocation | undefined {
786
+ if (!loc || typeof loc !== 'object') return undefined;
787
+ const file = typeof loc.file === 'string' ? loc.file : undefined;
788
+ const line = typeof loc.line === 'number' ? loc.line : undefined;
789
+ const column = typeof loc.column === 'number' ? loc.column : undefined;
790
+ return file || line || column ? { file, line, column } : undefined;
791
+ }
792
+
793
+ function safeTitlePath(test: TestCase): string[] {
794
+ try {
795
+ // titlePath() exists on TestCase and includes describe blocks.
796
+ return test.titlePath();
797
+ } catch {
798
+ return [test.title];
799
+ }
800
+ }
801
+
802
+ function testRef(test: TestCase): ProboTestRef {
803
+ return {
804
+ // @ts-expect-error: Playwright has test.id at runtime; keep optional for forward/back compat.
805
+ id: (test as any).id,
806
+ title: test.title,
807
+ titlePath: safeTitlePath(test),
808
+ location: safeLocation((test as any).location),
809
+ };
810
+ }
811
+
812
+ function serializeErrors(result: TestResult): string[] {
813
+ const errors: any[] = (result as any).errors || [];
814
+ if (!Array.isArray(errors)) return [];
815
+ return errors
816
+ .map((e) => {
817
+ if (!e) return null;
818
+ if (typeof e === 'string') return e;
819
+ if (typeof e.message === 'string') return e.message;
820
+ if (typeof e.value === 'string') return e.value;
821
+ try {
822
+ return JSON.stringify(e);
823
+ } catch {
824
+ return String(e);
825
+ }
826
+ })
827
+ .filter(Boolean) as string[];
828
+ }
829
+
830
+ function emit(event: ProboReporterEvent) {
831
+ try {
832
+ process.stdout.write(PREFIX + JSON.stringify(event) + '\\n');
833
+ } catch {
834
+ // Ignore reporter failures; never break the run.
835
+ }
836
+ }
837
+
838
+ /**
839
+ * Check if a step should be included in the output.
840
+ * Filters out hooks, fixtures, and internal Playwright operations.
841
+ */
842
+ function shouldIncludeStep(step: TestStep): boolean {
843
+ const category = (step as any).category;
844
+ const title = step.title?.toLowerCase() || '';
845
+
846
+ // Filter out hooks (beforeAll, beforeEach, afterAll, afterEach)
847
+ if (category === 'hook') {
848
+ return false;
849
+ }
850
+
851
+ // Filter out fixtures
852
+ if (category === 'fixture') {
853
+ return false;
854
+ }
855
+
856
+ // Filter out internal waiting/timeout operations
857
+ if (title.includes('waiting for') ||
858
+ title.includes('wait for') ||
859
+ title.includes('timeout') ||
860
+ title.includes('attaching') ||
861
+ title.startsWith('attach')) {
862
+ return false;
863
+ }
864
+
865
+ // Include user-defined steps and assertions only (exclude pw:api like click, evaluate, etc.)
866
+ return category === 'test.step' ||
867
+ category === 'expect' ||
868
+ !category; // Include steps without category (likely user actions)
869
+ }
870
+
871
+ export default class ProboReporter implements Reporter {
872
+ private stepIds = new WeakMap<TestStep, number>();
873
+ private stepDepths = new WeakMap<TestStep, number>();
874
+ private nextStepId = 1;
875
+
876
+ onBegin(config: FullConfig, suite: Suite) {
877
+ emit({
878
+ v: 1,
879
+ ts: Date.now(),
880
+ eventType: 'runBegin',
881
+ config: {
882
+ workers: config.workers,
883
+ retries: config.retries,
884
+ projects: (config.projects || []).map((p) => p.name || 'project'),
885
+ },
886
+ });
887
+ }
888
+
889
+ onEnd(result: any) {
890
+ emit({
891
+ v: 1,
892
+ ts: Date.now(),
893
+ eventType: 'runEnd',
894
+ status: result?.status || 'failed',
895
+ });
896
+ }
897
+
898
+ onTestBegin(test: TestCase, result: TestResult) {
899
+ emit({
900
+ v: 1,
901
+ ts: Date.now(),
902
+ eventType: 'testBegin',
903
+ test: testRef(test),
904
+ });
905
+ }
906
+
907
+ onTestEnd(test: TestCase, result: TestResult) {
908
+ emit({
909
+ v: 1,
910
+ ts: Date.now(),
911
+ eventType: 'testEnd',
912
+ test: testRef(test),
913
+ result: {
914
+ status: result.status,
915
+ expectedStatus: result.expectedStatus,
916
+ duration: result.duration,
917
+ errors: serializeErrors(result),
918
+ },
919
+ });
920
+ }
921
+
922
+ onStepBegin(test: TestCase, result: TestResult, step: TestStep) {
923
+ // Filter out hooks, fixtures, and internal operations
924
+ if (!shouldIncludeStep(step)) {
925
+ return;
926
+ }
927
+
928
+ const id = this.stepIds.get(step) ?? this.nextStepId++;
929
+ this.stepIds.set(step, id);
930
+
931
+ const depth = this.stepDepths.get(step) ?? (step.parent ? (this.stepDepths.get(step.parent) ?? 0) + 1 : 0);
932
+ this.stepDepths.set(step, depth);
933
+
934
+ emit({
935
+ v: 1,
936
+ ts: Date.now(),
937
+ eventType: 'stepBegin',
938
+ test: testRef(test),
939
+ step: {
940
+ id,
941
+ title: step.title,
942
+ category: (step as any).category,
943
+ depth,
944
+ location: safeLocation((step as any).location),
945
+ },
946
+ });
947
+ }
948
+
949
+ onStepEnd(test: TestCase, result: TestResult, step: TestStep) {
950
+ // Filter out hooks, fixtures, and internal operations
951
+ if (!shouldIncludeStep(step)) {
952
+ return;
953
+ }
954
+
955
+ const id = this.stepIds.get(step) ?? this.nextStepId++;
956
+ this.stepIds.set(step, id);
957
+
958
+ const err = (step as any).error;
959
+ emit({
960
+ v: 1,
961
+ ts: Date.now(),
962
+ eventType: 'stepEnd',
963
+ test: testRef(test),
964
+ step: {
965
+ id,
966
+ duration: (step as any).duration,
967
+ error: err ? (typeof err.message === 'string' ? err.message : String(err)) : null,
968
+ location: safeLocation((step as any).location),
969
+ },
970
+ });
971
+ }
972
+ }
973
+ `;
974
+ }
975
+
976
+ // --- Code generation API utilities for Probo Labs Playwright scripts ---
977
+ /**
978
+ * ProboCodeGenerator - Handles fetching test suite/scenario data and generating Playwright code
979
+ */
980
+ class ProboCodeGenerator {
981
+ /**
982
+ * Normalize API URL by removing trailing slash
983
+ */
984
+ static normalizeApiUrl(apiUrl) {
985
+ return apiUrl.replace(/\/+$/, '');
986
+ }
987
+ static async readResponseErrorText(response) {
988
+ try {
989
+ const contentType = response.headers.get('content-type');
990
+ if (contentType && contentType.includes('application/json')) {
991
+ const errorData = await response.json();
992
+ return errorData.error || errorData.detail || JSON.stringify(errorData);
993
+ }
994
+ let errorText = await response.text();
995
+ // Truncate HTML error pages to first 200 chars
996
+ if (errorText.length > 200) {
997
+ errorText = errorText.substring(0, 200) + '...';
998
+ }
999
+ return errorText;
1000
+ }
1001
+ catch (e) {
1002
+ // Best-effort fallback
1003
+ return `HTTP ${response.status}`;
1004
+ }
1005
+ }
1006
+ /**
1007
+ * Fetch scenario data from the backend API
1008
+ */
1009
+ static async fetchScenarioData(scenarioId, apiToken, apiUrl) {
1010
+ const normalizedUrl = this.normalizeApiUrl(apiUrl);
1011
+ const url = `${normalizedUrl}/api/scenarios/${scenarioId}/interactions`;
1012
+ const response = await fetch(url, {
1013
+ method: 'GET',
1014
+ headers: {
1015
+ 'Authorization': `Token ${apiToken}`,
1016
+ 'Content-Type': 'application/json',
1017
+ },
1018
+ });
1019
+ if (!response.ok) {
1020
+ const errorText = await this.readResponseErrorText(response);
1021
+ throw new Error(`Failed to fetch scenario ${scenarioId}: ${response.status} ${errorText}`);
1022
+ }
1023
+ return response.json();
1024
+ }
1025
+ /**
1026
+ * Fetch test suite data from the backend API
1027
+ */
1028
+ static async fetchTestSuiteData(testSuiteId, apiToken, apiUrl) {
1029
+ const normalizedUrl = this.normalizeApiUrl(apiUrl);
1030
+ // Note: test-suites endpoint doesn't have /api/ prefix (it's from the router at root level)
1031
+ const url = `${normalizedUrl}/test-suites/${testSuiteId}/`;
1032
+ const response = await fetch(url, {
1033
+ method: 'GET',
1034
+ headers: {
1035
+ 'Authorization': `Token ${apiToken}`,
1036
+ 'Content-Type': 'application/json',
1037
+ },
1038
+ });
1039
+ if (!response.ok) {
1040
+ const errorText = await this.readResponseErrorText(response);
1041
+ throw new Error(`Failed to fetch test suite ${testSuiteId}: ${response.status} ${errorText}`);
1042
+ }
1043
+ return response.json();
1044
+ }
1045
+ /**
1046
+ * Convert backend interaction format to Interaction[] format
1047
+ */
1048
+ static convertBackendInteractionsToInteractionFormat(backendInteractions) {
1049
+ return backendInteractions.map((backendInteraction) => {
1050
+ // Convert action string to PlaywrightAction enum
1051
+ const action = backendInteraction.action;
1052
+ // Convert apply_ai_status string to enum if present
1053
+ let applyAiStatus = null;
1054
+ if (backendInteraction.apply_ai_status) {
1055
+ applyAiStatus = backendInteraction.apply_ai_status;
1056
+ }
1057
+ // Build elementInfo from backend data
1058
+ const elementInfo = backendInteraction.elementInfo || null;
1059
+ // Build interaction object
1060
+ const interaction = {
1061
+ interactionId: backendInteraction.interactionId,
1062
+ action: action,
1063
+ argument: backendInteraction.argument || '',
1064
+ elementInfo: elementInfo,
1065
+ iframe_selector: backendInteraction.iframe_selector,
1066
+ css_selector: backendInteraction.css_selector,
1067
+ smart_selector: backendInteraction.smart_selector || undefined,
1068
+ smart_iframe_selector: backendInteraction.smart_iframe_selector || undefined,
1069
+ url: backendInteraction.url,
1070
+ timestamp: backendInteraction.last_replay_timestamp || Date.now(),
1071
+ base_screenshot_url: backendInteraction.base_screenshot_url,
1072
+ actual_interaction_screenshot_url: backendInteraction.actual_interaction_screenshot_url,
1073
+ candidates_screenshot_url: backendInteraction.candidates_screenshot_url,
1074
+ candidate_elements: backendInteraction.candidate_elements || [],
1075
+ recordingId: backendInteraction.recordingId,
1076
+ syncStatus: backendInteraction.syncStatus,
1077
+ serverResponse: backendInteraction.serverResponse || null,
1078
+ nativeName: backendInteraction.nativeName,
1079
+ nativeDescription: backendInteraction.nativeDescription,
1080
+ isNativeDescriptionElaborate: backendInteraction.isNativeDescriptionElaborate,
1081
+ parameterName: backendInteraction.parameterName,
1082
+ annotation: backendInteraction.annotation,
1083
+ last_replay_timestamp: backendInteraction.last_replay_timestamp,
1084
+ replay_status: backendInteraction.replay_status,
1085
+ apply_ai_status: applyAiStatus,
1086
+ error: null,
1087
+ index: backendInteraction.index,
1088
+ html: backendInteraction.html,
1089
+ stepId: backendInteraction.stepId,
1090
+ scrollableContainers: backendInteraction.scrollableContainers,
1091
+ stdout: undefined,
1092
+ waitForConfig: backendInteraction.waitForConfig,
1093
+ totpConfig: backendInteraction.totpConfig,
1094
+ isSecret: backendInteraction.isSecret || false,
1095
+ };
1096
+ return interaction;
1097
+ });
1098
+ }
1099
+ /**
1100
+ * Get default recorder settings for code generation
1101
+ */
1102
+ static getDefaultRecorderSettings() {
1103
+ return {
1104
+ ...DEFAULT_RECORDER_SETTINGS,
1105
+ // Override with sensible defaults for code generation
1106
+ enableSmartSelectors: true,
1107
+ debugLevel: 'INFO',
1108
+ enableConsoleLogs: false,
1109
+ };
1110
+ }
1111
+ /**
1112
+ * Get default viewport dimensions
1113
+ */
1114
+ static getDefaultViewPort() {
1115
+ return { width: 1280, height: 720 };
1116
+ }
1117
+ /**
1118
+ * Generate code for a single scenario
1119
+ * Fetches scenario data, converts to Interaction[], generates code
1120
+ */
1121
+ static async generateCodeForScenario(scenarioId, apiToken, apiUrl, options) {
1122
+ // Validate inputs
1123
+ if (!apiToken) {
1124
+ throw new Error('API token is required');
1125
+ }
1126
+ if (!apiUrl) {
1127
+ throw new Error('API URL is required');
1128
+ }
1129
+ // Fetch scenario data
1130
+ const scenarioData = await this.fetchScenarioData(scenarioId, apiToken, apiUrl);
1131
+ // Convert backend interactions to Interaction[] format
1132
+ const interactions = this.convertBackendInteractionsToInteractionFormat(scenarioData.interactions || []);
1133
+ // Get settings and viewport
1134
+ const settings = (options === null || options === void 0 ? void 0 : options.recorderSettings) || this.getDefaultRecorderSettings();
1135
+ const viewPort = (options === null || options === void 0 ? void 0 : options.viewPort) || scenarioData.viewPort || this.getDefaultViewPort();
1136
+ // Get parameter table rows
1137
+ const rows = scenarioData.parameterTable || [];
1138
+ // Generate code
1139
+ const codeOptions = {
1140
+ scenarioName: scenarioData.name,
1141
+ interactions: interactions,
1142
+ rows: rows,
1143
+ };
1144
+ return generateCode(codeOptions, settings, viewPort);
1145
+ }
1146
+ /**
1147
+ * Generate code for all scenarios in a test suite
1148
+ * Returns map of scenario names to generated code
1149
+ */
1150
+ static async generateCodeForTestSuite(testSuiteId, apiToken, apiUrl, options) {
1151
+ // Validate inputs
1152
+ if (!apiToken) {
1153
+ throw new Error('API token is required');
1154
+ }
1155
+ if (!apiUrl) {
1156
+ throw new Error('API URL is required');
1157
+ }
1158
+ // Fetch test suite data
1159
+ const testSuiteData = await this.fetchTestSuiteData(testSuiteId, apiToken, apiUrl);
1160
+ // Generate code for each scenario
1161
+ const scenarioResults = await Promise.all(testSuiteData.scenarios.map(async (scenario) => {
1162
+ try {
1163
+ const code = await this.generateCodeForScenario(scenario.id, apiToken, apiUrl, options);
1164
+ return {
1165
+ scenarioId: scenario.id,
1166
+ scenarioName: scenario.name,
1167
+ code: code,
1168
+ };
1169
+ }
1170
+ catch (error) {
1171
+ // Log error but continue with other scenarios
1172
+ console.error(`Failed to generate code for scenario ${scenario.id}: ${error.message}`);
1173
+ throw error; // Re-throw to fail fast for now
1174
+ }
1175
+ }));
1176
+ return {
1177
+ testSuiteId: testSuiteData.id,
1178
+ testSuiteName: testSuiteData.name,
1179
+ scenarios: scenarioResults,
1180
+ };
1181
+ }
1182
+ }
1183
+
1184
+ const execAsync = promisify(exec);
1185
+ /**
1186
+ * Ensures a directory exists, creating it recursively if needed
1187
+ */
1188
+ function ensureDirectoryExists(dir) {
1189
+ if (!fs.existsSync(dir)) {
1190
+ fs.mkdirSync(dir, { recursive: true });
1191
+ }
1192
+ }
1193
+ /**
1194
+ * Gets the default test suite directory path
1195
+ */
1196
+ function getDefaultTestSuiteDir(testSuiteId, testSuiteName) {
1197
+ const baseDir = path.join(os.homedir(), '.probium', 'test-suites');
1198
+ if (testSuiteName) {
1199
+ const sanitizedName = slugify(testSuiteName);
1200
+ return path.join(baseDir, `${testSuiteId}-${sanitizedName}`);
1201
+ }
1202
+ return path.join(baseDir, testSuiteId.toString());
1203
+ }
1204
+ /**
1205
+ * Count total tests by scanning spec files
1206
+ */
1207
+ function countTotalTests(testSuiteDir) {
1208
+ const testsDir = path.join(testSuiteDir, 'tests');
1209
+ if (!fs.existsSync(testsDir)) {
1210
+ return 0;
1211
+ }
1212
+ let totalTests = 0;
1213
+ const specFiles = fs.readdirSync(testsDir).filter(f => f.endsWith('.spec.ts'));
1214
+ for (const specFile of specFiles) {
1215
+ const filePath = path.join(testsDir, specFile);
1216
+ const content = fs.readFileSync(filePath, 'utf-8');
1217
+ // Count test() and it() calls
1218
+ const testMatches = content.match(/\btest\s*\(/g);
1219
+ const itMatches = content.match(/\bit\s*\(/g);
1220
+ if (testMatches)
1221
+ totalTests += testMatches.length;
1222
+ if (itMatches)
1223
+ totalTests += itMatches.length;
1224
+ // If no matches, assume at least 1 test per file
1225
+ if (!testMatches && !itMatches) {
1226
+ totalTests += 1;
1227
+ }
1228
+ }
1229
+ return totalTests;
1230
+ }
1231
+ /**
1232
+ * Parse Playwright statistics from stdout
1233
+ */
1234
+ function parsePlaywrightStatistics(stdout) {
1235
+ // Try to find final summary line: "X passed" or "X passed, Y failed" etc.
1236
+ const summaryMatch = stdout.match(/(\d+)\s+passed(?:,\s*(\d+)\s+failed)?(?:,\s*(\d+)\s+skipped)?/);
1237
+ if (summaryMatch) {
1238
+ return {
1239
+ passed: parseInt(summaryMatch[1]) || 0,
1240
+ failed: parseInt(summaryMatch[2]) || 0,
1241
+ skipped: parseInt(summaryMatch[3]) || 0,
1242
+ };
1243
+ }
1244
+ // Fallback: count individual test results
1245
+ const passed = (stdout.match(/✓/g) || []).length;
1246
+ const failed = (stdout.match(/✘/g) || []).length;
1247
+ const skipped = (stdout.match(/-\s+test/g) || []).length;
1248
+ return { passed, failed, skipped };
1249
+ }
1250
+ /**
1251
+ * Parse statistics from Playwright HTML report metadata (most reliable)
1252
+ */
1253
+ function parseStatisticsFromMetadata(testSuiteDir) {
1254
+ try {
1255
+ const reportDir = path.join(testSuiteDir, 'playwright-report');
1256
+ const metadataPath = path.join(reportDir, 'data', 'metadata.json');
1257
+ if (!fs.existsSync(metadataPath)) {
1258
+ return null;
1259
+ }
1260
+ const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
1261
+ return {
1262
+ passed: metadata.passed || 0,
1263
+ failed: metadata.failed || 0,
1264
+ skipped: metadata.skipped || 0,
1265
+ };
1266
+ }
1267
+ catch (error) {
1268
+ console.warn('⚠️ Could not parse metadata.json:', error);
1269
+ return null;
1270
+ }
1271
+ }
1272
+ /**
1273
+ * Update run status via API
1274
+ */
1275
+ async function updateRunStatus(runId, testSuiteId, updates, apiToken, apiUrl) {
1276
+ // Ensure apiUrl doesn't have trailing slash
1277
+ const baseUrl = apiUrl.endsWith('/') ? apiUrl.slice(0, -1) : apiUrl;
1278
+ const url = `${baseUrl}/test-suites/${testSuiteId}/runs/${runId}/`;
1279
+ const body = JSON.stringify(updates);
1280
+ const headers = {
1281
+ 'Authorization': `Token ${apiToken}`,
1282
+ 'Content-Type': 'application/json',
1283
+ };
1284
+ try {
1285
+ const response = await fetch$1(url, {
1286
+ method: 'PATCH',
1287
+ headers: headers,
1288
+ body: body,
1289
+ });
1290
+ if (!response.ok) {
1291
+ const errorText = await response.text();
1292
+ console.warn(`⚠️ Failed to update run status: ${response.status} ${errorText}`);
1293
+ }
1294
+ }
1295
+ catch (error) {
1296
+ console.error(`❌ Error updating run status:`, error);
1297
+ }
1298
+ }
1299
+ /**
1300
+ * TestSuiteRunner - Handles test suite file generation and execution
1301
+ */
1302
+ class TestSuiteRunner {
1303
+ /**
1304
+ * Lookup test suite ID by name and project
1305
+ */
1306
+ static async lookupTestSuiteByName(testSuiteName, projectName, apiToken, apiUrl) {
1307
+ const baseUrl = apiUrl.endsWith('/') ? apiUrl.slice(0, -1) : apiUrl;
1308
+ const url = `${baseUrl}/test-suites/?name=${encodeURIComponent(testSuiteName)}&project=${encodeURIComponent(projectName)}`;
1309
+ const response = await fetch$1(url, {
1310
+ method: 'GET',
1311
+ headers: {
1312
+ 'Authorization': `Token ${apiToken}`,
1313
+ 'Content-Type': 'application/json',
1314
+ },
1315
+ });
1316
+ if (!response.ok) {
1317
+ const errorText = await response.text();
1318
+ throw new Error(`Failed to lookup test suite: ${response.status} ${errorText}`);
1319
+ }
1320
+ const data = await response.json();
1321
+ if (Array.isArray(data) && data.length > 0) {
1322
+ return data[0].id;
1323
+ }
1324
+ if (data.results && Array.isArray(data.results) && data.results.length > 0) {
1325
+ return data.results[0].id;
1326
+ }
1327
+ throw new Error(`Test suite "${testSuiteName}" not found in project "${projectName}"`);
1328
+ }
1329
+ /**
1330
+ * Generate all files for a test suite
1331
+ */
1332
+ static async generateTestSuiteFiles(testSuiteId, apiToken, apiUrl, outputDir, testSuiteName, includeReporter = true, runId) {
1333
+ const testSuiteDir = outputDir || getDefaultTestSuiteDir(testSuiteId, testSuiteName);
1334
+ // Generate code for all scenarios
1335
+ const codeGenResult = await ProboCodeGenerator.generateCodeForTestSuite(testSuiteId, apiToken, apiUrl);
1336
+ // Ensure directories exist
1337
+ ensureDirectoryExists(testSuiteDir);
1338
+ const testsDir = path.join(testSuiteDir, 'tests');
1339
+ ensureDirectoryExists(testsDir);
1340
+ // Save each scenario's code to a .spec.ts file
1341
+ for (const scenario of codeGenResult.scenarios) {
1342
+ const fileName = `${slugify(scenario.scenarioName)}.spec.ts`;
1343
+ const filePath = path.join(testsDir, fileName);
1344
+ fs.writeFileSync(filePath, scenario.code, 'utf-8');
1345
+ console.log(`✅ Generated test file: ${filePath}`);
1346
+ }
1347
+ // Generate package.json
1348
+ await this.generatePackageJson(testSuiteDir, codeGenResult);
1349
+ // Generate playwright.config.ts with runId if available
1350
+ await this.generatePlaywrightConfig(testSuiteDir, includeReporter, runId);
1351
+ // Generate custom reporter file for live progress streaming (only if requested)
1352
+ if (includeReporter) {
1353
+ await this.generateProboReporter(testSuiteDir);
1354
+ }
1355
+ }
1356
+ /**
1357
+ * Generate package.json file
1358
+ */
1359
+ static async generatePackageJson(outputDir, codeGenResult) {
1360
+ // Check if any scenario has secrets by examining the generated code
1361
+ // We'll check for dotenv imports in the generated code
1362
+ const hasSecrets = codeGenResult.scenarios.some(scenario => scenario.code.includes("import { config } from 'dotenv'"));
1363
+ const sanitizedName = slugify(codeGenResult.testSuiteName);
1364
+ const packageJsonContent = generatePackageJson({
1365
+ name: sanitizedName,
1366
+ hasSecrets: hasSecrets,
1367
+ testScript: 'npx playwright test'
1368
+ });
1369
+ const filePath = path.join(outputDir, 'package.json');
1370
+ fs.writeFileSync(filePath, packageJsonContent, 'utf-8');
1371
+ console.log(`✅ Generated package.json: ${filePath}`);
1372
+ }
1373
+ /**
1374
+ * Generate playwright.config.ts file
1375
+ */
1376
+ static async generatePlaywrightConfig(outputDir, includeReporter = true, runId) {
1377
+ const configContent = generatePlaywrightConfig(includeReporter, runId);
1378
+ const filePath = path.join(outputDir, 'playwright.config.ts');
1379
+ fs.writeFileSync(filePath, configContent, 'utf-8');
1380
+ console.log(`✅ Generated playwright.config.ts: ${filePath}`);
1381
+ }
1382
+ /**
1383
+ * Generate Probo custom Playwright reporter (for live step/test events)
1384
+ */
1385
+ static async generateProboReporter(outputDir) {
1386
+ const reporterContent = generateProboReporter();
1387
+ const filePath = path.join(outputDir, 'probo-reporter.ts');
1388
+ fs.writeFileSync(filePath, reporterContent, 'utf-8');
1389
+ console.log(`✅ Generated probo-reporter.ts: ${filePath}`);
1390
+ }
1391
+ /**
1392
+ * Run a test suite
1393
+ * Generates files, installs dependencies, and executes tests
1394
+ */
1395
+ static async runTestSuite(testSuiteId, apiToken, apiUrl, testSuiteName, runId, options = {}) {
1396
+ const { outputDir, includeReporter = true, onStatusUpdate, onStdout, onStderr, onReporterEvent, } = options;
1397
+ const testSuiteDir = outputDir || getDefaultTestSuiteDir(testSuiteId, testSuiteName);
1398
+ let currentRunId = runId;
1399
+ try {
1400
+ // Create run record if not provided (needed for run-specific report directories)
1401
+ if (!currentRunId) {
1402
+ try {
1403
+ const createResponse = await fetch$1(`${apiUrl}/test-suites/${testSuiteId}/runs/`, {
1404
+ method: 'POST',
1405
+ headers: {
1406
+ 'Authorization': `Token ${apiToken}`,
1407
+ 'Content-Type': 'application/json',
1408
+ },
1409
+ });
1410
+ if (createResponse.ok) {
1411
+ const runData = await createResponse.json();
1412
+ currentRunId = runData.id;
1413
+ console.log(`✅ Created test suite run: ${currentRunId}`);
1414
+ }
1415
+ else {
1416
+ console.warn('⚠️ Failed to create run record, continuing without tracking');
1417
+ }
1418
+ }
1419
+ catch (error) {
1420
+ console.warn('⚠️ Failed to create run record:', error);
1421
+ }
1422
+ }
1423
+ // Generate all files (with runId if available for run-specific report directories)
1424
+ console.log(`📝 Generating test suite files for test suite ${testSuiteId}...`);
1425
+ await this.generateTestSuiteFiles(testSuiteId, apiToken, apiUrl, testSuiteDir, testSuiteName, includeReporter, currentRunId);
1426
+ // Count total tests
1427
+ const testsTotal = countTotalTests(testSuiteDir);
1428
+ if (currentRunId && testsTotal > 0) {
1429
+ await updateRunStatus(currentRunId, testSuiteId, { tests_total: testsTotal }, apiToken, apiUrl);
1430
+ if (onStatusUpdate) {
1431
+ await onStatusUpdate({ tests_total: testsTotal });
1432
+ }
1433
+ }
1434
+ // Install dependencies
1435
+ console.log(`📦 Installing dependencies in ${testSuiteDir}...`);
1436
+ try {
1437
+ const { stdout: installStdout, stderr: installStderr } = await execAsync('npm install', { cwd: testSuiteDir, timeout: 300000 } // 5 minute timeout for install
1438
+ );
1439
+ console.log('✅ Dependencies installed successfully');
1440
+ if (installStdout)
1441
+ console.log(installStdout);
1442
+ if (installStderr)
1443
+ console.warn(installStderr);
1444
+ }
1445
+ catch (installError) {
1446
+ console.error('❌ Failed to install dependencies:', installError);
1447
+ const errorMsg = `Failed to install dependencies: ${installError.message}`;
1448
+ if (currentRunId) {
1449
+ await updateRunStatus(currentRunId, testSuiteId, {
1450
+ status: 'error',
1451
+ exit_code: installError.code || 1,
1452
+ error_message: errorMsg,
1453
+ stderr: installError.stderr || installError.message || '',
1454
+ }, apiToken, apiUrl);
1455
+ if (onStatusUpdate) {
1456
+ await onStatusUpdate({
1457
+ status: 'error',
1458
+ exit_code: installError.code || 1,
1459
+ error_message: errorMsg,
1460
+ stderr: installError.stderr || installError.message || '',
1461
+ });
1462
+ }
1463
+ }
1464
+ return {
1465
+ success: false,
1466
+ exitCode: installError.code || 1,
1467
+ stdout: installError.stdout || '',
1468
+ stderr: installError.stderr || installError.message || '',
1469
+ error: errorMsg,
1470
+ runId: currentRunId,
1471
+ };
1472
+ }
1473
+ // Run Playwright tests with streaming output
1474
+ console.log(`🚀 Running Playwright tests in ${testSuiteDir}...`);
1475
+ return new Promise(async (resolve) => {
1476
+ var _a, _b;
1477
+ let stdout = '';
1478
+ let stderr = '';
1479
+ let hasResolved = false;
1480
+ let lastStatsUpdate = 0;
1481
+ let stdoutLineBuffer = '';
1482
+ const PROBO_REPORTER_PREFIX = '__PROBO_REPORTER_EVENT__';
1483
+ const reporterStats = { passed: 0, failed: 0, skipped: 0 };
1484
+ let lastReporterStatsUpdate = 0;
1485
+ const REPORTER_STATS_UPDATE_INTERVAL = 1500; // throttle API updates from reporter events
1486
+ const STATS_UPDATE_INTERVAL = 10000; // Update stats every 10 seconds
1487
+ const STEPS_UPDATE_INTERVAL = 2000; // Update steps every 2 seconds
1488
+ let lastStepsUpdate = 0;
1489
+ const collectedSteps = [];
1490
+ // Use spawn for streaming output
1491
+ const testProcess = spawn('npx', ['playwright', 'test'], {
1492
+ cwd: testSuiteDir,
1493
+ shell: true,
1494
+ stdio: ['ignore', 'pipe', 'pipe'],
1495
+ });
1496
+ // Stream stdout line by line
1497
+ (_a = testProcess.stdout) === null || _a === void 0 ? void 0 : _a.on('data', async (data) => {
1498
+ var _a, _b;
1499
+ const chunk = data.toString();
1500
+ stdoutLineBuffer += chunk;
1501
+ // Process complete lines so we can intercept reporter events without polluting logs.
1502
+ const parts = stdoutLineBuffer.split(/\n/);
1503
+ stdoutLineBuffer = (_a = parts.pop()) !== null && _a !== void 0 ? _a : '';
1504
+ let forwarded = '';
1505
+ for (const rawLine of parts) {
1506
+ const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
1507
+ if (line.startsWith(PROBO_REPORTER_PREFIX)) {
1508
+ const jsonPart = line.slice(PROBO_REPORTER_PREFIX.length);
1509
+ try {
1510
+ const reporterEvent = JSON.parse(jsonPart);
1511
+ // Call reporter event callback if provided
1512
+ if (onReporterEvent) {
1513
+ await onReporterEvent(reporterEvent);
1514
+ }
1515
+ // Collect steps from reporter events
1516
+ if ((reporterEvent === null || reporterEvent === void 0 ? void 0 : reporterEvent.eventType) === 'stepBegin') {
1517
+ const step = reporterEvent === null || reporterEvent === void 0 ? void 0 : reporterEvent.step;
1518
+ const id = typeof (step === null || step === void 0 ? void 0 : step.id) === 'number' ? step.id : null;
1519
+ const title = typeof (step === null || step === void 0 ? void 0 : step.title) === 'string' ? step.title : null;
1520
+ const depth = typeof (step === null || step === void 0 ? void 0 : step.depth) === 'number' ? step.depth : 0;
1521
+ if (id && title) {
1522
+ // Check if step already exists (update) or add new
1523
+ const existingIndex = collectedSteps.findIndex(s => s.id === id);
1524
+ if (existingIndex === -1) {
1525
+ collectedSteps.push({ id, title, depth, status: 'running' });
1526
+ }
1527
+ }
1528
+ }
1529
+ if ((reporterEvent === null || reporterEvent === void 0 ? void 0 : reporterEvent.eventType) === 'stepEnd') {
1530
+ const step = reporterEvent === null || reporterEvent === void 0 ? void 0 : reporterEvent.step;
1531
+ const id = typeof (step === null || step === void 0 ? void 0 : step.id) === 'number' ? step.id : null;
1532
+ if (id) {
1533
+ const error = typeof (step === null || step === void 0 ? void 0 : step.error) === 'string' ? step.error : null;
1534
+ const existingIndex = collectedSteps.findIndex(s => s.id === id);
1535
+ if (existingIndex !== -1) {
1536
+ // Update existing step with final status
1537
+ collectedSteps[existingIndex] = {
1538
+ ...collectedSteps[existingIndex],
1539
+ status: error ? 'failed' : 'passed',
1540
+ error: error || null,
1541
+ };
1542
+ }
1543
+ // If step doesn't exist, ignore (stepBegin should have been called first)
1544
+ }
1545
+ }
1546
+ // Save steps periodically
1547
+ if (currentRunId) {
1548
+ const now = Date.now();
1549
+ if (now - lastStepsUpdate > STEPS_UPDATE_INTERVAL) {
1550
+ lastStepsUpdate = now;
1551
+ await updateRunStatus(currentRunId, testSuiteId, { steps: [...collectedSteps] }, apiToken, apiUrl);
1552
+ }
1553
+ }
1554
+ // Keep a more reliable running tally than parsing stdout.
1555
+ if (currentRunId && (reporterEvent === null || reporterEvent === void 0 ? void 0 : reporterEvent.eventType) === 'testEnd' && ((_b = reporterEvent === null || reporterEvent === void 0 ? void 0 : reporterEvent.result) === null || _b === void 0 ? void 0 : _b.status)) {
1556
+ const status = reporterEvent.result.status;
1557
+ if (status === 'passed')
1558
+ reporterStats.passed += 1;
1559
+ else if (status === 'failed' || status === 'timedout' || status === 'interrupted')
1560
+ reporterStats.failed += 1;
1561
+ else if (status === 'skipped')
1562
+ reporterStats.skipped += 1;
1563
+ const now = Date.now();
1564
+ if (now - lastReporterStatsUpdate > REPORTER_STATS_UPDATE_INTERVAL) {
1565
+ lastReporterStatsUpdate = now;
1566
+ await updateRunStatus(currentRunId, testSuiteId, {
1567
+ tests_passed: reporterStats.passed,
1568
+ tests_failed: reporterStats.failed,
1569
+ tests_skipped: reporterStats.skipped,
1570
+ }, apiToken, apiUrl);
1571
+ if (onStatusUpdate) {
1572
+ await onStatusUpdate({
1573
+ tests_passed: reporterStats.passed,
1574
+ tests_failed: reporterStats.failed,
1575
+ tests_skipped: reporterStats.skipped,
1576
+ });
1577
+ }
1578
+ }
1579
+ }
1580
+ }
1581
+ catch (_c) {
1582
+ // If parsing fails, ignore and don't forward to logs.
1583
+ }
1584
+ continue;
1585
+ }
1586
+ forwarded += rawLine + '\n';
1587
+ }
1588
+ if (forwarded) {
1589
+ stdout += forwarded;
1590
+ // Call stdout callback if provided
1591
+ if (onStdout) {
1592
+ await onStdout(forwarded);
1593
+ }
1594
+ }
1595
+ // Parse and update statistics periodically
1596
+ if (currentRunId) {
1597
+ const now = Date.now();
1598
+ // Only use stdout parsing as a fallback (in case reporter isn't loaded).
1599
+ if (now - lastStatsUpdate > STATS_UPDATE_INTERVAL && now - lastReporterStatsUpdate > REPORTER_STATS_UPDATE_INTERVAL) {
1600
+ lastStatsUpdate = now;
1601
+ const stats = parsePlaywrightStatistics(stdout);
1602
+ await updateRunStatus(currentRunId, testSuiteId, {
1603
+ tests_passed: stats.passed,
1604
+ tests_failed: stats.failed,
1605
+ tests_skipped: stats.skipped,
1606
+ }, apiToken, apiUrl);
1607
+ if (onStatusUpdate) {
1608
+ await onStatusUpdate({
1609
+ tests_passed: stats.passed,
1610
+ tests_failed: stats.failed,
1611
+ tests_skipped: stats.skipped,
1612
+ });
1613
+ }
1614
+ }
1615
+ }
1616
+ });
1617
+ // Stream stderr line by line
1618
+ (_b = testProcess.stderr) === null || _b === void 0 ? void 0 : _b.on('data', async (data) => {
1619
+ const chunk = data.toString();
1620
+ stderr += chunk;
1621
+ // Call stderr callback if provided
1622
+ if (onStderr) {
1623
+ await onStderr(chunk);
1624
+ }
1625
+ });
1626
+ // Handle process completion
1627
+ testProcess.on('close', async (code) => {
1628
+ var _a;
1629
+ if (hasResolved)
1630
+ return;
1631
+ hasResolved = true;
1632
+ // Flush any remaining buffered stdout (may be a partial last line)
1633
+ if (stdoutLineBuffer) {
1634
+ const remaining = stdoutLineBuffer;
1635
+ stdoutLineBuffer = '';
1636
+ if (remaining.startsWith(PROBO_REPORTER_PREFIX)) {
1637
+ try {
1638
+ const reporterEvent = JSON.parse(remaining.slice(PROBO_REPORTER_PREFIX.length));
1639
+ if (onReporterEvent) {
1640
+ await onReporterEvent(reporterEvent);
1641
+ }
1642
+ if (currentRunId && (reporterEvent === null || reporterEvent === void 0 ? void 0 : reporterEvent.eventType) === 'testEnd' && ((_a = reporterEvent === null || reporterEvent === void 0 ? void 0 : reporterEvent.result) === null || _a === void 0 ? void 0 : _a.status)) {
1643
+ const status = reporterEvent.result.status;
1644
+ if (status === 'passed')
1645
+ reporterStats.passed += 1;
1646
+ else if (status === 'failed' || status === 'timedout' || status === 'interrupted')
1647
+ reporterStats.failed += 1;
1648
+ else if (status === 'skipped')
1649
+ reporterStats.skipped += 1;
1650
+ }
1651
+ }
1652
+ catch (_b) {
1653
+ // ignore
1654
+ }
1655
+ }
1656
+ else {
1657
+ stdout += remaining;
1658
+ if (onStdout) {
1659
+ await onStdout(remaining);
1660
+ }
1661
+ }
1662
+ }
1663
+ const exitCode = code !== null && code !== void 0 ? code : 1;
1664
+ const success = exitCode === 0;
1665
+ const isTestFailure = exitCode === 1 && stdout.length > 0;
1666
+ console.log(success ? '✅ Tests completed successfully' : (isTestFailure ? '⚠️ Tests completed with failures' : '❌ Test execution failed'));
1667
+ // Parse final statistics from metadata/stdout as fallback
1668
+ let parsedStats = null;
1669
+ if (fs.existsSync(path.join(testSuiteDir, 'playwright-report'))) {
1670
+ parsedStats = parseStatisticsFromMetadata(testSuiteDir);
1671
+ }
1672
+ if (!parsedStats) {
1673
+ parsedStats = parsePlaywrightStatistics(stdout);
1674
+ }
1675
+ // Use reporterStats as source of truth (tracked from real-time events)
1676
+ // Fall back to parsed stats only if reporterStats is empty
1677
+ const totalFromReporter = reporterStats.passed + reporterStats.failed + reporterStats.skipped;
1678
+ const finalStats = totalFromReporter > 0 ? reporterStats : (parsedStats || { passed: 0, failed: 0, skipped: 0 });
1679
+ // Update run status with final steps
1680
+ if (currentRunId) {
1681
+ const finalStatus = success ? 'success' : 'error';
1682
+ await updateRunStatus(currentRunId, testSuiteId, {
1683
+ status: finalStatus,
1684
+ exit_code: exitCode,
1685
+ stdout: stdout,
1686
+ stderr: stderr,
1687
+ steps: [...collectedSteps], // Save final steps state
1688
+ error_message: success || isTestFailure ? undefined : `Test execution failed with exit code ${exitCode}`,
1689
+ tests_passed: finalStats.passed,
1690
+ tests_failed: finalStats.failed,
1691
+ tests_skipped: finalStats.skipped,
1692
+ }, apiToken, apiUrl);
1693
+ if (onStatusUpdate) {
1694
+ await onStatusUpdate({
1695
+ status: finalStatus,
1696
+ exit_code: exitCode,
1697
+ stdout: stdout,
1698
+ stderr: stderr,
1699
+ tests_passed: finalStats.passed,
1700
+ tests_failed: finalStats.failed,
1701
+ tests_skipped: finalStats.skipped,
1702
+ });
1703
+ }
1704
+ // Artifact uploads disabled - reports are now accessed locally via trace viewer
1705
+ // Upload artifacts functionality preserved but disabled
1706
+ // await this.uploadArtifacts(currentRunId, testSuiteId, testSuiteDir, apiToken, apiUrl);
1707
+ }
1708
+ resolve({
1709
+ success: success,
1710
+ exitCode: exitCode,
1711
+ stdout: stdout,
1712
+ stderr: stderr,
1713
+ // Only set error if it's not a success and not a test failure (i.e., real execution error)
1714
+ error: success || isTestFailure ? undefined : `Test execution failed with exit code ${exitCode}`,
1715
+ runId: currentRunId,
1716
+ });
1717
+ });
1718
+ // Handle process errors
1719
+ testProcess.on('error', async (error) => {
1720
+ if (hasResolved)
1721
+ return;
1722
+ hasResolved = true;
1723
+ const errorMessage = error.message || String(error);
1724
+ stderr += errorMessage;
1725
+ console.error('❌ Test execution failed:', error);
1726
+ // Update run status
1727
+ if (currentRunId) {
1728
+ await updateRunStatus(currentRunId, testSuiteId, {
1729
+ status: 'error',
1730
+ exit_code: 1,
1731
+ error_message: `Test execution failed: ${errorMessage}`,
1732
+ stderr: stderr,
1733
+ }, apiToken, apiUrl);
1734
+ if (onStatusUpdate) {
1735
+ await onStatusUpdate({
1736
+ status: 'error',
1737
+ exit_code: 1,
1738
+ error_message: `Test execution failed: ${errorMessage}`,
1739
+ stderr: stderr,
1740
+ });
1741
+ }
1742
+ }
1743
+ resolve({
1744
+ success: false,
1745
+ exitCode: 1,
1746
+ stdout: stdout,
1747
+ stderr: stderr,
1748
+ error: `Test execution failed: ${errorMessage}`,
1749
+ runId: currentRunId,
1750
+ });
1751
+ });
1752
+ // Set timeout (1 hour)
1753
+ setTimeout(async () => {
1754
+ if (!hasResolved) {
1755
+ hasResolved = true;
1756
+ testProcess.kill();
1757
+ const errorMessage = 'Test execution timed out after 1 hour';
1758
+ stderr += errorMessage;
1759
+ // Update run status
1760
+ if (currentRunId) {
1761
+ await updateRunStatus(currentRunId, testSuiteId, {
1762
+ status: 'error',
1763
+ exit_code: 1,
1764
+ error_message: errorMessage,
1765
+ stderr: stderr,
1766
+ }, apiToken, apiUrl);
1767
+ if (onStatusUpdate) {
1768
+ await onStatusUpdate({
1769
+ status: 'error',
1770
+ exit_code: 1,
1771
+ error_message: errorMessage,
1772
+ stderr: stderr,
1773
+ });
1774
+ }
1775
+ }
1776
+ resolve({
1777
+ success: false,
1778
+ exitCode: 1,
1779
+ stdout: stdout,
1780
+ stderr: stderr,
1781
+ error: errorMessage,
1782
+ runId: currentRunId,
1783
+ });
1784
+ }
1785
+ }, 3600000); // 1 hour timeout
1786
+ });
1787
+ }
1788
+ catch (error) {
1789
+ console.error('❌ Error running test suite:', error);
1790
+ const errorMsg = error.message || String(error);
1791
+ if (currentRunId) {
1792
+ await updateRunStatus(currentRunId, testSuiteId, {
1793
+ status: 'error',
1794
+ exit_code: 1,
1795
+ error_message: errorMsg,
1796
+ }, apiToken, apiUrl).catch(() => { }); // Ignore errors updating status
1797
+ if (onStatusUpdate) {
1798
+ try {
1799
+ const result = onStatusUpdate({
1800
+ status: 'error',
1801
+ exit_code: 1,
1802
+ error_message: errorMsg,
1803
+ });
1804
+ if (result && typeof result.then === 'function') {
1805
+ await result.catch(() => { });
1806
+ }
1807
+ }
1808
+ catch (_a) {
1809
+ // Ignore errors in status update callback
1810
+ }
1811
+ }
1812
+ }
1813
+ return {
1814
+ success: false,
1815
+ exitCode: 1,
1816
+ stdout: '',
1817
+ stderr: errorMsg,
1818
+ error: errorMsg,
1819
+ runId: currentRunId,
1820
+ };
1821
+ }
1822
+ }
1823
+ /**
1824
+ * Upload artifacts for a test suite run
1825
+ *
1826
+ * NOTE: This method is preserved for potential future use but is currently disabled.
1827
+ * Artifacts are now accessed locally via the trace viewer instead of being uploaded to the backend.
1828
+ */
1829
+ static async uploadArtifacts(runId, testSuiteId, testSuiteDir, apiToken, apiUrl) {
1830
+ // Artifact uploads disabled - reports are now accessed locally via trace viewer
1831
+ // Method signature preserved for potential future re-enablement
1832
+ return;
1833
+ }
1834
+ }
1835
+
1836
+ async function runCLI(options) {
1837
+ const apiToken = options.apiKey || process.env.PROBO_API_KEY;
1838
+ if (!apiToken) {
1839
+ console.error('❌ Error: PROBO_API_KEY environment variable or --api-key flag is required');
1840
+ process.exit(1);
1841
+ }
1842
+ const apiUrl = options.apiEndpoint || process.env.PROBO_API_ENDPOINT || 'https://api.probolabs.ai';
1843
+ let testSuiteId;
1844
+ // Resolve test suite ID
1845
+ if (options.testSuiteId) {
1846
+ testSuiteId = options.testSuiteId;
1847
+ }
1848
+ else if (options.testSuiteName) {
1849
+ if (!options.project) {
1850
+ console.error('❌ Error: --project is required when using --test-suite-name');
1851
+ process.exit(1);
1852
+ }
1853
+ try {
1854
+ console.log(`🔍 Looking up test suite "${options.testSuiteName}" in project "${options.project}"...`);
1855
+ testSuiteId = await TestSuiteRunner.lookupTestSuiteByName(options.testSuiteName, options.project, apiToken, apiUrl);
1856
+ console.log(`✅ Found test suite ID: ${testSuiteId}`);
1857
+ }
1858
+ catch (error) {
1859
+ console.error(`❌ Error looking up test suite: ${error.message}`);
1860
+ process.exit(1);
1861
+ }
1862
+ }
1863
+ else {
1864
+ console.error('❌ Error: Either --test-suite-id or --test-suite-name must be provided');
1865
+ process.exit(1);
1866
+ }
1867
+ // Run the test suite
1868
+ try {
1869
+ const result = await TestSuiteRunner.runTestSuite(testSuiteId, apiToken, apiUrl, options.testSuiteName, undefined, // runId - will be created automatically
1870
+ {
1871
+ outputDir: options.outputDir,
1872
+ includeReporter: false, // CI/CD mode - no custom reporter
1873
+ });
1874
+ if (result.success) {
1875
+ console.log('✅ Test suite completed successfully');
1876
+ process.exit(0);
1877
+ }
1878
+ else {
1879
+ console.error(`❌ Test suite failed with exit code ${result.exitCode}`);
1880
+ if (result.error) {
1881
+ console.error(`Error: ${result.error}`);
1882
+ }
1883
+ process.exit(result.exitCode || 1);
1884
+ }
1885
+ }
1886
+ catch (error) {
1887
+ console.error(`❌ Error running test suite: ${error.message}`);
1888
+ process.exit(1);
1889
+ }
1890
+ }
1891
+
1892
+ export { runCLI };
1893
+ //# sourceMappingURL=cli.js.map