reporting-labs 0.6.7 → 0.6.9

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
@@ -303,7 +303,10 @@ 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 `****` |
@@ -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,7 +57,32 @@ class ReportingLabsReporter {
56
57
  this.masker = (0, mask_1.makeMasker)();
57
58
  this.globalErrors = [];
58
59
  this.globalOutput = [];
59
- this.options = options;
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) };
60
86
  this.masker = (0, mask_1.makeMasker)(options.maskKeys ?? [], { knownValues: options.maskValues ?? [], fromEnv: options.maskFromEnv !== false });
61
87
  }
62
88
  printsToStdio() { return false; }
@@ -595,6 +621,35 @@ class ReportingLabsReporter {
595
621
  }
596
622
  exports.default = ReportingLabsReporter;
597
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
+ }
598
653
  function ciRunLabel(env) {
599
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;
600
655
  return n ? `#${n}` : undefined;
@@ -549,8 +549,10 @@ html,body{-webkit-font-smoothing:antialiased}
549
549
  .clu2 li{display:grid;grid-template-columns:44px 1fr;gap:12px;padding:11px 0;border-top:1px solid var(--line)} .clu2 li:first-child{border-top:0;padding-top:2px}
550
550
  .clu2 .n{font-size:20px;font-weight:600;color:var(--fail);font-variant-numeric:tabular-nums;line-height:1.1} .clu2 .n small{display:block;font-size:10.5px;color:var(--ink-3);font-weight:500;letter-spacing:.04em;margin-top:1px}
551
551
  .clu2 .b{min-width:0} .clu2 .msg{font:12.5px/1.45 var(--mono);color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
552
- .clu2 .who{font-size:12.5px;color:var(--ink-2);margin-top:4px;line-height:1.6} .clu2 .who button{color:var(--accent-2);font-weight:500} .clu2 .who button:hover{text-decoration:underline} .clu2 .who button small{color:var(--ink-3);font-weight:400;font-size:11px}
553
- .clu2 .who .sep{color:var(--line-2);margin:0 7px} .clu2 .who .ex{color:var(--ink-3)}
552
+ .clu2 .who{font-size:12.5px;color:var(--ink-2);margin-top:5px;line-height:1.45;display:flex;flex-direction:column;align-items:flex-start;gap:3px}
553
+ .clu2 .who button{color:var(--accent-2);font-weight:500;text-align:left;white-space:normal;max-width:100%;padding:0 0 0 14px;text-indent:-14px} .clu2 .who button .tri{display:inline-block;width:14px;text-indent:0;color:var(--ink-3);font-weight:400}
554
+ .clu2 .who button:hover{text-decoration:underline} .clu2 .who button small{color:var(--ink-3);font-weight:400;font-size:11px}
555
+ .clu2 .who button.ex{color:var(--ink-3);font-weight:400;cursor:pointer;text-indent:0} .clu2 .who button.ex:hover{color:var(--accent-2);text-decoration:underline}
554
556
  @media (max-width:640px){.attn2 .ow{display:none} .attn2 .pr{width:72px} .attn2 .pr small{display:none}}
555
557
  /* ---------- run status, errors outside tests, snippets ---------- */
556
558
  .att-err{padding:12px 14px;background:var(--fail-bg);color:var(--ink);border-radius:8px 8px 0 0;line-height:1.5} .att-err code{font:12px var(--mono);background:var(--surface);padding:1px 5px;border-radius:4px}
@@ -1300,17 +1302,43 @@ function errorSignature(msg){
1300
1302
  }
1301
1303
  function failureClusters(){
1302
1304
  const m=new Map();
1303
- for(const t of data.tests){ if(!isFail(t.outcome)) continue; const r=t.results[t.results.length-1]; const e=r&&r.errors[0]; const x=e&&e.explain; const sig=x&&!/^(thrown|script)$/.test(x.kind)? x.kind+'|'+(x.locator||x.url||'')+'|'+(x.matcher||x.action||'') : errorSignature(e?e.message:'(no error message)'); const c=m.get(sig)||{sig,sample:e?e.message.split('\n').slice(0,2).join('\n'):'(no error message)',why:e&&e.explain?e.explain:null,tests:[]}; c.tests.push(t); m.set(sig,c); }
1305
+ for(const t of data.tests){ if(!isFail(t.outcome)) continue; const r=t.results[t.results.length-1]; const e=r&&r.errors[0]; const x=e&&e.explain; const msg=e?e.message:'(no error message)';
1306
+ // One cluster per root cause: the explain kind plus what it points at. An explanation that points at
1307
+ // nothing (a plain assertion) falls back to the message shape, so two different assertions stay apart.
1308
+ const sig=x&&!/^(thrown|script)$/.test(x.kind)? x.kind+'|'+(x.locator||x.url||'')+'|'+(x.matcher||x.action||'')+(x.locator||x.url?'':'|'+messageShape(msg)) : errorSignature(msg);
1309
+ const c=m.get(sig)||{sig,sample:msg.split('\n').map(l=>l.trim()).filter(Boolean).slice(0,3).join('\n'),why:x||null,tests:[]}; c.tests.push(t); m.set(sig,c); }
1304
1310
  return [...m.values()].sort((a,b)=>b.tests.length-a.tests.length);
1305
1311
  }
1312
+ // The message line shown under a cluster's explanation: the first line that says something the
1313
+ // explanation does not already say. Hamcrest's "Expected: is <200>" / "but: was <404>" is fully
1314
+ // covered by "The value was wrong: expected <200>, got <404>." so nothing is repeated.
1315
+ // The shape of a message: its first lines with numbers and quoted values blanked, so two assertions on
1316
+ // different values of the same kind cluster together, and assertions on different things stay apart.
1317
+ function messageShape(msg){ return String(msg||'').split('\n').map(l=>l.trim()).filter(Boolean).slice(0,3).map(errorSignature).join(' / '); }
1318
+ function cluToks(s){ const stop=/^(expected|expecting|but|is|was|were|received|actual|to|be|the|a|an|of|and|not|did|got|value|wrong|equal|have)$/; return new Set(String(s||'').toLowerCase().replace(/[^a-z0-9]+/g,' ').split(' ').filter(w=>w&&!stop.test(w))); }
1319
+ function clusterLine(c){
1320
+ const lines=c.sample.split('\n').filter(Boolean); if(!c.why) return lines[0]||c.sample;
1321
+ const have=cluToks(c.why.summary);
1322
+ // "Error: " / "AssertionError: " prefixes carry nothing; the expect(received).toBe(expected) hint line of
1323
+ // Jest-style matchers is a header, not a detail.
1324
+ const bare=l=>l.replace(/^[\w.$]*(Error|Exception|Failure|Throwable)(:\s*|$)/,'');
1325
+ return lines.find(l=>{ const b=bare(l); if(/^expect\(.*\)\.[\w.]+\(.*\)/.test(b)) return false; const t=[...cluToks(b)]; return t.length && t.some(w=>!have.has(w)); })||null;
1326
+ }
1327
+ // The tests of a cluster, six by name and "+N more" for the rest; the +N more button expands the list
1328
+ // in place and "show fewer" folds it again. Each name opens that test.
1329
+ function clusterWho(byTitle, all){
1330
+ const entries=[...byTitle.entries()], names=all?entries:entries.slice(0,6), extra=entries.length-names.length;
1331
+ const who=names.map(([title,ts])=>h('button',{onclick:()=>select(ts[0].id),title:ts.map(t=>t.project).join(', ')}, h('span',{class:'tri'},'›'), title, data.projects.length>1&&ts.length>1? h('small',{},' ×'+ts.length) : null));
1332
+ const swap=(ev,toAll)=>{ const d=ev.currentTarget.closest('.who'); d.replaceChildren(); for(const n of clusterWho(byTitle,toAll)) d.append(n); };
1333
+ if(extra>0) who.push(h('button',{class:'ex',onclick:ev=>swap(ev,true),title:'Show all '+entries.length+' tests'},'+'+extra+' more'));
1334
+ else if(all&&entries.length>6) who.push(h('button',{class:'ex',onclick:ev=>swap(ev,false)},'show fewer'));
1335
+ return who;
1336
+ }
1306
1337
  function clustersView(cl){
1307
1338
  return h('ol',{class:'clu2'}, cl.map(c=>{
1308
1339
  const byTitle=new Map(); for(const t of c.tests){ const g=byTitle.get(t.title)||[]; g.push(t); byTitle.set(t.title,g); }
1309
- const names=[...byTitle.entries()].slice(0,6), extra=byTitle.size-names.length;
1310
- const who=[]; names.forEach(([title,ts],k)=>{ if(k) who.push(h('span',{class:'sep'},'·')); who.push(h('button',{onclick:()=>select(ts[0].id),title:ts.map(t=>t.project).join(', ')}, title, data.projects.length>1&&ts.length>1? h('small',{},' ×'+ts.length) : null)); });
1311
- if(extra>0) who.push(h('span',{class:'sep'},'·'), h('span',{class:'ex'},'+'+extra+' more'));
1312
1340
  return h('li',{}, h('span',{class:'n'}, c.tests.length, h('small',{},c.tests.length===1?'test':'tests')),
1313
- h('div',{class:'b'}, c.why? h('div',{class:'why-c'}, h('span',{class:'why-k'},c.why.label), ' ', c.why.summary) : null, h('div',{class:'msg',title:c.sample}, c.sample.split('\n')[0]), h('div',{class:'who'}, who)));
1341
+ h('div',{class:'b'}, c.why? h('div',{class:'why-c'}, h('span',{class:'why-k'},c.why.label), ' ', c.why.summary) : null, (line=>line? h('div',{class:'msg',title:c.sample}, line):null)(clusterLine(c)), h('div',{class:'who'}, clusterWho(byTitle,false))));
1314
1342
  }));
1315
1343
  }
1316
1344
  function trend(){
package/dist/template.js CHANGED
@@ -620,8 +620,10 @@ html,body{-webkit-font-smoothing:antialiased}
620
620
  .clu2 li{display:grid;grid-template-columns:44px 1fr;gap:12px;padding:11px 0;border-top:1px solid var(--line)} .clu2 li:first-child{border-top:0;padding-top:2px}
621
621
  .clu2 .n{font-size:20px;font-weight:600;color:var(--fail);font-variant-numeric:tabular-nums;line-height:1.1} .clu2 .n small{display:block;font-size:10.5px;color:var(--ink-3);font-weight:500;letter-spacing:.04em;margin-top:1px}
622
622
  .clu2 .b{min-width:0} .clu2 .msg{font:12.5px/1.45 var(--mono);color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
623
- .clu2 .who{font-size:12.5px;color:var(--ink-2);margin-top:4px;line-height:1.6} .clu2 .who button{color:var(--accent-2);font-weight:500} .clu2 .who button:hover{text-decoration:underline} .clu2 .who button small{color:var(--ink-3);font-weight:400;font-size:11px}
624
- .clu2 .who .sep{color:var(--line-2);margin:0 7px} .clu2 .who .ex{color:var(--ink-3)}
623
+ .clu2 .who{font-size:12.5px;color:var(--ink-2);margin-top:5px;line-height:1.45;display:flex;flex-direction:column;align-items:flex-start;gap:3px}
624
+ .clu2 .who button{color:var(--accent-2);font-weight:500;text-align:left;white-space:normal;max-width:100%;padding:0 0 0 14px;text-indent:-14px} .clu2 .who button .tri{display:inline-block;width:14px;text-indent:0;color:var(--ink-3);font-weight:400}
625
+ .clu2 .who button:hover{text-decoration:underline} .clu2 .who button small{color:var(--ink-3);font-weight:400;font-size:11px}
626
+ .clu2 .who button.ex{color:var(--ink-3);font-weight:400;cursor:pointer;text-indent:0} .clu2 .who button.ex:hover{color:var(--accent-2);text-decoration:underline}
625
627
  @media (max-width:640px){.attn2 .ow{display:none} .attn2 .pr{width:72px} .attn2 .pr small{display:none}}
626
628
  /* ---------- run status, errors outside tests, snippets ---------- */
627
629
  .att-err{padding:12px 14px;background:var(--fail-bg);color:var(--ink);border-radius:8px 8px 0 0;line-height:1.5} .att-err code{font:12px var(--mono);background:var(--surface);padding:1px 5px;border-radius:4px}
@@ -1366,17 +1368,43 @@ function errorSignature(msg){
1366
1368
  }
1367
1369
  function failureClusters(){
1368
1370
  const m=new Map();
1369
- for(const t of data.tests){ if(!isFail(t.outcome)) continue; const r=t.results[t.results.length-1]; const e=r&&r.errors[0]; const x=e&&e.explain; const sig=x&&!/^(thrown|script)$/.test(x.kind)? x.kind+'|'+(x.locator||x.url||'')+'|'+(x.matcher||x.action||'') : errorSignature(e?e.message:'(no error message)'); const c=m.get(sig)||{sig,sample:e?e.message.split('\n').slice(0,2).join('\n'):'(no error message)',why:e&&e.explain?e.explain:null,tests:[]}; c.tests.push(t); m.set(sig,c); }
1371
+ for(const t of data.tests){ if(!isFail(t.outcome)) continue; const r=t.results[t.results.length-1]; const e=r&&r.errors[0]; const x=e&&e.explain; const msg=e?e.message:'(no error message)';
1372
+ // One cluster per root cause: the explain kind plus what it points at. An explanation that points at
1373
+ // nothing (a plain assertion) falls back to the message shape, so two different assertions stay apart.
1374
+ const sig=x&&!/^(thrown|script)$/.test(x.kind)? x.kind+'|'+(x.locator||x.url||'')+'|'+(x.matcher||x.action||'')+(x.locator||x.url?'':'|'+messageShape(msg)) : errorSignature(msg);
1375
+ const c=m.get(sig)||{sig,sample:msg.split('\n').map(l=>l.trim()).filter(Boolean).slice(0,3).join('\n'),why:x||null,tests:[]}; c.tests.push(t); m.set(sig,c); }
1370
1376
  return [...m.values()].sort((a,b)=>b.tests.length-a.tests.length);
1371
1377
  }
1378
+ // The message line shown under a cluster's explanation: the first line that says something the
1379
+ // explanation does not already say. Hamcrest's "Expected: is <200>" / "but: was <404>" is fully
1380
+ // covered by "The value was wrong: expected <200>, got <404>." so nothing is repeated.
1381
+ // The shape of a message: its first lines with numbers and quoted values blanked, so two assertions on
1382
+ // different values of the same kind cluster together, and assertions on different things stay apart.
1383
+ function messageShape(msg){ return String(msg||'').split('\n').map(l=>l.trim()).filter(Boolean).slice(0,3).map(errorSignature).join(' / '); }
1384
+ function cluToks(s){ const stop=/^(expected|expecting|but|is|was|were|received|actual|to|be|the|a|an|of|and|not|did|got|value|wrong|equal|have)$/; return new Set(String(s||'').toLowerCase().replace(/[^a-z0-9]+/g,' ').split(' ').filter(w=>w&&!stop.test(w))); }
1385
+ function clusterLine(c){
1386
+ const lines=c.sample.split('\n').filter(Boolean); if(!c.why) return lines[0]||c.sample;
1387
+ const have=cluToks(c.why.summary);
1388
+ // "Error: " / "AssertionError: " prefixes carry nothing; the expect(received).toBe(expected) hint line of
1389
+ // Jest-style matchers is a header, not a detail.
1390
+ const bare=l=>l.replace(/^[\w.$]*(Error|Exception|Failure|Throwable)(:\s*|$)/,'');
1391
+ return lines.find(l=>{ const b=bare(l); if(/^expect\(.*\)\.[\w.]+\(.*\)/.test(b)) return false; const t=[...cluToks(b)]; return t.length && t.some(w=>!have.has(w)); })||null;
1392
+ }
1393
+ // The tests of a cluster, six by name and "+N more" for the rest; the +N more button expands the list
1394
+ // in place and "show fewer" folds it again. Each name opens that test.
1395
+ function clusterWho(byTitle, all){
1396
+ const entries=[...byTitle.entries()], names=all?entries:entries.slice(0,6), extra=entries.length-names.length;
1397
+ const who=names.map(([title,ts])=>h('button',{onclick:()=>select(ts[0].id),title:ts.map(t=>t.project).join(', ')}, h('span',{class:'tri'},'›'), title, data.projects.length>1&&ts.length>1? h('small',{},' ×'+ts.length) : null));
1398
+ const swap=(ev,toAll)=>{ const d=ev.currentTarget.closest('.who'); d.replaceChildren(); for(const n of clusterWho(byTitle,toAll)) d.append(n); };
1399
+ if(extra>0) who.push(h('button',{class:'ex',onclick:ev=>swap(ev,true),title:'Show all '+entries.length+' tests'},'+'+extra+' more'));
1400
+ else if(all&&entries.length>6) who.push(h('button',{class:'ex',onclick:ev=>swap(ev,false)},'show fewer'));
1401
+ return who;
1402
+ }
1372
1403
  function clustersView(cl){
1373
1404
  return h('ol',{class:'clu2'}, cl.map(c=>{
1374
1405
  const byTitle=new Map(); for(const t of c.tests){ const g=byTitle.get(t.title)||[]; g.push(t); byTitle.set(t.title,g); }
1375
- const names=[...byTitle.entries()].slice(0,6), extra=byTitle.size-names.length;
1376
- const who=[]; names.forEach(([title,ts],k)=>{ if(k) who.push(h('span',{class:'sep'},'·')); who.push(h('button',{onclick:()=>select(ts[0].id),title:ts.map(t=>t.project).join(', ')}, title, data.projects.length>1&&ts.length>1? h('small',{},' ×'+ts.length) : null)); });
1377
- if(extra>0) who.push(h('span',{class:'sep'},'·'), h('span',{class:'ex'},'+'+extra+' more'));
1378
1406
  return h('li',{}, h('span',{class:'n'}, c.tests.length, h('small',{},c.tests.length===1?'test':'tests')),
1379
- h('div',{class:'b'}, c.why? h('div',{class:'why-c'}, h('span',{class:'why-k'},c.why.label), ' ', c.why.summary) : null, h('div',{class:'msg',title:c.sample}, c.sample.split('\n')[0]), h('div',{class:'who'}, who)));
1407
+ h('div',{class:'b'}, c.why? h('div',{class:'why-c'}, h('span',{class:'why-k'},c.why.label), ' ', c.why.summary) : null, (line=>line? h('div',{class:'msg',title:c.sample}, line):null)(clusterLine(c)), h('div',{class:'who'}, clusterWho(byTitle,false))));
1380
1408
  }));
1381
1409
  }
1382
1410
  function trend(){
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reporting-labs",
3
- "version": "0.6.7",
3
+ "version": "0.6.9",
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",