reporting-labs 0.6.1 → 0.6.3

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
@@ -88,7 +88,28 @@ One line per test. With this the report can rank failures by priority, group the
88
88
 
89
89
  - Known keys: `priority`, `severity`, `owner`, `feature`, `epic`, `story`, `issue`, `component`, `team`. Any other key you pass is shown too.
90
90
  - Your Playwright tags like `@sanity` or `@regression` stay as they are and still show on the test.
91
- - To make story and epic keys clickable, set `links` in the config: `links: { story: 'https://yourteam.atlassian.net/browse/{id}' }`.
91
+ - To make story and epic keys clickable, set `links` in the config: `links: { story: 'https://yourteam.atlassian.net/browse/{id}' }`. Several ids (`story: ['SHOP-1', 'SHOP-2']`) become one link each.
92
+ - A link whose URL needs more than the shown value (a workspace, a project, an organisation) takes an object. Any tool, any URL shape: every `{placeholder}` in `url` is filled from the object you pass in `meta()`, `display` is what the report shows (default `{id}`), the other fields only build the URL. ALM Octane / ValueEdge as an example:
93
+
94
+ ```ts
95
+ // reporting-labs.config.ts
96
+ links: {
97
+ octaneTestCase: {
98
+ url: 'https://oss.valueedge.com/ui/?p={p}#/entity-navigation?entityType=test&id={id}',
99
+ display: '{id}',
100
+ },
101
+ },
102
+ ```
103
+
104
+ ```ts
105
+ // tests/notifications.spec.ts
106
+ test('TC003 - manual trigger with all notifications disabled', async ({ page }) => {
107
+ meta({ priority: 'P1', owner: 'chetan', octaneTestCase: { id: '58966', p: '4001/14014' } });
108
+ // ... your test as usual
109
+ });
110
+ ```
111
+
112
+ The test shows a chip **octaneTestCase 58966**; clicking it opens the full URL. `p` never appears in the report and can differ per test. The same shape covers Azure DevOps (`url: 'https://dev.azure.com/{org}/{project}/_workitems/edit/{id}', display: 'AB#{id}'`) or any in-house tool. Jira, TestRail, Xray and Zephyr need only the plain `{id}` string.
92
113
  - Forgot one? After every run the console lists the tests that have no `meta()`, with file and line. Turn it off with `warnMissingMeta: false`.
93
114
  - `priority`, `severity`, `feature` and `owner` each get a tab in the Breakdown chart and a filter on the Tests page. Want the same for your own key, say `meta({ team: 'web' })`? Add it to `dimensions` in the config: `dimensions: ['priority', 'severity', 'feature', 'owner', 'team']`.
94
115
 
@@ -113,7 +134,7 @@ await testData(rowsFromExcelOrJson, 'Coupons'); // arr
113
134
  await testData(fs.readFileSync('data/users.csv', 'utf8'), 'users.csv'); // CSV text → table
114
135
  ```
115
136
 
116
- **Secrets are masked automatically.** Passwords, tokens, API keys, `Authorization` and `Cookie` headers, JWTs and `Bearer ...` values show as `****`. Add your own keys with `maskKeys: ['otp', 'pan']` in the config.
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.
117
138
 
118
139
  ## API calls: recorded on their own
119
140
 
@@ -284,7 +305,7 @@ Every option is optional. `npx reporting-labs init` writes them all, with commen
284
305
  | `project` | – | `{ name, version, team, url }` shown under the title |
285
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 |
286
307
  | `env` | – | Extra rows on the Environment card |
287
- | `links` | `{}` | Turn meta keys into links. `{id}` is replaced by the value |
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 |
288
309
  | `maskKeys` | `[]` | Extra keys to mask as `****` |
289
310
  | `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 |
290
311
  | `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'] }` |
package/dist/auto.js CHANGED
@@ -154,28 +154,65 @@ function patchContext(ctx) {
154
154
  };
155
155
  proto[PATCHED] = true;
156
156
  }
