@flash-ai-team/flash-test-framework 0.0.14 → 0.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,6 +18,8 @@ Then you can use:
18
18
 
19
19
  ```bash
20
20
  flash-test init <project-name>
21
+ flash-test run <suite-name> [-browser <browser>]
22
+ flash-test latest-run <suite-name>
21
23
  flash-test history [suite-name]
22
24
  flash-test --version
23
25
  ```
@@ -25,6 +27,11 @@ flash-test --version
25
27
  ### Commands
26
28
 
27
29
  * `init <project-name>`: Initialize a new project structure.
30
+ * `run <suite-name>`: Run a specific test suite.
31
+ * Example: `flash-test run parallel_suite`
32
+ * Example: `flash-test run parallel_suite -browser firefox`
33
+ * `latest-run <suite-name>`: View the status of the most recent run for a suite.
34
+ * Example: `flash-test latest-run parallel_suite`
28
35
  * `history [suite-name]`: View historical test runs and open the HTML dashboard.
29
36
  * Example: `flash-test history` (All suites)
30
37
  * Example: `flash-test history parallel_suite` (Specific suite)
package/dist/cli/index.js CHANGED
@@ -20,8 +20,17 @@ else if (command === 'history') {
20
20
  const suiteName = args[1];
21
21
  showHistory(suiteName);
22
22
  }
23
+ else if (command === 'run') {
24
+ runSuite(args.slice(1));
25
+ }
26
+ else if (command === 'latest-run') {
27
+ const suiteName = args[1];
28
+ showLatestRun(suiteName);
29
+ }
23
30
  else {
24
31
  console.log('Usage: flash-test init [project-name]');
32
+ console.log(' flash-test run <suite-name> [-browser <browser-name>]');
33
+ console.log(' flash-test latest-run <suite-name>');
25
34
  console.log(' flash-test history [suite-name]');
26
35
  console.log(' flash-test -v / --version');
27
36
  process.exit(1);
@@ -336,3 +345,93 @@ function generateHistoryHtml(runs, reportsDir) {
336
345
  console.log('Could not auto-open browser. Please open the file manually.');
337
346
  }
338
347
  }
