@sdods/core 0.2.0 → 0.2.2
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/modules.js +45 -6
- package/dist/auth/capture.js +2 -1
- package/dist/auth/index.js +48 -10
- package/dist/config/runner.js +3 -0
- package/dist/fixtures/auth.d.ts +9 -1
- package/dist/fixtures/auth.js +13 -5
- 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/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -3
package/dist/analyze/modules.js
CHANGED
|
@@ -199,20 +199,52 @@ export function detectModules(report, opts = {}) {
|
|
|
199
199
|
// S2 workspace (0.9)
|
|
200
200
|
const ws = longestWorkspaceMatch(r.file, workspaces);
|
|
201
201
|
if (ws) {
|
|
202
|
-
cast({
|
|
202
|
+
cast({
|
|
203
|
+
target: r.path,
|
|
204
|
+
kind,
|
|
205
|
+
module: ws,
|
|
206
|
+
weight: SIGNAL_WEIGHTS.workspace,
|
|
207
|
+
signal: 'workspace',
|
|
208
|
+
layer,
|
|
209
|
+
evidence: ev,
|
|
210
|
+
});
|
|
203
211
|
}
|
|
204
212
|
// S3 domain-dir (0.8)
|
|
205
213
|
const dd = DOMAIN_DIR_RE.exec(r.file.replace(/\\/g, '/'));
|
|
206
214
|
if (dd?.[1] && !GENERIC_DIRS.has(dd[1].toLowerCase())) {
|
|
207
|
-
cast({
|
|
215
|
+
cast({
|
|
216
|
+
target: r.path,
|
|
217
|
+
kind,
|
|
218
|
+
module: slugify(dd[1]),
|
|
219
|
+
weight: SIGNAL_WEIGHTS['domain-dir'],
|
|
220
|
+
signal: 'domain-dir',
|
|
221
|
+
layer,
|
|
222
|
+
evidence: ev,
|
|
223
|
+
});
|
|
208
224
|
}
|
|
209
225
|
// S4 source-file (0.7) - the primary signal
|
|
210
226
|
const mf = moduleOfFile(r.file);
|
|
211
227
|
if (mf) {
|
|
212
|
-
cast({
|
|
228
|
+
cast({
|
|
229
|
+
target: r.path,
|
|
230
|
+
kind,
|
|
231
|
+
module: mf,
|
|
232
|
+
weight: SIGNAL_WEIGHTS['source-file'],
|
|
233
|
+
signal: 'source-file',
|
|
234
|
+
layer,
|
|
235
|
+
evidence: ev,
|
|
236
|
+
});
|
|
213
237
|
}
|
|
214
238
|
// S5 route-segment (0.3) - today's rule, kept as the floor so nothing goes unassigned
|
|
215
|
-
cast({
|
|
239
|
+
cast({
|
|
240
|
+
target: r.path,
|
|
241
|
+
kind,
|
|
242
|
+
module: moduleOfPath(r.path),
|
|
243
|
+
weight: SIGNAL_WEIGHTS['route-segment'],
|
|
244
|
+
signal: 'route-segment',
|
|
245
|
+
layer,
|
|
246
|
+
evidence: ev,
|
|
247
|
+
});
|
|
216
248
|
}
|
|
217
249
|
// resolve: strongest vote per target
|
|
218
250
|
const winners = new Map();
|
|
@@ -224,8 +256,15 @@ export function detectModules(report, opts = {}) {
|
|
|
224
256
|
// build modules
|
|
225
257
|
const acc = new Map();
|
|
226
258
|
for (const v of winners.values()) {
|
|
227
|
-
const m = acc.get(v.module) ??
|
|
228
|
-
|
|
259
|
+
const m = acc.get(v.module) ?? {
|
|
260
|
+
routes: new Set(),
|
|
261
|
+
endpoints: new Set(),
|
|
262
|
+
layers: new Set(),
|
|
263
|
+
signals: new Set(),
|
|
264
|
+
evidence: [],
|
|
265
|
+
weights: [],
|
|
266
|
+
contract: false,
|
|
267
|
+
};
|
|
229
268
|
if (v.kind === 'endpoint')
|
|
230
269
|
m.endpoints.add(v.target);
|
|
231
270
|
else
|
package/dist/auth/capture.js
CHANGED
|
@@ -148,7 +148,8 @@ export async function captureAuth(opts) {
|
|
|
148
148
|
const token = await strategy.token({ config, user });
|
|
149
149
|
if (token) {
|
|
150
150
|
const file = tokenFileFor(config, user);
|
|
151
|
-
|
|
151
|
+
// Owner-only: this holds a usable API token for the application under test.
|
|
152
|
+
writeFileSync(file, JSON.stringify({ token, capturedAt: new Date().toISOString(), user: user.username, role: user.role }, null, 2), { mode: 0o600 });
|
|
152
153
|
result.tokenFile = file;
|
|
153
154
|
}
|
|
154
155
|
}
|
package/dist/auth/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
1
3
|
/**
|
|
2
4
|
* Define the project's auth strategy. `form` uses `auth.form` selectors from the project yaml
|
|
3
5
|
* unless a custom `login` is provided.
|
|
@@ -71,16 +73,52 @@ export async function formLogin(page, config, user) {
|
|
|
71
73
|
if (!form) {
|
|
72
74
|
throw new Error('auth.strategy is "form" but auth.form selectors are missing in sdods.project.yaml.');
|
|
73
75
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
76
|
+
try {
|
|
77
|
+
await page.goto(form.loginPath);
|
|
78
|
+
await page.locator(form.usernameSelector).fill(user.username);
|
|
79
|
+
await page.locator(form.passwordSelector).fill(user.password);
|
|
80
|
+
await page.locator(form.submitSelector).click();
|
|
81
|
+
if (form.readySelector)
|
|
82
|
+
await page.locator(form.readySelector).first().waitFor({ state: 'visible' });
|
|
83
|
+
else if (form.readyUrl)
|
|
84
|
+
await page.waitForURL(`**${form.readyUrl}*`);
|
|
85
|
+
else
|
|
86
|
+
await page.waitForLoadState('domcontentloaded');
|
|
87
|
+
}
|
|
88
|
+
catch (cause) {
|
|
89
|
+
throw await describeLoginFailure(page, config, user, cause);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The form login runs in its own context (see `defineAuth`), so Playwright's video/trace/screenshot
|
|
94
|
+
* settings never attach to it — a failure here would otherwise surface as a bare locator timeout
|
|
95
|
+
* with no artifact. Name the page we actually landed on, and leave a screenshot behind.
|
|
96
|
+
*/
|
|
97
|
+
async function describeLoginFailure(page, config, user, cause) {
|
|
98
|
+
let where = '';
|
|
99
|
+
let shot = '';
|
|
100
|
+
try {
|
|
101
|
+
const url = page.url();
|
|
102
|
+
const title = await page.title();
|
|
103
|
+
where = ` at ${url}${title ? ` (title: ${JSON.stringify(title)})` : ''}`;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// page already closed or crashed — the original error is still worth reporting
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
const dir = join(config.runtime.runDir, 'auth');
|
|
110
|
+
mkdirSync(dir, { recursive: true });
|
|
111
|
+
const file = join(dir, `login-failed-${user.role}-${user.username}.png`.replace(/\s+/g, '_'));
|
|
112
|
+
await page.screenshot({ path: file, fullPage: true });
|
|
113
|
+
shot = `\nScreenshot: ${file}`;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// screenshotting is best effort
|
|
117
|
+
}
|
|
118
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
119
|
+
return new Error(`Form login for ${user.username} (${user.role}) failed${where}.\n${reason}` +
|
|
120
|
+
`\nIs the application under test running at ${config.env.ui.baseUrl}, and does that page ` +
|
|
121
|
+
`use the auth.form selectors in sdods.project.yaml?${shot}`, { cause });
|
|
84
122
|
}
|
|
85
123
|
export const noopAuth = defineAuth({ strategy: 'none' });
|
|
86
124
|
//# sourceMappingURL=index.js.map
|
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/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/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
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const VERSION = "0.2.
|
|
1
|
+
export declare const VERSION = "0.2.2";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export const VERSION = '0.2.
|
|
1
|
+
export const VERSION = '0.2.2';
|
|
2
2
|
//# sourceMappingURL=version.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdods/core",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "SDODS runtime: configuration, project registry, fixtures, step libraries, data providers, screenshot narratives and self-healing locators.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "SDODS <admin@sdods.com>",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"playwright-bdd": ">=9"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@sdods/contracts": "0.2.
|
|
45
|
+
"@sdods/contracts": "0.2.2",
|
|
46
46
|
"@cucumber/gherkin": "^42.0.1",
|
|
47
47
|
"@cucumber/messages": "^34.2.1",
|
|
48
48
|
"@faker-js/faker": "^10.6.0",
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"ts-morph": "^28.0.0",
|
|
60
60
|
"yaml": "^2.9.0",
|
|
61
61
|
"zod": "^4.5.4",
|
|
62
|
-
"@sdods/db": "0.2.
|
|
62
|
+
"@sdods/db": "0.2.2"
|
|
63
63
|
},
|
|
64
64
|
"homepage": "https://sdods.com",
|
|
65
65
|
"bugs": {
|