@cosmicdrift/kumiko-testing 0.367.0 → 0.369.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.
Files changed (44) hide show
  1. package/package.json +31 -6
  2. package/src/bunfig.ts +1 -1
  3. package/src/e2e/define-app-e2e-config.ts +2 -2
  4. package/src/e2e/index.ts +3 -3
  5. package/src/e2e/poll.ts +1 -1
  6. package/src/e2e/screenshots.ts +2 -2
  7. package/src/e2e/seed-route.ts +5 -4
  8. package/src/helpers/access-assertions.ts +15 -0
  9. package/src/helpers/assertions.ts +35 -0
  10. package/src/helpers/boot-validator-fixture.ts +127 -0
  11. package/src/helpers/closed-connection-error.ts +59 -0
  12. package/src/helpers/db-cleanup.ts +41 -0
  13. package/src/helpers/e2e-generator.ts +496 -0
  14. package/src/helpers/expect-error.ts +25 -0
  15. package/src/helpers/file-provider-contract.ts +123 -0
  16. package/src/helpers/http-cookies.ts +52 -0
  17. package/src/helpers/index.ts +67 -0
  18. package/src/helpers/late-bound.ts +41 -0
  19. package/src/helpers/manual-clock.ts +28 -0
  20. package/src/helpers/multipart-helper.ts +96 -0
  21. package/src/helpers/mutable-master-key-provider.ts +57 -0
  22. package/src/helpers/observability-recorder.ts +54 -0
  23. package/src/helpers/real-providers.ts +30 -0
  24. package/src/helpers/seed.ts +49 -0
  25. package/src/helpers/shared-entities.ts +65 -0
  26. package/src/helpers/utils.ts +1 -0
  27. package/src/helpers/wait-for.ts +50 -0
  28. package/src/helpers/without-ambient-temporal.ts +14 -0
  29. package/src/helpers/zip-fixture.ts +41 -0
  30. package/src/preload/real.ts +1 -1
  31. package/src/scaffold.ts +2 -2
  32. package/src/seed-tenant.ts +1 -1
  33. package/src/setup-app-test-stack.ts +1 -1
  34. package/src/stack/db.ts +94 -0
  35. package/src/stack/drain-event-consumers.ts +81 -0
  36. package/src/stack/drain-jobs.ts +188 -0
  37. package/src/stack/event-collector.ts +24 -0
  38. package/src/stack/index.ts +28 -0
  39. package/src/stack/push-entity-projection-tables.ts +102 -0
  40. package/src/stack/redis.ts +78 -0
  41. package/src/stack/request-helper.ts +298 -0
  42. package/src/stack/table-helpers.ts +47 -0
  43. package/src/stack/test-stack.ts +715 -0
  44. /package/src/{e2e/timeouts.ts → e2e-timeouts.ts} +0 -0
