allure-report-manager 1.0.1

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 ADDED
@@ -0,0 +1,141 @@
1
+ # Allure Results Manager
2
+
3
+ Dashboard that parses a raw Allure results folder, lists every test case with
4
+ its status grouped by suite, and lets you override a test's status (with a
5
+ reason) — stats recalculate immediately to reflect the override.
6
+
7
+ ## Install
8
+
9
+ Install it in any project as a dev dependency:
10
+
11
+ ```bash
12
+ npm install --save-dev allure-results-manager
13
+ ```
14
+
15
+ or run it without installing:
16
+
17
+ ```bash
18
+ npx allure-results-manager --results ./allure-results
19
+ ```
20
+
21
+ The Allure 3 CLI (the `allure` npm package, no Java needed) comes bundled as
22
+ a dependency, so **Open Allure Report** works without a global install. If the
23
+ bundled copy can't be found, it falls back to `allure` on `PATH`, which
24
+ must be Allure 3 (`allure --version` → `3.x`), not the Java-based Allure 2
25
+ or the old `allure-commandline` package.
26
+
27
+ ## Run
28
+
29
+ ```bash
30
+ npx allure-results-manager [options]
31
+ ```
32
+
33
+ | Flag | Env var | Default |
34
+ | --- | --- | --- |
35
+ | `-r, --results <dir>` | `ALLURE_RESULTS_PATH` | `./allure-results` |
36
+ | `-o, --report <dir>` | `ALLURE_REPORT_PATH` | `./allure-report` |
37
+ | `-d, --data-dir <dir>` | `ALLURE_MANAGER_DATA_DIR` | `./.allure-results-manager` |
38
+ | `-p, --port <port>` | `ALLURE_MANAGER_PORT` | `4400` |
39
+ | `--host <host>` | `ALLURE_MANAGER_HOST` | `localhost` |
40
+ | `--env-file <file>` | | `./.env` if present |
41
+ | `--open` | | opens the dashboard in your browser |
42
+
43
+ Flags win over env vars, and env vars win over defaults. Relative paths
44
+ resolve from the directory you run the command in. A `.env` in that
45
+ directory is loaded if present (see `.env.example`). Real environment
46
+ variables take precedence over values in `.env`.
47
+
48
+ A typical `package.json` script in the consuming project:
49
+
50
+ ```json
51
+ "scripts": {
52
+ "allure:manage": "allure-results-manager --results ./allure-results --open"
53
+ }
54
+ ```
55
+
56
+ Add `.allure-results-manager/` (the overrides store) and your report folder
57
+ to that project's `.gitignore`, unless you want overrides committed.
58
+
59
+ ### Programmatic use
60
+
61
+ ```js
62
+ const { startServer, createApp } = require("allure-results-manager");
63
+
64
+ const { url, server } = await startServer({
65
+ resultsPath: "./allure-results",
66
+ reportPath: "./allure-report",
67
+ port: 4400,
68
+ });
69
+ // or mount createApp({...}) inside your own Express/http server.
70
+ ```
71
+
72
+ ### Developing this package
73
+
74
+ ```bash
75
+ npm install
76
+ npm start # runs the CLI against ./allure-results
77
+ npm pack # builds the tarball to test-install elsewhere
78
+ ```
79
+
80
+ ## How it works
81
+
82
+ - The server scans `ALLURE_RESULTS_PATH` for `*-result.json` files and loads
83
+ every test case (any status), deduping reruns by `historyId` and keeping
84
+ the latest attempt.
85
+ - Test cases are grouped into collapsible sections by suite (`subSuite`,
86
+ falling back to `suite`/`package`), so you can see which section each case
87
+ belongs to. Sections default to collapsed; click a header to expand it.
88
+ Each section header shows its own pass/fail/broken/skipped/unknown counts.
89
+ - Each test card shows its status, message/trace, its own recorded steps
90
+ (nested, with per-step pass/fail status — via **Steps (N)**), and a
91
+ **Change status** action to set a new status (`passed`, `failed`, `broken`,
92
+ `skipped`, `unknown`) plus a reason. **Revert to original** clears the
93
+ override. Fixture (`@Before`/`@After`) steps are intentionally excluded —
94
+ only the test's own steps are shown.
95
+ - Overrides persist in `.allure-results-manager/overrides.json`, keyed by test id, and are
96
+ applied on top of the raw Allure status whenever tests are loaded.
97
+ Setting a status also **rewrites the `status`/`statusDetails` fields of the
98
+ underlying `*-result.json` file** (everything else — steps, labels,
99
+ attachments — is left untouched), so `allure generate`/`allure serve` on
100
+ the same `ALLURE_RESULTS_PATH` will reflect the change too. The
101
+ `statusDetails.message`/`trace` are replaced with a note recording the new
102
+ status, reason, and timestamp (with the original trace appended). The
103
+ test's original status/message/trace are snapshotted in
104
+ `.allure-results-manager/overrides.json` the first time it's overridden; **Revert to
105
+ original** restores them in the raw file and clears the override.
106
+ - The stats tiles at the top are computed from the *effective* status
107
+ (original status with any override applied) across **all** tests,
108
+ regardless of the current filter/search — so changing a test's status
109
+ immediately updates the pass/fail/broken/skipped/unknown counts and pass
110
+ rate. Clicking a status tile (Total/Passed/Failed/Broken/Skipped/Unknown)
111
+ filters the list to that status and auto-expands the matching sections.
112
+ - The status dropdown and the search box (matches test name) both filter the
113
+ grouped list; a search or status filter is active auto-expands sections so
114
+ matches are immediately visible.
115
+ - **Overrides modify the raw results.** `.allure-results-manager/overrides.json` is the source
116
+ of truth for the original status (used to restore on revert) and is what
117
+ the dashboard reads to know which tests are overridden, but the raw
118
+ `*-result.json` file for the test is edited in place too. If
119
+ `.allure-results-manager/overrides.json` is deleted or lost after a test has been overridden,
120
+ the original status can no longer be restored automatically.
121
+ - **Open Allure Report** (top-right button) runs `allure generate` against
122
+ `ALLURE_RESULTS_PATH` into `ALLURE_REPORT_PATH` (the folder is deleted and regenerated fresh each time,
123
+ and paths such as the project root or the results folder are refused) and opens it in a new tab at `/report/`.
124
+ Since overrides are written through to the raw result files, the report
125
+ reflects any status changes made here.
126
+
127
+ ## API
128
+
129
+ - `GET /api/status` — Allure results path and whether it exists.
130
+ - `GET /api/tests?status=&search=&page=&pageSize=` — flat, paginated list of
131
+ test cases with effective status and any override (`pageSize` default 50,
132
+ max 200).
133
+ - `GET /api/tests/grouped?status=&search=` — test cases grouped by suite,
134
+ each group with its own `stats` block (used by the dashboard).
135
+ - `GET /api/stats` — counts by status, override count, and pass rate across
136
+ all tests.
137
+ - `PUT /api/tests/:id/status` — body `{ status, reason }`, sets an override.
138
+ - `DELETE /api/tests/:id/status` — clears the override for a test.
139
+ - `GET /api/report/status` — report path and whether it's been generated.
140
+ - `POST /api/report/generate` — runs `allure generate` and returns
141
+ `{ reportUrl }` to open.
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env node
2
+ const path = require("path");
3
+ const { parseArgs } = require("util");
4
+ const { spawn } = require("child_process");
5
+
6
+ const HELP = `Usage: allure-results-manager [options]
7
+
8
+ Dashboard for reviewing Allure raw results and overriding test statuses.
9
+
10
+ Options:
11
+ -r, --results <dir> Allure raw results folder (env ALLURE_RESULTS_PATH, default ./allure-results)
12
+ -o, --report <dir> Generated HTML report folder (env ALLURE_REPORT_PATH, default ./allure-report)
13
+ -d, --data-dir <dir> Where overrides are stored (env ALLURE_MANAGER_DATA_DIR, default ./.allure-results-manager)
14
+ -p, --port <port> Port to listen on (env ALLURE_MANAGER_PORT, default 4400)
15
+ --host <host> Host to bind (env ALLURE_MANAGER_HOST, default localhost)
16
+ --env-file <file> Load env vars from this file (default ./.env if present)
17
+ --open Open the dashboard in a browser once started
18
+ -h, --help Show this help
19
+ -v, --version Show version
20
+
21
+ Relative paths are resolved from the current working directory.`;
22
+
23
+ function openBrowser(url) {
24
+ const [cmd, args] =
25
+ process.platform === "win32"
26
+ ? ["cmd", ["/c", "start", "", url]]
27
+ : process.platform === "darwin"
28
+ ? ["open", [url]]
29
+ : ["xdg-open", [url]];
30
+ spawn(cmd, args, { stdio: "ignore", detached: true }).on("error", () => {}).unref();
31
+ }
32
+
33
+ async function main() {
34
+ let parsed;
35
+ try {
36
+ parsed = parseArgs({
37
+ options: {
38
+ results: { type: "string", short: "r" },
39
+ report: { type: "string", short: "o" },
40
+ "data-dir": { type: "string", short: "d" },
41
+ port: { type: "string", short: "p" },
42
+ host: { type: "string" },
43
+ "env-file": { type: "string" },
44
+ open: { type: "boolean" },
45
+ help: { type: "boolean", short: "h" },
46
+ version: { type: "boolean", short: "v" },
47
+ },
48
+ });
49
+ } catch (err) {
50
+ console.error(`${err.message}\n\n${HELP}`);
51
+ process.exit(1);
52
+ }
53
+ const { values } = parsed;
54
+
55
+ if (values.help) {
56
+ console.log(HELP);
57
+ return;
58
+ }
59
+ if (values.version) {
60
+ console.log(require("../package.json").version);
61
+ return;
62
+ }
63
+
64
+ // Load the consuming project's .env without overriding real env vars.
65
+ require("dotenv").config({
66
+ path: path.resolve(values["env-file"] || ".env"),
67
+ quiet: true,
68
+ });
69
+
70
+ const { startServer } = require("../lib/server");
71
+
72
+ try {
73
+ const { url, config } = await startServer({
74
+ resultsPath: values.results,
75
+ reportPath: values.report,
76
+ dataDir: values["data-dir"],
77
+ port: values.port,
78
+ host: values.host,
79
+ });
80
+ console.log(`Allure Results Manager running at ${url}`);
81
+ console.log(`Allure results: ${config.resultsPath}`);
82
+ console.log(`Allure report: ${config.reportPath}`);
83
+ console.log(`Overrides: ${path.join(config.dataDir, "overrides.json")}`);
84
+ if (values.open) openBrowser(url);
85
+ } catch (err) {
86
+ if (err.code === "EADDRINUSE") {
87
+ console.error(`Port ${err.port} is already in use. Pick another with --port.`);
88
+ } else {
89
+ console.error(err.message);
90
+ }
91
+ process.exit(1);
92
+ }
93
+ }
94
+
95
+ main();
@@ -0,0 +1,527 @@
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
+ <title>Allure Results Manager</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0f1115;
10
+ --panel: #171a21;
11
+ --panel-border: #262b36;
12
+ --text: #e6e9ef;
13
+ --muted: #8891a3;
14
+ --accent: #4f8cff;
15
+ --danger: #e5484d;
16
+ --danger-bg: rgba(229, 72, 77, 0.12);
17
+ --success: #3ecf8e;
18
+ --success-bg: rgba(62, 207, 142, 0.12);
19
+ --warn: #f5a623;
20
+ --warn-bg: rgba(245, 166, 35, 0.12);
21
+ --neutral: #8891a3;
22
+ --neutral-bg: rgba(136, 145, 163, 0.12);
23
+ --radius: 10px;
24
+ }
25
+ * { box-sizing: border-box; }
26
+ body {
27
+ margin: 0;
28
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
29
+ background: var(--bg);
30
+ color: var(--text);
31
+ }
32
+ header {
33
+ padding: 20px 28px;
34
+ border-bottom: 1px solid var(--panel-border);
35
+ display: flex;
36
+ align-items: center;
37
+ justify-content: space-between;
38
+ flex-wrap: wrap;
39
+ gap: 12px;
40
+ }
41
+ header h1 { font-size: 18px; margin: 0; font-weight: 600; }
42
+ header h1 span { color: var(--muted); font-weight: 400; }
43
+ #status-bar {
44
+ display: flex;
45
+ gap: 16px;
46
+ font-size: 13px;
47
+ color: var(--muted);
48
+ flex-wrap: wrap;
49
+ }
50
+ .status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
51
+ .dot-ok { background: var(--success); }
52
+ .dot-bad { background: var(--danger); }
53
+ main { padding: 24px 28px; max-width: 1100px; margin: 0 auto; }
54
+
55
+ .stats {
56
+ display: flex;
57
+ gap: 10px;
58
+ flex-wrap: wrap;
59
+ margin-bottom: 18px;
60
+ }
61
+ .stat-tile {
62
+ background: var(--panel);
63
+ border: 1px solid var(--panel-border);
64
+ border-radius: var(--radius);
65
+ padding: 10px 16px;
66
+ min-width: 96px;
67
+ }
68
+ .stat-tile.clickable { cursor: pointer; }
69
+ .stat-tile.clickable:hover { border-color: var(--accent); }
70
+ .stat-tile.active { border-color: var(--accent); background: rgba(79, 140, 255, 0.08); }
71
+ .stat-tile .value { font-size: 20px; font-weight: 700; }
72
+ .stat-tile .label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); margin-top: 2px; }
73
+ .stat-total .value { color: var(--text); }
74
+ .stat-passed .value { color: var(--success); }
75
+ .stat-failed .value { color: var(--danger); }
76
+ .stat-broken .value { color: var(--warn); }
77
+ .stat-skipped .value { color: var(--neutral); }
78
+ .stat-unknown .value { color: var(--neutral); }
79
+ .stat-overridden .value { color: var(--accent); }
80
+
81
+ .toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; gap: 12px; flex-wrap: wrap; }
82
+ .toolbar .counts { font-size: 13px; color: var(--muted); }
83
+ .toolbar .controls { display: flex; gap: 8px; align-items: center; }
84
+ select, button.refresh, #search-box {
85
+ background: var(--panel);
86
+ border: 1px solid var(--panel-border);
87
+ color: var(--text);
88
+ padding: 8px 14px;
89
+ border-radius: var(--radius);
90
+ cursor: pointer;
91
+ font-size: 13px;
92
+ }
93
+ #search-box { cursor: text; width: 220px; font-family: inherit; }
94
+ #search-box::placeholder { color: var(--muted); }
95
+ button.refresh:hover, select:hover, #search-box:focus { border-color: var(--accent); outline: none; }
96
+
97
+ .card {
98
+ background: var(--panel);
99
+ border: 1px solid var(--panel-border);
100
+ border-radius: var(--radius);
101
+ padding: 16px 18px;
102
+ margin-bottom: 12px;
103
+ }
104
+ .card-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; }
105
+ .card-title { font-size: 14px; font-weight: 600; margin: 0 0 4px; }
106
+ .card-suite { font-size: 12px; color: var(--muted); }
107
+ .badge {
108
+ font-size: 11px;
109
+ font-weight: 600;
110
+ text-transform: uppercase;
111
+ letter-spacing: 0.03em;
112
+ padding: 3px 8px;
113
+ border-radius: 999px;
114
+ white-space: nowrap;
115
+ }
116
+ .badge-passed { background: var(--success-bg); color: var(--success); }
117
+ .badge-failed { background: var(--danger-bg); color: var(--danger); }
118
+ .badge-broken { background: var(--warn-bg); color: var(--warn); }
119
+ .badge-skipped, .badge-unknown { background: var(--neutral-bg); color: var(--neutral); }
120
+ .original-status { font-size: 11px; color: var(--muted); text-decoration: line-through; margin-right: 6px; }
121
+ .message {
122
+ margin-top: 10px;
123
+ font-size: 12px;
124
+ font-family: "SFMono-Regular", Consolas, monospace;
125
+ background: #0b0d11;
126
+ border: 1px solid var(--panel-border);
127
+ border-radius: 8px;
128
+ padding: 10px 12px;
129
+ color: #c9cdd6;
130
+ white-space: pre-wrap;
131
+ max-height: 140px;
132
+ overflow-y: auto;
133
+ }
134
+ .override-note { margin-top: 10px; font-size: 13px; color: var(--accent); }
135
+ .steps {
136
+ margin-top: 10px;
137
+ background: #0b0d11;
138
+ border: 1px solid var(--panel-border);
139
+ border-radius: 8px;
140
+ padding: 10px 14px;
141
+ max-height: 260px;
142
+ overflow-y: auto;
143
+ }
144
+ .steps ul { list-style: none; margin: 0; padding-left: 16px; }
145
+ .steps > ul { padding-left: 0; }
146
+ .steps li { margin: 4px 0; font-size: 12.5px; }
147
+ .step-row { display: flex; align-items: center; gap: 8px; }
148
+ .step-dot { flex: 0 0 auto; width: 7px; height: 7px; border-radius: 50%; }
149
+ .step-dot-passed { background: var(--success); }
150
+ .step-dot-failed { background: var(--danger); }
151
+ .step-dot-broken { background: var(--warn); }
152
+ .step-dot-skipped, .step-dot-unknown { background: var(--neutral); }
153
+ .step-name { color: var(--text); }
154
+ .step-attachment { color: var(--muted); font-size: 11px; margin-left: 15px; }
155
+ .steps-empty { color: var(--muted); font-size: 12px; }
156
+ .hidden { display: none; }
157
+ .actions { display: flex; gap: 8px; margin-top: 12px; }
158
+ .actions button {
159
+ border: none;
160
+ border-radius: 8px;
161
+ padding: 7px 14px;
162
+ font-size: 13px;
163
+ cursor: pointer;
164
+ font-weight: 600;
165
+ }
166
+ .btn-edit { background: var(--accent); color: #04122c; }
167
+ .btn-steps { background: transparent; color: var(--text); border: 1px solid var(--panel-border) !important; }
168
+ .btn-revert { background: transparent; color: var(--muted); border: 1px solid var(--panel-border) !important; }
169
+ .empty { text-align: center; color: var(--muted); padding: 60px 0; }
170
+
171
+ .section { margin-bottom: 18px; }
172
+ .section-header {
173
+ display: flex; align-items: center; justify-content: space-between; gap: 12px;
174
+ background: var(--panel); border: 1px solid var(--panel-border); border-radius: var(--radius);
175
+ padding: 12px 16px; cursor: pointer; user-select: none;
176
+ }
177
+ .section-header:hover { border-color: var(--accent); }
178
+ .section-title { display: flex; align-items: center; gap: 10px; }
179
+ .section-chevron { color: var(--muted); font-size: 11px; transition: transform 0.15s ease; }
180
+ .section-chevron.collapsed { transform: rotate(-90deg); }
181
+ .section-name { font-size: 14px; font-weight: 600; }
182
+ .section-breadcrumb { font-size: 11px; color: var(--muted); }
183
+ .section-counts { display: flex; gap: 8px; align-items: center; font-size: 12px; }
184
+ .section-count { color: var(--muted); }
185
+ .section-count b { color: var(--text); }
186
+ .section-body { padding-top: 12px; }
187
+ .section-body.collapsed { display: none; }
188
+
189
+ .modal-backdrop {
190
+ position: fixed; inset: 0; background: rgba(0,0,0,0.5);
191
+ display: flex; align-items: center; justify-content: center;
192
+ z-index: 10;
193
+ }
194
+ .modal {
195
+ background: var(--panel); border: 1px solid var(--panel-border);
196
+ border-radius: var(--radius); padding: 24px; width: 480px; max-width: 94vw;
197
+ max-height: 88vh; overflow-y: auto;
198
+ }
199
+ .modal h2 { margin: 0 0 12px; font-size: 16px; }
200
+ .modal label { font-size: 12px; color: var(--muted); display: block; margin: 10px 0 4px; }
201
+ .modal input, .modal textarea, .modal select {
202
+ width: 100%; background: #0b0d11; border: 1px solid var(--panel-border);
203
+ color: var(--text); border-radius: 8px; padding: 8px 10px; font-size: 13px;
204
+ font-family: inherit;
205
+ }
206
+ .modal textarea { min-height: 100px; resize: vertical; }
207
+ .modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
208
+ .modal-actions button { border: none; border-radius: 8px; padding: 8px 16px; font-size: 13px; cursor: pointer; font-weight: 600; }
209
+ .btn-save { background: var(--accent); color: #04122c; }
210
+ .btn-cancel { background: transparent; color: var(--muted); border: 1px solid var(--panel-border) !important; }
211
+ </style>
212
+ </head>
213
+ <body>
214
+
215
+ <header>
216
+ <h1>Allure Results Manager <span>&mdash; test case status &amp; stats</span></h1>
217
+ <div id="status-bar">Loading status&hellip;</div>
218
+ <button class="refresh" id="btn-open-report" onclick="openAllureReport()">Open Allure Report</button>
219
+ </header>
220
+
221
+ <main>
222
+ <div class="stats" id="stats"></div>
223
+ <div class="toolbar">
224
+ <div class="counts" id="counts"></div>
225
+ <div class="controls">
226
+ <input id="search-box" type="search" placeholder="Search by test name&hellip;" oninput="onSearchInput()" />
227
+ <select id="filter-status" onchange="onFilterChange()">
228
+ <option value="">All statuses</option>
229
+ <option value="passed">Passed</option>
230
+ <option value="failed">Failed</option>
231
+ <option value="broken">Broken</option>
232
+ <option value="skipped">Skipped</option>
233
+ <option value="unknown">Unknown</option>
234
+ </select>
235
+ <button class="refresh" onclick="loadAll()">Refresh</button>
236
+ </div>
237
+ </div>
238
+ <div id="list"></div>
239
+ </main>
240
+
241
+ <div id="modal-root"></div>
242
+
243
+ <script>
244
+ let currentTests = [];
245
+ let lastStats = null;
246
+ const state = { status: '', search: '' };
247
+ const expandedSections = new Set();
248
+ let searchDebounce = null;
249
+
250
+ async function loadStatus() {
251
+ const res = await fetch('/api/status');
252
+ const s = await res.json();
253
+ const bar = document.getElementById('status-bar');
254
+ bar.innerHTML = `
255
+ <span><span class="status-dot ${s.allureResultsExists ? 'dot-ok' : 'dot-bad'}"></span>Allure results ${s.allureResultsExists ? 'found' : 'missing'}: ${escapeHtml(s.allureResultsPath)}</span>
256
+ `;
257
+ }
258
+
259
+ function escapeHtml(str) {
260
+ return String(str || '').replace(/[&<>"']/g, (c) => ({
261
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
262
+ }[c]));
263
+ }
264
+
265
+ function renderStats(stats) {
266
+ const tiles = [
267
+ { key: '', label: 'Total', value: stats.total, cls: 'stat-total', clickable: true },
268
+ { key: 'passed', label: 'Passed', value: stats.byStatus.passed || 0, cls: 'stat-passed', clickable: true },
269
+ { key: 'failed', label: 'Failed', value: stats.byStatus.failed || 0, cls: 'stat-failed', clickable: true },
270
+ { key: 'broken', label: 'Broken', value: stats.byStatus.broken || 0, cls: 'stat-broken', clickable: true },
271
+ { key: 'skipped', label: 'Skipped', value: stats.byStatus.skipped || 0, cls: 'stat-skipped', clickable: true },
272
+ { key: 'unknown', label: 'Unknown', value: stats.byStatus.unknown || 0, cls: 'stat-unknown', clickable: true },
273
+ { key: null, label: 'Overridden', value: stats.overridden, cls: 'stat-overridden', clickable: false },
274
+ { key: null, label: 'Pass rate', value: stats.passRate + '%', cls: 'stat-passed', clickable: false },
275
+ ];
276
+ document.getElementById('stats').innerHTML = tiles.map(t => {
277
+ const active = t.clickable && state.status === t.key;
278
+ const classes = ['stat-tile', t.cls, t.clickable ? 'clickable' : '', active ? 'active' : ''].filter(Boolean).join(' ');
279
+ const click = t.clickable ? ` onclick="onStatClick('${t.key}')"` : '';
280
+ return `
281
+ <div class="${classes}"${click}>
282
+ <div class="value">${t.value}</div>
283
+ <div class="label">${t.label}</div>
284
+ </div>
285
+ `;
286
+ }).join('');
287
+ }
288
+
289
+ function onStatClick(status) {
290
+ state.status = status;
291
+ document.getElementById('filter-status').value = status;
292
+ if (lastStats) renderStats(lastStats);
293
+ refreshTests();
294
+ }
295
+
296
+ function renderSteps(steps) {
297
+ if (!steps || steps.length === 0) {
298
+ return '<div class="steps-empty">No steps recorded.</div>';
299
+ }
300
+ return `<ul>${renderStepItems(steps)}</ul>`;
301
+ }
302
+
303
+ function renderStepItems(steps) {
304
+ return steps.map((step) => `
305
+ <li>
306
+ <div class="step-row">
307
+ <span class="step-dot step-dot-${step.status}"></span>
308
+ <span class="step-name">${escapeHtml(step.name)}</span>
309
+ </div>
310
+ ${(step.attachments || []).map(a => `<div class="step-attachment">&#128206; ${escapeHtml(a)}</div>`).join('')}
311
+ ${step.steps && step.steps.length ? `<ul>${renderStepItems(step.steps)}</ul>` : ''}
312
+ </li>
313
+ `).join('');
314
+ }
315
+
316
+ function toggleSteps(id) {
317
+ const el = document.getElementById(`steps-${id}`);
318
+ if (!el) return;
319
+ el.classList.toggle('hidden');
320
+ if (!el.classList.contains('hidden') && !el.dataset.rendered) {
321
+ const test = currentTests.find(t => t.id === id);
322
+ el.innerHTML = renderSteps(test.steps);
323
+ el.dataset.rendered = '1';
324
+ }
325
+ }
326
+
327
+ function renderCard(test) {
328
+ const wasOverridden = !!test.override;
329
+ const stepCount = (test.steps || []).length;
330
+ return `
331
+ <div class="card" data-id="${escapeHtml(test.id)}">
332
+ <div class="card-head">
333
+ <div>
334
+ <p class="card-title">${escapeHtml(test.name)}</p>
335
+ <div class="card-suite">${escapeHtml(test.suite || test.fullName)}</div>
336
+ </div>
337
+ <div style="display:flex; gap:6px; align-items:center;">
338
+ ${wasOverridden ? `<span class="original-status">${escapeHtml(test.originalStatus)}</span>` : ''}
339
+ <span class="badge badge-${test.status}">${escapeHtml(test.status)}</span>
340
+ </div>
341
+ </div>
342
+ ${test.statusMessage ? `<div class="message">${escapeHtml(test.statusMessage)}</div>` : ''}
343
+ ${wasOverridden ? `<div class="override-note">Status overridden${test.override.reason ? ': ' + escapeHtml(test.override.reason) : ''}</div>` : ''}
344
+ <div class="actions">
345
+ <button class="btn-edit" onclick="openEdit('${escapeHtml(test.id)}')">Change status</button>
346
+ <button class="btn-steps" onclick="toggleSteps('${escapeHtml(test.id)}')">Steps (${stepCount})</button>
347
+ ${wasOverridden ? `<button class="btn-revert" onclick="revert('${escapeHtml(test.id)}')">Revert to original</button>` : ''}
348
+ </div>
349
+ <div class="steps hidden" id="steps-${escapeHtml(test.id)}"></div>
350
+ </div>
351
+ `;
352
+ }
353
+
354
+ function sectionCountsHtml(stats) {
355
+ const parts = [
356
+ ['passed', stats.byStatus.passed || 0],
357
+ ['failed', stats.byStatus.failed || 0],
358
+ ['broken', stats.byStatus.broken || 0],
359
+ ['skipped', stats.byStatus.skipped || 0],
360
+ ['unknown', stats.byStatus.unknown || 0],
361
+ ].filter(([, count]) => count > 0);
362
+ return parts.map(([status, count]) => `<span class="section-count">${count} <b class="badge badge-${status}" style="padding:1px 6px;">${status}</b></span>`).join('');
363
+ }
364
+
365
+ function renderSection(group) {
366
+ const collapsed = !state.search && !state.status && !expandedSections.has(group.key);
367
+ const breadcrumb = [group.parentSuite, group.suite].filter(Boolean).filter(v => v !== group.subSuite).join(' / ');
368
+ return `
369
+ <div class="section">
370
+ <div class="section-header" data-key="${escapeHtml(group.key)}" onclick="toggleSection(this)">
371
+ <div class="section-title">
372
+ <span class="section-chevron ${collapsed ? 'collapsed' : ''}">&#9660;</span>
373
+ <div>
374
+ <div class="section-name">${escapeHtml(group.subSuite || group.suite || group.key)} <span style="color:var(--muted); font-weight:400;">(${group.stats.total})</span></div>
375
+ ${breadcrumb ? `<div class="section-breadcrumb">${escapeHtml(breadcrumb)}</div>` : ''}
376
+ </div>
377
+ </div>
378
+ <div class="section-counts">${sectionCountsHtml(group.stats)}</div>
379
+ </div>
380
+ <div class="section-body ${collapsed ? 'collapsed' : ''}">
381
+ ${group.tests.map(renderCard).join('')}
382
+ </div>
383
+ </div>
384
+ `;
385
+ }
386
+
387
+ function toggleSection(header) {
388
+ const key = header.dataset.key;
389
+ if (expandedSections.has(key)) {
390
+ expandedSections.delete(key);
391
+ } else {
392
+ expandedSections.add(key);
393
+ }
394
+ header.querySelector('.section-chevron').classList.toggle('collapsed');
395
+ header.nextElementSibling.classList.toggle('collapsed');
396
+ }
397
+
398
+ function renderList(data) {
399
+ const list = document.getElementById('list');
400
+ const counts = document.getElementById('counts');
401
+
402
+ counts.textContent = data.total === 0
403
+ ? 'No test cases found'
404
+ : `Showing ${data.total} test case(s) across ${data.groups.length} section(s)${state.status ? ' — filtered by ' + state.status : ''}`;
405
+
406
+ list.innerHTML = data.groups.length === 0
407
+ ? '<div class="empty">No test cases match this filter.</div>'
408
+ : data.groups.map(renderSection).join('');
409
+ }
410
+
411
+ async function fetchGroupedTests() {
412
+ const params = new URLSearchParams();
413
+ if (state.status) params.set('status', state.status);
414
+ if (state.search) params.set('search', state.search);
415
+ const res = await fetch(`/api/tests/grouped?${params.toString()}`);
416
+ return res.json();
417
+ }
418
+
419
+ async function refreshTests() {
420
+ const data = await fetchGroupedTests();
421
+ currentTests = data.groups.flatMap(g => g.tests);
422
+ renderList(data);
423
+ }
424
+
425
+ async function loadAll() {
426
+ await loadStatus();
427
+ const [testsData, statsRes] = await Promise.all([fetchGroupedTests(), fetch('/api/stats')]);
428
+ currentTests = testsData.groups.flatMap(g => g.tests);
429
+ lastStats = await statsRes.json();
430
+ renderStats(lastStats);
431
+ renderList(testsData);
432
+ }
433
+
434
+ function onFilterChange() {
435
+ state.status = document.getElementById('filter-status').value;
436
+ if (lastStats) renderStats(lastStats);
437
+ refreshTests();
438
+ }
439
+
440
+ function onSearchInput() {
441
+ clearTimeout(searchDebounce);
442
+ searchDebounce = setTimeout(() => {
443
+ state.search = document.getElementById('search-box').value;
444
+ refreshTests();
445
+ }, 250);
446
+ }
447
+
448
+ function closeModal() {
449
+ document.getElementById('modal-root').innerHTML = '';
450
+ }
451
+
452
+ function openEdit(id) {
453
+ const test = currentTests.find(t => t.id === id);
454
+ const options = ['passed', 'failed', 'broken', 'skipped', 'unknown']
455
+ .map(s => `<option value="${s}" ${s === test.status ? 'selected' : ''}>${s}</option>`)
456
+ .join('');
457
+
458
+ document.getElementById('modal-root').innerHTML = `
459
+ <div class="modal-backdrop">
460
+ <div class="modal">
461
+ <h2>Change status: ${escapeHtml(test.name)}</h2>
462
+ <label>Status</label>
463
+ <select id="edit-status">${options}</select>
464
+ <label>Reason</label>
465
+ <textarea id="edit-reason" placeholder="Why is this status being changed?">${escapeHtml(test.override ? test.override.reason : '')}</textarea>
466
+ <div class="modal-actions">
467
+ <button class="btn-cancel" onclick="closeModal()">Cancel</button>
468
+ <button class="btn-save" onclick="submitEdit('${escapeHtml(id)}')">Save</button>
469
+ </div>
470
+ </div>
471
+ </div>
472
+ `;
473
+ }
474
+
475
+ async function submitEdit(id) {
476
+ const status = document.getElementById('edit-status').value;
477
+ const reason = document.getElementById('edit-reason').value;
478
+
479
+ const res = await fetch(`/api/tests/${encodeURIComponent(id)}/status`, {
480
+ method: 'PUT',
481
+ headers: { 'Content-Type': 'application/json' },
482
+ body: JSON.stringify({ status, reason })
483
+ });
484
+ const data = await res.json().catch(() => ({}));
485
+ if (!res.ok) {
486
+ alert(data.error || 'Failed to update status');
487
+ return;
488
+ }
489
+ closeModal();
490
+ await loadAll();
491
+ }
492
+
493
+ async function revert(id) {
494
+ const res = await fetch(`/api/tests/${encodeURIComponent(id)}/status`, { method: 'DELETE' });
495
+ if (!res.ok) {
496
+ const data = await res.json().catch(() => ({}));
497
+ alert(data.error || 'Failed to revert status');
498
+ return;
499
+ }
500
+ await loadAll();
501
+ }
502
+
503
+ async function openAllureReport() {
504
+ const btn = document.getElementById('btn-open-report');
505
+ const original = btn.textContent;
506
+ btn.disabled = true;
507
+ btn.textContent = 'Generating report…';
508
+ try {
509
+ const res = await fetch('/api/report/generate', { method: 'POST' });
510
+ const data = await res.json().catch(() => ({}));
511
+ if (!res.ok) {
512
+ alert(data.error || 'Failed to generate Allure report');
513
+ return;
514
+ }
515
+ window.open(data.reportUrl, '_blank');
516
+ } catch (err) {
517
+ alert('Failed to generate Allure report');
518
+ } finally {
519
+ btn.disabled = false;
520
+ btn.textContent = original;
521
+ }
522
+ }
523
+
524
+ loadAll();
525
+ </script>
526
+ </body>
527
+ </html>
package/index.js ADDED
@@ -0,0 +1,15 @@
1
+ const { createApp, startServer } = require("./lib/server");
2
+ const { resolveConfig } = require("./lib/config");
3
+ const { createStore } = require("./lib/store");
4
+ const { loadAllTests, STATUSES } = require("./lib/allure");
5
+ const { computeStats } = require("./lib/stats");
6
+
7
+ module.exports = {
8
+ createApp,
9
+ startServer,
10
+ resolveConfig,
11
+ createStore,
12
+ loadAllTests,
13
+ computeStats,
14
+ STATUSES,
15
+ };
@@ -0,0 +1,53 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const { execFile } = require("child_process");
4
+
5
+ /**
6
+ * Finds the `cli.js` of the bundled `allure` (Allure 3) dependency. The
7
+ * package doesn't export `./package.json`, so walk up from its resolved entry
8
+ * point to the package root.
9
+ */
10
+ function findBundledCli() {
11
+ let entry;
12
+ try {
13
+ entry = require.resolve("allure");
14
+ } catch (err) {
15
+ return null;
16
+ }
17
+
18
+ let dir = path.dirname(entry);
19
+ while (dir !== path.dirname(dir)) {
20
+ const pkgFile = path.join(dir, "package.json");
21
+ if (fs.existsSync(pkgFile)) {
22
+ try {
23
+ const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf8"));
24
+ if (pkg.name === "allure") {
25
+ const bin = typeof pkg.bin === "string" ? pkg.bin : pkg.bin && pkg.bin.allure;
26
+ const cli = bin && path.join(dir, bin);
27
+ return cli && fs.existsSync(cli) ? cli : null;
28
+ }
29
+ } catch (err) {
30
+ return null;
31
+ }
32
+ }
33
+ dir = path.dirname(dir);
34
+ }
35
+ return null;
36
+ }
37
+
38
+ /**
39
+ * Runs the Allure CLI, preferring the bundled Allure 3 and falling back to
40
+ * whatever `allure` is on PATH.
41
+ */
42
+ function runAllure(args, callback) {
43
+ const bundled = findBundledCli();
44
+ if (bundled) {
45
+ return execFile(process.execPath, [bundled, ...args], callback);
46
+ }
47
+ // PATH fallback needs a shell to resolve allure.cmd on Windows; quote args
48
+ // so paths with spaces survive.
49
+ const quoted = args.map((a) => `"${String(a).replace(/"/g, '\\"')}"`);
50
+ return execFile("allure", quoted, { shell: true }, callback);
51
+ }
52
+
53
+ module.exports = { findBundledCli, runAllure };
package/lib/allure.js ADDED
@@ -0,0 +1,106 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const crypto = require("crypto");
4
+
5
+ const STATUSES = ["passed", "failed", "broken", "skipped", "unknown"];
6
+
7
+ function stableId(result) {
8
+ if (result.historyId) return result.historyId;
9
+ const basis = result.fullName || result.name || result.uuid || "";
10
+ return crypto.createHash("md5").update(basis).digest("hex");
11
+ }
12
+
13
+ function readResultFile(filePath) {
14
+ try {
15
+ const raw = fs.readFileSync(filePath, "utf8");
16
+ return JSON.parse(raw);
17
+ } catch (err) {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ function labelValue(labels, name) {
23
+ const label = (labels || []).find((l) => l.name === name);
24
+ return label ? label.value : null;
25
+ }
26
+
27
+ function normalizeSteps(steps) {
28
+ return (steps || []).map((step) => ({
29
+ name: step.name || "",
30
+ status: step.status || "unknown",
31
+ start: step.start || null,
32
+ stop: step.stop || null,
33
+ attachments: (step.attachments || []).map((a) => a.name || a.source),
34
+ steps: normalizeSteps(step.steps),
35
+ }));
36
+ }
37
+
38
+ function toTestCase(result, filePath) {
39
+ const statusDetails = result.statusDetails || {};
40
+ const labels = result.labels || [];
41
+
42
+ return {
43
+ id: stableId(result),
44
+ uuid: result.uuid,
45
+ historyId: result.historyId || null,
46
+ name: result.name || result.fullName || "Unnamed test",
47
+ fullName: result.fullName || result.name || "",
48
+ parentSuite: labelValue(labels, "parentSuite"),
49
+ suite: labelValue(labels, "suite"),
50
+ subSuite: labelValue(labels, "subSuite"),
51
+ package: labelValue(labels, "package"),
52
+ severity: labelValue(labels, "severity"),
53
+ status: result.status || "unknown",
54
+ statusMessage: statusDetails.message || "",
55
+ statusTrace: statusDetails.trace || "",
56
+ steps: normalizeSteps(result.steps),
57
+ description: result.description || "",
58
+ start: result.start || null,
59
+ stop: result.stop || null,
60
+ resultFilePath: filePath,
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Scans an Allure raw results directory and returns every test case
66
+ * (any status), deduped by historyId so reruns keep only the latest.
67
+ */
68
+ function loadAllTests(resultsDir) {
69
+ if (!resultsDir || !fs.existsSync(resultsDir)) {
70
+ return [];
71
+ }
72
+
73
+ const resultFiles = fs
74
+ .readdirSync(resultsDir)
75
+ .filter((f) => f.endsWith("-result.json"));
76
+
77
+ const byId = new Map();
78
+
79
+ for (const file of resultFiles) {
80
+ const filePath = path.join(resultsDir, file);
81
+ const result = readResultFile(filePath);
82
+ if (!result) continue;
83
+
84
+ const test = toTestCase(result, filePath);
85
+ const existing = byId.get(test.id);
86
+ if (!existing || (test.stop || 0) > (existing.stop || 0)) {
87
+ byId.set(test.id, test);
88
+ }
89
+ }
90
+
91
+ return Array.from(byId.values()).sort((a, b) => (b.stop || 0) - (a.stop || 0));
92
+ }
93
+
94
+ /**
95
+ * Overwrites the `status`/`statusDetails` fields of a raw *-result.json file
96
+ * in place, preserving everything else (steps, labels, attachments, etc.).
97
+ */
98
+ function writeResultStatus(filePath, { status, message, trace }) {
99
+ const raw = fs.readFileSync(filePath, "utf8");
100
+ const data = JSON.parse(raw);
101
+ data.status = status;
102
+ data.statusDetails = { ...(data.statusDetails || {}), message, trace };
103
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
104
+ }
105
+
106
+ module.exports = { loadAllTests, writeResultStatus, stableId, STATUSES };
package/lib/config.js ADDED
@@ -0,0 +1,30 @@
1
+ const path = require("path");
2
+
3
+ const DEFAULT_PORT = 4400;
4
+
5
+ /**
6
+ * Resolves runtime options. Precedence: explicit options (CLI flags or the
7
+ * programmatic API) > environment variables > defaults. Relative paths are
8
+ * resolved against `cwd` (the consuming project), never against this package,
9
+ * since it usually lives inside someone else's node_modules.
10
+ */
11
+ function resolveConfig(options = {}, env = process.env) {
12
+ const cwd = path.resolve(options.cwd || process.cwd());
13
+ const pick = (...values) => values.find((v) => v !== undefined && v !== null && v !== "");
14
+
15
+ const port = parseInt(pick(options.port, env.ALLURE_MANAGER_PORT, DEFAULT_PORT), 10);
16
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
17
+ throw new Error(`Invalid port: ${pick(options.port, env.ALLURE_MANAGER_PORT)}`);
18
+ }
19
+
20
+ return {
21
+ cwd,
22
+ port,
23
+ host: pick(options.host, env.ALLURE_MANAGER_HOST, "localhost"),
24
+ resultsPath: path.resolve(cwd, pick(options.resultsPath, env.ALLURE_RESULTS_PATH, "allure-results")),
25
+ reportPath: path.resolve(cwd, pick(options.reportPath, env.ALLURE_REPORT_PATH, "allure-report")),
26
+ dataDir: path.resolve(cwd, pick(options.dataDir, env.ALLURE_MANAGER_DATA_DIR, ".allure-results-manager")),
27
+ };
28
+ }
29
+
30
+ module.exports = { resolveConfig, DEFAULT_PORT };
package/lib/server.js ADDED
@@ -0,0 +1,270 @@
1
+ const path = require("path");
2
+ const fs = require("fs");
3
+ const express = require("express");
4
+
5
+ const { loadAllTests, writeResultStatus, STATUSES } = require("./allure");
6
+ const { createStore } = require("./store");
7
+ const { computeStats } = require("./stats");
8
+ const { resolveConfig } = require("./config");
9
+ const { runAllure } = require("./allure-cli");
10
+
11
+ const DEFAULT_PAGE_SIZE = 50;
12
+ const MAX_PAGE_SIZE = 200;
13
+ const DASHBOARD_DIR = path.join(__dirname, "..", "dashboard");
14
+
15
+ function isSameOrAncestor(candidate, target) {
16
+ const rel = path.relative(candidate, target);
17
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
18
+ }
19
+
20
+ /**
21
+ * The report dir is deleted before every regeneration, so refuse paths where
22
+ * that would wipe something that isn't a report (the project root, the
23
+ * results folder, or a parent of either).
24
+ */
25
+ function unsafeReportPathReason(config) {
26
+ const { reportPath, resultsPath, cwd, dataDir } = config;
27
+ if (reportPath === path.parse(reportPath).root) return "it is a filesystem root";
28
+ if (isSameOrAncestor(reportPath, cwd)) return "it is the project directory or one of its parents";
29
+ if (isSameOrAncestor(reportPath, resultsPath)) return "it contains the Allure results folder";
30
+ if (isSameOrAncestor(reportPath, dataDir)) return "it contains the overrides data folder";
31
+ return null;
32
+ }
33
+
34
+ /**
35
+ * Builds the Express app. `options` accepts { resultsPath, reportPath,
36
+ * dataDir, port, host, cwd }; anything omitted falls back to env vars, then
37
+ * defaults relative to `cwd`.
38
+ */
39
+ function createApp(options = {}) {
40
+ const config = resolveConfig(options);
41
+ const { resultsPath, reportPath } = config;
42
+ const { getOverride, setOverride, clearOverride } = createStore(config.dataDir);
43
+
44
+ const app = express();
45
+ app.locals.config = config;
46
+ app.use(express.json());
47
+ app.use(express.static(DASHBOARD_DIR));
48
+
49
+ function loadMergedTests() {
50
+ const tests = loadAllTests(resultsPath);
51
+ return tests.map((test) => {
52
+ const override = getOverride(test.id);
53
+ return {
54
+ ...test,
55
+ originalStatus: test.status,
56
+ status: override ? override.status : test.status,
57
+ override,
58
+ };
59
+ });
60
+ }
61
+
62
+ function applySearch(tests, search) {
63
+ if (!search) return tests;
64
+ const needle = search.trim().toLowerCase();
65
+ if (!needle) return tests;
66
+ return tests.filter((t) => (t.name || "").toLowerCase().includes(needle));
67
+ }
68
+
69
+ app.get("/api/status", (req, res) => {
70
+ res.json({
71
+ allureResultsPath: resultsPath,
72
+ allureResultsExists: fs.existsSync(resultsPath),
73
+ statuses: STATUSES,
74
+ });
75
+ });
76
+
77
+ app.get("/api/tests", (req, res) => {
78
+ let tests = loadMergedTests();
79
+
80
+ if (req.query.status) {
81
+ tests = tests.filter((t) => t.status === req.query.status);
82
+ }
83
+ tests = applySearch(tests, req.query.search);
84
+
85
+ let pageSize = parseInt(req.query.pageSize, 10);
86
+ if (!Number.isInteger(pageSize) || pageSize < 1) pageSize = DEFAULT_PAGE_SIZE;
87
+ pageSize = Math.min(pageSize, MAX_PAGE_SIZE);
88
+
89
+ const total = tests.length;
90
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
91
+
92
+ let page = parseInt(req.query.page, 10);
93
+ if (!Number.isInteger(page) || page < 1) page = 1;
94
+ page = Math.min(page, totalPages);
95
+
96
+ const start = (page - 1) * pageSize;
97
+ const items = tests.slice(start, start + pageSize);
98
+
99
+ res.json({ items, page, pageSize, total, totalPages });
100
+ });
101
+
102
+ app.get("/api/stats", (req, res) => {
103
+ res.json(computeStats(loadMergedTests()));
104
+ });
105
+
106
+ app.get("/api/tests/grouped", (req, res) => {
107
+ let tests = loadMergedTests();
108
+
109
+ if (req.query.status) {
110
+ tests = tests.filter((t) => t.status === req.query.status);
111
+ }
112
+ tests = applySearch(tests, req.query.search);
113
+
114
+ const groupsByKey = new Map();
115
+ for (const test of tests) {
116
+ const key = test.subSuite || test.suite || test.package || "Ungrouped";
117
+ if (!groupsByKey.has(key)) {
118
+ groupsByKey.set(key, {
119
+ key,
120
+ parentSuite: test.parentSuite,
121
+ suite: test.suite,
122
+ subSuite: test.subSuite,
123
+ tests: [],
124
+ });
125
+ }
126
+ groupsByKey.get(key).tests.push(test);
127
+ }
128
+
129
+ const groups = Array.from(groupsByKey.values())
130
+ .sort((a, b) => a.key.localeCompare(b.key))
131
+ .map((group) => ({ ...group, stats: computeStats(group.tests) }));
132
+
133
+ res.json({ groups, total: tests.length });
134
+ });
135
+
136
+ app.put("/api/tests/:id/status", (req, res) => {
137
+ const { id } = req.params;
138
+ const { status, reason } = req.body || {};
139
+
140
+ if (!STATUSES.includes(status)) {
141
+ return res.status(400).json({
142
+ error: `status must be one of: ${STATUSES.join(", ")}`,
143
+ });
144
+ }
145
+
146
+ const tests = loadAllTests(resultsPath);
147
+ const test = tests.find((t) => t.id === id);
148
+ if (!test) {
149
+ return res.status(404).json({ error: "Test not found in current Allure results" });
150
+ }
151
+
152
+ const existing = getOverride(id);
153
+ const original = (existing && existing.original) || {
154
+ status: test.status,
155
+ statusMessage: test.statusMessage,
156
+ statusTrace: test.statusTrace,
157
+ };
158
+
159
+ const override = setOverride(id, { status, reason, original });
160
+
161
+ const note = `Status overridden to "${status}" via Allure Results Manager on ${override.updatedAt}${
162
+ reason ? `. Reason: ${reason}` : "."
163
+ }`;
164
+ writeResultStatus(test.resultFilePath, {
165
+ status,
166
+ message: note,
167
+ trace: original.statusTrace
168
+ ? `${note}\n\nOriginal trace:\n${original.statusTrace}`
169
+ : note,
170
+ });
171
+
172
+ res.json({ override, stats: computeStats(loadMergedTests()) });
173
+ });
174
+
175
+ app.delete("/api/tests/:id/status", (req, res) => {
176
+ const { id } = req.params;
177
+ const override = getOverride(id);
178
+
179
+ if (override && override.original) {
180
+ const tests = loadAllTests(resultsPath);
181
+ const test = tests.find((t) => t.id === id);
182
+ if (test) {
183
+ writeResultStatus(test.resultFilePath, {
184
+ status: override.original.status,
185
+ message: override.original.statusMessage,
186
+ trace: override.original.statusTrace,
187
+ });
188
+ }
189
+ }
190
+
191
+ clearOverride(id);
192
+ res.json({ stats: computeStats(loadMergedTests()) });
193
+ });
194
+
195
+ app.get("/api/report/status", (req, res) => {
196
+ res.json({
197
+ reportPath,
198
+ reportExists: fs.existsSync(path.join(reportPath, "index.html")),
199
+ });
200
+ });
201
+
202
+ app.post("/api/report/generate", (req, res) => {
203
+ if (!fs.existsSync(resultsPath)) {
204
+ return res.status(400).json({ error: "Allure results path does not exist" });
205
+ }
206
+
207
+ const unsafeReason = unsafeReportPathReason(config);
208
+ if (unsafeReason) {
209
+ return res.status(400).json({
210
+ error: `Refusing to use "${reportPath}" as the report folder because ${unsafeReason}; it is deleted before each regeneration. Point --report / ALLURE_REPORT_PATH at a dedicated folder.`,
211
+ });
212
+ }
213
+
214
+ runAllure(["--version"], (versionErr, versionStdout, versionStderr) => {
215
+ if (versionErr) {
216
+ return res.status(500).json({
217
+ error:
218
+ "Failed to run the Allure CLI. Reinstall allure-results-manager (it bundles Allure 3), or install it globally with: npm install -g allure",
219
+ details: (versionStderr || versionErr.message || "").trim(),
220
+ });
221
+ }
222
+
223
+ const majorVersion = parseInt((versionStdout || "").trim().split(".")[0], 10);
224
+ if (majorVersion !== 3) {
225
+ return res.status(409).json({
226
+ error: `Allure CLI on PATH is version ${versionStdout.trim() || "unknown"}, but Allure 3 is required. Another install (often a Java-based Allure 2 from Homebrew/Scoop/apt, or a leftover allure-commandline package) is shadowing the npm-installed "allure" binary. Run "npm install -g allure" and remove the conflicting one from PATH.`,
227
+ });
228
+ }
229
+
230
+ // Regenerate from scratch so stale pages from a previous run's report
231
+ // (which the CLI doesn't clean up on its own) don't linger.
232
+ fs.rmSync(reportPath, { recursive: true, force: true });
233
+
234
+ runAllure(["generate", resultsPath, "--output", reportPath], (err, stdout, stderr) => {
235
+ if (err) {
236
+ return res.status(500).json({
237
+ error: "Failed to generate the Allure report.",
238
+ details: (stderr || err.message || "").trim(),
239
+ });
240
+ }
241
+ res.json({ ok: true, reportUrl: "/report/index.html" });
242
+ });
243
+ });
244
+ });
245
+
246
+ app.use("/report", express.static(reportPath));
247
+
248
+ return app;
249
+ }
250
+
251
+ /**
252
+ * Creates the app and starts listening. Resolves with { app, server, url,
253
+ * config }; rejects if the port can't be bound (e.g. already in use).
254
+ */
255
+ function startServer(options = {}) {
256
+ const app = createApp(options);
257
+ const { port, host } = app.locals.config;
258
+
259
+ return new Promise((resolve, reject) => {
260
+ const server = app.listen(port, host);
261
+ server.once("error", reject);
262
+ server.once("listening", () => {
263
+ const address = server.address();
264
+ const url = `http://${host}:${address.port}`;
265
+ resolve({ app, server, url, config: app.locals.config });
266
+ });
267
+ });
268
+ }
269
+
270
+ module.exports = { createApp, startServer };
package/lib/stats.js ADDED
@@ -0,0 +1,23 @@
1
+ const { STATUSES } = require("./allure");
2
+
3
+ /**
4
+ * Computes pass/fail/etc counts over a list of merged test cases
5
+ * (each with an effective `status`, i.e. after overrides are applied).
6
+ */
7
+ function computeStats(tests) {
8
+ const byStatus = Object.fromEntries(STATUSES.map((s) => [s, 0]));
9
+ let overridden = 0;
10
+
11
+ for (const test of tests) {
12
+ if (byStatus[test.status] === undefined) byStatus[test.status] = 0;
13
+ byStatus[test.status] += 1;
14
+ if (test.override) overridden += 1;
15
+ }
16
+
17
+ const total = tests.length;
18
+ const passRate = total ? Math.round(((byStatus.passed || 0) / total) * 1000) / 10 : 0;
19
+
20
+ return { total, byStatus, overridden, passRate };
21
+ }
22
+
23
+ module.exports = { computeStats };
package/lib/store.js ADDED
@@ -0,0 +1,59 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+
4
+ /**
5
+ * Override store backed by `<dataDir>/overrides.json`. The data dir lives in
6
+ * the consuming project (not inside this package) so overrides survive
7
+ * reinstalls of node_modules.
8
+ */
9
+ function createStore(dataDir) {
10
+ const overridesFile = path.join(dataDir, "overrides.json");
11
+
12
+ function ensureStore() {
13
+ if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
14
+ if (!fs.existsSync(overridesFile)) fs.writeFileSync(overridesFile, "{}");
15
+ }
16
+
17
+ function loadOverrides() {
18
+ ensureStore();
19
+ try {
20
+ return JSON.parse(fs.readFileSync(overridesFile, "utf8"));
21
+ } catch (err) {
22
+ return {};
23
+ }
24
+ }
25
+
26
+ function saveOverrides(overrides) {
27
+ fs.writeFileSync(overridesFile, JSON.stringify(overrides, null, 2));
28
+ }
29
+
30
+ function getOverride(id) {
31
+ const overrides = loadOverrides();
32
+ return overrides[id] || null;
33
+ }
34
+
35
+ function setOverride(id, { status, reason, original }) {
36
+ const overrides = loadOverrides();
37
+ const existing = overrides[id];
38
+ overrides[id] = {
39
+ status,
40
+ reason: reason || "",
41
+ updatedAt: new Date().toISOString(),
42
+ // Snapshot of the raw result's status/message/trace before the first
43
+ // override, so "Revert to original" can restore the raw file too.
44
+ original: (existing && existing.original) || original || null,
45
+ };
46
+ saveOverrides(overrides);
47
+ return overrides[id];
48
+ }
49
+
50
+ function clearOverride(id) {
51
+ const overrides = loadOverrides();
52
+ delete overrides[id];
53
+ saveOverrides(overrides);
54
+ }
55
+
56
+ return { overridesFile, loadOverrides, getOverride, setOverride, clearOverride };
57
+ }
58
+
59
+ module.exports = { createStore };
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ { "name": "allure-report-manager",
2
+ "version": "1.0.1",
3
+ "description": "Parses an Allure raw results folder, lists every test case with its status, and lets you override a test's status and reason with stats recalculated live",
4
+ "main": "index.js",
5
+ "bin": {
6
+ "allure-results-manager": "bin/allure-results-manager.js"
7
+ },
8
+ "files": [
9
+ "bin/",
10
+ "lib/",
11
+ "dashboard/",
12
+ "index.js",
13
+ "README.md"
14
+ ],
15
+ "scripts": {
16
+ "start": "node bin/allure-results-manager.js"
17
+ },
18
+ "keywords": [
19
+ "allure",
20
+ "allure-report",
21
+ "test-results",
22
+ "dashboard",
23
+ "qa"
24
+ ],
25
+ "engines": {
26
+ "node": ">=18.3"
27
+ },
28
+ "dependencies": {
29
+ "allure": "^3.18.0",
30
+ "dotenv": "^16.4.5",
31
+ "express": "^4.19.2"
32
+ }
33
+ }