@testsmith/testblocks 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,6 +16,7 @@ export declare class TestExecutor {
16
16
  constructor(options?: ExecutorOptions);
17
17
  initialize(): Promise<void>;
18
18
  cleanup(): Promise<void>;
19
+ private requiresBrowser;
19
20
  runTestFile(testFile: TestFile): Promise<TestResult[]>;
20
21
  private createBaseContext;
21
22
  runTestWithData(test: TestCase, testFile: TestFile, dataSet: TestDataSet, dataIndex: number): Promise<TestResult>;
@@ -42,9 +42,40 @@ class TestExecutor {
42
42
  this.browserContext = null;
43
43
  this.browser = null;
44
44
  }
45
+ requiresBrowser(testFile) {
46
+ const hasWebStep = (steps) => {
47
+ return steps.some(step => step.type.startsWith('web_'));
48
+ };
49
+ const hasWebStepInState = (state) => {
50
+ const steps = this.extractStepsFromBlocklyState(state);
51
+ return hasWebStep(steps);
52
+ };
53
+ // Check beforeAll/afterAll hooks
54
+ if (testFile.beforeAll && hasWebStepInState(testFile.beforeAll))
55
+ return true;
56
+ if (testFile.afterAll && hasWebStepInState(testFile.afterAll))
57
+ return true;
58
+ if (testFile.beforeEach && hasWebStepInState(testFile.beforeEach))
59
+ return true;
60
+ if (testFile.afterEach && hasWebStepInState(testFile.afterEach))
61
+ return true;
62
+ // Check all tests
63
+ for (const test of testFile.tests) {
64
+ if (hasWebStepInState(test.steps))
65
+ return true;
66
+ if (test.beforeEach && hasWebStepInState(test.beforeEach))
67
+ return true;
68
+ if (test.afterEach && hasWebStepInState(test.afterEach))
69
+ return true;
70
+ }
71
+ return false;
72
+ }
45
73
  async runTestFile(testFile) {
46
74
  const results = [];
47
- await this.initialize();
75
+ // Only initialize browser if test file contains web steps
76
+ if (this.requiresBrowser(testFile)) {
77
+ await this.initialize();
78
+ }
48
79
  // Register procedures from the test file
49
80
  if (testFile.procedures) {
50
81
  for (const [name, procedure] of Object.entries(testFile.procedures)) {
package/dist/cli/index.js CHANGED
@@ -56,6 +56,7 @@ program
56
56
  .option('-o, --output <dir>', 'Output directory for reports', './testblocks-results')
57
57
  .option('-b, --base-url <url>', 'Base URL for relative URLs')
58
58
  .option('-v, --var <vars...>', 'Variables in key=value format')
59
+ .option('-g, --globals <path>', 'Path to globals.json file', './globals.json')
59
60
  .option('--fail-fast', 'Stop on first test failure', false)
60
61
  .option('-p, --parallel <count>', 'Number of parallel workers', '1')
61
62
  .option('--filter <pattern>', 'Only run tests matching pattern')
@@ -72,21 +73,38 @@ program
72
73
  process.exit(1);
73
74
  }
74
75
  console.log(`Found ${files.length} test file(s)\n`);
75
- // Parse variables
76
- const variables = {};
76
+ // Load globals.json if it exists
77
+ let globalVariables = {};
78
+ const globalsPath = path.resolve(options.globals);
79
+ if (fs.existsSync(globalsPath)) {
80
+ try {
81
+ const globalsContent = fs.readFileSync(globalsPath, 'utf-8');
82
+ const globals = JSON.parse(globalsContent);
83
+ if (globals.variables && typeof globals.variables === 'object') {
84
+ globalVariables = globals.variables;
85
+ }
86
+ }
87
+ catch (e) {
88
+ console.warn(`Warning: Could not load globals from ${globalsPath}: ${e.message}`);
89
+ }
90
+ }
91
+ // Parse CLI variables (these override globals)
92
+ const cliVariables = {};
77
93
  if (options.var) {
78
94
  for (const v of options.var) {
79
95
  const [key, ...valueParts] = v.split('=');
80
96
  const value = valueParts.join('=');
81
97
  // Try to parse as JSON, otherwise use as string
82
98
  try {
83
- variables[key] = JSON.parse(value);
99
+ cliVariables[key] = JSON.parse(value);
84
100
  }
85
101
  catch {
86
- variables[key] = value;
102
+ cliVariables[key] = value;
87
103
  }
88
104
  }
89
105
  }
106
+ // Merge variables: globals first, then CLI overrides
107
+ const variables = { ...globalVariables, ...cliVariables };
90
108
  // Create executor options
91
109
  const executorOptions = {
92
110
  headless: !options.headed,
@@ -230,7 +248,7 @@ program
230
248
  'test:junit': 'testblocks run tests/**/*.testblocks.json -r junit -o reports',
231
249
  },
232
250
  devDependencies: {
233
- '@testsmith/testblocks': '^0.1.0',
251
+ '@testsmith/testblocks': '^0.3.0',
234
252
  },
235
253
  };
236
254
  fs.writeFileSync(packagePath, JSON.stringify(packageJson, null, 2));
@@ -257,8 +275,8 @@ program
257
275
  },
258
276
  {
259
277
  id: 'step-2',
260
- type: 'web_expect_title',
261
- params: { EXPECTED: 'Example Domain' },
278
+ type: 'web_assert_title_contains',
279
+ params: { TEXT: 'Example Domain' },
262
280
  },
263
281
  ],
