@cosmicdrift/kumiko-testing 0.297.0 → 0.304.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 +28 -0
- package/bin/__tests__/kumiko-testing-bunfig.test.ts +76 -0
- package/bin/kumiko-testing.ts +18 -4
- package/package.json +4 -4
- package/src/bunfig.ts +75 -0
- package/src/changes.json +18 -0
- package/src/e2e/define-app-e2e-config.ts +1 -1
- package/src/e2e/index.ts +1 -0
- package/src/e2e/screenshots.ts +27 -14
- package/src/e2e/seed-route.ts +106 -55
- package/src/e2e/seeded-tenant-fixture.ts +98 -74
- package/src/preload/ci-log.ts +1 -1
- package/src/scaffold.ts +1 -1
package/README.md
CHANGED
|
@@ -47,3 +47,31 @@ test("member sees the note", async ({ seedTenant, page }) => {
|
|
|
47
47
|
- App roles: `createE2eSeedRoutes({ extraRoles: ["TenantMember"] })` lets `tenant.addUser(["TenantMember"])` seed them;
|
|
48
48
|
`SystemAdmin` is never seedable and makes the route builder throw.
|
|
49
49
|
- A `SeedPart` receives `{ tenant }` and works against the in-process `seedTenant` and the HTTP tenant alike.
|
|
50
|
+
- Already have a custom server entry (mail transport, KMS, boot seeds, ...) that also runs in prod?
|
|
51
|
+
Point `serverEntry` at it instead of duplicating `e2e/server.ts`, and mount the seed routes only
|
|
52
|
+
when the seed condition holds: `...(isE2eSeedingEnabled() ? createE2eSeedRoutes() : [])` in its own
|
|
53
|
+
`extraRoutes` — prod then never registers the routes at all, not just a per-request 404.
|
|
54
|
+
`isE2eSeedingEnabled` is exported from `@cosmicdrift/kumiko-testing/e2e/seed-route`.
|
|
55
|
+
- App-wide defaults for tests (e.g. a completed onboarding) belong in a `SeedPart` passed to
|
|
56
|
+
`seedTenant({ with: [defaults] })` — never in a `postSave` hook on the `"tenant"` entity, since that
|
|
57
|
+
hook also fires on every real signup in prod.
|
|
58
|
+
|
|
59
|
+
## Screenshots (`./e2e`)
|
|
60
|
+
|
|
61
|
+
`runScreenshots`/`runMatrix` register their tests on the same `test` as above, so a scenario's `flow`
|
|
62
|
+
receives the seeded-tenant fixture too:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
{ name: "dispatch", flow: async (page, { seedTenant }) => {
|
|
66
|
+
const tenant = await seedTenant({ users: 1 });
|
|
67
|
+
await tenant.loginAs(page, tenant.members[0]!);
|
|
68
|
+
await page.goto("/dispatch");
|
|
69
|
+
},
|
|
70
|
+
beforeCapture: async (page) => page.addStyleTag({ content: ".live-clock { visibility: hidden }" }),
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`beforeCapture?: (page) => Promise<void>` runs after the viewport is set and the page has settled,
|
|
75
|
+
right before the screenshot. `runMatrix` calls it once per theme × viewport for the same scenario, so
|
|
76
|
+
it must be idempotent — hide or mask an element rather than a one-shot action like clicking a button,
|
|
77
|
+
which would only succeed on the first capture and time out on every one after.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// kumiko-framework#3120: `kumiko-testing bunfig` used to overwrite an
|
|
2
|
+
// existing bunfig.toml unconditionally, silently dropping app-owned sections
|
|
3
|
+
// (e.g. `[install.scopes]` with a private-registry token) and unmanaged keys
|
|
4
|
+
// (e.g. `concurrency`) alike. Foreign sections now merge back in verbatim;
|
|
5
|
+
// only an unmanaged key inside a *managed* section still blocks the write.
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
const CLI_PATH = join(import.meta.dir, "..", "kumiko-testing.ts");
|
|
13
|
+
|
|
14
|
+
async function runCli(args: string[], cwd: string) {
|
|
15
|
+
const proc = Bun.spawn(["bun", CLI_PATH, ...args], { cwd, stdout: "pipe", stderr: "pipe" });
|
|
16
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
17
|
+
new Response(proc.stdout).text(),
|
|
18
|
+
new Response(proc.stderr).text(),
|
|
19
|
+
proc.exited,
|
|
20
|
+
]);
|
|
21
|
+
return { stdout, stderr, exitCode };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("kumiko-testing bunfig — existing file with a foreign section", () => {
|
|
25
|
+
test("merges [install.scopes] back in instead of dropping it", async () => {
|
|
26
|
+
const dir = mkdtempSync(join(tmpdir(), "kumiko-testing-bunfig-"));
|
|
27
|
+
try {
|
|
28
|
+
const existing =
|
|
29
|
+
'[install.scopes]\n"@cosmicdriftgamestudio" = { token = "$GITHUB_TOKEN" }\n\n[test]\npreload = []\n';
|
|
30
|
+
writeFileSync(join(dir, "bunfig.toml"), existing);
|
|
31
|
+
|
|
32
|
+
const { exitCode } = await runCli(["bunfig"], dir);
|
|
33
|
+
|
|
34
|
+
expect(exitCode).toBe(0);
|
|
35
|
+
const written = readFileSync(join(dir, "bunfig.toml"), "utf-8");
|
|
36
|
+
expect(written).toContain("Generated by kumiko-testing");
|
|
37
|
+
expect(written).toContain(
|
|
38
|
+
'[install.scopes]\n"@cosmicdriftgamestudio" = { token = "$GITHUB_TOKEN" }',
|
|
39
|
+
);
|
|
40
|
+
} finally {
|
|
41
|
+
rmSync(dir, { recursive: true, force: true });
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("aborts and names an unmanaged key inside [test], leaves the file untouched", async () => {
|
|
46
|
+
const dir = mkdtempSync(join(tmpdir(), "kumiko-testing-bunfig-"));
|
|
47
|
+
try {
|
|
48
|
+
const existing = "[test]\nconcurrency = 8\npreload = []\n";
|
|
49
|
+
writeFileSync(join(dir, "bunfig.toml"), existing);
|
|
50
|
+
|
|
51
|
+
const { exitCode, stderr } = await runCli(["bunfig"], dir);
|
|
52
|
+
|
|
53
|
+
expect(exitCode).toBe(1);
|
|
54
|
+
expect(stderr).toContain("test.concurrency");
|
|
55
|
+
expect(readFileSync(join(dir, "bunfig.toml"), "utf-8")).toBe(existing);
|
|
56
|
+
} finally {
|
|
57
|
+
rmSync(dir, { recursive: true, force: true });
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("regenerates a file with no foreign sections and no unmanaged keys", async () => {
|
|
62
|
+
const dir = mkdtempSync(join(tmpdir(), "kumiko-testing-bunfig-"));
|
|
63
|
+
try {
|
|
64
|
+
writeFileSync(join(dir, "bunfig.toml"), "[test]\npreload = []\n");
|
|
65
|
+
|
|
66
|
+
const { exitCode } = await runCli(["bunfig"], dir);
|
|
67
|
+
|
|
68
|
+
expect(exitCode).toBe(0);
|
|
69
|
+
expect(readFileSync(join(dir, "bunfig.toml"), "utf-8")).toContain(
|
|
70
|
+
"Generated by kumiko-testing",
|
|
71
|
+
);
|
|
72
|
+
} finally {
|
|
73
|
+
rmSync(dir, { recursive: true, force: true });
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
});
|
package/bin/kumiko-testing.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
// biome-ignore-all lint/suspicious/noConsole: CLI script, console is the interface.
|
|
3
3
|
|
|
4
|
-
import { existsSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { parseArgs } from "node:util";
|
|
6
6
|
import { Glob } from "bun";
|
|
7
|
-
import { BUNFIG_FILES, renderBunfigFiles } from "../src/bunfig";
|
|
7
|
+
import { BUNFIG_FILES, mergeBunfig, renderBunfigFiles } from "../src/bunfig";
|
|
8
8
|
import { buildIntegrationTestArgs, selectIntegrationFiles } from "../src/integration-runner";
|
|
9
9
|
|
|
10
10
|
const USAGE = `kumiko-testing <command>
|
|
@@ -31,11 +31,25 @@ function runBunfig(args: readonly string[]): number {
|
|
|
31
31
|
coverage: values.coverage === true,
|
|
32
32
|
...(values.hoisted === true && { install: { linker: "hoisted" } }),
|
|
33
33
|
});
|
|
34
|
+
let blocked = false;
|
|
34
35
|
for (const [name, content] of Object.entries(files)) {
|
|
35
|
-
|
|
36
|
+
let toWrite = content;
|
|
37
|
+
if (existsSync(name)) {
|
|
38
|
+
const merged = mergeBunfig(content, readFileSync(name, "utf-8"));
|
|
39
|
+
if (!merged.ok) {
|
|
40
|
+
const keys = merged.unknownKeys.map((k) => `${k.section}.${k.key}`).join(", ");
|
|
41
|
+
console.error(
|
|
42
|
+
`${name} has key(s) kumiko-testing does not manage: ${keys}. Regenerating would drop them silently — remove them from ${name} or move them out of [install]/[test], then rerun.`,
|
|
43
|
+
);
|
|
44
|
+
blocked = true;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
toWrite = merged.content;
|
|
48
|
+
}
|
|
49
|
+
writeFileSync(name, toWrite);
|
|
36
50
|
console.log(`wrote ${name}`);
|
|
37
51
|
}
|
|
38
|
-
return 0;
|
|
52
|
+
return blocked ? 1 : 0;
|
|
39
53
|
}
|
|
40
54
|
|
|
41
55
|
async function runIntegration(args: readonly string[]): Promise<number> {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.304.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.
|
|
67
|
-
"@cosmicdrift/kumiko-dev-server": "0.
|
|
68
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
66
|
+
"@cosmicdrift/kumiko-bundled-features": "0.304.0",
|
|
67
|
+
"@cosmicdrift/kumiko-dev-server": "0.304.0",
|
|
68
|
+
"@cosmicdrift/kumiko-framework": "0.304.0",
|
|
69
69
|
"zod": "^4.4.3"
|
|
70
70
|
},
|
|
71
71
|
"peerDependencies": {
|
package/src/bunfig.ts
CHANGED
|
@@ -83,6 +83,81 @@ export function renderBunfig(variant: BunfigVariant, opts: BunfigOptions = {}):
|
|
|
83
83
|
return `${lines.join("\n")}\n`;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
type BunfigBlock = { readonly header: string; readonly lines: readonly string[] };
|
|
87
|
+
|
|
88
|
+
// Splits on un-indented `[header]` lines only — real bunfig files never
|
|
89
|
+
// indent a table header, and TOML array/inline-table values that happen to
|
|
90
|
+
// contain "[" are never alone on their own line. Anything before the first
|
|
91
|
+
// header (top-level keys like `preload`/`telemetry`/`smol`/`logLevel`, plus
|
|
92
|
+
// the header comments) is its own block under the synthetic header "" —
|
|
93
|
+
// dropping it silently lost those keys (kumiko-framework#3120).
|
|
94
|
+
function splitBunfigBlocks(content: string): readonly BunfigBlock[] {
|
|
95
|
+
const blocks: { header: string; lines: string[] }[] = [];
|
|
96
|
+
let current: { header: string; lines: string[] } = { header: "", lines: [] };
|
|
97
|
+
for (const line of content.split("\n")) {
|
|
98
|
+
const match = /^\[([^\]]+)\]\s*$/.exec(line);
|
|
99
|
+
if (match?.[1] !== undefined) {
|
|
100
|
+
blocks.push(current);
|
|
101
|
+
current = { header: match[1], lines: [] };
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
current.lines.push(line);
|
|
105
|
+
}
|
|
106
|
+
blocks.push(current);
|
|
107
|
+
return blocks;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function keysIn(lines: readonly string[]): readonly string[] {
|
|
111
|
+
const keys: string[] = [];
|
|
112
|
+
for (const line of lines) {
|
|
113
|
+
const match = /^([A-Za-z_][\w-]*)\s*=/.exec(line.trim());
|
|
114
|
+
if (match?.[1] !== undefined) keys.push(match[1]);
|
|
115
|
+
}
|
|
116
|
+
return keys;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export type UnknownBunfigKey = { readonly section: string; readonly key: string };
|
|
120
|
+
|
|
121
|
+
export type BunfigMergeResult =
|
|
122
|
+
| { readonly ok: true; readonly content: string }
|
|
123
|
+
| { readonly ok: false; readonly unknownKeys: readonly UnknownBunfigKey[] };
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Merges freshly generated bunfig content with whatever an app already has
|
|
127
|
+
* on disk. A section this run's `generated` output doesn't touch at all
|
|
128
|
+
* (e.g. `[install]` when `--install` wasn't passed, or `[install.scopes]`
|
|
129
|
+
* for a private-registry token, which renderBunfig never emits) is kept
|
|
130
|
+
* verbatim, appended after the generated content — so a section the
|
|
131
|
+
* template doesn't own this run is never silently dropped. A key inside a
|
|
132
|
+
* section the template DOES emit that isn't in this run's own version of
|
|
133
|
+
* that section (e.g. `concurrency`, `timeout`, kumiko-framework#3120) fails
|
|
134
|
+
* the merge instead of silently disappearing.
|
|
135
|
+
*/
|
|
136
|
+
export function mergeBunfig(generated: string, existingContent: string): BunfigMergeResult {
|
|
137
|
+
const generatedKeysBySection = new Map(
|
|
138
|
+
splitBunfigBlocks(generated).map((block) => [block.header, new Set(keysIn(block.lines))]),
|
|
139
|
+
);
|
|
140
|
+
const blocks = splitBunfigBlocks(existingContent);
|
|
141
|
+
const unknownKeys: UnknownBunfigKey[] = [];
|
|
142
|
+
const foreignBlocks: BunfigBlock[] = [];
|
|
143
|
+
for (const block of blocks) {
|
|
144
|
+
const allowed = generatedKeysBySection.get(block.header);
|
|
145
|
+
if (allowed === undefined) {
|
|
146
|
+
foreignBlocks.push(block);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
for (const key of keysIn(block.lines)) {
|
|
150
|
+
if (!allowed.has(key)) unknownKeys.push({ section: block.header, key });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (unknownKeys.length > 0) return { ok: false, unknownKeys };
|
|
154
|
+
if (foreignBlocks.length === 0) return { ok: true, content: generated };
|
|
155
|
+
const foreignText = foreignBlocks
|
|
156
|
+
.map((block) => `[${block.header}]\n${block.lines.join("\n").replace(/\n+$/, "")}\n`)
|
|
157
|
+
.join("\n");
|
|
158
|
+
return { ok: true, content: `${generated}\n${foreignText}` };
|
|
159
|
+
}
|
|
160
|
+
|
|
86
161
|
export function renderBunfigFiles(opts: BunfigOptions = {}): Readonly<Record<string, string>> {
|
|
87
162
|
const { dom, ...shared } = opts;
|
|
88
163
|
return {
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.304.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Screenshot scenarios get { seedTenant } in flow and a beforeCapture hook; the README no longer recommends a postSave hook on tenant for test defaults",
|
|
6
|
+
"detail": "`runScreenshots`/`runMatrix` register their tests on the same `test` as `@cosmicdrift/kumiko-testing/e2e`'s seeded-tenant fixture, so `Scenario.flow` now receives `(page, { seedTenant })` — existing single-parameter `flow(page)` scenarios keep working unchanged. `Scenario.beforeCapture?: (page) => Promise<void>` runs after the viewport is set and the page has settled, right before each screenshot. `seedTenant()`'s \"no baseURL\" error now only fires when a scenario actually calls it, not for every screenshot spec's fixture setup."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.299.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "kumiko-testing bunfig merges app-owned sections (e.g. install.scopes) back in and only aborts, naming the key, when a managed [install]/[test] section has a key the template doesn't emit; e2e seed routes gain an isE2eSeedingEnabled() export so a custom server entry can skip mounting them in prod",
|
|
12
|
+
"migration": "If `kumiko-testing bunfig` exits 1 naming a section.key (e.g. test.concurrency), remove or rename that key, or move it out of [install]/[test], then rerun. If your server entry mounts createE2eSeedRoutes() directly instead of using e2e/server.ts, guard the mount with isE2eSeedingEnabled() so prod never registers the seed routes."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.298.0",
|
|
16
|
+
"type": "breaking",
|
|
17
|
+
"title": "createE2eSeedRoutes() returns ExtraRouteDefinition[] instead of an extraRoutes callback",
|
|
18
|
+
"migration": "createE2eSeedRoutes() now returns readonly ExtraRouteDefinition[] instead of an (app, deps) => void callback. The call site extraRoutes: createE2eSeedRoutes() in setupTestStack is unchanged, but any code that imported createE2eSeedRoutes() to invoke it directly against app (rather than passing it through extraRoutes) must instead treat the result as a route list, e.g. register each entry through the framework's ExtraRouteDefinition handling."
|
|
19
|
+
},
|
|
2
20
|
{
|
|
3
21
|
"version": "0.295.0",
|
|
4
22
|
"type": "improvement",
|
|
@@ -76,7 +76,7 @@ function assertTemplateOwnedKeysUnset(project: E2eProject): void {
|
|
|
76
76
|
];
|
|
77
77
|
if (offending.length > 0) {
|
|
78
78
|
throw new Error(
|
|
79
|
-
`defineAppE2eConfig: project "${name}" sets ${offending.join(", ")}; timeouts, retries and workers belong to the template. Diagnose per docs/guides/test-failures.md instead of raising them.`,
|
|
79
|
+
`defineAppE2eConfig: project "${name}" sets ${offending.join(", ")}; timeouts, retries and workers belong to the template. Diagnose per https://github.com/CosmicDriftGameStudio/kumiko-framework/blob/main/docs/guides/test-failures.md instead of raising them.`,
|
|
80
80
|
);
|
|
81
81
|
}
|
|
82
82
|
}
|
package/src/e2e/index.ts
CHANGED
package/src/e2e/screenshots.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { mkdirSync, statSync } from "node:fs";
|
|
3
|
-
import { expect, type Page, type Request
|
|
3
|
+
import { expect, type Page, type Request } from "@playwright/test";
|
|
4
4
|
import { pinEnglishLocale } from "./pin-english-locale";
|
|
5
5
|
import { requireScreenshotDir } from "./screenshot-dir";
|
|
6
|
+
import { type SeedTenantFixture, test } from "./seeded-tenant-fixture";
|
|
6
7
|
|
|
7
8
|
// runScreenshots: one image per scenario → $SCREENSHOT_DIR/<name>.png.
|
|
8
9
|
// runMatrix: every scenario × locale × theme × viewport in ONE run →
|
|
@@ -30,10 +31,16 @@ export interface Scenario {
|
|
|
30
31
|
readonly name: string;
|
|
31
32
|
readonly description?: string;
|
|
32
33
|
readonly url?: string;
|
|
33
|
-
readonly flow?: (page: Page) => Promise<void>;
|
|
34
|
+
readonly flow?: (page: Page, fixtures: { seedTenant: SeedTenantFixture }) => Promise<void>;
|
|
34
35
|
readonly waitFor?: string;
|
|
35
36
|
readonly fullPage?: boolean;
|
|
36
37
|
readonly viewport?: { readonly width: number; readonly height: number };
|
|
38
|
+
// Runs after the viewport is set and the page has settled, right before the
|
|
39
|
+
// screenshot. runMatrix calls this once per theme × viewport combination for
|
|
40
|
+
// the scenario — it must be idempotent (e.g. hide/mask an element) rather
|
|
41
|
+
// than a one-shot action like clicking a dismiss button, which would only
|
|
42
|
+
// succeed on the first capture and time out on every one after.
|
|
43
|
+
readonly beforeCapture?: (page: Page) => Promise<void>;
|
|
37
44
|
// runMatrix only: opt out of the identical-theme-screenshot check for
|
|
38
45
|
// scenarios that legitimately render the same pixels across themes — e.g.
|
|
39
46
|
// a plain server-rendered content page with no client-side theme wiring.
|
|
@@ -93,8 +100,9 @@ async function openScenario(
|
|
|
93
100
|
page: Page,
|
|
94
101
|
s: Scenario,
|
|
95
102
|
inFlightDataRequests: () => number,
|
|
103
|
+
fixtures: { seedTenant: SeedTenantFixture },
|
|
96
104
|
): Promise<void> {
|
|
97
|
-
if (s.flow) await s.flow(page);
|
|
105
|
+
if (s.flow) await s.flow(page, fixtures);
|
|
98
106
|
else if (s.url) await page.goto(s.url);
|
|
99
107
|
else throw new Error(`Scenario "${s.name}" needs either url or flow`);
|
|
100
108
|
|
|
@@ -140,15 +148,19 @@ export function runScreenshots(scenarios: readonly Scenario[], opts: FlatOptions
|
|
|
140
148
|
// unreachable from Playwright (context.locale and --lang both no-op there). #1851
|
|
141
149
|
if (opts.pinLocale) test.use({ locale: "en-US" });
|
|
142
150
|
for (const s of scenarios) {
|
|
143
|
-
test(
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
151
|
+
test(
|
|
152
|
+
s.description ? `${s.name} — ${s.description}` : s.name,
|
|
153
|
+
async ({ page, seedTenant }) => {
|
|
154
|
+
if (opts.pinLocale) await pinEnglishLocale(page);
|
|
155
|
+
const inFlightDataRequests = countInFlightDataRequests(page);
|
|
156
|
+
if (s.viewport) await page.setViewportSize(s.viewport);
|
|
157
|
+
await openScenario(page, s, inFlightDataRequests, { seedTenant });
|
|
158
|
+
if (s.beforeCapture) await s.beforeCapture(page);
|
|
159
|
+
const path = `${outDir}/${s.name}.png`;
|
|
160
|
+
await page.screenshot({ path, fullPage: s.fullPage ?? false });
|
|
161
|
+
expect.soft(statSync(path).size).toBeGreaterThan(MIN_BYTES);
|
|
162
|
+
},
|
|
163
|
+
);
|
|
152
164
|
}
|
|
153
165
|
});
|
|
154
166
|
}
|
|
@@ -260,7 +272,7 @@ export function runMatrix<T extends string>(
|
|
|
260
272
|
|
|
261
273
|
for (const s of scenarios) {
|
|
262
274
|
if (only !== undefined && only !== s.name) continue;
|
|
263
|
-
test(s.name, async ({ page }) => {
|
|
275
|
+
test(s.name, async ({ page, seedTenant }) => {
|
|
264
276
|
// kumiko:locale drives the boot-time language (before goto); kumiko:theme
|
|
265
277
|
// is cleared so the mode is decided solely by applyTheme.
|
|
266
278
|
await page.addInitScript((lng) => {
|
|
@@ -268,7 +280,7 @@ export function runMatrix<T extends string>(
|
|
|
268
280
|
localStorage.removeItem("kumiko:theme");
|
|
269
281
|
}, locale);
|
|
270
282
|
const inFlightDataRequests = countInFlightDataRequests(page);
|
|
271
|
-
await openScenario(page, s, inFlightDataRequests);
|
|
283
|
+
await openScenario(page, s, inFlightDataRequests, { seedTenant });
|
|
272
284
|
|
|
273
285
|
const digests: ThemeScreenshotDigest<T>[] = [];
|
|
274
286
|
|
|
@@ -277,6 +289,7 @@ export function runMatrix<T extends string>(
|
|
|
277
289
|
for (const vp of viewports) {
|
|
278
290
|
await page.setViewportSize(VIEWPORTS[vp]);
|
|
279
291
|
await waitForSettledPage(page, inFlightDataRequests);
|
|
292
|
+
if (s.beforeCapture) await s.beforeCapture(page);
|
|
280
293
|
const dir = `${baseDir}/${s.name}/${locale}/${theme}`;
|
|
281
294
|
mkdirSync(dir, { recursive: true });
|
|
282
295
|
const path = `${dir}/${vp}.png`;
|
package/src/e2e/seed-route.ts
CHANGED
|
@@ -3,9 +3,13 @@ import {
|
|
|
3
3
|
getInbox,
|
|
4
4
|
mailTransportInMemoryFeature,
|
|
5
5
|
} from "@cosmicdrift/kumiko-bundled-features/mail-transport-inmemory";
|
|
6
|
-
import {
|
|
7
|
-
import
|
|
8
|
-
|
|
6
|
+
import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
|
|
7
|
+
import {
|
|
8
|
+
type ExtraRouteDefinition,
|
|
9
|
+
ExtraRouteRejection,
|
|
10
|
+
type SignatureExtraRouteVerifyRequest,
|
|
11
|
+
signatureRoute,
|
|
12
|
+
} from "@cosmicdrift/kumiko-framework/api";
|
|
9
13
|
import type { z } from "zod";
|
|
10
14
|
import {
|
|
11
15
|
persistTenantRows,
|
|
@@ -13,21 +17,13 @@ import {
|
|
|
13
17
|
type SeedWriter,
|
|
14
18
|
unwrapSavedRow,
|
|
15
19
|
} from "../seed-tenant";
|
|
16
|
-
import {
|
|
17
|
-
SEED_ENABLE_ENV,
|
|
18
|
-
SEED_ROUTE_PREFIX,
|
|
19
|
-
SEED_ROUTES,
|
|
20
|
-
SEED_TOKEN_ENV,
|
|
21
|
-
SEED_TOKEN_HEADER,
|
|
22
|
-
} from "./constants";
|
|
20
|
+
import { SEED_ENABLE_ENV, SEED_ROUTES, SEED_TOKEN_ENV, SEED_TOKEN_HEADER } from "./constants";
|
|
23
21
|
import {
|
|
24
22
|
createSeedUserRequestSchema,
|
|
25
23
|
inboxQuerySchema,
|
|
26
24
|
seedTenantRequestSchema,
|
|
27
25
|
} from "./seed-contract";
|
|
28
26
|
|
|
29
|
-
type ExtraRoutes = NonNullable<CreateKumikoServerOptions["extraRoutes"]>;
|
|
30
|
-
|
|
31
27
|
type ParsedBody<T> =
|
|
32
28
|
| { readonly success: true; readonly data: T }
|
|
33
29
|
| { readonly success: false; readonly error: string };
|
|
@@ -50,7 +46,18 @@ function parseJsonBody<S extends z.ZodType>(schema: S, raw: string): ParsedBody<
|
|
|
50
46
|
}
|
|
51
47
|
}
|
|
52
48
|
|
|
53
|
-
|
|
49
|
+
// Shared by every verify() below — a failed parse becomes a 400 the same way
|
|
50
|
+
// the pre-#3050 handler-level parseJsonBody/parseWith checks did.
|
|
51
|
+
function parseOrReject<T>(value: ParsedBody<T>): T {
|
|
52
|
+
if (value.success) return value.data;
|
|
53
|
+
throw new ExtraRouteRejection(400, { error: value.error });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Exported so a server entry that already mounts its own extraRoutes (instead
|
|
57
|
+
// of duplicating e2e/server.ts) can gate the *registration* itself — prod
|
|
58
|
+
// then never carries the seed routes at all, rather than relying solely on
|
|
59
|
+
// each route's own per-request assertGateOpen() check.
|
|
60
|
+
export function isE2eSeedingEnabled(): boolean {
|
|
54
61
|
return process.env[SEED_ENABLE_ENV] === "1" && process.env["NODE_ENV"] !== "production";
|
|
55
62
|
}
|
|
56
63
|
|
|
@@ -62,74 +69,118 @@ function tokenMatches(expected: string, provided: string | undefined): boolean {
|
|
|
62
69
|
return provided !== undefined && timingSafeEqual(digest(expected), digest(provided));
|
|
63
70
|
}
|
|
64
71
|
|
|
72
|
+
// Runs first in every verify() below — replaces the old `app.use(prefix/*)`
|
|
73
|
+
// guard middleware now that entry:"signature" owns its own access control.
|
|
74
|
+
function assertGateOpen(request: SignatureExtraRouteVerifyRequest): void {
|
|
75
|
+
// ExtraRouteRejection(status, body) always renders via c.json(body, status)
|
|
76
|
+
// (buildExtraRouteHonoHandler) — the plain-text "Not Found" from the old
|
|
77
|
+
// middleware becomes a JSON body; no test asserts the 404 body shape.
|
|
78
|
+
if (!isE2eSeedingEnabled()) throw new ExtraRouteRejection(404, { error: "Not Found" });
|
|
79
|
+
const expected = process.env[SEED_TOKEN_ENV];
|
|
80
|
+
if (expected === undefined || expected === "") {
|
|
81
|
+
throw new ExtraRouteRejection(500, {
|
|
82
|
+
error: `${SEED_TOKEN_ENV} is not set; refusing to serve seed routes`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
if (!tokenMatches(expected, request.headers[SEED_TOKEN_HEADER])) {
|
|
86
|
+
throw new ExtraRouteRejection(401, { error: "invalid seed token" });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
65
90
|
export type E2eSeedRoutesOptions = {
|
|
66
91
|
readonly extraRoles?: readonly string[];
|
|
67
92
|
};
|
|
68
93
|
|
|
69
|
-
|
|
94
|
+
// Builds the route definitions unconditionally (extraRoles validation must
|
|
95
|
+
// still throw in every environment) — the *mounting* decision is the
|
|
96
|
+
// caller's: use isE2eSeedingEnabled() to keep prod from ever registering
|
|
97
|
+
// these route objects at all, e.g. `...(isE2eSeedingEnabled() ?
|
|
98
|
+
// createE2eSeedRoutes() : [])` in a server entry that also runs in prod
|
|
99
|
+
// (kumiko-framework#3120). Each route's own assertGateOpen() 404s outside
|
|
100
|
+
// the seed condition regardless — this is belt-and-suspenders, not the only gate.
|
|
101
|
+
export function createE2eSeedRoutes(
|
|
102
|
+
options: E2eSeedRoutesOptions = {},
|
|
103
|
+
): readonly ExtraRouteDefinition[] {
|
|
70
104
|
const seedUserRequestSchema = createSeedUserRequestSchema(options.extraRoles);
|
|
71
|
-
return (app, deps) => {
|
|
72
|
-
const write: SeedWriter = async (handlerQn, payload, tenantId) =>
|
|
73
|
-
unwrapSavedRow(handlerQn, await deps.dispatchSystemWrite({ handlerQn, payload, tenantId }));
|
|
74
105
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
return c.json({ error: "invalid seed token" }, 401);
|
|
87
|
-
}
|
|
88
|
-
await next();
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
app.post(SEED_ROUTES.seedTenant, async (c) => {
|
|
92
|
-
const body = parseJsonBody(seedTenantRequestSchema, await c.req.text());
|
|
93
|
-
if (!body.success) return c.json({ error: body.error }, 400);
|
|
106
|
+
const seedTenantRoute = signatureRoute<z.infer<typeof seedTenantRequestSchema>>({
|
|
107
|
+
method: "POST",
|
|
108
|
+
path: SEED_ROUTES.seedTenant,
|
|
109
|
+
entry: "signature",
|
|
110
|
+
verify: async (request) => {
|
|
111
|
+
assertGateOpen(request);
|
|
112
|
+
return parseOrReject(parseJsonBody(seedTenantRequestSchema, request.rawBody));
|
|
113
|
+
},
|
|
114
|
+
handler: async (c, verified, deps) => {
|
|
115
|
+
const write: SeedWriter = async (handlerQn, payload, tenantId) =>
|
|
116
|
+
unwrapSavedRow(handlerQn, await deps.dispatchSystemWrite({ handlerQn, payload, tenantId }));
|
|
94
117
|
try {
|
|
95
118
|
return c.json(
|
|
96
|
-
await persistTenantRows(write, { name:
|
|
119
|
+
await persistTenantRows(write, { name: verified.name, users: verified.members }),
|
|
97
120
|
);
|
|
98
121
|
} catch (error) {
|
|
99
122
|
return c.json({ error: error instanceof Error ? error.message : "seed failed" }, 500);
|
|
100
123
|
}
|
|
101
|
-
}
|
|
124
|
+
},
|
|
125
|
+
});
|
|
102
126
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
127
|
+
const seedUserRoute = signatureRoute<z.infer<typeof seedUserRequestSchema>>({
|
|
128
|
+
method: "POST",
|
|
129
|
+
path: SEED_ROUTES.seedUser,
|
|
130
|
+
entry: "signature",
|
|
131
|
+
verify: async (request) => {
|
|
132
|
+
assertGateOpen(request);
|
|
133
|
+
return parseOrReject(parseJsonBody(seedUserRequestSchema, request.rawBody));
|
|
134
|
+
},
|
|
135
|
+
handler: async (c, verified, deps) => {
|
|
136
|
+
// No raw db in signature-route deps (fw#3050) — tenant:query:me, run as
|
|
137
|
+
// a SystemAdmin scoped to verified.tenantId, is the existing
|
|
138
|
+
// fetchOne(tenantTable, {id: tenantId}) lookup, just behind the dispatcher.
|
|
139
|
+
const tenantRow = await deps.dispatchSystemQuery({
|
|
140
|
+
handlerQn: TenantQueries.me,
|
|
141
|
+
payload: {},
|
|
142
|
+
tenantId: verified.tenantId,
|
|
143
|
+
});
|
|
144
|
+
if (tenantRow === null) {
|
|
145
|
+
return c.json({ error: `unknown tenant ${verified.tenantId}` }, 404);
|
|
108
146
|
}
|
|
147
|
+
const write: SeedWriter = async (handlerQn, payload, tenantId) =>
|
|
148
|
+
unwrapSavedRow(handlerQn, await deps.dispatchSystemWrite({ handlerQn, payload, tenantId }));
|
|
109
149
|
try {
|
|
110
|
-
return c.json(await persistUserRows(write,
|
|
150
|
+
return c.json(await persistUserRows(write, verified.tenantId, verified.roles));
|
|
111
151
|
} catch (error) {
|
|
112
152
|
return c.json({ error: error instanceof Error ? error.message : "seed failed" }, 500);
|
|
113
153
|
}
|
|
114
|
-
}
|
|
154
|
+
},
|
|
155
|
+
});
|
|
115
156
|
|
|
116
|
-
|
|
157
|
+
const inboxRoute = signatureRoute<z.infer<typeof inboxQuerySchema>>({
|
|
158
|
+
method: "GET",
|
|
159
|
+
path: SEED_ROUTES.inbox,
|
|
160
|
+
entry: "signature",
|
|
161
|
+
verify: async (request) => {
|
|
162
|
+
assertGateOpen(request);
|
|
163
|
+
return parseOrReject(
|
|
164
|
+
parseWith(inboxQuerySchema, {
|
|
165
|
+
tenantId: request.query["tenantId"],
|
|
166
|
+
to: request.query["to"],
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
},
|
|
170
|
+
handler: async (c, verified, deps) => {
|
|
117
171
|
if (!deps.registry.features.has(mailTransportInMemoryFeature.name)) {
|
|
118
172
|
return c.json(
|
|
119
173
|
{ error: `${mailTransportInMemoryFeature.name} is not mounted; no inbox to read` },
|
|
120
174
|
501,
|
|
121
175
|
);
|
|
122
176
|
}
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
to: c.req.query("to"),
|
|
126
|
-
});
|
|
127
|
-
if (!query.success) return c.json({ error: query.error }, 400);
|
|
128
|
-
const recipient = query.data.to.toLowerCase();
|
|
129
|
-
const messages = getInbox(query.data.tenantId).filter(
|
|
177
|
+
const recipient = verified.to.toLowerCase();
|
|
178
|
+
const messages = getInbox(verified.tenantId).filter(
|
|
130
179
|
(message) => message.to.toLowerCase() === recipient,
|
|
131
180
|
);
|
|
132
181
|
return c.json({ messages });
|
|
133
|
-
}
|
|
134
|
-
};
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
return [seedTenantRoute, seedUserRoute, inboxRoute];
|
|
135
186
|
}
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
type APIRequestContext,
|
|
4
|
+
test as base,
|
|
5
|
+
type Page,
|
|
6
|
+
type PlaywrightTestArgs,
|
|
7
|
+
type PlaywrightTestOptions,
|
|
8
|
+
type PlaywrightWorkerArgs,
|
|
9
|
+
} from "@playwright/test";
|
|
3
10
|
import type { z } from "zod";
|
|
4
11
|
import {
|
|
5
12
|
type BoundApi,
|
|
@@ -24,10 +31,20 @@ export type E2eSeededTenant = SeededTenant & {
|
|
|
24
31
|
readonly loginAs: (page: Page, user: SeededUser) => Promise<void>;
|
|
25
32
|
};
|
|
26
33
|
|
|
34
|
+
export type SeedTenantFixture = (opts?: E2eSeedTenantOptions) => Promise<E2eSeededTenant>;
|
|
35
|
+
|
|
27
36
|
type E2eFixtures = {
|
|
28
|
-
seedTenant:
|
|
37
|
+
seedTenant: SeedTenantFixture;
|
|
29
38
|
};
|
|
30
39
|
|
|
40
|
+
// Narrowed to the four fixtures the body below actually reads, so a unit
|
|
41
|
+
// test can drive it with plain stubs instead of a full Playwright run.
|
|
42
|
+
export type ProvideSeedTenantDeps = Pick<
|
|
43
|
+
PlaywrightTestArgs & PlaywrightWorkerArgs,
|
|
44
|
+
"request" | "context" | "playwright"
|
|
45
|
+
> &
|
|
46
|
+
Pick<PlaywrightTestOptions, "baseURL">;
|
|
47
|
+
|
|
31
48
|
async function postSeedRoute<S extends z.ZodType>(
|
|
32
49
|
request: APIRequestContext,
|
|
33
50
|
path: string,
|
|
@@ -41,83 +58,90 @@ async function postSeedRoute<S extends z.ZodType>(
|
|
|
41
58
|
return schema.parse(await response.json());
|
|
42
59
|
}
|
|
43
60
|
|
|
44
|
-
export
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
// One cookie jar per user: two tenants (or admin + member) in one test must not clobber each other's session.
|
|
51
|
-
const openLoggedInApi = async (user: SeededUser): Promise<BoundApi> => {
|
|
52
|
-
const ctx = await playwright.request.newContext({ baseURL });
|
|
53
|
-
openedContexts.push(ctx);
|
|
54
|
-
await loginViaApi(ctx, user);
|
|
55
|
-
return createHttpApi(ctx);
|
|
56
|
-
};
|
|
61
|
+
export async function provideSeedTenant(
|
|
62
|
+
{ request, context, playwright, baseURL }: ProvideSeedTenantDeps,
|
|
63
|
+
use: (fixture: SeedTenantFixture) => Promise<void>,
|
|
64
|
+
): Promise<void> {
|
|
65
|
+
const openedContexts: APIRequestContext[] = [];
|
|
66
|
+
const apiByUserId = new Map<string, BoundApi>();
|
|
57
67
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
};
|
|
66
|
-
const api: BoundApi = {
|
|
67
|
-
writeOk: async (type, payload, requestId) =>
|
|
68
|
-
(await bound()).writeOk(type, payload, requestId),
|
|
69
|
-
writeErr: async (type, payload) => (await bound()).writeErr(type, payload),
|
|
70
|
-
queryOk: async (type, payload) => (await bound()).queryOk(type, payload),
|
|
71
|
-
queryErr: async (type, payload) => (await bound()).queryErr(type, payload),
|
|
72
|
-
};
|
|
73
|
-
apiByUserId.set(user.id, api);
|
|
74
|
-
return api;
|
|
75
|
-
};
|
|
68
|
+
// One cookie jar per user: two tenants (or admin + member) in one test must not clobber each other's session.
|
|
69
|
+
const openLoggedInApi = async (user: SeededUser): Promise<BoundApi> => {
|
|
70
|
+
const ctx = await playwright.request.newContext({ baseURL });
|
|
71
|
+
openedContexts.push(ctx);
|
|
72
|
+
await loginViaApi(ctx, user);
|
|
73
|
+
return createHttpApi(ctx);
|
|
74
|
+
};
|
|
76
75
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
const httpApiFor = (user: SeededUser): BoundApi => {
|
|
77
|
+
const cached = apiByUserId.get(user.id);
|
|
78
|
+
if (cached !== undefined) return cached;
|
|
79
|
+
let opened: Promise<BoundApi> | undefined;
|
|
80
|
+
const bound = (): Promise<BoundApi> => {
|
|
81
|
+
opened ??= openLoggedInApi(user);
|
|
82
|
+
return opened;
|
|
83
|
+
};
|
|
84
|
+
const api: BoundApi = {
|
|
85
|
+
writeOk: async (type, payload, requestId) =>
|
|
86
|
+
(await bound()).writeOk(type, payload, requestId),
|
|
87
|
+
writeErr: async (type, payload) => (await bound()).writeErr(type, payload),
|
|
88
|
+
queryOk: async (type, payload) => (await bound()).queryOk(type, payload),
|
|
89
|
+
queryErr: async (type, payload) => (await bound()).queryErr(type, payload),
|
|
80
90
|
};
|
|
91
|
+
apiByUserId.set(user.id, api);
|
|
92
|
+
return api;
|
|
93
|
+
};
|
|
81
94
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
95
|
+
const loginAs = async (page: Page, user: SeededUser): Promise<void> => {
|
|
96
|
+
await page.context().clearCookies();
|
|
97
|
+
await loginViaApi(page.context().request, user);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
await use(async (opts = {}) => {
|
|
101
|
+
if (baseURL === undefined) {
|
|
102
|
+
throw new Error("seedTenant: the Playwright config has no baseURL");
|
|
103
|
+
}
|
|
104
|
+
const seeded = await postSeedRoute(
|
|
105
|
+
request,
|
|
106
|
+
SEED_ROUTES.seedTenant,
|
|
107
|
+
{ name: opts.name, members: opts.users },
|
|
108
|
+
seedTenantResponseSchema,
|
|
109
|
+
);
|
|
110
|
+
const tenantId = seeded.id;
|
|
111
|
+
const toUser = (credentials: SeededCredentials, roles: readonly string[]): SeededUser =>
|
|
112
|
+
withSession(credentials, tenantId, roles);
|
|
113
|
+
|
|
114
|
+
const admin = toUser(seeded.admin, [ROLES.TenantAdmin]);
|
|
115
|
+
await loginViaApi(context.request, admin);
|
|
116
|
+
|
|
117
|
+
const tenant: E2eSeededTenant = {
|
|
118
|
+
id: tenantId,
|
|
119
|
+
key: seeded.key,
|
|
120
|
+
name: seeded.name,
|
|
121
|
+
admin,
|
|
122
|
+
members: seeded.members.map((member) => toUser(member, [ROLES.Member])),
|
|
123
|
+
addUser: async (roles = [ROLES.Member]) =>
|
|
124
|
+
toUser(
|
|
125
|
+
await postSeedRoute(
|
|
126
|
+
request,
|
|
127
|
+
SEED_ROUTES.seedUser,
|
|
128
|
+
{ tenantId, roles: [...roles] } satisfies SeedUserRequest,
|
|
129
|
+
seedUserResponseSchema,
|
|
111
130
|
),
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
131
|
+
roles,
|
|
132
|
+
),
|
|
133
|
+
api: httpApiFor(admin),
|
|
134
|
+
apiAs: httpApiFor,
|
|
135
|
+
loginAs,
|
|
136
|
+
};
|
|
116
137
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
138
|
+
for (const part of opts.with ?? []) await part({ tenant });
|
|
139
|
+
return tenant;
|
|
140
|
+
});
|
|
120
141
|
|
|
121
|
-
|
|
122
|
-
|
|
142
|
+
await Promise.all(openedContexts.map((ctx) => ctx.dispose()));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const test = base.extend<E2eFixtures>({
|
|
146
|
+
seedTenant: provideSeedTenant,
|
|
123
147
|
});
|
package/src/preload/ci-log.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// biome-ignore-all lint/suspicious/noConsole: the preload exists to print this line.
|
|
2
|
-
console.log("[kumiko-test] CI profile active — config=bunfig.ci.toml
|
|
2
|
+
console.log("[kumiko-test] CI profile active — config=bunfig.ci.toml");
|
package/src/scaffold.ts
CHANGED
|
@@ -35,7 +35,7 @@ const RULES_MARKDOWN = [
|
|
|
35
35
|
`- Real-provider tests only run with \`${REAL_PROVIDERS_ENV}=1\`, via \`bun run test:real\` / \`bun run e2e:real\`, locally and never in CI.`,
|
|
36
36
|
"- Tests run in parallel by default. Timeouts, retries and workers live in the scripts, the bunfig files and `playwright.config.ts`; never raise them inside a test. The only exception carries a marker: `// @timeout-exception: #<issue> <reason>`.",
|
|
37
37
|
"- Every flow seeds its own tenant: `seedTenant(stack)` in integration tests, the `seedTenant` fixture in e2e. No shared tenant, no widened seed helper.",
|
|
38
|
-
"- Red or flaky? Diagnose the cause first (
|
|
38
|
+
"- Red or flaky? Diagnose the cause first (https://github.com/CosmicDriftGameStudio/kumiko-framework/blob/main/docs/guides/test-failures.md). A longer timeout, a retry or `serial` is not a fix.",
|
|
39
39
|
"- Run Playwright as `bunx --bun playwright`, as the scripts do.",
|
|
40
40
|
`- Playwright configs and e2e files start with \`${TEST_RUNTIME_DIRECTIVE}\`; without it the runtime-isolation guard flags their import of the test package.`,
|
|
41
41
|
"",
|