@elench/testkit 0.1.119 → 0.1.120

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.
Files changed (28) hide show
  1. package/lib/cli/commands/lint.mjs +4 -0
  2. package/lib/cli/operations/lint/operation.mjs +8 -0
  3. package/lib/config/index.mjs +1 -0
  4. package/lib/config-api/database-steps.mjs +208 -0
  5. package/lib/config-api/index.d.ts +90 -0
  6. package/lib/config-api/index.mjs +58 -0
  7. package/lib/lint/index.mjs +122 -2
  8. package/lib/runner/template.mjs +13 -0
  9. package/lib/ui/index.d.ts +72 -0
  10. package/lib/ui/index.mjs +10 -0
  11. package/lib/ui/provisioning.mjs +283 -0
  12. package/lib/ui/sandbox.mjs +135 -0
  13. package/node_modules/@elench/next-analysis/package.json +1 -1
  14. package/node_modules/@elench/testkit-bridge/package.json +2 -2
  15. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  16. package/node_modules/@elench/ts-analysis/package.json +1 -1
  17. package/node_modules/es-toolkit/CHANGELOG.md +801 -0
  18. package/node_modules/es-toolkit/src/compat/_internal/Equals.d.ts +1 -0
  19. package/node_modules/es-toolkit/src/compat/_internal/IsWritable.d.ts +3 -0
  20. package/node_modules/es-toolkit/src/compat/_internal/MutableList.d.ts +4 -0
  21. package/node_modules/es-toolkit/src/compat/_internal/RejectReadonly.d.ts +4 -0
  22. package/node_modules/esprima/ChangeLog +235 -0
  23. package/package.json +5 -5
  24. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts +0 -188
  25. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts.map +0 -1
  26. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js +0 -293
  27. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js.map +0 -1
  28. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/package.json +0 -25
package/lib/ui/index.d.ts CHANGED
@@ -27,6 +27,39 @@ export declare function createUiSandbox(options?: UiSandboxOptions): {
27
27
  import("@playwright/test").PlaywrightWorkerArgs & import("@playwright/test").PlaywrightWorkerOptions
28
28
  >;
29
29
  };
30
+ export interface ProvisionedUiSandbox {
31
+ id: string;
32
+ backendBaseUrl: string;
33
+ frontendBaseUrl: string;
34
+ identity: unknown;
35
+ owner: {
36
+ email: string;
37
+ id: string;
38
+ name: string;
39
+ organizationId: string;
40
+ organizationName: string;
41
+ password: string;
42
+ slug: string;
43
+ timeZone: string | null;
44
+ };
45
+ ensureAuthenticated(page: import("@playwright/test").Page): Promise<void>;
46
+ apiFetch<T = unknown>(
47
+ page: import("@playwright/test").Page,
48
+ method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE",
49
+ path: string,
50
+ options?: Record<string, unknown>
51
+ ): Promise<T>;
52
+ }
53
+ export declare function createProvisionedUiSandbox(options?: UiSandboxOptions & Record<string, unknown>): {
54
+ expect: typeof import("@playwright/test").expect;
55
+ test: import("@playwright/test").TestType<
56
+ import("@playwright/test").PlaywrightTestArgs & import("@playwright/test").PlaywrightTestOptions & {
57
+ guestPage: import("@playwright/test").Page;
58
+ sandbox: ProvisionedUiSandbox;
59
+ },
60
+ import("@playwright/test").PlaywrightWorkerArgs & import("@playwright/test").PlaywrightWorkerOptions
61
+ >;
62
+ };
30
63
  export declare function createSandboxId(options?: Pick<UiSandboxOptions, "prefix">): string;
31
64
  export declare function resolveFrontendBaseUrl(options?: UiSandboxOptions): string;
32
65
  export declare function resolveBackendBaseUrl(options?: UiSandboxOptions): string;
@@ -47,3 +80,42 @@ export declare function waitForAnimationFrames(
47
80
  page: import("@playwright/test").Page,
48
81
  frames?: number
49
82
  ): Promise<void>;
