@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/analyze/detectors.js +236 -24
- package/dist/analyze/index.d.ts +0 -1
- package/dist/analyze/index.js +0 -1
- package/dist/analyze/propose.js +80 -38
- package/dist/analyze/scan.js +19 -1
- package/dist/api/client.js +7 -1
- package/dist/auth/capture.js +19 -6
- package/dist/auth/index.js +23 -2
- package/dist/config/resolve.d.ts +13 -0
- package/dist/config/resolve.js +1 -0
- package/dist/config/runner.js +2 -2
- package/dist/config/tags.d.ts +29 -1
- package/dist/config/tags.js +46 -0
- package/dist/data/provider.js +5 -1
- package/dist/data/user-pool.js +35 -3
- package/dist/fixtures/api-context.d.ts +14 -1
- package/dist/fixtures/api-context.js +13 -0
- package/dist/fixtures/scenario.js +1 -4
- package/dist/fixtures/test.js +42 -1
- package/dist/fixtures/types.d.ts +2 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/reporters/dashboard.d.ts +86 -0
- package/dist/reporters/dashboard.js +319 -61
- package/dist/shots/hooks.js +0 -10
- package/dist/steps/a11y.steps.d.ts +180 -0
- package/dist/steps/a11y.steps.js +598 -0
- package/dist/steps/api.steps.js +5 -1
- package/dist/steps/browser.steps.d.ts +27 -0
- package/dist/steps/browser.steps.js +653 -0
- package/dist/steps/clock.steps.d.ts +4 -0
- package/dist/steps/clock.steps.js +73 -0
- package/dist/steps/data.steps.js +50 -2
- package/dist/steps/db.steps.d.ts +5 -0
- package/dist/steps/db.steps.js +105 -0
- package/dist/steps/dom.steps.d.ts +2 -0
- package/dist/steps/dom.steps.js +583 -0
- package/dist/steps/glob.d.ts +16 -1
- package/dist/steps/glob.js +40 -1
- package/dist/steps/iframe.steps.d.ts +2 -0
- package/dist/steps/iframe.steps.js +93 -0
- package/dist/steps/index.d.ts +11 -1
- package/dist/steps/index.js +11 -1
- package/dist/steps/net.steps.d.ts +63 -0
- package/dist/steps/net.steps.js +728 -0
- package/dist/steps/perf.steps.d.ts +248 -0
- package/dist/steps/perf.steps.js +514 -0
- package/dist/steps/tabs.steps.d.ts +5 -0
- package/dist/steps/tabs.steps.js +109 -0
- package/dist/steps/webhook.steps.d.ts +46 -0
- package/dist/steps/webhook.steps.js +129 -0
- package/package.json +3 -4
- package/dist/analyze/modules.d.ts +0 -74
- package/dist/analyze/modules.js +0 -353
- package/dist/config/playwright.d.ts +0 -37
- package/dist/config/playwright.js +0 -262
package/dist/auth/index.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
}
|
package/dist/config/resolve.d.ts
CHANGED
|
@@ -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;
|
package/dist/config/resolve.js
CHANGED
package/dist/config/runner.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
124
|
+
...coreStepsPatterns(cfg.project.steps?.core?.exclude ?? []),
|
|
125
125
|
`${toPosix(p.root)}/steps/**/*.ts`,
|
|
126
126
|
`${toPosix(p.root)}/pages/**/*.ts`,
|
|
127
127
|
],
|
package/dist/config/tags.d.ts
CHANGED
|
@@ -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
|
package/dist/config/tags.js
CHANGED
|
@@ -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
|
package/dist/data/provider.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/dist/data/user-pool.js
CHANGED
|
@@ -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 ??
|
|
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)}.
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/dist/fixtures/test.js
CHANGED
|
@@ -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,
|
package/dist/fixtures/types.d.ts
CHANGED
|
@@ -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
|