@sdods/core 0.2.2 → 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/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/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/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/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/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
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type Server } from 'node:http';
|
|
2
|
+
import './params.js';
|
|
3
|
+
/**
|
|
4
|
+
* An ephemeral callback receiver, for the leg where the application calls BACK.
|
|
5
|
+
*
|
|
6
|
+
* WHY — 16 trigger providers deliver by webhook. Without a receiver a suite can
|
|
7
|
+
* assert that a subscription was created and nothing about whether a delivery
|
|
8
|
+
* ever arrived, which is the half that actually matters: a trigger that
|
|
9
|
+
* registers and never fires looks identical to a working one.
|
|
10
|
+
*
|
|
11
|
+
* DESIGN — the server binds to an EPHEMERAL port on 127.0.0.1 and its URL is
|
|
12
|
+
* published into the scenario's template scope as `{{callback.url}}`, so the
|
|
13
|
+
* scenario never hardcodes a port. It is torn down at scenario end even when
|
|
14
|
+
* the scenario fails; a leaked listener silently poisons the next run on the
|
|
15
|
+
* same worker, and that is the kind of failure nobody traces back.
|
|
16
|
+
*
|
|
17
|
+
* Deliveries are recorded in arrival order and asserted by POLLING, never by
|
|
18
|
+
* sleeping. "Wait two seconds then check" is how a webhook suite becomes both
|
|
19
|
+
* slow and flaky at the same time.
|
|
20
|
+
*
|
|
21
|
+
* NOT a public tunnel. This receives from an application that can reach the
|
|
22
|
+
* runner — a local or in-VPC app under test. Reaching a hosted staging
|
|
23
|
+
* deployment needs a tunnel, which is an infrastructure decision rather than
|
|
24
|
+
* something a step library should quietly stand up.
|
|
25
|
+
*/
|
|
26
|
+
export interface Delivery {
|
|
27
|
+
method: string;
|
|
28
|
+
path: string;
|
|
29
|
+
headers: Record<string, string>;
|
|
30
|
+
body: string;
|
|
31
|
+
receivedAt: number;
|
|
32
|
+
}
|
|
33
|
+
export interface Receiver {
|
|
34
|
+
server: Server;
|
|
35
|
+
url: string;
|
|
36
|
+
deliveries: Delivery[];
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Extracted from the step so the receiver itself is testable without a browser,
|
|
40
|
+
* a config or a Playwright runner. It is the most intricate piece here — an
|
|
41
|
+
* HTTP server, async body reading and a shared array — and "it worked when I
|
|
42
|
+
* tried it" is not a claim anyone can re-check later.
|
|
43
|
+
*/
|
|
44
|
+
export declare function startReceiver(): Promise<Receiver>;
|
|
45
|
+
export declare function closeAllReceivers(): Promise<void>;
|
|
46
|
+
//# sourceMappingURL=webhook.steps.d.ts.map
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { expect } from '@playwright/test';
|
|
3
|
+
import './params.js';
|
|
4
|
+
import { AfterScenario, Given, Then, When } from '../fixtures/test.js';
|
|
5
|
+
import { render } from '../api/template.js';
|
|
6
|
+
import { SdodsError } from '../errors.js';
|
|
7
|
+
const receivers = new Map();
|
|
8
|
+
function key(apiContext) {
|
|
9
|
+
return String(apiContext.runId ?? 'default');
|
|
10
|
+
}
|
|
11
|
+
async function readBody(req) {
|
|
12
|
+
const chunks = [];
|
|
13
|
+
for await (const chunk of req)
|
|
14
|
+
chunks.push(chunk);
|
|
15
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
16
|
+
}
|
|
17
|
+
function requireReceiver(k) {
|
|
18
|
+
const r = receivers.get(k);
|
|
19
|
+
if (!r) {
|
|
20
|
+
throw new SdodsError('NOT_SUPPORTED', 'No callback receiver is listening.', {
|
|
21
|
+
hint: 'Use `Given a callback receiver is listening` first; its URL is available as {{callback.url}}.',
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
return r;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Extracted from the step so the receiver itself is testable without a browser,
|
|
28
|
+
* a config or a Playwright runner. It is the most intricate piece here — an
|
|
29
|
+
* HTTP server, async body reading and a shared array — and "it worked when I
|
|
30
|
+
* tried it" is not a claim anyone can re-check later.
|
|
31
|
+
*/
|
|
32
|
+
export async function startReceiver() {
|
|
33
|
+
const deliveries = [];
|
|
34
|
+
const server = createServer((req, res) => {
|
|
35
|
+
void readBody(req).then((body) => {
|
|
36
|
+
deliveries.push({
|
|
37
|
+
method: req.method ?? 'GET',
|
|
38
|
+
path: req.url ?? '/',
|
|
39
|
+
headers: Object.fromEntries(Object.entries(req.headers).map(([h, v]) => [
|
|
40
|
+
h,
|
|
41
|
+
Array.isArray(v) ? v.join(', ') : (v ?? ''),
|
|
42
|
+
])),
|
|
43
|
+
body,
|
|
44
|
+
receivedAt: Date.now(),
|
|
45
|
+
});
|
|
46
|
+
// 200 with an empty body: a provider that retries on a non-2xx would
|
|
47
|
+
// otherwise deliver the same event repeatedly and the counts below would
|
|
48
|
+
// be meaningless.
|
|
49
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
50
|
+
res.end('{}');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
54
|
+
const address = server.address();
|
|
55
|
+
if (!address || typeof address === 'string') {
|
|
56
|
+
throw new SdodsError('INTERNAL', 'The callback receiver did not report a port.');
|
|
57
|
+
}
|
|
58
|
+
return { server, url: `http://127.0.0.1:${address.port}`, deliveries };
|
|
59
|
+
}
|
|
60
|
+
Given('a callback receiver is listening', async ({ apiContext }) => {
|
|
61
|
+
const k = key(apiContext);
|
|
62
|
+
if (receivers.has(k))
|
|
63
|
+
return;
|
|
64
|
+
const receiver = await startReceiver();
|
|
65
|
+
receivers.set(k, receiver);
|
|
66
|
+
// Published as a scope value so the scenario writes {{callback.url}} rather
|
|
67
|
+
// than a port it cannot know.
|
|
68
|
+
apiContext.vars.set('callback', {
|
|
69
|
+
url: receiver.url,
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
When('I stop the callback receiver', async ({ apiContext }) => {
|
|
73
|
+
const k = key(apiContext);
|
|
74
|
+
const r = receivers.get(k);
|
|
75
|
+
if (!r)
|
|
76
|
+
return;
|
|
77
|
+
await new Promise((resolve) => r.server.close(() => resolve()));
|
|
78
|
+
receivers.delete(k);
|
|
79
|
+
});
|
|
80
|
+
Then('the callback receiver should receive {int} delivery/deliveries', async ({ apiContext }, count) => {
|
|
81
|
+
const r = requireReceiver(key(apiContext));
|
|
82
|
+
await expect
|
|
83
|
+
.poll(() => r.deliveries.length, {
|
|
84
|
+
message: `expected ${count} callback delivery/deliveries`,
|
|
85
|
+
})
|
|
86
|
+
.toBe(count);
|
|
87
|
+
});
|
|
88
|
+
Then('the callback receiver should receive a delivery', async ({ apiContext }) => {
|
|
89
|
+
const r = requireReceiver(key(apiContext));
|
|
90
|
+
await expect
|
|
91
|
+
.poll(() => r.deliveries.length, { message: 'no callback delivery arrived' })
|
|
92
|
+
.toBeGreaterThan(0);
|
|
93
|
+
});
|
|
94
|
+
Then('the callback receiver should receive a delivery containing {string}', async ({ apiContext, env }, text) => {
|
|
95
|
+
const r = requireReceiver(key(apiContext));
|
|
96
|
+
const needle = render(text, apiContext.vars.toObject(), env.vars);
|
|
97
|
+
await expect
|
|
98
|
+
.poll(() => r.deliveries.some((d) => d.body.includes(needle)), {
|
|
99
|
+
message: `no callback delivery contained "${needle}"`,
|
|
100
|
+
})
|
|
101
|
+
.toBe(true);
|
|
102
|
+
});
|
|
103
|
+
Then('the last callback delivery should have the header {string} set to {string}', async ({ apiContext, env }, header, value) => {
|
|
104
|
+
const r = requireReceiver(key(apiContext));
|
|
105
|
+
await expect
|
|
106
|
+
.poll(() => r.deliveries.length, { message: 'no callback delivery arrived' })
|
|
107
|
+
.toBeGreaterThan(0);
|
|
108
|
+
const scopes = [
|
|
109
|
+
apiContext.vars.toObject(),
|
|
110
|
+
env.vars,
|
|
111
|
+
];
|
|
112
|
+
const last = r.deliveries[r.deliveries.length - 1];
|
|
113
|
+
expect(last.headers[render(header, ...scopes).toLowerCase()]).toBe(render(value, ...scopes));
|
|
114
|
+
});
|
|
115
|
+
/**
|
|
116
|
+
* Torn down after EVERY scenario, including a failing one — hence a hook rather
|
|
117
|
+
* than a step. A leaked listener silently poisons the next scenario on the same
|
|
118
|
+
* worker, and that is a failure nobody traces back to the scenario that leaked.
|
|
119
|
+
*/
|
|
120
|
+
AfterScenario(async () => {
|
|
121
|
+
await closeAllReceivers();
|
|
122
|
+
});
|
|
123
|
+
export async function closeAllReceivers() {
|
|
124
|
+
for (const [k, r] of receivers) {
|
|
125
|
+
await new Promise((resolve) => r.server.close(() => resolve()));
|
|
126
|
+
receivers.delete(k);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
//# sourceMappingURL=webhook.steps.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdods/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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,14 +42,13 @@
|
|
|
42
42
|
"playwright-bdd": ">=9"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@sdods/contracts": "0.
|
|
45
|
+
"@sdods/contracts": "0.3.0",
|
|
46
46
|
"@cucumber/gherkin": "^42.0.1",
|
|
47
47
|
"@cucumber/messages": "^34.2.1",
|
|
48
48
|
"@faker-js/faker": "^10.6.0",
|
|
49
49
|
"@scalar/openapi-parser": "^0.29.0",
|
|
50
50
|
"ajv": "^8.20.0",
|
|
51
51
|
"ajv-formats": "^3.0.1",
|
|
52
|
-
"chart.js": "^4.5.1",
|
|
53
52
|
"csv-parse": "^7.0.2",
|
|
54
53
|
"cucumber-tag-expressions": "^2.0.3",
|
|
55
54
|
"dotenv": "^17.4.2",
|
|
@@ -59,7 +58,7 @@
|
|
|
59
58
|
"ts-morph": "^28.0.0",
|
|
60
59
|
"yaml": "^2.9.0",
|
|
61
60
|
"zod": "^4.5.4",
|
|
62
|
-
"@sdods/db": "0.
|
|
61
|
+
"@sdods/db": "0.3.0"
|
|
63
62
|
},
|
|
64
63
|
"homepage": "https://sdods.com",
|
|
65
64
|
"bugs": {
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
import type { AnalysisReport, Evidence, Layer } from '@sdods/contracts';
|
|
2
|
-
/**
|
|
3
|
-
* Module detection by signal fusion.
|
|
4
|
-
*
|
|
5
|
-
* Every detector casts weighted votes for "which module owns this route/endpoint"; the
|
|
6
|
-
* resolver keeps the strongest vote per target, then drops noise, merges aliases and splits
|
|
7
|
-
* oversized modules. Replaces the two independent first-URL-segment groupers that used to
|
|
8
|
-
* live in propose.ts and mcp/tools/analyze.ts.
|
|
9
|
-
*
|
|
10
|
-
* The ordering matters more than any individual rule. Measured against
|
|
11
|
-
* cypress-realworld-app (17 routes): first-URL-segment yields 14 groups, the source-file
|
|
12
|
-
* signal alone yields 8, and `backend/auth.ts` collapses /checkAuth + /login + /logout into
|
|
13
|
-
* one module without consulting the alias table at all.
|
|
14
|
-
*/
|
|
15
|
-
export type ModuleSignal = 'openapi-tag' | 'workspace' | 'framework-module' | 'domain-dir' | 'source-file' | 'har' | 'route-segment' | 'alias';
|
|
16
|
-
export declare const SIGNAL_WEIGHTS: Record<ModuleSignal, number>;
|
|
17
|
-
export interface ModuleVote {
|
|
18
|
-
/** the thing being assigned: a route or endpoint path */
|
|
19
|
-
target: string;
|
|
20
|
-
kind: 'route' | 'endpoint';
|
|
21
|
-
module: string;
|
|
22
|
-
weight: number;
|
|
23
|
-
signal: ModuleSignal;
|
|
24
|
-
layer: Layer;
|
|
25
|
-
evidence: Evidence;
|
|
26
|
-
}
|
|
27
|
-
export interface DetectedModule {
|
|
28
|
-
name: string;
|
|
29
|
-
layers: Layer[];
|
|
30
|
-
routes: string[];
|
|
31
|
-
endpoints: string[];
|
|
32
|
-
testingTypes: string[];
|
|
33
|
-
tags: string[];
|
|
34
|
-
/** mean weight of the winning votes; 0..1 */
|
|
35
|
-
confidence: number;
|
|
36
|
-
signals: ModuleSignal[];
|
|
37
|
-
evidence: Evidence[];
|
|
38
|
-
/** populated by the split pass; recorded in the report, not yet emitted to yaml */
|
|
39
|
-
children?: DetectedModule[];
|
|
40
|
-
}
|
|
41
|
-
export interface DetectModulesOptions {
|
|
42
|
-
/** a module with more targets than this splits into children (default 12) */
|
|
43
|
-
splitAfter?: number;
|
|
44
|
-
/** a module with this many targets or fewer merges into an alias sibling (default 1) */
|
|
45
|
-
mergeBelow?: number;
|
|
46
|
-
maxModules?: number;
|
|
47
|
-
}
|
|
48
|
-
export interface DetectModulesResult {
|
|
49
|
-
modules: DetectedModule[];
|
|
50
|
-
votes: ModuleVote[];
|
|
51
|
-
/** targets deliberately not turned into modules, with the reason */
|
|
52
|
-
ignored: Array<{
|
|
53
|
-
target: string;
|
|
54
|
-
reason: string;
|
|
55
|
-
}>;
|
|
56
|
-
}
|
|
57
|
-
/**
|
|
58
|
-
* Synonyms folded onto a canonical module name. Merges only; an alias never creates a module,
|
|
59
|
-
* which is why the weight is the lowest in the table.
|
|
60
|
-
*/
|
|
61
|
-
export declare const MODULE_ALIASES: Record<string, string[]>;
|
|
62
|
-
export declare function canonicalAlias(name: string): string | undefined;
|
|
63
|
-
/** Today's rule, kept: demoted to a weak signal, and reused as the sub-module split rule. */
|
|
64
|
-
export declare function moduleOfPath(path: string): string;
|
|
65
|
-
/**
|
|
66
|
-
* Module name from the file that defines a route. Returns undefined when the file names a role
|
|
67
|
-
* rather than a domain (`backend/app.ts`), so a weaker signal can take over.
|
|
68
|
-
*
|
|
69
|
-
* `backend/auth.ts` -> auth; `bankaccount-routes.ts` -> bankaccount;
|
|
70
|
-
* `TransactionsContainer.tsx` -> transactions; `backend/app.ts` -> undefined.
|
|
71
|
-
*/
|
|
72
|
-
export declare function moduleOfFile(file: string): string | undefined;
|
|
73
|
-
export declare function detectModules(report: AnalysisReport, opts?: DetectModulesOptions): DetectModulesResult;
|
|
74
|
-
//# sourceMappingURL=modules.d.ts.map
|
package/dist/analyze/modules.js
DELETED
|
@@ -1,353 +0,0 @@
|
|
|
1
|
-
import { slugify } from './propose.js';
|
|
2
|
-
export const SIGNAL_WEIGHTS = {
|
|
3
|
-
'openapi-tag': 1.0,
|
|
4
|
-
workspace: 0.9,
|
|
5
|
-
'framework-module': 0.85,
|
|
6
|
-
'domain-dir': 0.8,
|
|
7
|
-
'source-file': 0.7,
|
|
8
|
-
har: 0.5,
|
|
9
|
-
'route-segment': 0.3,
|
|
10
|
-
alias: 0.2,
|
|
11
|
-
};
|
|
12
|
-
/** Path segments that never name a domain. */
|
|
13
|
-
const RESERVED_SEGMENTS = new Set(['api', 'app', 'src', 'pages']);
|
|
14
|
-
/** Basenames that name a file's role, not its domain. */
|
|
15
|
-
const GENERIC_BASENAMES = new Set(['index', 'app', 'main', 'server', 'routes', 'route', 'router']);
|
|
16
|
-
/** Directories that group by layer, not by domain. */
|
|
17
|
-
const GENERIC_DIRS = new Set([
|
|
18
|
-
'src',
|
|
19
|
-
'backend',
|
|
20
|
-
'frontend',
|
|
21
|
-
'api',
|
|
22
|
-
'lib',
|
|
23
|
-
'app',
|
|
24
|
-
'pages',
|
|
25
|
-
'containers',
|
|
26
|
-
'views',
|
|
27
|
-
'components',
|
|
28
|
-
'routes',
|
|
29
|
-
'server',
|
|
30
|
-
]);
|
|
31
|
-
/** Directories that conventionally hold one subdirectory per domain. */
|
|
32
|
-
const DOMAIN_DIR_RE = /(?:^|\/)(?:src\/)?(?:modules|features|domains|containers|views|pages|apps|services)\/([^/]+)\//;
|
|
33
|
-
/** Route-param and route-group directories: `[id]`, `[...slug]`, `{id}`, `:id`, `(marketing)`. */
|
|
34
|
-
const DYNAMIC_DIR_RE = /^(?:\[.*\]|\{.*\}|:.+|\(.*\))$/;
|
|
35
|
-
/** Suffixes that describe a file's role and should be stripped before naming a module. */
|
|
36
|
-
const ROLE_SUFFIX_RE = /[-_.]?(routes?|router|controller|container|service|module|handler|page|view|screen|api)$/i;
|
|
37
|
-
/**
|
|
38
|
-
* Synonyms folded onto a canonical module name. Merges only; an alias never creates a module,
|
|
39
|
-
* which is why the weight is the lowest in the table.
|
|
40
|
-
*/
|
|
41
|
-
export const MODULE_ALIASES = {
|
|
42
|
-
auth: [
|
|
43
|
-
'login',
|
|
44
|
-
'signin',
|
|
45
|
-
'sign-in',
|
|
46
|
-
'logout',
|
|
47
|
-
'signout',
|
|
48
|
-
'sign-out',
|
|
49
|
-
'signup',
|
|
50
|
-
'sign-up',
|
|
51
|
-
'register',
|
|
52
|
-
'session',
|
|
53
|
-
'checkauth',
|
|
54
|
-
'check-auth',
|
|
55
|
-
'oauth',
|
|
56
|
-
'sso',
|
|
57
|
-
'identity',
|
|
58
|
-
],
|
|
59
|
-
user: ['users', 'profile', 'profiles', 'me', 'account', 'accounts'],
|
|
60
|
-
admin: ['administration', 'backoffice', 'back-office'],
|
|
61
|
-
search: ['searches', 'query'],
|
|
62
|
-
};
|
|
63
|
-
const ALIAS_TO_CANONICAL = (() => {
|
|
64
|
-
const out = {};
|
|
65
|
-
for (const [canonical, names] of Object.entries(MODULE_ALIASES)) {
|
|
66
|
-
for (const n of names)
|
|
67
|
-
out[n] = canonical;
|
|
68
|
-
}
|
|
69
|
-
return out;
|
|
70
|
-
})();
|
|
71
|
-
export function canonicalAlias(name) {
|
|
72
|
-
return ALIAS_TO_CANONICAL[name];
|
|
73
|
-
}
|
|
74
|
-
/** Today's rule, kept: demoted to a weak signal, and reused as the sub-module split rule. */
|
|
75
|
-
export function moduleOfPath(path) {
|
|
76
|
-
const first = path.split('/').filter(Boolean)[0];
|
|
77
|
-
if (!first || first.startsWith('{'))
|
|
78
|
-
return 'home';
|
|
79
|
-
const clean = first.replace(/[^a-zA-Z0-9]+/g, '-').toLowerCase();
|
|
80
|
-
return RESERVED_SEGMENTS.has(clean) ? 'core' : clean;
|
|
81
|
-
}
|
|
82
|
-
/**
|
|
83
|
-
* Module name from the file that defines a route. Returns undefined when the file names a role
|
|
84
|
-
* rather than a domain (`backend/app.ts`), so a weaker signal can take over.
|
|
85
|
-
*
|
|
86
|
-
* `backend/auth.ts` -> auth; `bankaccount-routes.ts` -> bankaccount;
|
|
87
|
-
* `TransactionsContainer.tsx` -> transactions; `backend/app.ts` -> undefined.
|
|
88
|
-
*/
|
|
89
|
-
export function moduleOfFile(file) {
|
|
90
|
-
const parts = file.replace(/\\/g, '/').split('/').filter(Boolean);
|
|
91
|
-
const base = (parts.pop() ?? '').replace(/\.[^.]+$/, '');
|
|
92
|
-
let name = base.replace(ROLE_SUFFIX_RE, '');
|
|
93
|
-
if (!name || GENERIC_BASENAMES.has(name.toLowerCase())) {
|
|
94
|
-
// File-router frameworks put the domain further up: `app/products/[id]/page.tsx` is the
|
|
95
|
-
// `products` module, not `id`. Walk past dynamic segments and route groups.
|
|
96
|
-
name = '';
|
|
97
|
-
while (parts.length) {
|
|
98
|
-
const dir = parts.pop();
|
|
99
|
-
if (DYNAMIC_DIR_RE.test(dir))
|
|
100
|
-
continue;
|
|
101
|
-
if (GENERIC_DIRS.has(dir.toLowerCase()))
|
|
102
|
-
return undefined;
|
|
103
|
-
name = dir;
|
|
104
|
-
break;
|
|
105
|
-
}
|
|
106
|
-
if (!name)
|
|
107
|
-
return undefined;
|
|
108
|
-
}
|
|
109
|
-
// split camelCase/PascalCase before slugify so TransactionsContainer -> transactions
|
|
110
|
-
const slug = slugify(name.replace(/([a-z0-9])([A-Z])/g, '$1-$2'));
|
|
111
|
-
if (!slug || slug === 'app' || GENERIC_DIRS.has(slug))
|
|
112
|
-
return undefined;
|
|
113
|
-
return slug;
|
|
114
|
-
}
|
|
115
|
-
/**
|
|
116
|
-
* Routes that carry no domain information and must not become modules.
|
|
117
|
-
*
|
|
118
|
-
* A path that is uninformative on its own is still fine when its defining file names a domain:
|
|
119
|
-
* `GET /{username}` in `backend/contact-routes.ts` is the `contact` module, and dropping it
|
|
120
|
-
* would lose a real module. So parameter-only and root routes are noise only when the file
|
|
121
|
-
* cannot name them either. Catch-all and optional-group routes are never valid test targets.
|
|
122
|
-
*/
|
|
123
|
-
function noiseReason(r) {
|
|
124
|
-
const p = r.path;
|
|
125
|
-
if (/^\/?\*/.test(p))
|
|
126
|
-
return 'catch-all route';
|
|
127
|
-
if (/\(.*\)\?/.test(p))
|
|
128
|
-
return 'optional-group route';
|
|
129
|
-
if (moduleOfFile(r.file))
|
|
130
|
-
return undefined;
|
|
131
|
-
const segs = p.split('/').filter(Boolean);
|
|
132
|
-
// a UI root path is a real, nameable screen (home); an API root carries nothing
|
|
133
|
-
if (segs.length === 0)
|
|
134
|
-
return r.kind === 'page' ? undefined : 'root route in a generic file';
|
|
135
|
-
if (segs.length === 1 && /^[{:]/.test(segs[0]))
|
|
136
|
-
return 'bare parameter route in a generic file';
|
|
137
|
-
return undefined;
|
|
138
|
-
}
|
|
139
|
-
function layerOf(r) {
|
|
140
|
-
return r.kind === 'api' ? 'api' : 'ui';
|
|
141
|
-
}
|
|
142
|
-
function longestWorkspaceMatch(file, workspaces) {
|
|
143
|
-
let best;
|
|
144
|
-
for (const w of workspaces) {
|
|
145
|
-
const prefix = w.replace(/\/?\*+$/, '').replace(/\/$/, '');
|
|
146
|
-
if (!prefix)
|
|
147
|
-
continue;
|
|
148
|
-
if (file === prefix || file.startsWith(`${prefix}/`)) {
|
|
149
|
-
if (!best || prefix.length > best.length)
|
|
150
|
-
best = prefix;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
if (!best)
|
|
154
|
-
return undefined;
|
|
155
|
-
// the workspace dir itself is generic (packages/, apps/) - name the package under it
|
|
156
|
-
const rest = file.slice(best.length).split('/').filter(Boolean)[0];
|
|
157
|
-
return rest ? slugify(rest) : undefined;
|
|
158
|
-
}
|
|
159
|
-
export function detectModules(report, opts = {}) {
|
|
160
|
-
const splitAfter = opts.splitAfter ?? 12;
|
|
161
|
-
const mergeBelow = opts.mergeBelow ?? 1;
|
|
162
|
-
const maxModules = opts.maxModules ?? 30;
|
|
163
|
-
const votes = [];
|
|
164
|
-
const ignored = [];
|
|
165
|
-
const cast = (v) => {
|
|
166
|
-
if (v.module)
|
|
167
|
-
votes.push(v);
|
|
168
|
-
};
|
|
169
|
-
// S1 openapi-tag (1.0) - the authoritative API grouping when a spec exists
|
|
170
|
-
const tagged = new Set();
|
|
171
|
-
for (const spec of report.openapi) {
|
|
172
|
-
for (const e of spec.endpoints) {
|
|
173
|
-
if (!e.tag)
|
|
174
|
-
continue;
|
|
175
|
-
tagged.add(e.path);
|
|
176
|
-
cast({
|
|
177
|
-
target: e.path,
|
|
178
|
-
kind: 'endpoint',
|
|
179
|
-
module: slugify(e.tag),
|
|
180
|
-
weight: SIGNAL_WEIGHTS['openapi-tag'],
|
|
181
|
-
signal: 'openapi-tag',
|
|
182
|
-
layer: 'api',
|
|
183
|
-
evidence: { file: spec.file, snippet: `tag: ${e.tag}` },
|
|
184
|
-
});
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
const workspaces = report.packageManager.workspaces ?? [];
|
|
188
|
-
for (const r of report.routes) {
|
|
189
|
-
if (tagged.has(r.path))
|
|
190
|
-
continue;
|
|
191
|
-
const reason = noiseReason(r);
|
|
192
|
-
if (reason) {
|
|
193
|
-
ignored.push({ target: r.path, reason });
|
|
194
|
-
continue;
|
|
195
|
-
}
|
|
196
|
-
const kind = r.kind === 'api' ? 'endpoint' : 'route';
|
|
197
|
-
const layer = layerOf(r);
|
|
198
|
-
const ev = { file: r.file, line: r.line, snippet: r.path };
|
|
199
|
-
// S2 workspace (0.9)
|
|
200
|
-
const ws = longestWorkspaceMatch(r.file, workspaces);
|
|
201
|
-
if (ws) {
|
|
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
|
-
});
|
|
211
|
-
}
|
|
212
|
-
// S3 domain-dir (0.8)
|
|
213
|
-
const dd = DOMAIN_DIR_RE.exec(r.file.replace(/\\/g, '/'));
|
|
214
|
-
if (dd?.[1] && !GENERIC_DIRS.has(dd[1].toLowerCase())) {
|
|
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
|
-
});
|
|
224
|
-
}
|
|
225
|
-
// S4 source-file (0.7) - the primary signal
|
|
226
|
-
const mf = moduleOfFile(r.file);
|
|
227
|
-
if (mf) {
|
|
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
|
-
});
|
|
237
|
-
}
|
|
238
|
-
// S5 route-segment (0.3) - today's rule, kept as the floor so nothing goes unassigned
|
|
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
|
-
});
|
|
248
|
-
}
|
|
249
|
-
// resolve: strongest vote per target
|
|
250
|
-
const winners = new Map();
|
|
251
|
-
for (const v of votes) {
|
|
252
|
-
const cur = winners.get(v.target);
|
|
253
|
-
if (!cur || v.weight > cur.weight)
|
|
254
|
-
winners.set(v.target, v);
|
|
255
|
-
}
|
|
256
|
-
// build modules
|
|
257
|
-
const acc = new Map();
|
|
258
|
-
for (const v of winners.values()) {
|
|
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
|
-
};
|
|
268
|
-
if (v.kind === 'endpoint')
|
|
269
|
-
m.endpoints.add(v.target);
|
|
270
|
-
else
|
|
271
|
-
m.routes.add(v.target);
|
|
272
|
-
m.layers.add(v.layer);
|
|
273
|
-
m.signals.add(v.signal);
|
|
274
|
-
m.weights.push(v.weight);
|
|
275
|
-
if (v.signal === 'openapi-tag')
|
|
276
|
-
m.contract = true;
|
|
277
|
-
if (m.evidence.length < 5)
|
|
278
|
-
m.evidence.push(v.evidence);
|
|
279
|
-
acc.set(v.module, m);
|
|
280
|
-
}
|
|
281
|
-
// alias merge - folds signin/signup (generic App.tsx) into auth (backend/auth.ts)
|
|
282
|
-
for (const [name, m] of [...acc.entries()]) {
|
|
283
|
-
const canonical = canonicalAlias(name);
|
|
284
|
-
const size = m.routes.size + m.endpoints.size;
|
|
285
|
-
if (canonical && canonical !== name && acc.has(canonical)) {
|
|
286
|
-
const t = acc.get(canonical);
|
|
287
|
-
for (const r of m.routes)
|
|
288
|
-
t.routes.add(r);
|
|
289
|
-
for (const e of m.endpoints)
|
|
290
|
-
t.endpoints.add(e);
|
|
291
|
-
for (const l of m.layers)
|
|
292
|
-
t.layers.add(l);
|
|
293
|
-
t.signals.add('alias');
|
|
294
|
-
t.weights.push(...m.weights);
|
|
295
|
-
for (const e of m.evidence)
|
|
296
|
-
if (t.evidence.length < 5)
|
|
297
|
-
t.evidence.push(e);
|
|
298
|
-
acc.delete(name);
|
|
299
|
-
}
|
|
300
|
-
else if (size <= mergeBelow && !canonical && acc.size > 1) {
|
|
301
|
-
// a singleton that nothing claims stays put rather than inventing a bucket;
|
|
302
|
-
// it is the caller's job (detect.ignore) to drop it if it is noise.
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
let modules = [...acc.entries()].map(([name, m]) => {
|
|
306
|
-
const testingTypes = ['functional', 'smoke', 'regression'];
|
|
307
|
-
if (m.contract)
|
|
308
|
-
testingTypes.push('contract');
|
|
309
|
-
if (canonicalAlias(name) === 'auth' || name === 'auth')
|
|
310
|
-
testingTypes.push('data-driven');
|
|
311
|
-
const targets = [...m.routes, ...m.endpoints];
|
|
312
|
-
const mod = {
|
|
313
|
-
name,
|
|
314
|
-
layers: [...m.layers],
|
|
315
|
-
routes: [...m.routes],
|
|
316
|
-
endpoints: [...m.endpoints],
|
|
317
|
-
testingTypes,
|
|
318
|
-
tags: [`@${name}`],
|
|
319
|
-
confidence: m.weights.length ? m.weights.reduce((a, b) => a + b, 0) / m.weights.length : 0,
|
|
320
|
-
signals: [...m.signals],
|
|
321
|
-
evidence: m.evidence,
|
|
322
|
-
};
|
|
323
|
-
// split pass: reuse the old first-segment rule where it is actually correct
|
|
324
|
-
if (targets.length > splitAfter) {
|
|
325
|
-
const groups = new Map();
|
|
326
|
-
for (const t of targets) {
|
|
327
|
-
const k = moduleOfPath(t);
|
|
328
|
-
groups.set(k, [...(groups.get(k) ?? []), t]);
|
|
329
|
-
}
|
|
330
|
-
if (groups.size > 1) {
|
|
331
|
-
mod.children = [...groups.entries()].map(([childName, ts]) => ({
|
|
332
|
-
name: childName,
|
|
333
|
-
layers: mod.layers,
|
|
334
|
-
routes: ts.filter((t) => m.routes.has(t)),
|
|
335
|
-
endpoints: ts.filter((t) => m.endpoints.has(t)),
|
|
336
|
-
testingTypes: mod.testingTypes,
|
|
337
|
-
tags: [`@${name}`, `@${name}/${childName}`],
|
|
338
|
-
confidence: mod.confidence,
|
|
339
|
-
signals: ['route-segment'],
|
|
340
|
-
evidence: [],
|
|
341
|
-
}));
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
return mod;
|
|
345
|
-
});
|
|
346
|
-
modules.sort((a, b) => b.confidence - a.confidence ||
|
|
347
|
-
b.routes.length + b.endpoints.length - (a.routes.length + a.endpoints.length) ||
|
|
348
|
-
a.name.localeCompare(b.name));
|
|
349
|
-
if (modules.length > maxModules)
|
|
350
|
-
modules = modules.slice(0, maxModules);
|
|
351
|
-
return { modules, votes, ignored };
|
|
352
|
-
}
|
|
353
|
-
//# sourceMappingURL=modules.js.map
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import { type PlaywrightTestConfig } from '@playwright/test';
|
|
2
|
-
import { type BrowserName, type Layer } from '@sdods/contracts';
|
|
3
|
-
import type { ProjectRegistry } from './registry.js';
|
|
4
|
-
export interface PlaywrightSelection {
|
|
5
|
-
project?: string;
|
|
6
|
-
env?: string;
|
|
7
|
-
layers?: string[];
|
|
8
|
-
browsers?: string[];
|
|
9
|
-
tags?: string;
|
|
10
|
-
runId?: string;
|
|
11
|
-
lint?: boolean;
|
|
12
|
-
allure?: boolean;
|
|
13
|
-
reporters?: string[];
|
|
14
|
-
reporterMode?: 'default' | 'server' | 'quiet';
|
|
15
|
-
}
|
|
16
|
-
export interface SdodsUseOption {
|
|
17
|
-
project: string;
|
|
18
|
-
layer: Layer;
|
|
19
|
-
browser?: BrowserName;
|
|
20
|
-
}
|
|
21
|
-
export declare const DASHBOARD_REPORTER = "@sdods/core/reporters/dashboard";
|
|
22
|
-
/** Convenience for `playwright.config.ts`: read the selection from SDODS_* env vars. */
|
|
23
|
-
export declare function selectionFromEnv(env?: NodeJS.ProcessEnv): PlaywrightSelection;
|
|
24
|
-
export interface GeneratedProject {
|
|
25
|
-
name: string;
|
|
26
|
-
project: string;
|
|
27
|
-
layer: Layer;
|
|
28
|
-
browser?: BrowserName;
|
|
29
|
-
}
|
|
30
|
-
/** Names (and identity) of the Playwright projects a selection would produce, without side effects. */
|
|
31
|
-
export declare function listGeneratedProjects(registry: ProjectRegistry, sel: PlaywrightSelection): GeneratedProject[];
|
|
32
|
-
/**
|
|
33
|
-
* Build the Playwright config for a selection of projects × layers × browsers.
|
|
34
|
-
* One `defineBddConfig` per project × layer; browsers reuse the generated testDir.
|
|
35
|
-
*/
|
|
36
|
-
export declare function buildPlaywrightConfig(registry: ProjectRegistry, sel?: PlaywrightSelection): PlaywrightTestConfig;
|
|
37
|
-
//# sourceMappingURL=playwright.d.ts.map
|