@sdods/core 0.2.2 → 0.3.1

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 (57) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/analyze/detectors.js +236 -24
  3. package/dist/analyze/index.d.ts +0 -1
  4. package/dist/analyze/index.js +0 -1
  5. package/dist/analyze/propose.js +80 -38
  6. package/dist/analyze/scan.js +19 -1
  7. package/dist/api/client.js +7 -1
  8. package/dist/auth/capture.js +19 -6
  9. package/dist/auth/index.js +23 -2
  10. package/dist/config/resolve.d.ts +13 -0
  11. package/dist/config/resolve.js +1 -0
  12. package/dist/config/runner.js +2 -2
  13. package/dist/config/tags.d.ts +29 -1
  14. package/dist/config/tags.js +46 -0
  15. package/dist/data/provider.js +5 -1
  16. package/dist/data/user-pool.js +35 -3
  17. package/dist/fixtures/api-context.d.ts +14 -1
  18. package/dist/fixtures/api-context.js +13 -0
  19. package/dist/fixtures/scenario.js +1 -4
  20. package/dist/fixtures/test.js +42 -1
  21. package/dist/fixtures/types.d.ts +2 -0
  22. package/dist/index.d.ts +1 -1
  23. package/dist/index.js +1 -1
  24. package/dist/reporters/dashboard.d.ts +86 -0
  25. package/dist/reporters/dashboard.js +319 -61
  26. package/dist/shots/hooks.js +0 -10
  27. package/dist/steps/a11y.steps.d.ts +180 -0
  28. package/dist/steps/a11y.steps.js +598 -0
  29. package/dist/steps/api.steps.js +5 -1
  30. package/dist/steps/browser.steps.d.ts +27 -0
  31. package/dist/steps/browser.steps.js +653 -0
  32. package/dist/steps/clock.steps.d.ts +4 -0
  33. package/dist/steps/clock.steps.js +73 -0
  34. package/dist/steps/data.steps.js +50 -2
  35. package/dist/steps/db.steps.d.ts +5 -0
  36. package/dist/steps/db.steps.js +105 -0
  37. package/dist/steps/dom.steps.d.ts +2 -0
  38. package/dist/steps/dom.steps.js +583 -0
  39. package/dist/steps/glob.d.ts +16 -1
  40. package/dist/steps/glob.js +40 -1
  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 +11 -1
  44. package/dist/steps/index.js +11 -1
  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/package.json +3 -4
  54. package/dist/analyze/modules.d.ts +0 -74
  55. package/dist/analyze/modules.js +0 -353
  56. package/dist/config/playwright.d.ts +0 -37
  57. package/dist/config/playwright.js +0 -262
@@ -1,5 +1,6 @@
1
1
  import { mkdirSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ import { SdodsError } from '../errors.js';
3
4
  /**
4
5
  * Define the project's auth strategy. `form` uses `auth.form` selectors from the project yaml
5
6
  * unless a custom `login` is provided.
@@ -31,7 +32,16 @@ export function defineAuth(def) {
31
32
  case 'token':
32
33
  return {
33
34
  strategy: 'token',
34
- login: async () => undefined,
35
+ // A `token` project can call the API and has NO browser session. Returning `undefined`
36
+ // here let a @ui scenario run signed-out and then fail on an assertion about the page,
37
+ // which reads as a product defect rather than as a misconfigured project. Fail where the
38
+ // cause is, and say what to do instead.
39
+ login: async () => {
40
+ throw new SdodsError('NOT_SUPPORTED', 'The `token` auth strategy provides an API credential, not a browser session.', {
41
+ hint: 'Use `strategy: "custom"` with a `login` that performs the sign-in and returns a storageState, or restrict these scenarios to the @api layer.',
42
+ exitCode: 2,
43
+ });
44
+ },
35
45
  token: def.token,
36
46
  };
37
47
  case 'oauth-client-credentials':
@@ -63,7 +73,18 @@ export function defineAuth(def) {
63
73
  },
64
74
  };
65
75
  case 'sso':
66
- return { strategy: 'sso', login: async () => undefined };
76
+ return {
77
+ strategy: 'sso',
78
+ // `sso` was a stub: it returned no session and threw nothing, so every scenario under it
79
+ // ran unauthenticated and silently. There is no generic SSO sign-in to implement — the
80
+ // flow is provider-specific — so the honest behaviour is to refuse and name the seam.
81
+ login: async () => {
82
+ throw new SdodsError('NOT_SUPPORTED', 'The `sso` auth strategy is a placeholder: it performs no sign-in.', {
83
+ hint: 'SSO flows are provider-specific. Use `strategy: "custom"` with a `login` that drives your identity provider, or capture a session once with `sdods auth capture --interactive`.',
84
+ exitCode: 2,
85
+ });
86
+ },
87
+ };
67
88
  case 'custom':
68
89
  return { strategy: 'custom', login: def.login, token: def.token };
69
90
  }
@@ -50,6 +50,19 @@ export interface ResolvedConfig {
50
50
  sources: {
51
51
  dotenvFiles: string[];
52
52
  };
53
+ /**
54
+ * The resolved `${VAR}` scope: the dotenv layer merged under `process.env`.
55
+ *
56
+ * Exposed because `loadDotEnvLayer` deliberately does NOT mutate `process.env`
57
+ * (doing so would silently outrank the process layer), so anything that
58
+ * interpolates `${VAR}` outside the config tree — dataset rows, most of all —
59
+ * cannot reach the values in `.env.<env>` unless it is handed this. Without
60
+ * it, the documented pattern of keeping passwords in `.env.<env>` and
61
+ * referencing them from `data/**\/users.csv` resolves to the literal string
62
+ * `${TEST_MEMBER_PASSWORD}`, and the failure surfaces far away as a provider
63
+ * rejecting the credential.
64
+ */
65
+ vars: Record<string, string | undefined>;
53
66
  }
