@qate/cli 1.0.0 → 1.1.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.
@@ -81,7 +81,7 @@ function getActionCode(toolName, input, indent, usePlaceholderResolve) {
81
81
  case 'go_to':
82
82
  return `${indent}await page.goto(${wrapValue(input.url, usePlaceholderResolve)});`;
83
83
  case 'click':
84
- return `${indent}await page.click('${escapeString(input.selector)}');`;
84
+ return `${indent}try {\n${indent} await page.click('${escapeString(input.selector)}');\n${indent}} catch (e) {\n${indent} if (e.message?.includes('intercepts pointer events')) {\n${indent} await page.locator('${escapeString(input.selector)}').evaluate(el => el.click());\n${indent} } else { throw e; }\n${indent}}`;
85
85
  case 'fill':
86
86
  return `${indent}await page.fill('${escapeString(input.selector)}', ${wrapValue(input.value, usePlaceholderResolve)});`;
87
87
  case 'type':
@@ -120,21 +120,21 @@ function getActionCode(toolName, input, indent, usePlaceholderResolve) {
120
120
  function wrapWithTiming(actionCode, stepId, stepIndex, indent, description) {
121
121
  const varPrefix = `step_${stepIndex}`;
122
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();
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
138
  ${indent}qateRecordStepTiming('${stepId}', ${stepIndex}, '${escapedDescription}', 'passed', ${varPrefix}_activityStart, ${varPrefix}_activityEnd, ${varPrefix}_settledEnd);`;
139
139
  }
140
140
  /**
@@ -144,18 +144,18 @@ ${indent}qateRecordStepTiming('${stepId}', ${stepIndex}, '${escapedDescription}'
144
144
  function wrapWithStepTracking(actionCode, stepId, stepIndex, indent, description) {
145
145
  const varPrefix = `step_${stepIndex}`;
146
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;
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
159
  ${indent}}`;
160
160
  }
161
161
  /**
@@ -193,9 +193,9 @@ function stepToPlaywright(step, indent = ' ', usePlaceholderResolve = false,
193
193
  case 'scroll':
194
194
  const direction = input.direction || 'down';
195
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);
196
+ actionCode = `await page.evaluate(({ dir, mult }) => {
197
+ const scrollAmount = window.innerHeight * mult;
198
+ window.scrollBy(0, dir === 'down' ? scrollAmount : -scrollAmount);
199
199
  }, { dir: '${direction}', mult: ${multiplier} });`;
200
200
  break;
201
201
  case 'waitForSelector':
@@ -347,41 +347,41 @@ function generateTestFile(test, baseUrl) {
347
347
  authRoleId: test.authRoleId || null,
348
348
  placeholders: placeholders,
349
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);
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
355
  `;
356
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
- });
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
385
  `;
386
386
  }
387
387
  /**
@@ -446,7 +446,7 @@ function toLambdaTestPlatform(os, osVersion) {
446
446
  * Generate playwright.config.ts
447
447
  */
448
448
  function generateConfig(options, appUrl) {
449
- const { provider = 'local', browsers = ['chromium'], browser = 'chrome', browserVersion = 'latest', os = 'windows', osVersion = '11', orchestration = 'websocket' } = options;
449
+ const { provider = 'local', browser = 'chromium', browserVersion = 'latest', os = 'windows', osVersion = '11', orchestration = 'websocket', device } = options;
450
450
  let projects = '';
451
451
  let connectOptions = '';
452
452
  // Determine effective orchestration mode
@@ -455,120 +455,134 @@ function generateConfig(options, appUrl) {
455
455
  if (provider === 'browserstack' && effectiveOrchestration === 'websocket') {
456
456
  const bsBrowser = toBrowserStackBrowser(browser);
457
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
- },
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
478
  },`;
479
479
  }
480
480
  else if (provider === 'browserstack' && effectiveOrchestration === 'cli') {
481
481
  // BrowserStack CLI orchestration - no connectOptions needed
482
- connectOptions = `
483
- // BrowserStack configuration (CLI orchestration)
484
- // Run with: npx browserstack-node-sdk playwright test
482
+ connectOptions = `
483
+ // BrowserStack configuration (CLI orchestration)
484
+ // Run with: npx browserstack-node-sdk playwright test
485
485
  // Docs: https://www.browserstack.com/docs/automate/playwright/getting-started/nodejs`;
486
486
  }
487
487
  else if (provider === 'saucelabs') {
488
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
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
494
  // Docs: https://docs.saucelabs.com/web-apps/automated-testing/playwright/`;
495
495
  }
496
496
  else if (provider === 'lambdatest' && effectiveOrchestration === 'websocket') {
497
497
  const ltBrowser = toLambdaTestBrowser(browser);
498
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
- },
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
519
  },`;
520
520
  }
521
521
  else if (provider === 'lambdatest' && effectiveOrchestration === 'cli') {
522
522
  // LambdaTest CLI orchestration - no connectOptions needed
523
- connectOptions = `
524
- // LambdaTest HyperExecute configuration (CLI orchestration)
525
- // Run with: ./hyperexecute --config hyperexecute.yaml
523
+ connectOptions = `
524
+ // LambdaTest HyperExecute configuration (CLI orchestration)
525
+ // Run with: ./hyperexecute --config hyperexecute.yaml
526
526
  // Docs: https://www.lambdatest.com/support/docs/hyperexecute-guided-walkthrough/`;
527
527
  }
