@qate/cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1342 @@
1
+ "use strict";
2
+ /**
3
+ * Playwright Code Generator
4
+ * Converts Qate test definitions to runnable Playwright test files
5
+ */
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.generatePlaywrightTests = generatePlaywrightTests;
8
+ exports.generateSummary = generateSummary;
9
+ /**
10
+ * Extract placeholder patterns ({{name}}) from a string
11
+ */
12
+ function extractPlaceholdersFromString(str) {
13
+ if (!str)
14
+ return [];
15
+ const matches = str.match(/\{\{([^}]+)\}\}/g);
16
+ if (!matches)
17
+ return [];
18
+ return matches.map(m => m.replace(/\{\{|\}\}/g, ''));
19
+ }
20
+ /**
21
+ * Extract all unique placeholders from test steps
22
+ */
23
+ function extractPlaceholders(test) {
24
+ const placeholders = new Set();
25
+ for (const step of test.steps || []) {
26
+ const input = step.toolInput || {};
27
+ // Check common input fields that may contain placeholders
28
+ const fieldsToCheck = ['value', 'text', 'url', 'expected', 'selector'];
29
+ for (const field of fieldsToCheck) {
30
+ if (typeof input[field] === 'string') {
31
+ for (const p of extractPlaceholdersFromString(input[field])) {
32
+ placeholders.add(p);
33
+ }
34
+ }
35
+ }
36
+ // Also check nested objects (like verification)
37
+ if (step.verification && typeof step.verification === 'object') {
38
+ for (const key in step.verification) {
39
+ if (typeof step.verification[key] === 'string') {
40
+ for (const p of extractPlaceholdersFromString(step.verification[key])) {
41
+ placeholders.add(p);
42
+ }
43
+ }
44
+ }
45
+ }
46
+ }
47
+ return Array.from(placeholders);
48
+ }
49
+ /**
50
+ * Check if a string contains placeholder patterns
51
+ */
52
+ function containsPlaceholder(str) {
53
+ if (!str)
54
+ return false;
55
+ return /\{\{[^}]+\}\}/.test(str);
56
+ }
57
+ /**
58
+ * Wrap a value with resolve() if it contains placeholders, otherwise return quoted string
59
+ */
60
+ function wrapValue(value, usePlaceholderResolve) {
61
+ if (!value)
62
+ return "''";
63
+ if (usePlaceholderResolve && containsPlaceholder(value)) {
64
+ return `resolve('${escapeString(value)}')`;
65
+ }
66
+ return `'${escapeString(value)}'`;
67
+ }
68
+ // Actions that trigger network/DOM changes and need settling
69
+ const SETTLING_ACTIONS = new Set([
70
+ 'goTo', 'go_to', 'click', 'fill', 'type',
71
+ 'selectOption', 'select_option', 'check', 'uncheck',
72
+ 'doubleClick', 'double_click', 'rightClick', 'right_click',
73
+ 'dragAndDrop', 'drag_and_drop', 'drag', 'evaluate', 'hover'
74
+ ]);
75
+ /**
76
+ * Get the pure Playwright action code (without timing wrapper)
77
+ */
78
+ function getActionCode(toolName, input, indent, usePlaceholderResolve) {
79
+ switch (toolName) {
80
+ case 'goTo':
81
+ case 'go_to':
82
+ return `${indent}await page.goto(${wrapValue(input.url, usePlaceholderResolve)});`;
83
+ case 'click':
84
+ return `${indent}await page.click('${escapeString(input.selector)}');`;
85
+ case 'fill':
86
+ return `${indent}await page.fill('${escapeString(input.selector)}', ${wrapValue(input.value, usePlaceholderResolve)});`;
87
+ case 'type':
88
+ const typeOpts = input.delay ? `, { delay: ${input.delay} }` : '';
89
+ return `${indent}await page.locator('${escapeString(input.selector)}').type(${wrapValue(input.text, usePlaceholderResolve)}${typeOpts});`;
90
+ case 'selectOption':
91
+ case 'select_option':
92
+ const values = Array.isArray(input.values) ? input.values : [input.values];
93
+ return `${indent}await page.selectOption('${escapeString(input.selector)}', ${JSON.stringify(values)});`;
94
+ case 'check':
95
+ return `${indent}await page.check('${escapeString(input.selector)}');`;
96
+ case 'uncheck':
97
+ return `${indent}await page.uncheck('${escapeString(input.selector)}');`;
98
+ case 'hover':
99
+ return `${indent}await page.hover('${escapeString(input.selector)}');`;
100
+ case 'doubleClick':
101
+ case 'double_click':
102
+ return `${indent}await page.dblclick('${escapeString(input.selector)}');`;
103
+ case 'rightClick':
104
+ case 'right_click':
105
+ return `${indent}await page.click('${escapeString(input.selector)}', { button: 'right' });`;
106
+ case 'dragAndDrop':
107
+ case 'drag_and_drop':
108
+ case 'drag':
109
+ return `${indent}await page.locator('${escapeString(input.sourceSelector)}').dragTo(page.locator('${escapeString(input.targetSelector)}'));`;
110
+ case 'evaluate':
111
+ return `${indent}await page.evaluate(() => { ${input.script} });`;
112
+ default:
113
+ return '';
114
+ }
115
+ }
116
+ /**
117
+ * Wrap action with timing capture and error tracking for step result reporting
118
+ * Used for settling actions (click, fill, goTo, etc.) that need networkidle wait
119
+ */
120
+ function wrapWithTiming(actionCode, stepId, stepIndex, indent, description) {
121
+ const varPrefix = `step_${stepIndex}`;
122
+ const escapedDescription = escapeString(description);
123
+ return `${indent}// Step ${stepIndex + 1}: ${stepId}
124
+ ${indent}qateStartStepNetworkCapture();
125
+ ${indent}const ${varPrefix}_activityStart = Date.now();
126
+ ${indent}let ${varPrefix}_error: string | undefined;
127
+ ${indent}try {
128
+ ${actionCode.split('\n').map(line => indent + ' ' + line.trim()).join('\n')}
129
+ ${indent}} catch (e) {
130
+ ${indent} ${varPrefix}_error = e instanceof Error ? e.message : String(e);
131
+ ${indent} const ${varPrefix}_activityEnd = Date.now();
132
+ ${indent} qateRecordStepTiming('${stepId}', ${stepIndex}, '${escapedDescription}', 'failed', ${varPrefix}_activityStart, ${varPrefix}_activityEnd, ${varPrefix}_activityEnd, ${varPrefix}_error);
133
+ ${indent} throw e;
134
+ ${indent}}
135
+ ${indent}const ${varPrefix}_activityEnd = Date.now();
136
+ ${indent}await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
137
+ ${indent}const ${varPrefix}_settledEnd = Date.now();
138
+ ${indent}qateRecordStepTiming('${stepId}', ${stepIndex}, '${escapedDescription}', 'passed', ${varPrefix}_activityStart, ${varPrefix}_activityEnd, ${varPrefix}_settledEnd);`;
139
+ }
140
+ /**
141
+ * Wrap action with step tracking (no networkidle wait)
142
+ * Used for non-settling actions like focus, press, scroll, assertions, etc.
143
+ */
144
+ function wrapWithStepTracking(actionCode, stepId, stepIndex, indent, description) {
145
+ const varPrefix = `step_${stepIndex}`;
146
+ const escapedDescription = escapeString(description);
147
+ return `${indent}// Step ${stepIndex + 1}: ${stepId}
148
+ ${indent}qateStartStepNetworkCapture();
149
+ ${indent}const ${varPrefix}_activityStart = Date.now();
150
+ ${indent}try {
151
+ ${actionCode.split('\n').map(line => indent + ' ' + line.trim()).join('\n')}
152
+ ${indent} const ${varPrefix}_activityEnd = Date.now();
153
+ ${indent} qateRecordStepTiming('${stepId}', ${stepIndex}, '${escapedDescription}', 'passed', ${varPrefix}_activityStart, ${varPrefix}_activityEnd, ${varPrefix}_activityEnd);
154
+ ${indent}} catch (e) {
155
+ ${indent} const ${varPrefix}_error = e instanceof Error ? e.message : String(e);
156
+ ${indent} const ${varPrefix}_activityEnd = Date.now();
157
+ ${indent} qateRecordStepTiming('${stepId}', ${stepIndex}, '${escapedDescription}', 'failed', ${varPrefix}_activityStart, ${varPrefix}_activityEnd, ${varPrefix}_activityEnd, ${varPrefix}_error);
158
+ ${indent} throw e;
159
+ ${indent}}`;
160
+ }
161
+ /**
162
+ * Convert a test step to Playwright code
163
+ */
164
+ function stepToPlaywright(step, indent = ' ', usePlaceholderResolve = false, stepIndex = 0) {
165
+ const toolName = step.toolName?.replace('ui_automation--', '') || '';
166
+ const input = step.toolInput || {};
167
+ const stepId = step.id || `step_${stepIndex}`;
168
+ const description = step.description || `${toolName} ${input.selector || input.url || ''}`.trim();
169
+ // For actions that trigger network changes, wrap with timing capture
170
+ if (SETTLING_ACTIONS.has(toolName)) {
171
+ const actionCode = getActionCode(toolName, input, indent, usePlaceholderResolve);
172
+ if (actionCode) {
173
+ return wrapWithTiming(actionCode, stepId, stepIndex, indent, description);
174
+ }
175
+ }
176
+ // Handle non-settling actions (assertions, waits, navigation info, etc.)
177
+ // Generate the raw action code first, then wrap with step tracking
178
+ let actionCode = null;
179
+ switch (toolName) {
180
+ case 'focus':
181
+ actionCode = `await page.focus('${escapeString(input.selector)}');`;
182
+ break;
183
+ case 'press':
184
+ case 'pressKey':
185
+ case 'press_key':
186
+ if (input.selector) {
187
+ actionCode = `await page.click('${escapeString(input.selector)}');\n await page.keyboard.press('${escapeString(input.key)}');`;
188
+ }
189
+ else {
190
+ actionCode = `await page.keyboard.press('${escapeString(input.key)}');`;
191
+ }
192
+ break;
193
+ case 'scroll':
194
+ const direction = input.direction || 'down';
195
+ const multiplier = input.multiplier || 0.7;
196
+ actionCode = `await page.evaluate(({ dir, mult }) => {
197
+ const scrollAmount = window.innerHeight * mult;
198
+ window.scrollBy(0, dir === 'down' ? scrollAmount : -scrollAmount);
199
+ }, { dir: '${direction}', mult: ${multiplier} });`;
200
+ break;
201
+ case 'waitForSelector':
202
+ case 'wait_for_selector':
203
+ case 'waitForElement':
204
+ case 'wait_for_element':
205
+ const state = input.state || 'visible';
206
+ actionCode = `await page.waitForSelector('${escapeString(input.selector)}', { state: '${state}' });`;
207
+ break;
208
+ case 'assert':
209
+ actionCode = generateAssertionCode(input, usePlaceholderResolve);
210
+ break;
211
+ case 'screenshot':
212
+ actionCode = `await page.screenshot({ fullPage: ${input.fullPage || false} });`;
213
+ break;
214
+ case 'goBack':
215
+ case 'go_back':
216
+ actionCode = `await page.goBack();`;
217
+ break;
218
+ case 'goForward':
219
+ case 'go_forward':
220
+ actionCode = `await page.goForward();`;
221
+ break;
222
+ case 'reload':
223
+ actionCode = `await page.reload();`;
224
+ break;
225
+ case 'getTitle':
226
+ case 'get_title':
227
+ actionCode = `const title = await page.title();`;
228
+ break;
229
+ case 'getCurrentUrl':
230
+ case 'get_current_url':
231
+ actionCode = `const url = page.url();`;
232
+ break;
233
+ case 'getInnerHTML':
234
+ case 'get_inner_html':
235
+ actionCode = `const html = await page.locator('${escapeString(input.selector)}').innerHTML();`;
236
+ break;
237
+ case 'getInputValue':
238
+ case 'get_input_value':
239
+ actionCode = `const value = await page.locator('${escapeString(input.selector)}').inputValue();`;
240
+ break;
241
+ case 'setViewportSize':
242
+ case 'set_viewport_size':
243
+ actionCode = `await page.setViewportSize({ width: ${input.width}, height: ${input.height} });`;
244
+ break;
245
+ default:
246
+ // For unknown tools, add a comment (no step tracking)
247
+ return `${indent}// TODO: Unsupported tool '${toolName}' - ${JSON.stringify(input)}`;
248
+ }
249
+ // Wrap the action with step tracking
250
+ if (actionCode) {
251
+ return wrapWithStepTracking(`${indent}${actionCode}`, stepId, stepIndex, indent, description);
252
+ }
253
+ return `${indent}// No action generated for '${toolName}'`;
254
+ }
255
+ /**
256
+ * Generate Playwright assertion code (raw, without indent prefix)
257
+ */
258
+ function generateAssertionCode(input, usePlaceholderResolve = false) {
259
+ const { selector, assertion, expected, attribute, timeout } = input;
260
+ const timeoutOpt = timeout ? `{ timeout: ${timeout} }` : '';
261
+ switch (assertion) {
262
+ case 'toBeVisible':
263
+ return `await expect(page.locator('${escapeString(selector)}')).toBeVisible(${timeoutOpt});`;
264
+ case 'toBeHidden':
265
+ return `await expect(page.locator('${escapeString(selector)}')).toBeHidden();`;
266
+ case 'toBeEnabled':
267
+ return `await expect(page.locator('${escapeString(selector)}')).toBeEnabled();`;
268
+ case 'toBeDisabled':
269
+ return `await expect(page.locator('${escapeString(selector)}')).toBeDisabled();`;
270
+ case 'toHaveText':
271
+ return `await expect(page.locator('${escapeString(selector)}')).toHaveText(${wrapValue(expected, usePlaceholderResolve)});`;
272
+ case 'toContainText':
273
+ return `await expect(page.locator('${escapeString(selector)}')).toContainText(${wrapValue(expected, usePlaceholderResolve)});`;
274
+ case 'toHaveValue':
275
+ return `await expect(page.locator('${escapeString(selector)}')).toHaveValue(${wrapValue(expected, usePlaceholderResolve)});`;
276
+ case 'toHaveAttribute':
277
+ return `await expect(page.locator('${escapeString(selector)}')).toHaveAttribute('${escapeString(attribute)}', ${wrapValue(expected, usePlaceholderResolve)});`;
278
+ case 'toHaveCount':
279
+ return `await expect(page.locator('${escapeString(selector)}')).toHaveCount(${expected});`;
280
+ case 'toBeChecked':
281
+ return `await expect(page.locator('${escapeString(selector)}')).toBeChecked();`;
282
+ case 'toBeUnchecked':
283
+ return `await expect(page.locator('${escapeString(selector)}')).not.toBeChecked();`;
284
+ case 'toBeEditable':
285
+ return `await expect(page.locator('${escapeString(selector)}')).toBeEditable();`;
286
+ case 'toBeFocused':
287
+ return `await expect(page.locator('${escapeString(selector)}')).toBeFocused();`;
288
+ case 'toHaveURL':
289
+ return `await expect(page).toHaveURL(${typeof expected === 'string' ? wrapValue(expected, usePlaceholderResolve) : expected});`;
290
+ case 'toHaveTitle':
291
+ return `await expect(page).toHaveTitle(${typeof expected === 'string' ? wrapValue(expected, usePlaceholderResolve) : expected});`;
292
+ default:
293
+ return `// TODO: Unsupported assertion '${assertion}'`;
294
+ }
295
+ }
296
+ /**
297
+ * Escape string for use in generated code
298
+ */
299
+ function escapeString(str) {
300
+ if (!str)
301
+ return '';
302
+ return str
303
+ .replace(/\\/g, '\\\\')
304
+ .replace(/'/g, "\\'")
305
+ .replace(/\n/g, '\\n')
306
+ .replace(/\r/g, '\\r');
307
+ }
308
+ /**
309
+ * Convert test name to valid filename
310
+ */
311
+ function toFilename(name) {
312
+ return name
313
+ .toLowerCase()
314
+ .replace(/[^a-z0-9]+/g, '-')
315
+ .replace(/^-|-$/g, '')
316
+ .substring(0, 50);
317
+ }
318
+ /**
319
+ * Generate a single test file
320
+ */
321
+ function generateTestFile(test, baseUrl) {
322
+ const placeholders = extractPlaceholders(test);
323
+ const hasPlaceholders = placeholders.length > 0;
324
+ // Filter and sort steps, then map with index for timing capture
325
+ const filteredSteps = (test.steps || [])
326
+ .filter(s => s.type === 'tool_use')
327
+ .sort((a, b) => (a.order || 0) - (b.order || 0));
328
+ const steps = filteredSteps
329
+ .map((step, index) => stepToPlaywright(step, ' ', hasPlaceholders, index))
330
+ .join('\n\n');
331
+ const description = test.description ? `\n * ${test.description}` : '';
332
+ const tags = test.tags?.length ? `\n * Tags: ${test.tags.join(', ')}` : '';
333
+ const priority = test.priority ? `\n * Priority: ${test.priority}` : '';
334
+ // Generate import statements
335
+ const imports = [`import { test, expect } from '@playwright/test';`];
336
+ imports.push(`import { qateRecordStepTiming, qateGetAndClearStepTimings, qateSetupNetworkCapture } from '../qate-runtime';`);
337
+ if (hasPlaceholders) {
338
+ imports.push(`import { qateResolve, replacePlaceholders } from '../qate-runtime';`);
339
+ }
340
+ // Generate test context and resolver setup for placeholder tests
341
+ let contextSetup = '';
342
+ if (hasPlaceholders) {
343
+ const testContext = {
344
+ testId: test.id?.toString() || '',
345
+ testName: test.name,
346
+ datasetId: test.datasetId || null,
347
+ authRoleId: test.authRoleId || null,
348
+ placeholders: placeholders,
349
+ };
350
+ contextSetup = `
351
+ // Resolve placeholders at runtime
352
+ const testContext = ${JSON.stringify(testContext, null, 6).replace(/\n/g, '\n ')};
353
+ const resolved = await qateResolve(testContext);
354
+ const resolve = (v: string) => replacePlaceholders(v, resolved);
355
+ `;
356
+ }
357
+ return `/**
358
+ * Test: ${test.name}${description}${tags}${priority}
359
+ *
360
+ * Generated by Qate CLI
361
+ * Do not edit manually - regenerate using: qate generate
362
+ */
363
+
364
+ ${imports.join('\n')}
365
+
366
+ test.describe('${escapeString(test.name)}', () => {
367
+ test('${escapeString(test.name)}', async ({ page }, testInfo) => {
368
+ // Set up network capture (only captures when QATE_EXECUTION_ID is set)
369
+ qateSetupNetworkCapture(page);
370
+ ${contextSetup}
371
+ try {
372
+ ${steps.split('\n').map(line => ' ' + line).join('\n')}
373
+ } finally {
374
+ // Always attach step timings for reporter (even on failure)
375
+ const stepTimings = qateGetAndClearStepTimings();
376
+ if (stepTimings.length > 0) {
377
+ await testInfo.attach('qate-step-timings', {
378
+ body: JSON.stringify(stepTimings),
379
+ contentType: 'application/json',
380
+ });
381
+ }
382
+ }
383
+ });
384
+ });
385
+ `;
386
+ }
387
+ /**
388
+ * Convert user-friendly OS name to BrowserStack format
389
+ */
390
+ function toBrowserStackOS(os) {
391
+ const osLower = os.toLowerCase();
392
+ if (osLower === 'macos' || osLower === 'mac' || osLower === 'osx') {
393
+ return 'OS X';
394
+ }
395
+ if (osLower === 'windows' || osLower === 'win') {
396
+ return 'Windows';
397
+ }
398
+ return 'Windows'; // default
399
+ }
400
+ /**
401
+ * Convert user-friendly browser name to BrowserStack format
402
+ */
403
+ function toBrowserStackBrowser(browser) {
404
+ const browserLower = browser.toLowerCase();
405
+ if (browserLower === 'safari' || browserLower === 'webkit') {
406
+ return 'playwright-webkit';
407
+ }
408
+ if (browserLower === 'firefox' || browserLower === 'ff') {
409
+ return 'playwright-firefox';
410
+ }
411
+ if (browserLower === 'edge') {
412
+ return 'edge';
413
+ }
414
+ return 'chrome'; // default
415
+ }
416
+ /**
417
+ * Convert user-friendly browser name to LambdaTest format
418
+ */
419
+ function toLambdaTestBrowser(browser) {
420
+ const browserLower = browser.toLowerCase();
421
+ if (browserLower === 'safari' || browserLower === 'webkit') {
422
+ return 'pw-webkit';
423
+ }
424
+ if (browserLower === 'firefox' || browserLower === 'ff') {
425
+ return 'pw-firefox';
426
+ }
427
+ if (browserLower === 'edge') {
428
+ return 'MicrosoftEdge';
429
+ }
430
+ return 'Chrome'; // default
431
+ }
432
+ /**
433
+ * Convert user-friendly OS to LambdaTest platform format
434
+ */
435
+ function toLambdaTestPlatform(os, osVersion) {
436
+ const osLower = os.toLowerCase();
437
+ if (osLower === 'macos' || osLower === 'mac' || osLower === 'osx') {
438
+ return `MacOS ${osVersion || 'Sonoma'}`;
439
+ }
440
+ if (osLower === 'windows' || osLower === 'win') {
441
+ return `Windows ${osVersion || '11'}`;
442
+ }
443
+ return `Windows ${osVersion || '11'}`; // default
444
+ }
445
+ /**
446
+ * Generate playwright.config.ts
447
+ */
448
+ function generateConfig(options, appUrl) {
449
+ const { provider = 'local', browsers = ['chromium'], browser = 'chrome', browserVersion = 'latest', os = 'windows', osVersion = '11', orchestration = 'websocket' } = options;
450
+ let projects = '';
451
+ let connectOptions = '';
452
+ // Determine effective orchestration mode
453
+ // Sauce Labs only supports CLI orchestration
454
+ const effectiveOrchestration = provider === 'saucelabs' ? 'cli' : orchestration;
455
+ if (provider === 'browserstack' && effectiveOrchestration === 'websocket') {
456
+ const bsBrowser = toBrowserStackBrowser(browser);
457
+ const bsOS = toBrowserStackOS(os);
458
+ connectOptions = `
459
+ // BrowserStack configuration (WebSocket/CDP approach)
460
+ // Docs: https://www.browserstack.com/docs/automate/playwright
461
+ use: {
462
+ connectOptions: {
463
+ wsEndpoint: \`wss://cdp.browserstack.com/playwright?caps=\${encodeURIComponent(JSON.stringify({
464
+ 'browser': '${bsBrowser}',
465
+ 'browser_version': '${browserVersion}',
466
+ 'os': '${bsOS}',
467
+ 'os_version': '${osVersion}',
468
+ 'project': 'Qate Tests',
469
+ 'build': process.env.BUILD_NUMBER || 'local',
470
+ 'name': 'Playwright Test',
471
+ 'browserstack.username': process.env.BROWSERSTACK_USERNAME,
472
+ 'browserstack.accessKey': process.env.BROWSERSTACK_ACCESS_KEY,
473
+ 'browserstack.debug': true,
474
+ 'browserstack.console': 'verbose',
475
+ 'browserstack.networkLogs': true,
476
+ }))}\`,
477
+ },
478
+ },`;
479
+ }
480
+ else if (provider === 'browserstack' && effectiveOrchestration === 'cli') {
481
+ // BrowserStack CLI orchestration - no connectOptions needed
482
+ connectOptions = `
483
+ // BrowserStack configuration (CLI orchestration)
484
+ // Run with: npx browserstack-node-sdk playwright test
485
+ // Docs: https://www.browserstack.com/docs/automate/playwright/getting-started/nodejs`;
486
+ }
487
+ else if (provider === 'saucelabs') {
488
+ // Sauce Labs only supports CLI orchestration
489
+ connectOptions = `
490
+ // Sauce Labs configuration (CLI orchestration only)
491
+ // IMPORTANT: Sauce Labs requires the saucectl CLI to run tests
492
+ // Install: npm install -g saucectl
493
+ // Run: saucectl run
494
+ // Docs: https://docs.saucelabs.com/web-apps/automated-testing/playwright/`;
495
+ }
496
+ else if (provider === 'lambdatest' && effectiveOrchestration === 'websocket') {
497
+ const ltBrowser = toLambdaTestBrowser(browser);
498
+ const ltPlatform = toLambdaTestPlatform(os, osVersion);
499
+ connectOptions = `
500
+ // LambdaTest configuration (WebSocket/CDP approach)
501
+ // Docs: https://www.lambdatest.com/support/docs/playwright-testing/
502
+ use: {
503
+ connectOptions: {
504
+ wsEndpoint: \`wss://cdp.lambdatest.com/playwright?capabilities=\${encodeURIComponent(JSON.stringify({
505
+ 'browserName': '${ltBrowser}',
506
+ 'browserVersion': '${browserVersion}',
507
+ 'LT:Options': {
508
+ 'platform': '${ltPlatform}',
509
+ 'build': process.env.BUILD_NUMBER || 'Qate Tests',
510
+ 'name': 'Qate Playwright Test',
511
+ 'user': process.env.LT_USERNAME,
512
+ 'accessKey': process.env.LT_ACCESS_KEY,
513
+ 'network': true,
514
+ 'video': true,
515
+ 'console': true,
516
+ }
517
+ }))}\`,
518
+ },
519
+ },`;
520
+ }
521
+ else if (provider === 'lambdatest' && effectiveOrchestration === 'cli') {
522
+ // LambdaTest CLI orchestration - no connectOptions needed
523
+ connectOptions = `
524
+ // LambdaTest HyperExecute configuration (CLI orchestration)
525
+ // Run with: ./hyperexecute --config hyperexecute.yaml
526
+ // Docs: https://www.lambdatest.com/support/docs/hyperexecute-guided-walkthrough/`;
527
+ }
528
+ else {
529
+ // Local provider
530
+ const browserProjects = browsers.map(browser => `
531
+ {
532
+ name: '${browser}',
533
+ use: { ...devices['Desktop Chrome'] },
534
+ }`).join(',');
535
+ projects = `
536
+ projects: [${browserProjects}
537
+ ],`;
538
+ }
539
+ return `/**
540
+ * Playwright Configuration
541
+ * Generated by Qate CLI
542
+ *
543
+ * Provider: ${provider}
544
+ */
545
+
546
+ import { defineConfig, devices } from '@playwright/test';
547
+
548
+ export default defineConfig({
549
+ testDir: './tests',
550
+ fullyParallel: true,
551
+ forbidOnly: !!process.env.CI,
552
+ retries: process.env.CI ? 2 : 0,
553
+ workers: process.env.CI ? 1 : undefined,
554
+ reporter: [
555
+ ['html'],
556
+ ['json', { outputFile: 'test-results.json' }],
557
+ ['./qate-reporter.ts'],
558
+ ],
559
+
560
+ use: {
561
+ baseURL: '${appUrl}',
562
+ trace: 'on-first-retry',
563
+ screenshot: 'only-on-failure',
564
+ },
565
+ ${connectOptions}${projects}
566
+ // Global timeout
567
+ timeout: 30000,
568
+ expect: {
569
+ timeout: 5000,
570
+ },
571
+ });
572
+ `;
573
+ }
574
+ /**
575
+ * Generate package.json
576
+ */
577
+ function generatePackageJson(name, provider = 'local') {
578
+ const safeName = name.toLowerCase().replace(/[^a-z0-9-]/g, '-');
579
+ const scripts = {
580
+ test: 'playwright test',
581
+ 'test:headed': 'playwright test --headed',
582
+ 'test:debug': 'playwright test --debug',
583
+ report: 'playwright show-report',
584
+ };
585
+ const devDependencies = {
586
+ '@playwright/test': '^1.40.0',
587
+ };
588
+ // Add saucectl for Sauce Labs
589
+ if (provider === 'saucelabs') {
590
+ scripts.test = 'saucectl run';
591
+ scripts['test:local'] = 'playwright test';
592
+ }
593
+ return JSON.stringify({
594
+ name: `${safeName}-e2e-tests`,
595
+ version: '1.0.0',
596
+ description: `E2E tests generated by Qate CLI`,
597
+ scripts,
598
+ devDependencies,
599
+ }, null, 2);
600
+ }
601
+ /**
602
+ * Generate Qate reporter for sending results back to Qate
603
+ */
604
+ function generateQateReporter(testMapping, options) {
605
+ const testMappingJson = JSON.stringify(testMapping, null, 2);
606
+ const executionId = options.executionId || '';
607
+ const apiUrl = options.apiUrl || 'https://api.qate.ai';
608
+ const provider = options.provider || 'local';
609
+ return `/**
610
+ * Qate Reporter
611
+ * Reports test results back to Qate for tracking and analytics
612
+ *
613
+ * Generated by Qate CLI
614
+ */
615
+
616
+ import type {
617
+ Reporter,
618
+ FullConfig,
619
+ Suite,
620
+ TestCase,
621
+ TestResult,
622
+ FullResult,
623
+ } from '@playwright/test/reporter';
624
+ import type { StepTiming } from './qate-runtime';
625
+
626
+ // Qate configuration (hardcoded at generation time)
627
+ const EXECUTION_ID = '${executionId}';
628
+ const API_URL = '${apiUrl}';
629
+ const PROVIDER = '${provider}';
630
+
631
+ // Test ID mapping from Qate
632
+ const TEST_MAPPING: { [filename: string]: { testId: string; testName: string } } = ${testMappingJson};
633
+
634
+ interface StepNetworkRequest {
635
+ url: string;
636
+ method: string;
637
+ status?: number;
638
+ contentType?: string;
639
+ duration?: number;
640
+ failure?: string;
641
+ }
642
+
643
+ interface StepResult {
644
+ stepId?: string;
645
+ stepIndex: number;
646
+ description: string;
647
+ status: 'passed' | 'failed' | 'error' | 'skipped';
648
+ duration: number;
649
+ error?: string;
650
+ performanceData?: {
651
+ activityStart: string;
652
+ activityEnd: string;
653
+ activityDuration: number;
654
+ activityDurationFormatted: string;
655
+ settledEnd?: string;
656
+ settledDuration?: number;
657
+ settledDurationFormatted?: string;
658
+ };
659
+ networkRequests?: StepNetworkRequest[];
660
+ }
661
+
662
+ interface TestResultData {
663
+ testId: string;
664
+ testName: string;
665
+ status: 'passed' | 'failed' | 'error';
666
+ duration: number;
667
+ startedAt: string;
668
+ steps: StepResult[];
669
+ error?: string;
670
+ metadata?: Record<string, unknown>;
671
+ }
672
+
673
+ class QateReporter implements Reporter {
674
+ private results: TestResultData[] = [];
675
+ private startTime: Date = new Date();
676
+ private config: FullConfig | null = null;
677
+
678
+ onBegin(config: FullConfig, suite: Suite) {
679
+ this.config = config;
680
+ this.startTime = new Date();
681
+ console.log('\\n[Qate] Starting test execution...');
682
+ console.log('[Qate] Execution ID:', EXECUTION_ID || 'not set');
683
+ }
684
+
685
+ onTestEnd(test: TestCase, result: TestResult) {
686
+ // Find test mapping by filename
687
+ const filePath = test.location.file;
688
+ const filename = filePath.split('/').pop()?.split('\\\\').pop() || '';
689
+ const mapping = TEST_MAPPING[filename];
690
+
691
+ if (!mapping) {
692
+ console.warn('[Qate] No mapping found for test file:', filename);
693
+ return;
694
+ }
695
+
696
+ // Get step timings from test attachments (cross-process communication from worker)
697
+ let collectedStepTimings: StepTiming[] = [];
698
+ const stepTimingsAttachment = result.attachments.find(a => a.name === 'qate-step-timings');
699
+ if (stepTimingsAttachment?.body) {
700
+ try {
701
+ collectedStepTimings = JSON.parse(stepTimingsAttachment.body.toString());
702
+ } catch (e) {
703
+ console.warn('[Qate] Failed to parse step timings attachment');
704
+ }
705
+ }
706
+
707
+ // Helper to format duration
708
+ const formatDuration = (ms: number): string => {
709
+ if (ms < 1000) return \`\${Math.round(ms)}ms\`;
710
+ if (ms < 60000) return \`\${(ms / 1000).toFixed(2)}s\`;
711
+ const minutes = Math.floor(ms / 60000);
712
+ const seconds = ((ms % 60000) / 1000).toFixed(1);
713
+ return \`\${minutes}m \${seconds}s\`;
714
+ };
715
+
716
+ // Build steps from stepTimings (collected via qateRecordStepTiming calls in generated test code)
717
+ // This is more reliable than result.steps which only contains steps defined via test.step()
718
+ const steps: StepResult[] = collectedStepTimings
719
+ .sort((a, b) => a.stepIndex - b.stepIndex)
720
+ .map((timing) => {
721
+ const stepResult: StepResult = {
722
+ stepId: timing.stepId,
723
+ stepIndex: timing.stepIndex,
724
+ description: timing.description,
725
+ status: timing.status,
726
+ duration: timing.settledDuration,
727
+ error: timing.error,
728
+ };
729
+
730
+ // Add performance data
731
+ stepResult.performanceData = {
732
+ activityStart: new Date(timing.activityStart).toISOString(),
733
+ activityEnd: new Date(timing.activityEnd).toISOString(),
734
+ activityDuration: timing.activityDuration,
735
+ activityDurationFormatted: formatDuration(timing.activityDuration),
736
+ settledEnd: new Date(timing.settledEnd).toISOString(),
737
+ settledDuration: timing.settledDuration,
738
+ settledDurationFormatted: formatDuration(timing.settledDuration),
739
+ };
740
+
741
+ // Add network requests if captured
742
+ if (timing.networkRequests && timing.networkRequests.length > 0) {
743
+ stepResult.networkRequests = timing.networkRequests;
744
+ }
745
+
746
+ return stepResult;
747
+ });
748
+
749
+ // Log step count for debugging
750
+ console.log(\`[Qate] Collected \${steps.length} step results from attachment\`);
751
+
752
+ const testResult: TestResultData = {
753
+ testId: mapping.testId,
754
+ testName: mapping.testName,
755
+ status: result.status === 'passed' ? 'passed' : result.status === 'timedOut' ? 'error' : 'failed',
756
+ duration: result.duration,
757
+ startedAt: result.startTime.toISOString(),
758
+ steps,
759
+ error: result.error?.message,
760
+ metadata: {
761
+ retries: result.retry,
762
+ workerIndex: result.workerIndex,
763
+ attachments: result.attachments.map(a => ({ name: a.name, contentType: a.contentType })),
764
+ },
765
+ };
766
+
767
+ this.results.push(testResult);
768
+
769
+ const statusIcon = testResult.status === 'passed' ? '✓' : '✗';
770
+ console.log(\`[Qate] \${statusIcon} \${mapping.testName} (\${testResult.duration}ms)\`);
771
+ }
772
+
773
+ async onEnd(result: FullResult) {
774
+ const apiKey = process.env.QATE_API_KEY;
775
+
776
+ if (!EXECUTION_ID) {
777
+ console.log('\\n[Qate] No execution ID configured - skipping result reporting');
778
+ return;
779
+ }
780
+
781
+ if (!apiKey) {
782
+ console.log('\\n[Qate] QATE_API_KEY not set - skipping result reporting');
783
+ return;
784
+ }
785
+
786
+ console.log('\\n[Qate] Reporting results to Qate...');
787
+
788
+ const payload = {
789
+ executionId: EXECUTION_ID,
790
+ status: result.status,
791
+ results: this.results,
792
+ summary: {
793
+ total: this.results.length,
794
+ passed: this.results.filter(r => r.status === 'passed').length,
795
+ failed: this.results.filter(r => r.status === 'failed').length,
796
+ error: this.results.filter(r => r.status === 'error').length,
797
+ status: result.status,
798
+ },
799
+ startedAt: this.startTime.toISOString(),
800
+ completedAt: new Date().toISOString(),
801
+ provider: PROVIDER,
802
+ environment: {
803
+ browser: this.config?.projects?.[0]?.name || 'unknown',
804
+ os: process.platform,
805
+ },
806
+ };
807
+
808
+ try {
809
+ const controller = new AbortController();
810
+ const timeoutId = setTimeout(() => controller.abort(), 30000); // 30s timeout
811
+
812
+ const response = await fetch(\`\${API_URL}/ci/report\`, {
813
+ method: 'POST',
814
+ headers: {
815
+ 'Content-Type': 'application/json',
816
+ 'X-API-Key': apiKey,
817
+ },
818
+ body: JSON.stringify(payload),
819
+ signal: controller.signal,
820
+ });
821
+
822
+ clearTimeout(timeoutId);
823
+
824
+ if (response.ok) {
825
+ const data = await response.json();
826
+ console.log('[Qate] Results reported successfully');
827
+ console.log(\`[Qate] Summary: \${data.summary?.passed || 0} passed, \${data.summary?.failed || 0} failed\`);
828
+ console.log(\`[Qate] View results at: https://app.qate.ai/executions/\${EXECUTION_ID}\`);
829
+ } else {
830
+ const error = await response.text();
831
+ console.error('[Qate] Failed to report results:', response.status, error);
832
+ }
833
+ } catch (error: any) {
834
+ if (error.name === 'AbortError') {
835
+ console.error('[Qate] Request timed out after 30 seconds');
836
+ } else {
837
+ console.error('[Qate] Error reporting results:', error.message || error);
838
+ }
839
+ }
840
+
841
+ // Small delay to ensure HTTP connection is properly closed before Playwright cleanup
842
+ await new Promise(resolve => setTimeout(resolve, 100));
843
+ }
844
+ }
845
+
846
+ export default QateReporter;
847
+ `;
848
+ }
849
+ /**
850
+ * Generate qate-runtime.ts helper for placeholder resolution
851
+ */
852
+ function generateQateRuntime(apiUrl, executionId) {
853
+ const trackingEnabled = executionId ? 'true' : 'false';
854
+ return `/**
855
+ * Qate Runtime Helper
856
+ * Resolves placeholders at runtime by calling the Qate API
857
+ *
858
+ * Generated by Qate CLI
859
+ */
860
+
861
+ export interface QateTestContext {
862
+ testId: string;
863
+ testName: string;
864
+ datasetId?: string | null;
865
+ authRoleId?: string | null;
866
+ placeholders: string[];
867
+ }
868
+
869
+ interface PlaceholderResolutionResult {
870
+ success: boolean;
871
+ values: Record<string, string>;
872
+ metadata: {
873
+ totpTimeRemaining?: number | null;
874
+ };
875
+ }
876
+
877
+ // Cache for non-TOTP values (cleared per test)
878
+ let placeholderCache: Record<string, string> = {};
879
+
880
+ /**
881
+ * Resolve placeholders by calling the Qate API
882
+ * Note: TOTP values are always fetched fresh (not cached)
883
+ */
884
+ export async function qateResolve(context: QateTestContext): Promise<Record<string, string>> {
885
+ const apiKey = process.env.QATE_API_KEY;
886
+ const apiUrl = '${apiUrl}';
887
+
888
+ if (!apiKey) {
889
+ console.warn('[Qate Runtime] QATE_API_KEY not set - placeholders will not be resolved');
890
+ return {};
891
+ }
892
+
893
+ if (context.placeholders.length === 0) {
894
+ return {};
895
+ }
896
+
897
+ // Clear cache for new test
898
+ placeholderCache = {};
899
+
900
+ try {
901
+ const response = await fetch(\`\${apiUrl}/ci/resolve-placeholders\`, {
902
+ method: 'POST',
903
+ headers: {
904
+ 'Content-Type': 'application/json',
905
+ 'X-API-Key': apiKey,
906
+ },
907
+ body: JSON.stringify({
908
+ testId: context.testId,
909
+ datasetId: context.datasetId,
910
+ authRoleId: context.authRoleId,
911
+ placeholders: context.placeholders,
912
+ }),
913
+ });
914
+
915
+ if (!response.ok) {
916
+ const error = await response.text();
917
+ console.error('[Qate Runtime] Failed to resolve placeholders:', response.status, error);
918
+ return {};
919
+ }
920
+
921
+ const data: PlaceholderResolutionResult = await response.json();
922
+
923
+ if (!data.success) {
924
+ console.warn('[Qate Runtime] Placeholder resolution returned unsuccessful');
925
+ return {};
926
+ }
927
+
928
+ // Cache non-TOTP values
929
+ for (const [key, value] of Object.entries(data.values)) {
930
+ if (key !== 'mfa_code') {
931
+ placeholderCache[key] = value;
932
+ }
933
+ }
934
+
935
+ // Log TOTP time remaining if present
936
+ if (data.metadata?.totpTimeRemaining != null) {
937
+ console.log(\`[Qate Runtime] TOTP time remaining: \${data.metadata.totpTimeRemaining}s\`);
938
+ }
939
+
940
+ return data.values;
941
+ } catch (error) {
942
+ console.error('[Qate Runtime] Error resolving placeholders:', error);
943
+ return {};
944
+ }
945
+ }
946
+
947
+ /**
948
+ * Replace placeholder patterns in a string with resolved values
949
+ */
950
+ export function replacePlaceholders(text: string, values: Record<string, string>): string {
951
+ return text.replace(/\{\{([^}]+)\}\}/g, (match, name) => {
952
+ if (name in values) {
953
+ return values[name];
954
+ }
955
+ console.warn(\`[Qate Runtime] Unresolved placeholder: \${name}\`);
956
+ return match; // Return original if not resolved
957
+ });
958
+ }
959
+
960
+ // Step timing storage - collected by QateReporter after test completes
961
+ export interface StepTiming {
962
+ stepId: string;
963
+ stepIndex: number;
964
+ description: string;
965
+ status: 'passed' | 'failed';
966
+ error?: string;
967
+ activityStart: number;
968
+ activityEnd: number;
969
+ settledEnd: number;
970
+ activityDuration: number;
971
+ settledDuration: number;
972
+ networkRequests?: NetworkRequest[];
973
+ }
974
+
975
+ // Global storage for step timings (cleared per test)
976
+ export const stepTimings: StepTiming[] = [];
977
+
978
+ // Network activity capture (only when tracking is enabled)
979
+ export interface NetworkRequest {
980
+ url: string;
981
+ method: string;
982
+ status?: number;
983
+ contentType?: string;
984
+ duration?: number;
985
+ failure?: string;
986
+ }
987
+
988
+ // Per-step network capture state
989
+ let currentStepNetworkRequests: NetworkRequest[] = [];
990
+ let pendingRequests = new Map<string, { url: string; method: string; requestTime: number }>();
991
+ let networkCaptureEnabled = false;
992
+ const MAX_NETWORK_REQUESTS_PER_STEP = 20;
993
+
994
+ /**
995
+ * Set up network capture on a Playwright page
996
+ * Only captures when QATE_EXECUTION_ID is set (tracking enabled)
997
+ */
998
+ export function qateSetupNetworkCapture(page: any): void {
999
+ // Skip if tracking is disabled
1000
+ if (!${trackingEnabled}) {
1001
+ return;
1002
+ }
1003
+ networkCaptureEnabled = true;
1004
+
1005
+ page.on('request', (request: any) => {
1006
+ if (!networkCaptureEnabled) return;
1007
+ if (currentStepNetworkRequests.length >= MAX_NETWORK_REQUESTS_PER_STEP) return;
1008
+
1009
+ const url = request.url();
1010
+ // Skip data URLs, chrome-extension, and static assets
1011
+ if (url.startsWith('data:') || url.startsWith('chrome-extension:') ||
1012
+ url.match(/\\.(png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot|ico|css)$/i)) {
1013
+ return;
1014
+ }
1015
+
1016
+ pendingRequests.set(url, {
1017
+ url: url.length > 500 ? url.substring(0, 500) + '...' : url,
1018
+ method: request.method(),
1019
+ requestTime: Date.now(),
1020
+ });
1021
+ });
1022
+
1023
+ page.on('response', (response: any) => {
1024
+ if (!networkCaptureEnabled) return;
1025
+ const url = response.url();
1026
+ const pending = pendingRequests.get(url);
1027
+ if (pending && currentStepNetworkRequests.length < MAX_NETWORK_REQUESTS_PER_STEP) {
1028
+ currentStepNetworkRequests.push({
1029
+ url: pending.url,
1030
+ method: pending.method,
1031
+ status: response.status(),
1032
+ contentType: response.headers()['content-type']?.split(';')[0],
1033
+ duration: Date.now() - pending.requestTime,
1034
+ });
1035
+ pendingRequests.delete(url);
1036
+ }
1037
+ });
1038
+
1039
+ page.on('requestfailed', (request: any) => {
1040
+ if (!networkCaptureEnabled) return;
1041
+ const url = request.url();
1042
+ const pending = pendingRequests.get(url);
1043
+ if (pending && currentStepNetworkRequests.length < MAX_NETWORK_REQUESTS_PER_STEP) {
1044
+ currentStepNetworkRequests.push({
1045
+ url: pending.url,
1046
+ method: pending.method,
1047
+ failure: request.failure()?.errorText || 'Request failed',
1048
+ duration: Date.now() - pending.requestTime,
1049
+ });
1050
+ pendingRequests.delete(url);
1051
+ }
1052
+ });
1053
+ }
1054
+
1055
+ /**
1056
+ * Start capturing network for a new step (clears previous step's data)
1057
+ */
1058
+ export function qateStartStepNetworkCapture(): void {
1059
+ currentStepNetworkRequests = [];
1060
+ pendingRequests.clear();
1061
+ }
1062
+
1063
+ /**
1064
+ * Get network requests captured during the current step
1065
+ */
1066
+ export function qateGetStepNetworkRequests(): NetworkRequest[] {
1067
+ return [...currentStepNetworkRequests];
1068
+ }
1069
+
1070
+ /**
1071
+ * Record step timing data for performance measurement
1072
+ * Called by generated test code after each action
1073
+ */
1074
+ export function qateRecordStepTiming(
1075
+ stepId: string,
1076
+ stepIndex: number,
1077
+ description: string,
1078
+ status: 'passed' | 'failed',
1079
+ activityStart: number,
1080
+ activityEnd: number,
1081
+ settledEnd: number,
1082
+ error?: string
1083
+ ): void {
1084
+ const activityDuration = activityEnd - activityStart;
1085
+ const settledDuration = settledEnd - activityStart;
1086
+
1087
+ // Capture network requests for this step (if tracking enabled)
1088
+ const networkReqs = qateGetStepNetworkRequests();
1089
+
1090
+ stepTimings.push({
1091
+ stepId,
1092
+ stepIndex,
1093
+ description,
1094
+ status,
1095
+ error,
1096
+ activityStart,
1097
+ activityEnd,
1098
+ settledEnd,
1099
+ activityDuration,
1100
+ settledDuration,
1101
+ networkRequests: networkReqs.length > 0 ? networkReqs : undefined,
1102
+ });
1103
+ }
1104
+
1105
+ /**
1106
+ * Get and clear step timings (called by reporter)
1107
+ */
1108
+ export function qateGetAndClearStepTimings(): StepTiming[] {
1109
+ const timings = [...stepTimings];
1110
+ stepTimings.length = 0;
1111
+ return timings;
1112
+ }
1113
+ `;
1114
+ }
1115
+ /**
1116
+ * Convert user-friendly browser name to Sauce Labs format
1117
+ */
1118
+ function toSauceLabsBrowser(browser) {
1119
+ const browserLower = browser.toLowerCase();
1120
+ if (browserLower === 'safari' || browserLower === 'webkit') {
1121
+ return 'webkit';
1122
+ }
1123
+ if (browserLower === 'firefox' || browserLower === 'ff') {
1124
+ return 'firefox';
1125
+ }
1126
+ if (browserLower === 'edge') {
1127
+ return 'chromium'; // Sauce Labs uses chromium for Edge
1128
+ }
1129
+ return 'chromium'; // default
1130
+ }
1131
+ /**
1132
+ * Convert user-friendly OS to Sauce Labs platform format
1133
+ */
1134
+ function toSauceLabsPlatform(os, osVersion) {
1135
+ const osLower = os.toLowerCase();
1136
+ if (osLower === 'macos' || osLower === 'mac' || osLower === 'osx') {
1137
+ return `macOS ${osVersion || '13'}`;
1138
+ }
1139
+ if (osLower === 'windows' || osLower === 'win') {
1140
+ return `Windows ${osVersion || '11'}`;
1141
+ }
1142
+ return `Windows ${osVersion || '11'}`; // default
1143
+ }
1144
+ /**
1145
+ * Generate Sauce Labs saucectl config.yml
1146
+ */
1147
+ function generateSauceConfig(appUrl, options) {
1148
+ const { browser = 'chrome', os = 'windows', osVersion = '11' } = options;
1149
+ const slBrowser = toSauceLabsBrowser(browser);
1150
+ const slPlatform = toSauceLabsPlatform(os, osVersion);
1151
+ const browserDisplayName = browser.charAt(0).toUpperCase() + browser.slice(1).toLowerCase();
1152
+ return `# Sauce Labs Configuration
1153
+ # Docs: https://docs.saucelabs.com/web-apps/automated-testing/playwright/yaml/
1154
+ apiVersion: v1alpha
1155
+ kind: playwright
1156
+ defaults:
1157
+ timeout: 30m
1158
+ sauce:
1159
+ region: us-west-1
1160
+ concurrency: 5
1161
+ metadata:
1162
+ tags:
1163
+ - qate
1164
+ - e2e
1165
+ build: "\${BUILD_NUMBER:-local}"
1166
+ playwright:
1167
+ version: package.json
1168
+ rootDir: ./
1169
+ suites:
1170
+ - name: "Qate E2E Tests - ${browserDisplayName}"
1171
+ platformName: "${slPlatform}"
1172
+ testMatch: ["tests/*.spec.ts"]
1173
+ params:
1174
+ browserName: "${slBrowser}"
1175
+ baseURL: "${appUrl}"
1176
+ artifacts:
1177
+ download:
1178
+ when: always
1179
+ match:
1180
+ - "playwright-report/*"
1181
+ - "test-results/*"
1182
+ directory: ./artifacts
1183
+ `;
1184
+ }
1185
+ /**
1186
+ * Generate BrowserStack browserstack.yml config
1187
+ */
1188
+ function generateBrowserStackConfig(options) {
1189
+ const { browser = 'chrome', browserVersion = 'latest', os = 'windows', osVersion = '11' } = options;
1190
+ const bsBrowser = toBrowserStackBrowser(browser);
1191
+ const bsOS = toBrowserStackOS(os);
1192
+ return `# BrowserStack Configuration
1193
+ # Docs: https://www.browserstack.com/docs/automate/playwright/getting-started/nodejs
1194
+ #
1195
+ # Run with: npx browserstack-node-sdk playwright test
1196
+
1197
+ userName: \${BROWSERSTACK_USERNAME}
1198
+ accessKey: \${BROWSERSTACK_ACCESS_KEY}
1199
+
1200
+ platforms:
1201
+ - os: ${bsOS}
1202
+ osVersion: "${osVersion}"
1203
+ browserName: ${bsBrowser}
1204
+ browserVersion: ${browserVersion}
1205
+
1206
+ parallelsPerPlatform: 5
1207
+
1208
+ browserstackLocal: false
1209
+
1210
+ projectName: "Qate Tests"
1211
+ buildName: "\${BUILD_NUMBER:-local}"
1212
+ buildIdentifier: "#\${BUILD_NUMBER:-1}"
1213
+
1214
+ debug: true
1215
+ networkLogs: true
1216
+ consoleLogs: verbose
1217
+ `;
1218
+ }
1219
+ /**
1220
+ * Generate LambdaTest HyperExecute YAML config
1221
+ */
1222
+ function generateHyperExecuteConfig(options) {
1223
+ const { browser = 'chrome', os = 'windows', osVersion = '11' } = options;
1224
+ const ltBrowser = toLambdaTestBrowser(browser);
1225
+ const runson = os.toLowerCase() === 'macos' || os.toLowerCase() === 'mac' ? 'mac' : 'linux';
1226
+ return `# LambdaTest HyperExecute Configuration
1227
+ # Docs: https://www.lambdatest.com/support/docs/hyperexecute-guided-walkthrough/
1228
+ #
1229
+ # Run with: ./hyperexecute --config hyperexecute.yaml
1230
+ # Download CLI: https://www.lambdatest.com/support/docs/hyperexecute-cli-run-tests-on-hyperexecute-grid/
1231
+
1232
+ version: "0.1"
1233
+ globalTimeout: 90
1234
+ testSuiteTimeout: 90
1235
+ testSuiteStep: 90
1236
+
1237
+ runson: ${runson}
1238
+
1239
+ autosplit: true
1240
+ retryOnFailure: false
1241
+ maxRetries: 1
1242
+ concurrency: 5
1243
+
1244
+ env:
1245
+ PLAYWRIGHT_BROWSERS_PATH: 0
1246
+
1247
+ pre:
1248
+ - npm install
1249
+ - npx playwright install ${ltBrowser === 'Chrome' ? 'chromium' : ltBrowser === 'pw-firefox' ? 'firefox' : 'webkit'}
1250
+
1251
+ testDiscovery:
1252
+ type: raw
1253
+ mode: dynamic
1254
+ command: grep -rn "test\\|it(" tests/*.spec.ts | cut -d':' -f1 | uniq
1255
+
1256
+ testRunnerCommand: npx playwright test $test --reporter=html
1257
+
1258
+ jobLabel:
1259
+ - qate
1260
+ - playwright
1261
+ - ${browser}
1262
+
1263
+ framework:
1264
+ name: playwright
1265
+ args:
1266
+ browserName: ${ltBrowser}
1267
+ platform: "${os === 'macos' || os === 'mac' ? 'MacOS' : 'Windows'} ${osVersion}"
1268
+ `;
1269
+ }
1270
+ /**
1271
+ * Main generator function
1272
+ */
1273
+ function generatePlaywrightTests(exportData, options) {
1274
+ const appUrl = exportData.application?.url || 'http://localhost:3000';
1275
+ const appName = exportData.application?.name || 'app';
1276
+ const tests = exportData.tests || [];
1277
+ const provider = options.provider || 'local';
1278
+ const result = {
1279
+ 'playwright.config.ts': generateConfig(options, appUrl),
1280
+ 'package.json': generatePackageJson(appName, provider),
1281
+ tests: {},
1282
+ };
1283
+ // Build test mapping for Qate reporter (filename -> testId/testName)
1284
+ const testMapping = {};
1285
+ // Check if any tests have placeholders
1286
+ let anyTestHasPlaceholders = false;
1287
+ // Generate test files
1288
+ for (const test of tests) {
1289
+ const filename = `${toFilename(test.name)}.spec.ts`;
1290
+ result.tests[filename] = generateTestFile(test, appUrl);
1291
+ // Add to test mapping for reporter
1292
+ testMapping[filename] = {
1293
+ testId: test.id?.toString() || '',
1294
+ testName: test.name,
1295
+ };
1296
+ // Check for placeholders in this test
1297
+ if (extractPlaceholders(test).length > 0) {
1298
+ anyTestHasPlaceholders = true;
1299
+ }
1300
+ }
1301
+ // Generate qate-runtime.ts - always needed for step timing functions used by reporter
1302
+ const apiUrl = options.apiUrl || 'https://api.qate.ai';
1303
+ result['qate-runtime.ts'] = generateQateRuntime(apiUrl, options.executionId);
1304
+ // Determine effective orchestration mode
1305
+ const orchestration = options.orchestration || 'websocket';
1306
+ const effectiveOrchestration = provider === 'saucelabs' ? 'cli' : orchestration;
1307
+ // Add provider-specific config files for CLI orchestration
1308
+ if (provider === 'saucelabs') {
1309
+ result['.sauce/config.yml'] = generateSauceConfig(appUrl, options);
1310
+ }
1311
+ else if (provider === 'browserstack' && effectiveOrchestration === 'cli') {
1312
+ result['browserstack.yml'] = generateBrowserStackConfig(options);
1313
+ }
1314
+ else if (provider === 'lambdatest' && effectiveOrchestration === 'cli') {
1315
+ result['hyperexecute.yaml'] = generateHyperExecuteConfig(options);
1316
+ }
1317
+ // Generate Qate reporter for result reporting
1318
+ result['qate-reporter.ts'] = generateQateReporter(testMapping, {
1319
+ executionId: options.executionId,
1320
+ apiUrl,
1321
+ provider: options.provider,
1322
+ });
1323
+ return result;
1324
+ }
1325
+ /**
1326
+ * Generate a summary of what will be created
1327
+ */
1328
+ function generateSummary(exportData) {
1329
+ const testCount = exportData.tests?.length || 0;
1330
+ const stepCount = exportData.tests?.reduce((sum, t) => sum + (t.steps?.length || 0), 0) || 0;
1331
+ const appName = exportData.application?.name || 'Unknown';
1332
+ const appUrl = exportData.application?.url || 'Not specified';
1333
+ const type = exportData.type === 'testsequence' ? 'Test Sequence' : 'Test Set';
1334
+ const name = exportData.testSequence?.name || exportData.testSet?.name || 'Unknown';
1335
+ return `
1336
+ Generating Playwright tests from ${type}: "${name}"
1337
+ Application: ${appName} (${appUrl})
1338
+ Tests: ${testCount}
1339
+ Total Steps: ${stepCount}
1340
+ `;
1341
+ }
1342
+ //# sourceMappingURL=PlaywrightGenerator.js.map