@elench/testkit 0.1.118 → 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 (34) hide show
  1. package/lib/app/doctor.mjs +11 -113
  2. package/lib/cli/assistant/command-observer.mjs +1 -1
  3. package/lib/cli/assistant/state.mjs +2 -0
  4. package/lib/cli/commands/lint.mjs +41 -0
  5. package/lib/cli/entrypoint.mjs +1 -0
  6. package/lib/cli/operations/lint/operation.mjs +20 -0
  7. package/lib/cli/renderers/doctor/text.mjs +5 -0
  8. package/lib/cli/renderers/lint/text.mjs +20 -0
  9. package/lib/config/index.mjs +1 -0
  10. package/lib/config-api/database-steps.mjs +340 -0
  11. package/lib/config-api/index.d.ts +126 -3
  12. package/lib/config-api/index.mjs +176 -12
  13. package/lib/lint/index.mjs +689 -0
  14. package/lib/runner/template-steps.mjs +8 -0
  15. package/lib/runner/template.mjs +13 -0
  16. package/lib/runtime/index.d.ts +43 -0
  17. package/lib/runtime/index.mjs +24 -0
  18. package/lib/runtime-src/k6/http-assertions.js +82 -0
  19. package/lib/shared/configured-steps.mjs +16 -0
  20. package/lib/ui/index.d.ts +118 -0
  21. package/lib/ui/index.mjs +21 -0
  22. package/lib/ui/provisioning.mjs +283 -0
  23. package/lib/ui/sandbox.mjs +250 -0
  24. package/node_modules/@elench/next-analysis/package.json +1 -1
  25. package/node_modules/@elench/testkit-bridge/package.json +2 -2
  26. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  27. package/node_modules/@elench/ts-analysis/package.json +1 -1
  28. package/node_modules/es-toolkit/CHANGELOG.md +801 -0
  29. package/node_modules/es-toolkit/src/compat/_internal/Equals.d.ts +1 -0
  30. package/node_modules/es-toolkit/src/compat/_internal/IsWritable.d.ts +3 -0
  31. package/node_modules/es-toolkit/src/compat/_internal/MutableList.d.ts +4 -0
  32. package/node_modules/es-toolkit/src/compat/_internal/RejectReadonly.d.ts +4 -0
  33. package/node_modules/esprima/ChangeLog +235 -0
  34. package/package.json +5 -5
@@ -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
+ }
@@ -0,0 +1,250 @@
1
+ import { expect, request, test as base } from "@playwright/test";
2
+
3
+ export function createUiSandbox(options = {}) {
4
+ const sandboxId = createSandboxId(options);
5
+ return {
6
+ expect,
7
+ test: base.extend({
8
+ testkitSandbox: async ({ page }, use) => {
9
+ await use({
10
+ id: sandboxId,
11
+ backendBaseUrl: resolveBackendBaseUrl(options),
12
+ frontendBaseUrl: resolveFrontendBaseUrl(options),
13
+ page,
14
+ });
15
+ },
16
+ }),
17
+ };
18
+ }
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
+
47
+ export function createSandboxId(options = {}) {
48
+ const prefix =
49
+ String(options.prefix || "ui").replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "ui";
50
+ const worker = process.env.TEST_WORKER_INDEX || process.env.TESTKIT_WORKER_INDEX || "0";
51
+ const run = process.env.TESTKIT_LEASE_ID || process.env.TESTKIT_RUNTIME_ID || process.pid;
52
+ return `${prefix}-${run}-${worker}`;
53
+ }
54
+
55
+ export function resolveFrontendBaseUrl(options = {}) {
56
+ return resolveBaseUrl(
57
+ options.frontendBaseUrl ||
58
+ options.baseUrl ||
59
+ process.env.TESTKIT_FRONTEND_BASE_URL ||
60
+ process.env.BASE_URL
61
+ );
62
+ }
63
+
64
+ export function resolveBackendBaseUrl(options = {}) {
65
+ return resolveBaseUrl(options.backendBaseUrl || process.env.TESTKIT_BACKEND_BASE_URL || process.env.API_BASE_URL);
66
+ }
67
+
68
+ export function assertSafeUiTarget(url, options = {}) {
69
+ const parsed = parseUrl(url);
70
+ if (!parsed) {
71
+ throw new Error(`Invalid UI target URL: ${url}`);
72
+ }
73
+ if (options.allowRemote === true) return parsed.toString().replace(/\/$/, "");
74
+ if (!isLocalHost(parsed.hostname)) {
75
+ throw new Error(`UI target must be local unless allowRemote is true: ${url}`);
76
+ }
77
+ return parsed.toString().replace(/\/$/, "");
78
+ }
79
+
80
+ export function assertSafeUiTargets(urls = [], options = {}) {
81
+ return urls.map((url) => assertSafeUiTarget(url, options));
82
+ }
83
+
84
+ export async function createAuthedApiClient(playwrightRequest = request, options = {}) {
85
+ const baseURL = assertSafeUiTarget(resolveBackendBaseUrl(options), options);
86
+ return playwrightRequest.newContext({
87
+ baseURL,
88
+ extraHTTPHeaders: options.headers || {},
89
+ });
90
+ }
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
+
200
+ export async function waitForUiSettled(page, options = {}) {
201
+ const timeout = options.timeout ?? 5_000;
202
+ await page.waitForLoadState("domcontentloaded", { timeout });
203
+ await waitForAnimationFrames(page, options.frames ?? 2);
204
+ await page.waitForLoadState("networkidle", { timeout }).catch(() => {});
205
+ }
206
+
207
+ export async function waitForAnimationFrames(page, frames = 2) {
208
+ const count = Math.max(1, Number(frames) || 1);
209
+ await page.evaluate(
210
+ (frameCount) =>
211
+ new Promise((resolve) => {
212
+ let remaining = frameCount;
213
+ const tick = () => {
214
+ remaining -= 1;
215
+ if (remaining <= 0) {
216
+ resolve();
217
+ return;
218
+ }
219
+ requestAnimationFrame(tick);
220
+ };
221
+ requestAnimationFrame(tick);
222
+ }),
223
+ count
224
+ );
225
+ }
226
+
227
+ function resolveBaseUrl(value) {
228
+ const normalized = String(value || "").trim();
229
+ if (!normalized) {
230
+ throw new Error("A UI base URL is required");
231
+ }
232
+ return assertSafeUiTarget(normalized);
233
+ }
234
+
235
+ function parseUrl(value) {
236
+ try {
237
+ return new URL(value);
238
+ } catch {
239
+ return null;
240
+ }
241
+ }
242
+
243
+ function isLocalHost(hostname) {
244
+ return (
245
+ hostname === "localhost" ||
246
+ hostname === "127.0.0.1" ||
247
+ hostname === "::1" ||
248
+ hostname.endsWith(".localhost")
249
+ );
250
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/next-analysis",
3
- "version": "0.1.118",
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.118",
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.118"
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.118",
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.118",
3
+ "version": "0.1.120",
4
4
  "description": "TypeScript compiler-backed source analysis primitives for Erench tools",
5
5
  "type": "module",
6
6
  "exports": {