@esimplicitylabs/katalyst-xspec 0.6.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/LICENSE +7 -0
- package/README.md +69 -0
- package/bin/katalyst-xspec.cjs +54 -0
- package/cli/init.cjs +679 -0
- package/cli/stubs.cjs +365 -0
- package/cli/upgrade.cjs +1014 -0
- package/dist/chunk-ACAXOGKZ.js +1611 -0
- package/dist/index.d.ts +881 -0
- package/dist/index.js +1091 -0
- package/dist/steps/index.d.ts +151 -0
- package/dist/steps/index.js +50 -0
- package/package.json +80 -0
- package/scripts/postinstall.cjs +85 -0
- package/skills/katalyst-bdd-architecture/SKILL.md +517 -0
- package/skills/katalyst-bdd-architecture/references/adapters.md +310 -0
- package/skills/katalyst-bdd-architecture/references/custom-steps.md +360 -0
- package/skills/katalyst-bdd-architecture/references/ports.md +256 -0
- package/skills/katalyst-bdd-create-test/SKILL.md +366 -0
- package/skills/katalyst-bdd-create-test/references/api-patterns.md +371 -0
- package/skills/katalyst-bdd-create-test/references/hybrid-patterns.md +420 -0
- package/skills/katalyst-bdd-create-test/references/tui-patterns.md +458 -0
- package/skills/katalyst-bdd-create-test/references/ui-patterns.md +415 -0
- package/skills/katalyst-bdd-quickstart/SKILL.md +292 -0
- package/skills/katalyst-bdd-step-reference/SKILL.md +147 -0
- package/skills/katalyst-bdd-step-reference/references/api-steps.md +247 -0
- package/skills/katalyst-bdd-step-reference/references/shared-steps.md +340 -0
- package/skills/katalyst-bdd-step-reference/references/tui-steps.md +483 -0
- package/skills/katalyst-bdd-step-reference/references/ui-steps.md +521 -0
- package/skills/katalyst-bdd-troubleshooting/SKILL.md +449 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1091 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertMasked,
|
|
3
|
+
clearFetchIntercept,
|
|
4
|
+
defaultBearerConfig,
|
|
5
|
+
defaultBypassConfig,
|
|
6
|
+
interpolate,
|
|
7
|
+
isFlagEnabled,
|
|
8
|
+
parseExpected,
|
|
9
|
+
registerAllSteps,
|
|
10
|
+
registerApiAssertionSteps,
|
|
11
|
+
registerApiAuthSteps,
|
|
12
|
+
registerApiHttpSteps,
|
|
13
|
+
registerApiSteps,
|
|
14
|
+
registerCleanup,
|
|
15
|
+
registerDebugSteps,
|
|
16
|
+
registerFlagSteps,
|
|
17
|
+
registerFormSteps,
|
|
18
|
+
registerHybridSteps,
|
|
19
|
+
registerHybridSuite,
|
|
20
|
+
registerLayoutSteps,
|
|
21
|
+
registerSharedCleanupSteps,
|
|
22
|
+
registerSharedSteps,
|
|
23
|
+
registerSharedVarSteps,
|
|
24
|
+
registerTuiBasicSteps,
|
|
25
|
+
registerTuiSteps,
|
|
26
|
+
registerTuiWizardSteps,
|
|
27
|
+
registerUiAuthSteps,
|
|
28
|
+
registerUiBasicSteps,
|
|
29
|
+
registerUiSteps,
|
|
30
|
+
registerWizardSteps,
|
|
31
|
+
selectPath,
|
|
32
|
+
setFlag,
|
|
33
|
+
setupBearerAuth,
|
|
34
|
+
setupBypassAuth,
|
|
35
|
+
setupFetchIntercept,
|
|
36
|
+
tryParseJson
|
|
37
|
+
} from "./chunk-ACAXOGKZ.js";
|
|
38
|
+
|
|
39
|
+
// src/fixtures.ts
|
|
40
|
+
import { test as base } from "playwright-bdd";
|
|
41
|
+
import { expect as expect2 } from "@playwright/test";
|
|
42
|
+
|
|
43
|
+
// src/world.ts
|
|
44
|
+
function initWorld() {
|
|
45
|
+
return {
|
|
46
|
+
vars: {},
|
|
47
|
+
headers: {},
|
|
48
|
+
cleanup: []
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// src/network.ts
|
|
53
|
+
function resolveApiRequestTarget(baseURL, env = process.env) {
|
|
54
|
+
const flag = (env.KATALYST_XSPEC_FORCE_IPV4 ?? env.STACK_TESTS_FORCE_IPV4)?.trim().toLowerCase();
|
|
55
|
+
if (flag === "false" || flag === "0") return { baseURL };
|
|
56
|
+
let parsed;
|
|
57
|
+
try {
|
|
58
|
+
parsed = new URL(baseURL);
|
|
59
|
+
} catch {
|
|
60
|
+
return { baseURL };
|
|
61
|
+
}
|
|
62
|
+
if (parsed.protocol !== "http:" || !parsed.hostname.endsWith(".localhost")) {
|
|
63
|
+
return { baseURL };
|
|
64
|
+
}
|
|
65
|
+
const host = parsed.host;
|
|
66
|
+
parsed.hostname = "127.0.0.1";
|
|
67
|
+
let rewritten = parsed.toString();
|
|
68
|
+
if (!baseURL.endsWith("/") && rewritten.endsWith("/")) rewritten = rewritten.slice(0, -1);
|
|
69
|
+
return { baseURL: rewritten, extraHTTPHeaders: { Host: host } };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// src/adapters/api/playwright-api.adapter.ts
|
|
73
|
+
var PlaywrightApiAdapter = class {
|
|
74
|
+
constructor(request) {
|
|
75
|
+
this.request = request;
|
|
76
|
+
}
|
|
77
|
+
async sendJson(method, path, body, headers) {
|
|
78
|
+
const opts = {
|
|
79
|
+
headers: { Accept: "application/json", ...headers || {} }
|
|
80
|
+
};
|
|
81
|
+
if (body !== void 0) {
|
|
82
|
+
opts.data = body;
|
|
83
|
+
opts.headers["Content-Type"] = "application/json";
|
|
84
|
+
}
|
|
85
|
+
const resp = await this.request.fetch(path, { method, ...opts });
|
|
86
|
+
const text = await resp.text();
|
|
87
|
+
const respHeaders = resp.headers();
|
|
88
|
+
const contentType = respHeaders["content-type"] || "";
|
|
89
|
+
const json = contentType.includes("application/json") ? tryParseJson(text) : tryParseJson(text);
|
|
90
|
+
return {
|
|
91
|
+
status: resp.status(),
|
|
92
|
+
text,
|
|
93
|
+
json,
|
|
94
|
+
headers: respHeaders,
|
|
95
|
+
contentType,
|
|
96
|
+
response: resp
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
async sendForm(method, path, form, headers) {
|
|
100
|
+
const body = new URLSearchParams(form).toString();
|
|
101
|
+
const resp = await this.request.fetch(path, {
|
|
102
|
+
method,
|
|
103
|
+
headers: {
|
|
104
|
+
Accept: "application/json",
|
|
105
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
106
|
+
...headers || {}
|
|
107
|
+
},
|
|
108
|
+
data: body
|
|
109
|
+
});
|
|
110
|
+
const text = await resp.text();
|
|
111
|
+
const respHeaders = resp.headers();
|
|
112
|
+
const contentType = respHeaders["content-type"] || "";
|
|
113
|
+
const json = contentType.includes("application/json") ? tryParseJson(text) : tryParseJson(text);
|
|
114
|
+
return {
|
|
115
|
+
status: resp.status(),
|
|
116
|
+
text,
|
|
117
|
+
json,
|
|
118
|
+
headers: respHeaders,
|
|
119
|
+
contentType,
|
|
120
|
+
response: resp
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// src/adapters/ui/playwright-ui.adapter.ts
|
|
126
|
+
import { expect } from "@playwright/test";
|
|
127
|
+
var PlaywrightUiAdapter = class {
|
|
128
|
+
constructor(page) {
|
|
129
|
+
this.page = page;
|
|
130
|
+
}
|
|
131
|
+
async goto(path) {
|
|
132
|
+
await this.page.goto(path);
|
|
133
|
+
await this.page.waitForLoadState("domcontentloaded");
|
|
134
|
+
}
|
|
135
|
+
async clickButton(name) {
|
|
136
|
+
await this.page.getByRole("button", { name }).first().click();
|
|
137
|
+
}
|
|
138
|
+
async clickLink(name) {
|
|
139
|
+
await this.page.getByRole("link", { name }).first().click();
|
|
140
|
+
}
|
|
141
|
+
async fillPlaceholder(placeholder, value) {
|
|
142
|
+
await this.page.getByPlaceholder(placeholder).first().fill(value);
|
|
143
|
+
}
|
|
144
|
+
async fillLabel(label, value) {
|
|
145
|
+
await this.page.getByLabel(label).first().fill(value);
|
|
146
|
+
}
|
|
147
|
+
async expectText(text) {
|
|
148
|
+
await expect(this.page.getByText(text).first()).toBeVisible();
|
|
149
|
+
}
|
|
150
|
+
async expectUrlContains(part) {
|
|
151
|
+
await expect(this.page).toHaveURL(new RegExp(part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
152
|
+
}
|
|
153
|
+
async goBack() {
|
|
154
|
+
await this.page.goBack();
|
|
155
|
+
}
|
|
156
|
+
async reload() {
|
|
157
|
+
await this.page.reload();
|
|
158
|
+
}
|
|
159
|
+
async waitSeconds(seconds) {
|
|
160
|
+
await this.page.waitForTimeout(seconds * 1e3);
|
|
161
|
+
}
|
|
162
|
+
async waitForPageLoad() {
|
|
163
|
+
await this.page.waitForLoadState("domcontentloaded");
|
|
164
|
+
await this.page.waitForLoadState("load");
|
|
165
|
+
try {
|
|
166
|
+
await this.page.waitForLoadState("networkidle", { timeout: 5e3 });
|
|
167
|
+
} catch {
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async getCurrentUrl() {
|
|
171
|
+
return this.page.url();
|
|
172
|
+
}
|
|
173
|
+
async zoomTo(scale) {
|
|
174
|
+
await this.page.evaluate((z) => {
|
|
175
|
+
document.documentElement.style.zoom = String(z);
|
|
176
|
+
}, scale);
|
|
177
|
+
}
|
|
178
|
+
async typeText(text) {
|
|
179
|
+
await this.page.keyboard.type(text);
|
|
180
|
+
}
|
|
181
|
+
async pressKey(key) {
|
|
182
|
+
await this.page.keyboard.press(key);
|
|
183
|
+
}
|
|
184
|
+
async clickElementThatContains(clickMode, elementType, text) {
|
|
185
|
+
const locator = this.page.locator(elementType).filter({ hasText: text }).first();
|
|
186
|
+
await this.performClick(locator, clickMode);
|
|
187
|
+
}
|
|
188
|
+
async clickElementWith(clickMode, ordinal, text, method) {
|
|
189
|
+
const idx = this.parseOrdinal(ordinal);
|
|
190
|
+
const locator = this.locatorBy(method, text).nth(idx);
|
|
191
|
+
await this.performClick(locator, clickMode);
|
|
192
|
+
}
|
|
193
|
+
async fillDropdown(value, dropdownLabel) {
|
|
194
|
+
const select = this.page.getByLabel(dropdownLabel).first();
|
|
195
|
+
try {
|
|
196
|
+
await select.selectOption({ label: value });
|
|
197
|
+
} catch {
|
|
198
|
+
await select.selectOption(value);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
async inputInElement(action, value, ordinal, text, method) {
|
|
202
|
+
const idx = this.parseOrdinal(ordinal);
|
|
203
|
+
const locator = this.locatorBy(method, text).nth(idx);
|
|
204
|
+
if (action === "type") {
|
|
205
|
+
await locator.type(value);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (action === "fill") {
|
|
209
|
+
await locator.fill(value);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
await locator.selectOption({ label: value });
|
|
214
|
+
} catch {
|
|
215
|
+
await locator.click({ force: true }).catch(() => {
|
|
216
|
+
});
|
|
217
|
+
await this.page.getByRole("option", { name: value }).first().click().catch(() => {
|
|
218
|
+
});
|
|
219
|
+
await this.page.getByText(value).first().click();
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
async expectUrl(mode, expected) {
|
|
223
|
+
this.assertUrlAgainst(this.page.url(), mode, expected);
|
|
224
|
+
}
|
|
225
|
+
async expectNewTabUrl(mode, expected) {
|
|
226
|
+
const context = this.page.context();
|
|
227
|
+
const alreadyOpen = context.pages().filter((p) => p !== this.page);
|
|
228
|
+
const newPage = alreadyOpen.length > 0 ? alreadyOpen[alreadyOpen.length - 1] : await context.waitForEvent("page", {
|
|
229
|
+
timeout: 1e4
|
|
230
|
+
});
|
|
231
|
+
await newPage.waitForLoadState("domcontentloaded");
|
|
232
|
+
this.assertUrlAgainst(newPage.url(), mode, expected);
|
|
233
|
+
}
|
|
234
|
+
async expectElementWithTextVisible(elementType, text, shouldBeVisible) {
|
|
235
|
+
const locator = this.page.locator(elementType).filter({ hasText: text });
|
|
236
|
+
if (shouldBeVisible) {
|
|
237
|
+
await expect(locator.first()).toBeVisible();
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (await locator.count() === 0) {
|
|
241
|
+
await expect(locator).toHaveCount(0);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
await expect(locator.first()).toBeHidden();
|
|
245
|
+
}
|
|
246
|
+
async expectElementState(ordinal, text, method, state) {
|
|
247
|
+
const idx = this.parseOrdinal(ordinal);
|
|
248
|
+
const locator = this.locatorBy(method, text).nth(idx);
|
|
249
|
+
await this.expectState(locator, state);
|
|
250
|
+
}
|
|
251
|
+
async expectElementStateWithin(ordinal, text, method, state, seconds) {
|
|
252
|
+
const idx = this.parseOrdinal(ordinal);
|
|
253
|
+
const locator = this.locatorBy(method, text).nth(idx);
|
|
254
|
+
await this.expectState(locator, state, { timeout: seconds * 1e3 });
|
|
255
|
+
}
|
|
256
|
+
parseOrdinal(ordinal) {
|
|
257
|
+
const n = Number.parseInt(String(ordinal), 10);
|
|
258
|
+
if (!Number.isFinite(n) || n < 1) {
|
|
259
|
+
throw new Error(`Invalid element ordinal '${ordinal}'. Expected 1,2,3...`);
|
|
260
|
+
}
|
|
261
|
+
return n - 1;
|
|
262
|
+
}
|
|
263
|
+
locatorBy(method, text) {
|
|
264
|
+
switch (method) {
|
|
265
|
+
case "text":
|
|
266
|
+
return this.page.getByText(text);
|
|
267
|
+
case "label":
|
|
268
|
+
return this.page.getByLabel(text);
|
|
269
|
+
case "placeholder":
|
|
270
|
+
return this.page.getByPlaceholder(text);
|
|
271
|
+
case "role":
|
|
272
|
+
return this.page.getByRole(text);
|
|
273
|
+
case "test ID":
|
|
274
|
+
return this.page.getByTestId(text);
|
|
275
|
+
case "alternative text":
|
|
276
|
+
return this.page.getByAltText(text);
|
|
277
|
+
case "title":
|
|
278
|
+
return this.page.getByTitle(text);
|
|
279
|
+
case "locator":
|
|
280
|
+
return this.page.locator(text);
|
|
281
|
+
default: {
|
|
282
|
+
const neverMethod = method;
|
|
283
|
+
throw new Error(`Unsupported locator method: ${neverMethod}`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
async performClick(locator, clickMode) {
|
|
288
|
+
if (clickMode === "dispatch click") {
|
|
289
|
+
await locator.dispatchEvent("click");
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (clickMode === "force click") {
|
|
293
|
+
await locator.click({ force: true });
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (clickMode === "force dispatch click") {
|
|
297
|
+
await locator.dispatchEvent("click");
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
await locator.click();
|
|
301
|
+
}
|
|
302
|
+
async expectState(locator, state, options) {
|
|
303
|
+
if (state === "visible") {
|
|
304
|
+
await expect(locator).toBeVisible(options);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (state === "hidden") {
|
|
308
|
+
await expect(locator).toBeHidden(options);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (state === "editable") {
|
|
312
|
+
await expect(locator).toBeEditable(options);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (state === "disabled") {
|
|
316
|
+
await expect(locator).toBeDisabled(options);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (state === "enabled") {
|
|
320
|
+
await expect(locator).toBeEnabled(options);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (state === "read-only") {
|
|
324
|
+
await expect(locator).toHaveJSProperty("readOnly", true, options);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const neverState = state;
|
|
328
|
+
throw new Error(`Unsupported expected state: ${neverState}`);
|
|
329
|
+
}
|
|
330
|
+
assertUrlAgainst(actualUrl, mode, expected) {
|
|
331
|
+
const expectedStr = String(expected);
|
|
332
|
+
const assertFor = (actual) => {
|
|
333
|
+
if (mode === "contains") {
|
|
334
|
+
expect(actual).toContain(expectedStr);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (mode === "doesntContain") {
|
|
338
|
+
expect(actual).not.toContain(expectedStr);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (mode === "equals") {
|
|
342
|
+
expect(actual).toBe(expectedStr);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const neverMode = mode;
|
|
346
|
+
throw new Error(`Unsupported URL assertion mode: ${neverMode}`);
|
|
347
|
+
};
|
|
348
|
+
if (expectedStr.startsWith("/")) {
|
|
349
|
+
const u = new URL(actualUrl);
|
|
350
|
+
const actualPath = `${u.pathname}${u.search}${u.hash}`;
|
|
351
|
+
assertFor(actualPath);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
assertFor(actualUrl);
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
// src/adapters/auth/universal-auth.adapter.ts
|
|
359
|
+
var UniversalAuthAdapter = class {
|
|
360
|
+
constructor(deps) {
|
|
361
|
+
this.deps = deps;
|
|
362
|
+
}
|
|
363
|
+
apiSetBearer(world, token) {
|
|
364
|
+
world.headers = { ...world.headers || {}, Authorization: `Bearer ${token}` };
|
|
365
|
+
}
|
|
366
|
+
async apiLoginAsAdmin(world) {
|
|
367
|
+
const username = process.env.DEFAULT_ADMIN_USERNAME || process.env.DEFAULT_ADMIN_EMAIL;
|
|
368
|
+
const password = process.env.DEFAULT_ADMIN_PASSWORD;
|
|
369
|
+
if (!username || !password) {
|
|
370
|
+
console.warn("apiLoginAsAdmin skipped: DEFAULT_ADMIN_USERNAME and DEFAULT_ADMIN_PASSWORD are not set");
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
await this.apiLogin(world, username, password);
|
|
374
|
+
}
|
|
375
|
+
async apiLoginAsUser(world) {
|
|
376
|
+
const username = process.env.DEFAULT_USER_USERNAME || process.env.NON_ADMIN_USERNAME;
|
|
377
|
+
const password = process.env.DEFAULT_USER_PASSWORD || process.env.NON_ADMIN_PASSWORD;
|
|
378
|
+
if (!username || !password) {
|
|
379
|
+
console.warn("apiLoginAsUser skipped: DEFAULT_USER_USERNAME and DEFAULT_USER_PASSWORD are not set");
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
await this.apiLogin(world, username, password);
|
|
383
|
+
}
|
|
384
|
+
async apiLogin(world, username, password) {
|
|
385
|
+
const loginPath = process.env.API_AUTH_LOGIN_PATH || "/auth/login";
|
|
386
|
+
const result = await this.deps.api.sendForm("POST", loginPath, { username, password });
|
|
387
|
+
world.lastStatus = result.status;
|
|
388
|
+
world.lastText = result.text;
|
|
389
|
+
world.lastJson = result.json;
|
|
390
|
+
world.lastHeaders = result.headers;
|
|
391
|
+
const json = result.json || {};
|
|
392
|
+
const token = json?.access_token;
|
|
393
|
+
if (typeof token !== "string" || !token) {
|
|
394
|
+
throw new Error(`API login failed for ${username}: ${result.status} ${result.text}`);
|
|
395
|
+
}
|
|
396
|
+
this.apiSetBearer(world, token);
|
|
397
|
+
}
|
|
398
|
+
async uiLoginAsAdmin(world) {
|
|
399
|
+
const username = process.env.DEFAULT_ADMIN_USERNAME || process.env.DEFAULT_ADMIN_EMAIL;
|
|
400
|
+
const password = process.env.DEFAULT_ADMIN_PASSWORD;
|
|
401
|
+
if (!username || !password) {
|
|
402
|
+
console.warn("uiLoginAsAdmin skipped: DEFAULT_ADMIN_USERNAME and DEFAULT_ADMIN_PASSWORD are not set");
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
await this.uiLogin(world, username, password);
|
|
406
|
+
}
|
|
407
|
+
async uiLoginAsUser(world) {
|
|
408
|
+
const username = process.env.DEFAULT_USER_USERNAME || process.env.NON_ADMIN_USERNAME;
|
|
409
|
+
const password = process.env.DEFAULT_USER_PASSWORD || process.env.NON_ADMIN_PASSWORD;
|
|
410
|
+
if (!username || !password) {
|
|
411
|
+
console.warn("uiLoginAsUser skipped: DEFAULT_USER_USERNAME and DEFAULT_USER_PASSWORD are not set");
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
await this.uiLogin(world, username, password);
|
|
415
|
+
}
|
|
416
|
+
async uiLogin(_world, username, password) {
|
|
417
|
+
const loginPath = process.env.UI_LOGIN_PATH || "/login";
|
|
418
|
+
const usernameField = process.env.UI_USERNAME_FIELD || "Username";
|
|
419
|
+
const passwordField = process.env.UI_PASSWORD_FIELD || "Password";
|
|
420
|
+
const loginButton = process.env.UI_LOGIN_BUTTON || "Login";
|
|
421
|
+
await this.deps.ui.goto(loginPath);
|
|
422
|
+
await this.deps.ui.fillPlaceholder(usernameField, username);
|
|
423
|
+
await this.deps.ui.fillPlaceholder(passwordField, password);
|
|
424
|
+
await this.deps.ui.clickButton(loginButton);
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
// src/adapters/cleanup/default-cleanup.adapter.ts
|
|
429
|
+
function looksTesty(meta) {
|
|
430
|
+
const s = typeof meta === "string" ? meta : JSON.stringify(meta ?? "");
|
|
431
|
+
return /__|test/i.test(s);
|
|
432
|
+
}
|
|
433
|
+
function isIdLike(value) {
|
|
434
|
+
const uuidPattern = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
435
|
+
const prefixedIdPattern = /^[a-z]+_[a-zA-Z0-9_-]+$/;
|
|
436
|
+
const numericPattern = /^\d+$/;
|
|
437
|
+
const objectIdPattern = /^[0-9a-fA-F]{24}$/;
|
|
438
|
+
const cuidPattern = /^c[a-z0-9]{24,}$/;
|
|
439
|
+
const ulidPattern = /^[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
440
|
+
return uuidPattern.test(value) || prefixedIdPattern.test(value) || numericPattern.test(value) || objectIdPattern.test(value) || cuidPattern.test(value) || ulidPattern.test(value);
|
|
441
|
+
}
|
|
442
|
+
function matchVar(rule, varNameLower) {
|
|
443
|
+
const m = rule.varMatch;
|
|
444
|
+
if (m.startsWith("/") && m.endsWith("/") && m.length > 2) {
|
|
445
|
+
const re = new RegExp(m.slice(1, -1));
|
|
446
|
+
return re.test(varNameLower);
|
|
447
|
+
}
|
|
448
|
+
return varNameLower.includes(m.toLowerCase());
|
|
449
|
+
}
|
|
450
|
+
function loadRulesFromEnv() {
|
|
451
|
+
const raw = process.env.CLEANUP_RULES?.trim();
|
|
452
|
+
if (!raw) return [];
|
|
453
|
+
try {
|
|
454
|
+
const parsed = JSON.parse(raw);
|
|
455
|
+
if (Array.isArray(parsed)) {
|
|
456
|
+
return parsed.filter((x) => x && typeof x === "object").map((x) => ({
|
|
457
|
+
varMatch: String(x.varMatch ?? ""),
|
|
458
|
+
method: x.method ? String(x.method).toUpperCase() : void 0,
|
|
459
|
+
path: String(x.path ?? ""),
|
|
460
|
+
body: x.body
|
|
461
|
+
})).filter((r) => r.varMatch && r.path);
|
|
462
|
+
}
|
|
463
|
+
} catch {
|
|
464
|
+
}
|
|
465
|
+
return [];
|
|
466
|
+
}
|
|
467
|
+
var defaultRules = [];
|
|
468
|
+
var DefaultCleanupAdapter = class {
|
|
469
|
+
constructor(input) {
|
|
470
|
+
const fromEnv = loadRulesFromEnv();
|
|
471
|
+
this.rules = input?.rules && input.rules.length ? input.rules : [...fromEnv, ...defaultRules];
|
|
472
|
+
this.allowHeuristic = input?.allowHeuristic ?? /^(1|true|yes|on)$/i.test(process.env.CLEANUP_ALLOW_ALL || "");
|
|
473
|
+
}
|
|
474
|
+
registerFromVar(world, varName, id, meta) {
|
|
475
|
+
if (!id) return;
|
|
476
|
+
const idStr = String(id);
|
|
477
|
+
if (!isIdLike(idStr)) return;
|
|
478
|
+
if (!this.allowHeuristic) {
|
|
479
|
+
if (!looksTesty(meta) && !looksTesty(varName)) return;
|
|
480
|
+
}
|
|
481
|
+
const name = (varName || "").toLowerCase();
|
|
482
|
+
const rule = this.rules.find((r) => matchVar(r, name));
|
|
483
|
+
if (!rule) return;
|
|
484
|
+
const path = rule.path.replace(/\{id\}/g, idStr);
|
|
485
|
+
registerCleanup(world, { method: rule.method ?? "DELETE", path, body: rule.body });
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
// src/fixtures.ts
|
|
490
|
+
var cachedAdminToken;
|
|
491
|
+
async function defaultGetAdminHeaders(request) {
|
|
492
|
+
if (cachedAdminToken) {
|
|
493
|
+
return { Authorization: `Bearer ${cachedAdminToken}` };
|
|
494
|
+
}
|
|
495
|
+
const staticToken = process.env.CLEANUP_AUTH_TOKEN;
|
|
496
|
+
if (staticToken) {
|
|
497
|
+
cachedAdminToken = staticToken;
|
|
498
|
+
return { Authorization: `Bearer ${staticToken}` };
|
|
499
|
+
}
|
|
500
|
+
const username = process.env.DEFAULT_ADMIN_USERNAME || process.env.DEFAULT_ADMIN_EMAIL;
|
|
501
|
+
const password = process.env.DEFAULT_ADMIN_PASSWORD;
|
|
502
|
+
if (!username || !password) {
|
|
503
|
+
return {};
|
|
504
|
+
}
|
|
505
|
+
const loginPath = process.env.API_AUTH_LOGIN_PATH || "/auth/login";
|
|
506
|
+
const body = new URLSearchParams({ username, password }).toString();
|
|
507
|
+
const resp = await request.post(loginPath, {
|
|
508
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
509
|
+
data: body
|
|
510
|
+
});
|
|
511
|
+
if (!resp.ok()) {
|
|
512
|
+
console.warn(`cleanup auth failed: ${resp.status()} ${resp.statusText()}`);
|
|
513
|
+
cachedAdminToken = void 0;
|
|
514
|
+
return {};
|
|
515
|
+
}
|
|
516
|
+
const json = await resp.json();
|
|
517
|
+
const token = json?.access_token;
|
|
518
|
+
if (typeof token === "string" && token) {
|
|
519
|
+
cachedAdminToken = token;
|
|
520
|
+
return { Authorization: `Bearer ${token}` };
|
|
521
|
+
}
|
|
522
|
+
console.warn("cleanup auth: no access_token in response");
|
|
523
|
+
cachedAdminToken = void 0;
|
|
524
|
+
return {};
|
|
525
|
+
}
|
|
526
|
+
function createBddTest(options = {}) {
|
|
527
|
+
const {
|
|
528
|
+
createApi = ({ apiRequest }) => new PlaywrightApiAdapter(apiRequest),
|
|
529
|
+
createUi = ({ page }) => new PlaywrightUiAdapter(page),
|
|
530
|
+
createAuth = ({ api, ui }) => new UniversalAuthAdapter({ api, ui }),
|
|
531
|
+
createCleanup = () => new DefaultCleanupAdapter(),
|
|
532
|
+
getCleanupAuth = defaultGetAdminHeaders,
|
|
533
|
+
createTui,
|
|
534
|
+
worldFactory = initWorld
|
|
535
|
+
} = options;
|
|
536
|
+
const test = base.extend({
|
|
537
|
+
world: async ({ apiRequest }, use) => {
|
|
538
|
+
const w = worldFactory();
|
|
539
|
+
await use(w);
|
|
540
|
+
if (w.skipCleanup) return;
|
|
541
|
+
if (!w.cleanup.length) return;
|
|
542
|
+
for (const item of [...w.cleanup].reverse()) {
|
|
543
|
+
const adminHeaders = await getCleanupAuth(apiRequest);
|
|
544
|
+
const headers = { ...adminHeaders, ...item.headers || {} };
|
|
545
|
+
if (item.body !== void 0) {
|
|
546
|
+
headers["Content-Type"] = "application/json";
|
|
547
|
+
}
|
|
548
|
+
try {
|
|
549
|
+
const resp = await apiRequest.fetch(item.path, {
|
|
550
|
+
method: item.method,
|
|
551
|
+
headers,
|
|
552
|
+
data: item.body
|
|
553
|
+
});
|
|
554
|
+
const status = resp.status();
|
|
555
|
+
if (status === 401 || status === 403) {
|
|
556
|
+
cachedAdminToken = void 0;
|
|
557
|
+
console.warn(`cleanup auth expired (${status}) for ${item.method} ${item.path}`);
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
if (status >= 400 && status !== 404) {
|
|
561
|
+
console.warn(`cleanup ${item.method} ${item.path} failed`, status);
|
|
562
|
+
}
|
|
563
|
+
} catch (err) {
|
|
564
|
+
console.warn("cleanup error", item.method, item.path, err);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
},
|
|
568
|
+
apiRequest: async ({ playwright }, use, testInfo) => {
|
|
569
|
+
const projectName = String(testInfo.project.name || "");
|
|
570
|
+
const baseURLFromProject = testInfo.project.use?.baseURL;
|
|
571
|
+
const baseURL = process.env.API_BASE_URL || process.env.TARGET_BASE_URL || (projectName.includes("api") ? baseURLFromProject : void 0) || (process.env.TARGET_PORT ? `http://localhost:${process.env.TARGET_PORT}` : void 0) || "http://localhost:3000";
|
|
572
|
+
const ctx = await playwright.request.newContext(resolveApiRequestTarget(baseURL));
|
|
573
|
+
try {
|
|
574
|
+
await use(ctx);
|
|
575
|
+
} finally {
|
|
576
|
+
await ctx.dispose();
|
|
577
|
+
}
|
|
578
|
+
},
|
|
579
|
+
api: async ({ apiRequest }, use) => {
|
|
580
|
+
await use(createApi({ apiRequest }));
|
|
581
|
+
},
|
|
582
|
+
cleanup: async ({ apiRequest }, use) => {
|
|
583
|
+
await use(createCleanup({ apiRequest }));
|
|
584
|
+
},
|
|
585
|
+
ui: async ({ page }, use) => {
|
|
586
|
+
await use(createUi({ page }));
|
|
587
|
+
},
|
|
588
|
+
auth: async ({ api, ui }, use) => {
|
|
589
|
+
await use(createAuth({ api, ui }));
|
|
590
|
+
},
|
|
591
|
+
/**
|
|
592
|
+
* TUI fixture for terminal user interface testing.
|
|
593
|
+
* Automatically stops the TUI application after the test completes.
|
|
594
|
+
*/
|
|
595
|
+
tui: async ({}, use) => {
|
|
596
|
+
const tuiAdapter = createTui?.();
|
|
597
|
+
await use(tuiAdapter);
|
|
598
|
+
if (tuiAdapter?.isRunning?.()) {
|
|
599
|
+
try {
|
|
600
|
+
await tuiAdapter.stop();
|
|
601
|
+
} catch (error) {
|
|
602
|
+
console.warn("Error stopping TUI adapter:", error);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
return { test, expect: expect2 };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// src/adapters/tui/tui-tester.adapter.ts
|
|
611
|
+
var DEFAULT_WAIT_OPTIONS = {
|
|
612
|
+
timeout: 3e4,
|
|
613
|
+
interval: 100
|
|
614
|
+
};
|
|
615
|
+
var KEY_MAP = {
|
|
616
|
+
enter: "enter",
|
|
617
|
+
return: "enter",
|
|
618
|
+
tab: "tab",
|
|
619
|
+
escape: "escape",
|
|
620
|
+
esc: "escape",
|
|
621
|
+
backspace: "backspace",
|
|
622
|
+
delete: "delete",
|
|
623
|
+
up: "up",
|
|
624
|
+
down: "down",
|
|
625
|
+
left: "left",
|
|
626
|
+
right: "right",
|
|
627
|
+
home: "home",
|
|
628
|
+
end: "end",
|
|
629
|
+
pageup: "pageup",
|
|
630
|
+
pagedown: "pagedown",
|
|
631
|
+
space: "space",
|
|
632
|
+
f1: "f1",
|
|
633
|
+
f2: "f2",
|
|
634
|
+
f3: "f3",
|
|
635
|
+
f4: "f4",
|
|
636
|
+
f5: "f5",
|
|
637
|
+
f6: "f6",
|
|
638
|
+
f7: "f7",
|
|
639
|
+
f8: "f8",
|
|
640
|
+
f9: "f9",
|
|
641
|
+
f10: "f10",
|
|
642
|
+
f11: "f11",
|
|
643
|
+
f12: "f12"
|
|
644
|
+
};
|
|
645
|
+
var TuiTesterAdapter = class {
|
|
646
|
+
constructor(config) {
|
|
647
|
+
this.tester = null;
|
|
648
|
+
this.running = false;
|
|
649
|
+
this.tuiTesterModule = null;
|
|
650
|
+
this.config = {
|
|
651
|
+
size: { cols: 80, rows: 24 },
|
|
652
|
+
...config
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Lazily load the tui-tester module to support optional dependency
|
|
657
|
+
*/
|
|
658
|
+
async loadTuiTester() {
|
|
659
|
+
if (this.tuiTesterModule) {
|
|
660
|
+
return this.tuiTesterModule;
|
|
661
|
+
}
|
|
662
|
+
try {
|
|
663
|
+
const module = await import("tui-tester");
|
|
664
|
+
this.tuiTesterModule = module;
|
|
665
|
+
return module;
|
|
666
|
+
} catch (error) {
|
|
667
|
+
throw new Error(
|
|
668
|
+
"tui-tester is not installed. Please install it with: npm install tui-tester\nAlso ensure tmux is installed on your system."
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Get the tester instance, throwing if not started
|
|
674
|
+
*/
|
|
675
|
+
getTester() {
|
|
676
|
+
if (!this.tester) {
|
|
677
|
+
throw new Error("TUI tester not started. Call start() first.");
|
|
678
|
+
}
|
|
679
|
+
return this.tester;
|
|
680
|
+
}
|
|
681
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
682
|
+
// Lifecycle Methods
|
|
683
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
684
|
+
async start() {
|
|
685
|
+
if (this.running) {
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
const { TmuxTester } = await this.loadTuiTester();
|
|
689
|
+
this.tester = new TmuxTester({
|
|
690
|
+
command: this.config.command,
|
|
691
|
+
size: this.config.size,
|
|
692
|
+
cwd: this.config.cwd,
|
|
693
|
+
env: this.config.env,
|
|
694
|
+
debug: this.config.debug,
|
|
695
|
+
shell: this.config.shell,
|
|
696
|
+
snapshotDir: this.config.snapshotDir
|
|
697
|
+
});
|
|
698
|
+
await this.tester.start();
|
|
699
|
+
this.running = true;
|
|
700
|
+
}
|
|
701
|
+
async stop() {
|
|
702
|
+
if (!this.running || !this.tester) {
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
try {
|
|
706
|
+
await this.tester.stop();
|
|
707
|
+
} finally {
|
|
708
|
+
this.running = false;
|
|
709
|
+
this.tester = null;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
async restart() {
|
|
713
|
+
await this.stop();
|
|
714
|
+
await this.start();
|
|
715
|
+
}
|
|
716
|
+
isRunning() {
|
|
717
|
+
return this.running && this.tester !== null;
|
|
718
|
+
}
|
|
719
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
720
|
+
// Input Methods
|
|
721
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
722
|
+
async typeText(text, options) {
|
|
723
|
+
const tester = this.getTester();
|
|
724
|
+
await tester.typeText(text, options?.delay);
|
|
725
|
+
}
|
|
726
|
+
async pressKey(key, modifiers) {
|
|
727
|
+
const tester = this.getTester();
|
|
728
|
+
const normalizedKey = KEY_MAP[key.toLowerCase()] || key.toLowerCase();
|
|
729
|
+
await tester.sendKey(normalizedKey, {
|
|
730
|
+
ctrl: modifiers?.ctrl,
|
|
731
|
+
alt: modifiers?.alt,
|
|
732
|
+
shift: modifiers?.shift
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
async sendText(text) {
|
|
736
|
+
const tester = this.getTester();
|
|
737
|
+
await tester.sendText(text);
|
|
738
|
+
}
|
|
739
|
+
async fillField(fieldLabel, value) {
|
|
740
|
+
const tester = this.getTester();
|
|
741
|
+
await tester.waitForText(fieldLabel, { timeout: DEFAULT_WAIT_OPTIONS.timeout });
|
|
742
|
+
await tester.typeText(value);
|
|
743
|
+
}
|
|
744
|
+
async selectOption(option) {
|
|
745
|
+
const tester = this.getTester();
|
|
746
|
+
await tester.waitForText(option, { timeout: DEFAULT_WAIT_OPTIONS.timeout });
|
|
747
|
+
await tester.sendKey("enter");
|
|
748
|
+
}
|
|
749
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
750
|
+
// Mouse Methods
|
|
751
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
752
|
+
async sendMouse(event) {
|
|
753
|
+
const tester = this.getTester();
|
|
754
|
+
await tester.sendMouse({
|
|
755
|
+
type: event.type,
|
|
756
|
+
position: event.position,
|
|
757
|
+
button: event.button
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
async click(x, y, button = "left") {
|
|
761
|
+
await this.sendMouse({
|
|
762
|
+
type: "click",
|
|
763
|
+
position: { x, y },
|
|
764
|
+
button
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
async clickOnText(text) {
|
|
768
|
+
const tester = this.getTester();
|
|
769
|
+
const lines = await tester.getScreenLines();
|
|
770
|
+
let found = false;
|
|
771
|
+
for (let y = 0; y < lines.length; y++) {
|
|
772
|
+
const x = lines[y].indexOf(text);
|
|
773
|
+
if (x !== -1) {
|
|
774
|
+
await this.click(x, y);
|
|
775
|
+
found = true;
|
|
776
|
+
break;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
if (!found) {
|
|
780
|
+
throw new Error(`Text "${text}" not found on screen`);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
784
|
+
// Assertion Methods
|
|
785
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
786
|
+
async expectText(text, options) {
|
|
787
|
+
const tester = this.getTester();
|
|
788
|
+
const opts = { ...DEFAULT_WAIT_OPTIONS, ...options };
|
|
789
|
+
await tester.waitForText(text, { timeout: opts.timeout });
|
|
790
|
+
}
|
|
791
|
+
async expectPattern(pattern, options) {
|
|
792
|
+
const tester = this.getTester();
|
|
793
|
+
const opts = { ...DEFAULT_WAIT_OPTIONS, ...options };
|
|
794
|
+
await tester.waitForPattern(pattern, { timeout: opts.timeout });
|
|
795
|
+
}
|
|
796
|
+
async expectNotText(text) {
|
|
797
|
+
const screenText = await this.getScreenText();
|
|
798
|
+
if (screenText.includes(text)) {
|
|
799
|
+
throw new Error(`Expected text "${text}" to NOT be present on screen, but it was found.`);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
async assertScreenContains(text) {
|
|
803
|
+
const tester = this.getTester();
|
|
804
|
+
await tester.assertScreenContains(text);
|
|
805
|
+
}
|
|
806
|
+
async assertScreenMatches(pattern) {
|
|
807
|
+
const screenText = await this.getScreenText();
|
|
808
|
+
if (!pattern.test(screenText)) {
|
|
809
|
+
throw new Error(`Screen content does not match pattern: ${pattern}
|
|
810
|
+
Screen content:
|
|
811
|
+
${screenText}`);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
815
|
+
// Wait Methods
|
|
816
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
817
|
+
async waitForText(text, options) {
|
|
818
|
+
await this.expectText(text, options);
|
|
819
|
+
}
|
|
820
|
+
async waitForPattern(pattern, options) {
|
|
821
|
+
await this.expectPattern(pattern, options);
|
|
822
|
+
}
|
|
823
|
+
async waitForReady() {
|
|
824
|
+
const tester = this.getTester();
|
|
825
|
+
await tester.sleep(500);
|
|
826
|
+
}
|
|
827
|
+
async waitSeconds(seconds) {
|
|
828
|
+
const tester = this.getTester();
|
|
829
|
+
await tester.sleep(seconds * 1e3);
|
|
830
|
+
}
|
|
831
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
832
|
+
// Screen Capture Methods
|
|
833
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
834
|
+
async captureScreen() {
|
|
835
|
+
const tester = this.getTester();
|
|
836
|
+
const capture = await tester.captureScreen();
|
|
837
|
+
const lines = capture.lines || await tester.getScreenLines();
|
|
838
|
+
return {
|
|
839
|
+
text: capture.text,
|
|
840
|
+
lines,
|
|
841
|
+
timestamp: Date.now(),
|
|
842
|
+
size: this.getSize()
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
async getScreenText() {
|
|
846
|
+
const tester = this.getTester();
|
|
847
|
+
return tester.getScreenText();
|
|
848
|
+
}
|
|
849
|
+
async getScreenLines() {
|
|
850
|
+
const tester = this.getTester();
|
|
851
|
+
return tester.getScreenLines();
|
|
852
|
+
}
|
|
853
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
854
|
+
// Snapshot Methods
|
|
855
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
856
|
+
async takeSnapshot(name) {
|
|
857
|
+
const tester = this.getTester();
|
|
858
|
+
await tester.takeSnapshot(name);
|
|
859
|
+
}
|
|
860
|
+
async matchSnapshot(name) {
|
|
861
|
+
const tester = this.getTester();
|
|
862
|
+
try {
|
|
863
|
+
const result = await tester.compareSnapshot(name);
|
|
864
|
+
return {
|
|
865
|
+
pass: result.pass,
|
|
866
|
+
diff: result.diff
|
|
867
|
+
};
|
|
868
|
+
} catch (error) {
|
|
869
|
+
if (error instanceof Error && error.message.includes("not found")) {
|
|
870
|
+
await this.takeSnapshot(name);
|
|
871
|
+
return { pass: true };
|
|
872
|
+
}
|
|
873
|
+
throw error;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
877
|
+
// Utility Methods
|
|
878
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
879
|
+
async clear() {
|
|
880
|
+
const tester = this.getTester();
|
|
881
|
+
await tester.clear();
|
|
882
|
+
}
|
|
883
|
+
async resize(size) {
|
|
884
|
+
const tester = this.getTester();
|
|
885
|
+
await tester.resize(size);
|
|
886
|
+
this.config.size = size;
|
|
887
|
+
}
|
|
888
|
+
getSize() {
|
|
889
|
+
if (this.tester) {
|
|
890
|
+
return this.tester.getSize();
|
|
891
|
+
}
|
|
892
|
+
return this.config.size || { cols: 80, rows: 24 };
|
|
893
|
+
}
|
|
894
|
+
getConfig() {
|
|
895
|
+
return { ...this.config };
|
|
896
|
+
}
|
|
897
|
+
};
|
|
898
|
+
|
|
899
|
+
// src/helpers/oidc-cleanup-auth.ts
|
|
900
|
+
var cachedOidcToken;
|
|
901
|
+
var cachedOidcHeaders;
|
|
902
|
+
function createOidcCleanupAuth(config = {}) {
|
|
903
|
+
return async (_request) => {
|
|
904
|
+
if (cachedOidcToken && cachedOidcHeaders) {
|
|
905
|
+
return cachedOidcHeaders;
|
|
906
|
+
}
|
|
907
|
+
const tokenUrl = config.tokenUrl || process.env.OIDC_TOKEN_URL;
|
|
908
|
+
const clientId = config.clientId || process.env.OIDC_CLIENT_ID;
|
|
909
|
+
const clientSecret = config.clientSecret || process.env.OIDC_CLIENT_SECRET;
|
|
910
|
+
const grantType = config.grantType || process.env.OIDC_GRANT_TYPE || "client_credentials";
|
|
911
|
+
const scope = config.scope || process.env.OIDC_SCOPE;
|
|
912
|
+
const username = config.username || process.env.OIDC_USERNAME;
|
|
913
|
+
const password = config.password || process.env.OIDC_PASSWORD;
|
|
914
|
+
if (!tokenUrl || !clientId) {
|
|
915
|
+
console.warn("OIDC cleanup auth skipped: OIDC_TOKEN_URL and OIDC_CLIENT_ID are required");
|
|
916
|
+
return {};
|
|
917
|
+
}
|
|
918
|
+
const params = {
|
|
919
|
+
client_id: clientId,
|
|
920
|
+
grant_type: grantType
|
|
921
|
+
};
|
|
922
|
+
if (clientSecret) params.client_secret = clientSecret;
|
|
923
|
+
if (scope) params.scope = scope;
|
|
924
|
+
if (username) params.username = username;
|
|
925
|
+
if (password) params.password = password;
|
|
926
|
+
try {
|
|
927
|
+
const resp = await fetch(tokenUrl, {
|
|
928
|
+
method: "POST",
|
|
929
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
930
|
+
body: new URLSearchParams(params)
|
|
931
|
+
});
|
|
932
|
+
if (!resp.ok) {
|
|
933
|
+
console.warn(`OIDC cleanup auth failed: ${resp.status} ${resp.statusText}`);
|
|
934
|
+
cachedOidcToken = void 0;
|
|
935
|
+
cachedOidcHeaders = void 0;
|
|
936
|
+
return {};
|
|
937
|
+
}
|
|
938
|
+
const json = await resp.json();
|
|
939
|
+
const token = json?.access_token;
|
|
940
|
+
if (typeof token === "string" && token) {
|
|
941
|
+
cachedOidcToken = token;
|
|
942
|
+
const headers = {
|
|
943
|
+
Authorization: `Bearer ${token}`
|
|
944
|
+
};
|
|
945
|
+
if (config.extraHeaders) {
|
|
946
|
+
Object.assign(headers, config.extraHeaders);
|
|
947
|
+
}
|
|
948
|
+
const envHeaders = process.env.OIDC_EXTRA_HEADERS;
|
|
949
|
+
if (envHeaders) {
|
|
950
|
+
try {
|
|
951
|
+
const parsed = JSON.parse(envHeaders);
|
|
952
|
+
if (parsed && typeof parsed === "object") {
|
|
953
|
+
Object.assign(headers, parsed);
|
|
954
|
+
}
|
|
955
|
+
} catch {
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
cachedOidcHeaders = headers;
|
|
959
|
+
return headers;
|
|
960
|
+
}
|
|
961
|
+
} catch (err) {
|
|
962
|
+
console.warn("OIDC cleanup auth error:", err);
|
|
963
|
+
}
|
|
964
|
+
cachedOidcToken = void 0;
|
|
965
|
+
cachedOidcHeaders = void 0;
|
|
966
|
+
return {};
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
function resetOidcCleanupAuth() {
|
|
970
|
+
cachedOidcToken = void 0;
|
|
971
|
+
cachedOidcHeaders = void 0;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// src/config.ts
|
|
975
|
+
function tagsForProject({ projectTag, extraTags, defaultExcludes = "not @Skip and not @ignore" }) {
|
|
976
|
+
if (extraTags) return `${defaultExcludes} and ${projectTag} and (${extraTags})`;
|
|
977
|
+
return `${defaultExcludes} and ${projectTag}`;
|
|
978
|
+
}
|
|
979
|
+
function resolveExtraTags(raw) {
|
|
980
|
+
const tagFilterRaw = raw?.trim();
|
|
981
|
+
if (!tagFilterRaw) return void 0;
|
|
982
|
+
const looksLikeExpression = /\s|@|\bnot\b|\band\b|\bor\b/.test(tagFilterRaw);
|
|
983
|
+
if (looksLikeExpression) return tagFilterRaw;
|
|
984
|
+
const parts = tagFilterRaw.split(",").map((s) => s.trim()).filter(Boolean).map((t) => t.startsWith("@") ? t : `@${t}`);
|
|
985
|
+
if (!parts.length) return void 0;
|
|
986
|
+
return parts.join(" or ");
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// src/workers.ts
|
|
990
|
+
import os from "os";
|
|
991
|
+
function resolveWorkers(options = {}) {
|
|
992
|
+
const { testType, ciWorkers = 1, defaultWorkers } = options;
|
|
993
|
+
if (testType === "tui") {
|
|
994
|
+
return 1;
|
|
995
|
+
}
|
|
996
|
+
const workersEnv = process.env.WORKERS?.trim();
|
|
997
|
+
if (workersEnv && workersEnv.toLowerCase() !== "auto") {
|
|
998
|
+
const parsed = parseInt(workersEnv, 10);
|
|
999
|
+
if (!isNaN(parsed) && parsed > 0) {
|
|
1000
|
+
return parsed;
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
if (process.env.CI) {
|
|
1004
|
+
return ciWorkers;
|
|
1005
|
+
}
|
|
1006
|
+
return defaultWorkers;
|
|
1007
|
+
}
|
|
1008
|
+
function getCpuCount() {
|
|
1009
|
+
return os.cpus().length;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// src/paths.ts
|
|
1013
|
+
function resolveFeatures(options = {}) {
|
|
1014
|
+
const { defaultDir = "features" } = options;
|
|
1015
|
+
const envValue = process.env.FEATURES_DIR?.trim();
|
|
1016
|
+
if (envValue) {
|
|
1017
|
+
return envValue;
|
|
1018
|
+
}
|
|
1019
|
+
return defaultDir;
|
|
1020
|
+
}
|
|
1021
|
+
function resolveSteps(options = {}) {
|
|
1022
|
+
const { defaultDir = "features/steps" } = options;
|
|
1023
|
+
const envValue = process.env.CUSTOM_STEPS_DIR?.trim();
|
|
1024
|
+
if (envValue) {
|
|
1025
|
+
return envValue;
|
|
1026
|
+
}
|
|
1027
|
+
return defaultDir;
|
|
1028
|
+
}
|
|
1029
|
+
function resolveBddPaths(options = {}) {
|
|
1030
|
+
const { tag, featuresOptions, stepsOptions } = options;
|
|
1031
|
+
const featuresDir = resolveFeatures(featuresOptions);
|
|
1032
|
+
const stepsDir = resolveSteps(stepsOptions);
|
|
1033
|
+
const features = tag ? `${featuresDir}/${tag}/**/*.feature` : `${featuresDir}/**/*.feature`;
|
|
1034
|
+
const steps = `${stepsDir}/**/*.ts`;
|
|
1035
|
+
return { features, steps };
|
|
1036
|
+
}
|
|
1037
|
+
export {
|
|
1038
|
+
DefaultCleanupAdapter,
|
|
1039
|
+
PlaywrightApiAdapter,
|
|
1040
|
+
PlaywrightUiAdapter,
|
|
1041
|
+
TuiTesterAdapter,
|
|
1042
|
+
UniversalAuthAdapter,
|
|
1043
|
+
assertMasked,
|
|
1044
|
+
base as baseTest,
|
|
1045
|
+
clearFetchIntercept,
|
|
1046
|
+
createBddTest,
|
|
1047
|
+
createOidcCleanupAuth,
|
|
1048
|
+
defaultBearerConfig,
|
|
1049
|
+
defaultBypassConfig,
|
|
1050
|
+
getCpuCount,
|
|
1051
|
+
initWorld,
|
|
1052
|
+
interpolate,
|
|
1053
|
+
isFlagEnabled,
|
|
1054
|
+
parseExpected,
|
|
1055
|
+
registerAllSteps,
|
|
1056
|
+
registerApiAssertionSteps,
|
|
1057
|
+
registerApiAuthSteps,
|
|
1058
|
+
registerApiHttpSteps,
|
|
1059
|
+
registerApiSteps,
|
|
1060
|
+
registerCleanup,
|
|
1061
|
+
registerDebugSteps,
|
|
1062
|
+
registerFlagSteps,
|
|
1063
|
+
registerFormSteps,
|
|
1064
|
+
registerHybridSteps,
|
|
1065
|
+
registerHybridSuite,
|
|
1066
|
+
registerLayoutSteps,
|
|
1067
|
+
registerSharedCleanupSteps,
|
|
1068
|
+
registerSharedSteps,
|
|
1069
|
+
registerSharedVarSteps,
|
|
1070
|
+
registerTuiBasicSteps,
|
|
1071
|
+
registerTuiSteps,
|
|
1072
|
+
registerTuiWizardSteps,
|
|
1073
|
+
registerUiAuthSteps,
|
|
1074
|
+
registerUiBasicSteps,
|
|
1075
|
+
registerUiSteps,
|
|
1076
|
+
registerWizardSteps,
|
|
1077
|
+
resetOidcCleanupAuth,
|
|
1078
|
+
resolveApiRequestTarget,
|
|
1079
|
+
resolveBddPaths,
|
|
1080
|
+
resolveExtraTags,
|
|
1081
|
+
resolveFeatures,
|
|
1082
|
+
resolveSteps,
|
|
1083
|
+
resolveWorkers,
|
|
1084
|
+
selectPath,
|
|
1085
|
+
setFlag,
|
|
1086
|
+
setupBearerAuth,
|
|
1087
|
+
setupBypassAuth,
|
|
1088
|
+
setupFetchIntercept,
|
|
1089
|
+
tagsForProject,
|
|
1090
|
+
tryParseJson
|
|
1091
|
+
};
|