@cosmicdrift/kumiko-testing 0.320.0 → 0.322.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/bin/kumiko-testing.ts +26 -10
- package/package.json +4 -4
- package/src/changes.json +21 -0
- package/src/e2e/auth-kit.ts +12 -0
- package/src/e2e/constants.ts +4 -0
- package/src/e2e/define-app-e2e-config.ts +7 -0
- package/src/e2e/index.ts +1 -0
- package/src/e2e/seeded-tenant-fixture.ts +74 -3
- package/src/integration-runner.ts +22 -0
package/bin/kumiko-testing.ts
CHANGED
|
@@ -5,15 +5,20 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
5
5
|
import { parseArgs } from "node:util";
|
|
6
6
|
import { Glob } from "bun";
|
|
7
7
|
import { BUNFIG_FILES, mergeBunfig, renderBunfigFiles } from "../src/bunfig";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
buildIntegrationTestArgs,
|
|
10
|
+
resolveRequestedIntegrationFiles,
|
|
11
|
+
selectIntegrationFiles,
|
|
12
|
+
} from "../src/integration-runner";
|
|
9
13
|
|
|
10
14
|
const USAGE = `kumiko-testing <command>
|
|
11
15
|
|
|
12
16
|
bunfig [--dom] [--coverage] [--hoisted] write bunfig.toml, bunfig.integration.toml and
|
|
13
17
|
bunfig.real.toml (plus bunfig.dom.toml with --dom);
|
|
14
18
|
--hoisted adds [install] linker = "hoisted"
|
|
15
|
-
integration [--parallel N] run every *.integration.test.ts under the cwd
|
|
16
|
-
[--timings <file>] [--update-timings]
|
|
19
|
+
integration [--parallel N] run every *.integration.test.ts under the cwd,
|
|
20
|
+
[--timings <file>] [--update-timings] or only the given file(s) when passed
|
|
21
|
+
[file...] as positional args
|
|
17
22
|
`;
|
|
18
23
|
|
|
19
24
|
function runBunfig(args: readonly string[]): number {
|
|
@@ -53,25 +58,36 @@ function runBunfig(args: readonly string[]): number {
|
|
|
53
58
|
}
|
|
54
59
|
|
|
55
60
|
async function runIntegration(args: readonly string[]): Promise<number> {
|
|
56
|
-
const { values } = parseArgs({
|
|
61
|
+
const { values, positionals } = parseArgs({
|
|
57
62
|
args: [...args],
|
|
58
63
|
options: {
|
|
59
64
|
parallel: { type: "string" },
|
|
60
65
|
timings: { type: "string" },
|
|
61
66
|
"update-timings": { type: "boolean" },
|
|
62
67
|
},
|
|
68
|
+
allowPositionals: true,
|
|
63
69
|
strict: true,
|
|
64
70
|
});
|
|
65
71
|
if (!existsSync(BUNFIG_FILES.integration)) {
|
|
66
72
|
console.error(`${BUNFIG_FILES.integration} not found - run \`kumiko-testing bunfig\` first`);
|
|
67
73
|
return 1;
|
|
68
74
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
+
let files: string[];
|
|
76
|
+
if (positionals.length > 0) {
|
|
77
|
+
try {
|
|
78
|
+
files = resolveRequestedIntegrationFiles(process.cwd(), positionals);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
81
|
+
return 1;
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
files = selectIntegrationFiles(
|
|
85
|
+
await Array.fromAsync(new Glob("**/*.integration.test.ts").scan({ cwd: process.cwd() })),
|
|
86
|
+
);
|
|
87
|
+
if (files.length === 0) {
|
|
88
|
+
console.error("no *.integration.test.ts files found");
|
|
89
|
+
return 1;
|
|
90
|
+
}
|
|
75
91
|
}
|
|
76
92
|
const testArgs = buildIntegrationTestArgs({
|
|
77
93
|
files,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.322.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>",
|
|
@@ -75,9 +75,9 @@
|
|
|
75
75
|
"kumiko-testing": "./bin/kumiko-testing.ts"
|
|
76
76
|
},
|
|
77
77
|
"dependencies": {
|
|
78
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
79
|
-
"@cosmicdrift/kumiko-dev-server": "0.
|
|
80
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
78
|
+
"@cosmicdrift/kumiko-bundled-features": "0.322.0",
|
|
79
|
+
"@cosmicdrift/kumiko-dev-server": "0.322.0",
|
|
80
|
+
"@cosmicdrift/kumiko-framework": "0.322.0",
|
|
81
81
|
"zod": "^4.4.3"
|
|
82
82
|
},
|
|
83
83
|
"peerDependencies": {
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,25 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.321.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "kumiko-testing integration accepts positional test-file args",
|
|
6
|
+
"detail": "`kumiko-testing integration` now runs only the given files/globs when\npositional args are passed, instead of always discovering every\n`*.integration.test.ts` file.",
|
|
7
|
+
"migration": "No action needed — omitting positional args keeps the previous full-discovery behavior."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.321.0",
|
|
11
|
+
"type": "improvement",
|
|
12
|
+
"title": "defineAppE2eConfig reserves KUMIKO_TRUSTED_PROXY_HOPS; loginViaApi sends a synthetic per-user X-Forwarded-For",
|
|
13
|
+
"detail": "`defineAppE2eConfig`'s `webServer.env` now sets\n`KUMIKO_TRUSTED_PROXY_HOPS=1` and rejects that key in a consumer's own\n`env` (template-owned, added to `RESERVED_ENV_KEYS`). `loginViaApi` sends\n`x-forwarded-for: syntheticClientIpFor(credentials.email)`, a new\nexported helper deriving a deterministic private-range IP from\n`sha256(email)`, so each seeded user's login lands in its own\nrate-limit bucket instead of every ::1 client sharing one.\n`loginViaUi` is unchanged.",
|
|
14
|
+
"migration": "Consumers using `defineAppE2eConfig` + `loginViaApi` (incl. the\n`seedTenant` fixture) need no changes — each seeded user now logs in\nfrom its own bucket. Browser logins (`loginViaUi`, raw `fetch` to\n`/api/auth/login` from a page) still share the `::1` bucket. A consumer\nwith its own e2e login helper (bypassing `loginViaApi`) should send\n`x-forwarded-for: syntheticClientIpFor(email)` (exported from\n`@cosmicdrift/kumiko-testing`) themselves, and must not set\n`KUMIKO_TRUSTED_PROXY_HOPS` in `defineAppE2eConfig`'s `env` — it's now\nreserved."
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "0.321.0",
|
|
18
|
+
"type": "fix",
|
|
19
|
+
"title": "e2e test fixture gives every test its own synthetic client IP",
|
|
20
|
+
"detail": "`test` from `@cosmicdrift/kumiko-testing/e2e` overrides the `context`\nfixture with a `context.route(\"**/api/**\")` that adds\n`x-forwarded-for: syntheticClientIpFor(perTestClientIpKey(testInfo))` to\nsame-origin requests; an explicit `x-forwarded-for` wins. Cross-origin\nrequests stay untouched because an extra header would force a CORS\npreflight. Routing disables the HTTP cache of the test's context.\n`seedTenant`'s per-user API contexts send\n`syntheticClientIpFor(user.email)`. The built-in `request` fixture is not\ncovered.",
|
|
21
|
+
"migration": "Specs that import `test` from `@cosmicdrift/kumiko-testing/e2e` need no\nchange. Specs importing `test` straight from `@playwright/test` keep\nsharing the `::1` bucket; switch their import to kumiko-testing."
|
|
22
|
+
},
|
|
2
23
|
{
|
|
3
24
|
"version": "0.320.0",
|
|
4
25
|
"type": "improvement",
|
package/src/e2e/auth-kit.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { currentTotpCode } from "@cosmicdrift/kumiko-bundled-features/auth-mfa/testing";
|
|
2
3
|
import type { WriteErrorInfo } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
4
|
import { type APIRequestContext, type APIResponse, expect, type Page } from "@playwright/test";
|
|
@@ -34,12 +35,23 @@ export async function csrfFetch(
|
|
|
34
35
|
return request.post(path, { headers: csrfHeaderFromCookies(cookies), data });
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
// Deterministic IP in the RFC 1918 private range, so the same key (a seeded
|
|
39
|
+
// user's email, a test's id) always lands in the same rate-limit bucket while
|
|
40
|
+
// different keys get distinct buckets (3 varying bytes ~= 16M slots).
|
|
41
|
+
export function syntheticClientIpFor(key: string): string {
|
|
42
|
+
const digest = createHash("sha256").update(key).digest();
|
|
43
|
+
return `10.${digest[0]}.${digest[1]}.${digest[2]}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const CLIENT_IP_HEADER = "x-forwarded-for";
|
|
47
|
+
|
|
37
48
|
export async function loginViaApi(
|
|
38
49
|
request: APIRequestContext,
|
|
39
50
|
credentials: LoginCredentials,
|
|
40
51
|
): Promise<void> {
|
|
41
52
|
const response = await request.post("/api/auth/login", {
|
|
42
53
|
data: { email: credentials.email, password: credentials.password },
|
|
54
|
+
headers: { [CLIENT_IP_HEADER]: syntheticClientIpFor(credentials.email) },
|
|
43
55
|
});
|
|
44
56
|
if (!response.ok()) {
|
|
45
57
|
throw new Error(
|
package/src/e2e/constants.ts
CHANGED
|
@@ -24,6 +24,10 @@ export const STYLESHEET_WATCH_ENV = "KUMIKO_DEV_STYLESHEET_WATCH";
|
|
|
24
24
|
// duplicated for the same Node/Bun-toolchain reason.
|
|
25
25
|
export const PROD_BUNDLES_ENV = "KUMIKO_DEV_PROD_BUNDLES";
|
|
26
26
|
|
|
27
|
+
// Same literal as kumiko-framework's client-ip.ts (TRUSTED_PROXY_HOPS_ENV),
|
|
28
|
+
// duplicated for the same Node/Bun-toolchain reason.
|
|
29
|
+
export const TRUSTED_PROXY_HOPS_ENV = "KUMIKO_TRUSTED_PROXY_HOPS";
|
|
30
|
+
|
|
27
31
|
export const SEED_ROUTE_PREFIX = "/__test";
|
|
28
32
|
|
|
29
33
|
export const SEED_ROUTES = {
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
SEED_ENABLE_ENV,
|
|
20
20
|
SEED_TOKEN_ENV,
|
|
21
21
|
STYLESHEET_WATCH_ENV,
|
|
22
|
+
TRUSTED_PROXY_HOPS_ENV,
|
|
22
23
|
} from "./constants";
|
|
23
24
|
import { isScreenshotRun, requireScreenshotDir, screenshotSpecsIgnore } from "./screenshot-dir";
|
|
24
25
|
import { E2E_TIMEOUT_MS } from "./timeouts";
|
|
@@ -61,6 +62,7 @@ const RESERVED_ENV_KEYS = [
|
|
|
61
62
|
SEED_TOKEN_ENV,
|
|
62
63
|
STYLESHEET_WATCH_ENV,
|
|
63
64
|
PROD_BUNDLES_ENV,
|
|
65
|
+
TRUSTED_PROXY_HOPS_ENV,
|
|
64
66
|
] as const;
|
|
65
67
|
|
|
66
68
|
type ProjectUse = Partial<PlaywrightTestOptions & PlaywrightWorkerOptions>;
|
|
@@ -205,6 +207,11 @@ export function defineAppE2eConfig(input: AppE2eConfigInput): PlaywrightTestConf
|
|
|
205
207
|
// E2E runs against prod-shaped bundles: splitting, no sourcemap,
|
|
206
208
|
// NODE_ENV=production — matches what actually ships.
|
|
207
209
|
[PROD_BUNDLES_ENV]: "1",
|
|
210
|
+
// E2e clients all share ::1, so the default trustedProxyHops (0,
|
|
211
|
+
// socket-only) would collapse every seeded user into one rate-limit
|
|
212
|
+
// bucket. loginViaApi sends a synthetic per-user X-Forwarded-For, so
|
|
213
|
+
// trust exactly one hop to give each user its own bucket.
|
|
214
|
+
[TRUSTED_PROXY_HOPS_ENV]: "1",
|
|
208
215
|
},
|
|
209
216
|
reuseExistingServer: false,
|
|
210
217
|
timeout: E2E_TIMEOUT_MS.webServer,
|
package/src/e2e/index.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
|
|
2
2
|
import {
|
|
3
3
|
type APIRequestContext,
|
|
4
|
+
type BrowserContext,
|
|
4
5
|
test as base,
|
|
5
6
|
type Page,
|
|
7
|
+
type Request as PlaywrightRequest,
|
|
6
8
|
type PlaywrightTestArgs,
|
|
7
9
|
type PlaywrightTestOptions,
|
|
8
10
|
type PlaywrightWorkerArgs,
|
|
11
|
+
type TestInfo,
|
|
9
12
|
} from "@playwright/test";
|
|
10
13
|
import type * as z from "zod";
|
|
11
14
|
import {
|
|
@@ -16,8 +19,14 @@ import {
|
|
|
16
19
|
type SeedTenantOptions,
|
|
17
20
|
withSession,
|
|
18
21
|
} from "../seed-types";
|
|
19
|
-
import {
|
|
20
|
-
|
|
22
|
+
import {
|
|
23
|
+
CLIENT_IP_HEADER,
|
|
24
|
+
clearSession,
|
|
25
|
+
createHttpApi,
|
|
26
|
+
loginViaApi,
|
|
27
|
+
syntheticClientIpFor,
|
|
28
|
+
} from "./auth-kit";
|
|
29
|
+
import { SEED_ROUTES, SEED_TOKEN_ENV } from "./constants";
|
|
21
30
|
import { seedRouteHeaders } from "./mail-capture";
|
|
22
31
|
import {
|
|
23
32
|
type ExtraSeedRequest,
|
|
@@ -77,7 +86,10 @@ export async function provideSeedTenant(
|
|
|
77
86
|
|
|
78
87
|
// One cookie jar per user: two tenants (or admin + member) in one test must not clobber each other's session.
|
|
79
88
|
const openLoggedInApi = async (user: SeededUser): Promise<BoundApi> => {
|
|
80
|
-
const ctx = await playwright.request.newContext({
|
|
89
|
+
const ctx = await playwright.request.newContext({
|
|
90
|
+
baseURL,
|
|
91
|
+
extraHTTPHeaders: { [CLIENT_IP_HEADER]: syntheticClientIpFor(user.email) },
|
|
92
|
+
});
|
|
81
93
|
openedContexts.push(ctx);
|
|
82
94
|
await loginViaApi(ctx, user);
|
|
83
95
|
return createHttpApi(ctx);
|
|
@@ -166,6 +178,65 @@ export async function provideSeedTenant(
|
|
|
166
178
|
await Promise.all(openedContexts.map((ctx) => ctx.dispose()));
|
|
167
179
|
}
|
|
168
180
|
|
|
181
|
+
// Every e2e client connects from ::1, and the e2e webServer trusts one proxy
|
|
182
|
+
// hop (defineAppE2eConfig). Without a distinct X-Forwarded-For per test, all
|
|
183
|
+
// browser pages of a run would share one `per: "ip"` rate-limit bucket and
|
|
184
|
+
// 429 under parallel load. The run token keeps a rerun within the limiter
|
|
185
|
+
// window out of the previous run's buckets; the retry index keeps a retry
|
|
186
|
+
// out of the bucket its failed attempt already drained.
|
|
187
|
+
export function perTestClientIpKey(
|
|
188
|
+
testInfo: Pick<TestInfo, "testId" | "repeatEachIndex" | "retry">,
|
|
189
|
+
runToken: string | undefined = process.env[SEED_TOKEN_ENV],
|
|
190
|
+
): string {
|
|
191
|
+
return `${runToken ?? ""}:${testInfo.testId}:${testInfo.repeatEachIndex}:${testInfo.retry}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Same-origin only: on a cross-origin fetch the extra header would force a
|
|
195
|
+
// CORS preflight the target never agreed to (why extraHTTPHeaders is no option
|
|
196
|
+
// here). An explicit X-Forwarded-For set by the test wins.
|
|
197
|
+
export function headersWithClientIp(
|
|
198
|
+
request: { readonly url: string; readonly frameUrl: string | undefined },
|
|
199
|
+
headers: Readonly<Record<string, string>>,
|
|
200
|
+
clientIp: string,
|
|
201
|
+
): Record<string, string> | undefined {
|
|
202
|
+
if (request.frameUrl === undefined) return undefined;
|
|
203
|
+
let sameOrigin: boolean;
|
|
204
|
+
try {
|
|
205
|
+
sameOrigin = new URL(request.frameUrl).origin === new URL(request.url).origin;
|
|
206
|
+
} catch {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
return sameOrigin ? { [CLIENT_IP_HEADER]: clientIp, ...headers } : undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function frameUrlOf(request: PlaywrightRequest): string | undefined {
|
|
213
|
+
try {
|
|
214
|
+
return request.frame().url();
|
|
215
|
+
} catch {
|
|
216
|
+
// Service-worker requests have no frame.
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function providePerTestClientIpContext(
|
|
222
|
+
{ context }: Pick<PlaywrightTestArgs, "context">,
|
|
223
|
+
use: (context: BrowserContext) => Promise<void>,
|
|
224
|
+
testInfo: Pick<TestInfo, "testId" | "repeatEachIndex" | "retry">,
|
|
225
|
+
): Promise<void> {
|
|
226
|
+
const clientIp = syntheticClientIpFor(perTestClientIpKey(testInfo));
|
|
227
|
+
await context.route("**/api/**", (route) => {
|
|
228
|
+
const request = route.request();
|
|
229
|
+
const headers = headersWithClientIp(
|
|
230
|
+
{ url: request.url(), frameUrl: frameUrlOf(request) },
|
|
231
|
+
request.headers(),
|
|
232
|
+
clientIp,
|
|
233
|
+
);
|
|
234
|
+
return headers === undefined ? route.fallback() : route.fallback({ headers });
|
|
235
|
+
});
|
|
236
|
+
await use(context);
|
|
237
|
+
}
|
|
238
|
+
|
|
169
239
|
export const test = base.extend<E2eFixtures>({
|
|
240
|
+
context: providePerTestClientIpContext,
|
|
170
241
|
seedTenant: provideSeedTenant,
|
|
171
242
|
});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
1
3
|
import { BUNFIG_FILES, TEST_TIMEOUT_MS } from "./bunfig";
|
|
2
4
|
|
|
3
5
|
export type IntegrationRunOptions = {
|
|
@@ -16,6 +18,26 @@ export function selectIntegrationFiles(paths: readonly string[]): string[] {
|
|
|
16
18
|
.sort();
|
|
17
19
|
}
|
|
18
20
|
|
|
21
|
+
/** Resolves `kumiko-testing integration`'s positional file args against `cwd`
|
|
22
|
+
* into absolute paths — absolute (not glob-relative) so bun's own
|
|
23
|
+
* `bun test <path>` treats them as files to run, not filter patterns.
|
|
24
|
+
* `exists` is injectable so this stays unit-testable without touching the
|
|
25
|
+
* real filesystem. Throws on the first missing file — the caller decides
|
|
26
|
+
* how to report it (CLI: print + exit 1). */
|
|
27
|
+
export function resolveRequestedIntegrationFiles(
|
|
28
|
+
cwd: string,
|
|
29
|
+
positionals: readonly string[],
|
|
30
|
+
exists: (path: string) => boolean = existsSync,
|
|
31
|
+
): string[] {
|
|
32
|
+
return positionals.map((arg) => {
|
|
33
|
+
const resolved = resolve(cwd, arg);
|
|
34
|
+
if (!exists(resolved)) {
|
|
35
|
+
throw new Error(`kumiko-testing integration: file not found: ${arg}`);
|
|
36
|
+
}
|
|
37
|
+
return resolved;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
19
41
|
const isPositiveInteger = (value: number): boolean => Number.isInteger(value) && value > 0;
|
|
20
42
|
|
|
21
43
|
export function buildIntegrationTestArgs(opts: IntegrationRunOptions): string[] {
|