@esimplicitylabs/katalyst-xspec 0.7.0 → 0.8.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/README.md +15 -1
- package/cli/init.cjs +74 -22
- package/cli/upgrade.cjs +21 -10
- package/dist/{chunk-UTTKW2SR.js → chunk-VHVH476W.js} +39 -6
- package/dist/index.d.ts +160 -8
- package/dist/index.js +323 -71
- package/dist/steps/index.js +1 -1
- package/package.json +2 -2
- package/skills/katalyst-bdd-architecture/SKILL.md +23 -38
- package/skills/katalyst-bdd-architecture/references/adapters.md +44 -25
- package/skills/katalyst-bdd-architecture/references/ports.md +16 -2
- package/skills/katalyst-bdd-create-test/SKILL.md +15 -5
- package/skills/katalyst-bdd-create-test/references/api-patterns.md +25 -16
- package/skills/katalyst-bdd-create-test/references/hybrid-patterns.md +30 -13
- package/skills/katalyst-bdd-create-test/references/ui-patterns.md +11 -0
- package/skills/katalyst-bdd-quickstart/SKILL.md +21 -17
- package/skills/katalyst-bdd-step-reference/SKILL.md +7 -4
- package/skills/katalyst-bdd-step-reference/references/api-steps.md +14 -6
- package/skills/katalyst-bdd-step-reference/references/shared-steps.md +2 -2
- package/skills/katalyst-bdd-step-reference/references/ui-steps.md +22 -1
- package/skills/katalyst-bdd-troubleshooting/SKILL.md +46 -37
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
apiLoginAsRole,
|
|
2
3
|
assertMasked,
|
|
3
4
|
clearFetchIntercept,
|
|
4
5
|
defaultBearerConfig,
|
|
@@ -33,8 +34,9 @@ import {
|
|
|
33
34
|
setupBearerAuth,
|
|
34
35
|
setupBypassAuth,
|
|
35
36
|
setupFetchIntercept,
|
|
36
|
-
tryParseJson
|
|
37
|
-
|
|
37
|
+
tryParseJson,
|
|
38
|
+
uiLoginAsRole
|
|
39
|
+
} from "./chunk-VHVH476W.js";
|
|
38
40
|
|
|
39
41
|
// src/fixtures.ts
|
|
40
42
|
import { test as base } from "playwright-bdd";
|
|
@@ -69,6 +71,110 @@ function resolveApiRequestTarget(baseURL, env = process.env) {
|
|
|
69
71
|
return { baseURL: rewritten, extraHTTPHeaders: { Host: host } };
|
|
70
72
|
}
|
|
71
73
|
|
|
74
|
+
// src/targets.ts
|
|
75
|
+
var DEFAULT_URL = "http://localhost:3000";
|
|
76
|
+
function resolveTargets(env = process.env) {
|
|
77
|
+
const frontend = env.FRONTEND_URL ? { frontendUrl: env.FRONTEND_URL, frontendSource: "FRONTEND_URL" } : env.BASE_URL ? { frontendUrl: env.BASE_URL, frontendSource: "BASE_URL" } : { frontendUrl: DEFAULT_URL, frontendSource: "default" };
|
|
78
|
+
const api = env.API_BASE_URL ? { apiBaseUrl: env.API_BASE_URL, apiSource: "API_BASE_URL" } : env.TARGET_BASE_URL ? { apiBaseUrl: env.TARGET_BASE_URL, apiSource: "TARGET_BASE_URL" } : env.TARGET_PORT ? { apiBaseUrl: `http://localhost:${env.TARGET_PORT}`, apiSource: "TARGET_PORT" } : frontend.frontendSource !== "default" ? { apiBaseUrl: frontend.frontendUrl, apiSource: "FRONTEND_URL" } : { apiBaseUrl: DEFAULT_URL, apiSource: "default" };
|
|
79
|
+
return { ...frontend, ...api };
|
|
80
|
+
}
|
|
81
|
+
function resolveApiBaseUrl({
|
|
82
|
+
env = process.env,
|
|
83
|
+
projectName = "",
|
|
84
|
+
projectBaseURL
|
|
85
|
+
}) {
|
|
86
|
+
return env.API_BASE_URL || env.TARGET_BASE_URL || (projectName.includes("api") ? projectBaseURL : void 0) || (env.TARGET_PORT ? `http://localhost:${env.TARGET_PORT}` : void 0) || projectBaseURL || DEFAULT_URL;
|
|
87
|
+
}
|
|
88
|
+
function formatTargets(t) {
|
|
89
|
+
return `katalyst-xspec targets: UI ${t.frontendUrl} (${t.frontendSource}) | API ${t.apiBaseUrl} (${t.apiSource})`;
|
|
90
|
+
}
|
|
91
|
+
function shouldLogTargets(env = process.env, argv = process.argv) {
|
|
92
|
+
const quiet = env.KATALYST_XSPEC_QUIET?.trim().toLowerCase();
|
|
93
|
+
if (quiet && quiet !== "false" && quiet !== "0") return false;
|
|
94
|
+
if (env.TEST_WORKER_INDEX !== void 0) return false;
|
|
95
|
+
if (argv.some((a) => /bddgen/.test(a))) return false;
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
function logTargets(targets = resolveTargets()) {
|
|
99
|
+
if (shouldLogTargets()) console.log(formatTargets(targets));
|
|
100
|
+
return targets;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// src/auth/credentials.ts
|
|
104
|
+
var MissingCredentialsError = class extends Error {
|
|
105
|
+
constructor(message) {
|
|
106
|
+
super(message);
|
|
107
|
+
this.name = "MissingCredentialsError";
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
function roleEnvKeys(role) {
|
|
111
|
+
const key = role.trim().toUpperCase().replace(/[^A-Z0-9]+/g, "_").replace(/^_|_$/g, "");
|
|
112
|
+
return { username: `AUTH_${key}_USERNAME`, password: `AUTH_${key}_PASSWORD` };
|
|
113
|
+
}
|
|
114
|
+
var LEGACY_KEYS = {
|
|
115
|
+
admin: { username: ["DEFAULT_ADMIN_USERNAME", "DEFAULT_ADMIN_EMAIL"], password: ["DEFAULT_ADMIN_PASSWORD"] },
|
|
116
|
+
user: { username: ["DEFAULT_USER_USERNAME", "NON_ADMIN_USERNAME"], password: ["DEFAULT_USER_PASSWORD", "NON_ADMIN_PASSWORD"] }
|
|
117
|
+
};
|
|
118
|
+
function resolveCredentials(role, { env = process.env, roles } = {}) {
|
|
119
|
+
const fromCode = roles && Object.entries(roles).find(([name]) => name.toLowerCase() === role.toLowerCase())?.[1];
|
|
120
|
+
const keys = roleEnvKeys(role);
|
|
121
|
+
const legacy = LEGACY_KEYS[role.toLowerCase()];
|
|
122
|
+
const pick = (primary, fallbacks = []) => [primary, ...fallbacks].map((k) => env[k]).find(Boolean);
|
|
123
|
+
const username = fromCode?.username || pick(keys.username, legacy?.username);
|
|
124
|
+
const password = fromCode?.password || pick(keys.password, legacy?.password);
|
|
125
|
+
if (username && password) return { username, password };
|
|
126
|
+
const missing = !username ? "username" : "password";
|
|
127
|
+
const alsoAccepted = legacy?.[missing]?.length ? ` (${legacy[missing].join(" or ")} also work)` : "";
|
|
128
|
+
throw new MissingCredentialsError(
|
|
129
|
+
`No ${missing} for role "${role}". Set ${keys[missing]} in .env${alsoAccepted}, or pass roles: { '${role}': { username, password } } to UniversalAuthAdapter.`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// src/auth/api-login.ts
|
|
134
|
+
var DEFAULT_TOKEN_PATHS = ["access_token", "token", "accessToken", "data.access_token", "data.token", "data.accessToken"];
|
|
135
|
+
function buildApiLoginRequest(creds, env = process.env) {
|
|
136
|
+
const body = (env.API_AUTH_BODY || "form").trim().toLowerCase();
|
|
137
|
+
if (body !== "form" && body !== "json") {
|
|
138
|
+
throw new Error(`API_AUTH_BODY must be "form" or "json" (got "${env.API_AUTH_BODY}")`);
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
path: env.API_AUTH_LOGIN_PATH || "/auth/login",
|
|
142
|
+
body,
|
|
143
|
+
fields: {
|
|
144
|
+
[env.API_AUTH_USERNAME_FIELD || "username"]: creds.username,
|
|
145
|
+
[env.API_AUTH_PASSWORD_FIELD || "password"]: creds.password
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
function extractToken(json, env = process.env) {
|
|
150
|
+
const paths = env.API_AUTH_TOKEN_PATH ? [env.API_AUTH_TOKEN_PATH] : DEFAULT_TOKEN_PATHS;
|
|
151
|
+
for (const p of paths) {
|
|
152
|
+
let value;
|
|
153
|
+
try {
|
|
154
|
+
value = selectPath(json, p);
|
|
155
|
+
} catch {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (typeof value === "string" && value) return value;
|
|
159
|
+
}
|
|
160
|
+
return void 0;
|
|
161
|
+
}
|
|
162
|
+
function apiLoginErrorMessage({
|
|
163
|
+
role,
|
|
164
|
+
path,
|
|
165
|
+
status,
|
|
166
|
+
text,
|
|
167
|
+
noToken = false
|
|
168
|
+
}) {
|
|
169
|
+
const keys = roleEnvKeys(role);
|
|
170
|
+
const body = text.length > 300 ? `${text.slice(0, 300)}\u2026` : text;
|
|
171
|
+
const head = noToken ? `API login as "${role}" failed: POST ${path} returned ${status} but no token found and no session cookie was set.` : `API login as "${role}" failed: POST ${path} returned ${status}.`;
|
|
172
|
+
const hint = noToken ? "Set API_AUTH_TOKEN_PATH to where the token is in the response (e.g. data.jwt)." : `Check ${keys.username} / ${keys.password}, API_AUTH_LOGIN_PATH, and API_AUTH_BODY (form|json) / API_AUTH_USERNAME_FIELD / API_AUTH_PASSWORD_FIELD.`;
|
|
173
|
+
return `${head}
|
|
174
|
+
Response: ${body || "(empty)"}
|
|
175
|
+
${hint}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
72
178
|
// src/adapters/api/playwright-api.adapter.ts
|
|
73
179
|
var PlaywrightApiAdapter = class {
|
|
74
180
|
constructor(request) {
|
|
@@ -284,6 +390,64 @@ var PlaywrightUiAdapter = class {
|
|
|
284
390
|
}
|
|
285
391
|
}
|
|
286
392
|
}
|
|
393
|
+
async fillField(name, value, options = {}) {
|
|
394
|
+
const candidates = [
|
|
395
|
+
this.page.getByLabel(name, { exact: true }),
|
|
396
|
+
this.page.getByPlaceholder(name, { exact: true }),
|
|
397
|
+
this.page.getByLabel(name),
|
|
398
|
+
this.page.getByPlaceholder(name),
|
|
399
|
+
this.page.locator(`[name="${name.replace(/"/g, '\\"')}"]`)
|
|
400
|
+
];
|
|
401
|
+
const any = candidates.reduce((acc, loc) => acc.or(loc));
|
|
402
|
+
try {
|
|
403
|
+
await any.first().waitFor({ state: "visible", timeout: options.timeoutMs ?? 1e4 });
|
|
404
|
+
} catch {
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
for (const loc of candidates) {
|
|
408
|
+
if (await loc.count() > 0) {
|
|
409
|
+
await loc.first().fill(value);
|
|
410
|
+
return true;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return false;
|
|
414
|
+
}
|
|
415
|
+
async waitForUrl(predicate, timeoutMs) {
|
|
416
|
+
try {
|
|
417
|
+
await this.page.waitForURL((u) => predicate(u.toString()), { timeout: timeoutMs });
|
|
418
|
+
return true;
|
|
419
|
+
} catch {
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
async waitForText(text, timeoutMs) {
|
|
424
|
+
try {
|
|
425
|
+
await this.page.getByText(text).first().waitFor({ state: "visible", timeout: timeoutMs });
|
|
426
|
+
return true;
|
|
427
|
+
} catch {
|
|
428
|
+
return false;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
async saveSession() {
|
|
432
|
+
return await this.page.context().storageState();
|
|
433
|
+
}
|
|
434
|
+
async restoreSession(state) {
|
|
435
|
+
const context = this.page.context();
|
|
436
|
+
if (state.cookies.length) await context.addCookies(state.cookies);
|
|
437
|
+
if (state.origins.some((o) => o.localStorage.length)) {
|
|
438
|
+
await context.addInitScript((origins) => {
|
|
439
|
+
try {
|
|
440
|
+
if (sessionStorage.getItem("__katalyst_xspec_session_restored")) return;
|
|
441
|
+
for (const o of origins) {
|
|
442
|
+
if (o.origin !== location.origin) continue;
|
|
443
|
+
for (const { name, value } of o.localStorage) localStorage.setItem(name, value);
|
|
444
|
+
}
|
|
445
|
+
sessionStorage.setItem("__katalyst_xspec_session_restored", "1");
|
|
446
|
+
} catch {
|
|
447
|
+
}
|
|
448
|
+
}, state.origins);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
287
451
|
async performClick(locator, clickMode) {
|
|
288
452
|
if (clickMode === "dispatch click") {
|
|
289
453
|
await locator.dispatchEvent("click");
|
|
@@ -356,72 +520,121 @@ var PlaywrightUiAdapter = class {
|
|
|
356
520
|
};
|
|
357
521
|
|
|
358
522
|
// src/adapters/auth/universal-auth.adapter.ts
|
|
523
|
+
var uiSessions = /* @__PURE__ */ new Map();
|
|
524
|
+
function clearUiSessions() {
|
|
525
|
+
uiSessions.clear();
|
|
526
|
+
}
|
|
359
527
|
var UniversalAuthAdapter = class {
|
|
360
|
-
constructor(
|
|
361
|
-
this.
|
|
528
|
+
constructor(options) {
|
|
529
|
+
this.api = options.api;
|
|
530
|
+
this.ui = options.ui;
|
|
531
|
+
this.roles = options.roles;
|
|
532
|
+
this.env = options.env ?? process.env;
|
|
533
|
+
}
|
|
534
|
+
/** Credentials for a role; throws MissingCredentialsError with the variables to set. */
|
|
535
|
+
credentialsFor(role) {
|
|
536
|
+
return resolveCredentials(role, { env: this.env, roles: this.roles });
|
|
362
537
|
}
|
|
363
538
|
apiSetBearer(world, token) {
|
|
364
539
|
world.headers = { ...world.headers || {}, Authorization: `Bearer ${token}` };
|
|
365
540
|
}
|
|
366
|
-
async
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
541
|
+
async apiLoginAs(world, role) {
|
|
542
|
+
await this.apiLogin(world, role, this.credentialsFor(role));
|
|
543
|
+
}
|
|
544
|
+
async uiLoginAs(world, role, options = {}) {
|
|
545
|
+
const creds = this.credentialsFor(role);
|
|
546
|
+
const reuse = options.reuseSession && this.sessionReuseEnabled();
|
|
547
|
+
const key = `${role.toLowerCase()}\0${creds.username}\0${this.env.UI_LOGIN_PATH || "/login"}`;
|
|
548
|
+
if (reuse && this.ui.restoreSession) {
|
|
549
|
+
const saved = uiSessions.get(key);
|
|
550
|
+
if (saved) {
|
|
551
|
+
await this.ui.restoreSession(saved);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
372
554
|
}
|
|
373
|
-
await this.
|
|
555
|
+
await this.uiLogin(world, role, creds);
|
|
556
|
+
if (reuse && this.ui.saveSession) uiSessions.set(key, await this.ui.saveSession());
|
|
374
557
|
}
|
|
375
|
-
|
|
376
|
-
|
|
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);
|
|
558
|
+
apiLoginAsAdmin(world) {
|
|
559
|
+
return this.apiLoginAs(world, "admin");
|
|
383
560
|
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
561
|
+
apiLoginAsUser(world) {
|
|
562
|
+
return this.apiLoginAs(world, "user");
|
|
563
|
+
}
|
|
564
|
+
uiLoginAsAdmin(world) {
|
|
565
|
+
return this.uiLoginAs(world, "admin");
|
|
566
|
+
}
|
|
567
|
+
uiLoginAsUser(world) {
|
|
568
|
+
return this.uiLoginAs(world, "user");
|
|
569
|
+
}
|
|
570
|
+
/** POST credentials to the login endpoint; keep the bearer token or session cookie. */
|
|
571
|
+
async apiLogin(world, role, creds) {
|
|
572
|
+
const req = buildApiLoginRequest(creds, this.env);
|
|
573
|
+
const result = req.body === "json" ? await this.api.sendJson("POST", req.path, req.fields) : await this.api.sendForm("POST", req.path, req.fields);
|
|
387
574
|
world.lastStatus = result.status;
|
|
388
575
|
world.lastText = result.text;
|
|
389
576
|
world.lastJson = result.json;
|
|
390
577
|
world.lastHeaders = result.headers;
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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");
|
|
578
|
+
const ok = result.status >= 200 && result.status < 300;
|
|
579
|
+
if (!ok) throw new Error(apiLoginErrorMessage({ role, path: req.path, status: result.status, text: result.text }));
|
|
580
|
+
const token = extractToken(result.json, this.env);
|
|
581
|
+
if (token) {
|
|
582
|
+
this.apiSetBearer(world, token);
|
|
403
583
|
return;
|
|
404
584
|
}
|
|
405
|
-
|
|
585
|
+
const setsCookie = Object.keys(result.headers || {}).some((h) => h.toLowerCase() === "set-cookie");
|
|
586
|
+
if (!setsCookie) {
|
|
587
|
+
throw new Error(apiLoginErrorMessage({ role, path: req.path, status: result.status, text: result.text, noToken: true }));
|
|
588
|
+
}
|
|
406
589
|
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
const
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
590
|
+
/** Fill and submit the login form, then confirm the login worked. */
|
|
591
|
+
async uiLogin(_world, role, creds) {
|
|
592
|
+
const loginPath = this.env.UI_LOGIN_PATH || "/login";
|
|
593
|
+
const usernameField = this.env.UI_USERNAME_FIELD || "Username";
|
|
594
|
+
const passwordField = this.env.UI_PASSWORD_FIELD || "Password";
|
|
595
|
+
const button = this.env.UI_LOGIN_BUTTON || "Login";
|
|
596
|
+
const timeoutMs = Number(this.env.UI_LOGIN_TIMEOUT) || 1e4;
|
|
597
|
+
const keys = roleEnvKeys(role);
|
|
598
|
+
await this.ui.goto(loginPath);
|
|
599
|
+
const loginUrl = await this.ui.getCurrentUrl();
|
|
600
|
+
if (this.ui.fillField) {
|
|
601
|
+
for (const [kind, field, value, setting] of [
|
|
602
|
+
["username", usernameField, creds.username, "UI_USERNAME_FIELD"],
|
|
603
|
+
["password", passwordField, creds.password, "UI_PASSWORD_FIELD"]
|
|
604
|
+
]) {
|
|
605
|
+
if (!await this.ui.fillField(field, value, { timeoutMs })) {
|
|
606
|
+
throw new Error(
|
|
607
|
+
`UI login as "${role}": No ${kind} field "${field}" on ${loginPath} (looked for a label, placeholder or name). Set ${setting} to the field's label or placeholder, or UI_LOGIN_PATH to the login page.`
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
} else {
|
|
612
|
+
await this.ui.fillPlaceholder(usernameField, creds.username);
|
|
613
|
+
await this.ui.fillPlaceholder(passwordField, creds.password);
|
|
614
|
+
}
|
|
615
|
+
await this.ui.clickButton(button);
|
|
616
|
+
const successUrl = this.env.UI_LOGIN_SUCCESS_URL;
|
|
617
|
+
const successText = this.env.UI_LOGIN_SUCCESS_TEXT;
|
|
618
|
+
let ok;
|
|
619
|
+
if (successText && this.ui.waitForText) {
|
|
620
|
+
ok = await this.ui.waitForText(successText, timeoutMs);
|
|
621
|
+
} else if (this.ui.waitForUrl) {
|
|
622
|
+
const loginPathname = new URL(loginUrl).pathname;
|
|
623
|
+
ok = await this.ui.waitForUrl(
|
|
624
|
+
(u) => successUrl ? u.includes(successUrl) : new URL(u).pathname !== loginPathname,
|
|
625
|
+
timeoutMs
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
if (ok === false) {
|
|
629
|
+
const expected = successText ? `for "${successText}" to appear` : successUrl ? `for a URL containing "${successUrl}"` : "to leave the login page";
|
|
630
|
+
throw new Error(
|
|
631
|
+
`UI login as "${role}" stayed on ${new URL(loginUrl).pathname} (waited ${timeoutMs}ms ${expected}). Check ${keys.username} / ${keys.password}. If your app doesn't navigate after login, set UI_LOGIN_SUCCESS_TEXT (text shown once logged in) or UI_LOGIN_SUCCESS_URL.`
|
|
632
|
+
);
|
|
413
633
|
}
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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);
|
|
634
|
+
}
|
|
635
|
+
sessionReuseEnabled() {
|
|
636
|
+
const v = this.env.UI_SESSION_REUSE?.trim().toLowerCase();
|
|
637
|
+
return v !== "false" && v !== "0";
|
|
425
638
|
}
|
|
426
639
|
};
|
|
427
640
|
|
|
@@ -497,32 +710,50 @@ async function defaultGetAdminHeaders(request) {
|
|
|
497
710
|
cachedAdminToken = staticToken;
|
|
498
711
|
return { Authorization: `Bearer ${staticToken}` };
|
|
499
712
|
}
|
|
500
|
-
const username = process.env.DEFAULT_ADMIN_USERNAME || process.env.DEFAULT_ADMIN_EMAIL;
|
|
501
|
-
const password = process.env.DEFAULT_ADMIN_PASSWORD;
|
|
713
|
+
const username = process.env.AUTH_ADMIN_USERNAME || process.env.DEFAULT_ADMIN_USERNAME || process.env.DEFAULT_ADMIN_EMAIL;
|
|
714
|
+
const password = process.env.AUTH_ADMIN_PASSWORD || process.env.DEFAULT_ADMIN_PASSWORD;
|
|
502
715
|
if (!username || !password) {
|
|
503
716
|
return {};
|
|
504
717
|
}
|
|
505
|
-
const
|
|
506
|
-
const
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
data: body
|
|
718
|
+
const req = buildApiLoginRequest({ username, password });
|
|
719
|
+
const resp = await request.post(req.path, {
|
|
720
|
+
headers: { Accept: "application/json" },
|
|
721
|
+
...req.body === "json" ? { data: req.fields } : { form: req.fields }
|
|
510
722
|
});
|
|
511
723
|
if (!resp.ok()) {
|
|
512
724
|
console.warn(`cleanup auth failed: ${resp.status()} ${resp.statusText()}`);
|
|
513
725
|
cachedAdminToken = void 0;
|
|
514
726
|
return {};
|
|
515
727
|
}
|
|
516
|
-
const json = await resp.json();
|
|
517
|
-
const token = json
|
|
518
|
-
if (
|
|
728
|
+
const json = await resp.json().catch(() => void 0);
|
|
729
|
+
const token = extractToken(json);
|
|
730
|
+
if (token) {
|
|
519
731
|
cachedAdminToken = token;
|
|
520
732
|
return { Authorization: `Bearer ${token}` };
|
|
521
733
|
}
|
|
522
|
-
|
|
734
|
+
if (resp.headers()["set-cookie"]) return {};
|
|
735
|
+
console.warn("cleanup auth: no token in login response (set API_AUTH_TOKEN_PATH)");
|
|
523
736
|
cachedAdminToken = void 0;
|
|
524
737
|
return {};
|
|
525
738
|
}
|
|
739
|
+
function lazyUi(binding) {
|
|
740
|
+
return new Proxy({}, {
|
|
741
|
+
get(_target, prop) {
|
|
742
|
+
const ui = binding.current;
|
|
743
|
+
if (!ui) {
|
|
744
|
+
if (prop === "then") return void 0;
|
|
745
|
+
throw new Error(
|
|
746
|
+
"UI login needs the browser: add `ui` to the step's fixtures, e.g. When('...', async ({ auth, ui, world }) => auth.uiLoginAs(world, 'admin'))."
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
const value = ui[prop];
|
|
750
|
+
return typeof value === "function" ? value.bind(ui) : value;
|
|
751
|
+
},
|
|
752
|
+
has(_target, prop) {
|
|
753
|
+
return binding.current ? prop in binding.current : false;
|
|
754
|
+
}
|
|
755
|
+
});
|
|
756
|
+
}
|
|
526
757
|
function createBddTest(options = {}) {
|
|
527
758
|
const {
|
|
528
759
|
createApi = ({ apiRequest }) => new PlaywrightApiAdapter(apiRequest),
|
|
@@ -534,6 +765,11 @@ function createBddTest(options = {}) {
|
|
|
534
765
|
worldFactory = initWorld
|
|
535
766
|
} = options;
|
|
536
767
|
const test = base.extend({
|
|
768
|
+
// Lets `auth` reach the UI adapter without depending on `page`, so API-only
|
|
769
|
+
// scenarios that log in never start a browser.
|
|
770
|
+
uiBinding: async ({}, use) => {
|
|
771
|
+
await use({});
|
|
772
|
+
},
|
|
537
773
|
world: async ({ apiRequest }, use) => {
|
|
538
774
|
const w = worldFactory();
|
|
539
775
|
await use(w);
|
|
@@ -566,9 +802,10 @@ function createBddTest(options = {}) {
|
|
|
566
802
|
}
|
|
567
803
|
},
|
|
568
804
|
apiRequest: async ({ playwright }, use, testInfo) => {
|
|
569
|
-
const
|
|
570
|
-
|
|
571
|
-
|
|
805
|
+
const baseURL = resolveApiBaseUrl({
|
|
806
|
+
projectName: String(testInfo.project.name || ""),
|
|
807
|
+
projectBaseURL: testInfo.project.use?.baseURL
|
|
808
|
+
});
|
|
572
809
|
const ctx = await playwright.request.newContext(resolveApiRequestTarget(baseURL));
|
|
573
810
|
try {
|
|
574
811
|
await use(ctx);
|
|
@@ -582,11 +819,13 @@ function createBddTest(options = {}) {
|
|
|
582
819
|
cleanup: async ({ apiRequest }, use) => {
|
|
583
820
|
await use(createCleanup({ apiRequest }));
|
|
584
821
|
},
|
|
585
|
-
ui: async ({ page }, use) => {
|
|
586
|
-
|
|
822
|
+
ui: async ({ page, uiBinding }, use) => {
|
|
823
|
+
const ui = createUi({ page });
|
|
824
|
+
uiBinding.current = ui;
|
|
825
|
+
await use(ui);
|
|
587
826
|
},
|
|
588
|
-
auth: async ({ api,
|
|
589
|
-
await use(createAuth({ api, ui }));
|
|
827
|
+
auth: async ({ api, apiRequest, uiBinding }, use) => {
|
|
828
|
+
await use(createAuth({ api, apiRequest, ui: lazyUi(uiBinding) }));
|
|
590
829
|
},
|
|
591
830
|
/**
|
|
592
831
|
* TUI fixture for terminal user interface testing.
|
|
@@ -1035,21 +1274,28 @@ function resolveBddPaths(options = {}) {
|
|
|
1035
1274
|
}
|
|
1036
1275
|
export {
|
|
1037
1276
|
DefaultCleanupAdapter,
|
|
1277
|
+
MissingCredentialsError,
|
|
1038
1278
|
PlaywrightApiAdapter,
|
|
1039
1279
|
PlaywrightUiAdapter,
|
|
1040
1280
|
TuiTesterAdapter,
|
|
1041
1281
|
UniversalAuthAdapter,
|
|
1282
|
+
apiLoginAsRole,
|
|
1042
1283
|
assertMasked,
|
|
1043
1284
|
base as baseTest,
|
|
1285
|
+
buildApiLoginRequest,
|
|
1044
1286
|
clearFetchIntercept,
|
|
1287
|
+
clearUiSessions,
|
|
1045
1288
|
createBddTest,
|
|
1046
1289
|
createOidcCleanupAuth,
|
|
1047
1290
|
defaultBearerConfig,
|
|
1048
1291
|
defaultBypassConfig,
|
|
1292
|
+
extractToken,
|
|
1293
|
+
formatTargets,
|
|
1049
1294
|
getCpuCount,
|
|
1050
1295
|
initWorld,
|
|
1051
1296
|
interpolate,
|
|
1052
1297
|
isFlagEnabled,
|
|
1298
|
+
logTargets,
|
|
1053
1299
|
parseExpected,
|
|
1054
1300
|
registerAllSteps,
|
|
1055
1301
|
registerApiAssertionSteps,
|
|
@@ -1074,17 +1320,23 @@ export {
|
|
|
1074
1320
|
registerUiSteps,
|
|
1075
1321
|
registerWizardSteps,
|
|
1076
1322
|
resetOidcCleanupAuth,
|
|
1323
|
+
resolveApiBaseUrl,
|
|
1077
1324
|
resolveApiRequestTarget,
|
|
1078
1325
|
resolveBddPaths,
|
|
1326
|
+
resolveCredentials,
|
|
1079
1327
|
resolveExtraTags,
|
|
1080
1328
|
resolveFeatures,
|
|
1081
1329
|
resolveSteps,
|
|
1330
|
+
resolveTargets,
|
|
1082
1331
|
resolveWorkers,
|
|
1332
|
+
roleEnvKeys,
|
|
1083
1333
|
selectPath,
|
|
1084
1334
|
setFlag,
|
|
1085
1335
|
setupBearerAuth,
|
|
1086
1336
|
setupBypassAuth,
|
|
1087
1337
|
setupFetchIntercept,
|
|
1338
|
+
shouldLogTargets,
|
|
1088
1339
|
tagsForProject,
|
|
1089
|
-
tryParseJson
|
|
1340
|
+
tryParseJson,
|
|
1341
|
+
uiLoginAsRole
|
|
1090
1342
|
};
|
package/dist/steps/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@esimplicitylabs/katalyst-xspec",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"peerDependencies": {
|
|
59
59
|
"@playwright/test": "^1.49.0",
|
|
60
60
|
"playwright-bdd": "^9.1.0",
|
|
61
|
-
"tui-tester": "^
|
|
61
|
+
"tui-tester": "^0.3.0",
|
|
62
62
|
"typescript": "^5.6.0"
|
|
63
63
|
},
|
|
64
64
|
"peerDependenciesMeta": {
|
|
@@ -304,52 +304,37 @@ class AxiosApiAdapter implements ApiPort {
|
|
|
304
304
|
|
|
305
305
|
### Custom Auth Adapter
|
|
306
306
|
|
|
307
|
+
Usually not needed: the default `UniversalAuthAdapter` logs in by role using `AUTH_<ROLE>_*` credentials and `API_AUTH_*` / `UI_*` settings (see docs/guides/authentication.md). Constructor: `new UniversalAuthAdapter({ api, ui, roles?, env? })`, where `roles` is `{ pm: { username, password } }` and overrides env vars.
|
|
308
|
+
|
|
309
|
+
When only the login request or page differs, **subclass** and override `apiLogin` / `uiLogin`. Credentials, the role steps, and UI session reuse keep working:
|
|
310
|
+
|
|
307
311
|
```typescript
|
|
308
|
-
import {
|
|
312
|
+
import { UniversalAuthAdapter, type Credentials, type World } from '@esimplicitylabs/katalyst-xspec';
|
|
309
313
|
|
|
310
|
-
class CustomAuthAdapter
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
apiKey: process.env.ADMIN_API_KEY,
|
|
316
|
-
});
|
|
317
|
-
|
|
318
|
-
if (result.json?.token) {
|
|
319
|
-
world.headers['Authorization'] = `Bearer ${result.json.token}`;
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
async apiLoginAsUser(world: World): Promise<void> {
|
|
324
|
-
const result = await this.deps.api.sendJson('POST', '/auth/login', {
|
|
325
|
-
email: process.env.DEFAULT_USER_USERNAME,
|
|
326
|
-
password: process.env.DEFAULT_USER_PASSWORD,
|
|
314
|
+
class CustomAuthAdapter extends UniversalAuthAdapter {
|
|
315
|
+
protected async apiLogin(world: World, role: string, creds: Credentials): Promise<void> {
|
|
316
|
+
const result = await this.api.sendJson('POST', '/auth/login', {
|
|
317
|
+
email: creds.username,
|
|
318
|
+
password: creds.password,
|
|
327
319
|
});
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
world.headers['Authorization'] = `Bearer ${result.json.token}`;
|
|
331
|
-
}
|
|
320
|
+
if (result.status !== 200) throw new Error(`login as ${role} failed: ${result.status}`);
|
|
321
|
+
this.apiSetBearer(world, (result.json as any).token);
|
|
332
322
|
}
|
|
333
|
-
|
|
334
|
-
async
|
|
335
|
-
await this.
|
|
336
|
-
await this.
|
|
337
|
-
await this.
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
async uiLoginAsUser(world: World): Promise<void> {
|
|
341
|
-
await this.deps.ui.goto('/login');
|
|
342
|
-
await this.deps.ui.fillLabel('Email', process.env.DEFAULT_USER_USERNAME!);
|
|
343
|
-
await this.deps.ui.fillLabel('Password', process.env.DEFAULT_USER_PASSWORD!);
|
|
344
|
-
await this.deps.ui.clickButton('Sign In');
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
apiSetBearer(world: World, token: string): void {
|
|
348
|
-
world.headers['Authorization'] = `Bearer ${token}`;
|
|
323
|
+
|
|
324
|
+
protected async uiLogin(world: World, role: string, creds: Credentials): Promise<void> {
|
|
325
|
+
await this.ui.goto('/login');
|
|
326
|
+
await this.ui.fillLabel('Email', creds.username);
|
|
327
|
+
await this.ui.fillLabel('Password', creds.password);
|
|
328
|
+
await this.ui.clickButton('Sign In');
|
|
329
|
+
await this.ui.expectUrlContains('/dashboard');
|
|
349
330
|
}
|
|
350
331
|
}
|
|
351
332
|
```
|
|
352
333
|
|
|
334
|
+
Writing an `AuthPort` from scratch: implement `apiLoginAsAdmin`, `apiLoginAsUser`, `uiLoginAsAdmin`, `uiLoginAsUser`, `apiSetBearer`, plus the optional `apiLoginAs(world, role)` and `uiLoginAs(world, role, { reuseSession })` so the role steps work for any role.
|
|
335
|
+
|
|
336
|
+
The `ui` passed to `createAuth` only works after a step requests the `ui` fixture (API login never starts a browser). Custom steps calling `auth.uiLoginAs` must include `ui`: `async ({ auth, ui, world }) => ...`.
|
|
337
|
+
|
|
353
338
|
### Using Custom Adapters
|
|
354
339
|
|
|
355
340
|
```typescript
|