@sdods/core 0.2.1 → 0.3.0

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.
Files changed (55) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/analyze/detectors.js +236 -24
  3. package/dist/analyze/propose.js +32 -10
  4. package/dist/analyze/scan.js +19 -1
  5. package/dist/api/client.js +7 -1
  6. package/dist/auth/capture.js +21 -7
  7. package/dist/auth/index.js +71 -12
  8. package/dist/config/resolve.d.ts +13 -0
  9. package/dist/config/resolve.js +1 -0
  10. package/dist/config/runner.js +3 -0
  11. package/dist/config/tags.d.ts +29 -1
  12. package/dist/config/tags.js +46 -0
  13. package/dist/data/provider.js +5 -1
  14. package/dist/data/user-pool.js +35 -3
  15. package/dist/fixtures/api-context.d.ts +14 -1
  16. package/dist/fixtures/api-context.js +13 -0
  17. package/dist/fixtures/auth.d.ts +9 -1
  18. package/dist/fixtures/auth.js +13 -5
  19. package/dist/fixtures/test.js +42 -1
  20. package/dist/fixtures/types.d.ts +2 -0
  21. package/dist/har/api-har.d.ts +1 -0
  22. package/dist/har/api-har.js +1 -1
  23. package/dist/har/index.d.ts +1 -0
  24. package/dist/har/index.js +1 -0
  25. package/dist/har/scrub.d.ts +17 -0
  26. package/dist/har/scrub.js +60 -0
  27. package/dist/reporters/dashboard.d.ts +86 -0
  28. package/dist/reporters/dashboard.js +319 -61
  29. package/dist/steps/a11y.steps.d.ts +180 -0
  30. package/dist/steps/a11y.steps.js +598 -0
  31. package/dist/steps/api.steps.js +5 -1
  32. package/dist/steps/browser.steps.d.ts +27 -0
  33. package/dist/steps/browser.steps.js +653 -0
  34. package/dist/steps/clock.steps.d.ts +4 -0
  35. package/dist/steps/clock.steps.js +73 -0
  36. package/dist/steps/data.steps.js +50 -2
  37. package/dist/steps/db.steps.d.ts +5 -0
  38. package/dist/steps/db.steps.js +105 -0
  39. package/dist/steps/dom.steps.d.ts +2 -0
  40. package/dist/steps/dom.steps.js +583 -0
  41. package/dist/steps/iframe.steps.d.ts +2 -0
  42. package/dist/steps/iframe.steps.js +93 -0
  43. package/dist/steps/index.d.ts +10 -0
  44. package/dist/steps/index.js +10 -0
  45. package/dist/steps/net.steps.d.ts +63 -0
  46. package/dist/steps/net.steps.js +728 -0
  47. package/dist/steps/perf.steps.d.ts +248 -0
  48. package/dist/steps/perf.steps.js +514 -0
  49. package/dist/steps/tabs.steps.d.ts +5 -0
  50. package/dist/steps/tabs.steps.js +109 -0
  51. package/dist/steps/webhook.steps.d.ts +46 -0
  52. package/dist/steps/webhook.steps.js +129 -0
  53. package/dist/version.d.ts +1 -1
  54. package/dist/version.js +1 -1
  55. package/package.json +3 -4
@@ -236,6 +236,9 @@ export function buildRunnerConfig(registry, sel = {}) {
236
236
  trace: 'on-first-retry',
237
237
  actionTimeout: timeouts.action,
238
238
  navigationTimeout: timeouts.navigation,
239
+ // Playwright's own `--headed` flag still wins on the CLI; this is what makes the documented
240
+ // SDODS_HEADED env var and the --headed CliOverride reach the browser at all.
241
+ headless: !first?.runtime.headed,
239
242
  },
240
243
  projects,
