@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
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { expect } from '@playwright/test';
|
|
2
|
+
import './params.js';
|
|
3
|
+
import { Given, Then } from '../fixtures/test.js';
|
|
4
|
+
import { render } from '../api/template.js';
|
|
5
|
+
import { SdodsError } from '../errors.js';
|
|
6
|
+
/**
|
|
7
|
+
* Clock control.
|
|
8
|
+
*
|
|
9
|
+
* DESIGN — installing the clock is a SEPARATE step from moving it, and it must
|
|
10
|
+
* come first. `page.clock.install()` has to run before the page under test
|
|
11
|
+
* reads `Date.now()`, so a scenario installs the clock, then navigates, then
|
|
12
|
+
* advances. Folding the two together would produce a step that appears to work
|
|
13
|
+
* and silently does nothing on an already-loaded page, which is worse than one
|
|
14
|
+
* that refuses.
|
|
15
|
+
*
|
|
16
|
+
* WHAT THIS UNBLOCKS — trials, expiry, scheduled runs and session TTLs. None of
|
|
17
|
+
* these can be tested by waiting: a 55-minute session TTL is not a thing a
|
|
18
|
+
* suite can sit through, so without clock control those scenarios are not slow,
|
|
19
|
+
* they are unwritable.
|
|
20
|
+
*/
|
|
21
|
+
const scopesOf = (apiContext, env) => [apiContext.vars.toObject(), env.vars];
|
|
22
|
+
/** ISO-8601, or a relative offset like "+2 days" / "-30 minutes". */
|
|
23
|
+
export function resolveTime(spec, from) {
|
|
24
|
+
const rel = /^([+-])\s*(\d+)\s*(second|minute|hour|day|week)s?$/i.exec(spec.trim());
|
|
25
|
+
if (rel) {
|
|
26
|
+
const [, sign, amount, unit] = rel;
|
|
27
|
+
const ms = { second: 1e3, minute: 6e4, hour: 36e5, day: 864e5, week: 6048e5 }[unit.toLowerCase()] ?? 0;
|
|
28
|
+
const delta = Number(amount) * ms * (sign === '-' ? -1 : 1);
|
|
29
|
+
return new Date(from.getTime() + delta);
|
|
30
|
+
}
|
|
31
|
+
const abs = new Date(spec);
|
|
32
|
+
if (Number.isNaN(abs.getTime())) {
|
|
33
|
+
throw new SdodsError('CONFIG_INVALID', `Cannot read "${spec}" as a time.`, {
|
|
34
|
+
hint: 'Use an ISO-8601 instant (2026-01-31T12:00:00Z) or a relative offset ("+2 days", "-30 minutes").',
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return abs;
|
|
38
|
+
}
|
|
39
|
+
Given('I install a fake clock', async ({ page }) => {
|
|
40
|
+
await page.clock.install();
|
|
41
|
+
});
|
|
42
|
+
Given('I install a fake clock set to {string}', async ({ page, apiContext, env }, when) => {
|
|
43
|
+
const at = resolveTime(render(when, ...scopesOf(apiContext, env)), new Date());
|
|
44
|
+
await page.clock.install({ time: at });
|
|
45
|
+
});
|
|
46
|
+
/**
|
|
47
|
+
* Jumps the clock without running the timers in between. This is what a trial
|
|
48
|
+
* expiring "two weeks later" means — and running two weeks of intervals would
|
|
49
|
+
* hang the test rather than simulate it.
|
|
50
|
+
*/
|
|
51
|
+
Given('the clock jumps to {string}', async ({ page, apiContext, env }, when) => {
|
|
52
|
+
const at = resolveTime(render(when, ...scopesOf(apiContext, env)), new Date());
|
|
53
|
+
await page.clock.setFixedTime(at);
|
|
54
|
+
});
|
|
55
|
+
/** Runs timers as it goes, so polling and countdowns actually fire. */
|
|
56
|
+
Given('the clock advances by {string}', async ({ page, apiContext, env }, amount) => {
|
|
57
|
+
const spec = render(amount, ...scopesOf(apiContext, env)).trim();
|
|
58
|
+
const normalised = /^[+-]/.test(spec) ? spec : `+${spec}`;
|
|
59
|
+
const target = resolveTime(normalised, new Date(0));
|
|
60
|
+
await page.clock.runFor(target.getTime());
|
|
61
|
+
});
|
|
62
|
+
Given('the clock resumes', async ({ page }) => {
|
|
63
|
+
await page.clock.resume();
|
|
64
|
+
});
|
|
65
|
+
Then('the page clock should read {string}', async ({ page, apiContext, env }, iso) => {
|
|
66
|
+
const expected = resolveTime(render(iso, ...scopesOf(apiContext, env)), new Date());
|
|
67
|
+
const actual = await page.evaluate(() => Date.now());
|
|
68
|
+
// A second of tolerance: the assertion is about which DAY or HOUR the page
|
|
69
|
+
// believes it is, and demanding millisecond equality would make it flaky for
|
|
70
|
+
// no benefit.
|
|
71
|
+
expect(Math.abs(actual - expected.getTime())).toBeLessThan(1000);
|
|
72
|
+
});
|
|
73
|
+
//# sourceMappingURL=clock.steps.js.map
|
package/dist/steps/data.steps.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
1
2
|
import './params.js';
|
|
2
3
|
import { Given, When } from '../fixtures/test.js';
|
|
4
|
+
import { tokenFileFor } from '../auth/capture.js';
|
|
3
5
|
import { render } from '../api/template.js';
|
|
4
6
|
import { SdodsError } from '../errors.js';
|
|
7
|
+
import { Logger } from '../logger.js';
|
|
8
|
+
const log = new Logger('data');
|
|
5
9
|
/* ── datasets ─────────────────────────────────────────────────────────── */
|
|
6
10
|
Given('I load dataset {string} row {int}', async ({ data, apiContext }, name, index) => {
|
|
7
11
|
apiContext.vars.setAll(await data.row(name, index));
|
|
@@ -41,8 +45,31 @@ Given('I use the user {string} from the pool', async ({ userPool, apiContext, $t
|
|
|
41
45
|
role: user.role,
|
|
42
46
|
});
|
|
43
47
|
});
|
|
44
|
-
/**
|
|
45
|
-
|
|
48
|
+
/**
|
|
49
|
+
* API-layer variant: leases the account, exposes its credentials as variables,
|
|
50
|
+
* AND authenticates the API client as that user.
|
|
51
|
+
*
|
|
52
|
+
* The authentication half used to be missing, which left the whole role-based
|
|
53
|
+
* API story unwired. Three seams were dead at once:
|
|
54
|
+
*
|
|
55
|
+
* 1. `storageState` is forced to undefined when `sdods.layer === 'api'`
|
|
56
|
+
* (fixtures/test.ts) — correct in itself, an API test should not need a
|
|
57
|
+
* browser, but it means the `@user:<role>` tag applies no credential here.
|
|
58
|
+
* 2. This step set variables and performed no authentication.
|
|
59
|
+
* 3. The strategy's `token()` result was written to
|
|
60
|
+
* `<role>-<n>.token.json` by `sdods auth capture` and never read back.
|
|
61
|
+
*
|
|
62
|
+
* The net effect was that every API request in a role-tagged scenario went out
|
|
63
|
+
* anonymous. Against an app that answers 401 the scenario failed loudly, which
|
|
64
|
+
* is survivable — but against one that answers 200 to anonymous reads, an
|
|
65
|
+
* authorization scenario passed while proving nothing at all.
|
|
66
|
+
*
|
|
67
|
+
* Resolution order: the cached token file (so `sdods auth capture` is
|
|
68
|
+
* meaningful and one mint is reused across a sharded run), then a live
|
|
69
|
+
* `token()` call. A strategy with no `token()` leaves auth unset and says so,
|
|
70
|
+
* rather than silently continuing anonymous.
|
|
71
|
+
*/
|
|
72
|
+
Given('I use a leased user with role {string} for API calls', async ({ userPool, apiContext, auth, config, $testInfo }, role) => {
|
|
46
73
|
const user = await userPool.lease(role, $testInfo.parallelIndex);
|
|
47
74
|
apiContext.vars.setAll({
|
|
48
75
|
username: user.username,
|
|
@@ -51,7 +78,28 @@ Given('I use a leased user with role {string} for API calls', async ({ userPool,
|
|
|
51
78
|
role: user.role,
|
|
52
79
|
...user.extra,
|
|
53
80
|
});
|
|
81
|
+
const token = readCachedToken(config, user) ?? (await auth?.token?.({ config, user }));
|
|
82
|
+
if (token)
|
|
83
|
+
apiContext.auth = { type: 'bearer', token };
|
|
84
|
+
else if (auth?.strategy && auth.strategy !== 'none') {
|
|
85
|
+
log.debug(`auth strategy "${auth.strategy}" provides no token() — API calls for ` +
|
|
86
|
+
`${user.username} (${role}) use the environment credential.`);
|
|
87
|
+
}
|
|
54
88
|
});
|
|
89
|
+
/** The token `sdods auth capture` wrote for this user, when it is still there. */
|
|
90
|
+
function readCachedToken(config, user) {
|
|
91
|
+
const file = tokenFileFor(config, user);
|
|
92
|
+
if (!existsSync(file))
|
|
93
|
+
return undefined;
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
96
|
+
return parsed.token;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// A corrupt cache must not fail the scenario — fall through to a live mint.
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
55
103
|
/* ── cleanup ──────────────────────────────────────────────────────────── */
|
|
56
104
|
When('I register cleanup {method} {string}', async ({ data, api, apiContext, env }, method, path) => {
|
|
57
105
|
const rendered = render(path, apiContext.vars.toObject(), env.vars);
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import './params.js';
|
|
2
|
+
export declare function identifier(kind: string, value: string): string;
|
|
3
|
+
/** `a = 1, b = x` → [['a', '1'], ['b', 'x']]. Values stay parameterised. */
|
|
4
|
+
export declare function parseWhere(clause: string): Array<[string, string]>;
|
|
5
|
+
//# sourceMappingURL=db.steps.d.ts.map
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { expect } from '@playwright/test';
|
|
2
|
+
import './params.js';
|
|
3
|
+
import { Then, When } from '../fixtures/test.js';
|
|
4
|
+
import { render } from '../api/template.js';
|
|
5
|
+
import { SdodsError } from '../errors.js';
|
|
6
|
+
/**
|
|
7
|
+
* Database assertions over the existing Kysely worker fixture.
|
|
8
|
+
*
|
|
9
|
+
* WHY — "the UI said 200 and no row was written" is the exact class of defect a
|
|
10
|
+
* black-box suite cannot see, and the `db` fixture has existed all along with
|
|
11
|
+
* no step reading it. A far-side check is the only way to catch a silent
|
|
12
|
+
* success.
|
|
13
|
+
*
|
|
14
|
+
* DESIGN — READ ONLY. There is deliberately no step that INSERTs or UPDATEs.
|
|
15
|
+
* Seeding through the database rather than the application is how a suite comes
|
|
16
|
+
* to assert against states the product cannot actually produce, and the rows it
|
|
17
|
+
* writes then outlive the scenario. Seed through the API; assert through here.
|
|
18
|
+
*
|
|
19
|
+
* The table and column names a scenario passes are validated against a strict
|
|
20
|
+
* identifier pattern before they reach the query builder. Kysely parameterises
|
|
21
|
+
* VALUES, but an identifier is not a parameter — it is interpolated — so a
|
|
22
|
+
* table name taken from a Gherkin string is an injection point unless it is
|
|
23
|
+
* checked. Rejecting anything that is not a plain identifier is a smaller
|
|
24
|
+
* surface than trying to escape it.
|
|
25
|
+
*/
|
|
26
|
+
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
27
|
+
const scopesOf = (apiContext, env) => [apiContext.vars.toObject(), env.vars];
|
|
28
|
+
function requireDb(db) {
|
|
29
|
+
if (!db) {
|
|
30
|
+
throw new SdodsError('DB_REQUIRED', 'This scenario needs a database connection.', {
|
|
31
|
+
hint: 'Declare `db:` in the environment yaml. The db fixture is undefined when no connection is configured, and a db assertion that silently passes without one is worthless.',
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return db;
|
|
35
|
+
}
|
|
36
|
+
export function identifier(kind, value) {
|
|
37
|
+
if (!IDENTIFIER.test(value)) {
|
|
38
|
+
throw new SdodsError('CONFIG_INVALID', `"${value}" is not a valid ${kind} name.`, {
|
|
39
|
+
hint: 'Table and column names are interpolated into SQL, not parameterised, so only plain identifiers are accepted.',
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
/** `a = 1, b = x` → [['a', '1'], ['b', 'x']]. Values stay parameterised. */
|
|
45
|
+
export function parseWhere(clause) {
|
|
46
|
+
return clause.split(',').map((part) => {
|
|
47
|
+
const m = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+?)\s*$/.exec(part);
|
|
48
|
+
if (!m) {
|
|
49
|
+
throw new SdodsError('CONFIG_INVALID', `Cannot read "${part.trim()}" as a condition.`, {
|
|
50
|
+
hint: 'Write conditions as `column = value`, separated by commas.',
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return [identifier('column', m[1]), m[2].replace(/^["']|["']$/g, '')];
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
async function rowsWhere(db, table, clause) {
|
|
57
|
+
const kysely = db;
|
|
58
|
+
let q = kysely.selectFrom(identifier('table', table)).selectAll();
|
|
59
|
+
for (const [col, val] of parseWhere(clause))
|
|
60
|
+
q = q.where(col, '=', val);
|
|
61
|
+
return q.execute();
|
|
62
|
+
}
|
|
63
|
+
/* ── assertions ───────────────────────────────────────────────────────── */
|
|
64
|
+
Then('the {string} table should have {int} row(s) where {string}', async ({ db, apiContext, env }, table, count, clause) => {
|
|
65
|
+
const scopes = scopesOf(apiContext, env);
|
|
66
|
+
const rows = await rowsWhere(requireDb(db), render(table, ...scopes), render(clause, ...scopes));
|
|
67
|
+
expect(rows, `${table} where ${clause}`).toHaveLength(count);
|
|
68
|
+
});
|
|
69
|
+
Then('the {string} table should have a row where {string}', async ({ db, apiContext, env }, table, clause) => {
|
|
70
|
+
const scopes = scopesOf(apiContext, env);
|
|
71
|
+
const rows = await rowsWhere(requireDb(db), render(table, ...scopes), render(clause, ...scopes));
|
|
72
|
+
expect(rows.length, `${table} where ${clause}`).toBeGreaterThan(0);
|
|
73
|
+
});
|
|
74
|
+
Then('the {string} table should have no row where {string}', async ({ db, apiContext, env }, table, clause) => {
|
|
75
|
+
const scopes = scopesOf(apiContext, env);
|
|
76
|
+
const rows = await rowsWhere(requireDb(db), render(table, ...scopes), render(clause, ...scopes));
|
|
77
|
+
expect(rows, `${table} where ${clause}`).toHaveLength(0);
|
|
78
|
+
});
|
|
79
|
+
Then('the {string} column of the row where {string} in {string} should be {string}', async ({ db, apiContext, env }, column, clause, table, value) => {
|
|
80
|
+
const scopes = scopesOf(apiContext, env);
|
|
81
|
+
const rows = await rowsWhere(requireDb(db), render(table, ...scopes), render(clause, ...scopes));
|
|
82
|
+
if (rows.length !== 1) {
|
|
83
|
+
throw new SdodsError('CONFIG_INVALID', `Expected exactly one row in ${table} where ${clause}, found ${rows.length}.`, {
|
|
84
|
+
hint: 'Narrow the condition. Asserting a column across several rows hides which one matched.',
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
expect(String(rows[0][identifier('column', render(column, ...scopes))])).toBe(render(value, ...scopes));
|
|
88
|
+
});
|
|
89
|
+
/**
|
|
90
|
+
* Polling variant. A write that the API acknowledges is often applied
|
|
91
|
+
* asynchronously, and the alternative to this step is a sleep — which is either
|
|
92
|
+
* too short (flaky) or too long (slow), and never right.
|
|
93
|
+
*/
|
|
94
|
+
When('I wait for the {string} table to have a row where {string}', async ({ db, apiContext, env }, table, clause) => {
|
|
95
|
+
const scopes = scopesOf(apiContext, env);
|
|
96
|
+
const handle = requireDb(db);
|
|
97
|
+
const t = render(table, ...scopes);
|
|
98
|
+
const c = render(clause, ...scopes);
|
|
99
|
+
await expect
|
|
100
|
+
.poll(async () => (await rowsWhere(handle, t, c)).length, {
|
|
101
|
+
message: `${t} never got a row where ${c}`,
|
|
102
|
+
})
|
|
103
|
+
.toBeGreaterThan(0);
|
|
104
|
+
});
|
|
105
|
+
//# sourceMappingURL=db.steps.js.map
|