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 +141 -0
- package/bin/allure-results-manager.js +95 -0
- package/dashboard/index.html +527 -0
- package/index.js +15 -0
- package/lib/allure-cli.js +53 -0
- package/lib/allure.js +106 -0
- package/lib/config.js +30 -0
- package/lib/server.js +270 -0
- package/lib/stats.js +23 -0
- package/lib/store.js +59 -0
- package/package.json +33 -0
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>— test case status & stats</span></h1>
|
|
217
|
+
<div id="status-bar">Loading status…</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…" 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
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
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">📎 ${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' : ''}">▼</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
|
+
}
|