348
+ function runSuite(args) {
349
+ const suiteName = args[0];
350
+ if (!suiteName) {
351
+ console.error('Error: Suite name is required.');
352
+ console.log('Usage: flash-test run <suite-name> [-browser <browser-name>]');
353
+ process.exit(1);
354
+ }
355
+ const suitePath = `tests/suites/${suiteName}.spec.ts`;
356
+ if (!fs_1.default.existsSync(path_1.default.join(process.cwd(), suitePath))) {
357
+ console.error(`Error: Suite file not found at ${suitePath}`);
358
+ process.exit(1);
359
+ }
360
+ let cmd = `npx playwright test ${suitePath}`;
361
+ // Check for -browser flag
362
+ const browserIndex = args.indexOf('-browser');
363
+ if (browserIndex !== -1 && args[browserIndex + 1]) {
364
+ const browser = args[browserIndex + 1];
365
+ cmd += ` --project=${browser}`;
366
+ }
367
+ console.log(`Executing: ${cmd}`);
368
+ try {
369
+ (0, child_process_1.execSync)(cmd, { stdio: 'inherit' });
370
+ }
371
+ catch (e) {
372
+ process.exit(1);
373
+ }
374
+ }
375
+ function showLatestRun(suiteName) {
376
+ if (!suiteName) {
377
+ console.error('Error: Suite name is required for latest-run.');
378
+ process.exit(1);
379
+ }
380
+ const reportsDir = path_1.default.join(process.cwd(), 'reports', suiteName);
381
+ if (!fs_1.default.existsSync(reportsDir)) {
382
+ console.log(`No reports found for suite: ${suiteName}`);
383
+ return;
384
+ }
385
+ // Find latest timestamp folder
386
+ const runs = fs_1.default.readdirSync(reportsDir)
387
+ .filter(f => f.startsWith('test_') && fs_1.default.statSync(path_1.default.join(reportsDir, f)).isDirectory())
388
+ .sort((a, b) => b.localeCompare(a)); // Reverse sort to get newest first
389
+ if (runs.length === 0) {
390
+ console.log(`No runs found for suite: ${suiteName}`);
391
+ return;
392
+ }
393
+ const latestRun = runs[0];
394
+ const runPath = path_1.default.join(reportsDir, latestRun);
395
+ const jsonPath = path_1.default.join(runPath, 'custom-report.json');
396
+ console.log(`\nLatest Run: ${suiteName}`);
397
+ console.log(`Timestamp: ${latestRun.replace('test_', '')}`);
398
+ console.log(`Report Path: ${runPath}`);
399
+ console.log('----------------------------------------');
400
+ if (fs_1.default.existsSync(jsonPath)) {
401
+ try {
402
+ const data = JSON.parse(fs_1.default.readFileSync(jsonPath, 'utf-8'));
403
+ let status = 'Unknown';
404
+ let passed = 0;
405
+ let failed = 0;
406
+ let duration = 0;
407
+ if (Array.isArray(data)) {
408
+ passed = data.filter((t) => t.status === 'passed').length;
409
+ failed = data.filter((t) => t.status === 'failed' || t.status === 'timedOut').length;
410
+ duration = data.reduce((acc, curr) => acc + (curr.duration || 0), 0);
411
+ status = failed > 0 ? 'Failed' : (passed > 0 ? 'Passed' : 'No Tests');
412
+ }
413
+ else if (data.stats) {
414
+ passed = data.stats.passed;
415
+ failed = data.stats.failed;
416
+ duration = data.stats.duration;
417
+ status = failed > 0 ? 'Failed' : 'Passed';
418
+ }
419
+ const colorFn = status === 'Failed' ? '\x1b[31m' : '\x1b[32m'; // Red or Green
420
+ const reset = '\x1b[0m';
421
+ console.log(`Status: ${colorFn}${status}${reset}`);
422
+ console.log(`Passed: ${passed}`);
423
+ console.log(`Failed: ${failed}`);
424
+ console.log(`Duration: ${(duration / 1000).toFixed(2)}s`);
425
+ if (failed > 0) {
426
+ console.log(`\n${colorFn}Errors detected! Check report for details.${reset}`);
427
+ }
428
+ }
429
+ catch (e) {
430
+ console.error('Error parsing report data.');
431
+ }
432
+ }
433
+ else {
434
+ console.log('Report data not found.');
435
+ }
436
+ console.log('');
437
+ }
@@ -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
  }
@@ -104,14 +104,19 @@ class HtmlReporter {
104
104
  fs.mkdirSync(reportDir, { recursive: true });
105
105
  }
106
106
  // Helper to copy attachment