83
+ export declare function readUiConfig(env?: NodeJS.ProcessEnv): unknown;
84
+ export declare function provisionUiIdentity(
85
+ page: import("@playwright/test").Page,
86
+ profileName?: string,
87
+ options?: Record<string, unknown>
88
+ ): Promise<{
89
+ credentials: { email: string; password: string };
90
+ organizations: unknown[];
91
+ profile: string;
92
+ seeded: Record<string, unknown>;
93
+ session: Record<string, unknown>;
94
+ token?: string;
95
+ user?: Record<string, unknown>;
96
+ }>;
97
+ export declare function loginWithCredentials(
98
+ page: import("@playwright/test").Page,
99
+ email: string,
100
+ password: string,
101
+ options?: Record<string, unknown>
102
+ ): Promise<void>;
103
+ export declare function loginAsProvisioned(
104
+ page: import("@playwright/test").Page,
105
+ identity: { credentials: { email: string; password: string } },
106
+ options?: Record<string, unknown>
107
+ ): Promise<unknown>;
108
+ export declare function loginAsUiProfile(
109
+ page: import("@playwright/test").Page,
110
+ profileName?: string,
111
+ options?: Record<string, unknown>
112
+ ): Promise<unknown>;
113
+ export declare function persistUiSessionStorage(
114
+ page: import("@playwright/test").Page,
115
+ identity: unknown,
116
+ options?: Record<string, unknown>
117
+ ): Promise<void>;
118
+ export declare function expectAuthenticatedShell(
119
+ page: import("@playwright/test").Page,
120
+ options?: Record<string, unknown>
121
+ ): Promise<void>;
package/lib/ui/index.mjs CHANGED
@@ -4,6 +4,7 @@ export {
4
4
  assertSafeUiTarget,
5
5
  assertSafeUiTargets,
6
6
  createAuthedApiClient,
7
+ createProvisionedUiSandbox,
7
8
  createSandboxId,
8
9
  createUiSandbox,
9
10
  resolveBackendBaseUrl,
@@ -11,3 +12,12 @@ export {
11
12
  waitForAnimationFrames,
12
13
  waitForUiSettled,
13
14
  } from "./sandbox.mjs";
15
+ export {
16
+ expectAuthenticatedShell,
17
+ loginAsProvisioned,
18
+ loginAsUiProfile,
19
+ loginWithCredentials,
20
+ persistUiSessionStorage,
21
+ provisionUiIdentity,
22
+ readUiConfig,
23
+ } from "./provisioning.mjs";
@@ -0,0 +1,283 @@
1
+ import { randomUUID } from "crypto";
2
+ import { execa } from "execa";
3
+ import { expect } from "@playwright/test";
4
+ import {
5
+ assertSafeUiTarget,
6
+ resolveBackendBaseUrl,
7
+ resolveFrontendBaseUrl,
8
+ waitForUiSettled,
9
+ } from "./sandbox.mjs";
10
+
11
+ const DEFAULT_PASSWORD = "TestkitPass2026";
12
+
13
+ export function readUiConfig(env = process.env) {
14
+ const raw = env.TESTKIT_UI_CONFIG_JSON;
15
+ if (!raw) return {};
16
+ try {
17
+ const parsed = JSON.parse(raw);
18
+ return parsed && typeof parsed === "object" ? parsed : {};
19
+ } catch (error) {
20
+ throw new Error(`TESTKIT_UI_CONFIG_JSON is not valid JSON: ${error?.message || error}`);
21
+ }
22
+ }
23
+
24
+ export async function provisionUiIdentity(page, profileName = "default", options = {}) {
25
+ const config = resolveAuthConfig(options.config);
26
+ const profile = resolveProfileConfig(config, profileName);
27
+ const backendBaseUrl = assertSafeUiTarget(
28
+ options.backendBaseUrl || config.backendBaseUrl || resolveBackendBaseUrl({ backendBaseUrl: config.backendBaseUrl })
29
+ );
30
+ const identity = buildIdentity(profileName, profile, options);
31
+ const request = page.context().request;
32
+
33
+ if (profile.signup !== false && config.signup !== false) {
34
+ const signupResponse = await request.post(`${backendBaseUrl}${profile.signupPath || config.signupPath || "/api/v1/auth/signup"}`, {
35
+ data: renderTemplateValue(profile.signupBody || config.signupBody || defaultSignupBody(), {
36
+ identity,
37
+ options,
38
+ profile: profileName,
39
+ }),
40
+ headers: { "Content-Type": "application/json" },
41
+ });
42
+ if (!isExpectedStatus(signupResponse.status(), profile.signupExpect || config.signupExpect || [201, 409])) {
43
+ throw await responseError(signupResponse, `Signup for UI profile "${profileName}"`);
44
+ }
45
+ }
46
+
47
+ let session = await login({ backendBaseUrl, config, identity, page, profile, profileName });
48
+ const sql = [...normalizeSqlList(config.afterSignupSql), ...normalizeSqlList(profile.afterSignupSql)];
49
+ if (sql.length > 0) {
50
+ await runProvisionSql({
51
+ config,
52
+ identity,
53
+ options,
54
+ profile: profileName,
55
+ session,
56
+ sql,
57
+ });
58
+ session = await login({ backendBaseUrl, config, identity, page, profile, profileName });
59
+ }
60
+
61
+ return {
62
+ credentials: {
63
+ email: identity.email,
64
+ password: identity.password,
65
+ },
66
+ organizations: Array.isArray(session.organizations) ? session.organizations : [],
67
+ profile: profileName,
68
+ seeded: {},
69
+ session,
70
+ token: session.token,
71
+ user: session.user,
72
+ };
73
+ }
74
+
75
+ export async function loginWithCredentials(page, email, password, options = {}) {
76
+ const config = resolveAuthConfig(options.config);
77
+ const frontendBaseUrl = assertSafeUiTarget(
78
+ options.frontendBaseUrl || config.frontendBaseUrl || resolveFrontendBaseUrl(options)
79
+ );
80
+ const loginPath = options.loginPagePath || config.loginPagePath || "/login";
81
+ const homePath = options.homePath || config.homePath || "/";
82
+
83
+ await page.goto(`${frontendBaseUrl}${loginPath}`);
84
+ await page.waitForLoadState("domcontentloaded");
85
+ await page.locator(options.emailSelector || config.emailSelector || 'input[type="email"], input#email').first().fill(email);
86
+ await page.locator(options.passwordSelector || config.passwordSelector || 'input[type="password"]').first().fill(password);
87
+ await page.locator(options.submitSelector || config.submitSelector || 'button[type="submit"]').first().click();
88
+ await page.waitForURL(`${frontendBaseUrl}${homePath}`, { timeout: options.timeout ?? 15_000 }).catch(async () => {
89
+ await page.waitForURL(homePath, { timeout: options.timeout ?? 15_000 });
90
+ });
91
+ await waitForUiSettled(page, options);
92
+ }
93
+
94
+ export async function loginAsProvisioned(page, identity, options = {}) {
95
+ await loginWithCredentials(page, identity.credentials.email, identity.credentials.password, options);
96
+ return identity;
97
+ }
98
+
99
+ export async function loginAsUiProfile(page, profileName = "default", options = {}) {
100
+ const identity = await provisionUiIdentity(page, profileName, options);
101
+ await loginWithCredentials(page, identity.credentials.email, identity.credentials.password, options);
102
+ return identity;
103
+ }
104
+
105
+ export async function persistUiSessionStorage(page, identity, options = {}) {
106
+ const config = resolveAuthConfig(options.config);
107
+ const storage = options.storage || config.storage || config.browserState?.localStorage || {};
108
+ const frontendBaseUrl = assertSafeUiTarget(
109
+ options.frontendBaseUrl || config.frontendBaseUrl || resolveFrontendBaseUrl(options)
110
+ );
111
+ await page.goto(`${frontendBaseUrl}${options.path || config.storagePath || "/login"}`);
112
+ await page.waitForLoadState("domcontentloaded");
113
+ await page.evaluate(
114
+ ({ identity: serializedIdentity, storageConfig }) => {
115
+ for (const [key, value] of Object.entries(storageConfig)) {
116
+ if (value === null || value === undefined) {
117
+ localStorage.removeItem(key);
118
+ } else if (typeof value === "string") {
119
+ localStorage.setItem(key, value);
120
+ } else {
121
+ localStorage.setItem(key, JSON.stringify(value));
122
+ }
123
+ }
124
+ window.dispatchEvent(new CustomEvent("testkit:session-storage", { detail: serializedIdentity }));
125
+ },
126
+ {
127
+ identity,
128
+ storageConfig: renderTemplateValue(storage, {
129
+ identity,
130
+ options,
131
+ profile: identity.profile,
132
+ session: identity.session,
133
+ }),
134
+ }
135
+ );
136
+ }
137
+
138
+ export async function expectAuthenticatedShell(page, options = {}) {
139
+ const config = resolveAuthConfig(options.config);
140
+ await expect(page.locator(options.selector || config.authenticatedSelector || 'button[aria-label="User menu"], button:has-text("User menu")').first()).toBeVisible({
141
+ timeout: options.timeout ?? 10_000,
142
+ });
143
+ }
144
+
145
+ function resolveAuthConfig(explicitConfig) {
146
+ const root = explicitConfig || readUiConfig();
147
+ const auth = root.auth || root;
148
+ if (!auth || typeof auth !== "object") {
149
+ throw new Error("Testkit UI auth config is required");
150
+ }
151
+ return auth;
152
+ }
153
+
154
+ function resolveProfileConfig(config, profileName) {
155
+ const profiles = config.profiles || {};
156
+ const profile = profiles[profileName] || profiles.default || {};
157
+ if (!profile || typeof profile !== "object") {
158
+ throw new Error(`UI profile "${profileName}" must be an object`);
159
+ }
160
+ return profile;
161
+ }
162
+
163
+ function buildIdentity(profileName, profile, options) {
164
+ const id = slugify(options.id || `${profileName}-${randomUUID()}`);
165
+ const domain = profile.emailDomain || options.emailDomain || "example.test";
166
+ return {
167
+ id,
168
+ email: renderTemplate(profile.email || `{identity.id}@${domain}`, { identity: { id }, options, profile: profileName }),
169
+ name: renderTemplate(profile.name || `Testkit ${profileName} {identity.id}`, { identity: { id }, options, profile: profileName }),
170
+ organizationName: renderTemplate(profile.organizationName || `Testkit ${profileName} {identity.id}`, {
171
+ identity: { id },
172
+ options,
173
+ profile: profileName,
174
+ }),
175
+ password: options.password || profile.password || DEFAULT_PASSWORD,
176
+ };
177
+ }
178
+
179
+ async function login({ backendBaseUrl, config, identity, page, profile, profileName }) {
180
+ const response = await page.context().request.post(`${backendBaseUrl}${profile.loginPath || config.loginPath || "/api/v1/auth/login"}`, {
181
+ data: renderTemplateValue(profile.loginBody || config.loginBody || defaultLoginBody(), {
182
+ identity,
183
+ profile: profileName,
184
+ }),
185
+ headers: { "Content-Type": "application/json" },
186
+ });
187
+ if (!isExpectedStatus(response.status(), profile.loginExpect || config.loginExpect || 200)) {
188
+ throw await responseError(response, `Login for UI profile "${profileName}"`);
189
+ }
190
+ const payload = await response.json().catch(() => ({}));
191
+ return readPath(payload, profile.sessionPath || config.sessionPath || "data") || payload;
192
+ }
193
+
194
+ async function runProvisionSql({ config, identity, options, profile, session, sql }) {
195
+ const databaseUrl = config.databaseUrl || process.env.TESTKIT_UI_DATABASE_URL || process.env.DATABASE_URL;
196
+ if (!databaseUrl) {
197
+ throw new Error("UI provisioning SQL requires auth.databaseUrl, TESTKIT_UI_DATABASE_URL, or DATABASE_URL");
198
+ }
199
+ for (const statement of sql) {
200
+ const rendered = renderSql(statement, { identity, options, profile, session });
201
+ const result = await execa("psql", [databaseUrl, "-v", "ON_ERROR_STOP=1", "-X", "-q", "-c", rendered], {
202
+ env: process.env,
203
+ reject: false,
204
+ stdout: "pipe",
205
+ stderr: "pipe",
206
+ });
207
+ if (result.exitCode !== 0) {
208
+ throw new Error(result.stderr || result.shortMessage || `UI provisioning SQL failed with exit code ${result.exitCode}`);
209
+ }
210
+ }
211
+ }
212
+
213
+ function defaultSignupBody() {
214
+ return {
215
+ email: "{identity.email}",
216
+ name: "{identity.name}",
217
+ organizationName: "{identity.organizationName}",
218
+ password: "{identity.password}",
219
+ };
220
+ }
221
+
222
+ function defaultLoginBody() {
223
+ return {
224
+ email: "{identity.email}",
225
+ password: "{identity.password}",
226
+ };
227
+ }
228
+
229
+ function normalizeSqlList(value) {
230
+ if (value == null) return [];
231
+ return Array.isArray(value) ? value : [value];
232
+ }
233
+
234
+ function renderTemplateValue(value, context) {
235
+ if (typeof value === "string") return renderTemplate(value, context);
236
+ if (Array.isArray(value)) return value.map((entry) => renderTemplateValue(entry, context));
237
+ if (!value || typeof value !== "object") return value;
238
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, renderTemplateValue(entry, context)]));
239
+ }
240
+
241
+ function renderTemplate(value, context) {
242
+ return String(value).replace(/\{([A-Za-z0-9_.[\]-]+)\}/g, (_match, key) => {
243
+ const resolved = readPath(context, key);
244
+ return resolved == null ? "" : String(resolved);
245
+ });
246
+ }
247
+
248
+ function renderSql(statement, context) {
249
+ return String(statement).replace(/\{([A-Za-z0-9_.[\]-]+)\}/g, (_match, key) => toSqlLiteral(readPath(context, key)));
250
+ }
251
+
252
+ function readPath(source, path) {
253
+ return String(path)
254
+ .replace(/\[(\d+)\]/g, ".$1")
255
+ .split(".")
256
+ .filter(Boolean)
257
+ .reduce((value, part) => (value == null ? undefined : value[part]), source);
258
+ }
259
+
260
+ function toSqlLiteral(value) {
261
+ if (value === null || value === undefined) return "null";
262
+ if (Array.isArray(value)) return `array[${value.map((entry) => toSqlLiteral(entry)).join(", ")}]`;
263
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
264
+ if (typeof value === "boolean") return value ? "true" : "false";
265
+ return `'${String(value).replaceAll("'", "''")}'`;
266
+ }
267
+
268
+ function isExpectedStatus(status, expected) {
269
+ return Array.isArray(expected) ? expected.includes(status) : status === expected;
270
+ }
271
+
272
+ async function responseError(response, label) {
273
+ const text = await response.text().catch(() => "");
274
+ return new Error(`${label} failed (${response.status()}): ${text || "no response body"}`);
275
+ }
276
+
277
+ function slugify(value) {
278
+ return String(value || "test")
279
+ .toLowerCase()
280
+ .replace(/[^a-z0-9]+/g, "-")
281
+ .replace(/^-+|-+$/g, "")
282
+ .slice(0, 64) || "test";
283
+ }
@@ -17,6 +17,33 @@ export function createUiSandbox(options = {}) {
17
17
  };