157
+ /** Wrap `obj[name]` (found on the prototype chain) so `after(result)` sees each resolved return value. */
158
+ function wrapAsync(obj, name, after) {
159
+ let proto = obj;
160
+ while (proto && !Object.prototype.hasOwnProperty.call(proto, name))
161
+ proto = Object.getPrototypeOf(proto);
162
+ if (!proto || typeof proto[name] !== 'function' || proto[name][PATCHED])
163
+ return;
164
+ const original = proto[name];
165
+ const wrapped = async function (...args) {
166
+ const result = await original.apply(this, args);
167
+ try {
168
+ after(result);
169
+ }
170
+ catch { /* never break the caller */ }
171
+ return result;
172
+ };
173
+ wrapped[PATCHED] = true;
174
+ proto[name] = wrapped;
175
+ }
176
+ /**
177
+ * Hook the places an APIRequestContext can come from, and patch the shared prototype the
178
+ * first time one shows up:
179
+ * - playwright.request.newContext() (the `request` fixture, manual contexts)
180
+ * - browser.newContext() / launchPersistentContext() / connect*() (page.request, context.request)
181
+ *
182
+ * No context is created here. An earlier version made a throwaway context at import time and
183
+ * disposed it; when that dispose raced with the test runner's tracing bookkeeping the runner
184
+ * tried to start a trace on a closed context and every test failed with
185
+ * "apiRequestContext._wrapApiCall: Target page, context or browser has been closed".
186
+ */
157
187
  function install() {
158
188
  let api;
159
189
  try {
160
- api = pw().request;
190
+ api = pw();
161
191
  }
162
192
  catch {
163
193
  return;
164
194
  }
165
- if (!api || typeof api.newContext !== 'function')
166
- return;
167
- const apiProto = Object.getPrototypeOf(api);
168
- if (apiProto[PATCHED])
169
- return;
170
- apiProto[PATCHED] = true;
171
- // Any context created later through request.newContext() patches the shared prototype on the spot.
172
- const newContext = apiProto.newContext;
173
- apiProto.newContext = async function patchedNewContext(...args) {
174
- const ctx = await newContext.apply(this, args);
175
- patchContext(ctx);
176
- return ctx;
195
+ const { request, chromium, firefox, webkit } = api;
196
+ if (request && typeof request.newContext === 'function')
197
+ wrapAsync(request, 'newContext', ctx => patchContext(ctx));
198
+ const onBrowser = (browser) => {
199
+ if (!browser || typeof browser.newContext !== 'function')
200
+ return;
201
+ wrapAsync(browser, 'newContext', ctx => ctx?.request && patchContext(ctx.request));
202
+ try {
203
+ for (const ctx of browser.contexts())
204
+ if (ctx?.request)
205
+ patchContext(ctx.request);
206
+ }
207
+ catch { /* not a browser */ }
177
208
  };
178
- // And do it right away with a throwaway context, so page.request works even if no request fixture is ever created.
179
- api.newContext().then((ctx) => { patchContext(ctx); return ctx.dispose(); }).catch(() => { });
209
+ for (const bt of [chromium, firefox, webkit]) {
210
+ if (!bt)
211
+ continue;
212
+ wrapAsync(bt, 'launch', onBrowser);
213
+ wrapAsync(bt, 'connect', onBrowser);
214
+ wrapAsync(bt, 'connectOverCDP', onBrowser);
215
+ wrapAsync(bt, 'launchPersistentContext', ctx => ctx?.request && patchContext(ctx.request));
216
+ }
180
217
  }
181
218
  install();
package/dist/mask.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export declare function makeMasker(extraKeys?: string[]): {
2
2
  mask: (v: unknown, key?: string) => unknown;
3
- maskStr: (s: string) => string;
3
+ maskStr: (input: string) => string;
4
4
  isSensitive: (k: string) => boolean;
5
5
  };
6
6
  export declare function parseCsv(text: string): {
package/dist/mask.js CHANGED
@@ -3,20 +3,60 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.makeMasker = makeMasker;
4
4
  exports.parseCsv = parseCsv;
5
5
  const DEFAULT_KEYS = ['password', 'passwd', 'pwd', 'secret', 'token', 'apikey', 'api_key', 'api-key', 'authorization', 'auth', 'cookie', 'set-cookie', 'session', 'credential', 'private', 'ssn', 'cvv', 'card'];
6
+ /** Values that are secrets on their own, whatever the surrounding text. */
6
7
  const VALUE_PATTERNS = [
7
- /\b(Bearer|Basic|Token)\s+[A-Za-z0-9._~+\/=-]{8,}/gi,
8
+ /\b(Bearer|Basic|Digest|Token)\s+[A-Za-z0-9._~+\/=-]{8,}/gi,
8
9
  /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}/g, // JWT
9
- /\b(sk|pk|ghp|xox[abp]|AKIA)[_-]?[A-Za-z0-9]{12,}\b/g,
10
- /(password|passwd|pwd|token|secret|api[_-]?key)(\s*[=:]\s*)([^\s&;,"']+)/gi,
10
+ /\b(?:sk|pk|rk)[-_](?:live|test)?[-_]?[A-Za-z0-9]{12,}\b/g, // Stripe-style keys
11
+ /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}\b/g, // GitHub tokens
12
+ /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
13
+ /\bxox[abpr]-[A-Za-z0-9-]{10,}\b/g, // Slack
14
+ /\bAKIA[0-9A-Z]{16}\b/g, // AWS access key id
15
+ /\bAIza[0-9A-Za-z_-]{30,}\b/g, // Google API key
16
+ /\bya29\.[0-9A-Za-z_-]{20,}\b/g, // Google OAuth token
17
+ /\bglpat-[A-Za-z0-9_-]{20,}\b/g, // GitLab
18
+ /\bnpm_[A-Za-z0-9]{30,}\b/g, // npm
19
+ /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b/g, // SendGrid
11
20
  ];
21
+ /** Words after "password is ..." that are plainly not a secret. */
22
+ const NOT_A_SECRET = new Set(['valid', 'invalid', 'visible', 'hidden', 'required', 'optional', 'missing', 'empty', 'null', 'undefined', 'none',
23
+ 'correct', 'incorrect', 'wrong', 'right', 'expired', 'set', 'unset', 'not', 'present', 'absent', 'ok', 'true', 'false', 'masked', 'blank',
24
+ 'too', 'the', 'a', 'an', 'same', 'different', 'changed', 'unchanged', 'shown', 'displayed', 'accepted', 'rejected', 'weak', 'strong']);
25
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
12
26
  function makeMasker(extraKeys = []) {
13
27
  const keys = [...DEFAULT_KEYS, ...extraKeys.map(k => k.toLowerCase())];
14
28
  const isSensitive = (k) => { const l = k.toLowerCase(); return keys.some(s => l === s || l.includes(s)); };
15
- const maskStr = (s) => VALUE_PATTERNS.reduce((acc, re) => acc.replace(re, (m, ...g) => {
16
- if (g.length >= 3 && typeof g[1] === 'string' && /[=:]/.test(g[1]))
17
- return g[0] + g[1] + '****';
18
- return m.split(/\s+/)[0] + ' ****';
19
- }), s);
29
+ // Key names as they appear in free text: password, X-Api-Key, access_token, userPassword, "password", db.password...
30
+ // `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('|')})`;
34
+ // key = value | key: value | "key": "value" | key => value | key -> value (value runs to a delimiter; a closing quote stays)
35
+ const KV = new RegExp(`(["']?)\\b(${KEY})\\b(["']?)(\\s*(?:=>|->|[=:])\\s*)(["']?)([^"'\\s&;,}\\]\\)]+)`, 'gi');
36
+ // 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');
39
+ 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
+ // Assertion output: when the text talks about a password/token/secret, the compared values are that secret.
41
+ // expect(token, 'token should match').toBe('x') -> Expected: "x" / Received: "tok_9f8e..."
42
+ const MENTIONS_SECRET = new RegExp(`\\b(?:${SPOKEN_KEYS.join('|')})s?\\b`, 'i');
43
+ const COMPARED = /\b(Expected|Received|Actual|expected|received|actual|got)(\s*[:=]\s*)(["'\u201c]?)([^"'\u201d\s]+)/g;
44
+ const maskStr = (input) => {
45
+ let s = input;
46
+ 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}****`);
49
+ s = s.replace(SPOKEN, (m, key, between, value) => {
50
+ const word = value.toLowerCase().replace(/[^a-z0-9]/g, '');
51
+ if (NOT_A_SECRET.has(word) || word === '****' || value === '****')
52
+ return m;
53
+ const explicit = /\b(is|was|of|as)\b|['"]$/.test(between);
54
+ return explicit || looksSecret(value) ? `${key}${between}****` : m;
55
+ });
56
+ if (MENTIONS_SECRET.test(s))
57
+ s = s.replace(COMPARED, (m, label, sep, q, value) => looksSecret(value) ? `${label}${sep}${q}****` : m);
58
+ return s;
59
+ };
20
60
  const mask = (v, key = '') => {
21
61
  if (key && isSensitive(key))
22
62
  return '****';
package/dist/meta.d.ts CHANGED
@@ -4,6 +4,9 @@ import type { ApiCall, TestMeta } from './types';
4
4
  *
5
5
  * @example
6
6
  * meta({ priority: 'P1', severity: 'critical', owner: 'naveen', feature: 'checkout', story: 'SHOP-231' });
7
+ *
8
+ * A value can be an object when the link needs more than one parameter (see `links` in the config):
9
+ * meta({ octaneTestCase: { id: '58966', p: '4001/14014' } });
7
10
  */
8
11
  export declare function meta(values: TestMeta): void;
9
12
  /**
package/dist/meta.js CHANGED
@@ -16,13 +16,19 @@ function info() {
16
16
  *
17
17
  * @example
18
18
  * meta({ priority: 'P1', severity: 'critical', owner: 'naveen', feature: 'checkout', story: 'SHOP-231' });
19
+ *
20
+ * A value can be an object when the link needs more than one parameter (see `links` in the config):
21
+ * meta({ octaneTestCase: { id: '58966', p: '4001/14014' } });
19
22
  */
20
23
  function meta(values) {
21
24
  const i = info();
22
25
  for (const [k, v] of Object.entries(values)) {
23
26
  if (v === undefined)
24
27
  continue;
25
- i.annotations.push({ type: k.toLowerCase(), description: String(v) });
28
+ // Objects (e.g. { id: '58966', p: '4001/14014' } for a multi-parameter link) travel as JSON.
29
+ // Arrays (story: ['SHOP-1', 'SHOP-2']) become one chip per value; objects (a multi-parameter link) travel as JSON.
30
+ const text = Array.isArray(v) ? v.map(String).join(', ') : v !== null && typeof v === 'object' ? JSON.stringify(v) : String(v);
31
+ i.annotations.push({ type: k.toLowerCase(), description: text });
26
32
  }
27
33
  }
28
34
  /**
@@ -34,7 +34,15 @@ export default class ReportingLabsReporter implements Reporter {
34
34
  private dimensions;
35
35
  /** Keys picked up from tags and annotations even when they are not breakdown dimensions. */
36
36
  private metaKeys;
37
- /** Pull meta values (priority, owner, story, epic...) from annotations and tags. */
37
+ /** The link template for a (lower-cased) meta key, falling back to '*'. */
38
+ private linkTemplate;
39
+ /** `links` reduced to URL templates, the shape the template expects. */
40
+ private linkUrls;
41
+ /**
42
+ * Pull meta values (priority, owner, story, epic...) from annotations and tags.
43
+ * An object value (meta({ octaneTestCase: { id, p } })) is folded to its display text and, when the key has a
44
+ * link template, to a ready-made href in `links`; the other fields never show in the report.
45
+ */
38
46
  private extractMeta;
39
47
  /** A local image file (path relative to the config) is embedded as a data URI so the report stays self-contained. URLs and data URIs pass through. */
40
48
  private resolveLogo;
package/dist/reporter.js CHANGED
@@ -105,6 +105,7 @@ class ReportingLabsReporter {
105
105
  const last = test.results[test.results.length - 1];
106
106
  const resultAnn = (last?.annotations ?? []);
107
107
  const annotations = [...test.annotations, ...resultAnn.filter(a => !test.annotations.some(b => b.type === a.type && b.description === a.description))];
108
+ const extracted = this.extractMeta(test);
108
109
  tests.push({
109
110
  id: test.id,
110
111
  key: `${project}::${file}::${[...titlePath, test.title].join(' › ')}${test.repeatEachIndex ? ' #' + (test.repeatEachIndex + 1) : ''}`,
@@ -116,7 +117,8 @@ class ReportingLabsReporter {
116
117
  project,
117
118
  tags: test.tags,
118
119
  annotations,
119
- meta: this.extractMeta(test),
120
+ meta: extracted.meta,
121
+ links: Object.keys(extracted.links).length ? extracted.links : undefined,
120
122
  outcome,
121
123
  expectedFailure,
122
124
  note,
@@ -195,7 +197,7 @@ class ReportingLabsReporter {
195
197
  ...(this.options.dimensionOrder ?? {}),
196
198
  },
197
199
  project: this.options.project,
198
- links: this.options.links ?? {},
200
+ links: this.linkUrls(),
199
201
  customCss: this.options.customCss ?? '',
200
202
  editorLinks: this.options.editorLinks ?? !process.env.CI,
201
203
  },
@@ -352,10 +354,34 @@ class ReportingLabsReporter {
352
354
  metaKeys() {
353
355
  return [...new Set([...this.dimensions(), ...META_KEYS, ...Object.keys(this.options.links ?? {}).map(k => k.toLowerCase())])].filter(k => k !== '*');
354
356
  }
355
- /** Pull meta values (priority, owner, story, epic...) from annotations and tags. */
357
+ /** The link template for a (lower-cased) meta key, falling back to '*'. */
358
+ linkTemplate(key) {
359
+ let star;
360
+ for (const [k, v] of Object.entries(this.options.links ?? {})) {
361
+ if (k.toLowerCase() === key)
362
+ return v;
363
+ if (k === '*')
364
+ star = v;
365
+ }
366
+ return star;
367
+ }
368
+ /** `links` reduced to URL templates, the shape the template expects. */
369
+ linkUrls() {
370
+ const out = {};
371
+ // Meta keys are lower-cased on the way in, so link keys must be too (links: { testCaseId } did not match before).
372
+ for (const [k, v] of Object.entries(this.options.links ?? {}))
373
+ out[k.toLowerCase()] = typeof v === 'string' ? v : v.url;
374
+ return out;
375
+ }
376
+ /**
377
+ * Pull meta values (priority, owner, story, epic...) from annotations and tags.
378
+ * An object value (meta({ octaneTestCase: { id, p } })) is folded to its display text and, when the key has a
379
+ * link template, to a ready-made href in `links`; the other fields never show in the report.
380
+ */
356
381
  extractMeta(test) {
357
382
  const dims = this.metaKeys();
358
383
  const meta = {};
384
+ const links = {};
359
385
  // Tags first (describe-level, then test-level), annotations last so a test can override its describe's tags.
360
386
  for (const raw of test.tags) {
361
387
  const tag = raw.replace(/^@/, '');
@@ -371,10 +397,22 @@ class ReportingLabsReporter {
371
397
  }
372
398
  for (const a of test.annotations) {
373
399
  const k = a.type.toLowerCase();
374
- if (dims.includes(k) && a.description)
400
+ if (!dims.includes(k) || !a.description)
401
+ continue;
402
+ const fields = parseObject(a.description);
403
+ if (!fields) {
375
404
  meta[k] = a.description;
405
+ continue;
406
+ }
407
+ const tpl = this.linkTemplate(k);
408
+ const display = typeof tpl === 'object' && tpl.display ? tpl.display : '{id}';
409
+ const shown = fill(display, fields, false);
410
+ meta[k] = shown || String(fields.id ?? Object.values(fields)[0] ?? '');
411
+ const url = typeof tpl === 'string' ? tpl : tpl?.url;
412
+ if (url)
413
+ links[k] = fill(url, fields, true);
376
414
  }
377
- return meta;
415
+ return { meta, links };
378
416
  }
379
417
  /** A local image file (path relative to the config) is embedded as a data URI so the report stays self-contained. URLs and data URIs pass through. */
380
418
  resolveLogo(logo) {
@@ -399,14 +437,15 @@ class ReportingLabsReporter {
399
437
  return path.relative(base, file).split(path.sep).join('/');
400
438
  }
401
439
  serializeError(e) {
402
- const out = { message: stripAnsi(e.message ?? e.value ?? '') };
440
+ // Assertions print received/expected values, so a failing `expect(token)` would leak the token without this.
441
+ const out = { message: this.masker.maskStr(stripAnsi(e.message ?? e.value ?? '')) };
403
442
  const why = (0, explain_1.explainError)(out.message);
404
443
  if (why)
405
444
  out.explain = why;
406
445
  if (e.stack)
407
- out.stack = stripAnsi(e.stack);
446
+ out.stack = this.masker.maskStr(stripAnsi(e.stack));
408
447
  if (e.snippet)
409
- out.snippet = stripAnsi(e.snippet);
448
+ out.snippet = this.masker.maskStr(stripAnsi(e.snippet));
410
449
  if (e.location)
411
450
  out.location = { file: this.rel(e.location.file), line: e.location.line, column: e.location.column };
412
451
  return out;
@@ -500,10 +539,10 @@ class ReportingLabsReporter {
500
539
  }
501
540
  serializeStep(s) {
502
541
  return {
503
- title: s.title,
542
+ title: this.masker.maskStr(s.title),
504
543
  category: s.category,
505
544
  duration: s.duration,
506
- error: s.error?.message ? stripAnsi(s.error.message) : undefined,
545
+ error: s.error?.message ? this.masker.maskStr(stripAnsi(s.error.message)) : undefined,
507
546
  steps: s.steps.filter(c => !isInternalAttach(c)).map(c => this.serializeStep(c)),
508
547
  };
509
548
  }
@@ -622,3 +661,25 @@ function stripAnsi(s) {
622
661
  function sanitize(s) {
623
662
  return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40);
624
663
  }
664
+ /** A JSON object literal in an annotation description (what meta() writes for object values), else undefined. */
665
+ function parseObject(text) {
666
+ if (!text.startsWith('{'))
667
+ return undefined;
668
+ try {
669
+ const v = JSON.parse(text);
670
+ return v && typeof v === 'object' && !Array.isArray(v) ? v : undefined;
671
+ }
672
+ catch {
673
+ return undefined;
674
+ }
675
+ }
676
+ /** Replace {field} placeholders; for URLs each value is encoded except '/' and ':' so a value like '4001/14014' survives. */
677
+ function fill(template, fields, forUrl) {
678
+ return template.replace(/\{(\w+)\}/g, (_m, name) => {
679
+ const v = fields[name];
680
+ if (v === undefined || v === null)
681
+ return '';
682
+ const text = String(v);
683
+ return forUrl ? encodeURIComponent(text).replace(/%2F/gi, '/').replace(/%3A/gi, ':') : text;
684
+ });
685
+ }
@@ -1488,8 +1488,8 @@ function renderDetail(t){
1488
1488
  editorLink(t))),
1489
1489
  h('div',{class:'badges'}, h('span',{class:'badge '+t.outcome},label[t.outcome]), t.expectedFailure? h('span',{class:'badge xfail',title:'Marked test.fail(): failing is the expected result'},'Expected failure') : null, sinceBadge(t), dots(t,10), data.bdd?h('span',{class:'badge scenario'},'Scenario'):null, t.tags.map(g=>h('span',{class:'badge tag'},g)), data.projects.length>1?h('span',{class:'badge tag'},t.project):null, h('span',{class:'loc'}, t.file+':'+t.line+' · '+ms(t.duration)+(t.outcome==='timedOut'&&t.timeout?' · timeout '+ms(t.timeout):'')+(t.retries?' · retries '+t.retries:''))));
1490
1490
  const metaKeys=Object.keys(t.meta);
1491
- const linkFor=(k,v)=>{ const tpl=data.options.links[k]||data.options.links['*']; if(tpl) return tpl.replace('{id}',encodeURIComponent(v)); if(/^https?:\/\//.test(v)) return v; return null; };
1492
- if(metaKeys.length) d.append(h('div',{class:'metas'}, metaKeys.map(k=>{ const v=t.meta[k], low=/^(P[3-4]|low|minor|trivial|normal|medium)$/i.test(v), href=linkFor(k,v); return h('span',{class:'meta '+k+(low?' low':'')}, h('span',{class:'k'},k), href? h('a',{href,target:'_blank',rel:'noopener'},v) : h('span',{class:'v'},v)); })));
1491
+ const linkFor=(k,v)=>{ if(t.links&&t.links[k]) return t.links[k]; const tpl=data.options.links[k]||data.options.links['*']; if(tpl) return tpl.replace('{id}',encodeURIComponent(v)); if(/^https?:\/\//.test(v)) return v; return null; };
1492
+ if(metaKeys.length) d.append(h('div',{class:'metas'}, metaKeys.map(k=>{ const v=t.meta[k], low=/^(P[3-4]|low|minor|trivial|normal|medium)$/i.test(v); const parts=(t.links&&t.links[k])||!data.options.links[k]&&!data.options.links['*']?[v]:v.split(/,\s+/); const nodes=[]; parts.forEach((part,i)=>{ const href=linkFor(k,part); if(i) nodes.push(', '); nodes.push(href? h('a',{href,target:'_blank',rel:'noopener'},part) : h('span',{class:'v'},part)); }); return h('span',{class:'meta '+k+(low?' low':'')}, h('span',{class:'k'},k), ...nodes); })));
1493
1493
  const otherAnn=t.annotations.filter(a=>!DIMS.includes(a.type.toLowerCase()) && !(a.type.toLowerCase() in t.meta));
1494
1494
  if(otherAnn.length) d.append(h('h4',{},'Annotations'), h('dl',{class:'kv'}, otherAnn.map(a=>[h('dt',{},a.type),h('dd',{},a.description||'')])));
1495
1495
  if(t.results.length>1){
package/dist/template.js CHANGED
@@ -1554,8 +1554,8 @@ function renderDetail(t){
1554
1554
  editorLink(t))),
1555
1555
  h('div',{class:'badges'}, h('span',{class:'badge '+t.outcome},label[t.outcome]), t.expectedFailure? h('span',{class:'badge xfail',title:'Marked test.fail(): failing is the expected result'},'Expected failure') : null, sinceBadge(t), dots(t,10), data.bdd?h('span',{class:'badge scenario'},'Scenario'):null, t.tags.map(g=>h('span',{class:'badge tag'},g)), data.projects.length>1?h('span',{class:'badge tag'},t.project):null, h('span',{class:'loc'}, t.file+':'+t.line+' · '+ms(t.duration)+(t.outcome==='timedOut'&&t.timeout?' · timeout '+ms(t.timeout):'')+(t.retries?' · retries '+t.retries:''))));
1556
1556
  const metaKeys=Object.keys(t.meta);
1557
- const linkFor=(k,v)=>{ const tpl=data.options.links[k]||data.options.links['*']; if(tpl) return tpl.replace('{id}',encodeURIComponent(v)); if(/^https?:\/\//.test(v)) return v; return null; };
1558
- if(metaKeys.length) d.append(h('div',{class:'metas'}, metaKeys.map(k=>{ const v=t.meta[k], low=/^(P[3-4]|low|minor|trivial|normal|medium)$/i.test(v), href=linkFor(k,v); return h('span',{class:'meta '+k+(low?' low':'')}, h('span',{class:'k'},k), href? h('a',{href,target:'_blank',rel:'noopener'},v) : h('span',{class:'v'},v)); })));
1557
+ const linkFor=(k,v)=>{ if(t.links&&t.links[k]) return t.links[k]; const tpl=data.options.links[k]||data.options.links['*']; if(tpl) return tpl.replace('{id}',encodeURIComponent(v)); if(/^https?:\/\//.test(v)) return v; return null; };
1558
+ if(metaKeys.length) d.append(h('div',{class:'metas'}, metaKeys.map(k=>{ const v=t.meta[k], low=/^(P[3-4]|low|minor|trivial|normal|medium)$/i.test(v); const parts=(t.links&&t.links[k])||!data.options.links[k]&&!data.options.links['*']?[v]:v.split(/,\s+/); const nodes=[]; parts.forEach((part,i)=>{ const href=linkFor(k,part); if(i) nodes.push(', '); nodes.push(href? h('a',{href,target:'_blank',rel:'noopener'},part) : h('span',{class:'v'},part)); }); return h('span',{class:'meta '+k+(low?' low':'')}, h('span',{class:'k'},k), ...nodes); })));
1559
1559
  const otherAnn=t.annotations.filter(a=>!DIMS.includes(a.type.toLowerCase()) && !(a.type.toLowerCase() in t.meta));
1560
1560
  if(otherAnn.length) d.append(h('h4',{},'Annotations'), h('dl',{class:'kv'}, otherAnn.map(a=>[h('dt',{},a.type),h('dd',{},a.description||'')])));
1561
1561
  if(t.results.length>1){
package/dist/types.d.ts CHANGED
@@ -77,8 +77,14 @@ export interface ReportingLabsOptions {
77
77
  /**
78
78
  * Turn meta values into links. Key = meta key (story, epic, ticket, issue, jira...), value = URL template with {id}.
79
79
  * e.g. { story: 'https://acme.atlassian.net/browse/{id}', epic: 'https://acme.atlassian.net/browse/{id}' }
80
+ *
81
+ * A link that needs more than the displayed value takes an object. Placeholders name the fields of the
82
+ * object passed to meta(); `display` (default '{id}') is what the report shows, the rest only builds the URL:
83
+ * links: { octaneTestCase: { url: 'https://oss.valueedge.com/ui/?p={p}#/entity-navigation?entityType=test&id={id}', display: '{id}' } }
84
+ * meta({ octaneTestCase: { id: '58966', p: '4001/14014' } })
85
+ * The report shows "octaneTestCase 58966", clicking it opens the full URL; p is never shown.
80
86
  */
81
- links?: Record<string, string>;
87
+ links?: Record<string, string | LinkTemplate>;
82
88
  /** Keys (case-insensitive substrings) whose values are masked in test data and API panels. */
83
89
  maskKeys?: string[];
84
90
  /** 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. */
@@ -94,6 +100,13 @@ export interface ReportingLabsOptions {
94
100
  /** Style Given/When/Then steps as Gherkin and label describe blocks as Features/Scenarios. Default: auto-detect */
95
101
  bdd?: boolean;
96
102
  }
103
+ /** A link built from several fields of a meta() object. */
104
+ export interface LinkTemplate {
105
+ /** URL with {field} placeholders, e.g. 'https://tms.example.com/{project}/cases/{id}'. */
106
+ url: string;
107
+ /** What the report shows for this meta key, with the same placeholders. Default '{id}'. */
108
+ display?: string;
109
+ }
97
110
  /** Lets you type a known value and still accept any string. */
98
111
  export type Loose<T extends string> = T | (string & {});
99
112
  /**
@@ -114,14 +127,14 @@ export interface TestMeta extends CustomMeta {
114
127
  feature?: string;
115
128
  /** Epic key, e.g. 'EPIC-18'. Becomes a link when `links.epic` is configured. */
116
129
  epic?: string;
117
- /** User story key, e.g. 'SHOP-250'. Becomes a link when `links.story` is configured. */
118
- story?: string;
119
- /** Bug / issue key this test guards against, e.g. 'PROMO-118'. */
120
- issue?: string;
130
+ /** User story key, e.g. 'SHOP-250', or several. Becomes a link when `links.story` is configured. */
131
+ story?: string | string[];
132
+ /** Bug / issue key this test guards against, e.g. 'PROMO-118', or several. */
133
+ issue?: string | string[];
121
134
  /** Test case id in your TCM tool (TestRail, Xray, Zephyr...). */
122
135
  testCaseId?: string;
123
- /** Free-form tags or any other key you like. */
124
- [key: string]: string | number | undefined;
136
+ /** Free-form tags or any other key you like. An object holds the fields of a multi-parameter link, see `links`. */
137
+ [key: string]: string | number | (string | number)[] | Record<string, string | number> | undefined;
125
138
  }
126
139
  /** Empty by default; augment it to add typed keys of your own. */
127
140
  export interface CustomMeta {
@@ -237,6 +250,8 @@ export interface TestData {
237
250
  }[];
238
251
  /** extracted dimension values, e.g. { priority: 'P1', severity: 'critical' } */
239
252
  meta: Record<string, string>;
253
+ /** Ready-made hrefs for meta keys whose link needed more than the displayed value (object meta + LinkTemplate). */
254
+ links?: Record<string, string>;
240
255
  outcome: Status;
241
256
  duration: number;
242
257
  results: ResultData[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reporting-labs",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
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",