@cosmicdrift/kumiko-testing 0.336.1 → 0.337.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-testing",
3
- "version": "0.336.1",
3
+ "version": "0.337.1",
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>",
@@ -75,9 +75,9 @@
75
75
  "kumiko-testing": "./bin/kumiko-testing.ts"
76
76
  },
77
77
  "dependencies": {
78
- "@cosmicdrift/kumiko-bundled-features": "0.336.1",
79
- "@cosmicdrift/kumiko-dev-server": "0.336.1",
80
- "@cosmicdrift/kumiko-framework": "0.336.1",
78
+ "@cosmicdrift/kumiko-bundled-features": "0.337.1",
79
+ "@cosmicdrift/kumiko-dev-server": "0.337.1",
80
+ "@cosmicdrift/kumiko-framework": "0.337.1",
81
81
  "zod": "^4.4.3"
82
82
  },
83
83
  "peerDependencies": {
package/src/bunfig.ts CHANGED
@@ -102,7 +102,9 @@ function splitBunfigBlocks(content: string): readonly BunfigBlock[] {
102
102
  const blocks: { header: string; lines: string[] }[] = [];
103
103
  let current: { header: string; lines: string[] } = { header: "", lines: [] };
104
104
  for (const line of content.split("\n")) {
105
- const match = /^\[([^\]]+)\]\s*$/.exec(line);
105
+ // An array-of-tables header keeps its inner brackets in `header`, so it
106
+ // never equals a generated header and is carried over as a foreign block.
107
+ const match = /^\[(\[[^\]]+\]|[^[\]]+)\]\s*$/.exec(line);
106
108
  if (match?.[1] !== undefined) {
107
109
  blocks.push(current);
108
110
  current = { header: match[1], lines: [] };
@@ -114,11 +116,19 @@ function splitBunfigBlocks(content: string): readonly BunfigBlock[] {
114
116
  return blocks;
115
117
  }
116
118
 
119
+ const TOML_KEY_SEGMENT = String.raw`"[^"]+"|[A-Za-z_][\w-]*`;
120
+ const TOML_KEY_LINE = new RegExp(
121
+ String.raw`^(?:"([^"]+)"|([A-Za-z_][\w-]*))(?:\s*\.\s*(?:${TOML_KEY_SEGMENT}))*\s*=`,
122
+ );
123
+
124
+ // Dotted and quoted keys resolve to their first segment, which is the key
125
+ // the template either owns or doesn't.
117
126
  function keysIn(lines: readonly string[]): readonly string[] {
118
127
  const keys: string[] = [];
119
128
  for (const line of lines) {
120
- const match = /^([A-Za-z_][\w-]*)\s*=/.exec(line.trim());
121
- if (match?.[1] !== undefined) keys.push(match[1]);
129
+ const match = TOML_KEY_LINE.exec(line.trim());
130
+ const key = match?.[1] ?? match?.[2];
131
+ if (key !== undefined) keys.push(key);
122
132
  }
123
133
  return keys;
124
134
  }
@@ -137,9 +147,12 @@ type MergedArrayKey = (typeof MERGED_ARRAY_KEYS)[number];
137
147
 
138
148
  const CURRENT_PACKAGE_PRELOADS = new Set<string>([...Object.values(PRELOADS).flat(), DOM_PRELOAD]);
139
149
 
140
- // Old app-local path of DOM_PRELOAD; excluded so an app that hasn't
141
- // regenerated doesn't run DOM setup twice.
142
- const SUPERSEDED_PRELOADS = new Set<string>(["./test-setup/dom.preload.ts"]);
150
+ // Old app-local path -> its replacement; dropped only when the replacement is
151
+ // generated for this variant, so an app that hasn't regenerated doesn't run
152
+ // DOM setup twice but a variant without it keeps its own setup.
153
+ const SUPERSEDED_PRELOADS: ReadonlyMap<string, string> = new Map([
154
+ ["./test-setup/dom.preload.ts", DOM_PRELOAD],
155
+ ]);
143
156
 
144
157
  // Ignore patterns the generator owns across all variants. One it omits for
145
158
  // this variant (`**/*.test.tsx` with --dom, `**/*.integration.test.ts` in the
@@ -153,9 +166,14 @@ const TEMPLATE_OWNED_IGNORE_PATTERNS = new Set<string>([
153
166
  ]),
154
167
  ]);