107
- const copyAttachment = (name, matchFn) => {
107
+ const copyAttachment = (name, subDir, matchFn) => {
108
108
  const attachment = result.attachments.find(matchFn);
109
109
  if (attachment && attachment.path && reportDir) {
110
+ const targetDir = path.join(reportDir, subDir);
111
+ if (!fs.existsSync(targetDir)) {
112
+ fs.mkdirSync(targetDir, { recursive: true });
113
+ }
110
114
  const fileName = `${name}_${test.title.replace(/[^a-z0-9]/gi, '_')}_${Date.now()}${path.extname(attachment.path)}`;
111
- const destPath = path.join(reportDir, fileName);
115
+ const destPath = path.join(targetDir, fileName);
112
116
  try {
113
117
  fs.copyFileSync(attachment.path, destPath);
114
- return fileName;
118
+ // Return relative path including subfolder
119
+ return `${subDir}/${fileName}`;
115
120
  }
116
121
  catch (e) {
117
122
  console.error(`Failed to copy ${name} attachment`, e);
@@ -120,10 +125,22 @@ class HtmlReporter {
120
125
  return undefined;
121
126
  };
122
127
  // Handle Video
123
- const videoRelativePath = copyAttachment('video', a => a.name === 'video' && !!a.path);
128
+ const videoRelativePath = copyAttachment('video', 'videos', a => a.name === 'video' && !!a.path);
124
129
  // 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 });
130
+ const screenshotRelativePath = copyAttachment('screenshot', 'screenshots', a => (a.name === 'screenshot' || a.contentType.startsWith('image/')) && !!a.path);
131
+ // Capture Times
132
+ const start = result.startTime instanceof Date ? result.startTime : new Date(result.startTime);
133
+ const startTime = start.toLocaleTimeString();
134
+ const endTime = new Date(start.getTime() + result.duration).toLocaleTimeString();
135
+ this.testResults.push({
136
+ test,
137
+ result,
138
+ suiteName,
139
+ videoPath: videoRelativePath,
140
+ screenshotPath: screenshotRelativePath,
141
+ startTime,
142
+ endTime
143
+ });
127
144
  }
128
145
  ansiToHtml(text) {
129
146
  if (!text)
@@ -214,6 +231,44 @@ class HtmlReporter {
214
231
  });
215
232
  }
216
233
  generateHtml() {
234
+ // Load Config
235
+ let config = { theme: 'dark', language: 'en' };
236
+ try {
237
+ const configPath = path.join(process.cwd(), 'report.config.json');
238
+ if (fs.existsSync(configPath)) {
239
+ config = { ...config, ...JSON.parse(fs.readFileSync(configPath, 'utf-8')) };
240
+ }
241
+ }
242
+ catch (e) { /* ignore */ }
243
+ // Translations
244
+ const translations = {
245
+ en: {
246
+ tests: 'TESTS', passed: 'Passed', failed: 'Failed', skipped: 'Skipped',
247
+ all: 'All', failures: 'Failures', searchTests: 'Search tests...',
248
+ executionSteps: 'Execution Steps', errorDetails: 'Error Details', artifacts: 'Artifacts',
249
+ screenshot: 'Screenshot', video: 'Video', goToArtifacts: 'Go to Artifacts',
250
+ openRawTab: 'Open Raw Tab', copy: 'Copy', start: 'Start', end: 'End', duration: 'Duration',
251
+ user: 'User', os: 'OS', ip: 'IP', beforeHooks: 'Before Hooks', afterHooks: 'After Hooks', workerCleanup: 'Worker Cleanup'
252
+ },
253
+ fr: {
254
+ tests: 'TESTS', passed: 'Réussi', failed: 'Échoué', skipped: 'Ignoré',
255
+ all: 'Tous', failures: 'Échecs', searchTests: 'Rechercher...',
256
+ executionSteps: "Étapes d'exécution", errorDetails: "Détails de l'erreur", artifacts: 'Artéfacts',
257
+ screenshot: "Capture d'écran", video: 'Vidéo', goToArtifacts: 'Voir les artéfacts',
258
+ openRawTab: 'Ouvrir onglet brut', copy: 'Copier', start: 'Début', end: 'Fin', duration: 'Durée',
259
+ user: 'Utilisateur', os: 'Système', ip: 'IP', beforeHooks: 'Hooks Avant', afterHooks: 'Hooks Après', workerCleanup: 'Nettoyage'
260
+ },
261
+ zh: {
262
+ tests: '测试', passed: '通过', failed: '失败', skipped: '跳过',
263
+ all: '全部', failures: '失败项', searchTests: '搜索测试...',
264
+ executionSteps: '执行步骤', errorDetails: '错误详情', artifacts: '产物',
265
+ screenshot: '截图', video: '视频', goToArtifacts: '查看产物',
266
+ openRawTab: '打开原始标签', copy: '复制', start: '开始', end: '结束', duration: '耗时',
267
+ user: '用户', os: '系统', ip: 'IP地址', beforeHooks: '前置钩子', afterHooks: '后置钩子', workerCleanup: '清理'
268
+ }
269
+ };
270
+ const lang = config.language && translations[config.language] ? config.language : 'en';
271
+ const t = translations[lang];
217
272
  const totalTests = this.testResults.length;
218
273
  const passedTests = this.testResults.filter(r => r.result.status === 'passed').length;
219
274
  const failedTests = this.testResults.filter(r => r.result.status === 'failed' || r.result.status === 'timedOut').length;
@@ -228,6 +283,8 @@ class HtmlReporter {
228
283
  title: item.test.title,
229
284
  status: item.result.status,
230
285
  duration: item.result.duration,
286
+ startTime: item.startTime,
287
+ endTime: item.endTime,
231
288
  steps: this.processSteps(item.result.steps),
232
289
  error: item.result.error ? this.ansiToHtml(item.result.error.stack || item.result.error.message || '') : undefined,
233
290
  screenshot: item.screenshotPath,
@@ -242,6 +299,7 @@ class HtmlReporter {
242
299
  <title>Test Result</title>
243
300
  <style>
244
301
  :root {
302
+ /* Theme: ${config.theme} (Default) */
245
303
  --bg-color: #1e1e1e;
246
304
  --sidebar-bg: #252526;
247
305
  --text-color: #d4d4d4;
@@ -252,6 +310,129 @@ class HtmlReporter {
252
310
  --skip-color: #ff9800;
253
311
  --blue-accent: #007acc;
254
312
  --code-bg: #0d1117;
313
+
314
+ /* Detailed Colors */
315
+ --card-bg: #252526;
316
+ --header-bg: #2d2d2d;
317
+ --text-secondary: #888;
318
+ --border-dim: #333;
319
+ --step-hover: #2a2d2e;
320
+ --input-bg: #1e1e1e;
321
+ --input-text: #fff;
322
+ --code-text: #ccc;
323
+ --info-box-bg: #2a2d2e;
324
+ }
325
+
326
+ /* Theme Definitions */
327
+ [data-theme="light"] {
328
+ --bg-color: #ffffff;
329
+ --sidebar-bg: #f3f3f3;
330
+ --text-color: #333333;
331
+ --border-color: #e5e5e5;
332
+ --hover-color: #e8e8e8;
333
+ --success-color: #2e7d32;
334
+ --fail-color: #d32f2f;
335
+ --skip-color: #ed6c02;
336
+ --blue-accent: #1976d2;
337
+ --code-bg: #f5f5f5;
338
+
339
+ --card-bg: #ffffff;
340
+ --header-bg: #f5f5f5;
341
+ --text-secondary: #666;
342
+ --border-dim: #eee;
343
+ --step-hover: #f0f0f0;
344
+ --input-bg: #ffffff;
345
+ --input-text: #333;
346
+ --code-text: #333;
347
+ --info-box-bg: #f5f5f5;
348
+ }
349
+ [data-theme="muted-light"] {
350
+ --bg-color: #f5f3ef;
351
+ --sidebar-bg: #ebe8e3;
352
+ --text-color: #4a4540;
353
+ --border-color: #d9d5cf;
354
+ --hover-color: #e5e2dc;
355
+ --success-color: #558b2f;
356
+ --fail-color: #c62828;
357
+ --skip-color: #e65100;
358
+ --blue-accent: #1565c0;
359
+ --code-bg: #e8e5e0;
360
+
361
+ --card-bg: #faf8f5;
362
+ --header-bg: #ebe8e3;
363
+ --text-secondary: #7a756e;
364
+ --border-dim: #d9d5cf;
365
+ --step-hover: #e5e2dc;
366
+ --input-bg: #faf8f5;
367
+ --input-text: #4a4540;
368
+ --code-text: #4a4540;
369
+ --info-box-bg: #ebe8e3;
370
+ }
371
+ [data-theme="midnight"] {
372
+ --bg-color: #0f172a;
373
+ --sidebar-bg: #1e293b;
374
+ --text-color: #e2e8f0;
375
+ --border-color: #334155;
376
+ --hover-color: #334155;
377
+ --success-color: #22c55e;
378
+ --fail-color: #ef4444;
379
+ --skip-color: #f59e0b;
380
+ --blue-accent: #6366f1;
381
+ --code-bg: #1e293b;
382
+
383
+ --card-bg: #1e293b;
384
+ --header-bg: #334155;
385
+ --text-secondary: #94a3b8;
386
+ --border-dim: #334155;
387
+ --step-hover: #334155;
388
+ --input-bg: #0f172a;
389
+ --input-text: #e2e8f0;
390
+ --code-text: #e2e8f0;
391
+ --info-box-bg: #1e293b;
392
+ }
393
+ [data-theme="forest"] {
394
+ --bg-color: #1a2f1a;
395
+ --sidebar-bg: #243d24;
396
+ --text-color: #e8f5e9;
397
+ --border-color: #2e4d2e;
398
+ --hover-color: #2e4d2e;
399
+ --success-color: #66bb6a;
400
+ --fail-color: #ef5350;
401
+ --skip-color: #ffa726;
402
+ --blue-accent: #4caf50;
403
+ --code-bg: #1b3320;
404
+
405
+ --card-bg: #243d24;
406
+ --header-bg: #2e4d2e;
407
+ --text-secondary: #a5d6a7;
408
+ --border-dim: #2e4d2e;
409
+ --step-hover: #2e4d2e;
410
+ --input-bg: #1a2f1a;
411
+ --input-text: #e8f5e9;
412
+ --code-text: #e8f5e9;
413
+ --info-box-bg: #243d24;
414
+ }
415
+ [data-theme="dracula"] {
416
+ --bg-color: #282a36;
417
+ --sidebar-bg: #44475a;
418
+ --text-color: #f8f8f2;
419
+ --border-color: #6272a4;
420
+ --hover-color: #44475a;
421
+ --success-color: #50fa7b;
422
+ --fail-color: #ff5555;
423
+ --skip-color: #ffb86c;
424
+ --blue-accent: #bd93f9;
425
+ --code-bg: #282a36;
426
+
427
+ --card-bg: #44475a;
428
+ --header-bg: #6272a4;
429
+ --text-secondary: #bd93f9;
430
+ --border-dim: #6272a4;
431
+ --step-hover: #6272a4;
432
+ --input-bg: #282a36;
433
+ --input-text: #f8f8f2;
434
+ --code-text: #f8f8f2;
435
+ --info-box-bg: #44475a;
255
436
  }
256
437
  body {
257
438
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
@@ -306,7 +487,7 @@ class HtmlReporter {
306
487
  justify-content: center;
307
488
  }
308
489
  .total-text { font-size: 1.5em; font-weight: bold; }
309
- .total-label { font-size: 0.8em; color: #888; }
490
+ .total-label { font-size: 0.8em; color: var(--text-secondary); }
310
491
 
311
492
  .stats-row {
312
493
  display: flex;
@@ -336,9 +517,9 @@ class HtmlReporter {
336
517
  }
337
518
  .filter-btn {
338
519
  flex: 1;
339
- background: #333;
520
+ background: var(--card-bg);
340
521
  border: 1px solid var(--border-color);
341
- color: #ccc;
522
+ color: var(--text-color);
342
523
  padding: 5px;
343
524
  cursor: pointer;
344
525
  font-size: 0.85em;
@@ -352,9 +533,9 @@ class HtmlReporter {
352
533
  .search-box {
353
534
  width: 100%;
354
535
  padding: 10px;
355
- background-color: #1e1e1e;
536
+ background-color: var(--input-bg);
356
537
  border: 1px solid var(--border-color);
357
- color: white;
538
+ color: var(--input-text);
358
539
  border-radius: 4px;
359
540
  box-sizing: border-box;
360
541
  margin-top: 10px;
@@ -401,7 +582,7 @@ class HtmlReporter {
401
582
  .detail-header-wrapper {
402
583
  padding: 20px 30px;
403
584
  border-bottom: 1px solid var(--border-color);
404
- background-color: #252526;
585
+ background-color: var(--card-bg);
405
586
  }
406
587
  .detail-title { font-size: 1.6em; margin: 10px 0; font-weight: 300; }
407
588
  .detail-badges { display: flex; gap: 10px; align-items: center; }
@@ -423,7 +604,7 @@ class HtmlReporter {
423
604
  }
424
605
 
425
606
  .section-card {
426
- background-color: #252526;
607
+ background-color: var(--card-bg);
427
608
  border: 1px solid var(--border-color);
428
609
  border-radius: 6px;
429
610
  margin-bottom: 25px;
@@ -431,7 +612,7 @@ class HtmlReporter {
431
612
  }
432
613
  .section-header {
433
614
  padding: 10px 15px;
434
- background-color: #2d2d2d;
615
+ background-color: var(--header-bg);
435
616
  border-bottom: 1px solid var(--border-color);
436
617
  font-weight: 600;
437
618
  color: #bbb;
@@ -443,7 +624,7 @@ class HtmlReporter {
443
624
  /* Steps */
444
625
  .step-row {
445
626
  padding: 8px 15px;
446
- border-bottom: 1px solid #333;
627
+ border-bottom: 1px solid var(--border-dim);
447
628
  display: flex;
448
629
  gap: 10px;
449
630
  align-items: flex-start;
@@ -452,10 +633,10 @@ class HtmlReporter {
452
633
  transition: background-color 0.2s;
453
634
  }
454
635
  .step-row:last-child { border-bottom: none; }
455
- .step-row:hover { background-color: #2a2d2e; }
636
+ .step-row:hover { background-color: var(--step-hover); }
456
637
  .step-status { margin-top: 3px; font-size: 1.2em; line-height: 1; }
457
638
  .step-content { flex: 1; }
458
- .step-meta { color: #666; font-size: 0.9em; margin-left: 10px; white-space: nowrap; }
639
+ .step-meta { color: var(--text-secondary); font-size: 0.9em; margin-left: 10px; white-space: nowrap; }
459
640
 
460
641
  /* Tree Toggle */
461
642
  .toggle-icon {
@@ -489,7 +670,7 @@ class HtmlReporter {
489
670
  padding: 15px;
490
671
  border-radius: 4px;
491
672
  font-family: Consolas, monospace;
492
- color: #d4d4d4;
673
+ color: var(--code-text);
493
674
  white-space: pre-wrap;
494
675
  overflow-x: auto;
495
676
  border: 1px solid #30363d;
@@ -498,12 +679,12 @@ class HtmlReporter {
498
679
  /* Code Snippet */
499
680
  .code-snippet {
500
681
  margin-top: 10px;
501
- background-color: #000;
682
+ background-color: var(--code-bg);
502
683
  border-radius: 4px;
503
684
  padding: 10px;
504
685
  font-family: Consolas, monospace;
505
686
  font-size: 0.9em;
506
- color: #ccc;
687
+ color: var(--code-text);
507
688
  white-space: pre;
508
689
  overflow-x: auto;
509
690
  border-left: 3px solid #ffcc00;
@@ -517,14 +698,14 @@ class HtmlReporter {
517
698
  padding: 20px;
518
699
  }
519
700
  .media-item {
520
- background-color: #000;
701
+ background-color: var(--card-bg);
521
702
  border-radius: 4px;
522
703
  border: 1px solid var(--border-color);
523
704
  overflow: hidden;
524
705
  }
525
706
  .media-header {
526
707
  padding: 8px 12px;
527
- background-color: #333;
708
+ background-color: var(--header-bg);
528
709
  color: #ccc;
529
710
  font-size: 0.85em;
530
711
  border-bottom: 1px solid #444;
@@ -547,43 +728,53 @@ class HtmlReporter {
547
728
  <body>
548
729
  <div class="sidebar">
549
730
  <div class="sidebar-header">
731
+ <div style="width:100%; margin-bottom:15px; display:flex; justify-content:flex-end">
732
+ <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">
733
+ <option value="dark">Dark</option>
734
+ <option value="light">Light</option>
735
+ <option value="muted-light">Muted Light</option>
736
+ <option value="midnight">Midnight</option>
737
+ <option value="forest">Forest</option>
738
+ <option value="dracula">Dracula</option>
739
+ </select>
740
+ </div>
550
741
  <div class="chart-container">
551
742
  <div class="chart-inner">
552
743
  <div class="total-text">${totalTests}</div>
553
- <div class="total-label">TESTS</div>
744
+ <div class="total-label">${t.tests}</div>
554
745
  </div>
555
746
  </div>
556
747
 
557
748
  <div class="stats-row">
558
749
  <div class="stat-item" onclick="setFilter('passed')" title="Show Passed">
559
750
  <span class="stat-value" style="color:var(--success-color)">${passedTests}</span>
560
- <span class="stat-label">Passed</span>
751
+ <span class="stat-label">${t.passed}</span>
561
752
  </div>
562
753
  <div class="stat-item" onclick="setFilter('failed')" title="Show Failed">
563
754
  <span class="stat-value" style="color:var(--fail-color)">${failedTests}</span>
564
- <span class="stat-label">Failed</span>
755
+ <span class="stat-label">${t.failed}</span>
565
756
  </div>
566
757
  <div class="stat-item" onclick="setFilter('skipped')" title="Show Skipped">
567
758
  <span class="stat-value" style="color:var(--skip-color)">${skippedTests}</span>
568
- <span class="stat-label">Skipped</span>
759
+ <span class="stat-label">${t.skipped}</span>
569
760
  </div>
570
761
  </div>
571
762
 
572
763
  <div style="margin-top:15px; font-size:0.8em; color:#666; text-align:center" id="reportTimestamp"></div>
573
764
 
574
765
  <!-- 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>
766
+ <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)">
767
+ <div style="margin-bottom:5px"><strong style="color:var(--text-secondary)">${t.user}:</strong> <span id="envUser">...</span></div>
768
+ <div style="margin-bottom:5px"><strong style="color:var(--text-secondary)">${t.os}:</strong> <span id="envOs">...</span></div>
769
+ <div><strong style="color:var(--text-secondary)">${t.ip}:</strong> <span id="envIp">...</span></div>
579
770
  </div>
580
771
 
581
772
  <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>
773
+ <button class="filter-btn active" onclick="setFilter('all')">${t.all}</button>
774
+ <button class="filter-btn" onclick="setFilter('failed')">${t.failures}</button>
584
775
  </div>
585
776
 
586
- <input type="text" class="search-box" placeholder="Search tests..." id="searchInput" onkeyup="filterTests()">
777
+ <input type="text" class="search-box" placeholder="${t.searchTests}" id="searchInput" onkeyup="filterTests()">
587
778
  </div>
588
779
  <div class="test-list" id="testList"></div>
589
780
  </div>
@@ -601,6 +792,7 @@ class HtmlReporter {
601
792
  const data = ${resultsData};
602
793
  const startTime = ${this.suiteStartTime};
603
794
  const envInfo = ${JSON.stringify(this.envInfo)};
795
+ const t = ${JSON.stringify(t)};
604
796
  let currentId = null;
605
797
  let currentFilter = 'all';
606
798
 
@@ -713,19 +905,23 @@ class HtmlReporter {
713
905
  const statusClass = test.status === 'passed' ? 'badge-passed' :
714
906
  (test.status === 'failed' || test.status === 'timedOut') ? 'badge-failed' : 'badge-skipped';
715
907
 
908
+ // Clean error message (remove Call log)
909
+ const cleanError = test.error ? test.error.split('Call log:')[0].trim() : '';
910
+
716
911
  // Steps (using tree renderer)
717
912
  const stepsHtml = test.steps.map(generateStepTreeHtml).join('');
718
913
 
719
914
  // Media
915
+ const hasArtifacts = test.screenshot || test.video;
720
916
  const mediaHtml = \`
721
917
  \${test.screenshot ? \`
722
918
  <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>
919
+ <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
920
  <img src="\${test.screenshot}" onclick="window.open(this.src)">
725
921
  </div>\` : ''}
726
922
  \${test.video ? \`
727
923
  <div class="media-item">
728
- <div class="media-header">Video</div>
924
+ <div class="media-header">\${t.video}</div>
729
925
  <video src="\${test.video}" controls></video>
730
926
  </div>\` : ''}
731
927
  \`;
@@ -735,31 +931,36 @@ class HtmlReporter {
735
931
  <div class="detail-meta">
736
932
  <span class="badge \${statusClass}">\${test.status}</span>
737
933
  <span>\${test.suite}</span>
738
- <span style="margin-left:auto">Duration: \${test.duration}ms</span>
934
+ \${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
935
  </div>
740
936
  <div class="detail-title">\${test.title}</div>
937
+ <div class="detail-timing" style="color:#888; font-size:0.9em; margin-top:5px">
938
+ <span style="margin-right:15px">\${t.start}: \${test.startTime}</span>
939
+ <span style="margin-right:15px">\${t.end}: \${test.endTime}</span>
940
+ <span>\${t.duration}: \${test.duration}ms</span>
941
+ </div>
741
942
  </div>
742
943
 
743
944
  <div class="detail-scroll-area">
744
- \${test.error ? \`
945
+ \${cleanError ? \`
745
946
  <div class="section-card error-box">
746
947
  <div style="display:flex; justify-content:space-between; margin-bottom:10px">
747
- <strong style="color:var(--fail-color)">Error Details</strong>
948
+ <strong style="color:var(--fail-color)">\${t.errorDetails}</strong>
748
949
  </div>
749
- <div class="stack-trace">\${test.error}</div>
950
+ <div class="stack-trace">\${cleanError}</div>
750
951
  </div>
751
952
  \` : ''}
752
953
 
753
954
  <div class="section-card">
754
- <div class="section-header">Execution Steps</div>
955
+ <div class="section-header">\${t.executionSteps}</div>
755
956
  <div class="steps-tree-container">
756
957
  \${stepsHtml}
757
958
  </div>
758
959
  </div>
759
960
 
760
- \${test.screenshot || test.video ? \`
761
- <div class="section-card">
762
- <div class="section-header">Artifacts</div>
961
+ \${hasArtifacts ? \`
962
+ <div id="artifacts-section" class="section-card">
963
+ <div class="section-header">\${t.artifacts}</div>
763
964
  <div class="media-grid">\${mediaHtml}</div>
764
965
  </div>
765
966
  \` : ''}
@@ -768,6 +969,22 @@ class HtmlReporter {
768
969
  content.classList.add('active');
769
970
  }
770
971
 
972
+ function setTheme(themeName) {
973
+ document.documentElement.setAttribute('data-theme', themeName);
974
+ localStorage.setItem('report-theme', themeName);
975
+ }
976
+
977
+ // Initialize Theme
978
+ // We prioritize the theme from report.config.json if it's set.
979
+ // This ensures the report respects the project configuration.
980
+ const configTheme = '${config.theme}' || 'dark';
981
+
982
+ // On initial load, we follow the config.
983
+ // However, we can check if there was a MANUALLY set preference in this session.
984
+ // For now, let's just respect the config theme as requested.
985
+ setTheme(configTheme);
986
+ document.getElementById('themeSelector').value = configTheme;
987
+
771
988
  // Initial render
772
989
  renderList(data);
773
990
  </script>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flash-ai-team/flash-test-framework",
3
- "version": "0.0.14",
3
+ "version": "0.0.16",
4
4
  "description": "A powerful keyword-driven automation framework built on top of Playwright and TypeScript.",
5
5
  "keywords": [
6
6
  "playwright",