528
528
  else {
529
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}
530
+ const deviceName = device && device !== 'Desktop' ? device : null;
531
+ let browserProject;
532
+ if (deviceName) {
533
+ browserProject = `
534
+ {
535
+ name: '${browser}',
536
+ use: { ...devices['${deviceName}'] },
537
+ }`;
538
+ }
539
+ else {
540
+ // Map browser name to default Playwright device
541
+ const defaultDevice = browser === 'firefox' ? 'Desktop Firefox' : browser === 'webkit' ? 'Desktop Safari' : 'Desktop Chrome';
542
+ browserProject = `
543
+ {
544
+ name: '${browser}',
545
+ use: { ...devices['${defaultDevice}'] },
546
+ }`;
547
+ }
548
+ projects = `
549
+ projects: [${browserProject}
537
550
  ],`;
538
551
  }
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
- });
552
+ return `/**
553
+ * Playwright Configuration
554
+ * Generated by Qate CLI
555
+ *
556
+ * Provider: ${provider}
557
+ */
558
+
559
+ import { defineConfig, devices } from '@playwright/test';
560
+
561
+ export default defineConfig({
562
+ testDir: './tests',
563
+ fullyParallel: true,
564
+ forbidOnly: !!process.env.CI,
565
+ retries: process.env.CI ? 2 : 0,
566
+ workers: process.env.CI ? 1 : undefined,
567
+ reporter: [
568
+ ['html'],
569
+ ['json', { outputFile: 'test-results.json' }],
570
+ ['junit', { outputFile: 'results/junit.xml' }],
571
+ ['./qate-reporter.ts'],
572
+ ],
573
+
574
+ use: {
575
+ baseURL: '${appUrl}',
576
+ trace: 'on-first-retry',
577
+ screenshot: 'only-on-failure',
578
+ },
579
+ ${connectOptions}${projects}
580
+ // Global timeout
581
+ timeout: 30000,
582
+ expect: {
583
+ timeout: 5000,
584
+ },
585
+ });
572
586
  `;
573
587
  }