155
168
 
156
- function isKeepableExtra(key: MergedArrayKey, entry: string): boolean {
169
+ function isKeepableExtra(
170
+ key: MergedArrayKey,
171
+ entry: string,
172
+ generatedValues: readonly string[],
173
+ ): boolean {
157
174
  if (key !== "preload") return !TEMPLATE_OWNED_IGNORE_PATTERNS.has(entry);
158
- if (SUPERSEDED_PRELOADS.has(entry)) return false;
175
+ const replacement = SUPERSEDED_PRELOADS.get(entry);
176
+ if (replacement !== undefined) return !generatedValues.includes(replacement);
159
177
  if (entry.startsWith(PRELOAD_PREFIX)) return CURRENT_PACKAGE_PRELOADS.has(entry);
160
178
  return true;
161
179
  }
@@ -232,7 +250,7 @@ export function mergeBunfig(generated: string, existingContent: string): BunfigM
232
250
  const generatedValues = arrayField(generatedTest, key);
233
251
  const existingValues = arrayField(existingTest, key);
234
252
  const extras = existingValues.filter(
235
- (item) => !generatedValues.includes(item) && isKeepableExtra(key, item),
253
+ (item) => !generatedValues.includes(item) && isKeepableExtra(key, item, generatedValues),
236
254
  );
237
255
  content = appendArrayExtras(content, key, extras);
238
256
  }
package/src/changes.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.337.0",
4
+ "type": "fix",
5
+ "title": "bunfig merge reports dotted/quoted keys and keeps array tables, empty schema env defaults are replaced, integration dir expansion prunes node_modules, seedTenant gains unsubscribeSse",
6
+ "detail": "`kumiko-testing bunfig` now reports dotted and quoted keys as unknown and keeps `[[array-of-tables]]` blocks instead of folding them into `[test]`, and drops the superseded `./test-setup/dom.preload.ts` only when the DOM preload is generated. The schema-env-defaults preload treats empty values like unset ones for every key. `kumiko-testing integration <dir>` skips `node_modules`, `dist` and `e2e` while expanding a directory. `seedTenant` returns `unsubscribeSse` to detach the tenant's SSE client from `events.sse`. A real-provider scenario `waitFor` now times out after `E2E_TIMEOUT_MS.realWait` (180 s), before the 240 s test timeout."
7
+ },
2
8
  {
3
9
  "version": "0.336.1",
4
10
  "type": "fix",
@@ -247,6 +247,10 @@ function collectPresentIdentities(): {
247
247
  };
248
248
  }
249
249
 