241
244
  metadata: {
@@ -5,7 +5,7 @@ export declare const RUNNER_SPECIAL_TAGS: RegExp;
5
5
  /** @deprecated Use {@link RUNNER_SPECIAL_TAGS}. Removed in the next minor. */
6
6
  export declare const PLAYWRIGHT_BDD_SPECIAL: RegExp;
7
7
  export declare const VALUE_TAG: RegExp;
8
- export declare const KNOWN_VALUE_TAGS: readonly ["env", "user", "data", "har", "jira", "github", "skip", "title"];
8
+ export declare const KNOWN_VALUE_TAGS: readonly ["env", "user", "data", "har", "jira", "github", "skip", "title", "flag"];
9
9
  export declare const BROWSERS_FOR_SKIP: readonly ["chromium", "firefox", "webkit", "mobile-chrome", "mobile-safari"];
10
10
  export interface TagTaxonomy {
11
11
  layers: readonly string[];
@@ -24,4 +24,32 @@ export declare function suiteOfTags(tags: readonly string[], suites: readonly st
24
24
  export declare function combineTagExpr(layerTag: string, userExpr?: string): string;
25
25
  /** `--tags @smoke` shorthand also accepts comma lists ("@smoke,@sanity" → "@smoke or @sanity"). */
26
26
  export declare function normalizeTagExpr(input?: string): string | undefined;
27
+ export interface TagGateContext {
28
+ /** `config.env.name` — the environment this run is actually pointed at. */
29
+ env: string;
30
+ /** Browser of the current Playwright project, when there is one. */
31
+ browser?: string;
32
+ /**
33
+ * Feature flags baked into the environment under test. Undefined means "not
34
+ * known", which is treated as "do not gate" — an unknown flag list must not
35
+ * silently skip a suite.
36
+ */
37
+ flags?: readonly string[];
38
+ /** Whether `@quarantine` scenarios run. Defaults to skipping them. */
39
+ quarantine?: 'run' | 'skip';
40
+ }
41
+ /**
42
+ * Why this scenario should not run here, or undefined to run it.
43
+ *
44
+ * Four tags were validated at LINT time and had no runtime path at all, which
45
+ * is the worst arrangement available: the tag reads as a control, the linter
46
+ * confirms it is spelled correctly, and the runner ignores it. A project can
47
+ * carry hundreds of `@env:` tags and still send every one of them at
48
+ * production.
49
+ *
50
+ * Kept a pure function, separate from the fixture that calls it, because the
51
+ * property that matters — "a tag that excludes this environment MUST skip" —
52
+ * should be testable without a browser, a config or a Playwright runner.
53
+ */
54
+ export declare function scenarioSkipReason(tags: readonly string[], ctx: TagGateContext): string | undefined;
27
55
  //# sourceMappingURL=tags.d.ts.map
@@ -13,6 +13,7 @@ export const KNOWN_VALUE_TAGS = [
13
13
  'github',
14
14
  'skip',
15
15
  'title',
16
+ 'flag',
16
17
  ];
17
18
  export const BROWSERS_FOR_SKIP = [
18
19
  'chromium',
@@ -74,4 +75,49 @@ export function normalizeTagExpr(input) {
74
75
  .map((t) => (t.startsWith('@') ? t : `@${t}`));
75
76
  return parts.length > 1 ? parts.join(' or ') : parts[0];
76
77
  }
78
+ /**
79
+ * Why this scenario should not run here, or undefined to run it.
80
+ *
81
+ * Four tags were validated at LINT time and had no runtime path at all, which
82
+ * is the worst arrangement available: the tag reads as a control, the linter
83
+ * confirms it is spelled correctly, and the runner ignores it. A project can
84
+ * carry hundreds of `@env:` tags and still send every one of them at
85
+ * production.
86
+ *
87
+ * Kept a pure function, separate from the fixture that calls it, because the
88
+ * property that matters — "a tag that excludes this environment MUST skip" —
89
+ * should be testable without a browser, a config or a Playwright runner.
90
+ */
91
+ export function scenarioSkipReason(tags, ctx) {
92
+ // @env:<name> — an ALLOW list. Tagging any environment excludes every other
93
+ // one; tagging none leaves the scenario unrestricted.
94
+ const envs = parseTagValues(tags, 'env');
95
+ if (envs.length && !envs.includes(ctx.env)) {
96
+ return `@env:${envs.join(', @env:')} — this run is on "${ctx.env}"`;
97
+ }
98
+ // @skip:<browser> — a DENY list, and the opposite direction on purpose: it
99
+ // names what must not run rather than what may.
100
+ if (ctx.browser) {
101
+ const skipped = parseTagValues(tags, 'skip');
102
+ if (skipped.includes(ctx.browser))
103
+ return `@skip:${ctx.browser}`;
104
+ }
105
+ // @quarantine — known-flaky, excluded unless explicitly asked for. Every
106
+ // recipe was excluding these by hand in its tag expression, which is a
107
+ // per-recipe list that drifts and that nobody can audit centrally.
108
+ if (tags.includes('@quarantine') && (ctx.quarantine ?? 'skip') === 'skip') {
109
+ return '@quarantine — set SDODS_QUARANTINE=run to include quarantined scenarios';
110
+ }
111
+ // @flag:<name> — the scenario needs a feature flag that this build may not
112
+ // carry. Flags are usually baked at build time, so a test cannot turn one on;
113
+ // it can only discover which way the build went and decline to assert.
114
+ if (ctx.flags) {
115
+ const required = parseTagValues(tags, 'flag');
116
+ const missing = required.filter((f) => !ctx.flags.includes(f));
117
+ if (missing.length) {
118
+ return `@flag:${missing.join(', @flag:')} — not enabled in "${ctx.env}"`;
119
+ }
120
+ }
121
+ return undefined;
122
+ }
77
123
  //# sourceMappingURL=tags.js.map
@@ -28,7 +28,11 @@ export class CompositeDataProvider {
28
28
  hint: `Known datasets: ${Object.keys(this.config.project.data.sources).join(', ') || '(none)'}. Add it under data.sources in sdods.project.yaml.`,
29
29
  });
30
30
  }
31
- return (await loadFileSource(this.config, spec, this.opts.vars ?? process.env));
31
+ // `config.vars` is the dotenv layer merged under process.env — the same
32
+ // scope the yaml was interpolated with. Falling back to bare `process.env`
33
+ // here meant a `${VAR}` in a dataset could only ever resolve from the
34
+ // shell, never from the `.env.<env>` file SDODS itself loaded.
35
+ return (await loadFileSource(this.config, spec, this.opts.vars ?? this.config.vars ?? process.env));
32
36
  }
33
37
  async row(dataset, index) {
34
38
  const rows = await this.load(dataset);
@@ -138,8 +138,30 @@ export class FileUserPool {
138
138
  hint: `Roles present: ${[...new Set(rows.map((r) => String(r[pool.roleColumn])))].join(', ')}.`,
139
139
  });
140
140
  }