574
588
  /**
@@ -606,244 +620,265 @@ function generateQateReporter(testMapping, options) {
606
620
  const executionId = options.executionId || '';
607
621
  const apiUrl = options.apiUrl || 'https://api.qate.ai';
608
622
  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;
623
+ return `/**
624
+ * Qate Reporter
625
+ * Reports test results back to Qate for tracking and analytics
626
+ *
627
+ * Generated by Qate CLI
628
+ */
629
+
630
+ import fs from 'fs';
631
+ import type {
632
+ Reporter,
633
+ FullConfig,
634
+ Suite,
635
+ TestCase,
636
+ TestResult,
637
+ FullResult,
638
+ } from '@playwright/test/reporter';
639
+ import type { StepTiming } from './qate-runtime';
640
+
641
+ // Qate configuration (hardcoded at generation time)
642
+ const EXECUTION_ID = '${executionId}';
643
+ const API_URL = '${apiUrl}';
644
+ const PROVIDER = '${provider}';
645
+
646
+ // Test ID mapping from Qate
647
+ const TEST_MAPPING: { [filename: string]: { testId: string; testName: string } } = ${testMappingJson};
648
+
649
+ interface StepNetworkRequest {
650
+ url: string;
651
+ method: string;
652
+ status?: number;
653
+ contentType?: string;
654
+ duration?: number;
655
+ failure?: string;
656
+ }
657
+
658
+ interface StepResult {
659
+ stepId?: string;
660
+ stepIndex: number;
661
+ description: string;
662
+ status: 'passed' | 'failed' | 'error' | 'skipped';
663
+ duration: number;
664
+ error?: string;
665
+ performanceData?: {
666
+ activityStart: string;
667
+ activityEnd: string;
668
+ activityDuration: number;
669
+ activityDurationFormatted: string;
670
+ settledEnd?: string;
671
+ settledDuration?: number;
672
+ settledDurationFormatted?: string;
673
+ };
674
+ networkRequests?: StepNetworkRequest[];
675
+ screenshot?: string;
676
+ }
677
+
678
+ interface TestResultData {
679
+ testId: string;
680
+ testName: string;
681
+ status: 'passed' | 'failed' | 'error';
682
+ duration: number;
683
+ startedAt: string;
684
+ steps: StepResult[];
685
+ error?: string;
686
+ metadata?: Record<string, unknown>;
687
+ }
688
+
689
+ class QateReporter implements Reporter {
690
+ private results: TestResultData[] = [];
691
+ private startTime: Date = new Date();
692
+ private config: FullConfig | null = null;
693
+
694
+ onBegin(config: FullConfig, suite: Suite) {
695
+ this.config = config;
696
+ this.startTime = new Date();
697
+ console.log('\\n[Qate] Starting test execution...');
698
+ console.log('[Qate] Execution ID:', EXECUTION_ID || 'not set');
699
+ }
700
+
701
+ onTestEnd(test: TestCase, result: TestResult) {
702
+ // Find test mapping by filename
703
+ const filePath = test.location.file;
704
+ const filename = filePath.split('/').pop()?.split('\\\\').pop() || '';
705
+ const mapping = TEST_MAPPING[filename];
706
+
707
+ if (!mapping) {
708
+ console.warn('[Qate] No mapping found for test file:', filename);
709
+ return;
710
+ }
711
+
712
+ // Get step timings from test attachments (cross-process communication from worker)
713
+ let collectedStepTimings: StepTiming[] = [];
714
+ const stepTimingsAttachment = result.attachments.find(a => a.name === 'qate-step-timings');
715
+ if (stepTimingsAttachment?.body) {
716
+ try {
717
+ collectedStepTimings = JSON.parse(stepTimingsAttachment.body.toString());
718
+ } catch (e) {
719
+ console.warn('[Qate] Failed to parse step timings attachment');
720
+ }
721
+ }
722
+
723
+ // Helper to format duration
724
+ const formatDuration = (ms: number): string => {
725
+ if (ms < 1000) return \`\${Math.round(ms)}ms\`;
726
+ if (ms < 60000) return \`\${(ms / 1000).toFixed(2)}s\`;
727
+ const minutes = Math.floor(ms / 60000);
728
+ const seconds = ((ms % 60000) / 1000).toFixed(1);
729
+ return \`\${minutes}m \${seconds}s\`;
730
+ };
731
+
732
+ // Build steps from stepTimings (collected via qateRecordStepTiming calls in generated test code)
733
+ // This is more reliable than result.steps which only contains steps defined via test.step()
734
+ const steps: StepResult[] = collectedStepTimings
735
+ .sort((a, b) => a.stepIndex - b.stepIndex)
736
+ .map((timing) => {
737
+ const stepResult: StepResult = {
738
+ stepId: timing.stepId,
739
+ stepIndex: timing.stepIndex,
740
+ description: timing.description,
741
+ status: timing.status,
742
+ duration: timing.settledDuration,
743
+ error: timing.error,
744
+ };
745
+
746
+ // Add performance data
747
+ stepResult.performanceData = {
748
+ activityStart: new Date(timing.activityStart).toISOString(),
749
+ activityEnd: new Date(timing.activityEnd).toISOString(),
750
+ activityDuration: timing.activityDuration,
751
+ activityDurationFormatted: formatDuration(timing.activityDuration),
752
+ settledEnd: new Date(timing.settledEnd).toISOString(),
753
+ settledDuration: timing.settledDuration,
754
+ settledDurationFormatted: formatDuration(timing.settledDuration),
755
+ };
756
+
757
+ // Add network requests if captured
758
+ if (timing.networkRequests && timing.networkRequests.length > 0) {
759
+ stepResult.networkRequests = timing.networkRequests;
760
+ }
761
+
762
+ return stepResult;
763
+ });
764
+
765
+ // Log step count for debugging
766
+ console.log(\`[Qate] Collected \${steps.length} step results from attachment\`);
767
+
768
+ // Read Playwright screenshot attachment on failure and attach to last failed step
769
+ if (result.status !== 'passed') {
770
+ const screenshotAttachment = result.attachments.find(
771
+ (a: { name: string; contentType: string; path?: string }) =>
772
+ a.name === 'screenshot' && a.contentType === 'image/png' && a.path
773
+ );
774
+ if (screenshotAttachment?.path) {
775
+ try {
776
+ const buffer = fs.readFileSync(screenshotAttachment.path);
777
+ const screenshotBase64 = buffer.toString('base64');
778
+ // Attach to the last failed/error step
779
+ const lastFailedStep = [...steps].reverse().find(s => s.status === 'failed' || s.status === 'error');
780
+ if (lastFailedStep) {
781
+ lastFailedStep.screenshot = screenshotBase64;
782
+ }
783
+ } catch { /* non-fatal */ }
784
+ }
785
+ }
786
+
787
+ const testResult: TestResultData = {
788
+ testId: mapping.testId,
789
+ testName: mapping.testName,
790
+ status: result.status === 'passed' ? 'passed' : result.status === 'timedOut' ? 'error' : 'failed',
791
+ duration: result.duration,
792
+ startedAt: result.startTime.toISOString(),
793
+ steps,
794
+ error: result.error?.message,
795
+ metadata: {
796
+ retries: result.retry,
797
+ workerIndex: result.workerIndex,
798
+ attachments: result.attachments.map(a => ({ name: a.name, contentType: a.contentType })),
799
+ },
800
+ };
801
+
802
+ this.results.push(testResult);
803
+
804
+ const statusIcon = testResult.status === 'passed' ? '✓' : '✗';
805
+ console.log(\`[Qate] \${statusIcon} \${mapping.testName} (\${testResult.duration}ms)\`);
806
+ }
807
+
808
+ async onEnd(result: FullResult) {
809
+ const apiKey = process.env.QATE_API_KEY;
810
+
811
+ if (!EXECUTION_ID) {
812
+ console.log('\\n[Qate] No execution ID configured - skipping result reporting');
813
+ return;
814
+ }
815
+
816
+ if (!apiKey) {
817
+ console.log('\\n[Qate] QATE_API_KEY not set - skipping result reporting');
818
+ return;
819
+ }
820
+
821
+ console.log('\\n[Qate] Reporting results to Qate...');
822
+
823
+ const payload = {
824
+ executionId: EXECUTION_ID,
825
+ status: result.status,
826
+ results: this.results,
827
+ summary: {
828
+ total: this.results.length,
829
+ passed: this.results.filter(r => r.status === 'passed').length,
830
+ failed: this.results.filter(r => r.status === 'failed').length,
831
+ error: this.results.filter(r => r.status === 'error').length,
832
+ status: result.status,
833
+ },
834
+ startedAt: this.startTime.toISOString(),
835
+ completedAt: new Date().toISOString(),
836
+ provider: PROVIDER,
837
+ environment: {
838
+ browser: this.config?.projects?.[0]?.name || 'unknown',
839
+ os: process.platform,
840
+ },
841
+ };
842
+
843
+ try {
844
+ const controller = new AbortController();
845
+ const timeoutId = setTimeout(() => controller.abort(), 30000); // 30s timeout
846
+
847
+ const response = await fetch(\`\${API_URL}/ci/report\`, {
848
+ method: 'POST',
849
+ headers: {
850
+ 'Content-Type': 'application/json',
851
+ 'X-API-Key': apiKey,
852
+ },
853
+ body: JSON.stringify(payload),
854
+ signal: controller.signal,
855
+ });
856
+
857
+ clearTimeout(timeoutId);
858
+
859
+ if (response.ok) {
860
+ const data = await response.json();
861
+ console.log('[Qate] Results reported successfully');
862
+ console.log(\`[Qate] Summary: \${data.summary?.passed || 0} passed, \${data.summary?.failed || 0} failed\`);
863
+ console.log(\`[Qate] View results at: https://app.qate.ai/executions/\${EXECUTION_ID}\`);
864
+ } else {
865
+ const error = await response.text();
866
+ console.error('[Qate] Failed to report results:', response.status, error);
867
+ }
868
+ } catch (error: any) {
869
+ if (error.name === 'AbortError') {
870
+ console.error('[Qate] Request timed out after 30 seconds');
871
+ } else {
872
+ console.error('[Qate] Error reporting results:', error.message || error);
873
+ }
874
+ }
875
+
876
+ // Small delay to ensure HTTP connection is properly closed before Playwright cleanup
877
+ await new Promise(resolve => setTimeout(resolve, 100));
878
+ }
879
+ }
880
+
881
+ export default QateReporter;
847
882
  `;
848
883
  }
849
884
  /**
@@ -851,265 +886,265 @@ export default QateReporter;
851
886
  */
852
887
  function generateQateRuntime(apiUrl, executionId) {
853
888
  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
- }
889
+ return `/**
890
+ * Qate Runtime Helper
891
+ * Resolves placeholders at runtime by calling the Qate API
892
+ *
893
+ * Generated by Qate CLI
894
+ */
895
+
896
+ export interface QateTestContext {
897
+ testId: string;
898
+ testName: string;
899
+ datasetId?: string | null;
900
+ authRoleId?: string | null;
901
+ placeholders: string[];
902
+ }
903
+
904
+ interface PlaceholderResolutionResult {
905
+ success: boolean;
906
+ values: Record<string, string>;
907
+ metadata: {
908
+ totpTimeRemaining?: number | null;
909
+ };
910
+ }
911
+
912
+ // Cache for non-TOTP values (cleared per test)
913
+ let placeholderCache: Record<string, string> = {};
914
+
915
+ /**
916
+ * Resolve placeholders by calling the Qate API
917
+ * Note: TOTP values are always fetched fresh (not cached)
918
+ */
919
+ export async function qateResolve(context: QateTestContext): Promise<Record<string, string>> {
920
+ const apiKey = process.env.QATE_API_KEY;
921
+ const apiUrl = '${apiUrl}';
922
+
923
+ if (!apiKey) {
924
+ console.warn('[Qate Runtime] QATE_API_KEY not set - placeholders will not be resolved');
925
+ return {};
926
+ }
927
+
928
+ if (context.placeholders.length === 0) {
929
+ return {};
930
+ }
931
+
932
+ // Clear cache for new test
933
+ placeholderCache = {};
934
+
935
+ try {
936
+ const response = await fetch(\`\${apiUrl}/ci/resolve-placeholders\`, {
937
+ method: 'POST',
938
+ headers: {
939
+ 'Content-Type': 'application/json',
940
+ 'X-API-Key': apiKey,
941
+ },
942
+ body: JSON.stringify({
943
+ testId: context.testId,
944
+ datasetId: context.datasetId,
945
+ authRoleId: context.authRoleId,
946
+ placeholders: context.placeholders,
947
+ }),
948
+ });
949
+
950
+ if (!response.ok) {
951
+ const error = await response.text();
952
+ console.error('[Qate Runtime] Failed to resolve placeholders:', response.status, error);
953
+ return {};
954
+ }
955
+
956
+ const data: PlaceholderResolutionResult = await response.json();
957
+
958
+ if (!data.success) {
959
+ console.warn('[Qate Runtime] Placeholder resolution returned unsuccessful');
960
+ return {};
961
+ }
962
+
963
+ // Cache non-TOTP values
964
+ for (const [key, value] of Object.entries(data.values)) {
965
+ if (key !== 'mfa_code') {
966
+ placeholderCache[key] = value;
967
+ }
968
+ }
969
+
970
+ // Log TOTP time remaining if present
971
+ if (data.metadata?.totpTimeRemaining != null) {
972
+ console.log(\`[Qate Runtime] TOTP time remaining: \${data.metadata.totpTimeRemaining}s\`);
973
+ }
974
+
975
+ return data.values;
976
+ } catch (error) {
977
+ console.error('[Qate Runtime] Error resolving placeholders:', error);
978
+ return {};
979
+ }
980
+ }
981
+
982
+ /**
983
+ * Replace placeholder patterns in a string with resolved values
984
+ */
985
+ export function replacePlaceholders(text: string, values: Record<string, string>): string {
986
+ return text.replace(/\{\{([^}]+)\}\}/g, (match, name) => {
987
+ if (name in values) {
988
+ return values[name];
989
+ }
990
+ console.warn(\`[Qate Runtime] Unresolved placeholder: \${name}\`);
991
+ return match; // Return original if not resolved
992
+ });
993
+ }
994
+
995
+ // Step timing storage - collected by QateReporter after test completes
996
+ export interface StepTiming {
997
+ stepId: string;
998
+ stepIndex: number;
999
+ description: string;
1000
+ status: 'passed' | 'failed';
1001
+ error?: string;
1002
+ activityStart: number;
1003
+ activityEnd: number;
1004
+ settledEnd: number;
1005
+ activityDuration: number;
1006
+ settledDuration: number;
1007
+ networkRequests?: NetworkRequest[];
1008
+ }
1009
+
1010
+ // Global storage for step timings (cleared per test)
1011
+ export const stepTimings: StepTiming[] = [];
1012
+
1013
+ // Network activity capture (only when tracking is enabled)
1014
+ export interface NetworkRequest {
1015
+ url: string;
1016
+ method: string;
1017
+ status?: number;
1018
+ contentType?: string;
1019
+ duration?: number;
1020
+ failure?: string;
1021
+ }
1022
+
1023
+ // Per-step network capture state
1024
+ let currentStepNetworkRequests: NetworkRequest[] = [];
1025
+ let pendingRequests = new Map<string, { url: string; method: string; requestTime: number }>();
1026
+ let networkCaptureEnabled = false;
1027
+ const MAX_NETWORK_REQUESTS_PER_STEP = 20;
1028
+
1029
+ /**
1030
+ * Set up network capture on a Playwright page
1031
+ * Only captures when QATE_EXECUTION_ID is set (tracking enabled)
1032
+ */
1033
+ export function qateSetupNetworkCapture(page: any): void {
1034
+ // Skip if tracking is disabled
1035
+ if (!${trackingEnabled}) {
1036
+ return;
1037
+ }
1038
+ networkCaptureEnabled = true;
1039
+
1040
+ page.on('request', (request: any) => {
1041
+ if (!networkCaptureEnabled) return;
1042
+ if (currentStepNetworkRequests.length >= MAX_NETWORK_REQUESTS_PER_STEP) return;
1043
+
1044
+ const url = request.url();
1045
+ // Skip data URLs, chrome-extension, and static assets
1046
+ if (url.startsWith('data:') || url.startsWith('chrome-extension:') ||
1047
+ url.match(/\\.(png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot|ico|css)$/i)) {
1048
+ return;
1049
+ }
1050
+
1051
+ pendingRequests.set(url, {
1052
+ url: url.length > 500 ? url.substring(0, 500) + '...' : url,
1053
+ method: request.method(),
1054
+ requestTime: Date.now(),
1055
+ });
1056
+ });
1057
+
1058
+ page.on('response', (response: any) => {
1059
+ if (!networkCaptureEnabled) return;
1060
+ const url = response.url();
1061
+ const pending = pendingRequests.get(url);
1062
+ if (pending && currentStepNetworkRequests.length < MAX_NETWORK_REQUESTS_PER_STEP) {
1063
+ currentStepNetworkRequests.push({
1064
+ url: pending.url,
1065
+ method: pending.method,
1066
+ status: response.status(),
1067
+ contentType: response.headers()['content-type']?.split(';')[0],
1068
+ duration: Date.now() - pending.requestTime,
1069
+ });
1070
+ pendingRequests.delete(url);
1071
+ }
1072
+ });
1073
+
1074
+ page.on('requestfailed', (request: any) => {
1075
+ if (!networkCaptureEnabled) return;
1076
+ const url = request.url();
1077
+ const pending = pendingRequests.get(url);
1078
+ if (pending && currentStepNetworkRequests.length < MAX_NETWORK_REQUESTS_PER_STEP) {
1079
+ currentStepNetworkRequests.push({
1080
+ url: pending.url,
1081
+ method: pending.method,
1082
+ failure: request.failure()?.errorText || 'Request failed',
1083
+ duration: Date.now() - pending.requestTime,
1084
+ });
1085
+ pendingRequests.delete(url);
1086
+ }
1087
+ });
1088
+ }
1089
+
1090
+ /**
1091
+ * Start capturing network for a new step (clears previous step's data)
1092
+ */
1093
+ export function qateStartStepNetworkCapture(): void {
1094
+ currentStepNetworkRequests = [];
1095
+ pendingRequests.clear();
1096
+ }
1097
+
1098
+ /**
1099
+ * Get network requests captured during the current step
1100
+ */
1101
+ export function qateGetStepNetworkRequests(): NetworkRequest[] {
1102
+ return [...currentStepNetworkRequests];
1103
+ }
1104
+
1105
+ /**
1106
+ * Record step timing data for performance measurement
1107
+ * Called by generated test code after each action
1108
+ */
1109
+ export function qateRecordStepTiming(
1110
+ stepId: string,
1111
+ stepIndex: number,
1112
+ description: string,
1113
+ status: 'passed' | 'failed',
1114
+ activityStart: number,
1115
+ activityEnd: number,
1116
+ settledEnd: number,
1117
+ error?: string
1118
+ ): void {
1119
+ const activityDuration = activityEnd - activityStart;
1120
+ const settledDuration = settledEnd - activityStart;
1121
+
1122
+ // Capture network requests for this step (if tracking enabled)
1123
+ const networkReqs = qateGetStepNetworkRequests();
1124
+
1125
+ stepTimings.push({
1126
+ stepId,
1127
+ stepIndex,
1128
+ description,
1129
+ status,
1130
+ error,
1131
+ activityStart,
1132
+ activityEnd,
1133
+ settledEnd,
1134
+ activityDuration,
1135
+ settledDuration,
1136
+ networkRequests: networkReqs.length > 0 ? networkReqs : undefined,
1137
+ });
1138
+ }
1139
+
1140
+ /**
1141
+ * Get and clear step timings (called by reporter)
1142
+ */
1143
+ export function qateGetAndClearStepTimings(): StepTiming[] {
1144
+ const timings = [...stepTimings];
1145
+ stepTimings.length = 0;
1146
+ return timings;
1147
+ }
1113
1148
  `;
1114
1149
  }
1115
1150
  /**
@@ -1149,37 +1184,37 @@ function generateSauceConfig(appUrl, options) {
1149
1184
  const slBrowser = toSauceLabsBrowser(browser);
1150
1185
  const slPlatform = toSauceLabsPlatform(os, osVersion);
1151
1186
  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
1187
+ return `# Sauce Labs Configuration
1188
+ # Docs: https://docs.saucelabs.com/web-apps/automated-testing/playwright/yaml/
1189
+ apiVersion: v1alpha
1190
+ kind: playwright
1191
+ defaults:
1192
+ timeout: 30m
1193
+ sauce:
1194
+ region: us-west-1
1195
+ concurrency: 5
1196
+ metadata:
1197
+ tags:
1198
+ - qate
1199
+ - e2e
1200
+ build: "\${BUILD_NUMBER:-local}"
1201
+ playwright:
1202
+ version: package.json
1203
+ rootDir: ./
1204
+ suites:
1205
+ - name: "Qate E2E Tests - ${browserDisplayName}"
1206
+ platformName: "${slPlatform}"
1207
+ testMatch: ["tests/*.spec.ts"]
1208
+ params:
1209
+ browserName: "${slBrowser}"
1210
+ baseURL: "${appUrl}"
1211
+ artifacts:
1212
+ download:
1213
+ when: always
1214
+ match:
1215
+ - "playwright-report/*"
1216
+ - "test-results/*"
1217
+ directory: ./artifacts
1183
1218
  `;
1184
1219
  }
1185
1220
  /**
@@ -1189,31 +1224,31 @@ function generateBrowserStackConfig(options) {
1189
1224
  const { browser = 'chrome', browserVersion = 'latest', os = 'windows', osVersion = '11' } = options;
1190
1225
  const bsBrowser = toBrowserStackBrowser(browser);
1191
1226
  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
1227
+ return `# BrowserStack Configuration
1228
+ # Docs: https://www.browserstack.com/docs/automate/playwright/getting-started/nodejs
1229
+ #
1230
+ # Run with: npx browserstack-node-sdk playwright test
1231
+
1232
+ userName: \${BROWSERSTACK_USERNAME}
1233
+ accessKey: \${BROWSERSTACK_ACCESS_KEY}
1234
+
1235
+ platforms:
1236
+ - os: ${bsOS}
1237
+ osVersion: "${osVersion}"
1238
+ browserName: ${bsBrowser}
1239
+ browserVersion: ${browserVersion}
1240
+
1241
+ parallelsPerPlatform: 5
1242
+
1243
+ browserstackLocal: false
1244
+
1245
+ projectName: "Qate Tests"
1246
+ buildName: "\${BUILD_NUMBER:-local}"
1247
+ buildIdentifier: "#\${BUILD_NUMBER:-1}"
1248
+
1249
+ debug: true
1250
+ networkLogs: true
1251
+ consoleLogs: verbose
1217
1252
  `;
1218
1253
  }
1219
1254
  /**
@@ -1223,48 +1258,48 @@ function generateHyperExecuteConfig(options) {
1223
1258
  const { browser = 'chrome', os = 'windows', osVersion = '11' } = options;
1224
1259
  const ltBrowser = toLambdaTestBrowser(browser);
1225
1260
  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}"
1261
+ return `# LambdaTest HyperExecute Configuration
1262
+ # Docs: https://www.lambdatest.com/support/docs/hyperexecute-guided-walkthrough/
1263
+ #
1264
+ # Run with: ./hyperexecute --config hyperexecute.yaml
1265
+ # Download CLI: https://www.lambdatest.com/support/docs/hyperexecute-cli-run-tests-on-hyperexecute-grid/
1266
+
1267
+ version: "0.1"
1268
+ globalTimeout: 90
1269
+ testSuiteTimeout: 90
1270
+ testSuiteStep: 90
1271
+
1272
+ runson: ${runson}
1273
+
1274
+ autosplit: true
1275
+ retryOnFailure: false
1276
+ maxRetries: 1
1277
+ concurrency: 5
1278
+
1279
+ env:
1280
+ PLAYWRIGHT_BROWSERS_PATH: 0
1281
+
1282
+ pre:
1283
+ - npm install
1284
+ - npx playwright install ${ltBrowser === 'Chrome' ? 'chromium' : ltBrowser === 'pw-firefox' ? 'firefox' : 'webkit'}
1285
+
1286
+ testDiscovery:
1287
+ type: raw
1288
+ mode: dynamic
1289
+ command: grep -rn "test\\|it(" tests/*.spec.ts | cut -d':' -f1 | uniq
1290
+
1291
+ testRunnerCommand: npx playwright test $test --reporter=html
1292
+
1293
+ jobLabel:
1294
+ - qate
1295
+ - playwright
1296
+ - ${browser}
1297
+
1298
+ framework:
1299
+ name: playwright
1300
+ args:
1301
+ browserName: ${ltBrowser}
1302
+ platform: "${os === 'macos' || os === 'mac' ? 'MacOS' : 'Windows'} ${osVersion}"
1268
1303
  `;
1269
1304
  }
1270
1305
  /**
@@ -1330,13 +1365,16 @@ function generateSummary(exportData) {
1330
1365
  const stepCount = exportData.tests?.reduce((sum, t) => sum + (t.steps?.length || 0), 0) || 0;
1331
1366
  const appName = exportData.application?.name || 'Unknown';
1332
1367
  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}
1368
+ const type = exportData.type === 'testsequence' ? 'Test Sequence'
1369
+ : exportData.type === 'smart' ? 'Smart Generate'
1370
+ : 'Test Set';
1371
+ const name = exportData.testSequence?.name || exportData.testSet?.name
1372
+ || (exportData.smartGenerate ? `PR #${exportData.smartGenerate.prNumber}` : 'Unknown');
1373
+ return `
1374
+ Generating Playwright tests from ${type}: "${name}"
1375
+ Application: ${appName} (${appUrl})
1376
+ Tests: ${testCount}
1377
+ Total Steps: ${stepCount}
1340
1378
  `;
1341
1379
  }
1342
1380
  //# sourceMappingURL=PlaywrightGenerator.js.map