250
+ export function scenarioWaitForTimeout(realProviderRun: boolean): number {
251
+ return realProviderRun ? E2E_TIMEOUT_MS.realWait : E2E_TIMEOUT_MS.navigation;
252
+ }
253
+
250
254
  async function openScenario(
251
255
  page: Page,
252
256
  s: Scenario,
@@ -259,8 +263,9 @@ async function openScenario(
259
263
 
260
264
  if (s.waitFor) {
261
265
  // Real-provider scenarios wait on LLM/OCR latency before the page is ready.
262
- const timeout = isRealProviderRun() ? E2E_TIMEOUT_MS.real : E2E_TIMEOUT_MS.navigation;
263
- await expect(page.locator(s.waitFor).first()).toBeVisible({ timeout });
266
+ await expect(page.locator(s.waitFor).first()).toBeVisible({
267
+ timeout: scenarioWaitForTimeout(isRealProviderRun()),
268
+ });
264
269
  }
265
270
  await waitForSettledPage(page, inFlightDataRequests);
266
271
  }
@@ -10,4 +10,7 @@ export const E2E_TIMEOUT_MS = {
10
10
  poll: 15_000,
11
11
  webServer: 90_000,
12
12
  real: 240_000,
13
+ // Selector wait of a real-provider scenario; below `real` so the locator error
14
+ // (selector, scenario) fires before the generic test timeout swallows it.
15
+ realWait: 180_000,
13
16
  } as const;
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { PROVIDER_ENV_KEYS } from "./provider-env-keys";
2
2
  export {
3
3
  type BoundApi,
4
+ type InProcessSeededTenant,
4
5
  type PersistedTenant,
5
6
  persistTenantRows,
6
7
  type SavedRow,
@@ -34,7 +34,7 @@ export function selectIntegrationFiles(paths: readonly string[]): string[] {
34
34
 
35
35
  function listDirectoryFiles(path: string): string[] | undefined {
36
36
  if (statSync(path, { throwIfNoEntry: false })?.isDirectory() !== true) return undefined;
37
- return readdirSync(path, { recursive: true, encoding: "utf8" });
37
+ return listIntegrationTestFiles(path);
38
38
  }
39
39
 
40
40
  /** Resolves `kumiko-testing integration`'s positional file args against `cwd`
@@ -49,9 +49,8 @@ if (typeof globalThis.window === "undefined") {
49
49
  // time. A static import would evaluate screen before happy-dom registers, so
50
50
  // every screen query would throw TypeError. Hence require() only after the
51
51
  // registration above.
52
- const { cleanup } = require("@testing-library/react/pure") as {
53
- cleanup: () => void;
54
- };
52
+ const { cleanup } =
53
+ require("@testing-library/react/pure") as typeof import("@testing-library/react/pure");
55
54
 
56
55
  const HTML_PRINT_LIMIT = 2000;
57
56
 
@@ -6,13 +6,11 @@ import { SCHEMA_ENV_DEFAULTS } from "./schema-env-defaults-values";
6
6
  const MIN_JWT_SECRET_LENGTH = 32;
7
7
 
8
8
  function isTooShortJwtSecret(key: string, value: string | undefined): boolean {
9
- return key === "JWT_SECRET" && (!value || value.length < MIN_JWT_SECRET_LENGTH);
9
+ return key === "JWT_SECRET" && value !== undefined && value.length < MIN_JWT_SECRET_LENGTH;
10
10
  }
11
11
 
12
+ // An empty value counts as unset: `${{ secrets.X }}` in GitHub Actions yields "" for a missing secret.
12
13
  for (const [key, value] of Object.entries(SCHEMA_ENV_DEFAULTS)) {
13
- if (isTooShortJwtSecret(key, process.env[key])) {
14
- process.env[key] = value;
15
- continue;
16
- }
17
- process.env[key] ??= value;
14
+ const current = process.env[key];
15
+ if (!current || isTooShortJwtSecret(key, current)) process.env[key] = value;
18
16
  }
@@ -169,24 +169,30 @@ function lightTenantRows(opts: SeedTenantOptions): PersistedTenant {
169
169
  return { ...identity, admin: lightCredentials(adminEmail), members };
170
170
  }
171
171
 
172
+ export type InProcessSeededTenant = SeededTenant & {
173
+ // Stops this tenant's events from landing in the stack's `events.sse`.
174
+ readonly unsubscribeSse: () => void;
175
+ };
176
+
172
177
  export async function seedTenant(
173
178
  stack: TestStack,
174
179
  opts: SeedTenantOptions = {},
175
- ): Promise<SeededTenant> {
180
+ ): Promise<InProcessSeededTenant> {
176
181
  const write = stackSeedWriter(stack);
177
182
  const persist = opts.persist === true;
178
183
  const rows = persist ? await persistTenantRows(write, opts) : lightTenantRows(opts);
179
184
  const { id } = rows;
180
185
 
181
186
  // setupTestStack subscribes events.sse only to test tenant 1; a seeded tenant has a fresh random id.
182
- stack.sseBroker.addClient(
183
- tenantChannel(id),
187
+ const channel = tenantChannel(id);
188
+ const sseClientId = stack.sseBroker.addClient(
189
+ channel,
184
190
  (event) => stack.events.sse.push(event),
185
191
  () => {},
186
192
  );
187
193
 
188
194
  const admin = withSession(rows.admin, id, [ROLES.TenantAdmin]);
189
- const tenant: SeededTenant = {
195
+ const tenant: InProcessSeededTenant = {
190
196
  id,
191
197
  key: rows.key,
192
198
  name: rows.name,
@@ -202,6 +208,7 @@ export async function seedTenant(
202
208
  id,
203
209
  roles,
204
210
  ),
211
+ unsubscribeSse: () => stack.sseBroker.removeClient(channel, sseClientId),
205
212
  api: bindApi(stack, admin.session),
206
213
  apiAs: (user) => bindApi(stack, user.session),
207
214
  };