18
18
  }
19
19
 
20
+ export function createProvisionedUiSandbox(options = {}) {
21
+ return {
22
+ expect,
23
+ test: base.extend({
24
+ sandbox: async ({}, use, testInfo) => {
25
+ await use(new ProvisionedUiSandbox(testInfo, options));
26
+ },
27
+ guestPage: async ({ browser }, use, testInfo) => {
28
+ const frontendBaseUrl = resolveFrontendBaseUrlFromTestInfo(testInfo, options);
29
+ let context = null;
30
+ try {
31
+ context = await browser.newContext({
32
+ baseURL: frontendBaseUrl,
33
+ });
34
+ await use(await context.newPage());
35
+ } finally {
36
+ await context?.close();
37
+ }
38
+ },
39
+ page: async ({ page, sandbox }, use) => {
40
+ await sandbox.ensureAuthenticated(page);
41
+ await use(page);
42
+ },
43
+ }),
44
+ };
45
+ }
46
+
20
47
  export function createSandboxId(options = {}) {
21
48
  const prefix =
22
49
  String(options.prefix || "ui").replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "ui";
@@ -62,6 +89,114 @@ export async function createAuthedApiClient(playwrightRequest = request, options
62
89
  });
63
90
  }
64
91
 
92
+ class ProvisionedUiSandbox {
93
+ constructor(testInfo, options = {}) {
94
+ this.authInFlight = null;
95
+ this.authenticated = false;
96
+ this.identity = null;
97
+ this.options = options;
98
+ this.id = createSandboxId({
99
+ prefix: options.prefix || buildStableTestPrefix(testInfo),
100
+ });
101
+ this.frontendBaseUrl = resolveFrontendBaseUrlFromTestInfo(testInfo, options);
102
+ this.backendBaseUrl = resolveBackendBaseUrl(options);
103
+ }
104
+
105
+ get owner() {
106
+ const identity = this.requireIdentity();
107
+ const organization = selectHomeOrganization(identity);
108
+ return {
109
+ email: identity.credentials.email,
110
+ id: identity.user?.id || "",
111
+ name: identity.user?.name || identity.credentials.email,
112
+ organizationId: organization?.id || "",
113
+ organizationName: organization?.name || "",
114
+ password: identity.credentials.password,
115
+ slug: organization?.slug || "",
116
+ timeZone: identity.user?.timeZone ?? null,
117
+ };
118
+ }
119
+
120
+ async ensureAuthenticated(page) {
121
+ if (this.authenticated) return;
122
+ if (!this.authInFlight) {
123
+ this.authInFlight = (async () => {
124
+ const {
125
+ expectAuthenticatedShell,
126
+ persistUiSessionStorage,
127
+ provisionUiIdentity,
128
+ } = await import("./provisioning.mjs");
129
+ this.identity = await provisionUiIdentity(page, this.options.profile || "owner", {
130
+ ...this.options,
131
+ backendBaseUrl: this.backendBaseUrl,
132
+ frontendBaseUrl: this.frontendBaseUrl,
133
+ id: this.id,
134
+ });
135
+ if (this.options.persistStorage !== false) {
136
+ await persistUiSessionStorage(page, this.identity, {
137
+ ...this.options,
138
+ frontendBaseUrl: this.frontendBaseUrl,
139
+ });
140
+ await page.goto(this.frontendBaseUrl);
141
+ await expectAuthenticatedShell(page, this.options);
142
+ }
143
+ this.authenticated = true;
144
+ })().finally(() => {
145
+ this.authInFlight = null;
146
+ });
147
+ }
148
+ await this.authInFlight;
149
+ }
150
+
151
+ async apiFetch(page, method, path, options = {}) {
152
+ await this.ensureAuthenticated(page);
153
+ const identity = this.requireIdentity();
154
+ const organization = selectHomeOrganization(identity);
155
+ const requestOptions = {
156
+ method,
157
+ headers: {
158
+ ...(identity.token ? { Authorization: `Bearer ${identity.token}` } : {}),
159
+ ...(organization?.id ? { "X-Organization-Id": organization.id } : {}),
160
+ ...(options.headers || {}),
161
+ },
162
+ ...(options.data !== undefined ? { data: options.data } : {}),
163
+ ...(options.multipart !== undefined ? { multipart: options.multipart } : {}),
164
+ };
165
+ const response = await page.context().request.fetch(`${this.backendBaseUrl}${path}`, requestOptions);
166
+ const text = await response.text();
167
+ if (!response.ok()) {
168
+ throw new Error(`${method} ${path} failed (${response.status()}): ${text || "no response body"}`);
169
+ }
170
+ return text ? JSON.parse(text) : {};
171
+ }
172
+
173
+ requireIdentity() {
174
+ if (!this.identity) {
175
+ throw new Error("UI sandbox identity has not been provisioned yet");
176
+ }
177
+ return this.identity;
178
+ }
179
+ }
180
+
181
+ function resolveFrontendBaseUrlFromTestInfo(testInfo, options = {}) {
182
+ const configured = typeof testInfo?.project?.use?.baseURL === "string" ? testInfo.project.use.baseURL : null;
183
+ return resolveFrontendBaseUrl({ ...options, frontendBaseUrl: options.frontendBaseUrl || configured || undefined });
184
+ }
185
+
186
+ function buildStableTestPrefix(testInfo) {
187
+ const title = Array.isArray(testInfo?.titlePath) ? testInfo.titlePath.join("-") : testInfo?.title || "ui";
188
+ return `${testInfo?.workerIndex ?? 0}-${testInfo?.retry ?? 0}-${title || "ui"}`;
189
+ }
190
+
191
+ function selectHomeOrganization(identity) {
192
+ const organizations = Array.isArray(identity.organizations) ? identity.organizations : [];
193
+ return (
194
+ organizations.find((organization) => organization.id && organization.id === identity.user?.homeOrganizationId) ||
195
+ organizations[0] ||
196
+ null
197
+ );
198
+ }
199
+
65
200
  export async function waitForUiSettled(page, options = {}) {
66
201
  const timeout = options.timeout ?? 5_000;
67
202
  await page.waitForLoadState("domcontentloaded", { timeout });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/next-analysis",
3
- "version": "0.1.119",
3
+ "version": "0.1.120",
4
4
  "description": "SWC-backed Next.js source analysis primitives for Erench tools",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit-bridge",
3
- "version": "0.1.119",
3
+ "version": "0.1.120",
4
4
  "description": "Browser bridge helpers for testkit",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -22,7 +22,7 @@
22
22
  "typecheck": "tsc -p tsconfig.json --noEmit"
23
23
  },
24
24
  "dependencies": {
25
- "@elench/testkit-protocol": "0.1.119"
25
+ "@elench/testkit-protocol": "0.1.120"
26
26
  },
27
27
  "private": false
28
28
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit-protocol",
3
- "version": "0.1.119",
3
+ "version": "0.1.120",
4
4
  "description": "Shared browser protocol for testkit bridge and extension consumers",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/ts-analysis",
3
- "version": "0.1.119",
3
+ "version": "0.1.120",
4
4
  "description": "TypeScript compiler-backed source analysis primitives for Erench tools",
5
5
  "type": "module",
6
6
  "exports": {