141
+ // A shared pool hands out an account without acquiring a lease at all.
142
+ // Deterministic by worker index, so two workers on the same role get
143
+ // different accounts when the pool has them and the same one when it does
144
+ // not — which is the point: sharing is what makes a single-account role
145
+ // usable by a parallel read-only suite.
146
+ if (pool.mode === 'shared') {
147
+ const picked = candidates[_parallelIndex % candidates.length];
148
+ const id = String(picked.r.id ?? picked.r.username ?? picked.index);
149
+ const user = {
150
+ id,
151
+ username: String(picked.r.username ?? id),
152
+ password: String(picked.r.password ?? ''),
153
+ role,
154
+ index: picked.index,
155
+ extra: picked.r,
156
+ leaseKey: '',
157
+ owner: this.opts.owner,
158
+ };
159
+ this.leased.set(role, user);
160
+ this.log.debug(`shared ${user.username} (${role}) for ${this.opts.owner}`);
161
+ return user;
162
+ }
141
163
  const ttl = pool.leaseTtlMs;
142
- const waitMs = this.opts.waitMs ?? 30_000;
164
+ const waitMs = this.opts.waitMs ?? pool.waitMs;
143
165
  const started = Date.now();
144
166
  while (true) {
145
167
  for (const { r, index } of candidates) {
@@ -163,14 +185,24 @@ export class FileUserPool {
163
185
  if (Date.now() - started > waitMs) {
164
186
  const owners = await this.store.owners();
165
187
  throw new SdodsError('USER_POOL_EXHAUSTED', `All ${candidates.length} user(s) with role "${role}" are leased.`, {
166
- hint: `Owners: ${JSON.stringify(owners)}. Increase env.users.poolSize, add users, or lower --workers.`,
188
+ hint: `Owners: ${JSON.stringify(owners)}. Waited ${Math.round(waitMs / 1000)}s. ` +
189
+ `Four ways out, in the order worth trying: (1) if these scenarios do not ` +
190
+ `mutate user-scoped state, set data.userPool.mode: shared — one account ` +
191
+ `then serves every worker, which is what a read-only suite needs; ` +
192
+ `(2) add more accounts with role "${role}" to dataset "${pool.dataset}"; ` +
193
+ `(3) raise data.userPool.waitMs (currently ${waitMs}ms); (4) lower --workers. ` +
194
+ `Note env.users.poolSize slices the dataset BEFORE role filtering, so a ` +
195
+ `small value can starve a role on its own.`,
167
196
  });
168
197
  }
169
198
  await new Promise((r) => setTimeout(r, 500));
170
199
  }
171
200
  }
172
201
  async release(user) {
173
- await this.store.release(user.leaseKey, this.opts.owner);
202
+ // A shared user holds no lease (leaseKey is empty). Releasing it would be at
203
+ // best a no-op and at worst a release of another worker's row.
204
+ if (user.leaseKey)
205
+ await this.store.release(user.leaseKey, this.opts.owner);
174
206
  this.leased.delete(user.role);
175
207
  }
176
208
  async releaseAll() {
@@ -5,6 +5,19 @@ export declare class ApiContext {
5
5
  readonly headers: Map<string, string>;
6
6
  readonly query: Map<string, string>;
7
7
  readonly history: ApiSnapshot[];
8
+ /**
9
+ * Scenario-level auth override.
10
+ *
11
+ * Three states, and the difference between the last two is load-bearing:
12
+ * a value use this credential
13
+ * undefined UNSET — fall back to the environment's `api.auth`
14
+ * null explicitly NONE — send the request unauthenticated
15
+ *
16
+ * Before this distinction existed, `I use no authentication` assigned
17
+ * `undefined` and therefore fell straight back to the environment credential,
18
+ * silently authenticating the very request the scenario was asserting is
19
+ * refused. Any suite whose env declared `api.auth` had a no-op step.
20
+ */
8
21
  auth: {
9
22
  type: 'bearer';
10
23
  token: string;
@@ -16,7 +29,7 @@ export declare class ApiContext {
16
29
  type: 'header';
17
30
  name: string;
18
31
  value: string;
19
- } | undefined;
32
+ } | null | undefined;
20
33
  /** step index → number of calls made during that step (for attachment numbering) */
21
34
  readonly callsByStep: Map<number, number>;
22
35
  get lastResponse(): ApiSnapshot['response'] | undefined;
@@ -4,6 +4,19 @@ export class ApiContext {
4
4
  headers = new Map();
5
5
  query = new Map();
6
6
  history = [];
7
+ /**
8
+ * Scenario-level auth override.
9
+ *
10
+ * Three states, and the difference between the last two is load-bearing:
11
+ * a value use this credential
12
+ * undefined UNSET — fall back to the environment's `api.auth`
13
+ * null explicitly NONE — send the request unauthenticated
14
+ *
15
+ * Before this distinction existed, `I use no authentication` assigned
16
+ * `undefined` and therefore fell straight back to the environment credential,
17
+ * silently authenticating the very request the scenario was asserting is
18
+ * refused. Any suite whose env declared `api.auth` had a no-op step.
19
+ */
7
20
  auth;
8
21
  /** step index → number of calls made during that step (for attachment numbering) */
9
22
  callsByStep = new Map();
@@ -39,7 +39,15 @@ export declare class AuthStateCache {
39
39
  private readState;
40
40
  /** Return a fresh storageState path for the user, capturing it with the strategy when needed. */
41
41
  ensure(user: PoolUserLike, auth: AuthStrategy, browser: Browser): Promise<string | undefined>;
42
- /** Atomic save: temp file + rename, sidecar written last. */
42
+ /**
43
+ * Atomic save: temp file + rename, sidecar written last.
44
+ *
45
+ * Written owner-only. This file is a live session for the application under test — cookies and
46
+ * localStorage, replayable as-is — and it used to land world-readable (0644 in a 0755
47
+ * directory), so any other account on the machine could lift it. Encryption is not the control
48
+ * here: the state has to be decryptable to be replayed, so a key would have to sit on the same
49
+ * disk. Restricting access is what actually protects it.
50
+ */
43
51
  save(user: PoolUserLike, state: Record<string, unknown>): string;
44
52
  /** Apply a cached state to a live context/page (step path: cookies + localStorage). */
45
53
  apply(args: {
@@ -92,15 +92,23 @@ export class AuthStateCache {
92
92
  return this.save(user, state);
93
93
  });
94
94
  }
95
- /** Atomic save: temp file + rename, sidecar written last. */
95
+ /**
96
+ * Atomic save: temp file + rename, sidecar written last.
97
+ *
98
+ * Written owner-only. This file is a live session for the application under test — cookies and
99
+ * localStorage, replayable as-is — and it used to land world-readable (0644 in a 0755
100
+ * directory), so any other account on the machine could lift it. Encryption is not the control
101
+ * here: the state has to be decryptable to be replayed, so a key would have to sit on the same
102
+ * disk. Restricting access is what actually protects it.
103
+ */
96
104
  save(user, state) {
97
- mkdirSync(this.dir, { recursive: true });
105
+ mkdirSync(this.dir, { recursive: true, mode: 0o700 });
98
106
  const file = this.fileFor(user);
99
107
  const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`;
100
- writeFileSync(tmp, JSON.stringify(state, null, 2));
108
+ writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
101
109
  renameSync(tmp, file);
102
110
  const sideTmp = `${this.sidecarFor(user)}.${process.pid}.tmp`;
103
- writeFileSync(sideTmp, JSON.stringify({ capturedAt: new Date().toISOString(), user: user.username, role: user.role }, null, 2));
111
+ writeFileSync(sideTmp, JSON.stringify({ capturedAt: new Date().toISOString(), user: user.username, role: user.role }, null, 2), { mode: 0o600 });
104
112
  renameSync(sideTmp, this.sidecarFor(user));
105
113
  return file;
106
114
  }
@@ -147,7 +155,7 @@ export class AuthStateCache {
147
155
  * LOCK_STALE_MS is treated as abandoned by a crashed worker.
148
156
  */
149
157
  async withLock(user, fn) {
150
- mkdirSync(this.dir, { recursive: true });
158
+ mkdirSync(this.dir, { recursive: true, mode: 0o700 });
151
159
  const lock = this.lockFor(user);
152
160
  const deadline = Date.now() + LOCK_STALE_MS * 2;
153
161
  for (;;) {
@@ -1,7 +1,7 @@
1
1
  import { join } from 'node:path';
2
2
  import { test as base, createBdd } from 'playwright-bdd';
3
3
  import { ProjectRegistry } from '../config/registry.js';
4
- import { parseTagValue } from '../config/tags.js';
4
+ import { parseTagValue, scenarioSkipReason } from '../config/tags.js';
5
5
  import { noopAuth } from '../auth/index.js';
6
6
  import { ApiClient } from '../api/client.js';
7
7
  import { CompositeDataProvider } from '../data/provider.js';
@@ -89,6 +89,47 @@ export const test = base.extend({
89
89
  { scope: 'worker' },
90
90
  ],
91
91
  // ── test scope ──────────────────────────────────────────────────────────
92
+ /**
93
+ * Declared FIRST in test scope, and automatic, so it decides before anything
94
+ * expensive happens — before a pool account is leased, before a browser
95
+ * context is built, before a session is minted.
96
+ *
97
+ * `@env:`, `@skip:<browser>` and `@flag:` were validated at LINT time and had
98
+ * no runtime path whatsoever. That is the worst arrangement available: the
99
+ * tag reads as a control, the linter confirms it is spelled correctly, and
100
+ * the runner ignores it — so a project can carry hundreds of `@env:` tags and
101
+ * still point every one of them at production. `@quarantine` was worse still:
102
+ * it was not a tag this framework knew at all, and every recipe excluded it
103
+ * by hand in a tag expression that drifts and that nobody can audit.
104
+ */
105
+ $sdodsTagGate: [
106
+ async ({ config, $tags }, use, testInfo) => {
107
+ const features = config.env.vars?.features;
108
+ const reason = scenarioSkipReason($tags, {
109
+ env: config.env.name,
110
+ browser: testInfo.project.use?.browserName,
111
+ // Only gate on flags when the environment actually declares them.
112
+ // An absent list means "not known", and an unknown list must never
113
+ // silently skip a suite.
114
+ flags: typeof features === 'string'
115
+ ? features
116
+ .split(',')
117
+ .map((f) => f.trim())
118
+ .filter(Boolean)
119
+ : undefined,
120
+ quarantine: process.env.SDODS_QUARANTINE === 'run' ? 'run' : 'skip',
121
+ });
122
+ if (reason) {
123
+ // Recorded as an annotation as well as a skip reason: a run report that
124
+ // says "skipped" without saying why is the thing that let 175 parked
125
+ // scenarios go unnoticed.
126
+ testInfo.annotations.push({ type: 'sdods:skipped', description: reason });
127
+ testInfo.skip(true, reason);
128
+ }
129
+ await use();
130
+ },
131
+ { auto: true },
132
+ ],
92
133
  scenario: async ({ config, sdods, $bddContext, $tags }, use, testInfo) => {
93
134
  const meta = new ScenarioMeta({
94
135
  config,
@@ -46,5 +46,7 @@ export interface TestFixtures {
46
46
  heal: Healer;
47
47
  /** auto fixture: pushes identity annotations */
48
48
  $sdodsAnnotations: void;
49
+ /** auto fixture: applies @env:, @skip:<browser>, @quarantine and @flag: */
50
+ $sdodsTagGate: void;
49
51
  }
50
52
  //# sourceMappingURL=types.d.ts.map
@@ -63,6 +63,7 @@ export interface HarFile {
63
63
  entries: SdodsHarEntry[];
64
64
  };
65
65
  }
66
+ export declare const SECRET_HEADER: RegExp;
66
67
  export declare function normalizeUrl(url: string): string;
67
68
  export declare function bodyHash(body: unknown): string;
68
69
  export declare function harKey(method: string, url: string, body?: unknown): string;
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { dirname } from 'node:path';
4
4
  import { redact } from '../logger.js';
5
5
  import { VERSION } from '../version.js';
6
- const SECRET_HEADER = /^(authorization|cookie|set-cookie|x-api-key|proxy-authorization)$/i;
6
+ export const SECRET_HEADER = /^(authorization|cookie|set-cookie|x-api-key|proxy-authorization)$/i;
7
7
  const VOLATILE_QUERY = /^(_|t|ts|timestamp|nonce|cb|cache)$/i;
8
8
  export function normalizeUrl(url) {
9
9
  let u;
@@ -1,4 +1,5 @@
1
1
  export * from './paths.js';
2
2
  export * from './api-har.js';
3
3
  export * from './hooks.js';
4
+ export * from './scrub.js';
4
5
  //# sourceMappingURL=index.d.ts.map
package/dist/har/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './paths.js';
2
2
  export * from './api-har.js';
3
3
  export * from './hooks.js';
4
+ export * from './scrub.js';
4
5
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Strip credentials out of a HAR recorded by the browser.
3
+ *
4
+ * The API layer builds its own HAR and redacts secret headers as it goes. The browser layer does
5
+ * not: Playwright's `routeFromHAR({ update: true })` writes the file itself, verbatim, including
6
+ * the `Cookie`, `Set-Cookie` and `Authorization` headers of the application under test. HARs are
7
+ * meant to be committed — the demo project's are in git — so recording against a real application
8
+ * while signed in wrote a live session into a tracked file.
9
+ *
10
+ * Values are replaced rather than the headers removed, so replay still matches on their presence.
11
+ */
12
+ export declare function scrubHar(har: unknown): {
13
+ changed: number;
14
+ };
15
+ /** Scrub a HAR file in place. Returns how many values were replaced, or -1 if it is not readable. */
16
+ export declare function scrubHarFile(file: string): number;
17
+ //# sourceMappingURL=scrub.d.ts.map
@@ -0,0 +1,60 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import { SECRET_HEADER } from './api-har.js';
3
+ /**
4
+ * Strip credentials out of a HAR recorded by the browser.
5
+ *
6
+ * The API layer builds its own HAR and redacts secret headers as it goes. The browser layer does
7
+ * not: Playwright's `routeFromHAR({ update: true })` writes the file itself, verbatim, including
8
+ * the `Cookie`, `Set-Cookie` and `Authorization` headers of the application under test. HARs are
9
+ * meant to be committed — the demo project's are in git — so recording against a real application
10
+ * while signed in wrote a live session into a tracked file.
11
+ *
12
+ * Values are replaced rather than the headers removed, so replay still matches on their presence.
13
+ */
14
+ export function scrubHar(har) {
15
+ let changed = 0;
16
+ const entries = har?.log?.entries;
17
+ if (!Array.isArray(entries))
18
+ return { changed };
19
+ for (const entry of entries) {
20
+ for (const side of ['request', 'response']) {
21
+ const headers = entry[side]?.headers;
22
+ if (!Array.isArray(headers))
23
+ continue;
24
+ for (const h of headers) {
25
+ if (h && typeof h.name === 'string' && SECRET_HEADER.test(h.name) && h.value !== '***') {
26
+ h.value = '***';
27
+ changed++;
28
+ }
29
+ }
30
+ }
31
+ // Playwright also records cookies as structured arrays alongside the headers.
32
+ for (const side of ['request', 'response']) {
33
+ const cookies = entry[side]?.cookies;
34
+ if (!Array.isArray(cookies))
35
+ continue;
36
+ for (const c of cookies) {
37
+ if (c && typeof c.value === 'string' && c.value !== '***') {
38
+ c.value = '***';
39
+ changed++;
40
+ }
41
+ }
42
+ }
43
+ }
44
+ return { changed };
45
+ }
46
+ /** Scrub a HAR file in place. Returns how many values were replaced, or -1 if it is not readable. */
47
+ export function scrubHarFile(file) {
48
+ let parsed;
49
+ try {
50
+ parsed = JSON.parse(readFileSync(file, 'utf8'));
51
+ }
52
+ catch {
53
+ return -1;
54
+ }
55
+ const { changed } = scrubHar(parsed);
56
+ if (changed > 0)
57
+ writeFileSync(file, `${JSON.stringify(parsed, null, 2)}\n`);
58
+ return changed;
59
+ }
60
+ //# sourceMappingURL=scrub.js.map
@@ -1,4 +1,21 @@
1
1
  import type { FullConfig, FullResult, Reporter, Suite, TestCase, TestResult } from '@playwright/test/reporter';
2
+ interface Entry {
3
+ id: string;
4
+ title: string;
5
+ fullTitle: string;
6
+ status: 'passed' | 'failed' | 'skipped' | 'timedOut' | 'interrupted';
7
+ outcome: 'expected' | 'unexpected' | 'flaky' | 'skipped';
8
+ duration: number;
9
+ error?: string;
10
+ retries: number;
11
+ projectName: string;
12
+ layer: string;
13
+ browser: string;
14
+ tags: string[];
15
+ file: string;
16
+ heals: number;
17
+ fingerprint?: string;
18
+ }
2
19
  export interface DashboardOptions {
3
20
  outputDir?: string;
4
21
  title?: string;
@@ -20,4 +37,73 @@ export default class DashboardReporter implements Reporter {
20
37
  onEnd(_result: FullResult): Promise<void>;
21
38
  printsToStdio(): boolean;
22
39
  }
40
+ export declare function errorSignature(error: string | undefined): string;
41
+ interface Group {
42
+ total: number;
43
+ passed: number;
44
+ failed: number;
45
+ skipped?: number;
46
+ flaky?: number;
47
+ }
48
+ export interface Metrics {
49
+ title: string;
50
+ generatedAt: string;
51
+ summary: {
52
+ total: number;
53
+ passed: number;
54
+ failed: number;
55
+ skipped: number;
56
+ timedOut: number;
57
+ flaky: number;
58
+ healed: number;
59
+ durationMs: number;
60
+ workers: number;
61
+ };
62
+ clusters: {
63
+ signature: string;
64
+ count: number;
65
+ titles: string[];
66
+ }[];
67
+ byRole: Record<string, Group>;
68
+ slowest: Entry[];
69
+ byProject: Record<string, Group>;
70
+ byLayer: Record<string, Group>;
71
+ byBrowser: Record<string, Group>;
72
+ byTag: Record<string, Group>;
73
+ failed: {
74
+ fingerprint?: string;
75
+ title: string;
76
+ runnerProject: string;
77
+ error?: string;
78
+ }[];
79
+ flaky: {
80
+ fingerprint?: string;
81
+ title: string;
82
+ runnerProject: string;
83
+ }[];
84
+ tests: Entry[];
85
+ }
86
+ /**
87
+ * The dashboard is a DECISION surface, not a report.
88
+ *
89
+ * Every run ends with someone asking one of four questions, and the layout answers
90
+ * them in the order they get asked:
91
+ *
92
+ * 1. Can I ship? -> the verdict line, in words, before any number
93
+ * 2. What do I fix first? -> failures CLUSTERED by error signature, because
94
+ * twelve scenarios failing on one broken selector
95
+ * is one problem and a list of twelve reads as twelve
96
+ * 3. Is it real or flaky? -> flake, retry and heal counts sit beside the verdict,
97
+ * not in a footer
98
+ * 4. Do I believe this run? -> skipped and healed are shown as WARNINGS, never
99
+ * folded into a pass rate. A suite that skipped a
100
+ * third of itself is not 100% green, and a healed
101
+ * locator means the application's DOM moved under us
102
+ *
103
+ * The role matrix is the one view a general-purpose reporter never has: when
104
+ * `@user:viewer` fails and `@user:admin` passes, that is a permissions regression,
105
+ * and it is invisible in any total.
106
+ */
107
+ export declare function renderHtml(m: Metrics): string;
108
+ export {};
23
109
  //# sourceMappingURL=dashboard.d.ts.map