@flash-ai-team/flash-test-framework 0.0.9 → 0.0.11

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
@@ -2,22 +2,53 @@
2
2
 
3
3
  A powerful, keyword-driven automation framework built on top of Playwright and TypeScript, featuring AI-powered capabilities.
4
4
 
5
- ## Installation
5
+ ## Quick Start
6
+
7
+ The easiest way to start a new project is using our CLI.
8
+
9
+ ### Global Installation
10
+
11
+ Alternatively, you can install the CLI globally to run `flash-test` commands directly.
6
12
 
7
13
  ```bash
8
- npm install @flash-ai-team/flash-test-framework
14
+ npm install -g @flash-ai-team/flash-test-framework
9
15
  ```
10
16
 
11
- ## Setup
17
+ Then you can use:
12
18
 
13
- 1. **Initialize Playwright** (if not already done):
19
+ ```bash
20
+ flash-test init <project-name>
21
+ flash-test --version
22
+ ```
23
+
24
+ 1. **Initialize a new project**:
25
+ Make a new folder and run:
14
26
  ```bash
15
- npx playwright install
27
+ flash-test init <project-name>
16
28
  ```
29
+ This will create:
30
+ * `package.json` with dependencies.
31
+ * `playwright.config.ts` configured for the framework.
32
+ * `tsconfig.json`.
33
+ * `tests/cases/ExampleTests.ts` and `tests/suites/Example.spec.ts`.
34
+
35
+ 2. **Install dependencies**:
36
+ ```bash
37
+ npm install
38
+ ```
39
+
40
+ 4. **Run the test**:
41
+ ```bash
42
+ npx playwright test
43
+ ```
44
+
45
+ ## Manual Setup (Optional)
17
46
 
