@cosmicdrift/kumiko-testing 0.305.0 → 0.306.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -0
- package/package.json +4 -4
- package/src/changes.json +17 -0
- package/src/e2e/auth-kit.ts +8 -1
- package/src/e2e/index.ts +2 -1
- package/src/e2e/mail-capture.ts +25 -9
- package/src/e2e/screenshots.ts +59 -3
- package/src/e2e/seed-contract.ts +1 -1
- package/src/e2e/seed-route.ts +45 -6
package/README.md
CHANGED
|
@@ -78,3 +78,21 @@ receives the seeded-tenant fixture too:
|
|
|
78
78
|
right before the screenshot. `runMatrix` calls it once per theme × viewport for the same scenario, so
|
|
79
79
|
it must be idempotent — hide or mask an element rather than a one-shot action like clicking a button,
|
|
80
80
|
which would only succeed on the first capture and time out on every one after.
|
|
81
|
+
|
|
82
|
+
`runMatrix` also supports real device emulation via Playwright projects named after a viewport id
|
|
83
|
+
(`desktop`, `tablet`, `mobile`) with `use.isMobile: true`:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
projects: [
|
|
87
|
+
{ name: "desktop", use: { ...devices["Desktop Chrome"] } },
|
|
88
|
+
{ name: "tablet", use: { ...devices["iPad Pro 11"] } }, // portrait, WebKit
|
|
89
|
+
{ name: "mobile", use: { ...devices["iPhone 13"] } }, // portrait, WebKit
|
|
90
|
+
]
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
A device project captures exactly `<name>.png` at the device's native size — no `setViewportSize`,
|
|
94
|
+
which would destroy the emulation. Any other project (the desktop pass) still loops the remaining
|
|
95
|
+
viewports via `setViewportSize`, skipping the ids a device project already covers. Without device
|
|
96
|
+
projects, nothing changes: desktop, tablet and mobile all render through `setViewportSize` as before.
|
|
97
|
+
`SCREENSHOT_VIEWPORTS` still filters both — a filtered-out device project's test is skipped, not run
|
|
98
|
+
empty. CI needs `bunx playwright install webkit` to run device projects that use a WebKit device.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.306.0",
|
|
4
4
|
"description": "Test template for Kumiko apps: per-flow seedTenant, app test stack, canonical test preloads, bunfig generator and integration runner.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -63,9 +63,9 @@
|
|
|
63
63
|
"kumiko-testing": "./bin/kumiko-testing.ts"
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
67
|
-
"@cosmicdrift/kumiko-dev-server": "0.
|
|
68
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
66
|
+
"@cosmicdrift/kumiko-bundled-features": "0.306.0",
|
|
67
|
+
"@cosmicdrift/kumiko-dev-server": "0.306.0",
|
|
68
|
+
"@cosmicdrift/kumiko-framework": "0.306.0",
|
|
69
69
|
"zod": "^4.4.3"
|
|
70
70
|
},
|
|
71
71
|
"peerDependencies": {
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,21 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.306.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "clearSession(page) for race-free session switches in E2E"
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
"version": "0.306.0",
|
|
9
|
+
"type": "breaking",
|
|
10
|
+
"title": "mailCapture and the /__test/inbox route read tenantless mail via a new mailOutbox option",
|
|
11
|
+
"detail": "Apps sending Dev/E2E mail through a raw createInMemoryTransport() (signup,\nforgot-password, magic-link — flows with no tenant) had no way to read it\nthrough the seed inbox route, which required tenantId and only checked\nmailTransportInMemoryFeature's per-tenant buffer. createE2eSeedRoutes()\nnow accepts mailOutbox: { sent: readonly EmailMessage[] } (the raw\ntransport's own array); inboxQuerySchema's tenantId is optional. The route\nreads whichever source(s) are configured, filters by to, and returns each\nsource newest-first instead of oldest-first.",
|
|
12
|
+
"migration": "mailCapture(request, tenantId, to) -> mailCapture(request, to, { tenantId }),\nand it now resolves to a single CapturedMail (the newest match) instead of\na readonly CapturedMail[]. Pass match: (mail) => boolean to pick a mail\nother than the newest at that address. Apps with their own ungated debug\nroute for a raw transport (e.g. /_debug/mails.json) pass that transport as\ncreateE2eSeedRoutes({ mailOutbox: transport }) and delete the app-local\nroute; tenantId becomes optional wherever only mailOutbox is used. Any\ndirect reader of GET /__test/inbox must expect newest-first ordering."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.306.0",
|
|
16
|
+
"type": "improvement",
|
|
17
|
+
"title": "runMatrix supports device-emulation Playwright projects for real native-size screenshots"
|
|
18
|
+
},
|
|
2
19
|
{
|
|
3
20
|
"version": "0.305.0",
|
|
4
21
|
"type": "fix",
|
package/src/e2e/auth-kit.ts
CHANGED
|
@@ -48,9 +48,16 @@ export async function loginViaApi(
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
// Leaves the app page first: an app page still open while its cookies vanish redirects itself
|
|
52
|
+
// to /login?next=… on its next request, racing whatever the test navigates to next.
|
|
53
|
+
export async function clearSession(page: Page): Promise<void> {
|
|
54
|
+
await page.goto("about:blank");
|
|
55
|
+
await page.context().clearCookies();
|
|
56
|
+
}
|
|
57
|
+
|
|
51
58
|
export async function loginViaUi(page: Page, credentials: LoginCredentials): Promise<void> {
|
|
52
59
|
// seedTenant already logged the context in, and an authenticated session never renders /login.
|
|
53
|
-
await page
|
|
60
|
+
await clearSession(page);
|
|
54
61
|
await page.goto("/login");
|
|
55
62
|
await page.locator("#login-email").fill(credentials.email);
|
|
56
63
|
await page.locator("#login-password").fill(credentials.password);
|
package/src/e2e/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ export {
|
|
|
3
3
|
apiCommand,
|
|
4
4
|
apiQuery,
|
|
5
5
|
apiWrite,
|
|
6
|
+
clearSession,
|
|
6
7
|
createHttpApi,
|
|
7
8
|
csrfFetch,
|
|
8
9
|
csrfHeaderFromCookies,
|
|
@@ -26,7 +27,7 @@ export {
|
|
|
26
27
|
type E2eProject,
|
|
27
28
|
resolveE2eWorkers,
|
|
28
29
|
} from "./define-app-e2e-config";
|
|
29
|
-
export { mailCapture } from "./mail-capture";
|
|
30
|
+
export { type MailCaptureOptions, mailCapture } from "./mail-capture";
|
|
30
31
|
export { pinEnglishLocale } from "./pin-english-locale";
|
|
31
32
|
export { pollForRow, waitForProjection } from "./poll";
|
|
32
33
|
export {
|
package/src/e2e/mail-capture.ts
CHANGED
|
@@ -15,12 +15,12 @@ export function seedRouteHeaders(): Record<string, string> {
|
|
|
15
15
|
|
|
16
16
|
async function readInbox(
|
|
17
17
|
request: APIRequestContext,
|
|
18
|
-
tenantId: string,
|
|
19
18
|
to: string,
|
|
19
|
+
tenantId: string | undefined,
|
|
20
20
|
): Promise<readonly CapturedMail[]> {
|
|
21
21
|
const response = await request.get(SEED_ROUTES.inbox, {
|
|
22
22
|
headers: seedRouteHeaders(),
|
|
23
|
-
params: { tenantId, to },
|
|
23
|
+
params: tenantId === undefined ? { to } : { tenantId, to },
|
|
24
24
|
});
|
|
25
25
|
if (!response.ok()) {
|
|
26
26
|
throw new Error(
|
|
@@ -30,14 +30,30 @@ async function readInbox(
|
|
|
30
30
|
return inboxResponseSchema.parse(await response.json()).messages;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
export
|
|
33
|
+
export type MailCaptureOptions = {
|
|
34
|
+
readonly tenantId?: string;
|
|
35
|
+
readonly match?: (mail: CapturedMail) => boolean;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// The inbox route returns the tenant buffer before the mailOutbox, each
|
|
39
|
+
// newest-first; `.find` keeps that order because mails carry no timestamp.
|
|
40
|
+
// With both sources mounted, a tenant-buffer match wins over a newer outbox one.
|
|
41
|
+
export async function mailCapture(
|
|
34
42
|
request: APIRequestContext,
|
|
35
|
-
tenantId: string,
|
|
36
43
|
to: string,
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
44
|
+
opts: MailCaptureOptions = {},
|
|
45
|
+
): Promise<CapturedMail> {
|
|
46
|
+
const { tenantId, match } = opts;
|
|
47
|
+
const isCandidate = (mail: CapturedMail) => match === undefined || match(mail);
|
|
48
|
+
const description = tenantId === undefined ? `for ${to}` : `for ${to} in tenant ${tenantId}`;
|
|
49
|
+
const message = `mailCapture: no mail arrived ${description}`;
|
|
50
|
+
|
|
51
|
+
const messages = await waitForProjection(
|
|
52
|
+
() => readInbox(request, to, tenantId),
|
|
53
|
+
(candidates) => candidates.some(isCandidate),
|
|
54
|
+
message,
|
|
42
55
|
);
|
|
56
|
+
const mail = messages.find(isCandidate);
|
|
57
|
+
if (mail === undefined) throw new Error(message);
|
|
58
|
+
return mail;
|
|
43
59
|
}
|
package/src/e2e/screenshots.ts
CHANGED
|
@@ -166,7 +166,7 @@ export function runScreenshots(scenarios: readonly Scenario[], opts: FlatOptions
|
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
const VIEWPORT_IDS = ["desktop", "tablet", "mobile"] as const;
|
|
169
|
-
type ViewportId = (typeof VIEWPORT_IDS)[number];
|
|
169
|
+
export type ViewportId = (typeof VIEWPORT_IDS)[number];
|
|
170
170
|
const VIEWPORTS: Record<ViewportId, { readonly width: number; readonly height: number }> = {
|
|
171
171
|
// 1920×1080 instead of the earlier 1280×900: these shots land in the
|
|
172
172
|
// handbook and doc pages, where a 1280 image visibly softens on a HiDPI
|
|
@@ -245,6 +245,49 @@ export function findIdenticalThemeScreenshots<T extends string>(
|
|
|
245
245
|
return violations;
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
+
export interface MatrixProjectInfo {
|
|
249
|
+
readonly name: string;
|
|
250
|
+
readonly isMobile: boolean;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export type MatrixViewportPlan =
|
|
254
|
+
| { readonly mode: "device"; readonly viewports: readonly [ViewportId] }
|
|
255
|
+
| { readonly mode: "desktop"; readonly viewports: readonly ViewportId[] }
|
|
256
|
+
| { readonly mode: "skip"; readonly reason: string };
|
|
257
|
+
|
|
258
|
+
function isViewportId(name: string): name is ViewportId {
|
|
259
|
+
return VIEWPORT_IDS.some((id) => id === name);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// A device project (name === a ViewportId, use.isMobile === true) already
|
|
263
|
+
// renders at its emulated device size, so setViewportSize would destroy that
|
|
264
|
+
// emulation — it captures exactly its own viewport instead of looping. Every
|
|
265
|
+
// other project runs the desktop pass, skipping ids a device project already
|
|
266
|
+
// covers so the same image isn't produced twice.
|
|
267
|
+
export function resolveMatrixViewports(
|
|
268
|
+
projectName: string,
|
|
269
|
+
isMobileProject: boolean,
|
|
270
|
+
projects: readonly MatrixProjectInfo[],
|
|
271
|
+
allowedViewports: readonly ViewportId[],
|
|
272
|
+
): MatrixViewportPlan {
|
|
273
|
+
if (isMobileProject && isViewportId(projectName)) {
|
|
274
|
+
if (!allowedViewports.includes(projectName)) {
|
|
275
|
+
return {
|
|
276
|
+
mode: "skip",
|
|
277
|
+
reason: `SCREENSHOT_VIEWPORTS excludes device project "${projectName}"`,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
return { mode: "device", viewports: [projectName] };
|
|
281
|
+
}
|
|
282
|
+
const deviceProjectIds = new Set(
|
|
283
|
+
projects.filter((p) => p.isMobile && isViewportId(p.name)).map((p) => p.name),
|
|
284
|
+
);
|
|
285
|
+
return {
|
|
286
|
+
mode: "desktop",
|
|
287
|
+
viewports: allowedViewports.filter((id) => !deviceProjectIds.has(id)),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
248
291
|
export function runMatrix<T extends string>(
|
|
249
292
|
scenarios: readonly Scenario[],
|
|
250
293
|
opts: MatrixOptions<T>,
|
|
@@ -273,6 +316,19 @@ export function runMatrix<T extends string>(
|
|
|
273
316
|
for (const s of scenarios) {
|
|
274
317
|
if (only !== undefined && only !== s.name) continue;
|
|
275
318
|
test(s.name, async ({ page, seedTenant }) => {
|
|
319
|
+
const info = test.info();
|
|
320
|
+
const plan = resolveMatrixViewports(
|
|
321
|
+
info.project.name,
|
|
322
|
+
info.project.use.isMobile === true,
|
|
323
|
+
info.config.projects.map((p) => ({ name: p.name, isMobile: p.use.isMobile === true })),
|
|
324
|
+
viewports,
|
|
325
|
+
);
|
|
326
|
+
if (plan.mode === "skip") {
|
|
327
|
+
test.skip(true, plan.reason);
|
|
328
|
+
// skip: test.skip() throws; the return only narrows `plan` for TypeScript.
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
276
332
|
// kumiko:locale drives the boot-time language (before goto); kumiko:theme
|
|
277
333
|
// is cleared so the mode is decided solely by applyTheme.
|
|
278
334
|
await page.addInitScript((lng) => {
|
|
@@ -286,8 +342,8 @@ export function runMatrix<T extends string>(
|
|
|
286
342
|
|
|
287
343
|
for (const theme of themes) {
|
|
288
344
|
await opts.applyTheme(page, theme);
|
|
289
|
-
for (const vp of viewports) {
|
|
290
|
-
await page.setViewportSize(VIEWPORTS[vp]);
|
|
345
|
+
for (const vp of plan.viewports) {
|
|
346
|
+
if (plan.mode === "desktop") await page.setViewportSize(VIEWPORTS[vp]);
|
|
291
347
|
await waitForSettledPage(page, inFlightDataRequests);
|
|
292
348
|
if (s.beforeCapture) await s.beforeCapture(page);
|
|
293
349
|
const dir = `${baseDir}/${s.name}/${locale}/${theme}`;
|
package/src/e2e/seed-contract.ts
CHANGED
|
@@ -65,7 +65,7 @@ export const seedUserRequestSchema = createSeedUserRequestSchema();
|
|
|
65
65
|
export const seedUserResponseSchema = seededCredentialsSchema;
|
|
66
66
|
|
|
67
67
|
export const inboxQuerySchema = z.strictObject({
|
|
68
|
-
tenantId: z.uuid(),
|
|
68
|
+
tenantId: z.uuid().optional(),
|
|
69
69
|
to: z.string().min(1).max(MAX_EMAIL_LENGTH),
|
|
70
70
|
});
|
|
71
71
|
|
package/src/e2e/seed-route.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import type { EmailMessage } from "@cosmicdrift/kumiko-bundled-features/channel-email";
|
|
2
3
|
import {
|
|
3
4
|
getInbox,
|
|
4
5
|
mailTransportInMemoryFeature,
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
} from "../seed-tenant";
|
|
20
21
|
import { SEED_ENABLE_ENV, SEED_ROUTES, SEED_TOKEN_ENV, SEED_TOKEN_HEADER } from "./constants";
|
|
21
22
|
import {
|
|
23
|
+
type CapturedMail,
|
|
22
24
|
createSeedUserRequestSchema,
|
|
23
25
|
inboxQuerySchema,
|
|
24
26
|
seedTenantRequestSchema,
|
|
@@ -89,8 +91,24 @@ function assertGateOpen(request: SignatureExtraRouteVerifyRequest): void {
|
|
|
89
91
|
|
|
90
92
|
export type E2eSeedRoutesOptions = {
|
|
91
93
|
readonly extraRoles?: readonly string[];
|
|
94
|
+
// Apps that send tenantless mail (signup/forgot-password/magic-link) via
|
|
95
|
+
// their own raw createInMemoryTransport() pass its `sent` array here so
|
|
96
|
+
// the inbox route can read it without a tenantId. Independent of
|
|
97
|
+
// mailTransportInMemoryFeature — both sources may be present at once.
|
|
98
|
+
readonly mailOutbox?: { readonly sent: readonly EmailMessage[] };
|
|
92
99
|
};
|
|
93
100
|
|
|
101
|
+
function toCapturedMail(message: EmailMessage): CapturedMail {
|
|
102
|
+
return {
|
|
103
|
+
to: message.to,
|
|
104
|
+
subject: message.subject,
|
|
105
|
+
html: message.html,
|
|
106
|
+
...(message.from !== undefined ? { from: message.from } : {}),
|
|
107
|
+
...(message.replyTo !== undefined ? { replyTo: message.replyTo } : {}),
|
|
108
|
+
...(message.headers !== undefined ? { headers: message.headers } : {}),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
94
112
|
// Builds the route definitions unconditionally (extraRoles validation must
|
|
95
113
|
// still throw in every environment) — the *mounting* decision is the
|
|
96
114
|
// caller's: use isE2eSeedingEnabled() to keep prod from ever registering
|
|
@@ -168,16 +186,37 @@ export function createE2eSeedRoutes(
|
|
|
168
186
|
);
|
|
169
187
|
},
|
|
170
188
|
handler: async (c, verified, deps) => {
|
|
171
|
-
|
|
189
|
+
const recipient = verified.to.toLowerCase();
|
|
190
|
+
const isRecipient = (message: { readonly to: string }) =>
|
|
191
|
+
message.to.toLowerCase() === recipient;
|
|
192
|
+
|
|
193
|
+
const tenantId = verified.tenantId;
|
|
194
|
+
const mailOutbox = options.mailOutbox;
|
|
195
|
+
const hasTenantSource =
|
|
196
|
+
tenantId !== undefined && deps.registry.features.has(mailTransportInMemoryFeature.name);
|
|
197
|
+
if (!hasTenantSource && mailOutbox === undefined) {
|
|
172
198
|
return c.json(
|
|
173
|
-
{
|
|
199
|
+
{
|
|
200
|
+
error:
|
|
201
|
+
`no inbox to read: neither ${mailTransportInMemoryFeature.name} is mounted with a ` +
|
|
202
|
+
"tenantId nor was mailOutbox passed to createE2eSeedRoutes()",
|
|
203
|
+
},
|
|
174
204
|
501,
|
|
175
205
|
);
|
|
176
206
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
207
|
+
|
|
208
|
+
// Both sources may be present; concatenation order between them is
|
|
209
|
+
// undefined (neither carries a timestamp). Within each source, newest
|
|
210
|
+
// first — that ordering is what mailCapture and its tests rely on.
|
|
211
|
+
const tenantMessages =
|
|
212
|
+
hasTenantSource && tenantId !== undefined
|
|
213
|
+
? [...getInbox(tenantId)].filter(isRecipient).reverse().map(toCapturedMail)
|
|
214
|
+
: [];
|
|
215
|
+
const outboxMessages =
|
|
216
|
+
mailOutbox !== undefined
|
|
217
|
+
? [...mailOutbox.sent].filter(isRecipient).reverse().map(toCapturedMail)
|
|
218
|
+
: [];
|
|
219
|
+
const messages: readonly CapturedMail[] = [...tenantMessages, ...outboxMessages];
|
|
181
220
|
return c.json({ messages });
|
|
182
221
|
},
|
|
183
222
|
});
|