@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.
@@ -0,0 +1,85 @@
1
+ import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
2
+ import { z } from "zod";
3
+
4
+ export const SEEDABLE_ROLES = [ROLES.TenantAdmin, ROLES.Member] as const;
5
+ export const MAX_SEED_MEMBERS = 10;
6
+ export const MAX_TENANT_NAME_LENGTH = 100;
7
+ const MAX_EMAIL_LENGTH = 320;
8
+
9
+ export const seedTenantRequestSchema = z.strictObject({
10
+ name: z.string().min(1).max(MAX_TENANT_NAME_LENGTH).optional(),
11
+ members: z.number().int().min(0).max(MAX_SEED_MEMBERS).optional(),
12
+ });
13
+
14
+ export const seededCredentialsSchema = z.object({
15
+ id: z.string(),
16
+ email: z.string(),
17
+ password: z.string(),
18
+ });
19
+
20
+ export const seedTenantResponseSchema = z.object({
21
+ id: z.string(),
22
+ key: z.string(),
23
+ name: z.string(),
24
+ admin: seededCredentialsSchema,
25
+ members: z.array(seededCredentialsSchema),
26
+ });
27
+
28
+ function isSystemAdminRole(role: string): boolean {
29
+ return role.trim().toLowerCase() === ROLES.SystemAdmin.toLowerCase();
30
+ }
31
+
32
+ export function resolveSeedableRoles(extraRoles: readonly string[] = []): readonly string[] {
33
+ for (const role of extraRoles) {
34
+ if (typeof role !== "string" || role.trim() === "") {
35
+ throw new Error(
36
+ `createE2eSeedRoutes: extraRoles entries must be non-empty strings, got ${JSON.stringify(role)}`,
37
+ );
38
+ }
39
+ if (isSystemAdminRole(role)) {
40
+ throw new Error(
41
+ `createE2eSeedRoutes: extraRoles must not contain ${ROLES.SystemAdmin}; it is never seedable`,
42
+ );
43
+ }
44
+ }
45
+ return [...new Set<string>([...SEEDABLE_ROLES, ...extraRoles])];
46
+ }
47
+
48
+ export function createSeedUserRequestSchema(extraRoles: readonly string[] = []) {
49
+ const seedable = resolveSeedableRoles(extraRoles);
50
+ return z.strictObject({
51
+ tenantId: z.uuid(),
52
+ roles: z
53
+ .array(
54
+ z.string().refine((role) => seedable.includes(role), {
55
+ message: `not a seedable role; allowed: ${seedable.join(", ")}`,
56
+ }),
57
+ )
58
+ .min(1)
59
+ .max(seedable.length),
60
+ });
61
+ }
62
+
63
+ export const seedUserRequestSchema = createSeedUserRequestSchema();
64
+
65
+ export const seedUserResponseSchema = seededCredentialsSchema;
66
+
67
+ export const inboxQuerySchema = z.strictObject({
68
+ tenantId: z.uuid(),
69
+ to: z.string().min(1).max(MAX_EMAIL_LENGTH),
70
+ });
71
+
72
+ const capturedMailSchema = z.object({
73
+ to: z.string(),
74
+ subject: z.string(),
75
+ html: z.string(),
76
+ from: z.string().optional(),
77
+ replyTo: z.string().optional(),
78
+ headers: z.record(z.string(), z.string()).optional(),
79
+ });
80
+
81
+ export const inboxResponseSchema = z.object({ messages: z.array(capturedMailSchema) });
82
+
83
+ export type CapturedMail = z.infer<typeof capturedMailSchema>;
84
+ export type SeedTenantResponse = z.infer<typeof seedTenantResponseSchema>;
85
+ export type SeedUserRequest = z.infer<typeof seedUserRequestSchema>;
@@ -0,0 +1,135 @@
1
+ import { createHash, timingSafeEqual } from "node:crypto";
2
+ import {
3
+ getInbox,
4
+ mailTransportInMemoryFeature,
5
+ } from "@cosmicdrift/kumiko-bundled-features/mail-transport-inmemory";
6
+ import { tenantTable } from "@cosmicdrift/kumiko-bundled-features/tenant";
7
+ import type { CreateKumikoServerOptions } from "@cosmicdrift/kumiko-dev-server";
8
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
9
+ import type { z } from "zod";
10
+ import {
11
+ persistTenantRows,
12
+ persistUserRows,
13
+ type SeedWriter,
14
+ unwrapSavedRow,
15
+ } 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";
23
+ import {
24
+ createSeedUserRequestSchema,
25
+ inboxQuerySchema,
26
+ seedTenantRequestSchema,
27
+ } from "./seed-contract";
28
+
29
+ type ExtraRoutes = NonNullable<CreateKumikoServerOptions["extraRoutes"]>;
30
+
31
+ type ParsedBody<T> =
32
+ | { readonly success: true; readonly data: T }
33
+ | { readonly success: false; readonly error: string };
34
+
35
+ function parseWith<S extends z.ZodType>(schema: S, value: unknown): ParsedBody<z.infer<S>> {
36
+ const result = schema.safeParse(value);
37
+ if (result.success) return { success: true, data: result.data };
38
+ const error = result.error.issues
39
+ .map((issue) => `${issue.path.join(".") || "body"}: ${issue.message}`)
40
+ .join("; ");
41
+ return { success: false, error };
42
+ }
43
+
44
+ function parseJsonBody<S extends z.ZodType>(schema: S, raw: string): ParsedBody<z.infer<S>> {
45
+ if (raw.trim() === "") return parseWith(schema, {});
46
+ try {
47
+ return parseWith(schema, JSON.parse(raw));
48
+ } catch {
49
+ return { success: false, error: "body: invalid JSON" };
50
+ }
51
+ }
52
+
53
+ function seedingEnabled(): boolean {
54
+ return process.env[SEED_ENABLE_ENV] === "1" && process.env["NODE_ENV"] !== "production";
55
+ }
56
+
57
+ function digest(value: string): Buffer {
58
+ return createHash("sha256").update(value).digest();
59
+ }
60
+
61
+ function tokenMatches(expected: string, provided: string | undefined): boolean {
62
+ return provided !== undefined && timingSafeEqual(digest(expected), digest(provided));
63
+ }
64
+
65
+ export type E2eSeedRoutesOptions = {
66
+ readonly extraRoles?: readonly string[];
67
+ };
68
+
69
+ export function createE2eSeedRoutes(options: E2eSeedRoutesOptions = {}): ExtraRoutes {
70
+ 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
+
75
+ app.use(`${SEED_ROUTE_PREFIX}/*`, async (c, next) => {
76
+ // c.notFound() would let the dev server answer unmatched GETs with its HTML shell (200).
77
+ if (!seedingEnabled()) return c.text("Not Found", 404);
78
+ const expected = process.env[SEED_TOKEN_ENV];
79
+ if (expected === undefined || expected === "") {
80
+ return c.json(
81
+ { error: `${SEED_TOKEN_ENV} is not set; refusing to serve seed routes` },
82
+ 500,
83
+ );
84
+ }
85
+ if (!tokenMatches(expected, c.req.header(SEED_TOKEN_HEADER))) {
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);
94
+ try {
95
+ return c.json(
96
+ await persistTenantRows(write, { name: body.data.name, users: body.data.members }),
97
+ );
98
+ } catch (error) {
99
+ return c.json({ error: error instanceof Error ? error.message : "seed failed" }, 500);
100
+ }
101
+ });
102
+
103
+ app.post(SEED_ROUTES.seedUser, async (c) => {
104
+ const body = parseJsonBody(seedUserRequestSchema, await c.req.text());
105
+ if (!body.success) return c.json({ error: body.error }, 400);
106
+ if ((await fetchOne(deps.db, tenantTable, { id: body.data.tenantId })) === undefined) {
107
+ return c.json({ error: `unknown tenant ${body.data.tenantId}` }, 404);
108
+ }
109
+ try {
110
+ return c.json(await persistUserRows(write, body.data.tenantId, body.data.roles));
111
+ } catch (error) {
112
+ return c.json({ error: error instanceof Error ? error.message : "seed failed" }, 500);
113
+ }
114
+ });
115
+
116
+ app.get(SEED_ROUTES.inbox, (c) => {
117
+ if (!deps.registry.features.has(mailTransportInMemoryFeature.name)) {
118
+ return c.json(
119
+ { error: `${mailTransportInMemoryFeature.name} is not mounted; no inbox to read` },
120
+ 501,
121
+ );
122
+ }
123
+ const query = parseWith(inboxQuerySchema, {
124
+ tenantId: c.req.query("tenantId"),
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(
130
+ (message) => message.to.toLowerCase() === recipient,
131
+ );
132
+ return c.json({ messages });
133
+ });
134
+ };
135
+ }
@@ -0,0 +1,123 @@
1
+ import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
2
+ import { type APIRequestContext, test as base, type Page } from "@playwright/test";
3
+ import type { z } from "zod";
4
+ import {
5
+ type BoundApi,
6
+ type SeededCredentials,
7
+ type SeededTenant,
8
+ type SeededUser,
9
+ type SeedTenantOptions,
10
+ withSession,
11
+ } from "../seed-types";
12
+ import { createHttpApi, loginViaApi } from "./auth-kit";
13
+ import { SEED_ROUTES } from "./constants";
14
+ import { seedRouteHeaders } from "./mail-capture";
15
+ import {
16
+ type SeedUserRequest,
17
+ seedTenantResponseSchema,
18
+ seedUserResponseSchema,
19
+ } from "./seed-contract";
20
+
21
+ export type E2eSeedTenantOptions = Omit<SeedTenantOptions, "persist">;
22
+
23
+ export type E2eSeededTenant = SeededTenant & {
24
+ readonly loginAs: (page: Page, user: SeededUser) => Promise<void>;
25
+ };
26
+
27
+ type E2eFixtures = {
28
+ seedTenant: (opts?: E2eSeedTenantOptions) => Promise<E2eSeededTenant>;
29
+ };
30
+
31
+ async function postSeedRoute<S extends z.ZodType>(
32
+ request: APIRequestContext,
33
+ path: string,
34
+ body: Record<string, unknown>,
35
+ schema: S,
36
+ ): Promise<z.infer<S>> {
37
+ const response = await request.post(path, { headers: seedRouteHeaders(), data: body });
38
+ if (!response.ok()) {
39
+ throw new Error(`seedTenant: POST ${path} -> ${response.status()} ${await response.text()}`);
40
+ }
41
+ return schema.parse(await response.json());
42
+ }
43
+
44
+ export const test = base.extend<E2eFixtures>({
45
+ seedTenant: async ({ request, context, playwright, baseURL }, use) => {
46
+ if (baseURL === undefined) throw new Error("seedTenant: the Playwright config has no baseURL");
47
+ const openedContexts: APIRequestContext[] = [];
48
+ const apiByUserId = new Map<string, BoundApi>();
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
+ };
57
+
58
+ const httpApiFor = (user: SeededUser): BoundApi => {
59
+ const cached = apiByUserId.get(user.id);
60
+ if (cached !== undefined) return cached;
61
+ let opened: Promise<BoundApi> | undefined;
62
+ const bound = (): Promise<BoundApi> => {
63
+ opened ??= openLoggedInApi(user);
64
+ return opened;
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
+ };
76
+
77
+ const loginAs = async (page: Page, user: SeededUser): Promise<void> => {
78
+ await page.context().clearCookies();
79
+ await loginViaApi(page.context().request, user);
80
+ };
81
+
82
+ await use(async (opts = {}) => {
83
+ const seeded = await postSeedRoute(
84
+ request,
85
+ SEED_ROUTES.seedTenant,
86
+ { name: opts.name, members: opts.users },
87
+ seedTenantResponseSchema,
88
+ );
89
+ const tenantId = seeded.id;
90
+ const toUser = (credentials: SeededCredentials, roles: readonly string[]): SeededUser =>
91
+ withSession(credentials, tenantId, roles);
92
+
93
+ const admin = toUser(seeded.admin, [ROLES.TenantAdmin]);
94
+ await loginViaApi(context.request, admin);
95
+
96
+ const tenant: E2eSeededTenant = {
97
+ id: tenantId,
98
+ key: seeded.key,
99
+ name: seeded.name,
100
+ admin,
101
+ members: seeded.members.map((member) => toUser(member, [ROLES.Member])),
102
+ addUser: async (roles = [ROLES.Member]) =>
103
+ toUser(
104
+ await postSeedRoute(
105
+ request,
106
+ SEED_ROUTES.seedUser,
107
+ { tenantId, roles: [...roles] } satisfies SeedUserRequest,
108
+ seedUserResponseSchema,
109
+ ),
110
+ roles,
111
+ ),
112
+ api: httpApiFor(admin),
113
+ apiAs: httpApiFor,
114
+ loginAs,
115
+ };
116
+
117
+ for (const part of opts.with ?? []) await part({ tenant });
118
+ return tenant;
119
+ });
120
+
121
+ await Promise.all(openedContexts.map((ctx) => ctx.dispose()));
122
+ },
123
+ });
@@ -0,0 +1,10 @@
1
+ // Class budget for every app: solon's 30s/5s flows run 4-parallel today; webServer is the
2
+ // slowest sample boot (Bun.build + Tailwind), poll is phronexsis's 15s projection wait.
3
+ export const E2E_TIMEOUT_MS = {
4
+ test: 30_000,
5
+ expect: 5_000,
6
+ action: 5_000,
7
+ navigation: 10_000,
8
+ poll: 15_000,
9
+ webServer: 90_000,
10
+ } as const;
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ export { PROVIDER_ENV_KEYS } from "./provider-env-keys";
2
+ export {
3
+ type BoundApi,
4
+ type PersistedTenant,
5
+ persistTenantRows,
6
+ type SavedRow,
7
+ type SeededCredentials,
8
+ type SeededTenant,
9
+ type SeededUser,
10
+ type SeedPart,
11
+ type SeedTenantOptions,
12
+ type SeedWriter,
13
+ seedTenant,
14
+ } from "./seed-tenant";
15
+ export {
16
+ type SetupAppTestStackOptions,
17
+ setupAppTestStack,
18
+ } from "./setup-app-test-stack";
@@ -0,0 +1,37 @@
1
+ import { BUNFIG_FILES, TEST_TIMEOUT_MS } from "./bunfig";
2
+
3
+ export type IntegrationRunOptions = {
4
+ readonly files: readonly string[];
5
+ readonly parallel?: number;
6
+ readonly timings?: string;
7
+ readonly updateTimings?: boolean;
8
+ };
9
+
10
+ const EXCLUDED_SEGMENTS: ReadonlySet<string> = new Set(["node_modules", "dist", "e2e"]);
11
+
12
+ export function selectIntegrationFiles(paths: readonly string[]): string[] {
13
+ return paths
14
+ .filter((path) => path.endsWith(".integration.test.ts"))
15
+ .filter((path) => !path.split("/").some((segment) => EXCLUDED_SEGMENTS.has(segment)))
16
+ .sort();
17
+ }
18
+
19
+ const isPositiveInteger = (value: number): boolean => Number.isInteger(value) && value > 0;
20
+
21
+ export function buildIntegrationTestArgs(opts: IntegrationRunOptions): string[] {
22
+ if (opts.parallel !== undefined && !isPositiveInteger(opts.parallel)) {
23
+ throw new Error(`--parallel must be a positive integer, got ${opts.parallel}`);
24
+ }
25
+ if (opts.updateTimings === true && opts.timings === undefined) {
26
+ throw new Error("--update-timings needs --timings <file>");
27
+ }
28
+ return [
29
+ "test",
30
+ `--config=${BUNFIG_FILES.integration}`,
31
+ `--timeout=${TEST_TIMEOUT_MS.integration}`,
32
+ ...(opts.parallel !== undefined ? [`--parallel=${opts.parallel}`] : []),
33
+ ...(opts.timings !== undefined ? [`--timings=${opts.timings}`] : []),
34
+ ...(opts.updateTimings === true ? ["--update-timings"] : []),
35
+ ...opts.files,
36
+ ];
37
+ }
@@ -0,0 +1,2 @@
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 concurrency=1");
@@ -0,0 +1,2 @@
1
+ import "./scrub-env";
2
+ import "./service-env-defaults";
@@ -0,0 +1,4 @@
1
+ import { requireRealProviders } from "@cosmicdrift/kumiko-framework/testing";
2
+ import "./service-env-defaults";
3
+
4
+ requireRealProviders();
@@ -0,0 +1,3 @@
1
+ import { scrubProviderEnv } from "../provider-env-keys";
2
+
3
+ scrubProviderEnv(process.env);
@@ -0,0 +1,14 @@
1
+ process.env["DATABASE_URL"] ??= "postgresql://kumiko:kumiko@localhost:15432/kumiko_dev";
2
+ process.env["TEST_DATABASE_URL"] ??= "postgresql://kumiko:kumiko@localhost:15432/kumiko_test";
3
+ process.env["REDIS_URL"] ??= "redis://localhost:16379";
4
+ process.env["MEILI_URL"] ??= "http://localhost:17700";
5
+ process.env["MEILI_MASTER_KEY"] ??= "kumiko-dev-key";
6
+ process.env["JWT_SECRET"] ??= "test-jwt-secret-at-least-32-characters-long";
7
+
8
+ process.env["MINIO_ENDPOINT"] ??= "http://localhost:19000";
9
+ process.env["MINIO_ACCESS_KEY"] ??= "kumiko";
10
+ process.env["MINIO_SECRET_KEY"] ??= "kumiko-dev-secret";
11
+ process.env["MINIO_BUCKET"] ??= "kumiko-dev";
12
+ process.env["MINIO_REGION"] ??= "us-east-1";
13
+
14
+ process.env["LEGACY_DATABASE_URL"] ??= "";
@@ -0,0 +1,6 @@
1
+ import { ensureTemporalPolyfill } from "@cosmicdrift/kumiko-framework/time";
2
+
3
+ await ensureTemporalPolyfill();
4
+
5
+ process.env["KUMIKO_INSTANCE_ID"] ??= "test-instance";
6
+ process.env.NODE_ENV ??= "test";
@@ -0,0 +1,16 @@
1
+ export const PROVIDER_ENV_KEYS = [
2
+ "ANTHROPIC_API_KEY",
3
+ "STRIPE_API_KEY",
4
+ "STRIPE_WEBHOOK_SECRET",
5
+ "MOLLIE_API_KEY",
6
+ "SMTP_HOST",
7
+ "SMTP_USER",
8
+ "SMTP_PASS",
9
+ "S3_ACCESS_KEY",
10
+ "S3_SECRET_KEY",
11
+ "PLATFORM_KEK_KMS_TOKEN",
12
+ ] as const;
13
+
14
+ export function scrubProviderEnv(env: Record<string, string | undefined>): void {
15
+ for (const key of PROVIDER_ENV_KEYS) delete env[key];
16
+ }
@@ -0,0 +1,203 @@
1
+ // @runtime tooling
2
+ import { BUNFIG_FILES, renderBunfigFiles, TEST_TIMEOUT_MS } from "./bunfig";
3
+ import { REAL_PROVIDERS_ENV } from "./e2e/constants";
4
+
5
+ export type ScaffoldTestSetup = {
6
+ readonly files: Readonly<Record<string, string>>;
7
+ readonly scripts: Readonly<Record<string, string>>;
8
+ readonly devDependencies: Readonly<Record<string, string>>;
9
+ readonly rulesMarkdown: string;
10
+ };
11
+
12
+ export type RenderTestSetupInput = {
13
+ readonly appName: string;
14
+ readonly frameworkVersion: string;
15
+ readonly port?: number;
16
+ };
17
+
18
+ // One above the scaffold's `bun dev` default (4173) so both can run side by side.
19
+ export const SCAFFOLD_E2E_PORT = 4174;
20
+
21
+ export const SCAFFOLD_INTEGRATION_PARALLEL = 4;
22
+
23
+ const PLAYWRIGHT_VERSION = "^1.62.1";
24
+
25
+ const TEST_RUNTIME_DIRECTIVE = "// @runtime test";
26
+
27
+ const TASK_CREATE = "tasks:write:task:create";
28
+ const TASK_LIST = "tasks:query:task:list";
29
+
30
+ const RULES_MARKDOWN = [
31
+ "## Testing",
32
+ "",
33
+ "- Three classes, told apart by file name: `*.test.ts` (unit, no services), `*.integration.test.ts` (real Postgres/Redis stack), `e2e/*.spec.ts` (Playwright against a booted app).",
34
+ "- Run them with `bun run test`, `bun run test:integration` and `bun run e2e`. Integration and e2e need `docker compose up -d` and a `.env` (copy `.env.example`).",
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
+ "- 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
+ "- 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 (`docs/guides/test-failures.md` in the kumiko-framework repo). A longer timeout, a retry or `serial` is not a fix.",
39
+ "- Run Playwright as `bunx --bun playwright`, as the scripts do.",
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
+ "",
42
+ ].join("\n");
43
+
44
+ function renderPlaywrightConfig(port: number): string {
45
+ return [
46
+ TEST_RUNTIME_DIRECTIVE,
47
+ 'import { defineAppE2eConfig } from "@cosmicdrift/kumiko-testing/e2e";',
48
+ "",
49
+ `export default defineAppE2eConfig({ port: ${port} });`,
50
+ "",
51
+ ].join("\n");
52
+ }
53
+
54
+ function renderE2eServer(appName: string): string {
55
+ return [
56
+ TEST_RUNTIME_DIRECTIVE,
57
+ 'import { randomUUID } from "node:crypto";',
58
+ 'import { runDevApp } from "@cosmicdrift/kumiko-dev-server";',
59
+ 'import { createE2eSeedRoutes } from "@cosmicdrift/kumiko-testing/e2e/seed-route";',
60
+ 'import { APP_FEATURES } from "../src/run-config";',
61
+ "",
62
+ "await runDevApp({",
63
+ " features: APP_FEATURES,",
64
+ ' clientEntry: "./src/client.tsx",',
65
+ " extraRoutes: createE2eSeedRoutes(),",
66
+ " auth: {",
67
+ " admin: {",
68
+ ` email: ${JSON.stringify(`admin@${appName}.local`)},`,
69
+ " password: randomUUID(),",
70
+ ' displayName: "Admin",',
71
+ " emailVerified: true,",
72
+ " memberships: [],",
73
+ " },",
74
+ " },",
75
+ "});",
76
+ "",
77
+ ].join("\n");
78
+ }
79
+
80
+ function renderE2eSmokeSpec(): string {
81
+ return [
82
+ TEST_RUNTIME_DIRECTIVE,
83
+ 'import type { SeedPart } from "@cosmicdrift/kumiko-testing";',
84
+ 'import { expect, test } from "@cosmicdrift/kumiko-testing/e2e";',
85
+ 'import type { Page } from "@playwright/test";',
86
+ "",
87
+ `const TASK_CREATE = ${JSON.stringify(TASK_CREATE)};`,
88
+ "",
89
+ "function withTask(title: string): SeedPart {",
90
+ " return async ({ tenant }) => {",
91
+ ' await tenant.api.writeOk(TASK_CREATE, { title, status: "todo", priority: 1 });',
92
+ " };",
93
+ "}",
94
+ "",
95
+ "async function openTasks(page: Page): Promise<void> {",
96
+ ' await page.goto("/");',
97
+ ' await page.getByRole("link", { name: "Tasks" }).first().click();',
98
+ "}",
99
+ "",
100
+ 'test("a seeded task shows up on the tasks screen", async ({ seedTenant, page }) => {',
101
+ ' const tenant = await seedTenant({ with: [withTask("Water the plants")] });',
102
+ " await tenant.loginAs(page, tenant.admin);",
103
+ " await openTasks(page);",
104
+ ' await expect(page.getByText("Water the plants")).toBeVisible();',
105
+ "});",
106
+ "",
107
+ 'test("a task stays invisible to another tenant", async ({ seedTenant, page }) => {',
108
+ ' await seedTenant({ with: [withTask("Secret plans")] });',
109
+ ' const other = await seedTenant({ with: [withTask("Own task")] });',
110
+ " await other.loginAs(page, other.admin);",
111
+ " await openTasks(page);",
112
+ ' await expect(page.getByText("Own task")).toBeVisible();',
113
+ ' await expect(page.getByText("Secret plans")).toHaveCount(0);',
114
+ "});",
115
+ "",
116
+ ].join("\n");
117
+ }
118
+
119
+ function renderUnitTest(): string {
120
+ return [
121
+ 'import { describe, expect, test } from "bun:test";',
122
+ 'import { createRegistry, validateBoot } from "@cosmicdrift/kumiko-framework/engine";',
123
+ 'import { composeFeatures } from "@cosmicdrift/kumiko-server-runtime/compose-features";',
124
+ 'import { APP_FEATURES, HAS_AUTH } from "../run-config";',
125
+ "",
126
+ 'describe("run-config", () => {',
127
+ ' test("the mounted features pass boot validation and register the task handlers", () => {',
128
+ " const features = composeFeatures([...APP_FEATURES], { includeBundled: HAS_AUTH });",
129
+ " validateBoot(features);",
130
+ " const registry = createRegistry(features);",
131
+ ` expect(registry.getWriteHandler(${JSON.stringify(TASK_CREATE)})).toBeDefined();`,
132
+ ` expect(registry.getQueryHandler(${JSON.stringify(TASK_LIST)})).toBeDefined();`,
133
+ " });",
134
+ "});",
135
+ "",
136
+ ].join("\n");
137
+ }
138
+
139
+ function renderIntegrationTest(): string {
140
+ return [
141
+ 'import { afterAll, beforeAll, describe, expect, test } from "bun:test";',
142
+ 'import type { TestStack } from "@cosmicdrift/kumiko-framework/stack";',
143
+ 'import { seedTenant, setupAppTestStack } from "@cosmicdrift/kumiko-testing";',
144
+ 'import { tasksFeature } from "../features/tasks";',
145
+ "",
146
+ `const TASK_CREATE = ${JSON.stringify(TASK_CREATE)};`,
147
+ `const TASK_LIST = ${JSON.stringify(TASK_LIST)};`,
148
+ "",
149
+ "type TaskList = { rows: { title: string }[] };",
150
+ "",
151
+ "let stack: TestStack;",
152
+ "",
153
+ "beforeAll(async () => {",
154
+ " stack = await setupAppTestStack([tasksFeature]);",
155
+ "});",
156
+ "",
157
+ "afterAll(async () => {",
158
+ " await stack.cleanup();",
159
+ "});",
160
+ "",
161
+ 'describe("tasks", () => {',
162
+ ' test("a created task is listed for its own tenant only", async () => {',
163
+ " const owner = await seedTenant(stack);",
164
+ " const other = await seedTenant(stack);",
165
+ "",
166
+ ' await owner.api.writeOk(TASK_CREATE, { title: "Write tests", status: "todo", priority: 1 });',
167
+ "",
168
+ " const ownerTasks = await owner.api.queryOk<TaskList>(TASK_LIST, {});",
169
+ ' expect(ownerTasks.rows.map((row) => row.title)).toEqual(["Write tests"]);',
170
+ " const otherTasks = await other.api.queryOk<TaskList>(TASK_LIST, {});",
171
+ " expect(otherTasks.rows).toEqual([]);",
172
+ " });",
173
+ "});",
174
+ "",
175
+ ].join("\n");
176
+ }
177
+
178
+ export function renderTestSetup(input: RenderTestSetupInput): ScaffoldTestSetup {
179
+ const { appName, frameworkVersion, port = SCAFFOLD_E2E_PORT } = input;
180
+ return {
181
+ files: {
182
+ ...renderBunfigFiles({ install: { linker: "hoisted" } }),
183
+ "playwright.config.ts": renderPlaywrightConfig(port),
184
+ "e2e/server.ts": renderE2eServer(appName),
185
+ "e2e/smoke.spec.ts": renderE2eSmokeSpec(),
186
+ "src/__tests__/run-config.test.ts": renderUnitTest(),
187
+ "src/__tests__/tasks.integration.test.ts": renderIntegrationTest(),
188
+ },
189
+ scripts: {
190
+ test: `bun --config=${BUNFIG_FILES.unit} test --timeout=${TEST_TIMEOUT_MS.unit} --dots`,
191
+ "test:integration": `bun kumiko-testing integration --parallel ${SCAFFOLD_INTEGRATION_PARALLEL}`,
192
+ "test:real": `${REAL_PROVIDERS_ENV}=1 bun --config=${BUNFIG_FILES.real} test --timeout=${TEST_TIMEOUT_MS.real}`,
193
+ "test:bunfig": "bun kumiko-testing bunfig --hoisted",
194
+ e2e: "bunx --bun playwright test",
195
+ "e2e:real": `${REAL_PROVIDERS_ENV}=1 bunx --bun playwright test`,
196
+ },
197
+ devDependencies: {
198
+ "@cosmicdrift/kumiko-testing": frameworkVersion,
199
+ "@playwright/test": PLAYWRIGHT_VERSION,
200
+ },
201
+ rulesMarkdown: RULES_MARKDOWN,
202
+ };
203
+ }