@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 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.305.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.305.0",
67
- "@cosmicdrift/kumiko-dev-server": "0.305.0",
68
- "@cosmicdrift/kumiko-framework": "0.305.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",
@@ -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.context().clearCookies();
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 {
@@ -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 function mailCapture(
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
- ): Promise<readonly CapturedMail[]> {
38
- return waitForProjection(
39
- () => readInbox(request, tenantId, to),
40
- (messages) => messages.length > 0,
41
- `mailCapture: no mail arrived for ${to} in tenant ${tenantId}`,
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
  }
@@ -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}`;
@@ -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
 
@@ -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
- if (!deps.registry.features.has(mailTransportInMemoryFeature.name)) {
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
- { error: `${mailTransportInMemoryFeature.name} is not mounted; no inbox to read` },
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
- const recipient = verified.to.toLowerCase();
178
- const messages = getInbox(verified.tenantId).filter(
179
- (message) => message.to.toLowerCase() === recipient,
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
  });