@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
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { hashPassword } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
|
|
3
|
+
import { TenantHandlers } from "@cosmicdrift/kumiko-bundled-features/tenant";
|
|
4
|
+
import { UserHandlers } from "@cosmicdrift/kumiko-bundled-features/user";
|
|
5
|
+
import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
|
|
6
|
+
import {
|
|
7
|
+
createSystemUser,
|
|
8
|
+
type SessionUser,
|
|
9
|
+
type TenantId,
|
|
10
|
+
type WriteResult,
|
|
11
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
12
|
+
import type { TestStack } from "@cosmicdrift/kumiko-framework/stack";
|
|
13
|
+
import { isPlainObject } from "@cosmicdrift/kumiko-framework/utils";
|
|
14
|
+
import {
|
|
15
|
+
type BoundApi,
|
|
16
|
+
type SeededCredentials,
|
|
17
|
+
type SeededTenant,
|
|
18
|
+
type SeedTenantOptions,
|
|
19
|
+
withSession,
|
|
20
|
+
} from "./seed-types";
|
|
21
|
+
|
|
22
|
+
export type {
|
|
23
|
+
BoundApi,
|
|
24
|
+
SeededCredentials,
|
|
25
|
+
SeededTenant,
|
|
26
|
+
SeededUser,
|
|
27
|
+
SeedPart,
|
|
28
|
+
SeedTenantOptions,
|
|
29
|
+
} from "./seed-types";
|
|
30
|
+
|
|
31
|
+
export type SavedRow = { readonly id: string; readonly data: { readonly version: number } };
|
|
32
|
+
|
|
33
|
+
export type SeedWriter = (
|
|
34
|
+
handlerQn: string,
|
|
35
|
+
payload: unknown,
|
|
36
|
+
tenantId: TenantId,
|
|
37
|
+
) => Promise<SavedRow>;
|
|
38
|
+
|
|
39
|
+
export type PersistedTenant = {
|
|
40
|
+
readonly id: TenantId;
|
|
41
|
+
readonly key: string;
|
|
42
|
+
readonly name: string;
|
|
43
|
+
readonly admin: SeededCredentials;
|
|
44
|
+
readonly members: readonly SeededCredentials[];
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function bindApi(stack: TestStack, user: SessionUser): BoundApi {
|
|
48
|
+
const { http } = stack;
|
|
49
|
+
return {
|
|
50
|
+
writeOk: (type, payload, requestId) => http.writeOk(type, payload, user, requestId),
|
|
51
|
+
writeErr: (type, payload) => http.writeErr(type, payload, user),
|
|
52
|
+
queryOk: (type, payload) => http.queryOk(type, payload, user),
|
|
53
|
+
queryErr: (type, payload) => http.queryErr(type, payload, user),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function lightCredentials(): SeededCredentials {
|
|
58
|
+
const id = randomUUID();
|
|
59
|
+
return { id, email: `user-${id}@example.test`, password: `pw-${randomUUID()}` };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function newTenantIdentity(name: string | undefined): {
|
|
63
|
+
id: TenantId;
|
|
64
|
+
key: string;
|
|
65
|
+
name: string;
|
|
66
|
+
} {
|
|
67
|
+
const id: TenantId = randomUUID();
|
|
68
|
+
return { id, key: `t-${id}`, name: name ?? `Test Tenant ${id.slice(0, 8)}` };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isSavedRow(value: unknown): value is SavedRow {
|
|
72
|
+
if (!isPlainObject(value) || typeof value["id"] !== "string") return false;
|
|
73
|
+
const data = value["data"];
|
|
74
|
+
return isPlainObject(data) && typeof data["version"] === "number";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function unwrapSavedRow(handlerQn: string, result: WriteResult): SavedRow {
|
|
78
|
+
if (!result.isSuccess) {
|
|
79
|
+
throw new Error(`seedTenant: ${handlerQn} failed: ${JSON.stringify(result.error)}`);
|
|
80
|
+
}
|
|
81
|
+
if (!isSavedRow(result.data)) {
|
|
82
|
+
throw new Error(`seedTenant: ${handlerQn} returned no saved row`);
|
|
83
|
+
}
|
|
84
|
+
return result.data;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function stackSeedWriter(stack: TestStack): SeedWriter {
|
|
88
|
+
return async (handlerQn, payload, tenantId) =>
|
|
89
|
+
unwrapSavedRow(
|
|
90
|
+
handlerQn,
|
|
91
|
+
await stack.dispatcher.write(
|
|
92
|
+
handlerQn,
|
|
93
|
+
payload,
|
|
94
|
+
createSystemUser(tenantId, [ROLES.SystemAdmin]),
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function persistUserRows(
|
|
100
|
+
write: SeedWriter,
|
|
101
|
+
tenantId: TenantId,
|
|
102
|
+
roles: readonly string[],
|
|
103
|
+
): Promise<SeededCredentials> {
|
|
104
|
+
const light = lightCredentials();
|
|
105
|
+
const created = await write(
|
|
106
|
+
UserHandlers.create,
|
|
107
|
+
{
|
|
108
|
+
email: light.email,
|
|
109
|
+
passwordHash: await hashPassword(light.password),
|
|
110
|
+
displayName: `Seed ${light.id.slice(0, 8)}`,
|
|
111
|
+
},
|
|
112
|
+
tenantId,
|
|
113
|
+
);
|
|
114
|
+
await write(
|
|
115
|
+
UserHandlers.update,
|
|
116
|
+
{ id: created.id, version: created.data.version, changes: { emailVerified: true } },
|
|
117
|
+
tenantId,
|
|
118
|
+
);
|
|
119
|
+
await write(TenantHandlers.addMember, { userId: created.id, tenantId, roles }, tenantId);
|
|
120
|
+
return { id: created.id, email: light.email, password: light.password };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function persistTenantRows(
|
|
124
|
+
write: SeedWriter,
|
|
125
|
+
opts: { readonly name?: string; readonly users?: number } = {},
|
|
126
|
+
): Promise<PersistedTenant> {
|
|
127
|
+
const { id, key, name } = newTenantIdentity(opts.name);
|
|
128
|
+
await write(TenantHandlers.create, { id, key, name }, id);
|
|
129
|
+
const admin = await persistUserRows(write, id, [ROLES.TenantAdmin]);
|
|
130
|
+
const members: SeededCredentials[] = [];
|
|
131
|
+
for (let i = 0; i < (opts.users ?? 0); i++) {
|
|
132
|
+
members.push(await persistUserRows(write, id, [ROLES.Member]));
|
|
133
|
+
}
|
|
134
|
+
return { id, key, name, admin, members };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function lightTenantRows(opts: SeedTenantOptions): PersistedTenant {
|
|
138
|
+
const identity = newTenantIdentity(opts.name);
|
|
139
|
+
const members = Array.from({ length: opts.users ?? 0 }, lightCredentials);
|
|
140
|
+
return { ...identity, admin: lightCredentials(), members };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function seedTenant(
|
|
144
|
+
stack: TestStack,
|
|
145
|
+
opts: SeedTenantOptions = {},
|
|
146
|
+
): Promise<SeededTenant> {
|
|
147
|
+
const write = stackSeedWriter(stack);
|
|
148
|
+
const persist = opts.persist === true;
|
|
149
|
+
const rows = persist ? await persistTenantRows(write, opts) : lightTenantRows(opts);
|
|
150
|
+
const { id } = rows;
|
|
151
|
+
|
|
152
|
+
const admin = withSession(rows.admin, id, [ROLES.TenantAdmin]);
|
|
153
|
+
const tenant: SeededTenant = {
|
|
154
|
+
id,
|
|
155
|
+
key: rows.key,
|
|
156
|
+
name: rows.name,
|
|
157
|
+
admin,
|
|
158
|
+
members: rows.members.map((member) => withSession(member, id, [ROLES.Member])),
|
|
159
|
+
addUser: async (roles = [ROLES.Member]) =>
|
|
160
|
+
withSession(
|
|
161
|
+
persist ? await persistUserRows(write, id, roles) : lightCredentials(),
|
|
162
|
+
id,
|
|
163
|
+
roles,
|
|
164
|
+
),
|
|
165
|
+
api: bindApi(stack, admin.session),
|
|
166
|
+
apiAs: (user) => bindApi(stack, user.session),
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
for (const part of opts.with ?? []) await part({ tenant });
|
|
170
|
+
return tenant;
|
|
171
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import type { WriteErrorInfo } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
|
+
|
|
4
|
+
export type BoundApi = {
|
|
5
|
+
writeOk: <T = Record<string, unknown>>(
|
|
6
|
+
type: string,
|
|
7
|
+
payload: unknown,
|
|
8
|
+
requestId?: string,
|
|
9
|
+
) => Promise<T>;
|
|
10
|
+
writeErr: (type: string, payload: unknown) => Promise<WriteErrorInfo>;
|
|
11
|
+
queryOk: <T = unknown>(type: string, payload: unknown) => Promise<T>;
|
|
12
|
+
queryErr: (type: string, payload: unknown) => Promise<WriteErrorInfo>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type SeededCredentials = {
|
|
16
|
+
readonly id: string;
|
|
17
|
+
readonly email: string;
|
|
18
|
+
readonly password: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type SeededUser = SeededCredentials & { readonly session: SessionUser };
|
|
22
|
+
|
|
23
|
+
export type SeedPart = (ctx: { readonly tenant: SeededTenant }) => Promise<void>;
|
|
24
|
+
|
|
25
|
+
export type SeedTenantOptions = {
|
|
26
|
+
readonly name?: string;
|
|
27
|
+
readonly users?: number;
|
|
28
|
+
readonly with?: readonly SeedPart[];
|
|
29
|
+
readonly persist?: boolean;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type SeededTenant = {
|
|
33
|
+
readonly id: TenantId;
|
|
34
|
+
readonly key: string;
|
|
35
|
+
readonly name: string;
|
|
36
|
+
readonly admin: SeededUser;
|
|
37
|
+
readonly members: readonly SeededUser[];
|
|
38
|
+
readonly addUser: (roles?: readonly string[]) => Promise<SeededUser>;
|
|
39
|
+
readonly api: BoundApi;
|
|
40
|
+
readonly apiAs: (user: SeededUser) => BoundApi;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function withSession(
|
|
44
|
+
credentials: SeededCredentials,
|
|
45
|
+
tenantId: TenantId,
|
|
46
|
+
roles: readonly string[],
|
|
47
|
+
): SeededUser {
|
|
48
|
+
return { ...credentials, session: { id: credentials.id, tenantId, roles } };
|
|
49
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type SetupTestStackFromFeaturesOptions,
|
|
3
|
+
setupTestStackFromFeatures,
|
|
4
|
+
} from "@cosmicdrift/kumiko-dev-server/setup-test-stack-from-features";
|
|
5
|
+
import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import { pushEntityProjectionTables, type TestStack } from "@cosmicdrift/kumiko-framework/stack";
|
|
7
|
+
|
|
8
|
+
export type SetupAppTestStackOptions = SetupTestStackFromFeaturesOptions & {
|
|
9
|
+
readonly registryTables?: boolean;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export async function setupAppTestStack(
|
|
13
|
+
features: readonly FeatureDefinition[],
|
|
14
|
+
options: SetupAppTestStackOptions = {},
|
|
15
|
+
): Promise<TestStack> {
|
|
16
|
+
const { registryTables = true, includeBundled = true, ...stackOptions } = options;
|
|
17
|
+
const stack = await setupTestStackFromFeatures(features, { ...stackOptions, includeBundled });
|
|
18
|
+
if (!registryTables) return stack;
|
|
19
|
+
try {
|
|
20
|
+
await pushEntityProjectionTables(stack, stack.registry);
|
|
21
|
+
} catch (error) {
|
|
22
|
+
await stack.cleanup();
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
return stack;
|
|
26
|
+
}
|