reporting-labs 0.6.5 → 0.6.7

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 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']` in the config; they apply to text too.
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
 
@@ -307,6 +307,8 @@ Every option is optional. `npx reporting-labs init` writes them all, with commen
307
307
  | `env` | – | Extra rows on the Environment card |
308
308
  | `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
309
  | `maskKeys` | `[]` | Extra keys to mask as `****` |
310
+ | `maskValues` | `[]` | Literal values to blank wherever they appear, keyed or not |
311
+ | `maskFromEnv` | `true` | Learn the values of sensitive-looking environment variables (`PASSWORD`, `API_TOKEN`) and blank them everywhere |
310
312
  | `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
313
  | `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
314
  | `widgets` | all on | Hide cards: `{ tags: false, timeline: false, ... }`. Failure clusters and the Trend chart are always shown |
@@ -606,7 +608,7 @@ Your Slack step can then read `results.json` for pass / fail counts and top fail
606
608
 
607
609
  ## Java teams
608
610
 
609
- The same report, from Java: `dev.reportinglabs` on Maven Central, for TestNG and JUnit 5, with zero-code add-ons for Selenium and REST Assured, a one-line add-on for Playwright for Java and a Cucumber JVM plugin (one row per scenario, Given/When/Then as steps). Source: [reporting-labs-java](https://github.com/naveenautomationlabs/reporting-labs-java). Guides: [reportinglabs.dev/get-started/java](https://reportinglabs.dev/get-started/java).
611
+ The same report, from Java: `dev.reportinglabs` on Maven Central, for TestNG and JUnit 5, with zero-code add-ons for Selenium, REST Assured and Playwright for Java, and a Cucumber JVM plugin (one row per scenario, Given/When/Then as steps). Source: [reporting-labs-java](https://github.com/naveenautomationlabs/reporting-labs-java). Guides: [reportinglabs.dev/get-started/java](https://reportinglabs.dev/get-started/java).
610
612
 
611
613
  ## Roadmap
612
614
 
package/dist/mask.d.ts CHANGED
@@ -1,7 +1,14 @@
1
- export declare function makeMasker(extraKeys?: string[]): {
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', 'auth', 'otp', 'pin', 'cvv', 'ssn'];
33
- const KEY = `(?:[\\w.-]*?(?:${compound.join('|')})[\\w-]*|${exact.join('|')})`;
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
- const SPOKEN = new RegExp(`\\b(${SPOKEN_KEYS.join('|')})(s?\\b\\s+(?:is|was|of|as)?\\s*['"]?)([^\\s'",;]+)`, 'gi');
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 => /^(Bearer|Basic|Digest|Token)\s/i.test(m) ? m.split(/\s+/)[0] + ' ****' : '****');
48
- s = s.replace(KV, (_m, q1, key, q2, sep, q3, _value) => `${q1}${key}${q2}${sep}${q3}****`);
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) || word === '****' || value === '****')
132
+ if (NOT_A_SECRET.has(word) || value.includes('*'))
52
133
  return m;
53
- const explicit = /\b(is|was|of|as)\b|['"]$/.test(between);
54
- return explicit || looksSecret(value) ? `${key}${between}****` : m;
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) ? `${label}${sep}${q}****` : m);
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.js CHANGED
@@ -57,7 +57,7 @@ class ReportingLabsReporter {
57
57
  this.globalErrors = [];
58
58
  this.globalOutput = [];
59
59
  this.options = options;
60
- this.masker = (0, mask_1.makeMasker)(options.maskKeys ?? []);
60
+ this.masker = (0, mask_1.makeMasker)(options.maskKeys ?? [], { knownValues: options.maskValues ?? [], fromEnv: options.maskFromEnv !== false });
61
61
  }
62
62
  printsToStdio() { return false; }
63
63
  /** Errors outside tests: a spec that throws at load, global setup, a crashed worker. */
@@ -317,6 +317,7 @@ class ReportingLabsReporter {
317
317
  return rows;
318
318
  }
