@appliqation/automation-sdk 2.1.11 → 2.3.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.
Files changed (38) hide show
  1. package/LICENSE +0 -0
  2. package/README.md +509 -7
  3. package/package.json +2 -2
  4. package/src/AppliqationClient.js +47 -2
  5. package/src/constants.js +0 -0
  6. package/src/core/AuthManager.js +0 -0
  7. package/src/core/HttpClient.js +52 -0
  8. package/src/index.d.ts +0 -0
  9. package/src/index.js +0 -0
  10. package/src/playwright/JwtBrowserAuth.js +0 -0
  11. package/src/playwright/fixture.js +0 -0
  12. package/src/playwright/global-setup.js +43 -0
  13. package/src/playwright/global-teardown.js +0 -0
  14. package/src/playwright/helpers/jwt-browser-auth.js +0 -0
  15. package/src/playwright/index.js +0 -0
  16. package/src/reporters/cypress/CypressReporter.js +49 -2
  17. package/src/reporters/cypress/UuidExtractor.js +0 -0
  18. package/src/reporters/cypress/index.js +0 -0
  19. package/src/reporters/jest/JestReporter.js +49 -2
  20. package/src/reporters/jest/UuidExtractor.js +0 -0
  21. package/src/reporters/jest/index.js +0 -0
  22. package/src/reporters/playwright/AppliqationReporter.js +734 -26
  23. package/src/reporters/playwright/helpers/DeviceOsDetector.js +0 -0
  24. package/src/reporters/playwright/helpers/UuidExtractor.js +8 -3
  25. package/src/reporters/playwright/index.d.ts +0 -0
  26. package/src/reporters/playwright/index.js +0 -0
  27. package/src/services/OrphanTestService.js +0 -0
  28. package/src/services/ResultService.js +193 -24
  29. package/src/services/RunMatrixService.js +44 -0
  30. package/src/services/TaggingService.js +241 -0
  31. package/src/utils/PayloadBuilder.js +0 -0
  32. package/src/utils/RunDataNormalizer.js +0 -0
  33. package/src/utils/UuidValidator.js +0 -0
  34. package/src/utils/errors.js +0 -0
  35. package/src/utils/index.js +0 -0
  36. package/src/utils/logger.js +0 -0
  37. package/src/utils/mapAppqUuid.js +0 -0
  38. package/src/utils/validator.js +0 -0
@@ -1,3 +1,4 @@
1
+ const path = require('path');
1
2
  const AppliqationClient = require('../../AppliqationClient');
2
3
  const UuidExtractor = require('./helpers/UuidExtractor');
3
4
  const DeviceOsDetector = require('./helpers/DeviceOsDetector');
@@ -5,6 +6,51 @@ const PayloadBuilder = require('../../utils/PayloadBuilder');
5
6
  const logger = require('../../utils/logger');
6
7
  const { DEFAULT_APPLIQATION_BASE_URL } = require('../../constants');
7
8
 
