@esimplicitylabs/katalyst-xspec 0.7.1 → 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/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
- } from "./chunk-UTTKW2SR.js";
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(deps) {
361
- this.deps = deps;
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 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;
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.apiLogin(world, username, password);
555
+ await this.uiLogin(world, role, creds);
556
+ if (reuse && this.ui.saveSession) uiSessions.set(key, await this.ui.saveSession());
374
557
  }
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);
558
+ apiLoginAsAdmin(world) {
559
+ return this.apiLoginAs(world, "admin");
383
560
  }
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 });
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 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");
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
- await this.uiLogin(world, username, password);
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
- 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;
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
- 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);
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 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
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?.access_token;
518
- if (typeof token === "string" && token) {
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
- console.warn("cleanup auth: no access_token in response");
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 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";
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
- await use(createUi({ page }));
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, ui }, use) => {
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
  };
@@ -22,7 +22,7 @@ import {
22
22
  registerUiSteps,
23
23
  registerWizardSteps,
24
24
  setFlag
25
- } from "../chunk-UTTKW2SR.js";
25
+ } from "../chunk-VHVH476W.js";
26
26
  export {
27
27
  isFlagEnabled,
28
28
  registerAllSteps,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esimplicitylabs/katalyst-xspec",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -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 { AuthPort, World, ApiPort, UiPort } from '@esimplicitylabs/katalyst-xspec';
312
+ import { UniversalAuthAdapter, type Credentials, type World } from '@esimplicitylabs/katalyst-xspec';
309
313
 
310
- class CustomAuthAdapter implements AuthPort {
311
- constructor(private deps: { api: ApiPort; ui: UiPort }) {}
312
-
313
- async apiLoginAsAdmin(world: World): Promise<void> {
314
- const result = await this.deps.api.sendJson('POST', '/auth/admin', {
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
- if (result.json?.token) {
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 uiLoginAsAdmin(world: World): Promise<void> {
335
- await this.deps.ui.goto('/admin/login');
336
- await this.deps.ui.fillLabel('Admin Key', process.env.ADMIN_KEY!);
337
- await this.deps.ui.clickButton('Login');
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