@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 +8 -4
- package/src/changes.json +6 -0
- package/src/e2e/auth-kit.ts +12 -1
- package/src/e2e/index.ts +3 -0
- package/src/e2e/screenshots.ts +49 -16
- package/src/e2e/seed-route.ts +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-testing",
|
|
3
|
-
"version": "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.
|
|
79
|
-
"@cosmicdrift/kumiko-dev-server": "0.
|
|
80
|
-
"@cosmicdrift/kumiko-framework": "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",
|
package/src/e2e/auth-kit.ts
CHANGED
|
@@ -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(
|
|
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";
|
package/src/e2e/screenshots.ts
CHANGED
|
@@ -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:
|
|
96
|
-
// a
|
|
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))
|
|
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 (
|
|
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)
|
|
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
|
-
|
|
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:
|
|
718
|
-
// requests already running
|
|
719
|
-
//
|
|
720
|
-
//
|
|
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
|
-
|
|
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.
|
package/src/e2e/seed-route.ts
CHANGED
|
@@ -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 {
|