9
+ /**
10
+ * Check if Appliqation reporting should be enabled
11
+ * Checks both environment variable and CLI flag
12
+ * @returns {boolean} True if reporting should be enabled
13
+ */
14
+ function isAppqEnabled() {
15
+ // Check environment variable first (easier to use)
16
+ const envEnabled = process.env.APPQ_ENABLE === '1' ||
17
+ process.env.APPQ_ENABLE === 'true' ||
18
+ process.env.APPLIQATION_ENABLE === '1' ||
19
+ process.env.APPLIQATION_ENABLE === 'true';
20
+
21
+ if (envEnabled) {
22
+ return true;
23
+ }
24
+
25
+ // Check CLI flag (requires -- separator: npx playwright test -- --appq)
26
+ const argv = process.argv || [];
27
+ return argv.includes('--appq');
28
+ }
29
+
30
+ /**
31
+ * Extract run title from CLI arguments
32
+ * Supports: --appq_run_title="My Title" or --appq_run_title=MyTitle
33
+ * @returns {string|null} Run title from CLI args, or null if not found
34
+ */
35
+ function getRunTitleFromCli() {
36
+ const argv = process.argv || [];
37
+
38
+ // Look for --appq_run_title=value or --appq_run_title="value"
39
+ for (const arg of argv) {
40
+ if (arg.startsWith('--appq_run_title=')) {
41
+ // Extract value after the = sign
42
+ let value = arg.substring('--appq_run_title='.length);
43
+
44
+ // Remove surrounding quotes if present
45
+ value = value.replace(/^["']|["']$/g, '');
46
+
47
+ return value || null;
48
+ }
49
+ }
50
+
51
+ return null;
52
+ }
53
+
8
54
  /**
9
55
  * Appliqation Reporter for Playwright
10
56
  * Automatically reports test results to Appliqation platform
@@ -49,6 +95,8 @@ class AppliqationReporter {
49
95
  batchSubmit: true,
50
96
  batchSize: 50,
51
97
  logLevel: 'info',
98
+ deleteOrphanOnlyRuns: config.deleteOrphanOnlyRuns !== false,
99
+ failOnOrphanOnlyRuns: config.failOnOrphanOnlyRuns !== false,
52
100
  ...config
53
101
  };
54
102
 
@@ -66,15 +114,26 @@ class AppliqationReporter {
66
114
  // Note: No default fallback - validation will catch missing value
67
115
 
68
116
  // Apply title fallback if not provided
69
- // Priority: 1) config.title, 2) process.env.APPLIQATION_RUN_TITLE, 3) timestamp-based default
117
+ // Priority: 1) config.title, 2) CLI --appq_run_title, 3) process.env.APPLIQATION_RUN_TITLE, 4) timestamp-based default
70
118
  if (!this.config.title) {
71
- this.config.title = process.env.APPLIQATION_RUN_TITLE || `Automation Run - ${new Date().toISOString()}`;
119
+ const cliTitle = getRunTitleFromCli();
120
+ this.config.title = cliTitle || process.env.APPLIQATION_RUN_TITLE || `Automation Run - ${new Date().toISOString()}`;
72
121
  }
73
122
 
74
- // Validate configuration
75
- this.validateConfig();
123
+ // Check if --appq flag is present (opt-in approach)
124
+ this.appqEnabled = isAppqEnabled();
125
+
126
+ if (this.appqEnabled) {
127
+ // Validate configuration only if appq is enabled
128
+ this.validateConfig();
129
+ logger.info('Appliqation reporting ENABLED');
130
+ console.log('✅ Appliqation reporting enabled: Results will be sent to Appliqation portal');
131
+ } else {
132
+ logger.info('Appliqation reporting DISABLED');
133
+ console.log('ℹ️ Appliqation reporting disabled: Set APPQ_ENABLE=1 or add -- --appq flag to enable');
134
+ }
76
135
 
77
- // Initialize SDK client
136
+ // Initialize SDK client (needed for UUID validation even if disabled)
78
137
  this.client = new AppliqationClient({
79
138
  baseUrl: this.config.baseUrl,
80
139
  apiKey: this.config.apiKey,
@@ -90,12 +149,27 @@ class AppliqationReporter {
90
149
  this.runsByProject = new Map(); // Map<projectKey, runInfo>
91
150
  this.resultsByRun = new Map(); // Map<runId, results[]>
92
151
  this.orphansByRun = new Map(); // Map<runId, orphans[]>
152
+ this.submittedUuidsByRun = new Map(); // Map<runId, Map<uuid, {file, title, browser}>>
153
+ this.submittedUuidsGlobal = new Map(); // Map<uuid, {file, title, browser, runId}>
93
154
  this.browserVersionDetected = new Map(); // Map<projectKey, boolean> - Track if version detected
94
155
  this.totalTests = 0;
95
156
  this.passedTests = 0;
96
157
  this.failedTests = 0;
97
158
  this.skippedTests = 0;
98
159
  this.orphanTests = 0;
160
+ this.validTests = 0; // Track tests with valid UUIDs
161
+ this.duplicateTests = 0; // Track duplicate UUID submissions
162
+ this.duplicateDetails = []; // Store details of each duplicate for summary
163
+ this.backendRejectedTests = 0; // Track tests rejected by backend validation (e.g., project mismatch)
164
+ this.rejectionsByRun = new Map(); // Track backend rejections per run: runId -> rejectedCount
165
+ this.deletedOrphanRuns = []; // Track deleted orphan-only runs
166
+ this.uniqueOrphans = new Map(); // Track unique orphan tests: "file:title" -> {file, title, browsers[]}
167
+ this.uniqueDuplicates = new Map(); // Track unique duplicate UUIDs: "uuid" -> {uuid, occurrences: [{file, title, browser}]}
168
+
169
+ // Execution tracking for summary file generation
170
+ this.executionStartTime = null;
171
+ this.executionEndTime = null;
172
+ this.playwrightOutputDir = null;
99
173
 
100
174
  // Show baseUrl only in DEBUG mode
101
175
  logger.info('Appliqation Playwright Reporter initialized', {
@@ -113,8 +187,23 @@ class AppliqationReporter {
113
187
  * @param {Object} suite - Root test suite
114
188
  */
115
189
  async onBegin(config, suite) {
190
+ // Capture execution start time for summary file
191
+ this.executionStartTime = new Date();
192
+
193
+ // Capture Playwright output directory
194
+ // Priority: 1) config._internal.outputDir (actual test-results path)
195
+ // 2) config.rootDir (project root)
196
+ // 3) process.cwd() (current working directory)
197
+ this.playwrightOutputDir = config._internal?.outputDir || config.rootDir || process.cwd();
198
+
116
199
  logger.info('Test run starting...');
117
200
 
201
+ // Skip run creation if --appq flag not present
202
+ if (!this.appqEnabled) {
203
+ logger.info('Appliqation reporting disabled. Skipping run matrix creation.');
204
+ return;
205
+ }
206
+
118
207
  if (!this.config.autoCreateRun) {
119
208
  logger.info('Auto-create run disabled. Skipping run matrix creation.');
120
209
  return;
@@ -309,7 +398,7 @@ class AppliqationReporter {
309
398
  * @param {Object} result - Test result
310
399
  */
311
400
  async onTestEnd(test, result) {
312
- this.totalTests++;
401
+ // Note: Don't increment totalTests here - it's incremented later for non-duplicates/non-orphans only
313
402
 
314
403
  try {
315
404
  // Extract UUID from test - check result.annotations first since test.info().annotations.push() adds to result
@@ -345,7 +434,24 @@ class AppliqationReporter {
345
434
 
346
435
  if (!uuid) {
347
436
  // Orphan test - no UUID found
348
- this.orphanTests++;
437
+ const orphanKey = `${test.location?.file || 'unknown'}:${test.title}`;
438
+
439
+ if (!this.uniqueOrphans.has(orphanKey)) {
440
+ // First occurrence of this unique orphan
441
+ this.orphanTests++;
442
+ this.uniqueOrphans.set(orphanKey, {
443
+ file: path.basename(test.location?.file || 'unknown'),
444
+ title: test.title,
445
+ browsers: [deviceInfo.browser]
446
+ });
447
+ } else {
448
+ // Same orphan on different browser - just add browser to list
449
+ const orphan = this.uniqueOrphans.get(orphanKey);
450
+ if (!orphan.browsers.includes(deviceInfo.browser)) {
451
+ orphan.browsers.push(deviceInfo.browser);
452
+ }
453
+ }
454
+
349
455
  this.trackOrphan(runInfo.runId, test, result, deviceInfo.browser);
350
456
 
351
457
  logger.warn('Test without UUID', {
@@ -357,6 +463,72 @@ class AppliqationReporter {
357
463
  return;
358
464
  }
359
465
 
466
+ // Duplicate detection (per-run AND per-browser - allows same UUID across different browsers/devices)
467
+ const trackingKey = `${runInfo.runId}:${deviceInfo.browser}`;
468
+ const submittedUuids = this.submittedUuidsByRun.get(trackingKey) || new Map();
469
+
470
+ // Check per-run+browser map (allows same UUID for different browsers in same run)
471
+ if (submittedUuids.has(uuid)) {
472
+ const firstOccurrence = submittedUuids.get(uuid);
473
+ const firstFile = path.basename(firstOccurrence.file);
474
+ const currentFile = path.basename(test.location?.file || 'unknown');
475
+
476
+ // Track unique duplicates
477
+ if (!this.uniqueDuplicates.has(uuid)) {
478
+ // First time seeing this duplicate UUID
479
+ this.duplicateTests++;
480
+ this.uniqueDuplicates.set(uuid, {
481
+ uuid,
482
+ occurrences: [
483
+ {
484
+ file: firstFile,
485
+ title: firstOccurrence.title,
486
+ browser: firstOccurrence.browser
487
+ },
488
+ {
489
+ file: currentFile,
490
+ title: test.title,
491
+ browser: deviceInfo.browser
492
+ }
493
+ ]
494
+ });
495
+ } else {
496
+ // Already tracking this duplicate - add this occurrence
497
+ const duplicate = this.uniqueDuplicates.get(uuid);
498
+ duplicate.occurrences.push({
499
+ file: currentFile,
500
+ title: test.title,
501
+ browser: deviceInfo.browser
502
+ });
503
+ }
504
+
505
+ // Keep old duplicateDetails for backward compatibility (if needed)
506
+ this.duplicateDetails.push({
507
+ uuid,
508
+ firstFile,
509
+ firstTitle: firstOccurrence.title,
510
+ duplicateFile: currentFile,
511
+ duplicateTitle: test.title
512
+ });
513
+
514
+ logger.warn(`Duplicate UUID detected: ${uuid}`);
515
+ logger.warn(` First: ${firstFile} -> "${firstOccurrence.title}"`);
516
+ logger.warn(` Dup: ${currentFile} -> "${test.title}"`);
517
+ return; // Skip duplicate submission
518
+ }
519
+
520
+ // Store UUID occurrence (per-run only)
521
+ submittedUuids.set(uuid, {
522
+ file: test.location?.file || 'unknown',
523
+ title: test.title,
524
+ browser: deviceInfo.browser
525
+ });
526
+ this.submittedUuidsByRun.set(trackingKey, submittedUuids);
527
+
528
+ // Count only tests that will be submitted (non-duplicate, non-orphan)
529
+ this.totalTests++;
530
+ this.validTests++; // Track tests with valid UUIDs
531
+
360
532
  // Create result object
361
533
  const testResult = {
362
534
  runId: runInfo.runId, // Add runId for batch submission
@@ -394,23 +566,271 @@ class AppliqationReporter {
394
566
  }
395
567
  }
396
568
 
569
+ /**
570
+ * Handle runs that have only orphan tests (no valid UUIDs).
571
+ * Deletes empty runs and shows clear error messages.
572
+ *
573
+ * @private
574
+ * @returns {Promise<Array>} Array of deleted runs
575
+ */
576
+ async handleOrphanOnlyRuns() {
577
+ if (!this.config.deleteOrphanOnlyRuns) {
578
+ return []; // Feature disabled
579
+ }
580
+
581
+ const deletedRuns = [];
582
+
583
+ for (const [projectKey, runInfo] of this.runsByProject.entries()) {
584
+ const orphanCount = this.orphansByRun.get(runInfo.runId)?.length || 0;
585
+ const validCount = this.resultsByRun.get(runInfo.runId)?.length || 0;
586
+
587
+ // CRITICAL CHECK: Delete ONLY if all tests are orphans
588
+ if (orphanCount > 0 && validCount === 0) {
589
+ // Show clear error message FIRST (before attempting deletion)
590
+ // This ensures users always see the message, even if deletion fails
591
+ this.printOrphanOnlyError(orphanCount, projectKey);
592
+
593
+ try {
594
+ logger.warn('Deleting orphan-only run', {
595
+ runId: runInfo.runId,
596
+ orphanCount,
597
+ projectKey
598
+ });
599
+
600
+ await this.client.deleteRun(runInfo.runId, 'all_tests_orphaned');
601
+ deletedRuns.push({ runId: runInfo.runId, orphanCount, projectKey });
602
+ } catch (error) {
603
+ logger.error('Failed to delete orphan-only run (may already be deleted)', {
604
+ error: error.message,
605
+ runId: runInfo.runId
606
+ });
607
+ // Still track as deleted since user saw the error message
608
+ deletedRuns.push({ runId: runInfo.runId, orphanCount, projectKey });
609
+ }
610
+ }
611
+ }
612
+
613
+ // Store for summary
614
+ this.deletedOrphanRuns = deletedRuns;
615
+ return deletedRuns;
616
+ }
617
+
618
+ /**
619
+ * Print error message for orphan-only runs.
620
+ *
621
+ * @param {number} orphanCount - Number of orphan tests
622
+ * @param {string} projectKey - Project key
623
+ * @private
624
+ */
625
+ printOrphanOnlyError(orphanCount, projectKey) {
626
+ console.error('\n╔════════════════════════════════════════════════════════════════════╗');
627
+ console.error('║ ❌ RUN CREATION FAILED - ALL TESTS MISSING UUID ANNOTATIONS ║');
628
+ console.error('╠════════════════════════════════════════════════════════════════════╣');
629
+ console.error(`║ Project: ${projectKey.padEnd(56)} ║`);
630
+ console.error(`║ Orphan Tests: ${orphanCount.toString().padEnd(51)} ║`);
631
+ console.error('║ ║');
632
+ console.error('║ ⚠️ NO RESULTS WERE SUBMITTED TO APPLIQATION ║');
633
+ console.error('║ The test run was automatically deleted to prevent analytics ║');
634
+ console.error('║ corruption. All tests are missing UUID annotations. ║');
635
+ console.error('╠════════════════════════════════════════════════════════════════════╣');
636
+ console.error('║ ✅ ACTION REQUIRED: Add UUID Annotations ║');
637
+ console.error('╠════════════════════════════════════════════════════════════════════╣');
638
+ console.error('║ ║');
639
+ console.error('║ Option 1: Using test tags (Recommended) ║');
640
+ console.error('║ ───────────────────────────────────────────────────────────── ║');
641
+ console.error('║ test(\'My Test\', { tag: \'@uuid:123-xxx-xxx\' }, async ({ page }) => {║');
642
+ console.error('║ // your test code ║');
643
+ console.error('║ }); ║');
644
+ console.error('║ ║');
645
+ console.error('║ Option 2: Using test annotations ║');
646
+ console.error('║ ───────────────────────────────────────────────────────────── ║');
647
+ console.error('║ test(\'My Test\', async ({ page }, testInfo) => { ║');
648
+ console.error('║ testInfo.annotations.push({ ║');
649
+ console.error('║ type: \'uuid\', ║');
650
+ console.error('║ description: \'123-xxx-xxx\' ║');
651
+ console.error('║ }); ║');
652
+ console.error('║ }); ║');
653
+ console.error('║ ║');
654
+ console.error('║ Option 3: Using mapAppqUuid helper ║');
655
+ console.error('║ ───────────────────────────────────────────────────────────── ║');
656
+ console.error('║ const { mapAppqUuid } = require(\'@appliqation/automation-sdk/utils\');║');
657
+ console.error('║ test(\'My Test\', async ({ page }, testInfo) => { ║');
658
+ console.error('║ mapAppqUuid(testInfo, \'123-xxx-xxx\'); ║');
659
+ console.error('║ }); ║');
660
+ console.error('╠════════════════════════════════════════════════════════════════════╣');
661
+ console.error('║ 📚 WHERE TO FIND UUIDs ║');
662
+ console.error('╠════════════════════════════════════════════════════════════════════╣');
663
+ console.error('║ ║');
664
+ console.error('║ 1. Open your test scenario in Appliqation UI ║');
665
+ console.error('║ 2. Find the test case you want to automate ║');
666
+ console.error('║ 3. Copy the UUID from the test case details ║');
667
+ console.error('║ Format: {scenario-id}-{uuid} (e.g., 1154-7a17b809-0ff9...) ║');
668
+ console.error('║ ║');
669
+ console.error('║ 💡 Tip: Export UUIDs in bulk from Appliqation for faster setup ║');
670
+ console.error('╚════════════════════════════════════════════════════════════════════╝\n');
671
+ }
672
+
673
+ /**
674
+ * Handle runs where ALL tests were rejected by backend validation
675
+ * This happens when tests have UUIDs but they're invalid (wrong project, format, duplicates)
676
+ * Called AFTER submission when we know backend rejection counts
677
+ * @private
678
+ * @returns {Array} Array of deleted runs
679
+ */
680
+ async handleAllRejectedRuns() {
681
+ if (!this.config.deleteOrphanOnlyRuns) {
682
+ return []; // Feature disabled
683
+ }
684
+
685
+ const deletedRuns = [];
686
+
687
+ for (const [projectKey, runInfo] of this.runsByProject.entries()) {
688
+ const submittedCount = this.resultsByRun.get(runInfo.runId)?.length || 0;
689
+ const orphanCount = this.orphansByRun.get(runInfo.runId)?.length || 0;
690
+ const rejectedCount = this.rejectionsByRun.get(runInfo.runId) || 0;
691
+
692
+ // Calculate how many were actually accepted by backend
693
+ const acceptedCount = submittedCount - rejectedCount;
694
+
695
+ // CRITICAL CHECK: Delete ONLY if we submitted tests but ALL were rejected
696
+ // AND there are no orphans (orphans are handled separately)
697
+ if (submittedCount > 0 && acceptedCount === 0 && orphanCount === 0) {
698
+ // Show user-friendly error message FIRST (before deletion attempt)
699
+ this.printAllRejectedError(submittedCount, rejectedCount, projectKey);
700
+
701
+ try {
702
+ logger.warn('Deleting all-rejected run', {
703
+ runId: runInfo.runId,
704
+ submittedCount,
705
+ rejectedCount,
706
+ acceptedCount,
707
+ projectKey
708
+ });
709
+ await this.client.deleteRun(runInfo.runId, 'all_tests_rejected');
710
+ deletedRuns.push({
711
+ runId: runInfo.runId,
712
+ submittedCount,
713
+ rejectedCount,
714
+ projectKey
715
+ });
716
+ } catch (error) {
717
+ logger.error('Failed to delete all-rejected run (may already be deleted)', {
718
+ error: error.message,
719
+ runId: runInfo.runId
720
+ });
721
+ // Non-fatal: Continue even if deletion fails
722
+ // Still track as deleted since end result is the same
723
+ deletedRuns.push({
724
+ runId: runInfo.runId,
725
+ submittedCount,
726
+ rejectedCount,
727
+ projectKey
728
+ });
729
+ }
730
+ }
731
+ }
732
+
733
+ // Track deleted rejected runs
734
+ if (deletedRuns.length > 0) {
735
+ this.deletedOrphanRuns = [...(this.deletedOrphanRuns || []), ...deletedRuns];
736
+ }
737
+
738
+ return deletedRuns;
739
+ }
740
+
741
+ /**
742
+ * Print user-friendly error message for all-rejected runs
743
+ * @private
744
+ */
745
+ printAllRejectedError(submittedCount, rejectedCount, projectKey) {
746
+ console.error('\n╔════════════════════════════════════════════════════════════════════╗');
747
+ console.error('║ ❌ RUN CREATION FAILED - ALL TESTS REJECTED BY BACKEND ║');
748
+ console.error('╠════════════════════════════════════════════════════════════════════╣');
749
+ console.error(`║ Project: ${projectKey.padEnd(56)} ║`);
750
+ console.error(`║ Tests Submitted: ${submittedCount.toString().padEnd(47)} ║`);
751
+ console.error(`║ Tests Rejected: ${rejectedCount.toString().padEnd(47)} ║`);
752
+ console.error('║ ║');
753
+ console.error('║ ⚠️ NO RESULTS WERE ACCEPTED BY APPLIQATION ║');
754
+ console.error('║ The test run was automatically deleted to prevent analytics ║');
755
+ console.error('║ corruption. All submitted tests were rejected by the backend. ║');
756
+ console.error('╠════════════════════════════════════════════════════════════════════╣');
757
+ console.error('║ ✅ COMMON REJECTION REASONS ║');
758
+ console.error('╠════════════════════════════════════════════════════════════════════╣');
759
+ console.error('║ ║');
760
+ console.error('║ 1. INVALID UUID FORMAT ║');
761
+ console.error('║ ───────────────────────────────────────────────────────────── ║');
762
+ console.error('║ ❌ Wrong: \'1154-uuid-here-99\' (extra suffix) ║');
763
+ console.error('║ ✅ Right: \'1154-7f991165-9f59-47e1-8e90-d57d2e9cbf66\' ║');
764
+ console.error('║ ║');
765
+ console.error('║ 2. WRONG PROJECT ║');
766
+ console.error('║ ───────────────────────────────────────────────────────────── ║');
767
+ console.error('║ Using UUID from project 1162 in project 1154 tests ║');
768
+ console.error('║ ✅ Solution: Use UUIDs from the correct project ║');
769
+ console.error('║ ║');
770
+ console.error('║ 3. DUPLICATE UUIDs ║');
771
+ console.error('║ ───────────────────────────────────────────────────────────── ║');
772
+ console.error('║ Multiple tests using the same UUID in one run ║');
773
+ console.error('║ ✅ Solution: Each test needs a unique UUID ║');
774
+ console.error('║ ║');
775
+ console.error('║ 4. TEST CASE DOESN\'T EXIST ║');
776
+ console.error('║ ───────────────────────────────────────────────────────────── ║');
777
+ console.error('║ UUID doesn\'t match any test case in your project ║');
778
+ console.error('║ ✅ Solution: Verify UUID exists in Appliqation Test Cases ║');
779
+ console.error('╠════════════════════════════════════════════════════════════════════╣');
780
+ console.error('║ 📋 HOW TO FIX ║');
781
+ console.error('╠════════════════════════════════════════════════════════════════════╣');
782
+ console.error('║ ║');
783
+ console.error('║ 1. Check test logs above for specific rejection reasons ║');
784
+ console.error('║ 2. Verify UUIDs in Appliqation portal → Test Cases ║');
785
+ console.error('║ 3. Ensure UUID format: {nid}-{uuid} (no extra suffixes) ║');
786
+ console.error('║ 4. Confirm UUIDs belong to the correct project ║');
787
+ console.error('║ 5. Remove any duplicate UUID mappings ║');
788
+ console.error('║ ║');
789
+ console.error('╚════════════════════════════════════════════════════════════════════╝\n');
790
+ }
791
+
397
792
  /**
398
793
  * Called once after all tests complete
399
794
  */
400
795
  async onEnd(result) {
401
- logger.info('Test run complete. Submitting results...');
796
+ logger.info('Test run complete.');
402
797
 
403
798
  try {
404
- // Submit batched results
405
- if (this.config.batchSubmit) {
406
- await this.submitBatchedResults();
407
- }
799
+ // Handle orphan-only runs BEFORE submitting results
800
+ const orphanDeletedRuns = await this.handleOrphanOnlyRuns();
801
+
802
+ // Skip submission if --appq flag not present
803
+ if (this.appqEnabled) {
804
+ logger.info('Submitting results to Appliqation...');
805
+
806
+ // Submit batched results
807
+ if (this.config.batchSubmit) {
808
+ await this.submitBatchedResults();
809
+ }
408
810
 
409
- // Submit orphan tests
410
- await this.submitOrphanTests();
811
+ // Submit orphan tests
812
+ await this.submitOrphanTests();
813
+
814
+ // Handle all-rejected runs AFTER submission (when we know backend rejection counts)
815
+ const rejectedDeletedRuns = await this.handleAllRejectedRuns();
816
+
817
+ // Combine all deleted runs
818
+ const allDeletedRuns = [...(orphanDeletedRuns || []), ...(rejectedDeletedRuns || [])];
819
+
820
+ // Exit with error code if any runs were deleted
821
+ if (allDeletedRuns.length > 0 && this.config.failOnOrphanOnlyRuns) {
822
+ logger.error('Exiting with error code due to deleted runs (orphan or rejected)');
823
+ process.exitCode = 1; // Set error exit code for CI/CD
824
+ }
825
+ } else {
826
+ logger.info('Appliqation reporting disabled. Skipping result submission.');
827
+ }
411
828
 
412
- // Print summary
829
+ // Print summary (always show, even if not submitted)
413
830
  this.printSummary();
831
+
832
+ // Write summary to file (always write, even if not submitted)
833
+ await this.writeSummaryToFile();
414
834
  } catch (error) {
415
835
  logger.error('Error in onEnd', {
416
836
  error: error.message
@@ -453,6 +873,11 @@ class AppliqationReporter {
453
873
  }
454
874
  });
455
875
 
876
+ // Track backend validation rejections (global and per-run)
877
+ const rejectedCount = summary.failed || 0;
878
+ this.backendRejectedTests += rejectedCount;
879
+ this.rejectionsByRun.set(runId, rejectedCount);
880
+
456
881
  logger.info(`Results submitted for run ${runId}`, {
457
882
  success: summary.success,
458
883
  failed: summary.failed,
@@ -558,27 +983,310 @@ class AppliqationReporter {
558
983
  * @private
559
984
  */
560
985
  printSummary() {
986
+ const testsSubmitted = this.totalTests;
987
+ const testsAccepted = testsSubmitted - this.backendRejectedTests;
988
+
561
989
  console.log('\n╔═══════════════════════════════════════════════════════════╗');
562
990
  console.log('║ Appliqation Test Results Summary ║');
563
991
  console.log('╠═══════════════════════════════════════════════════════════╣');
564
- console.log(`║ Total Tests: ${this.totalTests.toString().padStart(5)} ║`);
565
- console.log(`║ Passed: ${this.passedTests.toString().padStart(5)} ║`);
566
- console.log(`║ Failed: ${this.failedTests.toString().padStart(5)} ║`);
567
- console.log(`║ Skipped: ${this.skippedTests.toString().padStart(5)} ║`);
568
- console.log(`║ Orphan (No UUID): ${this.orphanTests.toString().padStart(5)} ║`);
992
+
993
+ // Show reporting status
994
+ if (!this.appqEnabled) {
995
+ console.log('║ ⚠️ Appliqation Reporting: DISABLED ║');
996
+ console.log('║ (Set APPQ_ENABLE=1 or add -- --appq flag) ║');
997
+ console.log('╠═══════════════════════════════════════════════════════════╣');
998
+ } else {
999
+ console.log('║ Submitted to Backend: ║');
1000
+ console.log(`║ Total Submitted: ${testsSubmitted.toString().padStart(5)} ║`);
1001
+ console.log(`║ ✅ Accepted: ${testsAccepted.toString().padStart(5)} ║`);
1002
+ console.log(`║ ❌ Rejected: ${this.backendRejectedTests.toString().padStart(5)} ║`);
1003
+ console.log('║ ║');
1004
+ }
1005
+ console.log('║ ║');
1006
+ console.log('║ Test Execution Results (Playwright): ║');
1007
+ console.log(`║ Passed: ${this.passedTests.toString().padStart(5)} ║`);
1008
+ console.log(`║ Failed: ${this.failedTests.toString().padStart(5)} ║`);
1009
+ console.log(`║ Skipped: ${this.skippedTests.toString().padStart(5)} ║`);
1010
+ console.log('║ ║');
1011
+ console.log('║ Not Submitted: ║');
1012
+ console.log(`║ Orphan (No UUID):${this.orphanTests.toString().padStart(5)} ║`);
1013
+ console.log(`║ Duplicates: ${this.duplicateTests.toString().padStart(5)} ║`);
569
1014
  console.log('╠═══════════════════════════════════════════════════════════╣');
570
- console.log(`║ Run Matrices Created: ${this.runsByProject.size} ║`);
571
1015
 
572
- for (const [projectKey, runInfo] of this.runsByProject.entries()) {
573
- console.log(`║ ${projectKey}: ${runInfo.runId} ║`);
1016
+ // Only show run matrices if reporting is enabled
1017
+ if (this.appqEnabled && this.runsByProject.size > 0) {
1018
+ console.log(`║ Run Matrices Created: ${this.runsByProject.size} ║`);
1019
+
1020
+ for (const [projectKey, runInfo] of this.runsByProject.entries()) {
1021
+ console.log(`║ ${projectKey}: ${runInfo.runId} ║`);
1022
+ }
1023
+
1024
+ // Show deleted orphan-only runs
1025
+ if (this.deletedOrphanRuns.length > 0) {
1026
+ console.log(`║ Deleted (Orphan-only): ${this.deletedOrphanRuns.length} ║`);
1027
+ }
574
1028
  }
575
1029
 
576
1030
  console.log('╚═══════════════════════════════════════════════════════════╝\n');
577
1031
 
1032
+ if (this.duplicateTests > 0) {
1033
+ console.log(`\n⚠️ Warning: ${this.duplicateTests} duplicate UUID(s) detected!\n`);
1034
+
1035
+ // Show details of each unique duplicate UUID
1036
+ let index = 1;
1037
+ for (const duplicate of this.uniqueDuplicates.values()) {
1038
+ console.log(`${index}. UUID: ${duplicate.uuid}`);
1039
+ console.log(` Occurrences (${duplicate.occurrences.length}):`);
1040
+ duplicate.occurrences.forEach((occ, i) => {
1041
+ console.log(` ${i + 1}) ${occ.file} - "${occ.title}" (${occ.browser})`);
1042
+ });
1043
+ console.log('');
1044
+ index++;
1045
+ }
1046
+
1047
+ console.log('Each test should have a unique UUID within a browser.\n');
1048
+ }
1049
+
1050
+ if (this.orphanTests > 0) {
1051
+ console.log(`\n⚠️ ${this.orphanTests} test(s) missing UUIDs - not submitted to Appliqation\n`);
1052
+
1053
+ // Show details of each unique orphan test
1054
+ let index = 1;
1055
+ for (const orphan of this.uniqueOrphans.values()) {
1056
+ console.log(`${index}. File: ${orphan.file}`);
1057
+ console.log(` Test: "${orphan.title}"`);
1058
+ console.log(` Browsers: ${orphan.browsers.join(', ')}\n`);
1059
+ index++;
1060
+ }
1061
+
1062
+ console.log('Fix: Add UUID tag → test(\'name\', { tag: \'@uuid:1154-xxx...\' }, async () => {...});\n');
1063
+ }
1064
+ }
1065
+
1066
+ /**
1067
+ * Write execution summary to file
1068
+ * Creates timestamped txt file in AppQ_Execution_Summary folder
1069
+ * @private
1070
+ */
1071
+ async writeSummaryToFile() {
1072
+ const fs = require('fs').promises;
1073
+ const path = require('path');
1074
+
1075
+ try {
1076
+ this.executionEndTime = new Date();
1077
+ const duration = this.executionEndTime - this.executionStartTime;
1078
+
1079
+ // Build output directory path
1080
+ // If playwrightOutputDir already contains 'test-results' (from config._internal.outputDir),
1081
+ // don't add it again. Otherwise, add 'test-results' to the path.
1082
+ const baseDir = this.playwrightOutputDir;
1083
+ const needsTestResults = !baseDir.includes('test-results');
1084
+
1085
+ const summaryDir = needsTestResults
1086
+ ? path.join(baseDir, 'test-results', 'AppQ_Execution_Summary')
1087
+ : path.join(baseDir, 'AppQ_Execution_Summary');
1088
+
1089
+ // Create directory if it doesn't exist
1090
+ await fs.mkdir(summaryDir, { recursive: true });
1091
+
1092
+ // Build filename with run title and timestamp
1093
+ const runTitle = this.config.title || 'execution_summary';
1094
+ const timestamp = this.formatDateTimeForFilename(this.executionStartTime);
1095
+ const filename = `${runTitle}_${timestamp}.txt`;
1096
+ const filepath = path.join(summaryDir, filename);
1097
+
1098
+ // Build comprehensive summary content
1099
+ const summaryContent = this.buildSummaryContent(duration);
1100
+
1101
+ // Write to file
1102
+ await fs.writeFile(filepath, summaryContent, 'utf8');
1103
+
1104
+ logger.info(`Execution summary saved to: ${filepath}`);
1105
+ console.log(`\n📄 Execution summary saved: ${filename}`);
1106
+
1107
+ } catch (error) {
1108
+ logger.error('Failed to write summary file', { error: error.message });
1109
+ // Don't throw - file writing failure should not break test run
1110
+ }
1111
+ }
1112
+
1113
+ /**
1114
+ * Format date/time for filename: 2025-01-21_14-30-45
1115
+ * @private
1116
+ */
1117
+ formatDateTimeForFilename(date) {
1118
+ const year = date.getFullYear();
1119
+ const month = String(date.getMonth() + 1).padStart(2, '0');
1120
+ const day = String(date.getDate()).padStart(2, '0');
1121
+ const hours = String(date.getHours()).padStart(2, '0');
1122
+ const minutes = String(date.getMinutes()).padStart(2, '0');
1123
+ const seconds = String(date.getSeconds()).padStart(2, '0');
1124
+
1125
+ return `${year}-${month}-${day}_${hours}-${minutes}-${seconds}`;
1126
+ }
1127
+
1128
+ /**
1129
+ * Build comprehensive summary content for file
1130
+ * @private
1131
+ */
1132
+ buildSummaryContent(duration) {
1133
+ const lines = [];
1134
+
1135
+ // Header
1136
+ lines.push('═══════════════════════════════════════════════════════════');
1137
+ lines.push(' APPLIQATION TEST EXECUTION SUMMARY');
1138
+ lines.push('═══════════════════════════════════════════════════════════');
1139
+ lines.push('');
1140
+
1141
+ // Execution Metadata
1142
+ lines.push('EXECUTION METADATA:');
1143
+ lines.push('─────────────────────────────────────────────────────────');
1144
+ lines.push(`Start Time: ${this.executionStartTime.toISOString()}`);
1145
+ lines.push(`End Time: ${this.executionEndTime.toISOString()}`);
1146
+ lines.push(`Duration: ${this.formatDuration(duration)}`);
1147
+ lines.push(`Run Title: ${this.config.title || 'N/A'}`);
1148
+ lines.push(`Appq Reporting: ${this.appqEnabled ? 'ENABLED' : 'DISABLED (Set APPQ_ENABLE=1 or add -- --appq flag)'}`);
1149
+ lines.push('');
1150
+
1151
+ // Test Results Summary (ASCII Table from printSummary)
1152
+ const testsSubmitted = this.totalTests;
1153
+ const testsAccepted = testsSubmitted - this.backendRejectedTests;
1154
+
1155
+ lines.push('╔═══════════════════════════════════════════════════════════╗');
1156
+ lines.push('║ Appliqation Test Results Summary ║');
1157
+ lines.push('╠═══════════════════════════════════════════════════════════╣');
1158
+
1159
+ // Show submission status
1160
+ if (!this.appqEnabled) {
1161
+ lines.push('║ ⚠️ Appliqation Reporting: DISABLED ║');
1162
+ lines.push('║ (Set APPQ_ENABLE=1 or add -- --appq flag) ║');
1163
+ lines.push('╠═══════════════════════════════════════════════════════════╣');
1164
+ } else {
1165
+ lines.push('║ Submitted to Backend: ║');
1166
+ lines.push(`║ Total Submitted: ${testsSubmitted.toString().padStart(5)} ║`);
1167
+ lines.push(`║ ✅ Accepted: ${testsAccepted.toString().padStart(5)} ║`);
1168
+ lines.push(`║ ❌ Rejected: ${this.backendRejectedTests.toString().padStart(5)} ║`);
1169
+ lines.push('║ ║');
1170
+ }
1171
+ lines.push('║ Test Execution Results (Playwright): ║');
1172
+ lines.push(`║ Passed: ${this.passedTests.toString().padStart(5)} ║`);
1173
+ lines.push(`║ Failed: ${this.failedTests.toString().padStart(5)} ║`);
1174
+ lines.push(`║ Skipped: ${this.skippedTests.toString().padStart(5)} ║`);
1175
+ lines.push('║ ║');
1176
+ lines.push('║ Not Submitted: ║');
1177
+ lines.push(`║ Orphan (No UUID):${this.orphanTests.toString().padStart(5)} ║`);
1178
+ lines.push(`║ Duplicates: ${this.duplicateTests.toString().padStart(5)} ║`);
1179
+ lines.push('╠═══════════════════════════════════════════════════════════╣');
1180
+
1181
+ // Run IDs (only show if reporting is enabled)
1182
+ if (this.appqEnabled && this.runsByProject.size > 0) {
1183
+ lines.push(`║ Run Matrices Created: ${this.runsByProject.size.toString().padStart(5)} ║`);
1184
+ for (const [projectKey, runInfo] of this.runsByProject) {
1185
+ const displayKey = projectKey.length > 30 ? projectKey.substring(0, 27) + '...' : projectKey;
1186
+ const paddedKey = displayKey.padEnd(30);
1187
+ lines.push(`║ ${paddedKey}: ${runInfo.runId.padEnd(20)} ║`);
1188
+ }
1189
+
1190
+ // Show deleted orphan-only runs
1191
+ if (this.deletedOrphanRuns.length > 0) {
1192
+ lines.push(`║ Deleted (Orphan-only): ${this.deletedOrphanRuns.length.toString().padStart(5)} ║`);
1193
+ }
1194
+ }
1195
+
1196
+ lines.push('╚═══════════════════════════════════════════════════════════╝');
1197
+ lines.push('');
1198
+
1199
+ // Detailed Errors Section
1200
+ if (this.duplicateTests > 0 || this.orphanTests > 0 || this.backendRejectedTests > 0) {
1201
+ lines.push('');
1202
+ lines.push('DETAILED ERRORS & WARNINGS:');
1203
+ lines.push('═══════════════════════════════════════════════════════════');
1204
+ lines.push('');
1205
+ }
1206
+
1207
+ // Duplicate UUIDs Details
1208
+ if (this.duplicateTests > 0 && this.uniqueDuplicates.size > 0) {
1209
+ lines.push('⚠️ DUPLICATE UUIDs DETECTED:');
1210
+ lines.push('─────────────────────────────────────────────────────────');
1211
+ lines.push(`Count: ${this.duplicateTests} unique UUID(s)`);
1212
+ lines.push('');
1213
+
1214
+ // List each unique duplicate UUID with all occurrences
1215
+ let index = 1;
1216
+ for (const duplicate of this.uniqueDuplicates.values()) {
1217
+ lines.push(`${index}. UUID: ${duplicate.uuid}`);
1218
+ lines.push(` Occurrences (${duplicate.occurrences.length}):`);
1219
+ duplicate.occurrences.forEach((occ, i) => {
1220
+ lines.push(` ${i + 1}) File: ${occ.file}`);
1221
+ lines.push(` Test: "${occ.title}"`);
1222
+ lines.push(` Browser: ${occ.browser}`);
1223
+ });
1224
+ lines.push('');
1225
+ index++;
1226
+ }
1227
+
1228
+ lines.push('Action Required: Each test should have a unique UUID within a browser.');
1229
+ lines.push('');
1230
+ }
1231
+
1232
+ // Orphan Tests Warning
578
1233
  if (this.orphanTests > 0) {
579
- console.log('⚠️ Warning: Some tests are missing UUID annotations!');
580
- console.log(' Add UUIDs to map tests to Appliqation test cases:');
581
- console.log(' test(\'My Test\', { tag: \'@uuid:123-xxx-...\' }, async ({ page }) => { ... });\n');
1234
+ lines.push('⚠️ TESTS MISSING UUIDs (Not Submitted):');
1235
+ lines.push('─────────────────────────────────────────────────────────');
1236
+ lines.push(`Count: ${this.orphanTests} unique test(s)`);
1237
+ lines.push('');
1238
+
1239
+ // List each unique orphan test with details
1240
+ let index = 1;
1241
+ for (const orphan of this.uniqueOrphans.values()) {
1242
+ lines.push(`${index}. File: ${orphan.file}`);
1243
+ lines.push(` Test: "${orphan.title}"`);
1244
+ lines.push(` Browsers: ${orphan.browsers.join(', ')}`);
1245
+ lines.push('');
1246
+ index++;
1247
+ }
1248
+
1249
+ lines.push('Action Required: Add UUID tags to submit results');
1250
+ lines.push('Example: test(\'name\', { tag: \'@uuid:1154-abc-...\' }, async () => {...});');
1251
+ lines.push('');
1252
+ }
1253
+
1254
+ // Backend Rejections
1255
+ if (this.backendRejectedTests > 0) {
1256
+ lines.push('BACKEND VALIDATION REJECTIONS:');
1257
+ lines.push('─────────────────────────────────────────────────────────');
1258
+ lines.push(`Total rejected: ${this.backendRejectedTests}`);
1259
+ lines.push('');
1260
+ lines.push('These test cases were rejected due to project ownership validation.');
1261
+ lines.push('The test case UUID does not belong to the project associated with');
1262
+ lines.push('your API key. Verify the correct project and test case mappings.');
1263
+ lines.push('');
1264
+ }
1265
+
1266
+ // Footer
1267
+ lines.push('═══════════════════════════════════════════════════════════');
1268
+ lines.push(`Generated by Appliqation SDK v${require('../../../package.json').version}`);
1269
+ lines.push(`Timestamp: ${new Date().toISOString()}`);
1270
+ lines.push('═══════════════════════════════════════════════════════════');
1271
+
1272
+ return lines.join('\n');
1273
+ }
1274
+
1275
+ /**
1276
+ * Format duration in human-readable format
1277
+ * @private
1278
+ */
1279
+ formatDuration(ms) {
1280
+ const seconds = Math.floor(ms / 1000);
1281
+ const minutes = Math.floor(seconds / 60);
1282
+ const hours = Math.floor(minutes / 60);
1283
+
1284
+ if (hours > 0) {
1285
+ return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
1286
+ } else if (minutes > 0) {
1287
+ return `${minutes}m ${seconds % 60}s`;
1288
+ } else {
1289
+ return `${seconds}s`;
582
1290
  }
583
1291
  }
584
1292