@cosmicdrift/kumiko-testing 0.353.0 → 0.354.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-testing",
3
- "version": "0.353.0",
3
+ "version": "0.354.1",
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>",
@@ -66,6 +66,10 @@
66
66
  "types": "./src/e2e/index.ts",
67
67
  "default": "./src/e2e/index.ts"
68
68
  },
69
+ "./e2e/constants": {
70
+ "types": "./src/e2e/constants.ts",
71
+ "default": "./src/e2e/constants.ts"
72
+ },
69
73
  "./e2e/seed-route": {
70
74
  "types": "./src/e2e/seed-route.ts",
71
75
  "default": "./src/e2e/seed-route.ts"
@@ -75,9 +79,9 @@
75
79
  "kumiko-testing": "./bin/kumiko-testing.ts"
76
80
  },
77
81
  "dependencies": {
78
- "@cosmicdrift/kumiko-bundled-features": "0.353.0",
79
- "@cosmicdrift/kumiko-dev-server": "0.353.0",
80
- "@cosmicdrift/kumiko-framework": "0.353.0",
82
+ "@cosmicdrift/kumiko-bundled-features": "0.354.1",
83
+ "@cosmicdrift/kumiko-dev-server": "0.354.1",
84
+ "@cosmicdrift/kumiko-framework": "0.354.1",
81
85
  "zod": "^4.4.3"
82
86
  },
