rspec-tracer 1.2.3 → 2.0.0.pre.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +384 -67
  3. data/README.md +454 -429
  4. data/bin/rspec-tracer +15 -0
  5. data/lib/rspec_tracer/cache/Rakefile +43 -0
  6. data/lib/rspec_tracer/cli/cache_clear.rb +111 -0
  7. data/lib/rspec_tracer/cli/cache_info.rb +104 -0
  8. data/lib/rspec_tracer/cli/doctor.rb +284 -0
  9. data/lib/rspec_tracer/cli/explain.rb +158 -0
  10. data/lib/rspec_tracer/cli/report_open.rb +82 -0
  11. data/lib/rspec_tracer/cli.rb +116 -0
  12. data/lib/rspec_tracer/configuration.rb +1196 -3
  13. data/lib/rspec_tracer/engine.rb +1168 -0
  14. data/lib/rspec_tracer/example.rb +141 -11
  15. data/lib/rspec_tracer/filter.rb +35 -0
  16. data/lib/rspec_tracer/line_stub.rb +61 -0
  17. data/lib/rspec_tracer/load_config.rb +2 -2
  18. data/lib/rspec_tracer/logger.rb +15 -0
  19. data/lib/rspec_tracer/rails/README.md +78 -0
  20. data/lib/rspec_tracer/rails/i18n_tracking.rb +137 -0
  21. data/lib/rspec_tracer/rails/notifications.rb +263 -0
  22. data/lib/rspec_tracer/rails/preset.rb +94 -0
  23. data/lib/rspec_tracer/rails/railtie.rb +22 -0
  24. data/lib/rspec_tracer/rails.rb +15 -0
  25. data/lib/rspec_tracer/remote_cache/README.md +140 -0
  26. data/lib/rspec_tracer/remote_cache/Rakefile +35 -11
  27. data/lib/rspec_tracer/remote_cache/archive.rb +137 -0
  28. data/lib/rspec_tracer/remote_cache/backend.rb +73 -0
  29. data/lib/rspec_tracer/remote_cache/git_ancestry.rb +241 -0
  30. data/lib/rspec_tracer/remote_cache/local_fs_backend.rb +439 -0
  31. data/lib/rspec_tracer/remote_cache/redis_backend.rb +554 -0
  32. data/lib/rspec_tracer/remote_cache/s3_backend.rb +712 -0
  33. data/lib/rspec_tracer/remote_cache/user_tasks.rb +436 -0
  34. data/lib/rspec_tracer/remote_cache/validator.rb +40 -62
  35. data/lib/rspec_tracer/remote_cache.rb +22 -0
  36. data/lib/rspec_tracer/reporters/README.md +103 -0
  37. data/lib/rspec_tracer/reporters/base.rb +87 -0
  38. data/lib/rspec_tracer/reporters/coverage_json_reporter.rb +338 -0
  39. data/lib/rspec_tracer/reporters/html/.gitignore +19 -0
  40. data/lib/rspec_tracer/reporters/html/.prettierignore +4 -0
  41. data/lib/rspec_tracer/reporters/html/.prettierrc.json +9 -0
  42. data/lib/rspec_tracer/reporters/html/README.md +80 -0
  43. data/lib/rspec_tracer/reporters/html/dist/assets/index.css +2 -0
  44. data/lib/rspec_tracer/reporters/html/dist/assets/index.js +1 -0
  45. data/lib/rspec_tracer/reporters/html/dist/index.html +24 -0
  46. data/lib/rspec_tracer/reporters/html/eslint.config.js +62 -0
  47. data/lib/rspec_tracer/reporters/html/package-lock.json +4941 -0
  48. data/lib/rspec_tracer/reporters/html/package.json +29 -0
  49. data/lib/rspec_tracer/reporters/html/src/app.jsx +130 -0
  50. data/lib/rspec_tracer/reporters/html/src/components/AllExamples.jsx +86 -0
  51. data/lib/rspec_tracer/reporters/html/src/components/DuplicateExamples.jsx +68 -0
  52. data/lib/rspec_tracer/reporters/html/src/components/ExamplesDependency.jsx +78 -0
  53. data/lib/rspec_tracer/reporters/html/src/components/FilesDependency.jsx +72 -0
  54. data/lib/rspec_tracer/reporters/html/src/components/FlakyExamples.jsx +42 -0
  55. data/lib/rspec_tracer/reporters/html/src/components/ReportTable.jsx +131 -0
  56. data/lib/rspec_tracer/reporters/html/src/components/SearchBar.jsx +19 -0
  57. data/lib/rspec_tracer/reporters/html/src/index.html +23 -0
  58. data/lib/rspec_tracer/reporters/html/src/main.jsx +37 -0
  59. data/lib/rspec_tracer/reporters/html/src/styles.css +434 -0
  60. data/lib/rspec_tracer/reporters/html/vite.config.js +42 -0
  61. data/lib/rspec_tracer/reporters/html_reporter.rb +266 -0
  62. data/lib/rspec_tracer/reporters/json_reporter.rb +88 -0
  63. data/lib/rspec_tracer/reporters/payload_builder.rb +235 -0
  64. data/lib/rspec_tracer/reporters/registry.rb +120 -0
  65. data/lib/rspec_tracer/reporters/terminal_reporter.rb +264 -0
  66. data/lib/rspec_tracer/rspec/README.md +73 -0
  67. data/lib/rspec_tracer/rspec/installation.rb +97 -0
  68. data/lib/rspec_tracer/rspec/metadata.rb +96 -0
  69. data/lib/rspec_tracer/rspec/parallel_tests.rb +459 -0
  70. data/lib/rspec_tracer/rspec/reporter_hook.rb +84 -0
  71. data/lib/rspec_tracer/rspec/runner_hook.rb +239 -0
  72. data/lib/rspec_tracer/source_file.rb +24 -7
  73. data/lib/rspec_tracer/storage/README.md +35 -0
  74. data/lib/rspec_tracer/storage/backend.rb +130 -0
  75. data/lib/rspec_tracer/storage/json_backend.rb +884 -0
  76. data/lib/rspec_tracer/storage/lazy_snapshot.rb +65 -0
  77. data/lib/rspec_tracer/storage/schema.rb +50 -0
  78. data/lib/rspec_tracer/storage/serializer/json.rb +41 -0
  79. data/lib/rspec_tracer/storage/serializer/msgpack.rb +167 -0
  80. data/lib/rspec_tracer/storage/snapshot.rb +141 -0
  81. data/lib/rspec_tracer/storage/sqlite_backend.rb +693 -0
  82. data/lib/rspec_tracer/time_formatter.rb +37 -18
  83. data/lib/rspec_tracer/tracker/README.md +36 -0
  84. data/lib/rspec_tracer/tracker/coverage_adapter.rb +174 -0
  85. data/lib/rspec_tracer/tracker/declared_globs.rb +100 -0
  86. data/lib/rspec_tracer/tracker/dependency_graph.rb +134 -0
  87. data/lib/rspec_tracer/tracker/env_matcher.rb +127 -0
  88. data/lib/rspec_tracer/tracker/env_snapshot.rb +77 -0
  89. data/lib/rspec_tracer/tracker/example_registry.rb +153 -0
  90. data/lib/rspec_tracer/tracker/file_digest.rb +61 -0
  91. data/lib/rspec_tracer/tracker/filter.rb +127 -0
  92. data/lib/rspec_tracer/tracker/input.rb +99 -0
  93. data/lib/rspec_tracer/tracker/io_hooks/file.rb +55 -0
  94. data/lib/rspec_tracer/tracker/io_hooks/io.rb +24 -0
  95. data/lib/rspec_tracer/tracker/io_hooks/json.rb +23 -0
  96. data/lib/rspec_tracer/tracker/io_hooks/kernel.rb +26 -0
  97. data/lib/rspec_tracer/tracker/io_hooks/yaml.rb +38 -0
  98. data/lib/rspec_tracer/tracker/io_hooks.rb +195 -0
  99. data/lib/rspec_tracer/tracker/loaded_files_tracker.rb +295 -0
  100. data/lib/rspec_tracer/tracker/new_file_detector.rb +62 -0
  101. data/lib/rspec_tracer/tracker/whole_suite_invalidators.rb +96 -0
  102. data/lib/rspec_tracer/version.rb +4 -1
  103. data/lib/rspec_tracer.rb +231 -491
  104. metadata +94 -43
  105. data/lib/rspec_tracer/cache.rb +0 -207
  106. data/lib/rspec_tracer/coverage_merger.rb +0 -42
  107. data/lib/rspec_tracer/coverage_reporter.rb +0 -187
  108. data/lib/rspec_tracer/coverage_writer.rb +0 -58
  109. data/lib/rspec_tracer/html_reporter/Rakefile +0 -18
  110. data/lib/rspec_tracer/html_reporter/assets/javascripts/application.js +0 -56
  111. data/lib/rspec_tracer/html_reporter/assets/javascripts/libraries/jquery.js +0 -10881
  112. data/lib/rspec_tracer/html_reporter/assets/javascripts/plugins/datatables.js +0 -15381
  113. data/lib/rspec_tracer/html_reporter/assets/stylesheets/application.css +0 -196
  114. data/lib/rspec_tracer/html_reporter/assets/stylesheets/plugins/datatables.css +0 -459
  115. data/lib/rspec_tracer/html_reporter/assets/stylesheets/plugins/jquery-ui.css +0 -436
  116. data/lib/rspec_tracer/html_reporter/assets/stylesheets/print.css +0 -92
  117. data/lib/rspec_tracer/html_reporter/assets/stylesheets/reset.css +0 -265
  118. data/lib/rspec_tracer/html_reporter/public/application.css +0 -5
  119. data/lib/rspec_tracer/html_reporter/public/application.js +0 -6
  120. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_asc.png +0 -0
  121. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_asc_disabled.png +0 -0
  122. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_both.png +0 -0
  123. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_desc.png +0 -0
  124. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_desc_disabled.png +0 -0
  125. data/lib/rspec_tracer/html_reporter/public/favicon.png +0 -0
  126. data/lib/rspec_tracer/html_reporter/public/loading.gif +0 -0
  127. data/lib/rspec_tracer/html_reporter/reporter.rb +0 -242
  128. data/lib/rspec_tracer/html_reporter/views/duplicate_examples.erb +0 -34
  129. data/lib/rspec_tracer/html_reporter/views/examples.erb +0 -58
  130. data/lib/rspec_tracer/html_reporter/views/examples_dependency.erb +0 -36
  131. data/lib/rspec_tracer/html_reporter/views/files_dependency.erb +0 -36
  132. data/lib/rspec_tracer/html_reporter/views/flaky_examples.erb +0 -38
  133. data/lib/rspec_tracer/html_reporter/views/layout.erb +0 -38
  134. data/lib/rspec_tracer/remote_cache/aws.rb +0 -176
  135. data/lib/rspec_tracer/remote_cache/cache.rb +0 -75
  136. data/lib/rspec_tracer/remote_cache/repo.rb +0 -210
  137. data/lib/rspec_tracer/report_generator.rb +0 -158
  138. data/lib/rspec_tracer/report_merger.rb +0 -68
  139. data/lib/rspec_tracer/report_writer.rb +0 -141
  140. data/lib/rspec_tracer/reporter.rb +0 -204
  141. data/lib/rspec_tracer/rspec_reporter.rb +0 -41
  142. data/lib/rspec_tracer/rspec_runner.rb +0 -56
  143. data/lib/rspec_tracer/ruby_coverage.rb +0 -9
  144. data/lib/rspec_tracer/runner.rb +0 -278
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "rspec-tracer-html-reporter",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "description": "Frontend bundle for RSpec-Tracer's HTML reporter. Built output is committed to dist/; users never run npm.",
7
+ "scripts": {
8
+ "build": "vite build",
9
+ "dev": "vite",
10
+ "lint": "eslint .",
11
+ "lint:fix": "eslint . --fix",
12
+ "format": "prettier --check .",
13
+ "format:fix": "prettier --write ."
14
+ },
15
+ "dependencies": {
16
+ "preact": "10.29.1"
17
+ },
18
+ "devDependencies": {
19
+ "@eslint/js": "9.39.4",
20
+ "@preact/preset-vite": "2.10.5",
21
+ "eslint": "9.39.4",
22
+ "eslint-config-prettier": "10.1.8",
23
+ "eslint-plugin-react": "7.37.5",
24
+ "eslint-plugin-react-hooks": "7.1.1",
25
+ "globals": "17.5.0",
26
+ "prettier": "3.8.3",
27
+ "vite": "8.0.10"
28
+ }
29
+ }
@@ -0,0 +1,130 @@
1
+ import { useMemo, useState } from 'preact/hooks';
2
+ import { AllExamples } from './components/AllExamples.jsx';
3
+ import { DuplicateExamples } from './components/DuplicateExamples.jsx';
4
+ import { FlakyExamples } from './components/FlakyExamples.jsx';
5
+ import { ExamplesDependency } from './components/ExamplesDependency.jsx';
6
+ import { FilesDependency } from './components/FilesDependency.jsx';
7
+
8
+ function buildTabs(payload) {
9
+ const reports = (payload && payload.reports) || {};
10
+ const tabs = [
11
+ {
12
+ id: 'all_examples',
13
+ label: 'All Examples',
14
+ count: (reports.all_examples || []).length,
15
+ render: () => <AllExamples items={reports.all_examples || []} />,
16
+ },
17
+ ];
18
+
19
+ if ((reports.duplicate_examples || []).length > 0) {
20
+ tabs.push({
21
+ id: 'duplicate_examples',
22
+ label: 'Duplicate Examples',
23
+ count: reports.duplicate_examples.length,
24
+ render: () => <DuplicateExamples items={reports.duplicate_examples} />,
25
+ });
26
+ }
27
+
28
+ if ((reports.flaky_examples || []).length > 0) {
29
+ tabs.push({
30
+ id: 'flaky_examples',
31
+ label: 'Flaky Examples',
32
+ count: reports.flaky_examples.length,
33
+ render: () => <FlakyExamples items={reports.flaky_examples} />,
34
+ });
35
+ }
36
+
37
+ tabs.push({
38
+ id: 'examples_dependency',
39
+ label: 'Examples Dependency',
40
+ count: (reports.examples_dependency || []).length,
41
+ render: () => <ExamplesDependency items={reports.examples_dependency || []} />,
42
+ });
43
+
44
+ tabs.push({
45
+ id: 'files_dependency',
46
+ label: 'Files Dependency',
47
+ count: (reports.files_dependency || []).length,
48
+ render: () => <FilesDependency items={reports.files_dependency || []} />,
49
+ });
50
+
51
+ return tabs;
52
+ }
53
+
54
+ export function App({ payload }) {
55
+ const tabs = useMemo(() => buildTabs(payload), [payload]);
56
+ const [activeId, setActiveId] = useState(tabs[0] ? tabs[0].id : null);
57
+ const summary = (payload && payload.summary) || {};
58
+ const generatedAt = (payload && payload.generated_at) || '';
59
+ const runId = (payload && payload.run_id) || '';
60
+ const active = tabs.find((t) => t.id === activeId) || tabs[0];
61
+
62
+ return (
63
+ <div class="report-root">
64
+ <header class="report-header">
65
+ <h1>RSpec Tracer Report</h1>
66
+ <dl class="summary">
67
+ <div class="summary-item">
68
+ <dt>Total</dt>
69
+ <dd>{summary.total_examples ?? 0}</dd>
70
+ </div>
71
+ <div class="summary-item summary-passed">
72
+ <dt>Passed</dt>
73
+ <dd>{summary.passed_examples ?? 0}</dd>
74
+ </div>
75
+ <div class="summary-item summary-failed">
76
+ <dt>Failed</dt>
77
+ <dd>{summary.failed_examples ?? 0}</dd>
78
+ </div>
79
+ <div class="summary-item summary-pending">
80
+ <dt>Pending</dt>
81
+ <dd>{summary.pending_examples ?? 0}</dd>
82
+ </div>
83
+ <div class="summary-item summary-skipped">
84
+ <dt>Skipped</dt>
85
+ <dd>{summary.skipped_examples ?? 0}</dd>
86
+ </div>
87
+ <div class="summary-item">
88
+ <dt>Flaky</dt>
89
+ <dd>{summary.flaky_examples ?? 0}</dd>
90
+ </div>
91
+ </dl>
92
+ <p class="report-meta">
93
+ <span>
94
+ Run <code>{runId}</code>
95
+ </span>
96
+ <span>Generated {generatedAt}</span>
97
+ </p>
98
+ </header>
99
+
100
+ <nav class="tab-bar" role="tablist" aria-label="Report sections">
101
+ {tabs.map((tab) => (
102
+ <button
103
+ key={tab.id}
104
+ type="button"
105
+ role="tab"
106
+ class={`tab-button${tab.id === (active && active.id) ? ' is-active' : ''}`}
107
+ aria-selected={tab.id === (active && active.id)}
108
+ aria-controls={`panel-${tab.id}`}
109
+ id={`tab-${tab.id}`}
110
+ onClick={() => setActiveId(tab.id)}
111
+ >
112
+ {tab.label}
113
+ <span class="tab-count">{tab.count}</span>
114
+ </button>
115
+ ))}
116
+ </nav>
117
+
118
+ {active && (
119
+ <section
120
+ class="report-panel"
121
+ role="tabpanel"
122
+ id={`panel-${active.id}`}
123
+ aria-labelledby={`tab-${active.id}`}
124
+ >
125
+ {active.render()}
126
+ </section>
127
+ )}
128
+ </div>
129
+ );
130
+ }
@@ -0,0 +1,86 @@
1
+ import { ReportTable } from './ReportTable.jsx';
2
+
3
+ const STATUS_CLASS = {
4
+ passed: 'status-passed',
5
+ failed: 'status-failed',
6
+ pending: 'status-pending',
7
+ skipped: 'status-skipped',
8
+ flaky: 'status-flaky',
9
+ interrupted: 'status-failed',
10
+ };
11
+
12
+ function statusClass(status) {
13
+ return STATUS_CLASS[status] || 'status-unknown';
14
+ }
15
+
16
+ function durationText(item) {
17
+ const result = item.execution_result || {};
18
+ const value = result.run_time;
19
+ if (typeof value !== 'number') return '';
20
+ if (value < 0.001) return `${(value * 1000000).toFixed(0)} \u00B5s`;
21
+ if (value < 1) return `${(value * 1000).toFixed(1)} ms`;
22
+ return `${value.toFixed(3)} s`;
23
+ }
24
+
25
+ const COLUMNS = [
26
+ {
27
+ key: 'description',
28
+ label: 'Description',
29
+ sortable: true,
30
+ render: (item) => <td class="cell-description">{item.description || ''}</td>,
31
+ searchValue: (item) => `${item.description || ''} ${item.id || ''}`,
32
+ },
33
+ {
34
+ key: 'location',
35
+ label: 'Location',
36
+ sortable: true,
37
+ render: (item) => (
38
+ <td class="cell-location">
39
+ <code>{item.location || ''}</code>
40
+ </td>
41
+ ),
42
+ searchValue: (item) => item.location || '',
43
+ },
44
+ {
45
+ key: 'status',
46
+ label: 'Status',
47
+ sortable: true,
48
+ render: (item) => (
49
+ <td class="cell-status">
50
+ <span class={`badge ${statusClass(item.status)}`}>{item.status || 'unknown'}</span>
51
+ </td>
52
+ ),
53
+ searchValue: (item) => item.status || '',
54
+ },
55
+ {
56
+ key: 'run_reason',
57
+ label: 'Run reason',
58
+ sortable: true,
59
+ render: (item) => <td class="cell-reason">{item.run_reason || ''}</td>,
60
+ searchValue: (item) => item.run_reason || '',
61
+ },
62
+ {
63
+ key: 'duration',
64
+ label: 'Duration',
65
+ sortable: true,
66
+ className: 'is-numeric',
67
+ render: (item) => <td class="cell-duration is-numeric">{durationText(item)}</td>,
68
+ sortValue: (item) => {
69
+ const result = item.execution_result || {};
70
+ return typeof result.run_time === 'number' ? result.run_time : -1;
71
+ },
72
+ searchValue: (item) => durationText(item),
73
+ },
74
+ ];
75
+
76
+ export function AllExamples({ items }) {
77
+ return (
78
+ <ReportTable
79
+ id="all-examples"
80
+ caption="All examples"
81
+ columns={COLUMNS}
82
+ items={items}
83
+ emptyMessage="No examples tracked in this run."
84
+ />
85
+ );
86
+ }
@@ -0,0 +1,68 @@
1
+ import { ReportTable } from './ReportTable.jsx';
2
+
3
+ function flatten(items) {
4
+ const rows = [];
5
+ items.forEach((group) => {
6
+ (group.entries || []).forEach((entry, index) => {
7
+ rows.push({
8
+ id: `${group.id}-${index}`,
9
+ groupId: group.id,
10
+ count: group.count,
11
+ description: entry.description || '',
12
+ location: entry.location || '',
13
+ });
14
+ });
15
+ });
16
+ return rows;
17
+ }
18
+
19
+ const COLUMNS = [
20
+ {
21
+ key: 'groupId',
22
+ label: 'Example ID',
23
+ sortable: true,
24
+ render: (item) => (
25
+ <td class="cell-id">
26
+ <code>{item.groupId}</code>
27
+ </td>
28
+ ),
29
+ searchValue: (item) => item.groupId || '',
30
+ },
31
+ {
32
+ key: 'count',
33
+ label: 'Occurrences',
34
+ sortable: true,
35
+ className: 'is-numeric',
36
+ render: (item) => <td class="cell-count is-numeric">{item.count}</td>,
37
+ sortValue: (item) => item.count,
38
+ },
39
+ {
40
+ key: 'description',
41
+ label: 'Description',
42
+ sortable: true,
43
+ render: (item) => <td class="cell-description">{item.description}</td>,
44
+ },
45
+ {
46
+ key: 'location',
47
+ label: 'Location',
48
+ sortable: true,
49
+ render: (item) => (
50
+ <td class="cell-location">
51
+ <code>{item.location}</code>
52
+ </td>
53
+ ),
54
+ },
55
+ ];
56
+
57
+ export function DuplicateExamples({ items }) {
58
+ const rows = flatten(items || []);
59
+ return (
60
+ <ReportTable
61
+ id="duplicate-examples"
62
+ caption="Duplicate examples"
63
+ columns={COLUMNS}
64
+ items={rows}
65
+ emptyMessage="No duplicate examples detected."
66
+ />
67
+ );
68
+ }
@@ -0,0 +1,78 @@
1
+ import { ReportTable } from './ReportTable.jsx';
2
+
3
+ const COLUMNS = [
4
+ {
5
+ key: 'example_id',
6
+ label: 'Example ID',
7
+ sortable: true,
8
+ render: (item) => (
9
+ <td class="cell-id">
10
+ <code>{item.example_id}</code>
11
+ </td>
12
+ ),
13
+ },
14
+ {
15
+ key: 'files_count',
16
+ label: 'Files',
17
+ sortable: true,
18
+ className: 'is-numeric',
19
+ render: (item) => <td class="cell-count is-numeric">{(item.files || []).length}</td>,
20
+ sortValue: (item) => (item.files || []).length,
21
+ searchValue: (item) => String((item.files || []).length),
22
+ },
23
+ {
24
+ key: 'env_keys_count',
25
+ label: 'Env keys',
26
+ sortable: true,
27
+ className: 'is-numeric',
28
+ render: (item) => <td class="cell-count is-numeric">{(item.env_keys || []).length}</td>,
29
+ sortValue: (item) => (item.env_keys || []).length,
30
+ searchValue: (item) => (item.env_keys || []).join(' '),
31
+ },
32
+ {
33
+ key: 'files',
34
+ label: 'Dependencies',
35
+ sortable: false,
36
+ render: (item) => (
37
+ <td class="cell-deps">
38
+ {(item.files || []).length > 0 && (
39
+ <details class="deps-details">
40
+ <summary>{(item.files || []).length} files</summary>
41
+ <ul class="deps-list">
42
+ {(item.files || []).map((file) => (
43
+ <li key={file}>
44
+ <code>{file}</code>
45
+ </li>
46
+ ))}
47
+ </ul>
48
+ </details>
49
+ )}
50
+ {(item.env_keys || []).length > 0 && (
51
+ <details class="deps-details deps-env">
52
+ <summary>{(item.env_keys || []).length} env keys</summary>
53
+ <ul class="deps-list">
54
+ {(item.env_keys || []).map((key) => (
55
+ <li key={key}>
56
+ <code>{key}</code>
57
+ </li>
58
+ ))}
59
+ </ul>
60
+ </details>
61
+ )}
62
+ </td>
63
+ ),
64
+ searchValue: (item) => [...(item.files || []), ...(item.env_keys || [])].join(' '),
65
+ },
66
+ ];
67
+
68
+ export function ExamplesDependency({ items }) {
69
+ return (
70
+ <ReportTable
71
+ id="examples-dependency"
72
+ caption="Examples dependency"
73
+ columns={COLUMNS}
74
+ items={items}
75
+ emptyMessage="No dependencies tracked."
76
+ />
77
+ );
78
+ }
@@ -0,0 +1,72 @@
1
+ import { ReportTable } from './ReportTable.jsx';
2
+
3
+ const COLUMNS = [
4
+ {
5
+ key: 'file_name',
6
+ label: 'File',
7
+ sortable: true,
8
+ render: (item) => (
9
+ <td class="cell-file">
10
+ <code>{item.file_name}</code>
11
+ </td>
12
+ ),
13
+ },
14
+ {
15
+ key: 'example_count',
16
+ label: 'Examples',
17
+ sortable: true,
18
+ className: 'is-numeric',
19
+ render: (item) => <td class="cell-count is-numeric">{item.example_count || 0}</td>,
20
+ sortValue: (item) => item.example_count || 0,
21
+ },
22
+ {
23
+ key: 'spec_file_count',
24
+ label: 'Spec files',
25
+ sortable: true,
26
+ className: 'is-numeric',
27
+ render: (item) => (
28
+ <td class="cell-count is-numeric">{Object.keys(item.spec_files || {}).length}</td>
29
+ ),
30
+ sortValue: (item) => Object.keys(item.spec_files || {}).length,
31
+ searchValue: (item) => Object.keys(item.spec_files || {}).join(' '),
32
+ },
33
+ {
34
+ key: 'spec_files',
35
+ label: 'Dependent spec files',
36
+ sortable: false,
37
+ render: (item) => {
38
+ const entries = Object.entries(item.spec_files || {});
39
+ if (entries.length === 0) {
40
+ return <td class="cell-deps" />;
41
+ }
42
+ return (
43
+ <td class="cell-deps">
44
+ <details class="deps-details">
45
+ <summary>{entries.length} spec files</summary>
46
+ <ul class="deps-list">
47
+ {entries.map(([spec, count]) => (
48
+ <li key={spec}>
49
+ <code>{spec}</code>
50
+ <span class="dep-count">&times;{count}</span>
51
+ </li>
52
+ ))}
53
+ </ul>
54
+ </details>
55
+ </td>
56
+ );
57
+ },
58
+ searchValue: (item) => Object.keys(item.spec_files || {}).join(' '),
59
+ },
60
+ ];
61
+
62
+ export function FilesDependency({ items }) {
63
+ return (
64
+ <ReportTable
65
+ id="files-dependency"
66
+ caption="Files dependency"
67
+ columns={COLUMNS}
68
+ items={items}
69
+ emptyMessage="No file dependencies tracked."
70
+ />
71
+ );
72
+ }
@@ -0,0 +1,42 @@
1
+ import { ReportTable } from './ReportTable.jsx';
2
+
3
+ const COLUMNS = [
4
+ {
5
+ key: 'id',
6
+ label: 'Example ID',
7
+ sortable: true,
8
+ render: (item) => (
9
+ <td class="cell-id">
10
+ <code>{item.id}</code>
11
+ </td>
12
+ ),
13
+ },
14
+ {
15
+ key: 'description',
16
+ label: 'Description',
17
+ sortable: true,
18
+ render: (item) => <td class="cell-description">{item.description || ''}</td>,
19
+ },
20
+ {
21
+ key: 'location',
22
+ label: 'Location',
23
+ sortable: true,
24
+ render: (item) => (
25
+ <td class="cell-location">
26
+ <code>{item.location || ''}</code>
27
+ </td>
28
+ ),
29
+ },
30
+ ];
31
+
32
+ export function FlakyExamples({ items }) {
33
+ return (
34
+ <ReportTable
35
+ id="flaky-examples"
36
+ caption="Flaky examples"
37
+ columns={COLUMNS}
38
+ items={items}
39
+ emptyMessage="No flaky examples detected."
40
+ />
41
+ );
42
+ }
@@ -0,0 +1,131 @@
1
+ import { useMemo, useState } from 'preact/hooks';
2
+ import { SearchBar } from './SearchBar.jsx';
3
+
4
+ // Shared tabular primitive. Columns are declarative:
5
+ // { key, label, sortable?: bool, className?: string,
6
+ // render: (item) => <td>...</td>, sortValue?: (item) => Comparable,
7
+ // searchValue?: (item) => string }
8
+ //
9
+ // sortValue defaults to the cell's searchValue; searchValue defaults to
10
+ // the string form of item[key]. Sort is ascending by default; clicking
11
+ // an already-sorted column toggles descending, clicking again clears.
12
+ export function ReportTable({ id, caption, columns, items, emptyMessage }) {
13
+ const [filter, setFilter] = useState('');
14
+ const [sort, setSort] = useState({ key: null, direction: 'asc' });
15
+
16
+ const searchableColumns = useMemo(
17
+ () => columns.map((col) => ({ ...col, searchValue: searchFn(col) })),
18
+ [columns]
19
+ );
20
+
21
+ const filtered = useMemo(() => {
22
+ if (!filter) return items;
23
+ const needle = filter.toLowerCase();
24
+ return items.filter((item) =>
25
+ searchableColumns.some((col) => {
26
+ const value = col.searchValue(item);
27
+ return value && value.toLowerCase().includes(needle);
28
+ })
29
+ );
30
+ }, [items, filter, searchableColumns]);
31
+
32
+ const sorted = useMemo(() => {
33
+ if (!sort.key) return filtered;
34
+ const col = columns.find((c) => c.key === sort.key);
35
+ if (!col) return filtered;
36
+ const extractor = col.sortValue || searchFn(col);
37
+ const direction = sort.direction === 'desc' ? -1 : 1;
38
+ return [...filtered].sort((a, b) => {
39
+ const av = extractor(a);
40
+ const bv = extractor(b);
41
+ if (av === bv) return 0;
42
+ if (av === null || av === undefined) return 1;
43
+ if (bv === null || bv === undefined) return -1;
44
+ return av > bv ? direction : -direction;
45
+ });
46
+ }, [filtered, sort, columns]);
47
+
48
+ const handleSort = (key, sortable) => {
49
+ if (!sortable) return;
50
+ if (sort.key !== key) {
51
+ setSort({ key, direction: 'asc' });
52
+ } else if (sort.direction === 'asc') {
53
+ setSort({ key, direction: 'desc' });
54
+ } else {
55
+ setSort({ key: null, direction: 'asc' });
56
+ }
57
+ };
58
+
59
+ return (
60
+ <div class="report-table">
61
+ <SearchBar
62
+ id={`${id}-search`}
63
+ value={filter}
64
+ onInput={setFilter}
65
+ placeholder={`Filter ${caption.toLowerCase()}...`}
66
+ />
67
+ <p class="report-table__counts" aria-live="polite">
68
+ Showing {sorted.length} of {items.length}
69
+ </p>
70
+ <table class="data-table" aria-describedby={`${id}-search`}>
71
+ <caption class="visually-hidden">{caption}</caption>
72
+ <thead>
73
+ <tr>
74
+ {columns.map((col) => {
75
+ const isSorted = sort.key === col.key;
76
+ const sortState = isSorted ? sort.direction : 'none';
77
+ return (
78
+ <th
79
+ key={col.key}
80
+ scope="col"
81
+ class={`${col.className || ''} ${col.sortable ? 'is-sortable' : ''}`.trim()}
82
+ aria-sort={
83
+ isSorted ? (sort.direction === 'asc' ? 'ascending' : 'descending') : 'none'
84
+ }
85
+ >
86
+ {col.sortable ? (
87
+ <button
88
+ type="button"
89
+ class="sort-button"
90
+ onClick={() => handleSort(col.key, col.sortable)}
91
+ data-sort={sortState}
92
+ >
93
+ {col.label}
94
+ <span class="sort-indicator" aria-hidden="true">
95
+ {isSorted ? (sort.direction === 'asc' ? '\u25B2' : '\u25BC') : '\u2195'}
96
+ </span>
97
+ </button>
98
+ ) : (
99
+ col.label
100
+ )}
101
+ </th>
102
+ );
103
+ })}
104
+ </tr>
105
+ </thead>
106
+ <tbody>
107
+ {sorted.length === 0 && (
108
+ <tr>
109
+ <td class="report-table__empty" colSpan={columns.length}>
110
+ {emptyMessage || 'No rows to display.'}
111
+ </td>
112
+ </tr>
113
+ )}
114
+ {sorted.map((item, index) => (
115
+ <tr key={item.id || item.example_id || item.file_name || index}>
116
+ {columns.map((col) => col.render(item))}
117
+ </tr>
118
+ ))}
119
+ </tbody>
120
+ </table>
121
+ </div>
122
+ );
123
+ }
124
+
125
+ function searchFn(col) {
126
+ if (col.searchValue) return col.searchValue;
127
+ return (item) => {
128
+ const value = item[col.key];
129
+ return value === null || value === undefined ? '' : String(value);
130
+ };
131
+ }
@@ -0,0 +1,19 @@
1
+ export function SearchBar({ value, onInput, placeholder, id }) {
2
+ return (
3
+ <div class="search-bar">
4
+ <label class="search-label" htmlFor={id}>
5
+ Filter
6
+ </label>
7
+ <input
8
+ id={id}
9
+ class="search-input"
10
+ type="search"
11
+ value={value}
12
+ placeholder={placeholder || 'Type to filter rows...'}
13
+ autoComplete="off"
14
+ spellCheck={false}
15
+ onInput={(event) => onInput(event.currentTarget.value)}
16
+ />
17
+ </div>
18
+ );
19
+ }
@@ -0,0 +1,23 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta name="generator" content="rspec-tracer" />
7
+ <title>RSpec Tracer Report</title>
8
+ </head>
9
+ <body>
10
+ <noscript>
11
+ <div class="noscript-banner">
12
+ JavaScript is disabled. Interactive search and sort are unavailable; the static tables below
13
+ contain the full run data.
14
+ </div>
15
+ </noscript>
16
+ <div id="app" data-hydrate="pending"></div>
17
+ <!-- RSPEC_TRACER_FALLBACK -->
18
+ <script id="report-data" type="application/json">
19
+ {}
20
+ </script>
21
+ <script type="module" src="./main.jsx"></script>
22
+ </body>
23
+ </html>