18
- 2. **Configuration**:
19
- * **Email Reporting**: Create `email.config.json` in your project root if you want email reports.
20
- * **AI Features**: Create `ai.config.json` with your OpenAI API key to use `AIWeb`.
47
+ If you prefer to set up manually:
48
+ 1. `npm install @flash-ai-team/flash-test-framework`
49
+ 2. Create `playwright.config.ts` and `tsconfig.json`.
50
+ 3. **Email Reporting**: Create `email.config.json`.
51
+ 4. **AI Features**: Create `ai.config.json`:
21
52
  ```json
22
53
  {
23
54
  "enabled": true,
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __importDefault = (this && this.__importDefault) || function (mod) {
4
+ return (mod && mod.__esModule) ? mod : { "default": mod };
5
+ };
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const args = process.argv.slice(2);
10
+ const command = args[0];
11
+ if (command === 'init') {
12
+ const projectName = args[1];
13
+ initProject(projectName);
14
+ }
15
+ else if (command === '--version' || command === '-v') {
16
+ printVersion();
17
+ }
18
+ else {
19
+ console.log('Usage: flash-test init [project-name]');
20
+ console.log(' flash-test -v / --version');
21
+ process.exit(1);
22
+ }
23
+ function printVersion() {
24
+ try {
25
+ const packageJsonPath = path_1.default.join(__dirname, '..', '..', 'package.json');
26
+ const packageJson = JSON.parse(fs_1.default.readFileSync(packageJsonPath, 'utf-8'));
27
+ console.log(`v${packageJson.version}`);
28
+ }
29
+ catch (error) {
30
+ console.error('Error reading version:', error);
31
+ process.exit(1);
32
+ }
33
+ }
34
+ function initProject(projectName) {
35
+ console.log('Initializing Flash Test Project...');
36
+ let projectDir = process.cwd();
37
+ let name = "my-flash-test-project";
38
+ if (projectName) {
39
+ projectDir = path_1.default.join(process.cwd(), projectName);
40
+ name = projectName;
41
+ if (!fs_1.default.existsSync(projectDir)) {
42
+ fs_1.default.mkdirSync(projectDir);
43
+ console.log(`Created directory: ${projectName}`);
44
+ }
45
+ }
46
+ // 1. Create Files
47
+ createFile(path_1.default.join(projectDir, 'package.json'), JSON.stringify({
48
+ "name": name,
49
+ "version": "1.0.0",
50
+ "scripts": {
51
+ "test": "playwright test"
52
+ },
53
+ "devDependencies": {
54
+ "@flash-ai-team/flash-test-framework": "latest",
55
+ "@playwright/test": "^1.57.0",
56
+ "typescript": "^5.9.3"
57
+ }
58
+ }, null, 2));
59
+ createFile(path_1.default.join(projectDir, 'playwright.config.ts'), `
60
+ import { defineConfig, devices } from '@playwright/test';
61
+
62
+ export default defineConfig({
63
+ testDir: './tests',
64
+ fullyParallel: true,
65
+ forbidOnly: !!process.env.CI,
66
+ retries: process.env.CI ? 2 : 0,
67
+ workers: process.env.CI ? 1 : undefined,
68
+ reporter: [
69
+ ['html', { open: 'never' }],
70
+ ['list'], // Keep 'list' for console output
71
+ [require.resolve('@flash-ai-team/flash-test-framework/dist/reporting/CustomReporter.js')],
72
+ [require.resolve('@flash-ai-team/flash-test-framework/dist/reporting/HtmlReporter.js')]
73
+ ],
74
+ use: {
75
+ trace: 'on-first-retry',
76
+ },
77
+ projects: [
78
+ {
79
+ name: 'chrome',
80
+ use: { ...devices['Desktop Chrome'] },
81
+ },
82
+ ],
83
+ });
84
+ `);
85
+ createFile(path_1.default.join(projectDir, 'tsconfig.json'), JSON.stringify({
86
+ "compilerOptions": {
87
+ "target": "es2016",
88
+ "module": "commonjs",
89
+ "esModuleInterop": true,
90
+ "forceConsistentCasingInFileNames": true,
91
+ "strict": true,
92
+ "skipLibCheck": true
93
+ }
94
+ }, null, 2));
95
+ createFile(path_1.default.join(projectDir, '.gitignore'), `
96
+ node_modules/
97
+ test-results/
98
+ playwright-report/
99
+ dist/
100
+ `);
101
+ const testsDir = path_1.default.join(projectDir, 'tests');
102
+ const casesDir = path_1.default.join(testsDir, 'cases');
103
+ const suitesDir = path_1.default.join(testsDir, 'suites');
104
+ if (!fs_1.default.existsSync(testsDir))
105
+ fs_1.default.mkdirSync(testsDir);
106
+ if (!fs_1.default.existsSync(casesDir))
107
+ fs_1.default.mkdirSync(casesDir);
108
+ if (!fs_1.default.existsSync(suitesDir))
109
+ fs_1.default.mkdirSync(suitesDir);
110
+ createFile(path_1.default.join(casesDir, 'ExampleTests.ts'), `
111
+ import { Web, el, TestCase } from '@flash-ai-team/flash-test-framework';
112
+
113
+ export class ExampleTests {
114
+
115
+ @TestCase
116
+ static async BasicVerify() {
117
+ await Web.navigateToUrl('https://example.com');
118
+ await Web.verifyTextPresent('Example Domain');
119
+
120
+ const header = el('h1', 'Header');
121
+ await Web.verifyElementPresent(header);
122
+ }
123
+ }
124
+ `);
125
+ createFile(path_1.default.join(suitesDir, 'Example.spec.ts'), `
126
+ import { test } from '@flash-ai-team/flash-test-framework';
127
+ import { ExampleTests } from '../cases/ExampleTests';
128
+
129
+ test.describe('Example Suite', () => {
130
+ test('Basic Verification', async () => {
131
+ await ExampleTests.BasicVerify();
132
+ });
133
+ });
134
+ `);
135
+ console.log('Project initialized successfully!');
136
+ if (projectName) {
137
+ console.log(`\nNext steps:\n cd ${projectName}\n npm install`);
138
+ }
139
+ else {
140
+ console.log('Run "npm install" to install dependencies.');
141
+ }
142
+ }
143
+ function createFile(filePath, content) {
144
+ if (fs_1.default.existsSync(filePath)) {
145
+ console.log(`Skipping existing file: ${path_1.default.basename(filePath)}`);
146
+ }
147
+ else {
148
+ fs_1.default.writeFileSync(filePath, content.trim());
149
+ console.log(`Created: ${path_1.default.basename(filePath)}`);
150
+ }
151
+ }
@@ -116,6 +116,12 @@ export declare class Web {
116
116
  * @param timeout - The maximum time to wait in milliseconds (default: 5000).
117
117
  */
118
118
  static verifyElementNotPresent(to: TestObject, timeout?: number): Promise<void>;
119
+ /**
120
+ * Verifies that the specified text is present on the page.
121
+ * @param text - The text to search for.
122
+ * @param timeout - The maximum time to wait in milliseconds (default: 5000).
123
+ */
124
+ static verifyTextPresent(text: string, timeout?: number): Promise<void>;
119
125
  /**
120
126
  * Verifies that the element's text matches the expected text.
121
127
  * @param to - The TestObject representing the element.
@@ -253,6 +253,14 @@ class Web {
253
253
  static async verifyElementNotPresent(to, timeout = 5000) {
254
254
  await (0, test_1.expect)(this.getLocator(to)).toBeHidden({ timeout });
255
255
  }
256
+ /**
257
+ * Verifies that the specified text is present on the page.
258
+ * @param text - The text to search for.
259
+ * @param timeout - The maximum time to wait in milliseconds (default: 5000).
260
+ */
261
+ static async verifyTextPresent(text, timeout = 5000) {
262
+ await (0, test_1.expect)(this.page.locator('body')).toContainText(text, { timeout });
263
+ }
256
264
  /**
257
265
  * Verifies that the element's text matches the expected text.
258
266
  * @param to - The TestObject representing the element.
@@ -506,6 +514,12 @@ __decorate([
506
514
  __metadata("design:paramtypes", [ObjectRepository_1.TestObject, Number]),
507
515
  __metadata("design:returntype", Promise)
508
516
  ], Web, "verifyElementNotPresent", null);
517
+ __decorate([
518
+ (0, Keyword_1.Keyword)("Verify Text Present"),
519
+ __metadata("design:type", Function),
520
+ __metadata("design:paramtypes", [String, Number]),
521
+ __metadata("design:returntype", Promise)
522
+ ], Web, "verifyTextPresent", null);
509
523
  __decorate([
510
524
  (0, Keyword_1.Keyword)("Verify Element Text"),
511
525
  __metadata("design:type", Function),
@@ -2,11 +2,15 @@ import { Reporter, FullConfig, Suite, TestCase, TestResult, FullResult } from '@
2
2
  declare class HtmlReporter implements Reporter {
3
3
  private suiteStartTime;
4
4
  private reportPath;
5
+ private fileCache;
5
6
  onBegin(config: FullConfig, suite: Suite): void;
6
7
  onEnd(result: FullResult): Promise<void>;
7
8
  private testResults;
8
9
  onTestEnd(test: TestCase, result: TestResult): void;
9
10
  private ansiToHtml;
11
+ private getFileContent;
12
+ private getSnippet;
13
+ private processSteps;
10
14
  private generateHtml;
11
15
  }
12
16
  export default HtmlReporter;
@@ -40,6 +40,8 @@ class HtmlReporter {
40
40
  constructor() {
41
41
  this.suiteStartTime = 0;
42
42
  this.reportPath = '';
43
+ // Cache for file contents to avoid repeated reads
44
+ this.fileCache = new Map();
43
45
  // onExit removed as generation is now in onEnd
44
46
  this.testResults = [];
45
47
  }
@@ -126,22 +128,80 @@ class HtmlReporter {
126
128
  html = html.replace(/\u001b\[0m/g, '</span></span></span></span>');
127
129
  return html;
128
130
  }
131
+ // New method to get file content with caching
132
+ getFileContent(filePath) {
133
+ if (this.fileCache.has(filePath)) {
134
+ return this.fileCache.get(filePath);
135
+ }
136
+ try {
137
+ if (fs.existsSync(filePath)) {
138
+ const content = fs.readFileSync(filePath, 'utf-8').split('\n');
139
+ this.fileCache.set(filePath, content);
140
+ return content;
141
+ }
142
+ }
143
+ catch (e) {
144
+ // Ignore errors reading file
145
+ }
146
+ return null;
147
+ }
148
+ // New method to extract code snippet
149
+ getSnippet(location) {
150
+ if (!location || !location.file)
151
+ return undefined;
152
+ const lines = this.getFileContent(location.file);
153
+ if (!lines)
154
+ return undefined;
155
+ const line = location.line - 1; // 0-indexed
156
+ if (line < 0 || line >= lines.length)
157
+ return undefined;
158
+ // Get 2 lines before and 2 lines after
159
+ const start = Math.max(0, line - 2);
160
+ const end = Math.min(lines.length, line + 3);
161
+ let snippet = '';
162
+ const maxLineNumWidth = end.toString().length;
163
+ for (let i = start; i < end; i++) {
164
+ const isErrorLine = i === line;
165
+ const lineNum = (i + 1).toString().padStart(maxLineNumWidth, ' ');
166
+ const prefix = isErrorLine ? '>' : ' ';
167
+ snippet += `${prefix} ${lineNum} | ${lines[i]}\n`;
168
+ }
169
+ return snippet;
170
+ }
171
+ processSteps(steps, depth = 0) {
172
+ return steps.map(step => {
173
+ const hasChildren = step.steps && step.steps.length > 0;
174
+ // Only try to get snippet for failed steps that have a location
175
+ let codeSnippet;
176
+ if (step.error && step.location) {
177
+ codeSnippet = this.getSnippet(step.location);
178
+ }
179
+ return {
180
+ title: step.title,
181
+ duration: step.duration,
182
+ error: step.error ? this.ansiToHtml(step.error.stack || step.error.message || '') : undefined,
183
+ codeSnippet: codeSnippet, // Add codeSnippet here
184
+ depth: depth,
185
+ steps: hasChildren ? this.processSteps(step.steps, depth + 1) : []
186
+ };
187
+ });
188
+ }
129
189
  generateHtml() {
130
190
  const totalTests = this.testResults.length;
131
191
  const passedTests = this.testResults.filter(r => r.result.status === 'passed').length;
132
192
  const failedTests = this.testResults.filter(r => r.result.status === 'failed' || r.result.status === 'timedOut').length;
133
193
  const skippedTests = totalTests - passedTests - failedTests;
194
+ // Calculate percentages for the pie chart
195
+ const passPct = totalTests ? (passedTests / totalTests) * 360 : 0;
196
+ const failPct = totalTests ? (failedTests / totalTests) * 360 : 0;
197
+ const skipPct = totalTests ? (skippedTests / totalTests) * 360 : 0;
134
198
  const resultsData = JSON.stringify(this.testResults.map((item, index) => ({
135
199
  id: index,
136
200
  suite: item.suiteName,
137
201
  title: item.test.title,
138
202
  status: item.result.status,
139
203
  duration: item.result.duration,
140
- steps: item.result.steps.map(step => ({
141
- title: step.title,
142
- duration: step.duration,
143
- error: step.error ? this.ansiToHtml(step.error.stack || step.error.message || '') : undefined
144
- })),
204
+ steps: this.processSteps(item.result.steps),
145
205
  error: item.result.error ? this.ansiToHtml(item.result.error.stack || item.result.error.message || '') : undefined,
146
206
  screenshot: item.screenshotPath,
147
207
  video: item.videoPath
@@ -164,6 +224,7 @@ class HtmlReporter {
164
224
  --fail-color: #f44336;
165
225
  --skip-color: #ff9800;
166
226
  --blue-accent: #007acc;
227
+ --code-bg: #0d1117;
167
228
  }
168
229
  body {
169
230
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
@@ -177,40 +238,99 @@ class HtmlReporter {
177
238
  }
178
239
  /* Sidebar styles */
179
240
  .sidebar {
180
- width: 350px;
241
+ width: 400px;
181
242
  background-color: var(--sidebar-bg);
182
243
  border-right: 1px solid var(--border-color);
183
244
  display: flex;
184
245
  flex-direction: column;
246
+ flex-shrink: 0;
185
247
  }
186
248
  .sidebar-header {
187
- padding: 15px;
249
+ padding: 20px;
188
250
  border-bottom: 1px solid var(--border-color);
251
+ display: flex;
252
+ flex-direction: column;
253
+ align-items: center;
254
+ gap: 15px;
189
255
  }
190
- .stats-container {
256
+ /* Pie Chart */
257
+ .chart-container {
258
+ position: relative;
259
+ width: 120px;
260
+ height: 120px;
261
+ border-radius: 50%;
262
+ background: conic-gradient(
263
+ var(--success-color) 0deg ${passPct}deg,
264
+ var(--fail-color) ${passPct}deg ${passPct + failPct}deg,
265
+ var(--skip-color) ${passPct + failPct}deg 360deg
266
+ );
191
267
  display: flex;
192
- gap: 10px;
193
- margin-bottom: 10px;
268
+ align-items: center;
269
+ justify-content: center;
270
+ }
271
+ .chart-inner {
272
+ width: 90px;
273
+ height: 90px;
274
+ background-color: var(--sidebar-bg);
275
+ border-radius: 50%;
276
+ display: flex;
277
+ flex-direction: column;
278
+ align-items: center;
279
+ justify-content: center;
280
+ }
281
+ .total-text { font-size: 1.5em; font-weight: bold; }
282
+ .total-label { font-size: 0.8em; color: #888; }
283
+
284
+ .stats-row {
285
+ display: flex;
286
+ gap: 15px;
194
287
  font-size: 0.9em;
288
+ width: 100%;
289
+ justify-content: space-around;
195
290
  }
196
291
  .stat-item {
197
292
  display: flex;
293
+ flex-direction: column;
198
294
  align-items: center;
199
- gap: 5px;
295
+ cursor: pointer;
296
+ padding: 5px 10px;
297
+ border-radius: 4px;
298
+ transition: background 0.2s;
200
299
  }
201
- .dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
202
- .passed-dot { background-color: var(--success-color); }
203
- .failed-dot { background-color: var(--fail-color); }
204
- .skipped-dot { background-color: var(--skip-color); }
300
+ .stat-item:hover { background-color: var(--hover-color); }
301
+ .stat-item.active { background-color: #333; box-shadow: 0 0 0 1px var(--border-color); }
302
+ .stat-value { font-weight: bold; font-size: 1.1em; }
303
+ .stat-label { font-size: 0.8em; color: #aaa; }
205
304
 
305
+ .filter-group {
306
+ display: flex;
307
+ gap: 5px;
308
+ width: 100%;
309
+ }
310
+ .filter-btn {
311
+ flex: 1;
312
+ background: #333;
313
+ border: 1px solid var(--border-color);
314
+ color: #ccc;
315
+ padding: 5px;
316
+ cursor: pointer;
317
+ font-size: 0.85em;
318
+ }
319
+ .filter-btn.active {
320
+ background-color: var(--blue-accent);
321
+ color: white;
322
+ border-color: var(--blue-accent);
323
+ }
324
+
206
325
  .search-box {
207
326
  width: 100%;
208
- padding: 8px;
209
- background-color: #3c3c3c;
327
+ padding: 10px;
328
+ background-color: #1e1e1e;
210
329
  border: 1px solid var(--border-color);
211
330
  color: white;
212
331
  border-radius: 4px;
213
332
  box-sizing: border-box;
333
+ margin-top: 10px;
214
334
  }
215
335
 
216
336
  .test-list {
@@ -218,130 +338,270 @@ class HtmlReporter {
218
338
  flex: 1;
219
339
  }
220
340
  .test-item {
221
- padding: 10px 15px;
341
+ padding: 12px 20px;
222
342
  border-bottom: 1px solid var(--border-color);
223
343
  cursor: pointer;
224
344
  display: flex;
225
345
  align-items: center;
226
- gap: 10px;
346
+ gap: 12px;
347
+ transition: background 0.1s;
227
348
  }
228
- .test-item:hover, .test-item.active { background-color: var(--hover-color); }
229
- .test-status-icon {
230
- font-size: 1.2em;
231
- width: 20px;
232
- text-align: center;
349
+ .test-item:hover, .test-item.active { background-color: var(--hover-color); border-left: 4px solid var(--blue-accent); padding-left: 16px; }
350
+ .test-status-indicator {
351
+ width: 10px;
352
+ height: 10px;
353
+ border-radius: 50%;
233
354
  }
355
+ .status-passed { background-color: var(--success-color); }
356
+ .status-failed { background-color: var(--fail-color); }
357
+ .status-skipped { background-color: var(--skip-color); }
358
+
234
359
  .test-info { flex: 1; overflow: hidden; }
235
- .test-title { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
236
- .test-suite { font-size: 0.8em; color: #888; }
237
- .test-duration { font-size: 0.8em; color: #888; }
360
+ .test-title { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 0.95em; }
361
+ .test-suite { font-size: 0.8em; color: #888; margin-top: 3px; }
362
+ .test-duration { font-size: 0.8em; color: #666; width: 50px; text-align: right; }
238
363
 
239
364
  /* Main Content */
240
365
  .main-content {
241
366
  flex: 1;
242
- padding: 20px;
243
- overflow-y: auto;
367
+ padding: 0;
368
+ overflow-y: hidden;
244
369
  display: none; /* Hidden by default until selected */
370
+ flex-direction: column;
245
371
  }
246
- .main-content.active { display: block; }
372
+ .main-content.active { display: flex; }
247
373
 
248
- .detail-header {
374
+ .detail-header-wrapper {
375
+ padding: 20px 30px;
249
376
  border-bottom: 1px solid var(--border-color);
250
- padding-bottom: 15px;
251
- margin-bottom: 20px;
377
+ background-color: #252526;
378
+ }
379
+ .detail-title { font-size: 1.6em; margin: 10px 0; font-weight: 300; }
380
+ .detail-badges { display: flex; gap: 10px; align-items: center; }
381
+ .badge {
382
+ padding: 3px 8px;
383
+ border-radius: 3px;
384
+ font-size: 0.8em;
385
+ font-weight: 600;
386
+ text-transform: uppercase;
252
387
  }
253
- .detail-title { font-size: 1.5em; margin-bottom: 5px; }
254
- .detail-meta { color: #888; font-size: 0.9em; display: flex; gap: 20px; }
388
+ .badge-passed { background-color: rgba(76, 175, 80, 0.2); color: var(--success-color); border: 1px solid var(--success-color); }
389
+ .badge-failed { background-color: rgba(244, 67, 54, 0.2); color: var(--fail-color); border: 1px solid var(--fail-color); }
390
+ .badge-skipped { background-color: rgba(255, 152, 0, 0.2); color: var(--skip-color); border: 1px solid var(--skip-color); }
255
391
 
256
- .section-title {
257
- font-size: 1.1em;
258
- margin: 20px 0 10px 0;
259
- color: #bbb;
260
- border-bottom: 1px solid #333;
261
- padding-bottom: 5px;
392
+ .detail-scroll-area {
393
+ flex: 1;
394
+ padding: 30px;
395
+ overflow-y: auto;
262
396
  }
263
397
 
264
- /* Steps */
265
- .steps-container {
398
+ .section-card {
266
399
  background-color: #252526;
267
- border-radius: 5px;
268
- padding: 10px;
400
+ border: 1px solid var(--border-color);
401
+ border-radius: 6px;
402
+ margin-bottom: 25px;
403
+ overflow: hidden;
269
404
  }
270
- .step-row {
271
- padding: 8px;
272
- border-left: 2px solid #555;
273
- margin-bottom: 5px;
405
+ .section-header {
406
+ padding: 10px 15px;
274
407
  background-color: #2d2d2d;
408
+ border-bottom: 1px solid var(--border-color);
409
+ font-weight: 600;
410
+ color: #bbb;
411
+ font-size: 0.9em;
412
+ text-transform: uppercase;
413
+ letter-spacing: 0.5px;
275
414
  }
276
- .step-row.passed { border-left-color: var(--success-color); }
277
- .step-row.failed { border-left-color: var(--fail-color); }
278
- .step-header { display: flex; justify-content: space-between; }
279
- .step-error {
280
- margin-top: 5px;
281
- padding: 10px;
282
- background-color: #0d1117;
283
- color: #c9d1d9;
284
- font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
285
- font-size: 0.85em;
286
- border: 1px solid #30363d;
287
- border-radius: 6px;
415
+
416
+ /* Steps */
417
+ .step-row {
418
+ padding: 8px 15px;
419
+ border-bottom: 1px solid #333;
420
+ display: flex;
421
+ gap: 10px;
422
+ align-items: flex-start;
423
+ font-family: Consolas, monospace;
424
+ font-size: 0.9em;
425
+ transition: background-color 0.2s;
426
+ }
427
+ .step-row:last-child { border-bottom: none; }
428
+ .step-row:hover { background-color: #2a2d2e; }
429
+ .step-status { margin-top: 3px; font-size: 1.2em; line-height: 1; }
430
+ .step-content { flex: 1; }
431
+ .step-meta { color: #666; font-size: 0.9em; margin-left: 10px; white-space: nowrap; }
432
+
433
+ /* Tree Toggle */
434
+ .toggle-icon {
435
+ display: inline-block;
436
+ width: 16px;
437
+ text-align: center;
438
+ cursor: pointer;
439
+ user-select: none;
440
+ font-size: 0.8em;
441
+ transition: transform 0.1s;
442
+ color: #ccc;
443
+ }
444
+ .step-row.collapsed + .children-container { display: none; }
445
+ .step-row.collapsed .toggle-icon { transform: rotate(-90deg); }
446
+
447
+ /* Error Box */
448
+ .error-box {
449
+ padding: 15px;
450
+ background-color: rgba(244, 67, 54, 0.1);
451
+ border-left: 4px solid var(--fail-color);
452
+ margin: 0;
453
+ }
454
+ .error-message {
455
+ color: #ff8a80;
456
+ font-size: 1.1em;
457
+ margin-bottom: 10px;
288
458
  white-space: pre-wrap;
459
+ }
460
+ .stack-trace {
461
+ background-color: var(--code-bg);
462
+ padding: 15px;
463
+ border-radius: 4px;
464
+ font-family: Consolas, monospace;
465
+ color: #d4d4d4;
466
+ white-space: pre-wrap;
467
+ overflow-x: auto;
468
+ border: 1px solid #30363d;
469
+ }
470
+
471
+ /* Code Snippet */
472
+ .code-snippet {
473
+ margin-top: 10px;
474
+ background-color: #000;
475
+ border-radius: 4px;
476
+ padding: 10px;
477
+ font-family: Consolas, monospace;
478
+ font-size: 0.9em;
479
+ color: #ccc;
480
+ white-space: pre;
289
481
  overflow-x: auto;
290
- line-height: 1.45;
482
+ border-left: 3px solid #ffcc00;
291
483
  }
292
484
 
293
485
  /* Media */
294
- .media-container {
295
- display: flex;
296
- flex-wrap: wrap;
486
+ .media-grid {
487
+ display: grid;
488
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
297
489
  gap: 20px;
490
+ padding: 20px;
298
491
  }
299
492
  .media-item {
300
493
  background-color: #000;
301
- padding: 5px;
302
494
  border-radius: 4px;
303
495
  border: 1px solid var(--border-color);
496
+ overflow: hidden;
304
497
  }
305
- img, video { max-width: 100%; display: block; }
306
-
307
- .empty-state {
308
- display: flex;
309
- justify-content: center;
310
- align-items: center;
311
- height: 100%;
312
- color: #666;
313
- font-size: 1.2em;
498
+ .media-header {
499
+ padding: 8px 12px;
500
+ background-color: #333;
501
+ color: #ccc;
502
+ font-size: 0.85em;
503
+ border-bottom: 1px solid #444;
504
+ }
505
+ img, video { width: 100%; height: auto; display: block; }
506
+
507
+ .copy-btn {
508
+ float: right;
509
+ background: transparent;
510
+ border: 1px solid #555;
511
+ color: #aaa;
512
+ cursor: pointer;
513
+ padding: 2px 8px;
514
+ border-radius: 3px;
314
515
  }
516
+ .copy-btn:hover { background: #333; color: white; }
517
+
315
518
  </style>
316
519
  </head>
317
520
  <body>
318
521
  <div class="sidebar">
319
522
  <div class="sidebar-header">
320
- <div class="stats-container">
321
- <div class="stat-item"><span class="dot passed-dot"></span>Passed: ${passedTests}</div>
322
- <div class="stat-item"><span class="dot failed-dot"></span>Failed: ${failedTests}</div>
323
- <div class="stat-item"><span class="dot skipped-dot"></span>Skipped: ${skippedTests}</div>
523
+ <div class="chart-container">
524
+ <div class="chart-inner">
525
+ <div class="total-text">${totalTests}</div>
526
+ <div class="total-label">TESTS</div>
527
+ </div>
324
528
  </div>
325
- <input type="text" class="search-box" placeholder="Filter tests..." id="searchInput" onkeyup="filterTests()">
529
+
530
+ <div class="stats-row">
531
+ <div class="stat-item" onclick="setFilter('passed')" title="Show Passed">
532
+ <span class="stat-value" style="color:var(--success-color)">${passedTests}</span>
533
+ <span class="stat-label">Passed</span>
534
+ </div>
535
+ <div class="stat-item" onclick="setFilter('failed')" title="Show Failed">
536
+ <span class="stat-value" style="color:var(--fail-color)">${failedTests}</span>
537
+ <span class="stat-label">Failed</span>
538
+ </div>
539
+ <div class="stat-item" onclick="setFilter('skipped')" title="Show Skipped">
540
+ <span class="stat-value" style="color:var(--skip-color)">${skippedTests}</span>
541
+ <span class="stat-label">Skipped</span>
542
+ </div>
543
+ </div>
544
+
545
+ <div class="filter-group">
546
+ <button class="filter-btn active" onclick="setFilter('all')">All</button>
547
+ <button class="filter-btn" onclick="setFilter('failed')">Failures</button>
548
+ </div>
549
+
550
+ <input type="text" class="search-box" placeholder="Search tests..." id="searchInput" onkeyup="filterTests()">
326
551
  </div>
327
552
  <div class="test-list" id="testList"></div>
328
553
  </div>
329
554
 
330
555
  <div id="contentArea" class="main-content">
331
- <div class="empty-state">Select a test view details</div>
556
+ <div class="empty-state">
557
+ <div style="text-align:center">
558
+ <div style="font-size:3em; margin-bottom:10px">🧪</div>
559
+ <div>Select a test to view execution details</div>
560
+ </div>
561
+ </div>
332
562
  </div>
333
563
 
334
564
  <script>
335
565
  const data = ${resultsData};
336
566
  let currentId = null;
567
+ let currentFilter = 'all';
568
+
569
+ function setFilter(filter) {
570
+ currentFilter = filter;
571
+ // Update buttons
572
+ document.querySelectorAll('.stat-item').forEach(el => el.classList.remove('active'));
573
+ document.querySelectorAll('.filter-btn').forEach(btn => btn.classList.remove('active'));
574
+
575
+ if (filter === 'all') document.querySelector('button[onclick="setFilter(\\'all\\')"]').classList.add('active');
576
+ else if (filter === 'failed') document.querySelector('button[onclick="setFilter(\\'failed\\')"]').classList.add('active');
577
+
578
+ filterTests();
579
+ }
580
+
581
+ function filterTests() {
582
+ const query = document.getElementById('searchInput').value.toLowerCase();
583
+ const filtered = data.filter(item => {
584
+ const matchesSearch = item.title.toLowerCase().includes(query) || item.suite.toLowerCase().includes(query);
585
+ const matchesFilter = currentFilter === 'all' ? true :
586
+ currentFilter === 'failed' ? (item.status === 'failed' || item.status === 'timedOut') :
587
+ item.status === currentFilter;
588
+ return matchesSearch && matchesFilter;
589
+ });
590
+ renderList(filtered);
591
+ }
337
592
 
338
593
  function renderList(items) {
339
594
  const container = document.getElementById('testList');
595
+ if (items.length === 0) {
596
+ container.innerHTML = '<div style="padding:20px; text-align:center; color:#666">No tests match current filter</div>';
597
+ return;
598
+ }
340
599
  container.innerHTML = items.map(item => {
341
- const icon = item.status === 'passed' ? '✅' : item.status === 'failed' ? '❌' : '⚠️';
600
+ const statusClass = item.status === 'passed' ? 'status-passed' :
601
+ (item.status === 'failed' || item.status === 'timedOut') ? 'status-failed' : 'status-skipped';
342
602
  const activeClass = currentId === item.id ? 'active' : '';
343
603
  return \`<div class="test-item \${activeClass}" onclick="selectTest(\${item.id})">
344
- <div class="test-status-icon">\${icon}</div>
604
+ <div class="test-status-indicator \${statusClass}"></div>
345
605
  <div class="test-info">
346
606
  <div class="test-title" title="\${item.title}">\${item.title}</div>
347
607
  <div class="test-suite">\${item.suite}</div>
@@ -351,70 +611,115 @@ class HtmlReporter {
351
611
  }).join('');
352
612
  }
353
613
 
614
+ function toggleStep(element) {
615
+ const row = element.closest('.step-row');
616
+ row.classList.toggle('collapsed');
617
+ const icon = row.querySelector('.toggle-icon');
618
+ if (row.classList.contains('collapsed')) {
619
+ icon.textContent = '▶';
620
+ } else {
621
+ icon.textContent = '▼';
622
+ }
623
+ }
624
+
625
+ function generateStepTreeHtml(step) {
626
+ const hasChildren = step.steps && step.steps.length > 0;
627
+ const isPassed = !step.error;
628
+ // Collapse by default if passed and has children
629
+ const isCollapsed = hasChildren && isPassed;
630
+ const collapsedClass = isCollapsed ? 'collapsed' : '';
631
+ const icon = hasChildren ? (isCollapsed ? '▶' : '▼') : '';
632
+
633
+ const statusIcon = step.error ? '❌' : '✓';
634
+ const style = step.error ? 'color:var(--fail-color)' : 'color:var(--success-color)';
635
+
636
+ let childHtml = '';
637
+ if (hasChildren) {
638
+ childHtml = \`<div class="children-container">\${step.steps.map(generateStepTreeHtml).join('')}</div>\`;
639
+ }
640
+
641
+ return \`
642
+ <div class="step-wrapper">
643
+ <div class="step-row \${collapsedClass}" style="padding-left: \${15 + (step.depth * 20)}px" >
644
+ \${hasChildren ? \`<span class="toggle-icon" onclick="toggleStep(this)">\${icon}</span>\` : '<span style="width:16px;display:inline-block"></span>'}
645
+ <div class="step-status" style="\${style}">\${statusIcon}</div>
646
+ <div class="step-content">
647
+ <div>\${step.title}</div>
648
+ \${step.error ? \`<div style="margin-top:5px; color:#ff8a80; font-size:0.9em">\${step.error}</div>\` : ''}
649
+ \${step.codeSnippet ? \`<div class="code-snippet">\${step.codeSnippet}</div>\` : ''}
650
+ </div>
651
+ <div class="step-meta">\${step.duration}ms</div>
652
+ </div>
653
+ \${childHtml}
654
+ </div>
655
+ \`;
656
+ }
657
+
354
658
  function selectTest(id) {
355
659
  currentId = id;
356
- renderList(data); // Re-render to update active state
660
+ filterTests(); // Re-render to update active state
357
661
  const test = data.find(t => t.id === id);
358
662
  if (!test) return;
359
663
 
360
664
  const content = document.getElementById('contentArea');
361
- const statusColor = test.status === 'passed' ? '#4caf50' : '#f44336';
665
+ const statusClass = test.status === 'passed' ? 'badge-passed' :
666
+ (test.status === 'failed' || test.status === 'timedOut') ? 'badge-failed' : 'badge-skipped';
362
667
 
363
- const stepsHtml = test.steps.map(step => \`
364
- <div class="step-row \${step.error ? 'failed' : 'passed'}">
365
- <div class="step-header">
366
- <span>\${step.title}</span>
367
- <span style="color: #888">\${step.duration}ms</span>
368
- </div>
369
- \${step.error ? \`<div class="step-error">\${step.error}</div>\` : ''}
370
- </div>
371
- \`).join('');
668
+ // Steps (using tree renderer)
669
+ const stepsHtml = test.steps.map(generateStepTreeHtml).join('');
372
670
 
671
+ // Media
373
672
  const mediaHtml = \`
374
673
  \${test.screenshot ? \`
375
674
  <div class="media-item">
376
- <div style="margin-bottom:5px; color:#aaa">Screenshot</div>
377
- <img src="\${test.screenshot}" height="200" onclick="window.open(this.src)">
675
+ <div class="media-header">Screenshot <a href="\${test.screenshot}" target="_blank" style="float:right; color:#4dabf7; text-decoration:none">Open New Tab</a></div>
676
+ <img src="\${test.screenshot}" onclick="window.open(this.src)">
378
677
  </div>\` : ''}
379
678
  \${test.video ? \`
380
679
  <div class="media-item">
381
- <div style="margin-bottom:5px; color:#aaa">Video</div>
382
- <video src="\${test.video}" height="200" controls></video>
680
+ <div class="media-header">Video</div>
681
+ <video src="\${test.video}" controls></video>
383
682
  </div>\` : ''}
384
683
  \`;
385
684
 
386
685
  content.innerHTML = \`
387
- <div class="detail-header">
388
- <div style="display:flex; align-items:center; gap:10px;">
389
- <span style="font-size:1.5em; color:\${statusColor}">\${test.status.toUpperCase()}</span>
390
- <div class="detail-title">\${test.title}</div>
391
- </div>
686
+ <div class="detail-header-wrapper">
392
687
  <div class="detail-meta">
393
- <span>Suite: \${test.suite}</span>
394
- <span>Duration: \${test.duration}ms</span>
688
+ <span class="badge \${statusClass}">\${test.status}</span>
689
+ <span>\${test.suite}</span>
690
+ <span style="margin-left:auto">Duration: \${test.duration}ms</span>
395
691
  </div>
396
- \${test.error ? \`<div class="step-error" style="margin-top:10px">\${test.error}</div>\` : ''}
692
+ <div class="detail-title">\${test.title}</div>
397
693
  </div>
694
+
695
+ <div class="detail-scroll-area">
696
+ \${test.error ? \`
697
+ <div class="section-card error-box">
698
+ <div style="display:flex; justify-content:space-between; margin-bottom:10px">
699
+ <strong style="color:var(--fail-color)">Error Details</strong>
700
+ </div>
701
+ <div class="stack-trace">\${test.error}</div>
702
+ </div>
703
+ \` : ''}
398
704
 
399
- <div class="section-title">Execution Steps</div>
400
- <div class="steps-container">
401
- \${stepsHtml}
402
- </div>
705
+ <div class="section-card">
706
+ <div class="section-header">Execution Steps</div>
707
+ <div class="steps-tree-container">
708
+ \${stepsHtml}
709
+ </div>
710
+ </div>
403
711
 
404
- \${test.screenshot || test.video ? \`<div class="section-title">Artifacts</div><div class="media-container">\${mediaHtml}</div>\` : ''}
712
+ \${test.screenshot || test.video ? \`
713
+ <div class="section-card">
714
+ <div class="section-header">Artifacts</div>
715
+ <div class="media-grid">\${mediaHtml}</div>
716
+ </div>
717
+ \` : ''}
718
+ </div>
405
719
  \`;
406
720
  content.classList.add('active');
407
721
  }
408
722
 
409
- function filterTests() {
410
- const query = document.getElementById('searchInput').value.toLowerCase();
411
- const filtered = data.filter(item =>
412
- item.title.toLowerCase().includes(query) ||
413
- item.suite.toLowerCase().includes(query)
414
- );
415
- renderList(filtered);
416
- }
417
-
418
723
  // Initial render
419
724
  renderList(data);
420
725
  </script>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flash-ai-team/flash-test-framework",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
4
4
  "description": "A powerful keyword-driven automation framework built on top of Playwright and TypeScript.",
5
5
  "keywords": [
6
6
  "playwright",
@@ -24,6 +24,9 @@
24
24
  "type": "commonjs",
25
25
  "main": "dist/index.js",
26
26
  "types": "dist/index.d.ts",
27
+ "bin": {
28
+ "flash-test": "./dist/cli/index.js"
29
+ },
27
30
  "directories": {
28
31
  "test": "tests"
29
32
  },