83
87
  "peerDependencies": {
package/src/changes.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.354.0",
4
+ "type": "improvement",
5
+ "title": "trackInFlightRequests(page) export for captureScreenshot",
6
+ "detail": "Export trackInFlightRequests(page) so captureScreenshot counts requests from the initial load; the first untracked call waits for network idle, and a pushState during a delayed navigation no longer drops in-flight requests"
7
+ },
2
8
  {
3
9
  "version": "0.353.0",
4
10
  "type": "fix",
@@ -65,10 +65,11 @@ async function postAuth(
65
65
  path: string,
66
66
  data: Record<string, unknown>,
67
67
  email: string,
68
+ bucketKey: string = email,
68
69
  ): Promise<APIResponse> {
69
70
  const response = await request.post(path, {
70
71
  data,
71
- headers: { [CLIENT_IP_HEADER]: syntheticClientIpFor(email) },
72
+ headers: { [CLIENT_IP_HEADER]: syntheticClientIpFor(bucketKey) },
72
73
  });
73
74
  if (!response.ok()) {
74
75
  throw new Error(
@@ -96,15 +97,24 @@ function assertNoUnansweredMfa(email: string, reply: z.infer<typeof loginReplySc
96
97
  }
97
98
  }
98
99
 
100
+ export type LoginViaApiOptions = {
101
+ // Rate-limit bucket (see syntheticClientIpFor) instead of the account's email,
102
+ // for runs that log the same account in repeatedly and must not share its bucket.
103
+ readonly bucketKey?: string;
104
+ };
105
+
99
106
  export async function loginViaApi(
100
107
  request: APIRequestContext,
101
108
  credentials: LoginCredentials,
109
+ options: LoginViaApiOptions = {},
102
110
  ): Promise<void> {
111
+ const bucketKey = options.bucketKey ?? credentials.email;
103
112
  const response = await postAuth(
104
113
  request,
105
114
  "/api/auth/login",
106
115
  { email: credentials.email, password: credentials.password },
107
116
  credentials.email,
117
+ bucketKey,
108
118
  );
109
119
  const reply = loginReplySchema.parse(await response.json());
110
120
  if (credentials.mfaTotpSecret === undefined) {
@@ -131,6 +141,7 @@ export async function loginViaApi(
131
141
  code: await unburnedTotpCode(credentials.mfaTotpSecret),
132
142
  },
133
143
  credentials.email,
144
+ bucketKey,
134
145
  );
135
146
  }
136
147
 
package/src/e2e/index.ts CHANGED
@@ -8,12 +8,14 @@ export {
8
8
  apiCommand,
9
9
  apiQuery,
10
10
  apiWrite,
11
+ CLIENT_IP_HEADER,
11
12
  clearSession,
12
13
  createHttpApi,
13
14
  csrfFetch,
14
15
  csrfHeaderFromCookies,
15
16
  enrollTotpViaApi,
16
17
  type LoginCredentials,
18
+ type LoginViaApiOptions,
17
19
  loginViaApi,
18
20
  loginViaUi,
19
21
  syntheticClientIpFor,
@@ -58,6 +60,7 @@ export {
58
60
  type Scenario,
59
61
  type ScenarioFixtures,
60
62
  type ThemeScreenshotDigest,
63
+ trackInFlightRequests,
61
64
  validateScenarios,
62
65
  } from "./screenshots";
63
66
  export type { CapturedMail } from "./seed-contract";
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { mkdirSync, statSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
4
  import { isRealProviderRun } from "@cosmicdrift/kumiko-framework/testing/real-providers";
5
- import { expect, type Page, type Request } from "@playwright/test";
5
+ import { expect, type Page, errors as playwrightErrors, type Request } from "@playwright/test";
6
6
  import { DESKTOP_VIEWPORT } from "./constants";
7
7
  import { pinEnglishLocale } from "./pin-english-locale";
8
8
  import { requireScreenshotDir, SCREENSHOT_DIR_ENV } from "./screenshot-dir";
@@ -92,18 +92,31 @@ function isMainFrameNavigationRequest(page: Page, request: Request): boolean {
92
92
  // Chromium drops the old document's fetches on a cross-document navigation
93
93
  // (reload, goto, location.href) without requestfinished or requestfailed, so
94
94
  // they would stay in flight forever. framenavigated alone can't tell that commit
95
- // apart from a pushState, which must keep them: only a framenavigated preceded by
96
- // a main-frame navigation request is a new document.
95
+ // apart from a pushState in the still-live old document, which must keep them:
96
+ // only a framenavigated after the navigation request's response is a new
97
+ // document (a delayed navigation response leaves the old document running).
97
98
  function countInFlightDataRequests(page: Page): () => number {
98
99
  const inFlight = new Set<Request>();
99
100
  let pendingDocumentNavigation: Request | undefined;
101
+ let pendingNavigationResponded = false;
100
102
  page.on("request", (request) => {
101
103
  if (DATA_REQUEST_TYPES.has(request.resourceType())) inFlight.add(request);
102
- else if (isMainFrameNavigationRequest(page, request)) pendingDocumentNavigation = request;
104
+ else if (isMainFrameNavigationRequest(page, request)) {
105
+ pendingDocumentNavigation = request;
106
+ pendingNavigationResponded = false;
107
+ }
108
+ });
109
+ page.on("response", (response) => {
110
+ if (response.request() === pendingDocumentNavigation) pendingNavigationResponded = true;
103
111
  });
104
112
  page.on("framenavigated", (frame) => {
105
- if (frame === page.mainFrame() && pendingDocumentNavigation !== undefined) {
113
+ if (
114
+ frame === page.mainFrame() &&
115
+ pendingDocumentNavigation !== undefined &&
116
+ pendingNavigationResponded
117
+ ) {
106
118
  pendingDocumentNavigation = undefined;
119
+ pendingNavigationResponded = false;
107
120
  inFlight.clear();
108
121
  }
109
122
  });
@@ -113,7 +126,10 @@ function countInFlightDataRequests(page: Page): () => number {
113
126
  page.on("requestfailed", (request) => {
114
127
  inFlight.delete(request);
115
128
  // A navigation that never commits (204, download) leaves the old document alive.
116
- if (request === pendingDocumentNavigation) pendingDocumentNavigation = undefined;
129
+ if (request === pendingDocumentNavigation) {
130
+ pendingDocumentNavigation = undefined;
131
+ pendingNavigationResponded = false;
132
+ }
117
133
  });
118
134
  return () => inFlight.size;
119
135
  }
@@ -619,13 +635,15 @@ export function runMatrix<T extends string>(
619
635
  }
620
636
  }
621
637
 
622
- // Per-page in-flight counter for captureScreenshot() — a call mid-flow attaches
623
- // its own page.on("request") listener the first time it sees a given page, so a
624
- // second captureScreenshot() on that page reuses the same tracker instead of
625
- // missing requests that were already in flight when the listener attached.
626
638
  const inFlightTrackersByPage = new WeakMap<Page, () => number>();
627
639
 
628
- function inFlightTrackerFor(page: Page): () => number {
640
+ /**
641
+ * Starts counting in-flight data requests (fetch/xhr) on `page`; idempotent per
642
+ * page. Call it before the first navigation (a fixture or beforeEach) so that
643
+ * captureScreenshot() also sees requests started by the initial page load.
644
+ * Requests that began before registration are invisible to it.
645
+ */
646
+ export function trackInFlightRequests(page: Page): () => number {
629
647
  const existing = inFlightTrackersByPage.get(page);
630
648
  if (existing !== undefined) return existing;
631
649
  const tracker = countInFlightDataRequests(page);
@@ -633,6 +651,17 @@ function inFlightTrackerFor(page: Page): () => number {
633
651
  return tracker;
634
652
  }
635
653
 
654
+ // Bounded because a page holding an EventSource (dev hot-reload) never goes network-idle.
655
+ const UNTRACKED_PAGE_IDLE_TIMEOUT_MS = 3_000;
656
+
657
+ async function waitForNetworkIdleBestEffort(page: Page): Promise<void> {
658
+ try {
659
+ await page.waitForLoadState("networkidle", { timeout: UNTRACKED_PAGE_IDLE_TIMEOUT_MS });
660
+ } catch (error) {
661
+ if (!(error instanceof playwrightErrors.TimeoutError)) throw error;
662
+ }
663
+ }
664
+
636
665
  // "viewport": what the window shows. "fullPage": Playwright's document-height
637
666
  // capture, which renders a sticky sidebar floating mid-page. "content": grows the
638
667
  // viewport until nothing overflows, including WorkspaceShell's internally
@@ -714,10 +743,11 @@ async function growViewportToContent(page: Page, name: string, width: number): P
714
743
  // spec (solon's `shot(page, id)`, offlot's inline docs/screenshots/e2e/* writes) —
715
744
  // reuses the runner's settle logic and reduced-motion default, and is a no-op
716
745
  // when SCREENSHOT_DIR is unset so a plain e2e run never writes into the repo.
717
- // Limitation: the in-flight tracker attaches on the first call per page, so data
718
- // requests already running at that moment are invisible to it. Right after a
719
- // click, `await expect(...)` the loaded state (or call this once earlier on the
720
- // page) before the first shot.
746
+ // Limitation: without an earlier trackInFlightRequests(page) the tracker attaches
747
+ // on the first call, so requests already running then are invisible to it; that
748
+ // first call only waits for network idle (best effort, skipped on timeout for
749
+ // pages with a never-ending stream). Register the tracker before navigating, or
750
+ // `await expect(...)` the loaded state after a click.
721
751
  export async function captureScreenshot(
722
752
  page: Page,
723
753
  name: string,
@@ -730,7 +760,10 @@ export async function captureScreenshot(
730
760
  const identities = opts.presentIdentities ?? [];
731
761
  assertPresentableMappings(identities);
732
762
  await page.emulateMedia({ reducedMotion: opts.reducedMotion ?? DEFAULT_REDUCED_MOTION });
733
- await waitForSettledPage(page, inFlightTrackerFor(page));
763
+ const alreadyTracked = inFlightTrackersByPage.has(page);
764
+ const tracker = trackInFlightRequests(page);
765
+ if (!alreadyTracked) await waitForNetworkIdleBestEffort(page);
766
+ await waitForSettledPage(page, tracker);
734
767
  const path = `${dir}/${name}.png`;
735
768
  mkdirSync(dirname(path), { recursive: true });
736
769
  // Before the content fit too: a presented value of another length can change the overflow.
@@ -4,6 +4,7 @@ import {
4
4
  getInbox,
5
5
  mailTransportInMemoryFeature,
6
6
  } from "@cosmicdrift/kumiko-bundled-features/mail-transport-inmemory";
7
+ import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
7
8
  import {
8
9
  type ExtraRouteDefinition,
9
10
  ExtraRouteRejection,
@@ -206,6 +207,15 @@ export function createE2eSeedRoutes(
206
207
  return parsed;
207
208
  },
208
209
  handler: async (c, verified, deps) => {
210
+ // seededTenantIds is never pruned, so a tenant removed after seeding must still 404.
211
+ const tenantRow = await deps.dispatchSystemQuery({
212
+ handlerQn: TenantQueries.me,
213
+ payload: {},
214
+ tenantId: verified.tenantId,
215
+ });
216
+ if (tenantRow === null) {
217
+ return c.json({ error: `unknown tenant ${verified.tenantId}` }, 404);
218
+ }
209
219
  const write: SeedWriter = async (handlerQn, payload, tenantId) =>
210
220
  unwrapSavedRow(handlerQn, await deps.dispatchSystemWrite({ handlerQn, payload, tenantId }));
211
221
  try {