@elench/testkit 0.1.119 → 0.1.121
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/lib/cli/commands/lint.mjs +4 -0
- package/lib/cli/operations/lint/operation.mjs +8 -0
- package/lib/config/index.mjs +1 -0
- package/lib/config-api/database-steps.mjs +208 -0
- package/lib/config-api/index.d.ts +90 -0
- package/lib/config-api/index.mjs +58 -0
- package/lib/lint/index.mjs +122 -2
- package/lib/runner/template.mjs +13 -0
- package/lib/ui/index.d.ts +72 -0
- package/lib/ui/index.mjs +10 -0
- package/lib/ui/provisioning.mjs +283 -0
- package/lib/ui/sandbox.mjs +146 -0
- package/node_modules/@elench/next-analysis/package.json +1 -1
- package/node_modules/@elench/testkit-bridge/package.json +2 -2
- package/node_modules/@elench/testkit-protocol/package.json +1 -1
- package/node_modules/@elench/ts-analysis/package.json +1 -1
- package/package.json +5 -5
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts +0 -188
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts.map +0 -1
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js +0 -293
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js.map +0 -1
- 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
|
+
}
|
package/lib/ui/sandbox.mjs
CHANGED
|
@@ -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,125 @@ 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
|
+
loginWithCredentials,
|
|
127
|
+
persistUiSessionStorage,
|
|
128
|
+
provisionUiIdentity,
|
|
129
|
+
} = await import("./provisioning.mjs");
|
|
130
|
+
this.identity = await provisionUiIdentity(page, this.options.profile || "owner", {
|
|
131
|
+
...this.options,
|
|
132
|
+
backendBaseUrl: this.backendBaseUrl,
|
|
133
|
+
frontendBaseUrl: this.frontendBaseUrl,
|
|
134
|
+
id: this.id,
|
|
135
|
+
});
|
|
136
|
+
if (this.options.persistStorage === false) {
|
|
137
|
+
await loginWithCredentials(
|
|
138
|
+
page,
|
|
139
|
+
this.identity.credentials.email,
|
|
140
|
+
this.identity.credentials.password,
|
|
141
|
+
{
|
|
142
|
+
...this.options,
|
|
143
|
+
frontendBaseUrl: this.frontendBaseUrl,
|
|
144
|
+
}
|
|
145
|
+
);
|
|
146
|
+
} else {
|
|
147
|
+
await persistUiSessionStorage(page, this.identity, {
|
|
148
|
+
...this.options,
|
|
149
|
+
frontendBaseUrl: this.frontendBaseUrl,
|
|
150
|
+
});
|
|
151
|
+
await page.goto(this.frontendBaseUrl);
|
|
152
|
+
await expectAuthenticatedShell(page, this.options);
|
|
153
|
+
}
|
|
154
|
+
this.authenticated = true;
|
|
155
|
+
})().finally(() => {
|
|
156
|
+
this.authInFlight = null;
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
await this.authInFlight;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async apiFetch(page, method, path, options = {}) {
|
|
163
|
+
await this.ensureAuthenticated(page);
|
|
164
|
+
const identity = this.requireIdentity();
|
|
165
|
+
const organization = selectHomeOrganization(identity);
|
|
166
|
+
const requestOptions = {
|
|
167
|
+
method,
|
|
168
|
+
headers: {
|
|
169
|
+
...(identity.token ? { Authorization: `Bearer ${identity.token}` } : {}),
|
|
170
|
+
...(organization?.id ? { "X-Organization-Id": organization.id } : {}),
|
|
171
|
+
...(options.headers || {}),
|
|
172
|
+
},
|
|
173
|
+
...(options.data !== undefined ? { data: options.data } : {}),
|
|
174
|
+
...(options.multipart !== undefined ? { multipart: options.multipart } : {}),
|
|
175
|
+
};
|
|
176
|
+
const response = await page.context().request.fetch(`${this.backendBaseUrl}${path}`, requestOptions);
|
|
177
|
+
const text = await response.text();
|
|
178
|
+
if (!response.ok()) {
|
|
179
|
+
throw new Error(`${method} ${path} failed (${response.status()}): ${text || "no response body"}`);
|
|
180
|
+
}
|
|
181
|
+
return text ? JSON.parse(text) : {};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
requireIdentity() {
|
|
185
|
+
if (!this.identity) {
|
|
186
|
+
throw new Error("UI sandbox identity has not been provisioned yet");
|
|
187
|
+
}
|
|
188
|
+
return this.identity;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function resolveFrontendBaseUrlFromTestInfo(testInfo, options = {}) {
|
|
193
|
+
const configured = typeof testInfo?.project?.use?.baseURL === "string" ? testInfo.project.use.baseURL : null;
|
|
194
|
+
return resolveFrontendBaseUrl({ ...options, frontendBaseUrl: options.frontendBaseUrl || configured || undefined });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function buildStableTestPrefix(testInfo) {
|
|
198
|
+
const title = Array.isArray(testInfo?.titlePath) ? testInfo.titlePath.join("-") : testInfo?.title || "ui";
|
|
199
|
+
return `${testInfo?.workerIndex ?? 0}-${testInfo?.retry ?? 0}-${title || "ui"}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function selectHomeOrganization(identity) {
|
|
203
|
+
const organizations = Array.isArray(identity.organizations) ? identity.organizations : [];
|
|
204
|
+
return (
|
|
205
|
+
organizations.find((organization) => organization.id && organization.id === identity.user?.homeOrganizationId) ||
|
|
206
|
+
organizations[0] ||
|
|
207
|
+
null
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
65
211
|
export async function waitForUiSettled(page, options = {}) {
|
|
66
212
|
const timeout = options.timeout ?? 5_000;
|
|
67
213
|
await page.waitForLoadState("domcontentloaded", { timeout });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elench/testkit-bridge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.121",
|
|
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.
|
|
25
|
+
"@elench/testkit-protocol": "0.1.121"
|
|
26
26
|
},
|
|
27
27
|
"private": false
|
|
28
28
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elench/testkit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.121",
|
|
4
4
|
"description": "Assistant-first CLI for running, inspecting, and debugging local testkit suites",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"workspaces": [
|
|
@@ -95,10 +95,10 @@
|
|
|
95
95
|
},
|
|
96
96
|
"dependencies": {
|
|
97
97
|
"@babel/code-frame": "^7.29.0",
|
|
98
|
-
"@elench/next-analysis": "0.1.
|
|
99
|
-
"@elench/testkit-bridge": "0.1.
|
|
100
|
-
"@elench/testkit-protocol": "0.1.
|
|
101
|
-
"@elench/ts-analysis": "0.1.
|
|
98
|
+
"@elench/next-analysis": "0.1.121",
|
|
99
|
+
"@elench/testkit-bridge": "0.1.121",
|
|
100
|
+
"@elench/testkit-protocol": "0.1.121",
|
|
101
|
+
"@elench/ts-analysis": "0.1.121",
|
|
102
102
|
"@oclif/core": "^4.10.6",
|
|
103
103
|
"@playwright/test": "^1.52.0",
|
|
104
104
|
"esbuild": "^0.25.11",
|