@cosmicdrift/kumiko-testing 0.295.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/LICENSE +57 -0
- package/README.md +49 -0
- package/bin/kumiko-testing.ts +76 -0
- package/package.json +93 -0
- package/src/bunfig.ts +94 -0
- package/src/changes.json +8 -0
- package/src/e2e/auth-kit.ts +151 -0
- package/src/e2e/constants.ts +27 -0
- package/src/e2e/define-app-e2e-config.ts +152 -0
- package/src/e2e/index.ts +56 -0
- package/src/e2e/mail-capture.ts +43 -0
- package/src/e2e/pin-english-locale.ts +14 -0
- package/src/e2e/poll.ts +39 -0
- package/src/e2e/screenshot-dir.ts +28 -0
- package/src/e2e/screenshots.ts +312 -0
- package/src/e2e/seed-contract.ts +85 -0
- package/src/e2e/seed-route.ts +135 -0
- package/src/e2e/seeded-tenant-fixture.ts +123 -0
- package/src/e2e/timeouts.ts +10 -0
- package/src/index.ts +18 -0
- package/src/integration-runner.ts +37 -0
- package/src/preload/ci-log.ts +2 -0
- package/src/preload/env.ts +2 -0
- package/src/preload/real.ts +4 -0
- package/src/preload/scrub-env.ts +3 -0
- package/src/preload/service-env-defaults.ts +14 -0
- package/src/preload/temporal.ts +6 -0
- package/src/provider-env-keys.ts +16 -0
- package/src/scaffold.ts +203 -0
- package/src/seed-tenant.ts +171 -0
- package/src/seed-types.ts +49 -0
- package/src/setup-app-test-stack.ts +26 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Business Source License 1.1
|
|
2
|
+
|
|
3
|
+
Parameters
|
|
4
|
+
|
|
5
|
+
Licensor: Marc Frost
|
|
6
|
+
|
|
7
|
+
Licensed Work: @cosmicdrift/kumiko-testing
|
|
8
|
+
The Licensed Work is © 2026 Marc Frost.
|
|
9
|
+
|
|
10
|
+
Additional Use Grant:
|
|
11
|
+
You may use the Licensed Work in production for any purpose, including
|
|
12
|
+
commercially, EXCEPT for the Restricted Use.
|
|
13
|
+
|
|
14
|
+
"Restricted Use" is defined as using the Licensed Work to provide a platform
|
|
15
|
+
or service to third parties that allows them to host, deploy, or run their
|
|
16
|
+
own applications built with the Licensed Work. This includes, but is not
|
|
17
|
+
limited to: managed hosting services, software-as-a-service (SaaS) platforms,
|
|
18
|
+
platform-as-a-service (PaaS), developer platforms, or any multi-tenant
|
|
19
|
+
managed offering of the Licensed Work.
|
|
20
|
+
|
|
21
|
+
This restriction does not apply to the Licensor, any entity controlled by,
|
|
22
|
+
controlling, or under common control with the Licensor ("Affiliates"), or
|
|
23
|
+
contractors acting on their behalf. The Licensor remains free to use the
|
|
24
|
+
Licensed Work for any purpose, including for the operation of kumiko.rocks.
|
|
25
|
+
|
|
26
|
+
Change Date: 2030-05-05
|
|
27
|
+
Change License: Apache License, Version 2.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
Terms
|
|
31
|
+
|
|
32
|
+
The Licensor hereby grants you the right to copy, modify, create derivative works,
|
|
33
|
+
redistribute, and make non-production use of the Licensed Work. The Licensor may
|
|
34
|
+
make an Additional Use Grant, above, permitting limited production use.
|
|
35
|
+
|
|
36
|
+
Effective on the Change Date, or the fourth anniversary of the first publicly
|
|
37
|
+
available distribution of the Licensed Work under this License, whichever comes
|
|
38
|
+
first, this License will convert to the Change License.
|
|
39
|
+
|
|
40
|
+
This Business Source License governs use of the Licensed Work in all cases, except
|
|
41
|
+
as to any use that is explicitly granted in the Additional Use Grant above or
|
|
42
|
+
under the Change License after the Change Date.
|
|
43
|
+
|
|
44
|
+
If your use of the Licensed Work does not comply with the requirements of this
|
|
45
|
+
License, you must cease use of the Licensed Work immediately.
|
|
46
|
+
|
|
47
|
+
All copies of the Licensed Work, and all derivative works thereof, must include
|
|
48
|
+
this License.
|
|
49
|
+
|
|
50
|
+
This License does not grant you any right, title, or interest in any trademark,
|
|
51
|
+
logo, or branding of the Licensor, except as required to comply with this License.
|
|
52
|
+
|
|
53
|
+
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON AN
|
|
54
|
+
“AS IS” BASIS. LICENSOR DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING
|
|
55
|
+
WITHOUT LIMITATION WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE,
|
|
56
|
+
TITLE, AND NON-INFRINGEMENT. IN NO EVENT WILL LICENSOR BE LIABLE FOR ANY DAMAGES
|
|
57
|
+
ARISING OUT OF OR RELATED TO THIS LICENSE OR THE USE OF THE LICENSED WORK.
|
package/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# @cosmicdrift/kumiko-testing
|
|
2
|
+
|
|
3
|
+
Test template for Kumiko apps. Test-only: never import it from production code.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { seedTenant, setupAppTestStack } from "@cosmicdrift/kumiko-testing";
|
|
7
|
+
|
|
8
|
+
const stack = await setupAppTestStack([myFeature]);
|
|
9
|
+
const tenant = await seedTenant(stack, { users: 2 }); // light: no rows, own id/key/users
|
|
10
|
+
const persisted = await seedTenant(stack, { persist: true }); // real rows via the dispatcher
|
|
11
|
+
await tenant.api.writeOk("my-feature:write:thing:create", { title: "x" });
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
- `preload/{temporal,ci-log,scrub-env,env,real}`: bunfig `preload` entries. `scrub-env` removes
|
|
15
|
+
`PROVIDER_ENV_KEYS` (unit default); `env` adds the localhost service defaults (integration);
|
|
16
|
+
`real` refuses to run without `KUMIKO_REAL_PROVIDERS=1` and in CI, and keeps the keys.
|
|
17
|
+
- `kumiko-testing bunfig [--dom] [--coverage]`: writes `bunfig.toml`,
|
|
18
|
+
`bunfig.integration.toml` and `bunfig.real.toml` (plus `bunfig.dom.toml`). `--dom` needs your own
|
|
19
|
+
`./test-setup/dom.preload.ts`.
|
|
20
|
+
- `kumiko-testing integration [--parallel N] [--timings <file>]`: runs `*.integration.test.ts`
|
|
21
|
+
with the 15s budget. No `--no-isolate`; `--parallel` only when you ask for it.
|
|
22
|
+
|
|
23
|
+
## E2E (`./e2e`, `./e2e/seed-route`)
|
|
24
|
+
|
|
25
|
+
`@playwright/test` is an optional peer dependency; only the `./e2e` subpath imports it. The server side
|
|
26
|
+
(`./e2e/seed-route`) never does.
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
// e2e/server.ts: mount the seed routes next to the app
|
|
30
|
+
runDevApp({ features, auth: { admin }, extraRoutes: createE2eSeedRoutes() });
|
|
31
|
+
|
|
32
|
+
// playwright.config.ts
|
|
33
|
+
export default defineAppE2eConfig({ port: 4321 });
|
|
34
|
+
|
|
35
|
+
// e2e/flow.spec.ts
|
|
36
|
+
import { expect, test } from "@cosmicdrift/kumiko-testing/e2e";
|
|
37
|
+
test("member sees the note", async ({ seedTenant, page }) => {
|
|
38
|
+
const tenant = await seedTenant({ users: 1, with: [seedNotes] });
|
|
39
|
+
await tenant.loginAs(page, tenant.members[0]!);
|
|
40
|
+
});
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
- `defineAppE2eConfig({ port, env?, locale?, projects?, serverEntry?, testDir?, testMatch? })`: parallelism, retries, timeouts and
|
|
44
|
+
workers (`KUMIKO_E2E_WORKERS` overrides) are fixed by the template; projects cannot set them.
|
|
45
|
+
- The seed routes answer only with `KUMIKO_TEST_SEED=1`, outside `NODE_ENV=production`, and with the
|
|
46
|
+
per-run `KUMIKO_TEST_SEED_TOKEN` in the `x-kumiko-test-seed` header. `defineAppE2eConfig` sets all three.
|
|
47
|
+
- App roles: `createE2eSeedRoutes({ extraRoles: ["TenantMember"] })` lets `tenant.addUser(["TenantMember"])` seed them;
|
|
48
|
+
`SystemAdmin` is never seedable and makes the route builder throw.
|
|
49
|
+
- A `SeedPart` receives `{ tenant }` and works against the in-process `seedTenant` and the HTTP tenant alike.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// biome-ignore-all lint/suspicious/noConsole: CLI script, console is the interface.
|
|
3
|
+
|
|
4
|
+
import { existsSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
import { Glob } from "bun";
|
|
7
|
+
import { BUNFIG_FILES, renderBunfigFiles } from "../src/bunfig";
|
|
8
|
+
import { buildIntegrationTestArgs, selectIntegrationFiles } from "../src/integration-runner";
|
|
9
|
+
|
|
10
|
+
const USAGE = `kumiko-testing <command>
|
|
11
|
+
|
|
12
|
+
bunfig [--dom] [--coverage] [--hoisted] write bunfig.toml, bunfig.integration.toml and
|
|
13
|
+
bunfig.real.toml (plus bunfig.dom.toml with --dom);
|
|
14
|
+
--hoisted adds [install] linker = "hoisted"
|
|
15
|
+
integration [--parallel N] run every *.integration.test.ts under the cwd
|
|
16
|
+
[--timings <file>] [--update-timings]
|
|
17
|
+
`;
|
|
18
|
+
|
|
19
|
+
function runBunfig(args: readonly string[]): number {
|
|
20
|
+
const { values } = parseArgs({
|
|
21
|
+
args: [...args],
|
|
22
|
+
options: {
|
|
23
|
+
dom: { type: "boolean" },
|
|
24
|
+
coverage: { type: "boolean" },
|
|
25
|
+
hoisted: { type: "boolean" },
|
|
26
|
+
},
|
|
27
|
+
strict: true,
|
|
28
|
+
});
|
|
29
|
+
const files = renderBunfigFiles({
|
|
30
|
+
dom: values.dom === true,
|
|
31
|
+
coverage: values.coverage === true,
|
|
32
|
+
...(values.hoisted === true && { install: { linker: "hoisted" } }),
|
|
33
|
+
});
|
|
34
|
+
for (const [name, content] of Object.entries(files)) {
|
|
35
|
+
writeFileSync(name, content);
|
|
36
|
+
console.log(`wrote ${name}`);
|
|
37
|
+
}
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function runIntegration(args: readonly string[]): Promise<number> {
|
|
42
|
+
const { values } = parseArgs({
|
|
43
|
+
args: [...args],
|
|
44
|
+
options: {
|
|
45
|
+
parallel: { type: "string" },
|
|
46
|
+
timings: { type: "string" },
|
|
47
|
+
"update-timings": { type: "boolean" },
|
|
48
|
+
},
|
|
49
|
+
strict: true,
|
|
50
|
+
});
|
|
51
|
+
if (!existsSync(BUNFIG_FILES.integration)) {
|
|
52
|
+
console.error(`${BUNFIG_FILES.integration} not found - run \`kumiko-testing bunfig\` first`);
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
55
|
+
const files = selectIntegrationFiles(
|
|
56
|
+
await Array.fromAsync(new Glob("**/*.integration.test.ts").scan({ cwd: process.cwd() })),
|
|
57
|
+
);
|
|
58
|
+
if (files.length === 0) {
|
|
59
|
+
console.error("no *.integration.test.ts files found");
|
|
60
|
+
return 1;
|
|
61
|
+
}
|
|
62
|
+
const testArgs = buildIntegrationTestArgs({
|
|
63
|
+
files,
|
|
64
|
+
...(values.parallel !== undefined && { parallel: Number(values.parallel) }),
|
|
65
|
+
...(values.timings !== undefined && { timings: values.timings }),
|
|
66
|
+
...(values["update-timings"] === true && { updateTimings: true }),
|
|
67
|
+
});
|
|
68
|
+
const proc = Bun.spawn(["bun", ...testArgs], { stdio: ["inherit", "inherit", "inherit"] });
|
|
69
|
+
return proc.exited;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
73
|
+
if (command === "bunfig") process.exit(runBunfig(rest));
|
|
74
|
+
if (command === "integration") process.exit(await runIntegration(rest));
|
|
75
|
+
console.error(USAGE);
|
|
76
|
+
process.exit(2);
|
package/package.json
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cosmicdrift/kumiko-testing",
|
|
3
|
+
"version": "0.295.0",
|
|
4
|
+
"description": "Test template for Kumiko apps: per-flow seedTenant, app test stack, canonical test preloads, bunfig generator and integration runner.",
|
|
5
|
+
"license": "BUSL-1.1",
|
|
6
|
+
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/CosmicDriftGameStudio/kumiko-framework.git",
|
|
10
|
+
"directory": "packages/testing"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/CosmicDriftGameStudio/kumiko-framework/issues"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://kumiko.rocks",
|
|
16
|
+
"type": "module",
|
|
17
|
+
"kumiko": {
|
|
18
|
+
"runtime": "test"
|
|
19
|
+
},
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./src/index.ts",
|
|
23
|
+
"default": "./src/index.ts"
|
|
24
|
+
},
|
|
25
|
+
"./bunfig": {
|
|
26
|
+
"types": "./src/bunfig.ts",
|
|
27
|
+
"default": "./src/bunfig.ts"
|
|
28
|
+
},
|
|
29
|
+
"./preload/temporal": {
|
|
30
|
+
"types": "./src/preload/temporal.ts",
|
|
31
|
+
"default": "./src/preload/temporal.ts"
|
|
32
|
+
},
|
|
33
|
+
"./preload/ci-log": {
|
|
34
|
+
"types": "./src/preload/ci-log.ts",
|
|
35
|
+
"default": "./src/preload/ci-log.ts"
|
|
36
|
+
},
|
|
37
|
+
"./preload/scrub-env": {
|
|
38
|
+
"types": "./src/preload/scrub-env.ts",
|
|
39
|
+
"default": "./src/preload/scrub-env.ts"
|
|
40
|
+
},
|
|
41
|
+
"./preload/env": {
|
|
42
|
+
"types": "./src/preload/env.ts",
|
|
43
|
+
"default": "./src/preload/env.ts"
|
|
44
|
+
},
|
|
45
|
+
"./preload/real": {
|
|
46
|
+
"types": "./src/preload/real.ts",
|
|
47
|
+
"default": "./src/preload/real.ts"
|
|
48
|
+
},
|
|
49
|
+
"./scaffold": {
|
|
50
|
+
"types": "./src/scaffold.ts",
|
|
51
|
+
"default": "./src/scaffold.ts"
|
|
52
|
+
},
|
|
53
|
+
"./e2e": {
|
|
54
|
+
"types": "./src/e2e/index.ts",
|
|
55
|
+
"default": "./src/e2e/index.ts"
|
|
56
|
+
},
|
|
57
|
+
"./e2e/seed-route": {
|
|
58
|
+
"types": "./src/e2e/seed-route.ts",
|
|
59
|
+
"default": "./src/e2e/seed-route.ts"
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"bin": {
|
|
63
|
+
"kumiko-testing": "./bin/kumiko-testing.ts"
|
|
64
|
+
},
|
|
65
|
+
"dependencies": {
|
|
66
|
+
"@cosmicdrift/kumiko-bundled-features": "0.295.0",
|
|
67
|
+
"@cosmicdrift/kumiko-dev-server": "0.295.0",
|
|
68
|
+
"@cosmicdrift/kumiko-framework": "0.295.0",
|
|
69
|
+
"zod": "^4.4.3"
|
|
70
|
+
},
|
|
71
|
+
"peerDependencies": {
|
|
72
|
+
"@playwright/test": "^1.62.1"
|
|
73
|
+
},
|
|
74
|
+
"peerDependenciesMeta": {
|
|
75
|
+
"@playwright/test": {
|
|
76
|
+
"optional": true
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
"devDependencies": {
|
|
80
|
+
"@playwright/test": "^1.62.1"
|
|
81
|
+
},
|
|
82
|
+
"publishConfig": {
|
|
83
|
+
"registry": "https://registry.npmjs.org",
|
|
84
|
+
"access": "public"
|
|
85
|
+
},
|
|
86
|
+
"files": [
|
|
87
|
+
"src",
|
|
88
|
+
"bin",
|
|
89
|
+
"!src/**/__tests__/**",
|
|
90
|
+
"README.md",
|
|
91
|
+
"LICENSE"
|
|
92
|
+
]
|
|
93
|
+
}
|
package/src/bunfig.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
export const TEST_TIMEOUT_MS = {
|
|
2
|
+
unit: 5000,
|
|
3
|
+
integration: 15000,
|
|
4
|
+
dom: 15000,
|
|
5
|
+
// Real providers add network round-trips and provider-side latency the local stack never has.
|
|
6
|
+
real: 120000,
|
|
7
|
+
} as const;
|
|
8
|
+
|
|
9
|
+
export const BUNFIG_FILES = {
|
|
10
|
+
unit: "bunfig.toml",
|
|
11
|
+
integration: "bunfig.integration.toml",
|
|
12
|
+
real: "bunfig.real.toml",
|
|
13
|
+
dom: "bunfig.dom.toml",
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
export type BunfigVariant = "unit" | "integration" | "real";
|
|
17
|
+
|
|
18
|
+
export type BunfigOptions = {
|
|
19
|
+
readonly dom?: boolean;
|
|
20
|
+
readonly coverage?: boolean;
|
|
21
|
+
readonly install?: { readonly linker: "hoisted" };
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const PRELOAD_PREFIX = "@cosmicdrift/kumiko-testing/preload";
|
|
25
|
+
const DOM_PRELOAD = "./test-setup/dom.preload.ts";
|
|
26
|
+
|
|
27
|
+
const PRELOADS = {
|
|
28
|
+
unit: [`${PRELOAD_PREFIX}/temporal`, `${PRELOAD_PREFIX}/scrub-env`],
|
|
29
|
+
integration: [`${PRELOAD_PREFIX}/temporal`, `${PRELOAD_PREFIX}/env`],
|
|
30
|
+
real: [`${PRELOAD_PREFIX}/temporal`, `${PRELOAD_PREFIX}/real`],
|
|
31
|
+
} as const satisfies Record<BunfigVariant, readonly string[]>;
|
|
32
|
+
|
|
33
|
+
const ALWAYS_IGNORED = ["**/e2e/**", "**/*.spec.ts", "**/*.spec.tsx", "**/dist/**"] as const;
|
|
34
|
+
|
|
35
|
+
const COVERAGE_IGNORED = ["**/scripts/**", "**/bin/**"] as const;
|
|
36
|
+
|
|
37
|
+
function ignoredPaths(variant: BunfigVariant, dom: boolean): readonly string[] {
|
|
38
|
+
switch (variant) {
|
|
39
|
+
case "unit":
|
|
40
|
+
return [
|
|
41
|
+
"**/*.integration.test.ts",
|
|
42
|
+
"**/*.real.test.ts",
|
|
43
|
+
...(dom ? [] : ["**/*.test.tsx"]),
|
|
44
|
+
...ALWAYS_IGNORED,
|
|
45
|
+
];
|
|
46
|
+
case "integration":
|
|
47
|
+
return ["**/*.real.test.ts", ...ALWAYS_IGNORED];
|
|
48
|
+
case "real":
|
|
49
|
+
return ["**/*.integration.test.ts", ...ALWAYS_IGNORED];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function tomlStringArray(key: string, items: readonly string[]): string {
|
|
54
|
+
return `${key} = [\n${items.map((item) => ` ${JSON.stringify(item)},`).join("\n")}\n]`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function renderBunfig(variant: BunfigVariant, opts: BunfigOptions = {}): string {
|
|
58
|
+
const dom = opts.dom === true;
|
|
59
|
+
if (dom && variant !== "unit") {
|
|
60
|
+
throw new Error(`bunfig option "dom" only applies to the unit variant, not "${variant}"`);
|
|
61
|
+
}
|
|
62
|
+
const preload = dom ? [...PRELOADS.unit, DOM_PRELOAD] : PRELOADS[variant];
|
|
63
|
+
const coverageDir = `coverage/${dom ? "dom" : variant}`;
|
|
64
|
+
|
|
65
|
+
const lines = [
|
|
66
|
+
`# Generated by kumiko-testing (${dom ? "dom" : variant}) - do not edit, regenerate with \`kumiko-testing bunfig\`.`,
|
|
67
|
+
"# bun ignores a [test] timeout key; the class budget is passed as --timeout by the runner scripts.",
|
|
68
|
+
...(opts.install !== undefined
|
|
69
|
+
? ["[install]", `linker = ${JSON.stringify(opts.install.linker)}`, ""]
|
|
70
|
+
: []),
|
|
71
|
+
"[test]",
|
|
72
|
+
tomlStringArray("preload", preload),
|
|
73
|
+
...(opts.coverage === true
|
|
74
|
+
? [
|
|
75
|
+
"coverage = true",
|
|
76
|
+
'coverageReporter = ["lcov"]',
|
|
77
|
+
`coverageDir = ${JSON.stringify(coverageDir)}`,
|
|
78
|
+
tomlStringArray("coveragePathIgnorePatterns", COVERAGE_IGNORED),
|
|
79
|
+
]
|
|
80
|
+
: []),
|
|
81
|
+
tomlStringArray("pathIgnorePatterns", ignoredPaths(variant, dom)),
|
|
82
|
+
];
|
|
83
|
+
return `${lines.join("\n")}\n`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function renderBunfigFiles(opts: BunfigOptions = {}): Readonly<Record<string, string>> {
|
|
87
|
+
const { dom, ...shared } = opts;
|
|
88
|
+
return {
|
|
89
|
+
[BUNFIG_FILES.unit]: renderBunfig("unit", shared),
|
|
90
|
+
[BUNFIG_FILES.integration]: renderBunfig("integration", shared),
|
|
91
|
+
[BUNFIG_FILES.real]: renderBunfig("real", shared),
|
|
92
|
+
...(dom === true ? { [BUNFIG_FILES.dom]: renderBunfig("unit", { ...shared, dom: true }) } : {}),
|
|
93
|
+
};
|
|
94
|
+
}
|
package/src/changes.json
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.295.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "New kumiko-testing package with seedTenant, preloads, bunfig generator and integration runner",
|
|
6
|
+
"detail": "seedTenant gives each test its own tenant (light by default, persist: true writes real rows through the dispatcher), setupAppTestStack mounts the bundled features and the entity projection tables, the preload subpaths (temporal, ci-log, scrub-env, env, real) replace the per-repo test-setup copies and scrub provider API keys, renderBunfig generates the bunfig variants, and kumiko-testing integration runs the integration files with the 15s budget and opt-in parallelism.\nThe `./e2e` subpath is the Playwright core: defineAppE2eConfig fixes parallelism, retries, timeouts and workers centrally, the test fixture seeds a tenant per test through the token-gated `./e2e/seed-route` routes and logs the admin in, and the auth kit (loginViaApi, csrfFetch, createHttpApi, totpCode, waitForProjection) replaces the per-app copies. A SeedPart runs unchanged against the in-process stack and over HTTP.\nThe screenshot runner (runScreenshots, runMatrix, pinEnglishLocale) moves in from the samples: `SCREENSHOT_DIR` is required with no default path, so a run can never overwrite the committed docs images, and `screenshotSpecsIgnore()` keeps screenshot specs out of every normal e2e run. Scenarios no longer take `settleMs`; the runner waits until no data request is in flight and the page layout stops changing."
|
|
7
|
+
}
|
|
8
|
+
]
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { currentTotpCode } from "@cosmicdrift/kumiko-bundled-features/auth-mfa/testing";
|
|
2
|
+
import type { WriteErrorInfo } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
|
+
import { type APIRequestContext, type APIResponse, expect, type Page } from "@playwright/test";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import type { BoundApi } from "../seed-types";
|
|
6
|
+
import { CSRF_COOKIE_NAME, CSRF_HEADER_NAME } from "./constants";
|
|
7
|
+
|
|
8
|
+
export type LoginCredentials = { readonly email: string; readonly password: string };
|
|
9
|
+
|
|
10
|
+
type ApiEndpoint = "/api/write" | "/api/query" | "/api/command";
|
|
11
|
+
|
|
12
|
+
type ApiReply = { readonly status: number; readonly body: unknown };
|
|
13
|
+
|
|
14
|
+
const successEnvelope = z.looseObject({
|
|
15
|
+
isSuccess: z.boolean().optional(),
|
|
16
|
+
data: z.unknown().optional(),
|
|
17
|
+
error: z.unknown().optional(),
|
|
18
|
+
});
|
|
19
|
+
const errorEnvelope = z.looseObject({ error: z.looseObject({ code: z.string() }) });
|
|
20
|
+
|
|
21
|
+
export function csrfHeaderFromCookies(
|
|
22
|
+
cookies: readonly { readonly name: string; readonly value: string }[],
|
|
23
|
+
): Record<string, string> {
|
|
24
|
+
const token = cookies.find((cookie) => cookie.name === CSRF_COOKIE_NAME)?.value;
|
|
25
|
+
return token === undefined ? {} : { [CSRF_HEADER_NAME]: token };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function csrfFetch(
|
|
29
|
+
request: APIRequestContext,
|
|
30
|
+
path: string,
|
|
31
|
+
data?: unknown,
|
|
32
|
+
): Promise<APIResponse> {
|
|
33
|
+
const { cookies } = await request.storageState();
|
|
34
|
+
return request.post(path, { headers: csrfHeaderFromCookies(cookies), data });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function loginViaApi(
|
|
38
|
+
request: APIRequestContext,
|
|
39
|
+
credentials: LoginCredentials,
|
|
40
|
+
): Promise<void> {
|
|
41
|
+
const response = await request.post("/api/auth/login", {
|
|
42
|
+
data: { email: credentials.email, password: credentials.password },
|
|
43
|
+
});
|
|
44
|
+
if (!response.ok()) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`loginViaApi(${credentials.email}): POST /api/auth/login -> ${response.status()} ${await response.text()}`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function loginViaUi(page: Page, credentials: LoginCredentials): Promise<void> {
|
|
52
|
+
// seedTenant already logged the context in, and an authenticated session never renders /login.
|
|
53
|
+
await page.context().clearCookies();
|
|
54
|
+
await page.goto("/login");
|
|
55
|
+
await page.locator("#login-email").fill(credentials.email);
|
|
56
|
+
await page.locator("#login-password").fill(credentials.password);
|
|
57
|
+
await page.locator("#login-password").press("Enter");
|
|
58
|
+
await expect(page.locator("#login-password")).toHaveCount(0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function postApi(
|
|
62
|
+
request: APIRequestContext,
|
|
63
|
+
endpoint: ApiEndpoint,
|
|
64
|
+
body: Record<string, unknown>,
|
|
65
|
+
): Promise<ApiReply> {
|
|
66
|
+
const response = await csrfFetch(request, endpoint, body);
|
|
67
|
+
const text = await response.text();
|
|
68
|
+
try {
|
|
69
|
+
return { status: response.status(), body: JSON.parse(text) };
|
|
70
|
+
} catch {
|
|
71
|
+
return { status: response.status(), body: text };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function describeReply(reply: ApiReply): string {
|
|
76
|
+
return `HTTP ${reply.status} ${JSON.stringify(reply.body)}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function successData(kind: string, type: string, reply: ApiReply): unknown {
|
|
80
|
+
const parsed = successEnvelope.safeParse(reply.body);
|
|
81
|
+
const succeeded =
|
|
82
|
+
reply.status >= 200 &&
|
|
83
|
+
reply.status < 300 &&
|
|
84
|
+
parsed.success &&
|
|
85
|
+
parsed.data.isSuccess !== false &&
|
|
86
|
+
parsed.data.error === undefined;
|
|
87
|
+
if (!succeeded) {
|
|
88
|
+
throw new Error(`Expected ${kind} "${type}" to succeed but got ${describeReply(reply)}`);
|
|
89
|
+
}
|
|
90
|
+
return parsed.data.data;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function failureInfo(kind: string, type: string, reply: ApiReply): WriteErrorInfo {
|
|
94
|
+
const parsed = errorEnvelope.safeParse(reply.body);
|
|
95
|
+
if (reply.status < 400 || !parsed.success) {
|
|
96
|
+
throw new Error(`Expected ${kind} "${type}" to fail but got ${describeReply(reply)}`);
|
|
97
|
+
}
|
|
98
|
+
// @cast-boundary engine-payload — the wire error envelope is WriteErrorInfo minus httpStatus
|
|
99
|
+
return { ...(parsed.data.error as Omit<WriteErrorInfo, "httpStatus">), httpStatus: reply.status };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function apiWrite<T = Record<string, unknown>>(
|
|
103
|
+
request: APIRequestContext,
|
|
104
|
+
type: string,
|
|
105
|
+
payload: unknown,
|
|
106
|
+
requestId?: string,
|
|
107
|
+
): Promise<T> {
|
|
108
|
+
const reply = await postApi(request, "/api/write", { type, payload, requestId });
|
|
109
|
+
return successData("write", type, reply) as T; // @cast-boundary engine-payload
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function apiQuery<T = unknown>(
|
|
113
|
+
request: APIRequestContext,
|
|
114
|
+
type: string,
|
|
115
|
+
payload: unknown = {},
|
|
116
|
+
): Promise<T> {
|
|
117
|
+
const reply = await postApi(request, "/api/query", { type, payload });
|
|
118
|
+
return successData("query", type, reply) as T; // @cast-boundary engine-payload
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function apiCommand(
|
|
122
|
+
request: APIRequestContext,
|
|
123
|
+
type: string,
|
|
124
|
+
payload: unknown,
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
const reply = await postApi(request, "/api/command", { type, payload });
|
|
127
|
+
if (reply.status !== 202) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`Expected command "${type}" to be accepted (202) but got ${describeReply(reply)}`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function createHttpApi(request: APIRequestContext): BoundApi {
|
|
135
|
+
return {
|
|
136
|
+
writeOk: (type, payload, requestId) => apiWrite(request, type, payload, requestId),
|
|
137
|
+
writeErr: async (type, payload) =>
|
|
138
|
+
failureInfo("write", type, await postApi(request, "/api/write", { type, payload })),
|
|
139
|
+
queryOk: (type, payload) => apiQuery(request, type, payload),
|
|
140
|
+
queryErr: async (type, payload) =>
|
|
141
|
+
failureInfo("query", type, await postApi(request, "/api/query", { type, payload })),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function totpCode(secret: Buffer | string, nowMs?: number): Promise<string> {
|
|
146
|
+
const key =
|
|
147
|
+
typeof secret === "string"
|
|
148
|
+
? (await import("@cosmicdrift/kumiko-bundled-features/auth-mfa")).base32Decode(secret)
|
|
149
|
+
: secret;
|
|
150
|
+
return currentTotpCode(key, nowMs);
|
|
151
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const KUMIKO_SECRETS_MASTER_KEY_V1 = "a3VtaWtvLXNjcmVlbnNob3QtZGV2LW1hc3Rlci0zMmI=";
|
|
2
|
+
|
|
3
|
+
export const PLAYWRIGHT_DEMO_ENV = {
|
|
4
|
+
JWT_SECRET: "kumiko-e2e-jwt-secret-min-32-chars-ephemeral",
|
|
5
|
+
KUMIKO_DEV_DB_NAME: "",
|
|
6
|
+
KUMIKO_SECRETS_MASTER_KEY_V1,
|
|
7
|
+
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
|
|
8
|
+
} as const;
|
|
9
|
+
|
|
10
|
+
export const CSRF_COOKIE_NAME = "kumiko_csrf";
|
|
11
|
+
export const CSRF_HEADER_NAME = "X-CSRF-Token";
|
|
12
|
+
|
|
13
|
+
export const SEED_ENABLE_ENV = "KUMIKO_TEST_SEED";
|
|
14
|
+
export const SEED_TOKEN_ENV = "KUMIKO_TEST_SEED_TOKEN";
|
|
15
|
+
export const SEED_TOKEN_HEADER = "x-kumiko-test-seed";
|
|
16
|
+
export const E2E_WORKERS_ENV = "KUMIKO_E2E_WORKERS";
|
|
17
|
+
|
|
18
|
+
// Same literal as the framework's requireRealProviders(), which does not export it.
|
|
19
|
+
export const REAL_PROVIDERS_ENV = "KUMIKO_REAL_PROVIDERS";
|
|
20
|
+
|
|
21
|
+
export const SEED_ROUTE_PREFIX = "/__test";
|
|
22
|
+
|
|
23
|
+
export const SEED_ROUTES = {
|
|
24
|
+
seedTenant: `${SEED_ROUTE_PREFIX}/seed-tenant`,
|
|
25
|
+
seedUser: `${SEED_ROUTE_PREFIX}/seed-user`,
|
|
26
|
+
inbox: `${SEED_ROUTE_PREFIX}/inbox`,
|
|
27
|
+
} as const;
|