@flash-ai-team/flash-test-framework 0.0.15 → 0.0.17

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.
@@ -1,11 +1,13 @@
1
1
  import { Reporter, FullConfig, Suite, TestCase, TestResult, FullResult } from '@playwright/test/reporter';
2
2
  declare class CustomReporter implements Reporter {
3
3
  private results;
4
- private logBuffer;
4
+ private executionLogBuffer;
5
5
  private reportFolder;
6
6
  private log;
7
7
  onBegin(config: FullConfig, suite: Suite): void;
8
8
  onTestEnd(test: TestCase, result: TestResult): void;
9
+ onStdOut(chunk: string | Buffer, test?: TestCase, result?: TestResult): void;
10
+ onStdErr(chunk: string | Buffer, test?: TestCase, result?: TestResult): void;
9
11
  onEnd(result: FullResult): void;
10
12
  }
11
13
  export default CustomReporter;
@@ -39,12 +39,12 @@ const ReporterUtils_1 = require("./ReporterUtils");
39
39
  class CustomReporter {
40
40
  constructor() {
41
41
  this.results = [];
42
- this.logBuffer = [];
42
+ this.executionLogBuffer = []; // Unified buffer
43
43
  this.reportFolder = '';
44
44
  }
45
45
  log(message) {
46
46
  console.log(message);
47
- this.logBuffer.push(message);
47
+ this.executionLogBuffer.push(message + '\n');
48
48
  }
49
49
  onBegin(config, suite) {
50
50
  this.reportFolder = (0, ReporterUtils_1.getReportFolder)(suite);
@@ -93,17 +93,15 @@ class CustomReporter {
93
93
  lines.forEach(line => this.log(line));
94
94
  // Write to individual file
95
95
  if (this.reportFolder) {
96
- // Ensure folder exists (it normally should be created in onEnd, but we are in onTestEnd now.
97
- // We might need to create it here if it doesn't exist yet, or wait?
98
- // Safest is to ensure it exists.
99
- if (!fs.existsSync(this.reportFolder)) {
96
+ const logsDir = path.join(this.reportFolder, 'logs');
97
+ if (!fs.existsSync(logsDir)) {
100
98
  try {
101
- fs.mkdirSync(this.reportFolder, { recursive: true });
99
+ fs.mkdirSync(logsDir, { recursive: true });
102
100
  }
103
101
  catch (e) { /* ignore race */ }
104
102
  }
105
103
  const sanitizedTitle = test.title.replace(/[^a-z0-9]/gi, '_');
106
- const individualLogPath = path.join(this.reportFolder, `execution_${sanitizedTitle}.log`);
104
+ const individualLogPath = path.join(logsDir, `execution_${sanitizedTitle}.log`);
107
105
  try {
108
106
  fs.writeFileSync(individualLogPath, lines.join('\n'));
109
107
  }
@@ -112,17 +110,31 @@ class CustomReporter {
112
110
  }
113
111
  }
114
112
  }
113
+ onStdOut(chunk, test, result) {
114
+ const text = chunk.toString();
115
+ process.stdout.write(text); // Print to real console
116
+ this.executionLogBuffer.push(text); // Add raw text to unified log
117
+ }
118
+ onStdErr(chunk, test, result) {
119
+ const text = chunk.toString();
120
+ process.stderr.write(text); // Print to real stderr
121
+ this.executionLogBuffer.push(text); // Add raw text to unified log
122
+ }
115
123
  onEnd(result) {
116
124
  if (!this.reportFolder)
117
125
  return;
118
126
  const reportPath = path.join(this.reportFolder, 'custom-report.json');
119
- const logPath = path.join(this.reportFolder, 'execution.log');
127
+ const logsDir = path.join(this.reportFolder, 'logs');
128
+ const logPath = path.join(this.reportFolder, 'execution.log'); // Unified execution log
120
129
  if (!fs.existsSync(this.reportFolder)) {
121
130
  fs.mkdirSync(this.reportFolder, { recursive: true });
122
131
  }
132
+ if (!fs.existsSync(logsDir)) {
133
+ fs.mkdirSync(logsDir, { recursive: true });
134
+ }
123
135
  fs.writeFileSync(reportPath, JSON.stringify(this.results, null, 2));
124
136
  this.log(`Custom JSON Report generated at: ${reportPath}`);
125
- fs.writeFileSync(logPath, this.logBuffer.join('\n'));
137
+ fs.writeFileSync(logPath, this.executionLogBuffer.join('')); // Join with empty string to preserve raw chunks and newlines
126
138
  console.log(`Execution Log generated at: ${logPath}`);
127
139
  }
128
140
  }
@@ -86,6 +86,31 @@ class HtmlReporter {
86
86
  }
87
87
  fs.writeFileSync(this.reportPath, html);
88
88
  console.log(`HTML Report generated at: ${this.reportPath}`);
89
+ // Check if we should open the report
90
+ let openReport = true; // Default to true
91
+ try {
92
+ const configPath = path.join(process.cwd(), 'report.config.json');
93
+ if (fs.existsSync(configPath)) {
94
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
95
+ if (config.openReport === false) {
96
+ openReport = false;
97
+ }
98
+ }
99
+ }
100
+ catch (e) { /* ignore */ }
101
+ if (openReport) {
102
+ try {
103
+ // Write a marker file for VS Code extension to detect and open Latest Run view
104
+ const markerPath = path.join(process.cwd(), '.flash-report-ready');
105
+ fs.writeFileSync(markerPath, JSON.stringify({
106
+ reportPath: this.reportPath,
107
+ timestamp: Date.now()
108
+ }));
109
+ }
110
+ catch (e) {
111
+ console.log('Could not write report marker file.');
112
+ }
113
+ }
89
114
  }
90
115
  onTestEnd(test, result) {
91
116
  let suite = test.parent;
@@ -104,14 +129,19 @@ class HtmlReporter {
104
129
  fs.mkdirSync(reportDir, { recursive: true });
105
130
  }
106
131
  // Helper to copy attachment
107
- const copyAttachment = (name, matchFn) => {
132
+ const copyAttachment = (name, subDir, matchFn) => {
108
133
  const attachment = result.attachments.find(matchFn);
109
134
  if (attachment && attachment.path && reportDir) {
135
+ const targetDir = path.join(reportDir, subDir);
136
+ if (!fs.existsSync(targetDir)) {
137
+ fs.mkdirSync(targetDir, { recursive: true });
138
+ }
110
139
  const fileName = `${name}_${test.title.replace(/[^a-z0-9]/gi, '_')}_${Date.now()}${path.extname(attachment.path)}`;
111
- const destPath = path.join(reportDir, fileName);
140
+ const destPath = path.join(targetDir, fileName);
112
141
  try {
113
142
  fs.copyFileSync(attachment.path, destPath);
114
- return fileName;
143
+ // Return relative path including subfolder
144
+ return `${subDir}/${fileName}`;
115
145
  }
116
146
  catch (e) {
117
147
  console.error(`Failed to copy ${name} attachment`, e);
@@ -120,10 +150,22 @@ class HtmlReporter {
120
150
  return undefined;
121
151
  };
122
152
  // Handle Video
123
- const videoRelativePath = copyAttachment('video', a => a.name === 'video' && !!a.path);
153
+ const videoRelativePath = copyAttachment('video', 'videos', a => a.name === 'video' && !!a.path);
124
154
  // Handle Screenshot (usually named 'screenshot' or has image content type)
125
- const screenshotRelativePath = copyAttachment('screenshot', a => (a.name === 'screenshot' || a.contentType.startsWith('image/')) && !!a.path);
126
- this.testResults.push({ test, result, suiteName, videoPath: videoRelativePath, screenshotPath: screenshotRelativePath });
155
+ const screenshotRelativePath = copyAttachment('screenshot', 'screenshots', a => (a.name === 'screenshot' || a.contentType.startsWith('image/')) && !!a.path);
156
+ // Capture Times
157
+ const start = result.startTime instanceof Date ? result.startTime : new Date(result.startTime);
158
+ const startTime = start.toLocaleTimeString();
159
+ const endTime = new Date(start.getTime() + result.duration).toLocaleTimeString();
160
+ this.testResults.push({
161
+ test,
162
+ result,
163
+ suiteName,
164
+ videoPath: videoRelativePath,
165
+ screenshotPath: screenshotRelativePath,
166
+ startTime,
167
+ endTime
168
+ });
127
169
  }
128
170
  ansiToHtml(text) {
129
171
  if (!text)
@@ -214,6 +256,44 @@ class HtmlReporter {
214
256
  });
215
257
  }
216
258
  generateHtml() {
259
+ // Load Config
260
+ let config = { theme: 'dark', language: 'en' };
261
+ try {
262
+ const configPath = path.join(process.cwd(), 'report.config.json');
263
+ if (fs.existsSync(configPath)) {
264
+ config = { ...config, ...JSON.parse(fs.readFileSync(configPath, 'utf-8')) };
265
+ }
266
+ }
267
+ catch (e) { /* ignore */ }
268
+ // Translations
269
+ const translations = {
270
+ en: {
271
+ tests: 'TESTS', passed: 'Passed', failed: 'Failed', skipped: 'Skipped',
272
+ all: 'All', failures: 'Failures', searchTests: 'Search tests...',
273
+ executionSteps: 'Execution Steps', errorDetails: 'Error Details', artifacts: 'Artifacts',
274
+ screenshot: 'Screenshot', video: 'Video', goToArtifacts: 'Go to Artifacts',
275
+ openRawTab: 'Open Raw Tab', copy: 'Copy', start: 'Start', end: 'End', duration: 'Duration',
276
+ user: 'User', os: 'OS', ip: 'IP', beforeHooks: 'Before Hooks', afterHooks: 'After Hooks', workerCleanup: 'Worker Cleanup'
277
+ },
278
+ fr: {
279
+ tests: 'TESTS', passed: 'Réussi', failed: 'Échoué', skipped: 'Ignoré',
280
+ all: 'Tous', failures: 'Échecs', searchTests: 'Rechercher...',
281
+ executionSteps: "Étapes d'exécution", errorDetails: "Détails de l'erreur", artifacts: 'Artéfacts',
282
+ screenshot: "Capture d'écran", video: 'Vidéo', goToArtifacts: 'Voir les artéfacts',
283
+ openRawTab: 'Ouvrir onglet brut', copy: 'Copier', start: 'Début', end: 'Fin', duration: 'Durée',
284
+ user: 'Utilisateur', os: 'Système', ip: 'IP', beforeHooks: 'Hooks Avant', afterHooks: 'Hooks Après', workerCleanup: 'Nettoyage'
285
+ },
286
+ zh: {
287
+ tests: '测试', passed: '通过', failed: '失败', skipped: '跳过',
288
+ all: '全部', failures: '失败项', searchTests: '搜索测试...',
289
+ executionSteps: '执行步骤', errorDetails: '错误详情', artifacts: '产物',
290
+ screenshot: '截图', video: '视频', goToArtifacts: '查看产物',
291
+ openRawTab: '打开原始标签', copy: '复制', start: '开始', end: '结束', duration: '耗时',
292
+ user: '用户', os: '系统', ip: 'IP地址', beforeHooks: '前置钩子', afterHooks: '后置钩子', workerCleanup: '清理'
293
+ }
294
+ };
295
+ const lang = config.language && translations[config.language] ? config.language : 'en';
296
+ const t = translations[lang];
217
297
  const totalTests = this.testResults.length;
218
298
  const passedTests = this.testResults.filter(r => r.result.status === 'passed').length;
219
299
  const failedTests = this.testResults.filter(r => r.result.status === 'failed' || r.result.status === 'timedOut').length;
@@ -228,6 +308,8 @@ class HtmlReporter {
228
308
  title: item.test.title,
229
309
  status: item.result.status,
230
310
  duration: item.result.duration,
311
+ startTime: item.startTime,
312
+ endTime: item.endTime,
231
313
  steps: this.processSteps(item.result.steps),
232
314
  error: item.result.error ? this.ansiToHtml(item.result.error.stack || item.result.error.message || '') : undefined,
233
315
  screenshot: item.screenshotPath,
@@ -242,6 +324,7 @@ class HtmlReporter {
242
324
  <title>Test Result</title>
243
325
  <style>
244
326
  :root {
327
+ /* Theme: ${config.theme} (Default) */
245
328
  --bg-color: #1e1e1e;
246
329
  --sidebar-bg: #252526;
247
330
  --text-color: #d4d4d4;
@@ -252,6 +335,129 @@ class HtmlReporter {
252
335
  --skip-color: #ff9800;
253
336
  --blue-accent: #007acc;
254
337
  --code-bg: #0d1117;
338
+
339
+ /* Detailed Colors */
340
+ --card-bg: #252526;
341
+ --header-bg: #2d2d2d;
342
+ --text-secondary: #888;
343
+ --border-dim: #333;
344
+ --step-hover: #2a2d2e;
345
+ --input-bg: #1e1e1e;
346
+ --input-text: #fff;
347
+ --code-text: #ccc;
348
+ --info-box-bg: #2a2d2e;
349
+ }
350
+
351
+ /* Theme Definitions */
352
+ [data-theme="light"] {
353
+ --bg-color: #ffffff;
354
+ --sidebar-bg: #f3f3f3;
355
+ --text-color: #333333;
356
+ --border-color: #e5e5e5;
357
+ --hover-color: #e8e8e8;
358
+ --success-color: #2e7d32;
359
+ --fail-color: #d32f2f;
360
+ --skip-color: #ed6c02;
361
+ --blue-accent: #1976d2;
362
+ --code-bg: #f5f5f5;
363
+
364
+ --card-bg: #ffffff;
365
+ --header-bg: #f5f5f5;
366
+ --text-secondary: #666;
367
+ --border-dim: #eee;
368
+ --step-hover: #f0f0f0;
369
+ --input-bg: #ffffff;
370
+ --input-text: #333;
371
+ --code-text: #333;
372
+ --info-box-bg: #f5f5f5;
373
+ }
374
+ [data-theme="muted-light"] {
375
+ --bg-color: #f5f3ef;
376
+ --sidebar-bg: #ebe8e3;
377
+ --text-color: #4a4540;
378
+ --border-color: #d9d5cf;
379
+ --hover-color: #e5e2dc;
380
+ --success-color: #558b2f;
381
+ --fail-color: #c62828;
382
+ --skip-color: #e65100;
383
+ --blue-accent: #1565c0;
384
+ --code-bg: #e8e5e0;
385
+
386
+ --card-bg: #faf8f5;
387
+ --header-bg: #ebe8e3;
388
+ --text-secondary: #7a756e;
389
+ --border-dim: #d9d5cf;
390
+ --step-hover: #e5e2dc;
391
+ --input-bg: #faf8f5;
392
+ --input-text: #4a4540;
393
+ --code-text: #4a4540;
394
+ --info-box-bg: #ebe8e3;
395
+ }
396
+ [data-theme="midnight"] {
397
+ --bg-color: #0f172a;
398
+ --sidebar-bg: #1e293b;
399
+ --text-color: #e2e8f0;
400
+ --border-color: #334155;
401
+ --hover-color: #334155;
402
+ --success-color: #22c55e;
403
+ --fail-color: #ef4444;
404
+ --skip-color: #f59e0b;
405
+ --blue-accent: #6366f1;
406
+ --code-bg: #1e293b;
407
+
408
+ --card-bg: #1e293b;
409
+ --header-bg: #334155;
410
+ --text-secondary: #94a3b8;
411
+ --border-dim: #334155;
412
+ --step-hover: #334155;
413
+ --input-bg: #0f172a;
414
+ --input-text: #e2e8f0;
415
+ --code-text: #e2e8f0;
416
+ --info-box-bg: #1e293b;
417
+ }
418
+ [data-theme="forest"] {
419
+ --bg-color: #1a2f1a;
420
+ --sidebar-bg: #243d24;
421
+ --text-color: #e8f5e9;
422
+ --border-color: #2e4d2e;
423
+ --hover-color: #2e4d2e;
424
+ --success-color: #66bb6a;
425
+ --fail-color: #ef5350;
426
+ --skip-color: #ffa726;
427
+ --blue-accent: #4caf50;
428
+ --code-bg: #1b3320;
429
+
430
+ --card-bg: #243d24;
431
+ --header-bg: #2e4d2e;
432
+ --text-secondary: #a5d6a7;
433
+ --border-dim: #2e4d2e;
434
+ --step-hover: #2e4d2e;
435
+ --input-bg: #1a2f1a;
436
+ --input-text: #e8f5e9;
437
+ --code-text: #e8f5e9;
438
+ --info-box-bg: #243d24;
439
+ }
440
+ [data-theme="dracula"] {
441
+ --bg-color: #282a36;
442
+ --sidebar-bg: #44475a;
443
+ --text-color: #f8f8f2;
444
+ --border-color: #6272a4;
445
+ --hover-color: #44475a;
446
+ --success-color: #50fa7b;
447
+ --fail-color: #ff5555;
448
+ --skip-color: #ffb86c;
449
+ --blue-accent: #bd93f9;
450
+ --code-bg: #282a36;
451
+
452
+ --card-bg: #44475a;
453
+ --header-bg: #6272a4;
454
+ --text-secondary: #bd93f9;
455
+ --border-dim: #6272a4;
456
+ --step-hover: #6272a4;
457
+ --input-bg: #282a36;
458
+ --input-text: #f8f8f2;
459
+ --code-text: #f8f8f2;
460
+ --info-box-bg: #44475a;
255
461
  }
256
462
  body {
257
463
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
@@ -306,7 +512,7 @@ class HtmlReporter {
306
512
  justify-content: center;
307
513
  }
308
514
  .total-text { font-size: 1.5em; font-weight: bold; }
309
- .total-label { font-size: 0.8em; color: #888; }
515
+ .total-label { font-size: 0.8em; color: var(--text-secondary); }
310
516
 
311
517
  .stats-row {
312
518
  display: flex;
@@ -336,9 +542,9 @@ class HtmlReporter {
336
542
  }
337
543
  .filter-btn {
338
544
  flex: 1;
339
- background: #333;
545
+ background: var(--card-bg);
340
546
  border: 1px solid var(--border-color);
341
- color: #ccc;
547
+ color: var(--text-color);
342
548
  padding: 5px;
343
549
  cursor: pointer;
344
550
  font-size: 0.85em;
@@ -352,9 +558,9 @@ class HtmlReporter {
352
558
  .search-box {
353
559
  width: 100%;
354
560
  padding: 10px;
355
- background-color: #1e1e1e;
561
+ background-color: var(--input-bg);
356
562
  border: 1px solid var(--border-color);
357
- color: white;
563
+ color: var(--input-text);
358
564
  border-radius: 4px;
359
565
  box-sizing: border-box;
360
566
  margin-top: 10px;
@@ -401,7 +607,7 @@ class HtmlReporter {
401
607
  .detail-header-wrapper {
402
608
  padding: 20px 30px;
403
609
  border-bottom: 1px solid var(--border-color);
404
- background-color: #252526;
610
+ background-color: var(--card-bg);
405
611
  }
406
612
  .detail-title { font-size: 1.6em; margin: 10px 0; font-weight: 300; }
407
613
  .detail-badges { display: flex; gap: 10px; align-items: center; }
@@ -423,7 +629,7 @@ class HtmlReporter {
423
629
  }
424
630
 
425
631
  .section-card {
426
- background-color: #252526;
632
+ background-color: var(--card-bg);
427
633
  border: 1px solid var(--border-color);
428
634
  border-radius: 6px;
429
635
  margin-bottom: 25px;
@@ -431,7 +637,7 @@ class HtmlReporter {
431
637
  }
432
638
  .section-header {
433
639
  padding: 10px 15px;
434
- background-color: #2d2d2d;
640
+ background-color: var(--header-bg);
435
641
  border-bottom: 1px solid var(--border-color);
436
642
  font-weight: 600;
437
643
  color: #bbb;
@@ -443,7 +649,7 @@ class HtmlReporter {
443
649
  /* Steps */
444
650
  .step-row {
445
651
  padding: 8px 15px;
446
- border-bottom: 1px solid #333;
652
+ border-bottom: 1px solid var(--border-dim);
447
653
  display: flex;
448
654
  gap: 10px;
449
655
  align-items: flex-start;
@@ -452,10 +658,10 @@ class HtmlReporter {
452
658
  transition: background-color 0.2s;
453
659
  }
454
660
  .step-row:last-child { border-bottom: none; }
455
- .step-row:hover { background-color: #2a2d2e; }
661
+ .step-row:hover { background-color: var(--step-hover); }
456
662
  .step-status { margin-top: 3px; font-size: 1.2em; line-height: 1; }
457
663
  .step-content { flex: 1; }
458
- .step-meta { color: #666; font-size: 0.9em; margin-left: 10px; white-space: nowrap; }
664
+ .step-meta { color: var(--text-secondary); font-size: 0.9em; margin-left: 10px; white-space: nowrap; }
459
665
 
460
666
  /* Tree Toggle */
461
667
  .toggle-icon {
@@ -489,7 +695,7 @@ class HtmlReporter {
489
695
  padding: 15px;
490
696
  border-radius: 4px;
491
697
  font-family: Consolas, monospace;
492
- color: #d4d4d4;
698
+ color: var(--code-text);
493
699
  white-space: pre-wrap;
494
700
  overflow-x: auto;
495
701
  border: 1px solid #30363d;
@@ -498,12 +704,12 @@ class HtmlReporter {
498
704
  /* Code Snippet */
499
705
  .code-snippet {
500
706
  margin-top: 10px;
501
- background-color: #000;
707
+ background-color: var(--code-bg);
502
708
  border-radius: 4px;
503
709
  padding: 10px;
504
710
  font-family: Consolas, monospace;
505
711
  font-size: 0.9em;
506
- color: #ccc;
712
+ color: var(--code-text);
507
713
  white-space: pre;
508
714
  overflow-x: auto;
509
715
  border-left: 3px solid #ffcc00;
@@ -517,14 +723,14 @@ class HtmlReporter {
517
723
  padding: 20px;
518
724
  }
519
725
  .media-item {
520
- background-color: #000;
726
+ background-color: var(--card-bg);
521
727
  border-radius: 4px;
522
728
  border: 1px solid var(--border-color);
523
729
  overflow: hidden;
524
730
  }
525
731
  .media-header {
526
732
  padding: 8px 12px;
527
- background-color: #333;
733
+ background-color: var(--header-bg);
528
734
  color: #ccc;
529
735
  font-size: 0.85em;
530
736
  border-bottom: 1px solid #444;
@@ -547,43 +753,53 @@ class HtmlReporter {
547
753
  <body>
548
754
  <div class="sidebar">
549
755
  <div class="sidebar-header">
756
+ <div style="width:100%; margin-bottom:15px; display:flex; justify-content:flex-end">
757
+ <select id="themeSelector" onchange="setTheme(this.value)" style="background:var(--input-bg); color:var(--input-text); border:1px solid var(--border-color); padding:2px 5px; border-radius:3px; outline:none; font-size:0.8em">
758
+ <option value="dark">Dark</option>
759
+ <option value="light">Light</option>
760
+ <option value="muted-light">Muted Light</option>
761
+ <option value="midnight">Midnight</option>
762
+ <option value="forest">Forest</option>
763
+ <option value="dracula">Dracula</option>
764
+ </select>
765
+ </div>
550
766
  <div class="chart-container">
551
767
  <div class="chart-inner">
552
768
  <div class="total-text">${totalTests}</div>
553
- <div class="total-label">TESTS</div>
769
+ <div class="total-label">${t.tests}</div>
554
770
  </div>
555
771
  </div>
556
772
 
557
773
  <div class="stats-row">
558
774
  <div class="stat-item" onclick="setFilter('passed')" title="Show Passed">
559
775
  <span class="stat-value" style="color:var(--success-color)">${passedTests}</span>
560
- <span class="stat-label">Passed</span>
776
+ <span class="stat-label">${t.passed}</span>
561
777
  </div>
562
778
  <div class="stat-item" onclick="setFilter('failed')" title="Show Failed">
563
779
  <span class="stat-value" style="color:var(--fail-color)">${failedTests}</span>
564
- <span class="stat-label">Failed</span>
780
+ <span class="stat-label">${t.failed}</span>
565
781
  </div>
566
782
  <div class="stat-item" onclick="setFilter('skipped')" title="Show Skipped">
567
783
  <span class="stat-value" style="color:var(--skip-color)">${skippedTests}</span>
568
- <span class="stat-label">Skipped</span>
784
+ <span class="stat-label">${t.skipped}</span>
569
785
  </div>
570
786
  </div>
571
787
 
572
788
  <div style="margin-top:15px; font-size:0.8em; color:#666; text-align:center" id="reportTimestamp"></div>
573
789
 
574
790
  <!-- Environment Info -->
575
- <div style="margin-top:15px; padding:15px; background-color:#2a2d2e; border-top:1px solid var(--border-color); font-size:0.85em; color:#bbb">
576
- <div style="margin-bottom:5px"><strong style="color:#888">User:</strong> <span id="envUser">...</span></div>
577
- <div style="margin-bottom:5px"><strong style="color:#888">OS:</strong> <span id="envOs">...</span></div>
578
- <div><strong style="color:#888">IP:</strong> <span id="envIp">...</span></div>
791
+ <div style="margin-top:15px; padding:15px; background-color:var(--info-box-bg); border-top:1px solid var(--border-color); font-size:0.85em; color:var(--text-secondary)">
792
+ <div style="margin-bottom:5px"><strong style="color:var(--text-secondary)">${t.user}:</strong> <span id="envUser">...</span></div>
793
+ <div style="margin-bottom:5px"><strong style="color:var(--text-secondary)">${t.os}:</strong> <span id="envOs">...</span></div>
794
+ <div><strong style="color:var(--text-secondary)">${t.ip}:</strong> <span id="envIp">...</span></div>
579
795
  </div>
580
796
 
581
797
  <div class="filter-group">
582
- <button class="filter-btn active" onclick="setFilter('all')">All</button>
583
- <button class="filter-btn" onclick="setFilter('failed')">Failures</button>
798
+ <button class="filter-btn active" onclick="setFilter('all')">${t.all}</button>
799
+ <button class="filter-btn" onclick="setFilter('failed')">${t.failures}</button>
584
800
  </div>
585
801
 
586
- <input type="text" class="search-box" placeholder="Search tests..." id="searchInput" onkeyup="filterTests()">
802
+ <input type="text" class="search-box" placeholder="${t.searchTests}" id="searchInput" onkeyup="filterTests()">
587
803
  </div>
588
804
  <div class="test-list" id="testList"></div>
589
805
  </div>
@@ -601,6 +817,7 @@ class HtmlReporter {
601
817
  const data = ${resultsData};
602
818
  const startTime = ${this.suiteStartTime};
603
819
  const envInfo = ${JSON.stringify(this.envInfo)};
820
+ const t = ${JSON.stringify(t)};
604
821
  let currentId = null;
605
822
  let currentFilter = 'all';
606
823
 
@@ -713,19 +930,23 @@ class HtmlReporter {
713
930
  const statusClass = test.status === 'passed' ? 'badge-passed' :
714
931
  (test.status === 'failed' || test.status === 'timedOut') ? 'badge-failed' : 'badge-skipped';
715
932
 
933
+ // Clean error message (remove Call log)
934
+ const cleanError = test.error ? test.error.split('Call log:')[0].trim() : '';
935
+
716
936
  // Steps (using tree renderer)
717
937
  const stepsHtml = test.steps.map(generateStepTreeHtml).join('');
718
938
 
719
939
  // Media
940
+ const hasArtifacts = test.screenshot || test.video;
720
941
  const mediaHtml = \`
721
942
  \${test.screenshot ? \`
722
943
  <div class="media-item">
723
- <div class="media-header">Screenshot <a href="\${test.screenshot}" target="_blank" style="float:right; color:#4dabf7; text-decoration:none">Open New Tab</a></div>
944
+ <div class="media-header">\${t.screenshot} <a href="\${test.screenshot}" target="_blank" style="float:right; color:#4dabf7; text-decoration:none">${t.openRawTab}</a></div>
724
945
  <img src="\${test.screenshot}" onclick="window.open(this.src)">
725
946
  </div>\` : ''}
726
947
  \${test.video ? \`
727
948
  <div class="media-item">
728
- <div class="media-header">Video</div>
949
+ <div class="media-header">\${t.video}</div>
729
950
  <video src="\${test.video}" controls></video>
730
951
  </div>\` : ''}
731
952
  \`;
@@ -735,31 +956,36 @@ class HtmlReporter {
735
956
  <div class="detail-meta">
736
957
  <span class="badge \${statusClass}">\${test.status}</span>
737
958
  <span>\${test.suite}</span>
738
- <span style="margin-left:auto">Duration: \${test.duration}ms</span>
959
+ \${hasArtifacts ? \`<button class="filter-btn" onclick="document.getElementById('artifacts-section').scrollIntoView({behavior: 'smooth'})" style="margin-left:auto; padding:2px 8px; font-size:11px; height:auto; line-height:normal">\${t.goToArtifacts} ⬇</button>\` : ''}
739
960
  </div>
740
961
  <div class="detail-title">\${test.title}</div>
962
+ <div class="detail-timing" style="color:#888; font-size:0.9em; margin-top:5px">
963
+ <span style="margin-right:15px">\${t.start}: \${test.startTime}</span>
964
+ <span style="margin-right:15px">\${t.end}: \${test.endTime}</span>
965
+ <span>\${t.duration}: \${test.duration}ms</span>
966
+ </div>
741
967
  </div>
742
968
 
743
969
  <div class="detail-scroll-area">
744
- \${test.error ? \`
970
+ \${cleanError ? \`
745
971
  <div class="section-card error-box">
746
972
  <div style="display:flex; justify-content:space-between; margin-bottom:10px">
747
- <strong style="color:var(--fail-color)">Error Details</strong>
973
+ <strong style="color:var(--fail-color)">\${t.errorDetails}</strong>
748
974
  </div>
749
- <div class="stack-trace">\${test.error}</div>
975
+ <div class="stack-trace">\${cleanError}</div>
750
976
  </div>
751
977
  \` : ''}
752
978
 
753
979
  <div class="section-card">
754
- <div class="section-header">Execution Steps</div>
980
+ <div class="section-header">\${t.executionSteps}</div>
755
981
  <div class="steps-tree-container">
756
982
  \${stepsHtml}
757
983
  </div>
758
984
  </div>
759
985
 
760
- \${test.screenshot || test.video ? \`
761
- <div class="section-card">
762
- <div class="section-header">Artifacts</div>
986
+ \${hasArtifacts ? \`
987
+ <div id="artifacts-section" class="section-card">
988
+ <div class="section-header">\${t.artifacts}</div>
763
989
  <div class="media-grid">\${mediaHtml}</div>
764
990
  </div>
765
991
  \` : ''}
@@ -768,6 +994,22 @@ class HtmlReporter {
768
994
  content.classList.add('active');
769
995
  }
770
996
 
997
+ function setTheme(themeName) {
998
+ document.documentElement.setAttribute('data-theme', themeName);
999
+ localStorage.setItem('report-theme', themeName);
1000
+ }
1001
+
1002
+ // Initialize Theme
1003
+ // We prioritize the theme from report.config.json if it's set.
1004
+ // This ensures the report respects the project configuration.
1005
+ const configTheme = '${config.theme}' || 'dark';
1006
+
1007
+ // On initial load, we follow the config.
1008
+ // However, we can check if there was a MANUALLY set preference in this session.
1009
+ // For now, let's just respect the config theme as requested.
1010
+ setTheme(configTheme);
1011
+ document.getElementById('themeSelector').value = configTheme;
1012
+
771
1013
  // Initial render
772
1014
  renderList(data);
773
1015
  </script>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flash-ai-team/flash-test-framework",
3
- "version": "0.0.15",
3
+ "version": "0.0.17",
4
4
  "description": "A powerful keyword-driven automation framework built on top of Playwright and TypeScript.",
5
5
  "keywords": [
6
6
  "playwright",