54
67
  export interface ResolveOptions {
55
68
  rootDir: string;
@@ -162,6 +162,7 @@ export function resolveConfig(opts) {
162
162
  runtime: rt,
163
163
  provenance: Object.fromEntries(prov.byPath),
164
164
  sources: { dotenvFiles: dotenv.files },
165
+ vars,
165
166
  };
166
167
  }
167
168
  function markAll(obj, prov, layer, prefix) {
@@ -3,7 +3,7 @@ import { join, resolve as resolvePath } from 'node:path';
3
3
  import { devices } from '@playwright/test';
4
4
  import { cucumberReporter, defineBddConfig } from 'playwright-bdd';
5
5
  import { runnerProjectName, runFiles } from '@sdods/contracts';
6
- import { coreStepsGlob } from '../steps/glob.js';
6
+ import { coreStepsPatterns } from '../steps/glob.js';
7
7
  import { combineTagExpr, normalizeTagExpr } from './tags.js';
8
8
  const DEVICE_FOR_BROWSER = {
9
9
  chromium: 'Desktop Chrome',
@@ -121,7 +121,7 @@ export function buildRunnerConfig(registry, sel = {}) {
121
121
  const testDir = defineBddConfig({
122
122
  features: `${toPosix(p.root)}/features/**/*.feature`,
123
123
  steps: [
124
- coreStepsGlob(),
124
+ ...coreStepsPatterns(cfg.project.steps?.core?.exclude ?? []),
125
125
  `${toPosix(p.root)}/steps/**/*.ts`,
126
126
  `${toPosix(p.root)}/pages/**/*.ts`,
127
127
  ],
@@ -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();
@@ -37,10 +37,7 @@ export class ScenarioMeta {
37
37
  pickleLine: init.pickleLine,
38
38
  exampleIndex: null,
39
39
  tags,
40
- // `featureUri`, not `init.featureUri`: playwright-bdd hands us a repo-root-relative path,
41
- // and moduleForFeature resolves its argument against project.root, so passing the raw
42
- // value yields projects/<slug>/projects/<slug>/... and matches no module.
43
- module: moduleForFeature(config.project, featureUri)?.name,
40
+ module: moduleForFeature(config.project, init.featureUri)?.name,
44
41
  process: process.env.SDODS_PROCESS || undefined,
45
42
  retry: testInfo.retry,
46
43
  workerIndex: testInfo.workerIndex,
@@ -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
package/dist/index.d.ts CHANGED
@@ -12,6 +12,6 @@ export { PageRegistry } from './fixtures/pages.js';
12
12
  export { AuthStateCache } from './fixtures/auth.js';
13
13
  export { ScenarioMeta } from './fixtures/scenario.js';
14
14
  export type { TestFixtures, WorkerFixtures, SdodsOption } from './fixtures/types.js';
15
- export { coreStepsGlob, coreStepsDir } from './steps/glob.js';
15
+ export { coreStepsGlob, coreStepsPatterns, coreStepNames, coreStepsDir } from './steps/glob.js';
16
16
  export { VERSION } from './version.js';
17
17
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -11,6 +11,6 @@ export { BasePage } from './pages/base-page.js';
11
11
  export { PageRegistry } from './fixtures/pages.js';
12
12
  export { AuthStateCache } from './fixtures/auth.js';
13
13
  export { ScenarioMeta } from './fixtures/scenario.js';
14
- export { coreStepsGlob, coreStepsDir } from './steps/glob.js';
14
+ export { coreStepsGlob, coreStepsPatterns, coreStepNames, coreStepsDir } from './steps/glob.js';
15
15
  export { VERSION } from './version.js';
16
16
  //# sourceMappingURL=index.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