@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/analyze/detectors.js +236 -24
- package/dist/analyze/propose.js +32 -10
- package/dist/analyze/scan.js +19 -1
- package/dist/api/client.js +7 -1
- package/dist/auth/capture.js +21 -7
- package/dist/auth/index.js +71 -12
- package/dist/config/resolve.d.ts +13 -0
- package/dist/config/resolve.js +1 -0
- package/dist/config/runner.js +3 -0
- 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/auth.d.ts +9 -1
- package/dist/fixtures/auth.js +13 -5
- package/dist/fixtures/test.js +42 -1
- package/dist/fixtures/types.d.ts +2 -0
- package/dist/har/api-har.d.ts +1 -0
- package/dist/har/api-har.js +1 -1
- package/dist/har/index.d.ts +1 -0
- package/dist/har/index.js +1 -0
- package/dist/har/scrub.d.ts +17 -0
- package/dist/har/scrub.js +60 -0
- package/dist/reporters/dashboard.d.ts +86 -0
- package/dist/reporters/dashboard.js +319 -61
- 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/iframe.steps.d.ts +2 -0
- package/dist/steps/iframe.steps.js +93 -0
- package/dist/steps/index.d.ts +10 -0
- package/dist/steps/index.js +10 -0
- 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/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -4
package/dist/config/runner.js
CHANGED
|
@@ -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: {
|
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();
|
package/dist/fixtures/auth.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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: {
|
package/dist/fixtures/auth.js
CHANGED
|
@@ -92,15 +92,23 @@ export class AuthStateCache {
|
|
|
92
92
|
return this.save(user, state);
|
|
93
93
|
});
|
|
94
94
|
}
|
|
95
|
-
/**
|
|
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 (;;) {
|
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/har/api-har.d.ts
CHANGED
|
@@ -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;
|
package/dist/har/api-har.js
CHANGED
|
@@ -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;
|
package/dist/har/index.d.ts
CHANGED
package/dist/har/index.js
CHANGED
|
@@ -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
|