264
282
  tags: ['web', 'smoke'],
@@ -205,10 +205,114 @@ class HTMLReporter {
205
205
  }
206
206
  }
207
207
  exports.HTMLReporter = HTMLReporter;
208
+ // Format step type for display (same as StepResultItem.tsx)
209
+ function formatStepType(type) {
210
+ const displayNames = {
211
+ 'logic_log': 'Log',
212
+ 'logic_set_variable': 'Set Variable',
213
+ 'logic_get_variable': 'Get Variable',
214
+ 'api_set_header': 'Set Header',
215
+ 'api_set_headers': 'Set Headers',
216
+ 'api_clear_headers': 'Clear Headers',
217
+ 'api_assert_status': 'Assert Status',
218
+ 'api_assert_body_contains': 'Assert Body',
219
+ 'api_extract': 'Extract',
220
+ 'api_extract_jsonpath': 'Extract (JSONPath)',
221
+ 'api_extract_xpath': 'Extract (XPath)',
222
+ 'web_navigate': 'Navigate',
223
+ 'web_click': 'Click',
224
+ 'web_fill': 'Fill',
225
+ 'web_type': 'Type',
226
+ 'web_press_key': 'Press Key',
227
+ 'web_select': 'Select',
228
+ 'web_checkbox': 'Checkbox',
229
+ 'web_hover': 'Hover',
230
+ 'web_wait_for_element': 'Wait for Element',
231
+ 'web_wait_for_url': 'Wait for URL',
232
+ 'web_wait': 'Wait',
233
+ 'web_screenshot': 'Screenshot',
234
+ 'web_get_text': 'Get Text',
235
+ 'web_get_attribute': 'Get Attribute',
236
+ 'web_get_input_value': 'Get Input Value',
237
+ 'web_get_title': 'Get Title',
238
+ 'web_get_url': 'Get URL',
239
+ 'web_assert_visible': 'Assert Visible',
240
+ 'web_assert_not_visible': 'Assert Not Visible',
241
+ 'web_assert_text_contains': 'Assert Text Contains',
242
+ 'web_assert_text_equals': 'Assert Text Equals',
243
+ 'web_assert_url_contains': 'Assert URL Contains',
244
+ 'web_assert_title_contains': 'Assert Title Contains',
245
+ 'web_assert_enabled': 'Assert Enabled',
246
+ 'web_assert_checked': 'Assert Checked',
247
+ 'totp_generate': 'Generate TOTP',
248
+ 'totp_generate_from_numeric': 'Generate TOTP (Numeric)',
249
+ 'totp_setup': 'Setup TOTP',
250
+ 'totp_code': 'TOTP Code',
251
+ 'totp_time_remaining': 'TOTP Time Remaining',
252
+ 'totp_wait_for_new_code': 'Wait for New TOTP',
253
+ };
254
+ if (displayNames[type]) {
255
+ return displayNames[type];
256
+ }
257
+ return type
258
+ .split('_')
259
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
260
+ .join(' ');
261
+ }
262
+ // Get step summary from output
263
+ function getStepSummary(stepType, output) {
264
+ if (!output || typeof output !== 'object')
265
+ return null;
266
+ const out = output;
267
+ return out._summary || null;
268
+ }
269
+ // Check if step is an API request
270
+ function isApiRequestStep(stepType) {
271
+ return ['api_get', 'api_post', 'api_put', 'api_patch', 'api_delete'].includes(stepType);
272
+ }
273
+ // Format step output for display
274
+ function formatStepOutput(output, stepType) {
275
+ if (output === undefined || output === null)
276
+ return '';
277
+ if (stepType === 'logic_log' && typeof output === 'object' && output !== null) {
278
+ const logOutput = output;
279
+ if (logOutput._message) {
280
+ return String(logOutput._message);
281
+ }
282
+ }
283
+ if (typeof output === 'string')
284
+ return output;
285
+ if (typeof output === 'number' || typeof output === 'boolean')
286
+ return String(output);
287
+ if (typeof output === 'object') {
288
+ const filtered = Object.fromEntries(Object.entries(output)
289
+ .filter(([key]) => !key.startsWith('_')));
290
+ if (Object.keys(filtered).length === 0) {
291
+ return '';
292
+ }
293
+ return JSON.stringify(filtered, null, 2);
294
+ }
295
+ return JSON.stringify(output, null, 2);
296
+ }
208
297
  function generateHTMLReport(data) {
209
298
  const { timestamp, summary, testFiles } = data;
210
- const passRate = summary.totalTests > 0
211
- ? ((summary.passed / summary.totalTests) * 100).toFixed(1)
299
+ // Separate lifecycle hooks from actual tests for counting
300
+ let actualTests = 0;
301
+ let actualPassed = 0;
302
+ let actualFailed = 0;
303
+ for (const { results } of testFiles) {
304
+ for (const result of results) {
305
+ if (!result.isLifecycle) {
306
+ actualTests++;
307
+ if (result.status === 'passed')
308
+ actualPassed++;
309
+ else
310
+ actualFailed++;
311
+ }
312
+ }
313
+ }
314
+ const passRate = actualTests > 0
315
+ ? ((actualPassed / actualTests) * 100).toFixed(1)
212
316
  : '0';
213
317
  let html = `<!DOCTYPE html>
214
318
  <html lang="en">
@@ -271,17 +375,39 @@ function generateHTMLReport(data) {
271
375
  align-items: center;
272
376
  }
273
377
  .test-file-path { font-size: 12px; color: var(--color-text-secondary); font-weight: normal; }
274
- .test-case {
275
- padding: 12px 16px;
378
+ .lifecycle-section {
379
+ background: #f1f5f9;
380
+ padding: 8px 16px;
276
381
  border-bottom: 1px solid var(--color-border);
382
+ }
383
+ .lifecycle-header {
384
+ font-size: 12px;
385
+ font-weight: 600;
386
+ color: var(--color-text-secondary);
387
+ text-transform: uppercase;
388
+ letter-spacing: 0.5px;
389
+ margin-bottom: 4px;
390
+ }
391
+ .lifecycle-item {
277
392
  display: flex;
278
393
  align-items: center;
279
- gap: 12px;
394
+ gap: 8px;
395
+ padding: 4px 0;
396
+ font-size: 13px;
397
+ }
398
+ .test-case {
399
+ padding: 12px 16px;
400
+ border-bottom: 1px solid var(--color-border);
280
401
  }
281
402
  .test-case:last-child { border-bottom: none; }
282
403
  .test-case.passed { border-left: 3px solid var(--color-passed); }
283
404
  .test-case.failed { border-left: 3px solid var(--color-failed); }
284
405
  .test-case.skipped { border-left: 3px solid var(--color-skipped); }
406
+ .test-case-header {
407
+ display: flex;
408
+ align-items: center;
409
+ gap: 12px;
410
+ }
285
411
  .status-icon {
286
412
  width: 20px;
287
413
  height: 20px;
@@ -296,18 +422,15 @@ function generateHTMLReport(data) {
296
422
  .status-icon.passed { background: var(--color-passed); }
297
423
  .status-icon.failed { background: var(--color-failed); }
298
424
  .status-icon.skipped { background: var(--color-skipped); }
299
- .test-name { flex: 1; }
425
+ .test-name { flex: 1; font-weight: 500; }
300
426
  .test-duration { color: var(--color-text-secondary); font-size: 14px; }
301
- .error-details {
427
+ .error-message {
302
428
  background: #fef2f2;
303
429
  border: 1px solid #fecaca;
304
430
  border-radius: 4px;
305
- padding: 12px;
306
- margin: 8px 16px 12px 44px;
307
- font-family: monospace;
431
+ padding: 8px 12px;
432
+ margin: 8px 0 0 32px;
308
433
  font-size: 13px;
309
- white-space: pre-wrap;
310
- word-break: break-word;
311
434
  color: #991b1b;
312
435
  }
313
436
  .steps-toggle {
@@ -321,27 +444,87 @@ function generateHTMLReport(data) {
321
444
  .steps-toggle:hover { text-decoration: underline; }
322
445
  .steps-list {
323
446
  display: none;
324
- padding: 8px 16px 12px 44px;
447
+ margin: 12px 0 0 32px;
448
+ border-left: 2px solid var(--color-border);
449
+ padding-left: 16px;
325
450
  }
326
451
  .steps-list.open { display: block; }
327
452
  .step {
453
+ padding: 8px 0;
454
+ border-bottom: 1px solid #f1f5f9;
455
+ }
456
+ .step:last-child { border-bottom: none; }
457
+ .step-header {
328
458
  display: flex;
329
459
  align-items: center;
330
460
  gap: 8px;
331
- padding: 4px 0;
332
461
  font-size: 13px;
333
462
  }
334
- .step-icon { font-size: 10px; }
335
- .step-icon.passed { color: var(--color-passed); }
336
- .step-icon.failed { color: var(--color-failed); }
337
- .step-type { color: var(--color-text-secondary); }
338
- .step-duration { color: var(--color-text-secondary); font-size: 12px; }
463
+ .step-dot {
464
+ width: 8px;
465
+ height: 8px;
466
+ border-radius: 50%;
467
+ flex-shrink: 0;
468
+ }
469
+ .step-dot.passed { background: var(--color-passed); }
470
+ .step-dot.failed { background: var(--color-failed); }
471
+ .step-type { font-weight: 500; }
472
+ .step-summary { color: var(--color-text-secondary); }
473
+ .step-duration { color: var(--color-text-secondary); font-size: 12px; margin-left: auto; }
474
+ .step-details {
475
+ margin-top: 8px;
476
+ padding: 8px;
477
+ background: #f8fafc;
478
+ border-radius: 4px;
479
+ font-size: 12px;
480
+ }
481
+ .step-error {
482
+ color: #991b1b;
483
+ margin-top: 4px;
484
+ }
485
+ .response-status {
486
+ display: flex;
487
+ align-items: center;
488
+ gap: 8px;
489
+ margin-bottom: 8px;
490
+ }
491
+ .response-label { font-weight: 500; }
492
+ .status-code { font-family: monospace; padding: 2px 6px; border-radius: 4px; }
493
+ .status-code.success { background: #dcfce7; color: #166534; }
494
+ .status-code.client-error { background: #fef3c7; color: #92400e; }
495
+ .status-code.server-error { background: #fef2f2; color: #991b1b; }
496
+ .response-section {
497
+ margin-top: 8px;
498
+ }
499
+ .response-section summary {
500
+ cursor: pointer;
501
+ font-weight: 500;
502
+ font-size: 12px;
503
+ color: var(--color-text-secondary);
504
+ }
505
+ .response-pre {
506
+ background: #1e293b;
507
+ color: #e2e8f0;
508
+ padding: 12px;
509
+ border-radius: 4px;
510
+ overflow-x: auto;
511
+ font-family: monospace;
512
+ font-size: 12px;
513
+ margin-top: 4px;
514
+ white-space: pre-wrap;
515
+ word-break: break-word;
516
+ }
517
+ .stack-trace {
518
+ background: #fef2f2;
519
+ color: #991b1b;
520
+ }
339
521
  .screenshot {
340
522
  max-width: 100%;
341
523
  max-height: 300px;
342
524
  border: 1px solid var(--color-border);
343
525
  border-radius: 4px;
344
526
  margin-top: 8px;
527
+ cursor: pointer;
345
528
  }
346
529
  </style>
347
530
  </head>
@@ -352,15 +535,15 @@ function generateHTMLReport(data) {
352
535
 
353
536
  <div class="summary">
354
537
  <div class="summary-card">
355
- <div class="value">${summary.totalTests}</div>
538
+ <div class="value">${actualTests}</div>
356
539
  <div class="label">Total Tests</div>
357
540
  </div>
358
541
  <div class="summary-card passed">
359
- <div class="value">${summary.passed}</div>
542
+ <div class="value">${actualPassed}</div>
360
543
  <div class="label">Passed</div>
361
544
  </div>
362
545
  <div class="summary-card failed">
363
- <div class="value">${summary.failed}</div>
546
+ <div class="value">${actualFailed}</div>
364
547
  <div class="label">Failed</div>
365
548
  </div>
366
549
  <div class="summary-card">
@@ -374,8 +557,11 @@ function generateHTMLReport(data) {
374
557
  </div>
375
558
  `;
376
559
  for (const { file, testFile, results } of testFiles) {
377
- const filePassed = results.filter(r => r.status === 'passed').length;
378
- const fileFailed = results.filter(r => r.status !== 'passed').length;
560
+ // Separate lifecycle hooks from tests
561
+ const lifecycleResults = results.filter(r => r.isLifecycle);
562
+ const testResults = results.filter(r => !r.isLifecycle);
563
+ const filePassed = testResults.filter(r => r.status === 'passed').length;
564
+ const fileFailed = testResults.filter(r => r.status !== 'passed').length;
379
565
  html += `
380
566
  <div class="test-file">
381
567
  <div class="test-file-header">
@@ -383,36 +569,131 @@ function generateHTMLReport(data) {
383
569
  <span class="test-file-path">${escapeHtml(file)} • ${filePassed} passed, ${fileFailed} failed</span>
384
570
  </div>
385
571
  `;
386
- for (const result of results) {
572
+ // Render lifecycle hooks (beforeAll)
573
+ const beforeAllHooks = lifecycleResults.filter(r => r.lifecycleType === 'beforeAll');
574
+ if (beforeAllHooks.length > 0) {
575
+ html += ` <div class="lifecycle-section">\n`;
576
+ html += ` <div class="lifecycle-header">Before All</div>\n`;
577
+ for (const hook of beforeAllHooks) {
578
+ const icon = hook.status === 'passed' ? '✓' : '✗';
579
+ const iconClass = hook.status === 'passed' ? 'passed' : 'failed';
580
+ html += ` <div class="lifecycle-item">
581
+ <span class="step-dot ${iconClass}"></span>
582
+ <span>${hook.steps.length} steps</span>
583
+ <span style="color: var(--color-text-secondary); font-size: 12px;">${hook.duration}ms</span>
584
+ </div>\n`;
585
+ if (hook.error) {
586
+ html += ` <div class="error-message" style="margin: 4px 0 0 16px;">${escapeHtml(hook.error.message)}</div>\n`;
587
+ }
588
+ }
589
+ html += ` </div>\n`;
590
+ }
591
+ // Render actual tests
592
+ for (const result of testResults) {
387
593
  const statusIcon = result.status === 'passed' ? '✓' : result.status === 'failed' ? '✗' : '○';
594
+ const testId = `test-${Math.random().toString(36).substr(2, 9)}`;
388
595
  html += `
389
596
  <div class="test-case ${result.status}">
390
- <div class="status-icon ${result.status}">${statusIcon}</div>
391
- <div class="test-name">${escapeHtml(result.testName)}</div>
392
- <div class="test-duration">${result.duration}ms</div>
393
- <button class="steps-toggle" onclick="this.nextElementSibling.classList.toggle('open')">
394
- ${result.steps.length} steps
395
- </button>
396
- </div>
397
- <div class="steps-list">
597
+ <div class="test-case-header">
598
+ <div class="status-icon ${result.status}">${statusIcon}</div>
599
+ <div class="test-name">${escapeHtml(result.testName)}</div>
600
+ <div class="test-duration">${result.duration}ms</div>
601
+ <button class="steps-toggle" onclick="document.getElementById('${testId}').classList.toggle('open'); this.textContent = this.textContent.includes('▶') ? '▼ ${result.steps.length} steps' : '▶ ${result.steps.length} steps'">
602
+ ▶ ${result.steps.length} steps
603
+ </button>
604
+ </div>
398
605
  `;
606
+ if (result.error) {
607
+ html += ` <div class="error-message">${escapeHtml(result.error.message)}</div>\n`;
608
+ }
609
+ html += ` <div class="steps-list" id="${testId}">\n`;
399
610
  for (const step of result.steps) {
400
- const stepIcon = step.status === 'passed' ? '✓' : '✗';
401
- html += `
402
- <div class="step">
403
- <span class="step-icon ${step.status}">${stepIcon}</span>
404
- <span class="step-type">${escapeHtml(step.stepType)}</span>
405
- <span class="step-duration">${step.duration}ms</span>
406
- </div>
611
+ const summary = getStepSummary(step.stepType, step.output);
612
+ const isApiRequest = isApiRequestStep(step.stepType);
613
+ const response = step.output;
614
+ html += ` <div class="step">
615
+ <div class="step-header">
616
+ <span class="step-dot ${step.status}"></span>
617
+ <span class="step-type">${escapeHtml(formatStepType(step.stepType))}</span>
618
+ ${summary ? `<span class="step-summary">${escapeHtml(summary)}</span>` : ''}
619
+ <span class="step-duration">${step.duration}ms</span>
620
+ </div>
407
621
  `;
622
+ // Show step error
623
+ if (step.error) {
624
+ html += ` <div class="step-error">${escapeHtml(step.error.message)}</div>\n`;
625
+ }
626
+ // Show API response details
627
+ if (isApiRequest && response) {
628
+ html += ` <div class="step-details">\n`;
629
+ if (response.status) {
630
+ const statusClass = response.status >= 200 && response.status < 300 ? 'success'
631
+ : response.status >= 400 && response.status < 500 ? 'client-error'
632
+ : response.status >= 500 ? 'server-error' : '';
633
+ html += ` <div class="response-status">
634
+ <span class="response-label">Status:</span>
635
+ <span class="status-code ${statusClass}">${response.status}</span>
636
+ </div>\n`;
637
+ }
638
+ if (response.headers && Object.keys(response.headers).length > 0) {
639
+ html += ` <details class="response-section">
640
+ <summary>Headers</summary>
641
+ <pre class="response-pre">${escapeHtml(JSON.stringify(response.headers, null, 2))}</pre>
642
+ </details>\n`;
643
+ }
644
+ if (response.body !== undefined) {
645
+ const bodyStr = typeof response.body === 'string'
646
+ ? response.body
647
+ : JSON.stringify(response.body, null, 2);
648
+ html += ` <details class="response-section">
649
+ <summary>Body</summary>
650
+ <pre class="response-pre">${escapeHtml(bodyStr)}</pre>
651
+ </details>\n`;
652
+ }
653
+ html += ` </div>\n`;
654
+ }
655
+ else if (step.output !== undefined && step.output !== null && !step.screenshot) {
656
+ // Show generic output for non-API steps
657
+ const outputStr = formatStepOutput(step.output, step.stepType);
658
+ if (outputStr) {
659
+ html += ` <div class="step-details">
660
+ <pre class="response-pre">${escapeHtml(outputStr)}</pre>
661
+ </div>\n`;
662
+ }
663
+ }
664
+ // Show stack trace for errors
665
+ if (step.error?.stack) {
666
+ html += ` <details class="response-section">
667
+ <summary>Stack Trace</summary>
668
+ <pre class="response-pre stack-trace">${escapeHtml(step.error.stack)}</pre>
669
+ </details>\n`;
670
+ }
671
+ // Show screenshot
408
672
  if (step.screenshot) {
409
- html += ` <img class="screenshot" src="${step.screenshot}" alt="Screenshot at failure">\n`;
673
+ html += ` <img class="screenshot" src="${step.screenshot}" alt="Screenshot at failure" onclick="window.open(this.src, '_blank')">\n`;
410
674
  }
675
+ html += ` </div>\n`;
411
676
  }
677
+ html += ` </div>\n`;
412
678
  html += ` </div>\n`;
413
- if (result.error) {
414
- html += ` <div class="error-details">${escapeHtml(result.error.message)}${result.error.stack ? '\n\n' + escapeHtml(result.error.stack) : ''}</div>\n`;
679
+ }
680
+ // Render lifecycle hooks (afterAll)
681
+ const afterAllHooks = lifecycleResults.filter(r => r.lifecycleType === 'afterAll');
682
+ if (afterAllHooks.length > 0) {
683
+ html += ` <div class="lifecycle-section">\n`;
684
+ html += ` <div class="lifecycle-header">After All</div>\n`;
685
+ for (const hook of afterAllHooks) {
686
+ const iconClass = hook.status === 'passed' ? 'passed' : 'failed';
687
+ html += ` <div class="lifecycle-item">
688
+ <span class="step-dot ${iconClass}"></span>
689
+ <span>${hook.steps.length} steps</span>
690
+ <span style="color: var(--color-text-secondary); font-size: 12px;">${hook.duration}ms</span>
691
+ </div>\n`;
692
+ if (hook.error) {
693
+ html += ` <div class="error-message" style="margin: 4px 0 0 16px;">${escapeHtml(hook.error.message)}</div>\n`;
694
+ }
415
695
  }
696
+ html += ` </div>\n`;
416
697
  }
417
698
  html += ` </div>\n`;
418
699
  }