@@ -0,0 +1,67 @@
1
+ // Test assertions and domain test fixtures. Production code (dev-server, bin/)
2
+ // must import nothing from this subpath — the stack builders live in
3
+ // `@cosmicdrift/kumiko-testing/stack`.
4
+
5
+ // The four cache/injection resets stay in their own modules (they close over
6
+ // module-private state) and are only re-exported here — they are out of /crypto
7
+ // and /db as of #1631. A production call to resetPiiSubjectKmsForTests() silently
8
+ // switches the PII layer off, and subject-annotated fields are written in
9
+ // plaintext from then on: no error, no log.
10
+ export {
11
+ resetBlindIndexKeyForTests,
12
+ resetEntityFieldEncryptionCacheForTests,
13
+ resetEventPiiCatalogForTests,
14
+ resetPiiSubjectKmsForTests,
15
+ } from "@cosmicdrift/kumiko-framework/internal/testing";
16
+
17
+ export { rolesOf } from "./access-assertions.js";
18
+ export { expectError, expectSuccess } from "./assertions.js";
19
+ export { withBootValidatorFixture } from "./boot-validator-fixture.js";
20
+ export {
21
+ captureClosedConnectionError,
22
+ terminateBackendsByApplicationName,
23
+ testDatabaseUrl,
24
+ } from "./closed-connection-error.js";
25
+ export { type ClearableTable, clearTables, resetTestTables } from "./db-cleanup.js";
26
+ export {
27
+ type E2EGeneratorOptions,
28
+ type E2ETestSpec,
29
+ type EditFillOp,
30
+ generateE2ESpec,
31
+ generateZodFixture,
32
+ } from "./e2e-generator.js";
33
+ export { expectErrorIncludes } from "./expect-error.js";
34
+ export { describeFileProviderContract } from "./file-provider-contract.js";
35
+ export {
36
+ getSetCookieRaw,
37
+ getSetCookies,
38
+ getSetCookieValue,
39
+ type ParsedSetCookie,
40
+ } from "./http-cookies.js";
41
+ export { createLateBoundHolder, type LateBoundHolder } from "./late-bound.js";
42
+ export { createManualClock, type ManualClock } from "./manual-clock.js";
43
+ export { buildMultipartBody, patchFileInstanceofForBunTest } from "./multipart-helper.js";
44
+ export {
45
+ createMutableMasterKeyProvider,
46
+ createTestEnvelopeCipher,
47
+ createTestMasterKeyProvider,
48
+ type MutableMasterKeyProvider,
49
+ } from "./mutable-master-key-provider.js";
50
+ export {
51
+ createRecordingProvider,
52
+ type RecordingProvider,
53
+ } from "./observability-recorder.js";
54
+ export { isRealProviderRun, REAL_PROVIDERS_ENV, requireRealProviders } from "./real-providers.js";
55
+ export { deleteRows, seedRow, seedRows, updateRows } from "./seed.js";
56
+ export {
57
+ sharedItemEntity,
58
+ sharedItemTable,
59
+ sharedUserEntity,
60
+ sharedUserTable,
61
+ sharedWidgetEntity,
62
+ sharedWidgetTable,
63
+ } from "./shared-entities.js";
64
+ export { sleep } from "./utils.js";
65
+ export { waitFor } from "./wait-for.js";
66
+ export { withoutAmbientTemporal } from "./without-ambient-temporal.js";
67
+ export { zipWithEntries } from "./zip-fixture.js";
@@ -0,0 +1,41 @@
1
+ // Late-bound reference for values that only exist AFTER setupTestStack
2
+ // returns — typically the session-callbacks, which close over stack.db.
3
+ //
4
+ // Without this helper, integration tests repeat the same trampoline
5
+ // pattern (let real; async wrapper; null-check on each call). The holder
6
+ // inverts the dependency: the test passes the trampolines into setupTestStack
7
+ // first, then injects the concrete impl once db is available.
8
+ //
9
+ // Production wiring (runDevApp / runProdApp) doesn't reuse this — those
10
+ // wrappers inline an equivalent let-closure-throw pattern to avoid the
11
+ // runtime-isolation cross from dev → test packages. Same idea, different
12
+ // file.
13
+
14
+ export type LateBoundHolder<T> = {
15
+ /** Store the concrete value. Must be called before any trampoline fires. */
16
+ set(value: T): void;
17
+ /** Fetch the concrete value or throw if set() hasn't been called yet. */
18
+ get(): T;
19
+ /** True after set() has been called. */
20
+ isReady(): boolean;
21
+ };
22
+
23
+ export function createLateBoundHolder<T>(label = "value"): LateBoundHolder<T> {
24
+ let value: T | undefined;
25
+ let isSet = false;
26
+ return {
27
+ set(v) {
28
+ value = v;
29
+ isSet = true;
30
+ },
31
+ get() {
32
+ if (!isSet) {
33
+ throw new Error(`late-bound ${label} accessed before set() was called`);
34
+ }
35
+ return value as T;
36
+ },
37
+ isReady() {
38
+ return isSet;
39
+ },
40
+ };
41
+ }
@@ -0,0 +1,28 @@
1
+ import type { Temporal } from "@cosmicdrift/kumiko-types/temporal";
2
+ import type { Clock } from "@cosmicdrift/kumiko-types/tz-context";
3
+
4
+ // Instant arithmetic rejects calendar units (days and larger).
5
+ type ClockStep = {
6
+ readonly hours?: number;
7
+ readonly minutes?: number;
8
+ readonly seconds?: number;
9
+ readonly milliseconds?: number;
10
+ };
11
+
12
+ export type ManualClock = Clock & {
13
+ set(instant: Temporal.Instant): void;
14
+ advance(step: ClockStep): void;
15
+ };
16
+
17
+ export function createManualClock(start: Temporal.Instant): ManualClock {
18
+ let current = start;
19
+ return {
20
+ now: () => current,
21
+ set: (instant) => {
22
+ current = instant;
23
+ },
24
+ advance: (step) => {
25
+ current = current.add(step);
26
+ },
27
+ };
28
+ }
@@ -0,0 +1,96 @@
1
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
2
+
3
+ // Workarounds for Bun v1.3.x bun:test limitations with multipart/form-data.
4
+ //
5
+ // Two bugs combine to break file upload tests in bun:test:
6
+ //
7
+ // 1. Content-Type omission: both app.request({body: formData}) and
8
+ // fetch(url, {body: formData}) stringify FormData via .toString() instead
9
+ // of serializing it as multipart, so no Content-Type header is set.
10
+ // Fix: serialize FormData manually via buildMultipartBody().
11
+ //
12
+ // 2. Cross-realm instanceof: Hono's multipart parser creates Blob objects from
13
+ // a different JS realm than the test globals. In bun:test this means
14
+ // `parsedValue instanceof File` is always false even when the value has all
15
+ // File properties. Fix: patchFilInstanceofForBunTest().
16
+ //
17
+ // Both fixes are test-only — production code and real HTTP clients are unaffected.
18
+
19
+ /**
20
+ * Serializes a FormData instance to multipart/form-data bytes.
21
+ *
22
+ * Returns the encoded body and the Content-Type header value (including the
23
+ * generated boundary). Pass both directly to app.request or fetch.
24
+ */
25
+
26
+ export async function buildMultipartBody(
27
+ fd: FormData,
28
+ ): Promise<{ body: BodyInit; contentType: string }> {
29
+ const boundary = `KumikoBnd${Date.now()}${Math.random().toString(36).slice(2, 8)}`;
30
+ const enc = new TextEncoder();
31
+ const parts: Uint8Array[] = [];
32
+
33
+ for (const [name, value] of fd.entries()) {
34
+ const v = value as unknown as {
35
+ name?: string;
36
+ type?: string;
37
+ arrayBuffer?: () => Promise<ArrayBuffer>;
38
+ };
39
+ if (typeof v === "object" && v !== null && typeof v.arrayBuffer === "function") {
40
+ parts.push(
41
+ enc.encode(
42
+ `--${boundary}\r\nContent-Disposition: form-data; name="${name}"; filename="${v.name ?? "blob"}"\r\nContent-Type: ${v.type || "application/octet-stream"}\r\n\r\n`,
43
+ ),
44
+ );
45
+ parts.push(new Uint8Array(await v.arrayBuffer()));
46
+ parts.push(enc.encode("\r\n"));
47
+ } else {
48
+ parts.push(
49
+ enc.encode(
50
+ `--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${String(value)}\r\n`,
51
+ ),
52
+ );
53
+ }
54
+ }
55
+ parts.push(enc.encode(`--${boundary}--\r\n`));
56
+
57
+ const total = parts.reduce((s, p) => s + p.length, 0);
58
+ const buf = new Uint8Array(total);
59
+ let off = 0;
60
+ for (const p of parts) {
61
+ buf.set(p, off);
62
+ off += p.length;
63
+ }
64
+ return {
65
+ body: buf as unknown as BodyInit,
66
+ contentType: `multipart/form-data; boundary=${boundary}`,
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Patches File[Symbol.hasInstance] so that cross-realm Blob objects returned
72
+ * by Hono's parseBody() pass `instanceof File` checks in bun:test.
73
+ *
74
+ * In bun:test the multipart parser runs in a different JS realm than the test
75
+ * globals, so the Blob/File constructors differ. The patch replaces the
76
+ * prototype-chain check with a duck-type check: an object with string `.name`,
77
+ * number `.size`, and function `.arrayBuffer` is treated as a File.
78
+ *
79
+ * Safe to call multiple times (idempotent via the `_patched` marker).
80
+ */
81
+ export function patchFileInstanceofForBunTest(): void {
82
+ // skip: idempotent re-patch — already installed, nothing to do
83
+ if ((File as unknown as { _kumikoPatched?: boolean })._kumikoPatched) return;
84
+ Object.defineProperty(File, Symbol.hasInstance, {
85
+ value(instance: unknown): boolean {
86
+ if (!isPlainObject(instance)) return false;
87
+ return (
88
+ typeof instance["name"] === "string" &&
89
+ typeof instance["size"] === "number" &&
90
+ typeof instance["arrayBuffer"] === "function"
91
+ );
92
+ },
93
+ configurable: true,
94
+ });
95
+ (File as unknown as { _kumikoPatched?: boolean })._kumikoPatched = true;
96
+ }
@@ -0,0 +1,57 @@
1
+ // Test utility: a MasterKeyProvider wrapper whose backing provider can be
2
+ // swapped mid-test. Use-case: simulating KEK-rotation ("ops flipped
3
+ // CURRENT=2") without rebuilding the whole stack + SecretsContext.
4
+ //
5
+ // Prod analogue: ENV swap + process restart. This helper is purely for
6
+ // tests that want to exercise pre- and post-rotation behaviour in a
7
+ // single suite.
8
+
9
+ import { randomBytes } from "node:crypto";
10
+ import {
11
+ createEnvelopeCipher,
12
+ createEnvMasterKeyProvider,
13
+ type EnvelopeCipher,
14
+ type EnvelopeCipherOptions,
15
+ type MasterKeyProvider,
16
+ } from "@cosmicdrift/kumiko-framework/secrets";
17
+
18
+ export type MutableMasterKeyProvider = MasterKeyProvider & {
19
+ // Replace the backing provider. All future wrapDek/unwrapDek/currentVersion
20
+ // calls delegate to `next`. Existing in-flight calls already hold a
21
+ // reference to the old provider's closure and finish under its contract.
22
+ replace(next: MasterKeyProvider): void;
23
+ };
24
+
25
+ export function createMutableMasterKeyProvider(
26
+ initial: MasterKeyProvider,
27
+ ): MutableMasterKeyProvider {
28
+ let current = initial;
29
+ return {
30
+ wrapDek: (dek, scope) => current.wrapDek(dek, scope),
31
+ unwrapDek: (e, v, scope) => current.unwrapDek(e, v, scope),
32
+ currentVersion: () => current.currentVersion(),
33
+ isAvailable: () => current.isAvailable(),
34
+ replace: (next) => {
35
+ current = next;
36
+ },
37
+ };
38
+ }
39
+
40
+ // Single-version env provider for tests — the shape every integration test
41
+ // needs to exercise encrypted config keys / entity fields without caring
42
+ // about keyring mechanics. Pass a fixed key to share it across stacks.
43
+ export function createTestMasterKeyProvider(keyBase64?: string): MasterKeyProvider {
44
+ return createEnvMasterKeyProvider({
45
+ env: {
46
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
47
+ KUMIKO_SECRETS_MASTER_KEY_V1: keyBase64 ?? randomBytes(32).toString("base64"),
48
+ },
49
+ });
50
+ }
51
+
52
+ export function createTestEnvelopeCipher(
53
+ keyBase64?: string,
54
+ opts?: EnvelopeCipherOptions,
55
+ ): EnvelopeCipher {
56
+ return createEnvelopeCipher(createTestMasterKeyProvider(keyBase64), opts ?? {});
57
+ }
@@ -0,0 +1,54 @@
1
+ import {
2
+ DEFAULT_SENSITIVE_CONFIG,
3
+ type MetricEvent,
4
+ mergeSensitiveConfig,
5
+ type ObservabilityOptions,
6
+ type ObservabilityProvider,
7
+ type RecordedSpan,
8
+ RecordingMeter,
9
+ RecordingTracer,
10
+ } from "@cosmicdrift/kumiko-framework/observability";
11
+
12
+ // Provider that keeps every emitted span + metric event in arrays for
13
+ // assertion in integration tests. Use instead of ConsoleProvider when the
14
+ // test needs to inspect the trace tree or verify metric emissions.
15
+ export type RecordingProvider = ObservabilityProvider & {
16
+ readonly spans: readonly RecordedSpan[];
17
+ readonly metricEvents: readonly MetricEvent[];
18
+ // Returns spans filtered by name — handy for `.find(s => s.name === "http.request")`.
19
+ spansByName(name: string): readonly RecordedSpan[];
20
+ // All spans sharing a trace id — use this to reconstruct a single request's tree.
21
+ spansByTraceId(traceId: string): readonly RecordedSpan[];
22
+ reset(): void;
23
+ };
24
+
25
+ export function createRecordingProvider(options: ObservabilityOptions = {}): RecordingProvider {
26
+ const sensitiveConfig = mergeSensitiveConfig(options.sensitiveFilter ?? DEFAULT_SENSITIVE_CONFIG);
27
+ const spans: RecordedSpan[] = [];
28
+ const metricEvents: MetricEvent[] = [];
29
+
30
+ const tracer = new RecordingTracer({
31
+ sensitiveConfig,
32
+ onSpanEnd: (s) => spans.push(s),
33
+ });
34
+ const meter = new RecordingMeter((e) => metricEvents.push(e));
35
+
36
+ return {
37
+ name: "recording",
38
+ tracer,
39
+ meter,
40
+ spans,
41
+ metricEvents,
42
+ spansByName(name: string) {
43
+ return spans.filter((s) => s.name === name);
44
+ },
45
+ spansByTraceId(traceId: string) {
46
+ return spans.filter((s) => s.traceId === traceId);
47
+ },
48
+ reset() {
49
+ spans.length = 0;
50
+ metricEvents.length = 0;
51
+ },
52
+ async shutdown() {},
53
+ };
54
+ }
@@ -0,0 +1,30 @@
1
+ // Playwright configs load this module under plain Node via
2
+ // @cosmicdrift/kumiko-testing/e2e, so it must stay import-free.
3
+ export const REAL_PROVIDERS_ENV = "KUMIKO_REAL_PROVIDERS";
4
+ const CI_FLAG = "CI";
5
+
6
+ function isCi(): boolean {
7
+ const ci = process.env[CI_FLAG];
8
+ return ci !== undefined && ci !== "" && ci !== "0" && ci.toLowerCase() !== "false";
9
+ }
10
+
11
+ // Apps and the e2e/bunfig templates gate real-provider runs on this same
12
+ // check instead of re-reading KUMIKO_REAL_PROVIDERS themselves.
13
+ export function isRealProviderRun(
14
+ env: Readonly<Record<string, string | undefined>> = process.env,
15
+ ): boolean {
16
+ return env[REAL_PROVIDERS_ENV] === "1";
17
+ }
18
+
19
+ export function requireRealProviders(): void {
20
+ if (isCi()) {
21
+ throw new Error(
22
+ `Real-provider tests never run in CI (CI is set). Run them locally with ${REAL_PROVIDERS_ENV}=1 via the test:real / e2e:real script.`,
23
+ );
24
+ }
25
+ if (!isRealProviderRun()) {
26
+ throw new Error(
27
+ `Real-provider test needs ${REAL_PROVIDERS_ENV}=1 (an API key alone never enables it). Run it via the test:real / e2e:real script.`,
28
+ );
29
+ }
30
+ }
@@ -0,0 +1,49 @@
1
+ // Test-only ES-bypass: seed / mutate a managed projection directly, skipping
2
+ // the event log. Production code CANNOT do this — the write helpers reject a
3
+ // branded EntityTable at compile time (#742). Tests seed read-model state to
4
+ // arrange a scenario without driving the full event path; a projection rebuild
5
+ // is not part of the test lifecycle, so eventless writes are safe here. The
6
+ // `as EntityTableMeta` strips the executor-only brand at this single sanctioned
7
+ // seam — the signatures mirror the query helpers so a call-site migration is a
8
+ // plain identifier rename.
9
+
10
+ import type { EntityTable, EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
11
+ import {
12
+ deleteMany,
13
+ insertMany,
14
+ insertOne,
15
+ updateMany,
16
+ type WhereObject,
17
+ } from "@cosmicdrift/kumiko-framework/db";
18
+ import type { AnyDb } from "@cosmicdrift/kumiko-framework/internal/stack-support";
19
+
20
+ type SeedTable = EntityTable | EntityTableMeta;
21
+
22
+ export function seedRow<TRow = unknown>(
23
+ db: AnyDb,
24
+ table: SeedTable,
25
+ values: Record<string, unknown>,
26
+ ): Promise<TRow | undefined> {
27
+ return insertOne<TRow>(db, table as EntityTableMeta, values);
28
+ }
29
+
30
+ export function seedRows<TRow = unknown>(
31
+ db: AnyDb,
32
+ table: SeedTable,
33
+ rows: ReadonlyArray<Record<string, unknown>>,
34
+ ): Promise<readonly TRow[]> {
35
+ return insertMany<TRow>(db, table as EntityTableMeta, rows);
36
+ }
37
+
38
+ export function updateRows<TRow = unknown>(
39
+ db: AnyDb,
40
+ table: SeedTable,
41
+ set: Record<string, unknown>,
42
+ where: WhereObject,
43
+ ): Promise<readonly TRow[]> {
44
+ return updateMany<TRow>(db, table as EntityTableMeta, set, where);
45
+ }
46
+
47
+ export function deleteRows(db: AnyDb, table: SeedTable, where: WhereObject): Promise<void> {
48
+ return deleteMany(db, table as EntityTableMeta, where);
49
+ }
@@ -0,0 +1,65 @@
1
+ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
2
+ import {
3
+ createBooleanField,
4
+ createEntity,
5
+ createNumberField,
6
+ createTextField,
7
+ } from "@cosmicdrift/kumiko-framework/engine";
8
+
9
+ // --- Shared Entity Fixtures -------------------------------------------------
10
+ //
11
+ // Replaces inline `createEntity(...) + buildEntityTable(...)` boilerplate
12
+ // that appeared in 20+ integration tests. Pick the shape closest to what
13
+ // the test needs; if a feature needs extras (hooks, state-machine, fields),
14
+ // keep a local inline entity rather than bloating these shared ones.
15
+
16
+ // "Just a name" — minimal entity with `name: text`, softDelete on.
17
+ // Used by every pipeline test that only needs SOMETHING to write events
18
+ // against (event-dispatcher*, event-retention, event-dedup, …).
19
+ export const sharedWidgetEntity = createEntity({
20
+ fields: {
21
+ name: createTextField({ required: true, personal: false, reason: "test_fixture" }),
22
+ },
23
+ softDelete: true,
24
+ });
25
+ export const sharedWidgetTable = buildEntityTable("widget", sharedWidgetEntity);
26
+
27
+ // User with searchable name/email fields. Used by full-stack, cascade,
28
+ // and any test that exercises search-indexing or field-access on a
29
+ // realistic-looking user record.
30
+ export const sharedUserEntity = createEntity({
31
+ fields: {
32
+ email: createTextField({
33
+ required: true,
34
+ format: "email",
35
+ searchable: true,
36
+ personal: false,
37
+ reason: "test_fixture",
38
+ }),
39
+ firstName: createTextField({
40
+ searchable: true,
41
+ personal: false,
42
+ reason: "test_fixture",
43
+ }),
44
+ lastName: createTextField({
45
+ searchable: true,
46
+ personal: false,
47
+ reason: "test_fixture",
48
+ }),
49
+ isEnabled: createBooleanField({ default: true }),
50
+ },
51
+ softDelete: true,
52
+ searchWeight: 10,
53
+ });
54
+ export const sharedUserTable = buildEntityTable("user", sharedUserEntity);
55
+
56
+ // Item with name + optional price. Used by error-contract, batch,
57
+ // projection-rebuild — tests that need "a thing you can CRUD".
58
+ export const sharedItemEntity = createEntity({
59
+ fields: {
60
+ name: createTextField({ required: true, personal: false, reason: "test_fixture" }),
61
+ price: createNumberField(),
62
+ },
63
+ softDelete: true,
64
+ });
65
+ export const sharedItemTable = buildEntityTable("item", sharedItemEntity);
@@ -0,0 +1 @@
1
+ export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Polls a condition with escalating timeouts.
3
+ *
4
+ * Default schedule: 250ms → 1s → 3s between attempts. Tries first (already-true
5
+ * returns immediately), then sleeps `delays[i]` after each failure before the
6
+ * next try — so N delays yield N+1 attempts and the full backoff budget.
7
+ * Throws the last assertion error if all attempts fail.
8
+ *
9
+ * Usage (assertion form):
10
+ * await waitFor(() => {
11
+ * expect(events).toHaveLength(1);
12
+ * });
13
+ *
14
+ * Usage (predicate form — a literal `false` return means "not yet", anything
15
+ * else, including `undefined`, counts as success):
16
+ * await waitFor(() => events.length >= 4);
17
+ */
18
+ export async function waitFor(
19
+ // biome-ignore lint/suspicious/noConfusingVoidType: void must stay in the union so the existing expect(...) block form (implicit void return) still type-checks
20
+ fn: () => void | boolean | Promise<void | boolean>,
21
+ options?: { delays?: number[] },
22
+ ): Promise<void> {
23
+ const delays = options?.delays ?? [250, 1000, 3000];
24
+ if (delays.length === 0) {
25
+ throw new Error("waitFor: empty delay schedule");
26
+ }
27
+ let lastError: unknown;
28
+
29
+ for (let i = 0; ; i++) {
30
+ try {
31
+ const result = await fn();
32
+ if (result !== false) {
33
+ // skip: condition already true — no further polling
34
+ return;
35
+ }
36
+ lastError = undefined;
37
+ } catch (err) {
38
+ lastError = err;
39
+ }
40
+ if (i >= delays.length) {
41
+ break;
42
+ }
43
+ await new Promise((r) => setTimeout(r, delays[i]));
44
+ }
45
+
46
+ if (lastError !== undefined) {
47
+ throw lastError;
48
+ }
49
+ throw new Error("waitFor: condition never became true within the delay schedule");
50
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Run `fn` with `globalThis.Temporal` deleted, then restore.
3
+ * Used by ambient-independence regression tests (kumiko-framework#1525/#1550).
4
+ */
5
+ export async function withoutAmbientTemporal<T>(fn: () => T | Promise<T>): Promise<T> {
6
+ const savedGlobal: unknown = Reflect.get(globalThis, "Temporal");
7
+ Reflect.deleteProperty(globalThis, "Temporal");
8
+ try {
9
+ return await fn();
10
+ } finally {
11
+ if (savedGlobal === undefined) Reflect.deleteProperty(globalThis, "Temporal");
12
+ else Reflect.set(globalThis, "Temporal", savedGlobal);
13
+ }
14
+ }
@@ -0,0 +1,41 @@
1
+ const LOCAL_HEADER_FIXED_LENGTH = 30;
2
+ const CENTRAL_HEADER_FIXED_LENGTH = 46;
3
+ const EOCD_LENGTH = 22;
4
+
5
+ // Minimal stored ZIP (no compression, zero CRC): enough structure for readers that
6
+ // walk the central directory. Not for extraction.
7
+ export function zipWithEntries(...entryNames: readonly string[]): Uint8Array {
8
+ const encoder = new TextEncoder();
9
+ const names = entryNames.map((name) => encoder.encode(name));
10
+ const localSize = names.reduce((sum, name) => sum + LOCAL_HEADER_FIXED_LENGTH + name.length, 0);
11
+ const centralSize = names.reduce(
12
+ (sum, name) => sum + CENTRAL_HEADER_FIXED_LENGTH + name.length,
13
+ 0,
14
+ );
15
+ const bytes = new Uint8Array(localSize + centralSize + EOCD_LENGTH);
16
+ const view = new DataView(bytes.buffer);
17
+
18
+ let localOffset = 0;
19
+ let centralOffset = localSize;
20
+ for (const name of names) {
21
+ view.setUint32(localOffset, 0x04034b50, true);
22
+ view.setUint16(localOffset + 26, name.length, true);
23
+ bytes.set(name, localOffset + LOCAL_HEADER_FIXED_LENGTH);
24
+
25
+ view.setUint32(centralOffset, 0x02014b50, true);
26
+ view.setUint16(centralOffset + 28, name.length, true);
27
+ view.setUint32(centralOffset + 42, localOffset, true);
28
+ bytes.set(name, centralOffset + CENTRAL_HEADER_FIXED_LENGTH);
29
+
30
+ localOffset += LOCAL_HEADER_FIXED_LENGTH + name.length;
31
+ centralOffset += CENTRAL_HEADER_FIXED_LENGTH + name.length;
32
+ }
33
+
34
+ const eocdOffset = localSize + centralSize;
35
+ view.setUint32(eocdOffset, 0x06054b50, true);
36
+ view.setUint16(eocdOffset + 8, names.length, true);
37
+ view.setUint16(eocdOffset + 10, names.length, true);
38
+ view.setUint32(eocdOffset + 12, centralSize, true);
39
+ view.setUint32(eocdOffset + 16, localSize, true);
40
+ return bytes;
41
+ }
@@ -1,4 +1,4 @@
1
- import { requireRealProviders } from "@cosmicdrift/kumiko-framework/testing";
1
+ import { requireRealProviders } from "../helpers/real-providers.js";
2
2
  import "./schema-env-defaults";
3
3
  import "./service-env-defaults";
4
4
 
package/src/scaffold.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // @runtime tooling
2
- import { REAL_PROVIDERS_ENV } from "@cosmicdrift/kumiko-framework/testing/real-providers";
3
2
  import { BUNFIG_FILES, renderBunfigFiles, TEST_TIMEOUT_MS } from "./bunfig";
3
+ import { REAL_PROVIDERS_ENV } from "./helpers/real-providers.js";
4
4
 
5
5
  export type ScaffoldTestSetup = {
6
6
  readonly files: Readonly<Record<string, string>>;
@@ -140,8 +140,8 @@ function renderUnitTest(): string {
140
140
  function renderIntegrationTest(): string {
141
141
  return [
142
142
  'import { afterAll, beforeAll, describe, expect, test } from "bun:test";',
143
- 'import type { TestStack } from "@cosmicdrift/kumiko-framework/stack";',
144
143
  'import { seedTenant, setupAppTestStack } from "@cosmicdrift/kumiko-testing";',
144
+ 'import type { TestStack } from "@cosmicdrift/kumiko-testing/stack";',
145
145
  'import { tasksFeature } from "../features/tasks";',
146
146
  "",
147
147
  `const TASK_CREATE = ${JSON.stringify(TASK_CREATE)};`,
@@ -10,7 +10,6 @@ import {
10
10
  tenantChannel,
11
11
  type WriteResult,
12
12
  } from "@cosmicdrift/kumiko-framework/engine";
13
- import type { TestStack } from "@cosmicdrift/kumiko-framework/stack";
14
13
  import { isPlainObject } from "@cosmicdrift/kumiko-framework/utils";
15
14
  import {
16
15
  type BoundApi,
@@ -21,6 +20,7 @@ import {
21
20
  splitSeedRoles,
22
21
  withSession,
23
22
  } from "./seed-types";
23
+ import type { TestStack } from "./stack/index.js";
24
24
 
25
25
  export type {
26
26
  BoundApi,
@@ -3,7 +3,7 @@ import {
3
3
  setupTestStackFromFeatures,
4
4
  } from "@cosmicdrift/kumiko-dev-server/setup-test-stack-from-features";
5
5
  import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
6
- import { pushEntityProjectionTables, type TestStack } from "@cosmicdrift/kumiko-framework/stack";
6
+ import { pushEntityProjectionTables, type TestStack } from "./stack/index.js";
7
7
 
8
8
  export type SetupAppTestStackOptions = SetupTestStackFromFeaturesOptions & {
9
9
  readonly registryTables?: boolean;