319
319
  toDataBlock(name, raw) {
320
+ name = this.masker.maskStr(String(name)); // a block name can carry a secret too
320
321
  let v;
321
322
  try {
322
323
  v = JSON.parse(raw);
@@ -326,7 +327,10 @@ class ReportingLabsReporter {
326
327
  }
327
328
  if (v && typeof v === 'object' && typeof v.csv === 'string' && Object.keys(v).length === 1) {
328
329
  const { columns, rows } = (0, mask_1.parseCsv)(v.csv);
329
- const masked = rows.map(r => r.map((c, i) => this.masker.isSensitive(columns[i] ?? '') ? '****' : this.masker.maskStr(c)));
330
+ const masked = rows.map(r => r.map((c, i) => { if (this.masker.isSensitive(columns[i] ?? '')) {
331
+ this.masker.learn(c);
332
+ return '****';
333
+ } return this.masker.maskStr(c); }));
330
334
  return { name, kind: 'table', columns, rows: masked };
331
335
  }
332
336
  v = this.masker.mask(v);
@@ -1405,11 +1405,16 @@ function projects(){
1405
1405
  /* ---------- main ---------- */
1406
1406
  function main(){
1407
1407
  const projSel = h('select',{id:'projsel',onchange:e=>{state.project=e.target.value;refresh();}}, h('option',{value:'all'},'All projects'), data.projects.map(p=>h('option',{value:p},p)));
1408
+ // Status filter, the same state the Overview pills, the donut legend and the "f" key drive.
1409
+ const counts = {}; for(const t of data.tests){ const k=isFail(t.outcome)?'failed':t.outcome; counts[k]=(counts[k]||0)+1; }
1410
+ const statusSel = h('select',{id:'statussel','aria-label':'Status',onchange:e=>{state.status=e.target.value;refresh();}},
1411
+ h('option',{value:'all'},'Any status'),
1412
+ [['passed','Passed'],['failed','Failed'],['flaky','Flaky'],['skipped','Skipped']].map(([k,l])=>h('option',{value:k},l+(counts[k]?' ('+counts[k]+')':''))));
1408
1413
  return h('div',{class:'main'},
1409
1414
  h('aside',{class:'list'},
1410
1415
  h('div',{class:'tools'}, h('input',{type:'search',placeholder:'Search tests, tags, files…',oninput:e=>{state.q=e.target.value.toLowerCase();refresh();}}), data.projects.length>1?projSel:null,
1411
1416
  h('div',{class:'seg'}, [['file','Spec files'],['folder','Folders'],['flat','Flat']].map(([k,l])=>h('button',{'data-g':k,'aria-pressed':state.group===k,onclick:()=>{state.group=k;refresh();}},l))),
1412
- DIMS.length? h('div',{class:'dims'}, DIMS.map(d=>h('select',{'data-dim':d,onchange:e=>{state.dims[d]=e.target.value||null;refresh();}}, h('option',{value:''},'Any '+d), dimValues(d).map(v=>h('option',{value:v},v))))) : null),
1417
+ h('div',{class:'dims'}, statusSel, DIMS.map(d=>h('select',{'data-dim':d,onchange:e=>{state.dims[d]=e.target.value||null;refresh();}}, h('option',{value:''},'Any '+d), dimValues(d).map(v=>h('option',{value:v},v)))))),
1413
1418
  h('div',{class:'items',id:'items'}),
1414
1419
  h('div',{class:'hint-kbd'}, h('span',{class:'kbd'},'j'),' / ',h('span',{class:'kbd'},'k'),' next / prev · ',h('span',{class:'kbd'},'f'),' failed only · ',h('span',{class:'kbd'},'/'),' search · ',h('span',{class:'kbd'},'1'),'–',h('span',{class:'kbd'},'5'),' switch view')),
1415
1420
  h('section',{class:'detail',id:'detail'}));
@@ -1428,6 +1433,7 @@ function refresh(){
1428
1433
  document.querySelectorAll('.dim .row').forEach(r=>{ const card=r.closest('.card'); const d=card.querySelector('h2').textContent.replace(/^By /,''); r.setAttribute('aria-pressed', state.dims[d]===r.querySelector('.k').textContent); });
1429
1434
  document.querySelectorAll('select[data-dim]').forEach(sel=>{ sel.value=state.dims[sel.dataset.dim]||''; });
1430
1435
  const ps=$('#projsel'); if(ps) ps.value=state.project;
1436
+ const ss=$('#statussel'); if(ss) ss.value=state.status;
1431
1437
  const vis=visible(), ids=new Set(vis.map(t=>t.id));
1432
1438
  document.querySelectorAll('.cell').forEach(c=>c.classList.toggle('dim',!ids.has(c.dataset.id)));
1433
1439
  const box=$('#items'); box.innerHTML='';
package/dist/template.js CHANGED
@@ -1471,11 +1471,16 @@ function projects(){
1471
1471
  /* ---------- main ---------- */
1472
1472
  function main(){
1473
1473
  const projSel = h('select',{id:'projsel',onchange:e=>{state.project=e.target.value;refresh();}}, h('option',{value:'all'},'All projects'), data.projects.map(p=>h('option',{value:p},p)));
1474
+ // Status filter, the same state the Overview pills, the donut legend and the "f" key drive.
1475
+ const counts = {}; for(const t of data.tests){ const k=isFail(t.outcome)?'failed':t.outcome; counts[k]=(counts[k]||0)+1; }
1476
+ const statusSel = h('select',{id:'statussel','aria-label':'Status',onchange:e=>{state.status=e.target.value;refresh();}},
1477
+ h('option',{value:'all'},'Any status'),
1478
+ [['passed','Passed'],['failed','Failed'],['flaky','Flaky'],['skipped','Skipped']].map(([k,l])=>h('option',{value:k},l+(counts[k]?' ('+counts[k]+')':''))));
1474
1479
  return h('div',{class:'main'},
1475
1480
  h('aside',{class:'list'},
1476
1481
  h('div',{class:'tools'}, h('input',{type:'search',placeholder:'Search tests, tags, files…',oninput:e=>{state.q=e.target.value.toLowerCase();refresh();}}), data.projects.length>1?projSel:null,
1477
1482
  h('div',{class:'seg'}, [['file','Spec files'],['folder','Folders'],['flat','Flat']].map(([k,l])=>h('button',{'data-g':k,'aria-pressed':state.group===k,onclick:()=>{state.group=k;refresh();}},l))),
1478
- DIMS.length? h('div',{class:'dims'}, DIMS.map(d=>h('select',{'data-dim':d,onchange:e=>{state.dims[d]=e.target.value||null;refresh();}}, h('option',{value:''},'Any '+d), dimValues(d).map(v=>h('option',{value:v},v))))) : null),
1483
+ h('div',{class:'dims'}, statusSel, DIMS.map(d=>h('select',{'data-dim':d,onchange:e=>{state.dims[d]=e.target.value||null;refresh();}}, h('option',{value:''},'Any '+d), dimValues(d).map(v=>h('option',{value:v},v)))))),
1479
1484
  h('div',{class:'items',id:'items'}),
1480
1485
  h('div',{class:'hint-kbd'}, h('span',{class:'kbd'},'j'),' / ',h('span',{class:'kbd'},'k'),' next / prev · ',h('span',{class:'kbd'},'f'),' failed only · ',h('span',{class:'kbd'},'/'),' search · ',h('span',{class:'kbd'},'1'),'–',h('span',{class:'kbd'},'5'),' switch view')),
1481
1486
  h('section',{class:'detail',id:'detail'}));
@@ -1494,6 +1499,7 @@ function refresh(){
1494
1499
  document.querySelectorAll('.dim .row').forEach(r=>{ const card=r.closest('.card'); const d=card.querySelector('h2').textContent.replace(/^By /,''); r.setAttribute('aria-pressed', state.dims[d]===r.querySelector('.k').textContent); });
1495
1500
  document.querySelectorAll('select[data-dim]').forEach(sel=>{ sel.value=state.dims[sel.dataset.dim]||''; });
1496
1501
  const ps=$('#projsel'); if(ps) ps.value=state.project;
1502
+ const ss=$('#statussel'); if(ss) ss.value=state.status;
1497
1503
  const vis=visible(), ids=new Set(vis.map(t=>t.id));
1498
1504
  document.querySelectorAll('.cell').forEach(c=>c.classList.toggle('dim',!ids.has(c.dataset.id)));
1499
1505
  const box=$('#items'); box.innerHTML='';
package/dist/types.d.ts CHANGED
@@ -87,6 +87,10 @@ export interface ReportingLabsOptions {
87
87
  links?: Record<string, string | LinkTemplate>;
88
88
  /** Keys (case-insensitive substrings) whose values are masked in test data and API panels. */
89
89
  maskKeys?: string[];
90
+ /** Literal values to blank wherever they appear, keyed or not (the test password, a token). Pass the real ones from CI: process.env.PASSWORD. */
91
+ maskValues?: string[];
92
+ /** Learn the values of PASSWORD / API_TOKEN / *_SECRET environment variables and blank them everywhere (default true). */
93
+ maskFromEnv?: boolean;
90
94
  /** 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
95
  history?: {
92
96
  enabled?: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reporting-labs",
3
- "version": "0.6.5",
3
+ "version": "0.6.7",
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",