reporting-labs 0.6.6 → 0.6.8
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 +8 -3
- package/dist/mask.d.ts +8 -1
- package/dist/mask.js +112 -14
- package/dist/reporter.d.ts +7 -0
- package/dist/reporter.js +62 -3
- package/dist/types.d.ts +6 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ Today it ships with a **Playwright** reporter. WebdriverIO, Cypress and Jest/Vit
|
|
|
19
19
|
**1. Install**
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
|
-
npm i -D reporting-labs
|
|
22
|
+
npm i -D reporting-labs@latest
|
|
23
23
|
```
|
|
24
24
|
|
|
25
25
|
**2. Create the config file**
|
|
@@ -134,7 +134,7 @@ await testData(rowsFromExcelOrJson, 'Coupons'); // arr
|
|
|
134
134
|
await testData(fs.readFileSync('data/users.csv', 'utf8'), 'users.csv'); // CSV text → table
|
|
135
135
|
```
|
|
136
136
|
|
|
137
|
-
**Secrets are masked automatically**, wherever they show up: `console.log` output, `log()` lines, `testData()` blocks, API headers and bodies, step titles and assertion messages. Covered forms: `password=x`, `Password: x`, `{ password: 'x' }`, `"password":"x"`, `X-Api-Key: x`, `access_token=x`, "password is x", "with password S3cret@1", `Bearer ...` / `Basic ...`, JWTs, and well-known token formats (GitHub, AWS, Slack, Stripe, Google, GitLab, npm, SendGrid). A failing `expect(token)` shows `Received: "****"`. Add your own keys with `maskKeys: ['otp', 'pan']`
|
|
137
|
+
**Secrets are masked automatically**, wherever they show up: `console.log` output, `log()` lines, `testData()` blocks, API headers and bodies, step titles and assertion messages. Covered forms: `password=x`, `Password: x`, `{ password: 'x' }`, `"password":"x"`, `X-Api-Key: x`, `access_token=x`, "password is x", "with password S3cret@1", `Bearer ...` / `Basic ...`, JWTs, and well-known token formats (GitHub, AWS, Slack, Stripe, Google, GitLab, npm, SendGrid). A failing `expect(token)` shows `Received: "****"`. Also `user:pass@host` in URLs, `curl -u user:pass`, `credentials user:pass`, `password for user X is Y`, `Typed x into password field`, and Luhn-valid card numbers. The masker remembers every value it has masked (and the values of `PASSWORD` / `API_TOKEN` / `*_SECRET` environment variables), so a secret that later appears with no key at all (`Logging in as admin / s3cret`) is blanked too. Add your own keys with `maskKeys: ['otp', 'pan']` and values it cannot know about with `maskValues: [process.env.PASSWORD]`; `maskFromEnv: false` turns the environment learning off.
|
|
138
138
|
|
|
139
139
|
## API calls: recorded on their own
|
|
140
140
|
|
|
@@ -303,10 +303,15 @@ Every option is optional. `npx reporting-labs init` writes them all, with commen
|
|
|
303
303
|
| `title` | `'Test report'` | Title in the header |
|
|
304
304
|
| `logo` | – | Your logo next to the title: `'logo.png'` (a file next to the config, embedded in the report) or an https URL |
|
|
305
305
|
| `project` | – | `{ name, version, team, url }` shown under the title |
|
|
306
|
-
| `metadata` | `{}` | Chips in the header, e.g. `{ env: 'staging', build: '#1842' }`. `build` labels the run in the trend; in CI the run number is used when it is not set |
|
|
306
|
+
| `metadata` | `{}` | Chips in the header, e.g. `{ env: 'staging', build: '#1842' }`. `build` labels the run in the trend; in CI the run number is used when it is not set. The environment name is also read from the process environment and wins over `env` here: `ENV`, `TEST_ENV`, `APP_ENV`, `TARGET_ENV` and friends, or any variable ending in `_ENV` / `_ENVIRONMENT` (`OPENCART_ENV`), so a config that says `local` still labels CI reports `dev`, `qa`, `stage` |
|
|
307
|
+
| `envVar` | – | Name of the variable that holds the environment name, when the detection cannot guess it |
|
|
308
|
+
|
|
309
|
+
**Runtime overrides.** `REPORTING_LABS_METADATA_<KEY>` sets a header chip from the environment (`REPORTING_LABS_METADATA_ENV=qa`, `REPORTING_LABS_METADATA_RELEASE=2.3`) and `REPORTING_LABS_TITLE`, `_THEME`, `_PALETTE`, `_ACCENT`, `_LOGO` the matching option; they win over the config. The env chip resolves in this order: `REPORTING_LABS_METADATA_ENV`, the variable `envVar` names, the conventional names (`ENV`, `TEST_ENV`, `APP_ENV`, `TARGET_ENV`, `CI_ENVIRONMENT_NAME`, anything ending in `_ENV`), then `metadata.env` in the config.
|
|
307
310
|
| `env` | – | Extra rows on the Environment card |
|
|
308
311
|
| `links` | `{}` | Turn meta keys into links. `{id}` is replaced by the value. An object `{ url, display }` builds the URL from several fields of an object passed to `meta()`, see below |
|
|
309
312
|
| `maskKeys` | `[]` | Extra keys to mask as `****` |
|
|
313
|
+
| `maskValues` | `[]` | Literal values to blank wherever they appear, keyed or not |
|
|
314
|
+
| `maskFromEnv` | `true` | Learn the values of sensitive-looking environment variables (`PASSWORD`, `API_TOKEN`) and blank them everywhere |
|
|
310
315
|
| `dimensions` | `['priority','severity','feature','owner']` | Which `meta()` keys get a tab in the Breakdown chart and a dropdown filter on the Tests page. Add your own key, e.g. `'team'`, to get a chart for it |
|
|
311
316
|
| `dimensionOrder` | P0…P4, blocker…trivial | The order values appear in those charts and filters. Only needed for your own values, e.g. `{ severity: ['high','medium','low'] }` |
|
|
312
317
|
| `widgets` | all on | Hide cards: `{ tags: false, timeline: false, ... }`. Failure clusters and the Trend chart are always shown |
|
package/dist/mask.d.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
export
|
|
1
|
+
export interface MaskerOptions {
|
|
2
|
+
/** Literal values to blank wherever they appear, keyed or not. */
|
|
3
|
+
knownValues?: string[];
|
|
4
|
+
/** Learn the values of PASSWORD / API_TOKEN / *_SECRET environment variables (default true). */
|
|
5
|
+
fromEnv?: boolean;
|
|
6
|
+
}
|
|
7
|
+
export declare function makeMasker(extraKeys?: string[], opts?: MaskerOptions): {
|
|
2
8
|
mask: (v: unknown, key?: string) => unknown;
|
|
3
9
|
maskStr: (input: string) => string;
|
|
4
10
|
isSensitive: (k: string) => boolean;
|
|
11
|
+
learn: (value: unknown, minLength?: number) => void;
|
|
5
12
|
};
|
|
6
13
|
export declare function parseCsv(text: string): {
|
|
7
14
|
columns: string[];
|
package/dist/mask.js
CHANGED
|
@@ -18,24 +18,93 @@ const VALUE_PATTERNS = [
|
|
|
18
18
|
/\bnpm_[A-Za-z0-9]{30,}\b/g, // npm
|
|
19
19
|
/\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b/g, // SendGrid
|
|
20
20
|
];
|
|
21
|
+
/** 13 to 19 digits, spaces or dashes between groups allowed; masked only when the Luhn check passes. */
|
|
22
|
+
const CARD_NUMBER = /(?<![\w.-])\d(?:[ -]?\d){12,18}(?![\w.-])/g;
|
|
23
|
+
const luhn = (s) => { let sum = 0, dbl = false, digits = 0; for (let i = s.length - 1; i >= 0; i--) {
|
|
24
|
+
const c = s.charCodeAt(i);
|
|
25
|
+
if (c < 48 || c > 57)
|
|
26
|
+
continue;
|
|
27
|
+
let d = c - 48;
|
|
28
|
+
digits++;
|
|
29
|
+
if (dbl) {
|
|
30
|
+
d *= 2;
|
|
31
|
+
if (d > 9)
|
|
32
|
+
d -= 9;
|
|
33
|
+
}
|
|
34
|
+
sum += d;
|
|
35
|
+
dbl = !dbl;
|
|
36
|
+
} return digits >= 13 && sum % 10 === 0; };
|
|
37
|
+
/** Auth schemes and placeholders that follow a sensitive key but are not the secret. */
|
|
38
|
+
const NOT_A_VALUE = new Set(['bearer', 'basic', 'digest', 'token', 'undefined', 'redacted', 'hidden', 'secret', 'password']);
|
|
21
39
|
/** Words after "password is ..." that are plainly not a secret. */
|
|
22
40
|
const NOT_A_SECRET = new Set(['valid', 'invalid', 'visible', 'hidden', 'required', 'optional', 'missing', 'empty', 'null', 'undefined', 'none',
|
|
23
41
|
'correct', 'incorrect', 'wrong', 'right', 'expired', 'set', 'unset', 'not', 'present', 'absent', 'ok', 'true', 'false', 'masked', 'blank',
|
|
24
42
|
'too', 'the', 'a', 'an', 'same', 'different', 'changed', 'unchanged', 'shown', 'displayed', 'accepted', 'rejected', 'weak', 'strong']);
|
|
25
43
|
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
26
|
-
function makeMasker(extraKeys = []) {
|
|
44
|
+
function makeMasker(extraKeys = [], opts = {}) {
|
|
27
45
|
const keys = [...DEFAULT_KEYS, ...extraKeys.map(k => k.toLowerCase())];
|
|
28
46
|
const isSensitive = (k) => { const l = k.toLowerCase(); return keys.some(s => l === s || l.includes(s)); };
|
|
47
|
+
// ---- values already known to be secrets ----
|
|
48
|
+
// Every value the key patterns have masked, the values under sensitive keys in data blocks and API
|
|
49
|
+
// calls, `maskValues`, and sensitive-looking environment variables. Once known, a value is blanked
|
|
50
|
+
// wherever it shows up later, keyed or not: "Logging in as admin / s3cret" is masked because
|
|
51
|
+
// "password=s3cret" (or a PASSWORD env var) came first.
|
|
52
|
+
const learned = new Set();
|
|
53
|
+
let learnedRe = null, learnedDirty = false;
|
|
54
|
+
const MAX_LEARNED = 500;
|
|
55
|
+
const learn = (value, minLength = 4) => {
|
|
56
|
+
if (typeof value !== 'string')
|
|
57
|
+
return;
|
|
58
|
+
let v = value.trim();
|
|
59
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))
|
|
60
|
+
v = v.slice(1, -1);
|
|
61
|
+
if (v.length < minLength || v.includes('***') || !/[A-Za-z0-9]/.test(v) || learned.size >= MAX_LEARNED)
|
|
62
|
+
return;
|
|
63
|
+
const word = v.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
64
|
+
if (NOT_A_SECRET.has(word) || NOT_A_VALUE.has(word))
|
|
65
|
+
return;
|
|
66
|
+
if (!learned.has(v)) {
|
|
67
|
+
learned.add(v);
|
|
68
|
+
learnedDirty = true;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
const learnedPattern = () => {
|
|
72
|
+
if (!learnedDirty)
|
|
73
|
+
return learnedRe;
|
|
74
|
+
const vals = [...learned].sort((a, b) => b.length - a.length); // longest first
|
|
75
|
+
learnedRe = vals.length ? new RegExp(vals.map(v => /^[A-Za-z0-9_]+$/.test(v) ? `(?<![A-Za-z0-9_])${esc(v)}(?![A-Za-z0-9_])` : esc(v)).join('|'), 'g') : null;
|
|
76
|
+
learnedDirty = false;
|
|
77
|
+
return learnedRe;
|
|
78
|
+
};
|
|
79
|
+
for (const v of opts.knownValues ?? [])
|
|
80
|
+
learn(v, 4);
|
|
81
|
+
if (opts.fromEnv !== false) {
|
|
82
|
+
// PASSWORD=…, API_TOKEN=…, OAUTH_CLIENT_SECRET=… in the environment: the values a CI job injects are the
|
|
83
|
+
// ones that end up in log lines. Short values are skipped so a plain word is not blanked everywhere.
|
|
84
|
+
try {
|
|
85
|
+
for (const [k, v] of Object.entries(process.env))
|
|
86
|
+
if (isSensitive(k))
|
|
87
|
+
learn(v, 6);
|
|
88
|
+
}
|
|
89
|
+
catch { /* no env access */ }
|
|
90
|
+
}
|
|
29
91
|
// Key names as they appear in free text: password, X-Api-Key, access_token, userPassword, "password", db.password...
|
|
30
92
|
// `auth`, `otp`, `pin`... are matched as whole words only, so "author" or "pinned" stay untouched.
|
|
31
|
-
const compound = ['password', 'passwd', 'pwd', 'passcode', 'secret', 'token', 'api[_-]?key', 'cookie', 'credentials?', 'private[_-]?key', 'access[_-]?key', 'session[_-]?id', ...extraKeys.map(esc)];
|
|
32
|
-
const exact = ['authorization', '
|
|
33
|
-
const KEY = `(?:[\\w.-]*?(?:${compound.join('|')})[\\w
|
|
93
|
+
const compound = ['password', 'passwort', 'passwd', 'pwd', 'passcode', 'secret', 'token', 'api[ _-]?key', 'cookie', 'credentials?', 'private[ _-]?key', 'access[ _-]?key', 'session[ _-]?id', '(?<![A-Za-z])auth(?![A-Za-z])', ...extraKeys.map(esc)];
|
|
94
|
+
const exact = ['authorization', 'pass', 'pw', 'otp', 'pin', 'cvv', 'ssn'];
|
|
95
|
+
const KEY = `(?:[\\w.-]*?(?:${compound.join('|')})[\\w.-]*|${exact.join('|')})`;
|
|
34
96
|
// key = value | key: value | "key": "value" | key => value | key -> value (value runs to a delimiter; a closing quote stays)
|
|
35
97
|
const KV = new RegExp(`(["']?)\\b(${KEY})\\b(["']?)(\\s*(?:=>|->|[=:])\\s*)(["']?)([^"'\\s&;,}\\]\\)]+)`, 'gi');
|
|
36
98
|
// password is S3cret | token was abc | password 'S3cret' | with password S3cret@123 (only when the value looks like a secret)
|
|
37
|
-
const SPOKEN_KEYS = ['password', 'passwd', 'pwd', 'passcode', 'secret', 'token', 'api[ _-]?key', 'access[ _-]?key', 'otp', 'pin', 'cvv', ...extraKeys.map(esc)];
|
|
38
|
-
|
|
99
|
+
const SPOKEN_KEYS = ['password', 'passwort', 'passwd', 'pwd', 'pass', 'passcode', 'secret', 'token', 'api[ _-]?key', 'access[ _-]?key', 'otp', 'pin', 'cvv', ...extraKeys.map(esc)];
|
|
100
|
+
// "password is x", "password for user admin is x", "token: x", "the token is: x", "with pwd x"
|
|
101
|
+
const SPOKEN = new RegExp(`\\b(${SPOKEN_KEYS.join('|')})(s?\\b(?:\\s+for\\s+(?:\\S+\\s+){1,3}?(?:[:=]|(?:is|was)\\b)\\s*|\\s*[:=]?\\s*(?:(?:is|was|of|as)\\b\\s*)?[:=]?\\s*)['"]?)((?=[A-Za-z0-9])[^\\s'",;]+)`, 'gi');
|
|
102
|
+
// user:password@host in a URL, curl -u user:password, "credentials admin:x"
|
|
103
|
+
const URL_USERINFO = /(:\/\/[^\s/:@]+:)([^\s@]+)(@)/g;
|
|
104
|
+
const CLI_USER = /((?:^|\s)(?:-u|--user|--username|--credentials?)\s+[^\s:]+:)(\S+)/g;
|
|
105
|
+
const CREDS_PAIR = /\b(credentials?|creds|login)(\s*[:=]?\s+[^\s:'"]+:)([^\s'",;]+)/gi;
|
|
106
|
+
// The value comes first: "Typed s3cret into password field", "Entered x in #password"
|
|
107
|
+
const VALUE_THEN_KEY = /([^\s'"(]+)(\s+(?:into|in|to|for|as)\s+(?:the\s+)?['"#]?[\w-]*?(?:password|passwd|pwd|secret|token|otp|pin)\b)/gi;
|
|
39
108
|
const looksSecret = (v) => !/\s/.test(v) && (/\d/.test(v) && v.length >= 4 || /[^A-Za-z0-9]/.test(v) && v.length >= 6 || /[a-z][A-Z]|[A-Z][a-z].*[A-Z]/.test(v) && v.length >= 8);
|
|
40
109
|
// Assertion output: when the text talks about a password/token/secret, the compared values are that secret.
|
|
41
110
|
// expect(token, 'token should match').toBe('x') -> Expected: "x" / Received: "tok_9f8e..."
|
|
@@ -43,23 +112,52 @@ function makeMasker(extraKeys = []) {
|
|
|
43
112
|
const COMPARED = /\b(Expected|Received|Actual|expected|received|actual|got)(\s*[:=]\s*)(["'\u201c]?)([^"'\u201d\s]+)/g;
|
|
44
113
|
const maskStr = (input) => {
|
|
45
114
|
let s = input;
|
|
115
|
+
s = s.replace(CARD_NUMBER, m => luhn(m) ? '****' : m);
|
|
46
116
|
for (const re of VALUE_PATTERNS)
|
|
47
|
-
s = s.replace(re, m =>
|
|
48
|
-
|
|
117
|
+
s = s.replace(re, m => {
|
|
118
|
+
if (/^(Bearer|Basic|Digest|Token)\s/i.test(m)) {
|
|
119
|
+
const i = m.search(/\s/);
|
|
120
|
+
learn(m.slice(i).trim());
|
|
121
|
+
return m.slice(0, i) + ' ****';
|
|
122
|
+
}
|
|
123
|
+
learn(m);
|
|
124
|
+
return '****';
|
|
125
|
+
});
|
|
126
|
+
s = s.replace(URL_USERINFO, (_m, a, value, b) => { learn(value); return `${a}****${b}`; });
|
|
127
|
+
s = s.replace(CLI_USER, (_m, a, value) => { learn(value); return `${a}****`; });
|
|
128
|
+
s = s.replace(CREDS_PAIR, (_m, a, b, value) => { learn(value); return `${a}${b}****`; });
|
|
129
|
+
s = s.replace(KV, (_m, q1, key, q2, sep, q3, value) => { learn(value); return `${q1}${key}${q2}${sep}${q3}****`; });
|
|
49
130
|
s = s.replace(SPOKEN, (m, key, between, value) => {
|
|
50
131
|
const word = value.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
51
|
-
if (NOT_A_SECRET.has(word) ||
|
|
132
|
+
if (NOT_A_SECRET.has(word) || value.includes('*'))
|
|
52
133
|
return m;
|
|
53
|
-
const explicit = /\b(is|was|of|as)\b|['"]
|
|
54
|
-
|
|
134
|
+
const explicit = /\b(is|was|of|as)\b|['"]$|[:=]\s*$/.test(between);
|
|
135
|
+
if (explicit || looksSecret(value)) {
|
|
136
|
+
learn(value);
|
|
137
|
+
return `${key}${between}****`;
|
|
138
|
+
}
|
|
139
|
+
return m;
|
|
140
|
+
});
|
|
141
|
+
s = s.replace(VALUE_THEN_KEY, (m, value, rest) => {
|
|
142
|
+
const word = value.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
143
|
+
if (NOT_A_SECRET.has(word) || value.includes('*') || !looksSecret(value))
|
|
144
|
+
return m;
|
|
145
|
+
learn(value);
|
|
146
|
+
return `****${rest}`;
|
|
55
147
|
});
|
|
56
148
|
if (MENTIONS_SECRET.test(s))
|
|
57
|
-
s = s.replace(COMPARED, (m, label, sep, q, value) => looksSecret(value)
|
|
149
|
+
s = s.replace(COMPARED, (m, label, sep, q, value) => { if (!looksSecret(value))
|
|
150
|
+
return m; learn(value); return `${label}${sep}${q}****`; });
|
|
151
|
+
const known = learnedPattern();
|
|
152
|
+
if (known)
|
|
153
|
+
s = s.replace(known, '****');
|
|
58
154
|
return s;
|
|
59
155
|
};
|
|
60
156
|
const mask = (v, key = '') => {
|
|
61
|
-
if (key && isSensitive(key))
|
|
157
|
+
if (key && isSensitive(key)) {
|
|
158
|
+
learn(v);
|
|
62
159
|
return '****';
|
|
160
|
+
}
|
|
63
161
|
if (typeof v === 'string')
|
|
64
162
|
return maskStr(v);
|
|
65
163
|
if (Array.isArray(v))
|
|
@@ -72,7 +170,7 @@ function makeMasker(extraKeys = []) {
|
|
|
72
170
|
}
|
|
73
171
|
return v;
|
|
74
172
|
};
|
|
75
|
-
return { mask, maskStr, isSensitive };
|
|
173
|
+
return { mask, maskStr, isSensitive, learn };
|
|
76
174
|
}
|
|
77
175
|
function parseCsv(text) {
|
|
78
176
|
const lines = text.replace(/\r/g, '').split('\n').filter(l => l.trim());
|
package/dist/reporter.d.ts
CHANGED
|
@@ -54,3 +54,10 @@ export default class ReportingLabsReporter implements Reporter {
|
|
|
54
54
|
private serializeStep;
|
|
55
55
|
private serializeAttachment;
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* The environment name the run was pointed at, from the process environment: the variable named
|
|
59
|
+
* by `envVar` if given, else the usual names (ENV, TEST_ENV, APP_ENV, TARGET_ENV, …), else any
|
|
60
|
+
* variable whose name ends in _ENV or _ENVIRONMENT (OPENCART_ENV, app_env). Every project names
|
|
61
|
+
* it differently; this finds it without being told. Undefined when nothing fits.
|
|
62
|
+
*/
|
|
63
|
+
export declare function detectEnvName(env: NodeJS.ProcessEnv, envVar?: string): string | undefined;
|
package/dist/reporter.js
CHANGED
|
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.detectEnvName = detectEnvName;
|
|
36
37
|
const fs = __importStar(require("fs"));
|
|
37
38
|
const path = __importStar(require("path"));
|
|
38
39
|
const os = __importStar(require("os"));
|
|
@@ -56,8 +57,33 @@ class ReportingLabsReporter {
|
|
|
56
57
|
this.masker = (0, mask_1.makeMasker)();
|
|
57
58
|
this.globalErrors = [];
|
|
58
59
|
this.globalOutput = [];
|
|
59
|
-
|
|
60
|
-
|
|
60
|
+
// The env chip. A runtime variable beats the config file, the same precedence as everything
|
|
61
|
+
// else: the ENV / TEST_ENV / ENVIRONMENT / APP_ENV a CI job exports wins over metadata.env in
|
|
62
|
+
// the config, so a config that says env: 'local' still labels the pipeline's reports dev, qa,
|
|
63
|
+
// stage without anyone touching it. The config value applies when no variable is set.
|
|
64
|
+
// Runtime overrides, highest precedence: REPORTING_LABS_METADATA_<KEY> sets a header chip
|
|
65
|
+
// (REPORTING_LABS_METADATA_ENV=qa, REPORTING_LABS_METADATA_RELEASE=2.3) and REPORTING_LABS_TITLE /
|
|
66
|
+
// _THEME / _PALETTE / _ACCENT / _LOGO the matching option, so a pipeline can label a run without
|
|
67
|
+
// touching the config. Same contract as the Java reporter's REPORTING_LABS_* variables.
|
|
68
|
+
const metadata = { ...(options.metadata ?? {}) };
|
|
69
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
70
|
+
const m = /^REPORTING_LABS_METADATA_([A-Z0-9_]+)$/.exec(k);
|
|
71
|
+
if (m && v && v.trim())
|
|
72
|
+
metadata[m[1].toLowerCase()] = v.trim();
|
|
73
|
+
}
|
|
74
|
+
// Below the explicit override, the environment name the run was pointed at, found by convention
|
|
75
|
+
// (ENV, TEST_ENV, APP_ENV, TARGET_ENV, anything ending in _ENV) or the variable `envVar` names.
|
|
76
|
+
// It beats metadata.env in the config: a runtime value wins over the file, as everywhere else.
|
|
77
|
+
if (!process.env.REPORTING_LABS_METADATA_ENV) {
|
|
78
|
+
const detected = detectEnvName(process.env, options.envVar);
|
|
79
|
+
if (detected)
|
|
80
|
+
metadata.env = detected;
|
|
81
|
+
}
|
|
82
|
+
const scalar = (name, current) => { const v = process.env['REPORTING_LABS_' + name]; return v && v.trim() ? v.trim() : current; };
|
|
83
|
+
this.options = { ...options, metadata,
|
|
84
|
+
title: scalar('TITLE', options.title), theme: scalar('THEME', options.theme), palette: scalar('PALETTE', options.palette),
|
|
85
|
+
accent: scalar('ACCENT', options.accent), logo: scalar('LOGO', options.logo) };
|
|
86
|
+
this.masker = (0, mask_1.makeMasker)(options.maskKeys ?? [], { knownValues: options.maskValues ?? [], fromEnv: options.maskFromEnv !== false });
|
|
61
87
|
}
|
|
62
88
|
printsToStdio() { return false; }
|
|
63
89
|
/** Errors outside tests: a spec that throws at load, global setup, a crashed worker. */
|
|
@@ -317,6 +343,7 @@ class ReportingLabsReporter {
|
|
|
317
343
|
return rows;
|
|
318
344
|
}
|
|
319
345
|
toDataBlock(name, raw) {
|
|
346
|
+
name = this.masker.maskStr(String(name)); // a block name can carry a secret too
|
|
320
347
|
let v;
|
|
321
348
|
try {
|
|
322
349
|
v = JSON.parse(raw);
|
|
@@ -326,7 +353,10 @@ class ReportingLabsReporter {
|
|
|
326
353
|
}
|
|
327
354
|
if (v && typeof v === 'object' && typeof v.csv === 'string' && Object.keys(v).length === 1) {
|
|
328
355
|
const { columns, rows } = (0, mask_1.parseCsv)(v.csv);
|
|
329
|
-
const masked = rows.map(r => r.map((c, i) => this.masker.isSensitive(columns[i] ?? '')
|
|
356
|
+
const masked = rows.map(r => r.map((c, i) => { if (this.masker.isSensitive(columns[i] ?? '')) {
|
|
357
|
+
this.masker.learn(c);
|
|
358
|
+
return '****';
|
|
359
|
+
} return this.masker.maskStr(c); }));
|
|
330
360
|
return { name, kind: 'table', columns, rows: masked };
|
|
331
361
|
}
|
|
332
362
|
v = this.masker.mask(v);
|
|
@@ -591,6 +621,35 @@ class ReportingLabsReporter {
|
|
|
591
621
|
}
|
|
592
622
|
exports.default = ReportingLabsReporter;
|
|
593
623
|
/** Run number from the CI system, used to label history entries when metadata.build is not set. */
|
|
624
|
+
/** Variables whose name ends in ENV but never hold an environment name. */
|
|
625
|
+
const NOT_AN_ENV_VAR = new Set(['GITHUB_ENV', 'NODE_ENV', 'BASH_ENV', 'VIRTUAL_ENV', 'CONDA_DEFAULT_ENV', 'RUNNER_ENVIRONMENT', 'PIPENV_ACTIVE', 'ZSH_ENV', 'JAVA_ENV', 'DOTNET_ENVIRONMENT', 'ASPNETCORE_ENVIRONMENT', 'HOSTING_ENVIRONMENT']);
|
|
626
|
+
/** Names every project seems to pick first, in order. */
|
|
627
|
+
const ENV_VAR_NAMES = ['ENV', 'TEST_ENV', 'ENVIRONMENT', 'APP_ENV', 'TARGET_ENV', 'RUN_ENV', 'DEPLOY_ENV', 'ENV_NAME', 'TEST_ENVIRONMENT', 'TARGET_ENVIRONMENT', 'CI_ENVIRONMENT_NAME', 'DEPLOYMENT_ENVIRONMENT', 'STAGE'];
|
|
628
|
+
/** dev, qa, stage-2, app_qa, prod-eu: a short token, never a path, URL or sentence. */
|
|
629
|
+
const looksLikeEnvName = (v) => !!v && /^[A-Za-z][\w.-]{0,31}$/.test(v.trim());
|
|
630
|
+
/**
|
|
631
|
+
* The environment name the run was pointed at, from the process environment: the variable named
|
|
632
|
+
* by `envVar` if given, else the usual names (ENV, TEST_ENV, APP_ENV, TARGET_ENV, …), else any
|
|
633
|
+
* variable whose name ends in _ENV or _ENVIRONMENT (OPENCART_ENV, app_env). Every project names
|
|
634
|
+
* it differently; this finds it without being told. Undefined when nothing fits.
|
|
635
|
+
*/
|
|
636
|
+
function detectEnvName(env, envVar) {
|
|
637
|
+
const get = (name) => { const v = env[name] ?? env[name.toLowerCase()]; return looksLikeEnvName(v) ? v.trim() : undefined; };
|
|
638
|
+
if (envVar)
|
|
639
|
+
return get(envVar) ?? (looksLikeEnvName(env[envVar]) ? env[envVar].trim() : undefined);
|
|
640
|
+
for (const n of ENV_VAR_NAMES) {
|
|
641
|
+
const v = get(n);
|
|
642
|
+
if (v)
|
|
643
|
+
return v;
|
|
644
|
+
}
|
|
645
|
+
const wild = Object.keys(env).filter(k => /_(ENV|ENVIRONMENT|ENV_NAME)$/i.test(k) && !NOT_AN_ENV_VAR.has(k.toUpperCase())).sort();
|
|
646
|
+
for (const k of wild) {
|
|
647
|
+
const v = env[k];
|
|
648
|
+
if (looksLikeEnvName(v))
|
|
649
|
+
return v.trim();
|
|
650
|
+
}
|
|
651
|
+
return undefined;
|
|
652
|
+
}
|
|
594
653
|
function ciRunLabel(env) {
|
|
595
654
|
const n = env.GITHUB_RUN_NUMBER || env.BUILD_NUMBER || env.CI_PIPELINE_IID || env.CIRCLE_BUILD_NUM || env.BUILD_BUILDNUMBER || env.BITBUCKET_BUILD_NUMBER;
|
|
596
655
|
return n ? `#${n}` : undefined;
|
package/dist/types.d.ts
CHANGED
|
@@ -25,6 +25,8 @@ export interface ReportingLabsOptions {
|
|
|
25
25
|
embedVideos?: boolean;
|
|
26
26
|
/** Key/value shown in the header (env, branch, build...). `build` labels the run in the history; when it is not set the CI run number is used. */
|
|
27
27
|
metadata?: Record<string, string>;
|
|
28
|
+
/** Name of the environment variable that holds the environment name, when it is not one the reporter finds by itself (ENV, TEST_ENV, APP_ENV, anything ending in _ENV). */
|
|
29
|
+
envVar?: string;
|
|
28
30
|
/** Extra sections rendered below the summary. HTML is allowed. */
|
|
29
31
|
sections?: Array<{
|
|
30
32
|
title: string;
|
|
@@ -87,6 +89,10 @@ export interface ReportingLabsOptions {
|
|
|
87
89
|
links?: Record<string, string | LinkTemplate>;
|
|
88
90
|
/** Keys (case-insensitive substrings) whose values are masked in test data and API panels. */
|
|
89
91
|
maskKeys?: string[];
|
|
92
|
+
/** Literal values to blank wherever they appear, keyed or not (the test password, a token). Pass the real ones from CI: process.env.PASSWORD. */
|
|
93
|
+
maskValues?: string[];
|
|
94
|
+
/** Learn the values of PASSWORD / API_TOKEN / *_SECRET environment variables and blank them everywhere (default true). */
|
|
95
|
+
maskFromEnv?: boolean;
|
|
90
96
|
/** Keep a rolling run history next to the report and draw a trend chart. Per-test outcomes are stored too, which powers new-vs-known failures, flaky history and duration regressions. */
|
|
91
97
|
history?: {
|
|
92
98
|
enabled?: boolean;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "reporting-labs",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.8",
|
|
4
4
|
"description": "reportingLabs – beautiful, customizable single-file HTML test reports. One line of config. Playwright adapter included, more runners on the way.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"test-report",
|