@sdods/core 0.3.2 → 0.5.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/api/client.d.ts +62 -1
- package/dist/api/client.js +189 -27
- package/dist/api/template.d.ts +15 -2
- package/dist/api/template.js +31 -13
- package/dist/auth/capture.js +4 -3
- package/dist/auth/index.js +3 -18
- package/dist/config/defaults.js +2 -0
- package/dist/config/resolve.d.ts +5 -1
- package/dist/config/resolve.js +16 -3
- package/dist/config/runner.d.ts +26 -1
- package/dist/config/runner.js +155 -24
- package/dist/config/tags.d.ts +14 -2
- package/dist/config/tags.js +27 -7
- package/dist/fixtures/api-context.d.ts +7 -0
- package/dist/fixtures/api-context.js +7 -0
- package/dist/fixtures/auth.js +72 -5
- package/dist/fixtures/test.js +9 -2
- package/dist/heal/healer.d.ts +12 -1
- package/dist/heal/healer.js +80 -34
- package/dist/heal/strategies.d.ts +12 -2
- package/dist/heal/strategies.js +42 -0
- package/dist/lint/index.d.ts +1 -0
- package/dist/lint/index.js +44 -3
- package/dist/lint/steps.d.ts +13 -0
- package/dist/lint/steps.js +333 -0
- package/dist/load-module.d.ts +7 -0
- package/dist/load-module.js +70 -0
- package/dist/logger.d.ts +1 -0
- package/dist/logger.js +9 -3
- package/dist/steps/a11y.steps.js +10 -10
- package/dist/steps/api.steps.js +64 -27
- package/dist/steps/browser.steps.js +34 -33
- package/dist/steps/clock.steps.js +5 -5
- package/dist/steps/data.steps.js +61 -8
- package/dist/steps/db.steps.js +8 -8
- package/dist/steps/dom.steps.js +49 -49
- package/dist/steps/hybrid.steps.js +9 -5
- package/dist/steps/iframe.steps.js +9 -9
- package/dist/steps/net.steps.d.ts +12 -7
- package/dist/steps/net.steps.js +78 -83
- package/dist/steps/perf.steps.js +6 -6
- package/dist/steps/tabs.steps.js +6 -6
- package/dist/steps/ui.steps.js +94 -45
- package/dist/steps/webhook.steps.js +3 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +15 -1
- package/package.json +6 -4
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { expect } from '@playwright/test';
|
|
2
2
|
import './params.js';
|
|
3
3
|
import { Given, Then } from '../fixtures/test.js';
|
|
4
|
-
import {
|
|
4
|
+
import { renderStrict } from '../api/template.js';
|
|
5
5
|
import { SdodsError } from '../errors.js';
|
|
6
6
|
/**
|
|
7
7
|
* Clock control.
|
|
@@ -40,7 +40,7 @@ Given('I install a fake clock', async ({ page }) => {
|
|
|
40
40
|
await page.clock.install();
|
|
41
41
|
});
|
|
42
42
|
Given('I install a fake clock set to {string}', async ({ page, apiContext, env }, when) => {
|
|
43
|
-
const at = resolveTime(
|
|
43
|
+
const at = resolveTime(renderStrict(when, ...scopesOf(apiContext, env)), new Date());
|
|
44
44
|
await page.clock.install({ time: at });
|
|
45
45
|
});
|
|
46
46
|
/**
|
|
@@ -49,12 +49,12 @@ Given('I install a fake clock set to {string}', async ({ page, apiContext, env }
|
|
|
49
49
|
* hang the test rather than simulate it.
|
|
50
50
|
*/
|
|
51
51
|
Given('the clock jumps to {string}', async ({ page, apiContext, env }, when) => {
|
|
52
|
-
const at = resolveTime(
|
|
52
|
+
const at = resolveTime(renderStrict(when, ...scopesOf(apiContext, env)), new Date());
|
|
53
53
|
await page.clock.setFixedTime(at);
|
|
54
54
|
});
|
|
55
55
|
/** Runs timers as it goes, so polling and countdowns actually fire. */
|
|
56
56
|
Given('the clock advances by {string}', async ({ page, apiContext, env }, amount) => {
|
|
57
|
-
const spec =
|
|
57
|
+
const spec = renderStrict(amount, ...scopesOf(apiContext, env)).trim();
|
|
58
58
|
const normalised = /^[+-]/.test(spec) ? spec : `+${spec}`;
|
|
59
59
|
const target = resolveTime(normalised, new Date(0));
|
|
60
60
|
await page.clock.runFor(target.getTime());
|
|
@@ -63,7 +63,7 @@ Given('the clock resumes', async ({ page }) => {
|
|
|
63
63
|
await page.clock.resume();
|
|
64
64
|
});
|
|
65
65
|
Then('the page clock should read {string}', async ({ page, apiContext, env }, iso) => {
|
|
66
|
-
const expected = resolveTime(
|
|
66
|
+
const expected = resolveTime(renderStrict(iso, ...scopesOf(apiContext, env)), new Date());
|
|
67
67
|
const actual = await page.evaluate(() => Date.now());
|
|
68
68
|
// A second of tolerance: the assertion is about which DAY or HOUR the page
|
|
69
69
|
// believes it is, and demanding millisecond equality would make it flaky for
|
package/dist/steps/data.steps.js
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
2
2
|
import './params.js';
|
|
3
3
|
import { Given, When } from '../fixtures/test.js';
|
|
4
4
|
import { tokenFileFor } from '../auth/capture.js';
|
|
5
|
-
import {
|
|
5
|
+
import { renderStrict } from '../api/template.js';
|
|
6
6
|
import { SdodsError } from '../errors.js';
|
|
7
7
|
import { Logger } from '../logger.js';
|
|
8
8
|
const log = new Logger('data');
|
|
@@ -29,6 +29,7 @@ Given('I use a leased user with role {string}', async ({ userPool, authCache, au
|
|
|
29
29
|
role: user.role,
|
|
30
30
|
...user.extra,
|
|
31
31
|
});
|
|
32
|
+
LEASED.set(apiContext, user);
|
|
32
33
|
if (sdods.layer !== 'api')
|
|
33
34
|
await authCache.apply({ context, page, user, auth, browser });
|
|
34
35
|
});
|
|
@@ -44,6 +45,7 @@ Given('I use the user {string} from the pool', async ({ userPool, apiContext, $t
|
|
|
44
45
|
userId: user.id,
|
|
45
46
|
role: user.role,
|
|
46
47
|
});
|
|
48
|
+
LEASED.set(apiContext, user);
|
|
47
49
|
});
|
|
48
50
|
/**
|
|
49
51
|
* API-layer variant: leases the account, exposes its credentials as variables,
|
|
@@ -68,6 +70,18 @@ Given('I use the user {string} from the pool', async ({ userPool, apiContext, $t
|
|
|
68
70
|
* meaningful and one mint is reused across a sharded run), then a live
|
|
69
71
|
* `token()` call. A strategy with no `token()` leaves auth unset and says so,
|
|
70
72
|
* rather than silently continuing anonymous.
|
|
73
|
+
*
|
|
74
|
+
* Attaching the token is itself a choice of credential class, though, and it
|
|
75
|
+
* must not be made behind the scenario's back (#87). A `custom` strategy's
|
|
76
|
+
* `token()` can mint a personal API key for an app whose scenarios also sign
|
|
77
|
+
* in with a session; attached silently, such a scenario sends BOTH, and "a key
|
|
78
|
+
* is refused on a session-only route" passes on whichever one the server reads
|
|
79
|
+
* first. So `auth.apiToken` decides: `implicit` attaches here, `explicit` only
|
|
80
|
+
* leases and the scenario says `I authenticate the API with the leased user's
|
|
81
|
+
* token`. Unset, `custom` is explicit and every other strategy keeps the
|
|
82
|
+
* implicit behaviour #18 introduced — for `token` and
|
|
83
|
+
* `oauth-client-credentials` the token is the only credential there is.
|
|
84
|
+
* Either way the attached credential is logged at info.
|
|
71
85
|
*/
|
|
72
86
|
Given('I use a leased user with role {string} for API calls', async ({ userPool, apiContext, auth, config, $testInfo }, role) => {
|
|
73
87
|
const user = await userPool.lease(role, $testInfo.parallelIndex);
|
|
@@ -78,17 +92,56 @@ Given('I use a leased user with role {string} for API calls', async ({ userPool,
|
|
|
78
92
|
role: user.role,
|
|
79
93
|
...user.extra,
|
|
80
94
|
});
|
|
81
|
-
|
|
82
|
-
if (
|
|
83
|
-
|
|
95
|
+
LEASED.set(apiContext, user);
|
|
96
|
+
if (apiTokenMode(config, auth) === 'explicit') {
|
|
97
|
+
log.info(`leased ${user.username} (${role}) for API calls; no credential attached ` +
|
|
98
|
+
`(auth.apiToken: explicit). Add "I authenticate the API with the leased user's token" to send its token.`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const found = await leasedUserToken(config, auth, user);
|
|
102
|
+
if (found) {
|
|
103
|
+
apiContext.auth = { type: 'bearer', token: found.token };
|
|
104
|
+
log.info(`API calls authenticate as ${user.username} (${role}): bearer token from ${found.source}.`);
|
|
105
|
+
}
|
|
84
106
|
else if (auth?.strategy && auth.strategy !== 'none') {
|
|
85
|
-
log.
|
|
107
|
+
log.info(`auth strategy "${auth.strategy}" provides no token() — API calls for ` +
|
|
86
108
|
`${user.username} (${role}) use the environment credential.`);
|
|
87
109
|
}
|
|
88
110
|
});
|
|
89
|
-
|
|
90
|
-
|
|
111
|
+
Given("I authenticate the API with the leased user's token", async ({ apiContext, auth, config }) => {
|
|
112
|
+
const user = LEASED.get(apiContext);
|
|
113
|
+
if (!user) {
|
|
114
|
+
throw new SdodsError('AUTH_FAILED', 'No user has been leased in this scenario yet.', {
|
|
115
|
+
hint: 'Lease one first with `Given I use a leased user with role "<role>" for API calls` (or `I use a leased user with role "<role>"`).',
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
const found = await leasedUserToken(config, auth, user);
|
|
119
|
+
if (!found) {
|
|
120
|
+
throw new SdodsError('AUTH_FAILED', `No API token for ${user.username} (${user.role}): no cached token file and the "${auth?.strategy ?? 'none'}" auth strategy's token() returned none.`, {
|
|
121
|
+
hint: `Run \`sdods auth capture --user ${user.role}\` to cache one, or implement token() in the project's defineAuth.`,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
apiContext.auth = { type: 'bearer', token: found.token };
|
|
125
|
+
log.info(`API calls authenticate as ${user.username} (${user.role}): bearer token from ${found.source}.`);
|
|
126
|
+
});
|
|
127
|
+
/** The user the scenario leased, so the explicit token step authenticates as that same account. */
|
|
128
|
+
const LEASED = new WeakMap();
|
|
129
|
+
function apiTokenMode(config, auth) {
|
|
130
|
+
return config.project.auth?.apiToken ?? (auth?.strategy === 'custom' ? 'explicit' : 'implicit');
|
|
131
|
+
}
|
|
132
|
+
/** The cached `sdods auth capture` token, else a live `token()` — with where it came from. */
|
|
133
|
+
async function leasedUserToken(config, auth, user) {
|
|
91
134
|
const file = tokenFileFor(config, user);
|
|
135
|
+
const cached = readCachedToken(file);
|
|
136
|
+
if (cached)
|
|
137
|
+
return { token: cached, source: `cached token file ${file}` };
|
|
138
|
+
const minted = await auth?.token?.({ config, user });
|
|
139
|
+
return minted
|
|
140
|
+
? { token: minted, source: `the "${auth?.strategy}" strategy's token()` }
|
|
141
|
+
: undefined;
|
|
142
|
+
}
|
|
143
|
+
/** The token `sdods auth capture` wrote for this user, when it is still there. */
|
|
144
|
+
function readCachedToken(file) {
|
|
92
145
|
if (!existsSync(file))
|
|
93
146
|
return undefined;
|
|
94
147
|
try {
|
|
@@ -102,7 +155,7 @@ function readCachedToken(config, user) {
|
|
|
102
155
|
}
|
|
103
156
|
/* ── cleanup ──────────────────────────────────────────────────────────── */
|
|
104
157
|
When('I register cleanup {method} {string}', async ({ data, api, apiContext, env }, method, path) => {
|
|
105
|
-
const rendered =
|
|
158
|
+
const rendered = renderStrict(path, apiContext.vars.toObject(), env.vars);
|
|
106
159
|
data.registerCleanup(async () => {
|
|
107
160
|
await api.send(method, rendered, { silent: true });
|
|
108
161
|
}, `${method} ${rendered}`);
|
package/dist/steps/db.steps.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { expect } from '@playwright/test';
|
|
2
2
|
import './params.js';
|
|
3
3
|
import { Then, When } from '../fixtures/test.js';
|
|
4
|
-
import {
|
|
4
|
+
import { renderStrict } from '../api/template.js';
|
|
5
5
|
import { SdodsError } from '../errors.js';
|
|
6
6
|
/**
|
|
7
7
|
* Database assertions over the existing Kysely worker fixture.
|
|
@@ -63,28 +63,28 @@ async function rowsWhere(db, table, clause) {
|
|
|
63
63
|
/* ── assertions ───────────────────────────────────────────────────────── */
|
|
64
64
|
Then('the {string} table should have {int} row(s) where {string}', async ({ db, apiContext, env }, table, count, clause) => {
|
|
65
65
|
const scopes = scopesOf(apiContext, env);
|
|
66
|
-
const rows = await rowsWhere(requireDb(db),
|
|
66
|
+
const rows = await rowsWhere(requireDb(db), renderStrict(table, ...scopes), renderStrict(clause, ...scopes));
|
|
67
67
|
expect(rows, `${table} where ${clause}`).toHaveLength(count);
|
|
68
68
|
});
|
|
69
69
|
Then('the {string} table should have a row where {string}', async ({ db, apiContext, env }, table, clause) => {
|
|
70
70
|
const scopes = scopesOf(apiContext, env);
|
|
71
|
-
const rows = await rowsWhere(requireDb(db),
|
|
71
|
+
const rows = await rowsWhere(requireDb(db), renderStrict(table, ...scopes), renderStrict(clause, ...scopes));
|
|
72
72
|
expect(rows.length, `${table} where ${clause}`).toBeGreaterThan(0);
|
|
73
73
|
});
|
|
74
74
|
Then('the {string} table should have no row where {string}', async ({ db, apiContext, env }, table, clause) => {
|
|
75
75
|
const scopes = scopesOf(apiContext, env);
|
|
76
|
-
const rows = await rowsWhere(requireDb(db),
|
|
76
|
+
const rows = await rowsWhere(requireDb(db), renderStrict(table, ...scopes), renderStrict(clause, ...scopes));
|
|
77
77
|
expect(rows, `${table} where ${clause}`).toHaveLength(0);
|
|
78
78
|
});
|
|
79
79
|
Then('the {string} column of the row where {string} in {string} should be {string}', async ({ db, apiContext, env }, column, clause, table, value) => {
|
|
80
80
|
const scopes = scopesOf(apiContext, env);
|
|
81
|
-
const rows = await rowsWhere(requireDb(db),
|
|
81
|
+
const rows = await rowsWhere(requireDb(db), renderStrict(table, ...scopes), renderStrict(clause, ...scopes));
|
|
82
82
|
if (rows.length !== 1) {
|
|
83
83
|
throw new SdodsError('CONFIG_INVALID', `Expected exactly one row in ${table} where ${clause}, found ${rows.length}.`, {
|
|
84
84
|
hint: 'Narrow the condition. Asserting a column across several rows hides which one matched.',
|
|
85
85
|
});
|
|
86
86
|
}
|
|
87
|
-
expect(String(rows[0][identifier('column',
|
|
87
|
+
expect(String(rows[0][identifier('column', renderStrict(column, ...scopes))])).toBe(renderStrict(value, ...scopes));
|
|
88
88
|
});
|
|
89
89
|
/**
|
|
90
90
|
* Polling variant. A write that the API acknowledges is often applied
|
|
@@ -94,8 +94,8 @@ Then('the {string} column of the row where {string} in {string} should be {strin
|
|
|
94
94
|
When('I wait for the {string} table to have a row where {string}', async ({ db, apiContext, env }, table, clause) => {
|
|
95
95
|
const scopes = scopesOf(apiContext, env);
|
|
96
96
|
const handle = requireDb(db);
|
|
97
|
-
const t =
|
|
98
|
-
const c =
|
|
97
|
+
const t = renderStrict(table, ...scopes);
|
|
98
|
+
const c = renderStrict(clause, ...scopes);
|
|
99
99
|
await expect
|
|
100
100
|
.poll(async () => (await rowsWhere(handle, t, c)).length, {
|
|
101
101
|
message: `${t} never got a row where ${c}`,
|
package/dist/steps/dom.steps.js
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
import { expect } from '@playwright/test';
|
|
2
2
|
import './params.js';
|
|
3
3
|
import { Then, When } from '../fixtures/test.js';
|
|
4
|
-
import {
|
|
4
|
+
import { renderStrict } from '../api/template.js';
|
|
5
5
|
import { SdodsError } from '../errors.js';
|
|
6
6
|
/**
|
|
7
7
|
* DOM assertions the rest of the library cannot express.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* Before this file the library's only negative UI step was `I should not see the text`, so the deny
|
|
10
10
|
* direction of an authorisation scenario — the only direction worth asserting against a
|
|
11
11
|
* default-allow policy — is unwritable. These steps close that, plus the ordinal, counting,
|
|
12
12
|
* focus and overlay vocabulary that a real onboarding had to hand-write.
|
|
13
13
|
*
|
|
14
14
|
* Two conventions hold throughout this file, both of them load-bearing:
|
|
15
15
|
*
|
|
16
|
-
* 1. EVERY string argument goes through `
|
|
16
|
+
* 1. EVERY string argument goes through `renderStrict()`, ids and role names included. A step that
|
|
17
17
|
* silently drops `{{vars}}` fails much later, in a scenario that looks unrelated; rendering a
|
|
18
18
|
* string with no placeholder in it is a no-op, so there is no cost to doing it everywhere.
|
|
19
19
|
*
|
|
@@ -164,24 +164,24 @@ function bag(key) {
|
|
|
164
164
|
// Proves the element is not on screen. Passes when it is absent too, because "a user cannot see
|
|
165
165
|
// it" is the invariant — use the "should not exist" form when the DOM node itself is the point.
|
|
166
166
|
Then('the element with test id {string} should not be visible', async ({ page, apiContext, env }, id) => {
|
|
167
|
-
await expect(page.getByTestId(
|
|
167
|
+
await expect(page.getByTestId(renderStrict(id, ...scopesOf(apiContext, env)))).toBeHidden();
|
|
168
168
|
});
|
|
169
169
|
// Proves the node was never rendered — not merely hidden with CSS. The distinction is the point of
|
|
170
170
|
// a deny: a control the server refused to send is a different posture from one the client chose
|
|
171
171
|
// not to paint, which is one CSS toggle or one devtools edit away from being usable.
|
|
172
172
|
Then('the element with test id {string} should not exist', async ({ page, apiContext, env }, id) => {
|
|
173
|
-
await expect(page.getByTestId(
|
|
173
|
+
await expect(page.getByTestId(renderStrict(id, ...scopesOf(apiContext, env)))).toHaveCount(0);
|
|
174
174
|
});
|
|
175
175
|
// Proves a named control is not on screen — the deny half of `the {string} {role} should be
|
|
176
176
|
// visible`, which had no counterpart.
|
|
177
177
|
Then('the {string} {role} should not be visible', async ({ page, apiContext, env }, name, role) => {
|
|
178
|
-
const n =
|
|
178
|
+
const n = renderStrict(name, ...scopesOf(apiContext, env));
|
|
179
179
|
await expect(page.getByRole(role, { name: n })).toBeHidden();
|
|
180
180
|
});
|
|
181
181
|
// Proves the app rendered no such control at all. Distinct from "not visible": this is what an
|
|
182
182
|
// authorisation scenario wants when the claim is that the affordance was never offered.
|
|
183
183
|
Then('no {string} {role} should exist', async ({ page, apiContext, env }, name, role) => {
|
|
184
|
-
const n =
|
|
184
|
+
const n = renderStrict(name, ...scopesOf(apiContext, env));
|
|
185
185
|
await expect(page.getByRole(role, { name: n })).toHaveCount(0);
|
|
186
186
|
});
|
|
187
187
|
// Proves the absence is the SETTLED state, not the loading state. This is the trap that makes
|
|
@@ -190,7 +190,7 @@ Then('no {string} {role} should exist', async ({ page, apiContext, env }, name,
|
|
|
190
190
|
// passes for everyone as long as it lands first. Holding it across a window outlives the fetch.
|
|
191
191
|
Then('the {string} {role} should stay absent for {int} seconds', async ({ page, apiContext, env }, name, role, seconds) => {
|
|
192
192
|
assertPositiveDuration(seconds);
|
|
193
|
-
const n =
|
|
193
|
+
const n = renderStrict(name, ...scopesOf(apiContext, env));
|
|
194
194
|
const locator = page.getByRole(role, { name: n });
|
|
195
195
|
const deadline = Date.now() + seconds * 1000;
|
|
196
196
|
do {
|
|
@@ -205,7 +205,7 @@ Then('the {string} {role} should stay absent for {int} seconds', async ({ page,
|
|
|
205
205
|
// placeholder, a title or alt. `I should see the text` resolves through `getByText`, which reads
|
|
206
206
|
// text nodes only, so a value prefilled into a field is invisible to it.
|
|
207
207
|
Then('the text {string} should appear anywhere on the page', async ({ page, apiContext, env }, text) => {
|
|
208
|
-
const wanted =
|
|
208
|
+
const wanted = renderStrict(text, ...scopesOf(apiContext, env));
|
|
209
209
|
await expect
|
|
210
210
|
.poll(async () => readableText(page), { message: `page never rendered "${wanted}"` })
|
|
211
211
|
.toContain(wanted);
|
|
@@ -215,7 +215,7 @@ Then('the text {string} should appear anywhere on the page', async ({ page, apiC
|
|
|
215
215
|
// The blank-page guard is the point: on a surface that rendered nothing this check would pass for
|
|
216
216
|
// every string in existence, which is the vacuous pass this library refuses to ship.
|
|
217
217
|
Then('the text {string} should not appear anywhere on the page', async ({ page, apiContext, env }, text) => {
|
|
218
|
-
const unwanted =
|
|
218
|
+
const unwanted = renderStrict(text, ...scopesOf(apiContext, env));
|
|
219
219
|
const haystack = await readableText(page);
|
|
220
220
|
if (haystack.length === 0) {
|
|
221
221
|
throw new SdodsError('RUN_FAILED', 'The page has rendered no readable text at all.', {
|
|
@@ -228,14 +228,14 @@ Then('the text {string} should not appear anywhere on the page', async ({ page,
|
|
|
228
228
|
// Proves the control is offered AND actionable. Healed, on the same terms as the built-in
|
|
229
229
|
// visibility assertion — this is the allow direction. The deny direction below is not healed.
|
|
230
230
|
Then('the {string} {role} should be enabled', async ({ page, heal, apiContext, env }, name, role) => {
|
|
231
|
-
const n =
|
|
231
|
+
const n = renderStrict(name, ...scopesOf(apiContext, env));
|
|
232
232
|
const locator = await heal.resolve(page.getByRole(role, { name: n }), { role: role, name: n, text: n, description: `${role} "${n}"` }, 'assert');
|
|
233
233
|
await expect(locator).toBeEnabled();
|
|
234
234
|
});
|
|
235
235
|
// Proves the control is rendered but refuses to act. NOT healed, deliberately: this is a deny
|
|
236
236
|
// assertion, and healing onto a different, disabled element would fabricate the pass.
|
|
237
237
|
Then('the {string} {role} should be disabled', async ({ page, apiContext, env }, name, role) => {
|
|
238
|
-
const n =
|
|
238
|
+
const n = renderStrict(name, ...scopesOf(apiContext, env));
|
|
239
239
|
await expect(page.getByRole(role, { name: n })).toBeDisabled();
|
|
240
240
|
});
|
|
241
241
|
// Proves the disabled state is the settled one. `toBeDisabled()` retries until it passes, so a
|
|
@@ -243,7 +243,7 @@ Then('the {string} {role} should be disabled', async ({ page, apiContext, env },
|
|
|
243
243
|
// admin too — as long as the first poll lands before the fetch resolves. The hold outlives it.
|
|
244
244
|
Then('the {string} {role} should stay disabled for {int} seconds', async ({ page, apiContext, env }, name, role, seconds) => {
|
|
245
245
|
assertPositiveDuration(seconds);
|
|
246
|
-
const n =
|
|
246
|
+
const n = renderStrict(name, ...scopesOf(apiContext, env));
|
|
247
247
|
const locator = page.getByRole(role, { name: n });
|
|
248
248
|
const deadline = Date.now() + seconds * 1000;
|
|
249
249
|
do {
|
|
@@ -259,7 +259,7 @@ Then('the {string} {role} should stay disabled for {int} seconds', async ({ page
|
|
|
259
259
|
// Known weakness, stated rather than hidden: the absent branch is satisfied by a surface that has
|
|
260
260
|
// not rendered yet. Where that race is plausible, use "should stay absent for {int} seconds".
|
|
261
261
|
Then('the {string} {role} should be absent or disabled', async ({ page, apiContext, env }, name, role) => {
|
|
262
|
-
const n =
|
|
262
|
+
const n = renderStrict(name, ...scopesOf(apiContext, env));
|
|
263
263
|
const locator = page.getByRole(role, { name: n });
|
|
264
264
|
if ((await locator.count()) === 0)
|
|
265
265
|
return;
|
|
@@ -267,24 +267,24 @@ Then('the {string} {role} should be absent or disabled', async ({ page, apiConte
|
|
|
267
267
|
});
|
|
268
268
|
// Proves an element addressed by test id is actionable.
|
|
269
269
|
Then('the element with test id {string} should be enabled', async ({ page, heal, apiContext, env }, id) => {
|
|
270
|
-
const testId =
|
|
270
|
+
const testId = renderStrict(id, ...scopesOf(apiContext, env));
|
|
271
271
|
const locator = await heal.resolve(page.getByTestId(testId), { testId, description: `test id "${testId}"` }, 'assert');
|
|
272
272
|
await expect(locator).toBeEnabled();
|
|
273
273
|
});
|
|
274
274
|
// Proves an element addressed by test id is rendered but inert. Not healed — deny direction.
|
|
275
275
|
Then('the element with test id {string} should be disabled', async ({ page, apiContext, env }, id) => {
|
|
276
|
-
await expect(page.getByTestId(
|
|
276
|
+
await expect(page.getByTestId(renderStrict(id, ...scopesOf(apiContext, env)))).toBeDisabled();
|
|
277
277
|
});
|
|
278
278
|
// Proves a test-id-addressed action is not actionable, in whichever shape the denial took.
|
|
279
279
|
Then('the element with test id {string} should be absent or disabled', async ({ page, apiContext, env }, id) => {
|
|
280
|
-
const locator = page.getByTestId(
|
|
280
|
+
const locator = page.getByTestId(renderStrict(id, ...scopesOf(apiContext, env)));
|
|
281
281
|
if ((await locator.count()) === 0)
|
|
282
282
|
return;
|
|
283
283
|
await expect(locator.first()).toBeDisabled();
|
|
284
284
|
});
|
|
285
285
|
// Proves an opt-in is on.
|
|
286
286
|
Then('the {string} checkbox should be checked', async ({ page, heal, apiContext, env }, label) => {
|
|
287
|
-
const l =
|
|
287
|
+
const l = renderStrict(label, ...scopesOf(apiContext, env));
|
|
288
288
|
const locator = await heal.resolve(page.getByLabel(l), { label: l, role: 'checkbox', name: l, description: `${l} checkbox` }, 'assert');
|
|
289
289
|
await expect(locator).toBeChecked();
|
|
290
290
|
});
|
|
@@ -294,7 +294,7 @@ Then('the {string} checkbox should be checked', async ({ page, heal, apiContext,
|
|
|
294
294
|
// extra safety; what it buys is the failure MESSAGE — "no such checkbox is rendered" rather than a
|
|
295
295
|
// retry timeout that reads as though the assertion itself were wrong.
|
|
296
296
|
Then('the {string} checkbox should not be checked', async ({ page, apiContext, env }, label) => {
|
|
297
|
-
const l =
|
|
297
|
+
const l = renderStrict(label, ...scopesOf(apiContext, env));
|
|
298
298
|
const locator = page.getByLabel(l);
|
|
299
299
|
await expect(locator, `no "${l}" checkbox is rendered`).toHaveCount(1);
|
|
300
300
|
await expect(locator).not.toBeChecked();
|
|
@@ -307,7 +307,7 @@ Then('the {string} checkbox should not be checked', async ({ page, apiContext, e
|
|
|
307
307
|
// Proves the first of several identical elements is clickable, on a surface where the built-in
|
|
308
308
|
// step can only report ambiguity.
|
|
309
309
|
When('I click the first element with test id {string}', async ({ page, apiContext, env }, id) => {
|
|
310
|
-
const testId =
|
|
310
|
+
const testId = renderStrict(id, ...scopesOf(apiContext, env));
|
|
311
311
|
const locator = page.getByTestId(testId);
|
|
312
312
|
await expectAtLeast(locator, 1, `elements with test id "${testId}"`);
|
|
313
313
|
await locator.first().click();
|
|
@@ -315,7 +315,7 @@ When('I click the first element with test id {string}', async ({ page, apiContex
|
|
|
315
315
|
// Proves a specific row of a repeated list is reachable — the assertion a list scenario needs
|
|
316
316
|
// before it can act on row 2 at all.
|
|
317
317
|
When('I click the element with test id {string} at position {int}', async ({ page, apiContext, env }, id, position) => {
|
|
318
|
-
const testId =
|
|
318
|
+
const testId = renderStrict(id, ...scopesOf(apiContext, env));
|
|
319
319
|
const index = indexOfPosition(position, `elements with test id "${testId}"`);
|
|
320
320
|
const locator = page.getByTestId(testId);
|
|
321
321
|
await expectAtLeast(locator, position, `elements with test id "${testId}"`);
|
|
@@ -323,14 +323,14 @@ When('I click the element with test id {string} at position {int}', async ({ pag
|
|
|
323
323
|
});
|
|
324
324
|
// Proves the first of several same-named controls is clickable.
|
|
325
325
|
When('I click the first {string} {role}', async ({ page, apiContext, env }, name, role) => {
|
|
326
|
-
const n =
|
|
326
|
+
const n = renderStrict(name, ...scopesOf(apiContext, env));
|
|
327
327
|
const locator = page.getByRole(role, { name: n });
|
|
328
328
|
await expectAtLeast(locator, 1, `"${n}" ${role} elements`);
|
|
329
329
|
await locator.first().click();
|
|
330
330
|
});
|
|
331
331
|
// Proves a specific one of several same-named controls is clickable.
|
|
332
332
|
When('I click the {string} {role} at position {int}', async ({ page, apiContext, env }, name, role, position) => {
|
|
333
|
-
const n =
|
|
333
|
+
const n = renderStrict(name, ...scopesOf(apiContext, env));
|
|
334
334
|
const index = indexOfPosition(position, `"${n}" ${role} elements`);
|
|
335
335
|
const locator = page.getByRole(role, { name: n });
|
|
336
336
|
await expectAtLeast(locator, position, `"${n}" ${role} elements`);
|
|
@@ -339,28 +339,28 @@ When('I click the {string} {role} at position {int}', async ({ page, apiContext,
|
|
|
339
339
|
// Proves the first of several identical fields accepts input.
|
|
340
340
|
When('I fill the first element with test id {string} with {string}', async ({ page, apiContext, env }, id, value) => {
|
|
341
341
|
const scopes = scopesOf(apiContext, env);
|
|
342
|
-
const testId =
|
|
342
|
+
const testId = renderStrict(id, ...scopes);
|
|
343
343
|
const locator = page.getByTestId(testId);
|
|
344
344
|
await expectAtLeast(locator, 1, `elements with test id "${testId}"`);
|
|
345
|
-
await locator.first().fill(
|
|
345
|
+
await locator.first().fill(renderStrict(value, ...scopes));
|
|
346
346
|
});
|
|
347
347
|
// Proves a specific row's field accepts input — editing row 3 of a repeated editor.
|
|
348
348
|
When('I fill the element with test id {string} at position {int} with {string}', async ({ page, apiContext, env }, id, position, value) => {
|
|
349
349
|
const scopes = scopesOf(apiContext, env);
|
|
350
|
-
const testId =
|
|
350
|
+
const testId = renderStrict(id, ...scopes);
|
|
351
351
|
const index = indexOfPosition(position, `elements with test id "${testId}"`);
|
|
352
352
|
const locator = page.getByTestId(testId);
|
|
353
353
|
await expectAtLeast(locator, position, `elements with test id "${testId}"`);
|
|
354
|
-
await locator.nth(index).fill(
|
|
354
|
+
await locator.nth(index).fill(renderStrict(value, ...scopes));
|
|
355
355
|
});
|
|
356
356
|
// Proves at least one such element rendered, where the built-in visibility step would report a
|
|
357
357
|
// strict-mode violation instead of an answer.
|
|
358
358
|
Then('the first element with test id {string} should be visible', async ({ page, apiContext, env }, id) => {
|
|
359
|
-
await expect(page.getByTestId(
|
|
359
|
+
await expect(page.getByTestId(renderStrict(id, ...scopesOf(apiContext, env))).first()).toBeVisible();
|
|
360
360
|
});
|
|
361
361
|
// Proves the Nth repeat rendered — i.e. that the list is at least N long AND that entry is shown.
|
|
362
362
|
Then('the element with test id {string} at position {int} should be visible', async ({ page, apiContext, env }, id, position) => {
|
|
363
|
-
const testId =
|
|
363
|
+
const testId = renderStrict(id, ...scopesOf(apiContext, env));
|
|
364
364
|
const index = indexOfPosition(position, `elements with test id "${testId}"`);
|
|
365
365
|
await expect(page.getByTestId(testId).nth(index), `no element with test id "${testId}" at position ${position}`).toBeVisible();
|
|
366
366
|
});
|
|
@@ -368,18 +368,18 @@ Then('the element with test id {string} at position {int} should be visible', as
|
|
|
368
368
|
// ordering or pagination scenario mean something.
|
|
369
369
|
Then('the element with test id {string} at position {int} should contain {string}', async ({ page, apiContext, env }, id, position, text) => {
|
|
370
370
|
const scopes = scopesOf(apiContext, env);
|
|
371
|
-
const testId =
|
|
371
|
+
const testId = renderStrict(id, ...scopes);
|
|
372
372
|
const index = indexOfPosition(position, `elements with test id "${testId}"`);
|
|
373
|
-
await expect(page.getByTestId(testId).nth(index), `element with test id "${testId}" at position ${position}`).toContainText(
|
|
373
|
+
await expect(page.getByTestId(testId).nth(index), `element with test id "${testId}" at position ${position}`).toContainText(renderStrict(text, ...scopes));
|
|
374
374
|
});
|
|
375
375
|
// Proves at least one same-named control rendered and is shown.
|
|
376
376
|
Then('the first {string} {role} should be visible', async ({ page, apiContext, env }, name, role) => {
|
|
377
|
-
const n =
|
|
377
|
+
const n = renderStrict(name, ...scopesOf(apiContext, env));
|
|
378
378
|
await expect(page.getByRole(role, { name: n }).first()).toBeVisible();
|
|
379
379
|
});
|
|
380
380
|
// Proves the Nth same-named control rendered and is shown.
|
|
381
381
|
Then('the {string} {role} at position {int} should be visible', async ({ page, apiContext, env }, name, role, position) => {
|
|
382
|
-
const n =
|
|
382
|
+
const n = renderStrict(name, ...scopesOf(apiContext, env));
|
|
383
383
|
const index = indexOfPosition(position, `"${n}" ${role} elements`);
|
|
384
384
|
await expect(page.getByRole(role, { name: n }).nth(index), `no "${n}" ${role} at position ${position}`).toBeVisible();
|
|
385
385
|
});
|
|
@@ -387,24 +387,24 @@ Then('the {string} {role} at position {int} should be visible', async ({ page, a
|
|
|
387
387
|
// Proves the collection is exactly this size — the assertion that catches a duplicated render or
|
|
388
388
|
// a leaked row that a "should be visible" check happily ignores.
|
|
389
389
|
Then('the element with test id {string} should have exactly {int} matches', async ({ page, apiContext, env }, id, count) => {
|
|
390
|
-
await expect(page.getByTestId(
|
|
390
|
+
await expect(page.getByTestId(renderStrict(id, ...scopesOf(apiContext, env)))).toHaveCount(count);
|
|
391
391
|
});
|
|
392
392
|
// Proves the collection reached a floor, for lists whose exact length is not the point. A floor of
|
|
393
393
|
// 0 is refused: it would pass on a blank page, which is not an assertion.
|
|
394
394
|
Then('the element with test id {string} should have at least {int} matches', async ({ page, apiContext, env }, id, count) => {
|
|
395
|
-
const testId =
|
|
395
|
+
const testId = renderStrict(id, ...scopesOf(apiContext, env));
|
|
396
396
|
assertMeaningfulFloor(count, `elements with test id "${testId}"`);
|
|
397
397
|
await expectAtLeast(page.getByTestId(testId), count, `elements with test id "${testId}"`);
|
|
398
398
|
});
|
|
399
399
|
// Proves exactly N same-named controls rendered — e.g. that a "Delete" appears on every row and
|
|
400
400
|
// nowhere else.
|
|
401
401
|
Then('there should be exactly {int} {string} {role} elements', async ({ page, apiContext, env }, count, name, role) => {
|
|
402
|
-
const n =
|
|
402
|
+
const n = renderStrict(name, ...scopesOf(apiContext, env));
|
|
403
403
|
await expect(page.getByRole(role, { name: n })).toHaveCount(count);
|
|
404
404
|
});
|
|
405
405
|
// Proves at least N same-named controls rendered. A floor of 0 is refused for the same reason.
|
|
406
406
|
Then('there should be at least {int} {string} {role} elements', async ({ page, apiContext, env }, count, name, role) => {
|
|
407
|
-
const n =
|
|
407
|
+
const n = renderStrict(name, ...scopesOf(apiContext, env));
|
|
408
408
|
assertMeaningfulFloor(count, `"${n}" ${role} elements`);
|
|
409
409
|
await expectAtLeast(page.getByRole(role, { name: n }), count, `"${n}" ${role} elements`);
|
|
410
410
|
});
|
|
@@ -416,7 +416,7 @@ Then('there should be at least {int} {string} {role} elements', async ({ page, a
|
|
|
416
416
|
// dialog contract. The body-focus guard is essential: `document.activeElement` falls back to
|
|
417
417
|
// `<body>`, whose text is the whole page, so an unguarded name match passes for almost any string.
|
|
418
418
|
Then('the focused element should be named {string}', async ({ page, apiContext, env }, name) => {
|
|
419
|
-
const wanted =
|
|
419
|
+
const wanted = renderStrict(name, ...scopesOf(apiContext, env));
|
|
420
420
|
const info = await readFocus(page);
|
|
421
421
|
expect(info.focused, `nothing is focused — <${info.tag}> holds focus, so no element is named "${wanted}"`).toBe(true);
|
|
422
422
|
expect(info.name.toLowerCase(), `focus is on <${info.tag}> named "${info.name}", not on "${wanted}"`).toContain(wanted.toLowerCase());
|
|
@@ -439,7 +439,7 @@ Then('the focused element should be inside the open dialog', async ({ page }) =>
|
|
|
439
439
|
// Proves focus is on one specific named control, resolved the same way a user's screen reader
|
|
440
440
|
// would name it.
|
|
441
441
|
Then('focus should be on the {string} {role}', async ({ page, apiContext, env }, name, role) => {
|
|
442
|
-
const n =
|
|
442
|
+
const n = renderStrict(name, ...scopesOf(apiContext, env));
|
|
443
443
|
await expect(page.locator(':focus'), 'nothing on the page is focused').toHaveCount(1);
|
|
444
444
|
await expect(page.getByRole(role, { name: n }).first()).toBeFocused();
|
|
445
445
|
});
|
|
@@ -450,7 +450,7 @@ When('I replace the focused field with {string}', async ({ page, apiContext, env
|
|
|
450
450
|
const focused = page.locator('input:focus, textarea:focus, [contenteditable="true"]:focus');
|
|
451
451
|
await expect(focused, 'no editable element is focused — did the rename affordance open?').toHaveCount(1);
|
|
452
452
|
await page.keyboard.press('ControlOrMeta+a');
|
|
453
|
-
await page.keyboard.type(
|
|
453
|
+
await page.keyboard.type(renderStrict(value, ...scopesOf(apiContext, env)));
|
|
454
454
|
});
|
|
455
455
|
/* ── keyboard ─────────────────────────────────────────────────────────── */
|
|
456
456
|
// Proves a repeated key press is expressible at all — walking a tab ring or a listbox needs it,
|
|
@@ -461,7 +461,7 @@ When('I press {string} {int} times', async ({ page, apiContext, env }, key, time
|
|
|
461
461
|
hint: 'Press counts are whole numbers, 1 or more. A 0-press step does nothing, and any assertion that follows it would be about an untouched page.',
|
|
462
462
|
});
|
|
463
463
|
}
|
|
464
|
-
const k =
|
|
464
|
+
const k = renderStrict(key, ...scopesOf(apiContext, env));
|
|
465
465
|
for (let i = 0; i < times; i += 1)
|
|
466
466
|
await page.keyboard.press(k);
|
|
467
467
|
});
|
|
@@ -473,7 +473,7 @@ When('I press {string} {int} times recording every focused element', async ({ pa
|
|
|
473
473
|
hint: 'A focus walk of 0 presses records nothing, and "every recorded focus stayed inside" would then be true of an empty list.',
|
|
474
474
|
});
|
|
475
475
|
}
|
|
476
|
-
const k =
|
|
476
|
+
const k = renderStrict(key, ...scopesOf(apiContext, env));
|
|
477
477
|
const walk = [];
|
|
478
478
|
for (let i = 0; i < times; i += 1) {
|
|
479
479
|
await page.keyboard.press(k);
|
|
@@ -503,13 +503,13 @@ Then('every recorded focus should have stayed inside the dialog', async ({ apiCo
|
|
|
503
503
|
// the loop checks before it presses, because blurring first can dismiss the very overlay under
|
|
504
504
|
// test.
|
|
505
505
|
When('I tab to the first focusable element inside the {string} {role}', async ({ page, heal, apiContext, env }, name, role) => {
|
|
506
|
-
const n =
|
|
506
|
+
const n = renderStrict(name, ...scopesOf(apiContext, env));
|
|
507
507
|
const region = await heal.resolve(page.getByRole(role, { name: n }), { role: role, name: n, text: n, description: `${role} "${n}"` }, 'assert');
|
|
508
508
|
await tabToFirstFocusable(page, region.first(), `the "${n}" ${role}`);
|
|
509
509
|
});
|
|
510
510
|
// Proves the same for a region that carries a test id rather than an accessible name.
|
|
511
511
|
When('I tab to the first focusable element inside the element with test id {string}', async ({ page, heal, apiContext, env }, id) => {
|
|
512
|
-
const testId =
|
|
512
|
+
const testId = renderStrict(id, ...scopesOf(apiContext, env));
|
|
513
513
|
const region = await heal.resolve(page.getByTestId(testId), { testId, description: `test id "${testId}"` }, 'assert');
|
|
514
514
|
await tabToFirstFocusable(page, region.first(), `the element with test id "${testId}"`);
|
|
515
515
|
});
|
|
@@ -543,29 +543,29 @@ Then('no dialog should be open', async ({ page }) => {
|
|
|
543
543
|
});
|
|
544
544
|
// Proves the dialog's single unlabelled field accepts input.
|
|
545
545
|
When('I fill the open dialog field with {string}', async ({ page, apiContext, env }, value) => {
|
|
546
|
-
await fillOverlayField(page.getByRole('dialog').last(), 'dialog',
|
|
546
|
+
await fillOverlayField(page.getByRole('dialog').last(), 'dialog', renderStrict(value, ...scopesOf(apiContext, env)));
|
|
547
547
|
});
|
|
548
548
|
// Proves the same for an alert dialog. Kept separate from `dialog` because a confirm prompt is
|
|
549
549
|
// routinely stacked ON TOP of an already-open dialog, and only the role tells them apart.
|
|
550
550
|
When('I fill the open alert dialog field with {string}', async ({ page, apiContext, env }, value) => {
|
|
551
|
-
await fillOverlayField(page.getByRole('alertdialog').last(), 'alert dialog',
|
|
551
|
+
await fillOverlayField(page.getByRole('alertdialog').last(), 'alert dialog', renderStrict(value, ...scopesOf(apiContext, env)));
|
|
552
552
|
});
|
|
553
553
|
// Proves an unlabelled popover editor accepts input — a loop-count or filter popper typically
|
|
554
554
|
// holds one bare <input> with no label, id or test id.
|
|
555
555
|
When('I fill the open popover field with {string}', async ({ page, apiContext, env }, value) => {
|
|
556
|
-
await fillOverlayField(page.locator(POPOVER_SELECTOR).last(), 'popover',
|
|
556
|
+
await fillOverlayField(page.locator(POPOVER_SELECTOR).last(), 'popover', renderStrict(value, ...scopesOf(apiContext, env)));
|
|
557
557
|
});
|
|
558
558
|
// Proves a NAMED field inside the open dialog accepts input. Needed even though the field has a
|
|
559
559
|
// label: the form behind the overlay usually carries the same label, which makes an unscoped
|
|
560
560
|
// `getByLabel` ambiguous rather than wrong.
|
|
561
561
|
When('I fill the {string} field of the open dialog with {string}', async ({ page, apiContext, env }, label, value) => {
|
|
562
562
|
const scopes = scopesOf(apiContext, env);
|
|
563
|
-
const l =
|
|
563
|
+
const l = renderStrict(label, ...scopes);
|
|
564
564
|
const dialog = page.getByRole('dialog').last();
|
|
565
565
|
await expect(dialog, 'no dialog is open').toBeVisible();
|
|
566
566
|
const field = dialog.getByLabel(l);
|
|
567
567
|
await expect(field, `the open dialog has no "${l}" field`).toHaveCount(1);
|
|
568
|
-
await field.fill(
|
|
568
|
+
await field.fill(renderStrict(value, ...scopes));
|
|
569
569
|
});
|
|
570
570
|
/**
|
|
571
571
|
* The topmost overlay of a kind is the LAST in DOM order — portals append to <body>, so a confirm
|
|
@@ -2,17 +2,21 @@ import { expect } from '@playwright/test';
|
|
|
2
2
|
import './params.js';
|
|
3
3
|
import { Then, When } from '../fixtures/test.js';
|
|
4
4
|
import { getPath } from '../api/json-path.js';
|
|
5
|
-
import {
|
|
5
|
+
import { renderJson, renderStrict } from '../api/template.js';
|
|
6
6
|
/* Hybrid glue: seed through the API, verify in the browser. */
|
|
7
7
|
When('I seed via {method} {string} with body:', async ({ api, apiContext, env }, method, path, body) => {
|
|
8
8
|
const scopes = [apiContext.vars.toObject(), env.vars];
|
|
9
|
-
await api.send(method,
|
|
9
|
+
await api.send(method, renderStrict(path, ...scopes), { body: renderJson(body, ...scopes) });
|
|
10
10
|
});
|
|
11
|
-
Then('the UI should show the text from JSON path {string}', async ({ page, apiContext }, jsonPath) => {
|
|
12
|
-
const
|
|
11
|
+
Then('the UI should show the text from JSON path {string}', async ({ page, apiContext, env }, jsonPath) => {
|
|
12
|
+
const path = renderStrict(jsonPath, apiContext.vars.toObject(), env.vars);
|
|
13
|
+
const value = getPath(apiContext.last().response.body, path);
|
|
13
14
|
await expect(page.getByText(String(value), { exact: false }).first()).toBeVisible();
|
|
14
15
|
});
|
|
15
|
-
When('I replay HAR {string} for {string}', async ({ page, config },
|
|
16
|
+
When('I replay HAR {string} for {string}', async ({ page, config, apiContext, env }, harName, glob) => {
|
|
17
|
+
const scopes = [apiContext.vars.toObject(), env.vars];
|
|
18
|
+
const har = renderStrict(harName, ...scopes);
|
|
19
|
+
const urlGlob = renderStrict(glob, ...scopes);
|
|
16
20
|
const { resolve, isAbsolute } = await import('node:path');
|
|
17
21
|
const file = isAbsolute(har)
|
|
18
22
|
? har
|