@cosmicdrift/kumiko-testing 0.308.0 → 0.309.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/package.json +4 -4
- package/src/bunfig.ts +5 -2
- package/src/changes.json +37 -0
- package/src/e2e/constants.ts +10 -1
- package/src/e2e/define-app-e2e-config.ts +14 -3
- package/src/e2e/index.ts +4 -0
- package/src/e2e/screenshots.ts +113 -13
- package/src/e2e/seed-contract.ts +9 -0
- package/src/e2e/seed-route.ts +5 -1
- package/src/e2e/seeded-tenant-fixture.ts +1 -1
- package/src/e2e/timeouts.ts +3 -0
- package/src/preload/service-env-defaults-values.ts +14 -0
- package/src/preload/service-env-defaults.ts +4 -13
- package/src/scaffold.ts +7 -1
- package/src/seed-tenant.ts +24 -8
- package/src/seed-types.ts +9 -0
- package/src/setup-app-test-stack.ts +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.309.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.309.0",
|
|
67
|
+
"@cosmicdrift/kumiko-dev-server": "0.309.0",
|
|
68
|
+
"@cosmicdrift/kumiko-framework": "0.309.0",
|
|
69
69
|
"zod": "^4.4.3"
|
|
70
70
|
},
|
|
71
71
|
"peerDependencies": {
|
package/src/bunfig.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { E2E_TIMEOUT_MS } from "./e2e/timeouts";
|
|
2
|
+
|
|
1
3
|
export const TEST_TIMEOUT_MS = {
|
|
2
4
|
unit: 5000,
|
|
3
5
|
integration: 15000,
|
|
4
6
|
dom: 15000,
|
|
5
|
-
//
|
|
6
|
-
real
|
|
7
|
+
// Same class budget as Playwright real runs (E2E_TIMEOUT_MS.real) — one
|
|
8
|
+
// constant covers both the bun test:real path and defineAppE2eConfig.
|
|
9
|
+
real: E2E_TIMEOUT_MS.real,
|
|
7
10
|
} as const;
|
|
8
11
|
|
|
9
12
|
export const BUNFIG_FILES = {
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,41 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.309.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "reducedMotion defaults to \"reduce\" in runScreenshots/runMatrix/captureScreenshot; captureScreenshot(page, name) added",
|
|
6
|
+
"detail": "rAF-driven chart/tween animations were invisible to the settle-detection\nwait, so screenshots sometimes captured a mid-animation frame. Both matrix\nhelpers now call page.emulateMedia({ reducedMotion: \"reduce\" }) at test\nstart, before the first navigation. The new captureScreenshot(page, name,\nopts?) applies the same media emulation at capture time, so it only affects\nanimations started after that point; set reducedMotion via test.use() for\nmid-flow shots of charts that animate on mount. captureScreenshot reuses the\nmatrix runner's settle logic, writes $SCREENSHOT_DIR/<name>.png, and is a\nno-op when SCREENSHOT_DIR is unset, for solon's mid-flow shot(page, id) and\nofflot's inline page.screenshot writes into docs/screenshots/e2e/.",
|
|
7
|
+
"migration": "Pass `reducedMotion: \"no-preference\"` in runScreenshots/runMatrix's\noptions, or as captureScreenshot's third argument, for a scenario that\nmust keep real motion."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.309.0",
|
|
11
|
+
"type": "fix",
|
|
12
|
+
"title": "test:real now filters to *.real.test.ts, and real-provider runs share one 240s timeout budget",
|
|
13
|
+
"detail": "bunfig.real.toml's pathIgnorePatterns is blacklist-only (no gitignore-style\nnegation), so an unfiltered `bun test --config=bunfig.real.toml` still ran\nthe whole unit suite under the real-provider env. The generated `test:real`\nscript now passes a positional `real.test.ts` filter. TEST_TIMEOUT_MS.real\n(bun --timeout) and E2E_TIMEOUT_MS.real (the Playwright real-run path via\ndefineAppE2eConfig) now both come from the same 240_000 template constant\ninstead of a separately hardcoded 120s, covering solon's real-provider\ndocument-onboarding flow. `isRealProviderRun(env?)` is exported next to\n`REAL_PROVIDERS_ENV`/`requireRealProviders` so apps stop duplicating the\n`KUMIKO_REAL_PROVIDERS === \"1\"` literal."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"version": "0.309.0",
|
|
17
|
+
"type": "improvement",
|
|
18
|
+
"title": "runMatrix locales are app-injectable via localeTags, and gains a namedDevice mode for phone-style device projects",
|
|
19
|
+
"detail": "runMatrix's locale list was fixed to en/de with hardcoded BCP47 tags.\nopts.localeTags lets an app supply its own locale -> tag map (e.g. \"es\" ->\n\"es-ES\"); locales with neither an app override nor the en/de default fall\nback to Intl.Locale(locale).maximize().region derivation, throwing only\nwhen no tag is derivable. A Playwright project whose name isn't a\nViewportId but is a mobile/device project (offlot's \"phone\" project) now\ngets its own namedDevice output subtree\n(<dir>/<outputPrefix>/<name>/<locale>/<theme>/<viewport>.png) fixed to the mobile\nviewport, instead of incorrectly falling through to the desktop pass."
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"version": "0.309.0",
|
|
23
|
+
"type": "improvement",
|
|
24
|
+
"title": "Scenario flow receives runMatrix's locale; Scenario.captureStyle for screenshot-only CSS",
|
|
25
|
+
"detail": "runMatrix passes the current locale to `flow(page, { seedTenant, locale })`,\nso apps whose routes carry the locale in the path (offlot's public vehicle\npage) can build the URL per locale. `captureStyle` is handed to Playwright's\nscreenshot `style` option in runScreenshots and runMatrix: the CSS applies\nto the capture only and does not leak into the next theme × viewport shot."
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"version": "0.309.0",
|
|
29
|
+
"type": "improvement",
|
|
30
|
+
"title": "seedTenant accepts an admin identity (displayName/email) for demo and screenshot tenants",
|
|
31
|
+
"detail": "SeedTenantOptions gained an optional `admin: { displayName?, email? }`.\nemail supports a `{tenantId}` placeholder, substituted per call, so a\nfixed template (e.g. `admin+{tenantId}@example.test`) stays unique across\nthe global `read_users_email_unique` index instead of colliding across\nper-scenario tenants. Threaded through both the plain seed-tenant.ts path\nand the HTTP seed route/fixture."
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"version": "0.309.0",
|
|
35
|
+
"type": "fix",
|
|
36
|
+
"title": "defineAppE2eConfig's webServer env now defaults the infra service vars, environment wins",
|
|
37
|
+
"detail": "webServer.env is now built as `{ ...infraEnvDefaults(), ...PLAYWRIGHT_DEMO_ENV, ...env }`,\nwhere infraEnvDefaults() reads the same SERVICE_ENV_DEFAULTS the app's own\npreload uses (DATABASE_URL, REDIS_URL, MEILI_URL, MINIO_*, ...), falling\nback to each default only when process.env doesn't already carry a value.\nA CI/shell-set env var still wins over the template default. No\n`--env-file` is loaded here."
|
|
38
|
+
},
|
|
2
39
|
{
|
|
3
40
|
"version": "0.308.0",
|
|
4
41
|
"type": "fix",
|
package/src/e2e/constants.ts
CHANGED
|
@@ -15,9 +15,18 @@ export const SEED_TOKEN_ENV = "KUMIKO_TEST_SEED_TOKEN";
|
|
|
15
15
|
export const SEED_TOKEN_HEADER = "x-kumiko-test-seed";
|
|
16
16
|
export const E2E_WORKERS_ENV = "KUMIKO_E2E_WORKERS";
|
|
17
17
|
|
|
18
|
-
// Same literal as the framework's requireRealProviders(),
|
|
18
|
+
// Same literal as the framework's requireRealProviders()/isRealProviderRun(),
|
|
19
|
+
// duplicated because the Playwright config runs under Node and importing the
|
|
20
|
+
// framework's "./testing" barrel there would pull in its full Bun-toolchain
|
|
21
|
+
// dependency graph for a single string constant.
|
|
19
22
|
export const REAL_PROVIDERS_ENV = "KUMIKO_REAL_PROVIDERS";
|
|
20
23
|
|
|
24
|
+
export function isRealProviderRun(
|
|
25
|
+
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
26
|
+
): boolean {
|
|
27
|
+
return env[REAL_PROVIDERS_ENV] === "1";
|
|
28
|
+
}
|
|
29
|
+
|
|
21
30
|
// Same literal as kumiko-dev-server's createKumikoServer (STYLESHEET_WATCH_ENV),
|
|
22
31
|
// duplicated because the Playwright config runs under Node and can't import
|
|
23
32
|
// that Bun-toolchain-adjacent module.
|
|
@@ -8,11 +8,12 @@ import {
|
|
|
8
8
|
type PlaywrightWorkerOptions,
|
|
9
9
|
type Project,
|
|
10
10
|
} from "@playwright/test";
|
|
11
|
+
import { SERVICE_ENV_DEFAULTS } from "../preload/service-env-defaults-values";
|
|
11
12
|
import {
|
|
12
13
|
E2E_WORKERS_ENV,
|
|
14
|
+
isRealProviderRun,
|
|
13
15
|
PLAYWRIGHT_DEMO_ENV,
|
|
14
16
|
PROD_BUNDLES_ENV,
|
|
15
|
-
REAL_PROVIDERS_ENV,
|
|
16
17
|
SEED_ENABLE_ENV,
|
|
17
18
|
SEED_TOKEN_ENV,
|
|
18
19
|
STYLESHEET_WATCH_ENV,
|
|
@@ -20,6 +21,15 @@ import {
|
|
|
20
21
|
import { screenshotSpecsIgnore } from "./screenshot-dir";
|
|
21
22
|
import { E2E_TIMEOUT_MS } from "./timeouts";
|
|
22
23
|
|
|
24
|
+
// A `??` merge per key, not a raw process.env spread — CI or the shell
|
|
25
|
+
// environment wins over the local-dev default without leaking unrelated
|
|
26
|
+
// host env vars into the webServer process.
|
|
27
|
+
function infraEnvDefaults(): Record<string, string> {
|
|
28
|
+
return Object.fromEntries(
|
|
29
|
+
Object.entries(SERVICE_ENV_DEFAULTS).map(([key, value]) => [key, process.env[key] ?? value]),
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
23
33
|
const TEMPLATE_OWNED_PROJECT_KEYS = [
|
|
24
34
|
"fullyParallel",
|
|
25
35
|
"retries",
|
|
@@ -121,7 +131,7 @@ export function defineAppE2eConfig(input: AppE2eConfigInput): PlaywrightTestConf
|
|
|
121
131
|
assertNoReservedEnv(env);
|
|
122
132
|
for (const project of projects) assertTemplateOwnedKeysUnset(project);
|
|
123
133
|
const baseURL = `http://localhost:${port}`;
|
|
124
|
-
const realRun =
|
|
134
|
+
const realRun = isRealProviderRun();
|
|
125
135
|
|
|
126
136
|
return defineConfig({
|
|
127
137
|
testDir,
|
|
@@ -134,7 +144,7 @@ export function defineAppE2eConfig(input: AppE2eConfigInput): PlaywrightTestConf
|
|
|
134
144
|
retries: 0,
|
|
135
145
|
workers: resolveE2eWorkers(),
|
|
136
146
|
reporter: [["list"]],
|
|
137
|
-
timeout: E2E_TIMEOUT_MS.test,
|
|
147
|
+
timeout: realRun ? E2E_TIMEOUT_MS.real : E2E_TIMEOUT_MS.test,
|
|
138
148
|
expect: { timeout: E2E_TIMEOUT_MS.expect },
|
|
139
149
|
use: {
|
|
140
150
|
baseURL,
|
|
@@ -149,6 +159,7 @@ export function defineAppE2eConfig(input: AppE2eConfigInput): PlaywrightTestConf
|
|
|
149
159
|
command: `bun run ${serverEntry}`,
|
|
150
160
|
url: baseURL,
|
|
151
161
|
env: {
|
|
162
|
+
...infraEnvDefaults(),
|
|
152
163
|
...PLAYWRIGHT_DEMO_ENV,
|
|
153
164
|
...env,
|
|
154
165
|
PORT: String(port),
|
package/src/e2e/index.ts
CHANGED
|
@@ -16,8 +16,10 @@ export {
|
|
|
16
16
|
CSRF_COOKIE_NAME,
|
|
17
17
|
CSRF_HEADER_NAME,
|
|
18
18
|
E2E_WORKERS_ENV,
|
|
19
|
+
isRealProviderRun,
|
|
19
20
|
KUMIKO_SECRETS_MASTER_KEY_V1,
|
|
20
21
|
PLAYWRIGHT_DEMO_ENV,
|
|
22
|
+
REAL_PROVIDERS_ENV,
|
|
21
23
|
SEED_ENABLE_ENV,
|
|
22
24
|
SEED_TOKEN_ENV,
|
|
23
25
|
} from "./constants";
|
|
@@ -37,6 +39,7 @@ export {
|
|
|
37
39
|
} from "./screenshot-dir";
|
|
38
40
|
export {
|
|
39
41
|
applyDefaultTheme,
|
|
42
|
+
captureScreenshot,
|
|
40
43
|
DEFAULT_THEMES,
|
|
41
44
|
type DefaultThemeId,
|
|
42
45
|
type FlatOptions,
|
|
@@ -45,6 +48,7 @@ export {
|
|
|
45
48
|
runMatrix,
|
|
46
49
|
runScreenshots,
|
|
47
50
|
type Scenario,
|
|
51
|
+
type ScenarioFixtures,
|
|
48
52
|
type ThemeScreenshotDigest,
|
|
49
53
|
validateScenarios,
|
|
50
54
|
} from "./screenshots";
|
package/src/e2e/screenshots.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { mkdirSync, statSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
3
4
|
import { expect, type Page, type Request } from "@playwright/test";
|
|
4
5
|
import { pinEnglishLocale } from "./pin-english-locale";
|
|
5
|
-
import { requireScreenshotDir } from "./screenshot-dir";
|
|
6
|
+
import { requireScreenshotDir, SCREENSHOT_DIR_ENV } from "./screenshot-dir";
|
|
6
7
|
import { type SeedTenantFixture, test } from "./seeded-tenant-fixture";
|
|
7
8
|
|
|
9
|
+
export const DEFAULT_REDUCED_MOTION = "reduce" as const;
|
|
10
|
+
|
|
8
11
|
// runScreenshots: one image per scenario → $SCREENSHOT_DIR/<name>.png.
|
|
9
12
|
// runMatrix: every scenario × locale × theme × viewport in ONE run →
|
|
10
13
|
// $SCREENSHOT_DIR/<name>/<locale>/<theme>/<viewport>.png (feeds the preview switcher).
|
|
@@ -27,13 +30,24 @@ export async function applyDefaultTheme(page: Page, theme: DefaultThemeId): Prom
|
|
|
27
30
|
}, theme);
|
|
28
31
|
}
|
|
29
32
|
|
|
33
|
+
export interface ScenarioFixtures {
|
|
34
|
+
readonly seedTenant: SeedTenantFixture;
|
|
35
|
+
// runMatrix's current locale, for apps whose routes carry the locale in the
|
|
36
|
+
// path (kumiko:locale alone can't change the URL). Undefined in runScreenshots.
|
|
37
|
+
readonly locale?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
30
40
|
export interface Scenario {
|
|
31
41
|
readonly name: string;
|
|
32
42
|
readonly description?: string;
|
|
33
43
|
readonly url?: string;
|
|
34
|
-
readonly flow?: (page: Page, fixtures:
|
|
44
|
+
readonly flow?: (page: Page, fixtures: ScenarioFixtures) => Promise<void>;
|
|
35
45
|
readonly waitFor?: string;
|
|
36
46
|
readonly fullPage?: boolean;
|
|
47
|
+
// Playwright's screenshot-only `style`: applied for the capture and removed
|
|
48
|
+
// afterwards, unlike an addStyleTag in beforeCapture that would persist into
|
|
49
|
+
// the next theme × viewport capture.
|
|
50
|
+
readonly captureStyle?: string;
|
|
37
51
|
readonly viewport?: { readonly width: number; readonly height: number };
|
|
38
52
|
// Runs after the viewport is set and the page has settled, right before the
|
|
39
53
|
// screenshot. runMatrix calls this once per theme × viewport combination for
|
|
@@ -100,7 +114,7 @@ async function openScenario(
|
|
|
100
114
|
page: Page,
|
|
101
115
|
s: Scenario,
|
|
102
116
|
inFlightDataRequests: () => number,
|
|
103
|
-
fixtures:
|
|
117
|
+
fixtures: ScenarioFixtures,
|
|
104
118
|
): Promise<void> {
|
|
105
119
|
if (s.flow) await s.flow(page, fixtures);
|
|
106
120
|
else if (s.url) await page.goto(s.url);
|
|
@@ -112,8 +126,13 @@ async function openScenario(
|
|
|
112
126
|
await waitForSettledPage(page, inFlightDataRequests);
|
|
113
127
|
}
|
|
114
128
|
|
|
129
|
+
export type ReducedMotionOption = "reduce" | "no-preference";
|
|
130
|
+
|
|
115
131
|
export interface FlatOptions {
|
|
116
132
|
readonly pinLocale?: boolean;
|
|
133
|
+
// rAF-driven chart tweens etc. are invisible to waitForSettledPage's DOM/scroll
|
|
134
|
+
// fingerprint, so "reduce" is the default — pass "no-preference" to capture motion.
|
|
135
|
+
readonly reducedMotion?: ReducedMotionOption;
|
|
117
136
|
}
|
|
118
137
|
|
|
119
138
|
// Fail at registration time, not mid-run: a url-only scenario with no
|
|
@@ -151,13 +170,20 @@ export function runScreenshots(scenarios: readonly Scenario[], opts: FlatOptions
|
|
|
151
170
|
test(
|
|
152
171
|
s.description ? `${s.name} — ${s.description}` : s.name,
|
|
153
172
|
async ({ page, seedTenant }) => {
|
|
173
|
+
// reducedMotion isn't a PlaywrightTestOptions fixture (test.use can't
|
|
174
|
+
// set it), so it's applied per-page like the rest of emulateMedia.
|
|
175
|
+
await page.emulateMedia({ reducedMotion: opts.reducedMotion ?? DEFAULT_REDUCED_MOTION });
|
|
154
176
|
if (opts.pinLocale) await pinEnglishLocale(page);
|
|
155
177
|
const inFlightDataRequests = countInFlightDataRequests(page);
|
|
156
178
|
if (s.viewport) await page.setViewportSize(s.viewport);
|
|
157
179
|
await openScenario(page, s, inFlightDataRequests, { seedTenant });
|
|
158
180
|
if (s.beforeCapture) await s.beforeCapture(page);
|
|
159
181
|
const path = `${outDir}/${s.name}.png`;
|
|
160
|
-
await page.screenshot({
|
|
182
|
+
await page.screenshot({
|
|
183
|
+
path,
|
|
184
|
+
fullPage: s.fullPage ?? false,
|
|
185
|
+
...(s.captureStyle !== undefined && { style: s.captureStyle }),
|
|
186
|
+
});
|
|
161
187
|
expect.soft(statSync(path).size).toBeGreaterThan(MIN_BYTES);
|
|
162
188
|
},
|
|
163
189
|
);
|
|
@@ -204,13 +230,41 @@ export interface MatrixOptions<T extends string> {
|
|
|
204
230
|
readonly themes: readonly T[];
|
|
205
231
|
readonly applyTheme: (page: Page, theme: T) => Promise<void>;
|
|
206
232
|
readonly locales?: readonly string[];
|
|
233
|
+
readonly localeTags?: Readonly<Record<string, string>>;
|
|
234
|
+
readonly reducedMotion?: ReducedMotionOption;
|
|
207
235
|
}
|
|
208
236
|
|
|
209
237
|
// App locale codes ("en"/"de") -> BCP47 tags for Playwright's browser-context
|
|
210
238
|
// `locale` option, pinning JS-side Intl/navigator.language regardless of the
|
|
211
239
|
// host's own locale — without it, a screenshot regen on a non-en-US host bakes
|
|
212
240
|
// in the host's Intl-driven formatting regardless of SCREENSHOT_LOCALES.
|
|
213
|
-
const
|
|
241
|
+
const DEFAULT_LOCALE_TAGS: Readonly<Record<string, string>> = { en: "en-US", de: "de-DE" };
|
|
242
|
+
|
|
243
|
+
// Intl.Locale.maximize() derives a region for a bare language code (es -> es-ES)
|
|
244
|
+
// the same way DEFAULT_LOCALE_TAGS was hand-picked for en/de — apps with a
|
|
245
|
+
// locale that needs a different region (e.g. a Swiss "de" build) pass their
|
|
246
|
+
// own `localeTags` instead of relying on the derived default.
|
|
247
|
+
function deriveLocaleTag(locale: string): string | undefined {
|
|
248
|
+
try {
|
|
249
|
+
const region = new Intl.Locale(locale).maximize().region;
|
|
250
|
+
return region === undefined ? undefined : `${locale}-${region}`;
|
|
251
|
+
} catch {
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function resolveLocaleTag(
|
|
257
|
+
locale: string,
|
|
258
|
+
localeTags: Readonly<Record<string, string>> | undefined,
|
|
259
|
+
): string {
|
|
260
|
+
const tag = localeTags?.[locale] ?? DEFAULT_LOCALE_TAGS[locale] ?? deriveLocaleTag(locale);
|
|
261
|
+
if (tag === undefined) {
|
|
262
|
+
throw new Error(
|
|
263
|
+
`runMatrix(): no BCP47 tag for locale "${locale}" — pass it in opts.localeTags`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
return tag;
|
|
267
|
+
}
|
|
214
268
|
|
|
215
269
|
export interface ThemeScreenshotDigest<T extends string> {
|
|
216
270
|
readonly viewport: string;
|
|
@@ -252,6 +306,15 @@ export interface MatrixProjectInfo {
|
|
|
252
306
|
|
|
253
307
|
export type MatrixViewportPlan =
|
|
254
308
|
| { readonly mode: "device"; readonly viewports: readonly [ViewportId] }
|
|
309
|
+
// A device project whose name is NOT a ViewportId (e.g. offlot's "phone",
|
|
310
|
+
// devices["iPhone 13"]) writes under its own <baseDir>/<projectName>/ subtree
|
|
311
|
+
// instead of the shared matrix directory, so it never collides with the
|
|
312
|
+
// desktop pass's own "mobile" viewport file for the same scenario.
|
|
313
|
+
| {
|
|
314
|
+
readonly mode: "namedDevice";
|
|
315
|
+
readonly viewports: readonly [ViewportId];
|
|
316
|
+
readonly outputPrefix: string;
|
|
317
|
+
}
|
|
255
318
|
| { readonly mode: "desktop"; readonly viewports: readonly ViewportId[] }
|
|
256
319
|
| { readonly mode: "skip"; readonly reason: string };
|
|
257
320
|
|
|
@@ -279,6 +342,9 @@ export function resolveMatrixViewports(
|
|
|
279
342
|
}
|
|
280
343
|
return { mode: "device", viewports: [projectName] };
|
|
281
344
|
}
|
|
345
|
+
if (isMobileProject) {
|
|
346
|
+
return { mode: "namedDevice", viewports: ["mobile"], outputPrefix: projectName };
|
|
347
|
+
}
|
|
282
348
|
const deviceProjectIds = new Set(
|
|
283
349
|
projects.filter((p) => p.isMobile && isViewportId(p.name)).map((p) => p.name),
|
|
284
350
|
);
|
|
@@ -305,17 +371,15 @@ export function runMatrix<T extends string>(
|
|
|
305
371
|
// Browser-context locale for JS-side Intl/navigator.language — the
|
|
306
372
|
// kumiko:locale seed below only drives the app's own i18n strings.
|
|
307
373
|
// ponytail: see the runScreenshots() locale comment above / #1851
|
|
308
|
-
const tag =
|
|
309
|
-
if (tag === undefined) {
|
|
310
|
-
throw new Error(
|
|
311
|
-
`runMatrix(): no BCP47 tag mapped for locale "${locale}" — extend LOCALE_TAGS`,
|
|
312
|
-
);
|
|
313
|
-
}
|
|
374
|
+
const tag = resolveLocaleTag(locale, opts.localeTags);
|
|
314
375
|
test.use({ locale: tag });
|
|
315
376
|
|
|
316
377
|
for (const s of scenarios) {
|
|
317
378
|
if (only !== undefined && only !== s.name) continue;
|
|
318
379
|
test(s.name, async ({ page, seedTenant }) => {
|
|
380
|
+
// reducedMotion isn't a PlaywrightTestOptions fixture (test.use can't set
|
|
381
|
+
// it), so it's applied per-page like the rest of emulateMedia.
|
|
382
|
+
await page.emulateMedia({ reducedMotion: opts.reducedMotion ?? DEFAULT_REDUCED_MOTION });
|
|
319
383
|
const info = test.info();
|
|
320
384
|
const plan = resolveMatrixViewports(
|
|
321
385
|
info.project.name,
|
|
@@ -336,9 +400,11 @@ export function runMatrix<T extends string>(
|
|
|
336
400
|
localStorage.removeItem("kumiko:theme");
|
|
337
401
|
}, locale);
|
|
338
402
|
const inFlightDataRequests = countInFlightDataRequests(page);
|
|
339
|
-
await openScenario(page, s, inFlightDataRequests, { seedTenant });
|
|
403
|
+
await openScenario(page, s, inFlightDataRequests, { seedTenant, locale });
|
|
340
404
|
|
|
341
405
|
const digests: ThemeScreenshotDigest<T>[] = [];
|
|
406
|
+
const projectBaseDir =
|
|
407
|
+
plan.mode === "namedDevice" ? `${baseDir}/${plan.outputPrefix}` : baseDir;
|
|
342
408
|
|
|
343
409
|
for (const theme of themes) {
|
|
344
410
|
await opts.applyTheme(page, theme);
|
|
@@ -346,7 +412,7 @@ export function runMatrix<T extends string>(
|
|
|
346
412
|
if (plan.mode === "desktop") await page.setViewportSize(VIEWPORTS[vp]);
|
|
347
413
|
await waitForSettledPage(page, inFlightDataRequests);
|
|
348
414
|
if (s.beforeCapture) await s.beforeCapture(page);
|
|
349
|
-
const dir = `${
|
|
415
|
+
const dir = `${projectBaseDir}/${s.name}/${locale}/${theme}`;
|
|
350
416
|
mkdirSync(dir, { recursive: true });
|
|
351
417
|
const path = `${dir}/${vp}.png`;
|
|
352
418
|
// animations: "disabled" jumps to end-state at the engine level — immune to CSS specificity, unlike an addStyleTag injection.
|
|
@@ -356,6 +422,7 @@ export function runMatrix<T extends string>(
|
|
|
356
422
|
path,
|
|
357
423
|
fullPage: s.fullPage ?? false,
|
|
358
424
|
animations: "disabled",
|
|
425
|
+
...(s.captureStyle !== undefined && { style: s.captureStyle }),
|
|
359
426
|
});
|
|
360
427
|
expect.soft(buffer.length).toBeGreaterThan(MIN_BYTES);
|
|
361
428
|
digests.push({
|
|
@@ -379,3 +446,36 @@ export function runMatrix<T extends string>(
|
|
|
379
446
|
});
|
|
380
447
|
}
|
|
381
448
|
}
|
|
449
|
+
|
|
450
|
+
// Per-page in-flight counter for captureScreenshot() — a call mid-flow attaches
|
|
451
|
+
// its own page.on("request") listener the first time it sees a given page, so a
|
|
452
|
+
// second captureScreenshot() on that page reuses the same tracker instead of
|
|
453
|
+
// missing requests that were already in flight when the listener attached.
|
|
454
|
+
const inFlightTrackersByPage = new WeakMap<Page, () => number>();
|
|
455
|
+
|
|
456
|
+
function inFlightTrackerFor(page: Page): () => number {
|
|
457
|
+
const existing = inFlightTrackersByPage.get(page);
|
|
458
|
+
if (existing !== undefined) return existing;
|
|
459
|
+
const tracker = countInFlightDataRequests(page);
|
|
460
|
+
inFlightTrackersByPage.set(page, tracker);
|
|
461
|
+
return tracker;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Inline mid-flow screenshot for a single point in an existing e2e/real-provider
|
|
465
|
+
// spec (solon's `shot(page, id)`, offlot's inline docs/screenshots/e2e/* writes) —
|
|
466
|
+
// reuses the runner's settle logic and reduced-motion default, and is a no-op
|
|
467
|
+
// when SCREENSHOT_DIR is unset so a plain e2e run never writes into the repo.
|
|
468
|
+
export async function captureScreenshot(
|
|
469
|
+
page: Page,
|
|
470
|
+
name: string,
|
|
471
|
+
opts: { readonly reducedMotion?: ReducedMotionOption } = {},
|
|
472
|
+
): Promise<void> {
|
|
473
|
+
const dir = process.env[SCREENSHOT_DIR_ENV];
|
|
474
|
+
// skip: a plain e2e run without SCREENSHOT_DIR must not write screenshots.
|
|
475
|
+
if (dir === undefined || dir === "") return;
|
|
476
|
+
await page.emulateMedia({ reducedMotion: opts.reducedMotion ?? DEFAULT_REDUCED_MOTION });
|
|
477
|
+
await waitForSettledPage(page, inFlightTrackerFor(page));
|
|
478
|
+
const path = `${dir}/${name}.png`;
|
|
479
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
480
|
+
await page.screenshot({ path, animations: "disabled" });
|
|
481
|
+
}
|
package/src/e2e/seed-contract.ts
CHANGED
|
@@ -5,10 +5,19 @@ export const SEEDABLE_ROLES = [ROLES.TenantAdmin, ROLES.Member] as const;
|
|
|
5
5
|
export const MAX_SEED_MEMBERS = 10;
|
|
6
6
|
export const MAX_TENANT_NAME_LENGTH = 100;
|
|
7
7
|
const MAX_EMAIL_LENGTH = 320;
|
|
8
|
+
const MAX_DISPLAY_NAME_LENGTH = 100;
|
|
9
|
+
|
|
10
|
+
export const seedAdminIdentitySchema = z.strictObject({
|
|
11
|
+
displayName: z.string().min(1).max(MAX_DISPLAY_NAME_LENGTH).optional(),
|
|
12
|
+
// May contain the literal "{tenantId}" placeholder — substituted server-side
|
|
13
|
+
// in persistTenantRows, since user:create's email uniqueness is global.
|
|
14
|
+
email: z.string().min(1).max(MAX_EMAIL_LENGTH).optional(),
|
|
15
|
+
});
|
|
8
16
|
|
|
9
17
|
export const seedTenantRequestSchema = z.strictObject({
|
|
10
18
|
name: z.string().min(1).max(MAX_TENANT_NAME_LENGTH).optional(),
|
|
11
19
|
members: z.number().int().min(0).max(MAX_SEED_MEMBERS).optional(),
|
|
20
|
+
admin: seedAdminIdentitySchema.optional(),
|
|
12
21
|
});
|
|
13
22
|
|
|
14
23
|
export const seededCredentialsSchema = z.object({
|
package/src/e2e/seed-route.ts
CHANGED
|
@@ -134,7 +134,11 @@ export function createE2eSeedRoutes(
|
|
|
134
134
|
unwrapSavedRow(handlerQn, await deps.dispatchSystemWrite({ handlerQn, payload, tenantId }));
|
|
135
135
|
try {
|
|
136
136
|
return c.json(
|
|
137
|
-
await persistTenantRows(write, {
|
|
137
|
+
await persistTenantRows(write, {
|
|
138
|
+
name: verified.name,
|
|
139
|
+
users: verified.members,
|
|
140
|
+
admin: verified.admin,
|
|
141
|
+
}),
|
|
138
142
|
);
|
|
139
143
|
} catch (error) {
|
|
140
144
|
return c.json({ error: error instanceof Error ? error.message : "seed failed" }, 500);
|
|
@@ -104,7 +104,7 @@ export async function provideSeedTenant(
|
|
|
104
104
|
const seeded = await postSeedRoute(
|
|
105
105
|
request,
|
|
106
106
|
SEED_ROUTES.seedTenant,
|
|
107
|
-
{ name: opts.name, members: opts.users },
|
|
107
|
+
{ name: opts.name, members: opts.users, admin: opts.admin },
|
|
108
108
|
seedTenantResponseSchema,
|
|
109
109
|
);
|
|
110
110
|
const tenantId = seeded.id;
|
package/src/e2e/timeouts.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// Class budget for every app: solon's 30s/5s flows run 4-parallel today; webServer is the
|
|
2
2
|
// slowest sample boot (Bun.build + Tailwind), poll is phronexsis's 15s projection wait.
|
|
3
|
+
// `real` covers solon's document-onboarding real-provider flow (240s), the slowest
|
|
4
|
+
// consumer today — apps no longer set test.setTimeout/describe.configure for real runs.
|
|
3
5
|
export const E2E_TIMEOUT_MS = {
|
|
4
6
|
test: 30_000,
|
|
5
7
|
expect: 5_000,
|
|
@@ -7,4 +9,5 @@ export const E2E_TIMEOUT_MS = {
|
|
|
7
9
|
navigation: 10_000,
|
|
8
10
|
poll: 15_000,
|
|
9
11
|
webServer: 90_000,
|
|
12
|
+
real: 240_000,
|
|
10
13
|
} as const;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const SERVICE_ENV_DEFAULTS = {
|
|
2
|
+
DATABASE_URL: "postgresql://kumiko:kumiko@localhost:15432/kumiko_dev",
|
|
3
|
+
TEST_DATABASE_URL: "postgresql://kumiko:kumiko@localhost:15432/kumiko_test",
|
|
4
|
+
REDIS_URL: "redis://localhost:16379",
|
|
5
|
+
MEILI_URL: "http://localhost:17700",
|
|
6
|
+
MEILI_MASTER_KEY: "kumiko-dev-key",
|
|
7
|
+
JWT_SECRET: "test-jwt-secret-at-least-32-characters-long",
|
|
8
|
+
MINIO_ENDPOINT: "http://localhost:19000",
|
|
9
|
+
MINIO_ACCESS_KEY: "kumiko",
|
|
10
|
+
MINIO_SECRET_KEY: "kumiko-dev-secret",
|
|
11
|
+
MINIO_BUCKET: "kumiko-dev",
|
|
12
|
+
MINIO_REGION: "us-east-1",
|
|
13
|
+
LEGACY_DATABASE_URL: "",
|
|
14
|
+
} as const satisfies Record<string, string>;
|
|
@@ -1,14 +1,5 @@
|
|
|
1
|
-
|
|
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";
|
|
1
|
+
import { SERVICE_ENV_DEFAULTS } from "./service-env-defaults-values";
|
|
7
2
|
|
|
8
|
-
|
|
9
|
-
process.env[
|
|
10
|
-
|
|
11
|
-
process.env["MINIO_BUCKET"] ??= "kumiko-dev";
|
|
12
|
-
process.env["MINIO_REGION"] ??= "us-east-1";
|
|
13
|
-
|
|
14
|
-
process.env["LEGACY_DATABASE_URL"] ??= "";
|
|
3
|
+
for (const [key, value] of Object.entries(SERVICE_ENV_DEFAULTS)) {
|
|
4
|
+
process.env[key] ??= value;
|
|
5
|
+
}
|
package/src/scaffold.ts
CHANGED
|
@@ -189,7 +189,13 @@ export function renderTestSetup(input: RenderTestSetupInput): ScaffoldTestSetup
|
|
|
189
189
|
scripts: {
|
|
190
190
|
test: `bun --config=${BUNFIG_FILES.unit} test --timeout=${TEST_TIMEOUT_MS.unit} --dots`,
|
|
191
191
|
"test:integration": `bun kumiko-testing integration --parallel ${SCAFFOLD_INTEGRATION_PARALLEL}`,
|
|
192
|
-
|
|
192
|
+
// bunfig.real.toml's pathIgnorePatterns can only blacklist (no "!" negation,
|
|
193
|
+
// verified against bun 1.4), so it can't express "match only *.real.test.ts"
|
|
194
|
+
// on its own — every *.test.ts file matches bun's default test glob too,
|
|
195
|
+
// real.test.ts included. The positional filter narrows the run to files
|
|
196
|
+
// whose path contains "real.test.ts", so an unfiltered test:real never
|
|
197
|
+
// picks up the unit suite.
|
|
198
|
+
"test:real": `${REAL_PROVIDERS_ENV}=1 bun --config=${BUNFIG_FILES.real} test --timeout=${TEST_TIMEOUT_MS.real} real.test.ts`,
|
|
193
199
|
"test:bunfig": "bun kumiko-testing bunfig --hoisted",
|
|
194
200
|
e2e: "bunx --bun playwright test",
|
|
195
201
|
"e2e:real": `${REAL_PROVIDERS_ENV}=1 bunx --bun playwright test`,
|
package/src/seed-tenant.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type { TestStack } from "@cosmicdrift/kumiko-framework/stack";
|
|
|
13
13
|
import { isPlainObject } from "@cosmicdrift/kumiko-framework/utils";
|
|
14
14
|
import {
|
|
15
15
|
type BoundApi,
|
|
16
|
+
type SeedAdminIdentity,
|
|
16
17
|
type SeededCredentials,
|
|
17
18
|
type SeededTenant,
|
|
18
19
|
type SeedTenantOptions,
|
|
@@ -21,6 +22,7 @@ import {
|
|
|
21
22
|
|
|
22
23
|
export type {
|
|
23
24
|
BoundApi,
|
|
25
|
+
SeedAdminIdentity,
|
|
24
26
|
SeededCredentials,
|
|
25
27
|
SeededTenant,
|
|
26
28
|
SeededUser,
|
|
@@ -54,9 +56,14 @@ function bindApi(stack: TestStack, user: SessionUser): BoundApi {
|
|
|
54
56
|
};
|
|
55
57
|
}
|
|
56
58
|
|
|
57
|
-
function lightCredentials(): SeededCredentials {
|
|
59
|
+
function lightCredentials(email?: string): SeededCredentials {
|
|
58
60
|
const id = randomUUID();
|
|
59
|
-
|
|
61
|
+
const resolvedEmail = email ?? `user-${id}@example.test`;
|
|
62
|
+
return { id, email: resolvedEmail, password: `pw-${randomUUID()}` };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function substituteTenantId(email: string, tenantId: string): string {
|
|
66
|
+
return email.replaceAll("{tenantId}", tenantId);
|
|
60
67
|
}
|
|
61
68
|
|
|
62
69
|
function newTenantIdentity(name: string | undefined): {
|
|
@@ -100,14 +107,17 @@ export async function persistUserRows(
|
|
|
100
107
|
write: SeedWriter,
|
|
101
108
|
tenantId: TenantId,
|
|
102
109
|
roles: readonly string[],
|
|
110
|
+
identity: SeedAdminIdentity = {},
|
|
103
111
|
): Promise<SeededCredentials> {
|
|
104
|
-
const light = lightCredentials(
|
|
112
|
+
const light = lightCredentials(
|
|
113
|
+
identity.email !== undefined ? substituteTenantId(identity.email, tenantId) : undefined,
|
|
114
|
+
);
|
|
105
115
|
const created = await write(
|
|
106
116
|
UserHandlers.create,
|
|
107
117
|
{
|
|
108
118
|
email: light.email,
|
|
109
119
|
passwordHash: await hashPassword(light.password),
|
|
110
|
-
displayName: `Seed ${light.id.slice(0, 8)}`,
|
|
120
|
+
displayName: identity.displayName ?? `Seed ${light.id.slice(0, 8)}`,
|
|
111
121
|
},
|
|
112
122
|
tenantId,
|
|
113
123
|
);
|
|
@@ -122,11 +132,15 @@ export async function persistUserRows(
|
|
|
122
132
|
|
|
123
133
|
export async function persistTenantRows(
|
|
124
134
|
write: SeedWriter,
|
|
125
|
-
opts: {
|
|
135
|
+
opts: {
|
|
136
|
+
readonly name?: string;
|
|
137
|
+
readonly users?: number;
|
|
138
|
+
readonly admin?: SeedAdminIdentity;
|
|
139
|
+
} = {},
|
|
126
140
|
): Promise<PersistedTenant> {
|
|
127
141
|
const { id, key, name } = newTenantIdentity(opts.name);
|
|
128
142
|
await write(TenantHandlers.create, { id, key, name }, id);
|
|
129
|
-
const admin = await persistUserRows(write, id, [ROLES.TenantAdmin]);
|
|
143
|
+
const admin = await persistUserRows(write, id, [ROLES.TenantAdmin], opts.admin);
|
|
130
144
|
const members: SeededCredentials[] = [];
|
|
131
145
|
for (let i = 0; i < (opts.users ?? 0); i++) {
|
|
132
146
|
members.push(await persistUserRows(write, id, [ROLES.Member]));
|
|
@@ -136,8 +150,10 @@ export async function persistTenantRows(
|
|
|
136
150
|
|
|
137
151
|
function lightTenantRows(opts: SeedTenantOptions): PersistedTenant {
|
|
138
152
|
const identity = newTenantIdentity(opts.name);
|
|
139
|
-
const members = Array.from({ length: opts.users ?? 0 }, lightCredentials);
|
|
140
|
-
|
|
153
|
+
const members = Array.from({ length: opts.users ?? 0 }, () => lightCredentials());
|
|
154
|
+
const adminEmail =
|
|
155
|
+
opts.admin?.email !== undefined ? substituteTenantId(opts.admin.email, identity.id) : undefined;
|
|
156
|
+
return { ...identity, admin: lightCredentials(adminEmail), members };
|
|
141
157
|
}
|
|
142
158
|
|
|
143
159
|
export async function seedTenant(
|
package/src/seed-types.ts
CHANGED
|
@@ -22,11 +22,20 @@ export type SeededUser = SeededCredentials & { readonly session: SessionUser };
|
|
|
22
22
|
|
|
23
23
|
export type SeedPart = (ctx: { readonly tenant: SeededTenant }) => Promise<void>;
|
|
24
24
|
|
|
25
|
+
// email may contain the literal "{tenantId}" placeholder, substituted with the
|
|
26
|
+
// seeded tenant's id — user:create's email uniqueness is global (fw#2134/#2593),
|
|
27
|
+
// so a fixed literal email collides across per-scenario tenants.
|
|
28
|
+
export type SeedAdminIdentity = {
|
|
29
|
+
readonly displayName?: string;
|
|
30
|
+
readonly email?: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
25
33
|
export type SeedTenantOptions = {
|
|
26
34
|
readonly name?: string;
|
|
27
35
|
readonly users?: number;
|
|
28
36
|
readonly with?: readonly SeedPart[];
|
|
29
37
|
readonly persist?: boolean;
|
|
38
|
+
readonly admin?: SeedAdminIdentity;
|
|
30
39
|
};
|
|
31
40
|
|
|
32
41
|
export type SeededTenant = {
|
|
@@ -14,7 +14,11 @@ export async function setupAppTestStack(
|
|
|
14
14
|
options: SetupAppTestStackOptions = {},
|
|
15
15
|
): Promise<TestStack> {
|
|
16
16
|
const { registryTables = true, includeBundled = true, ...stackOptions } = options;
|
|
17
|
-
const stack = await setupTestStackFromFeatures(features, {
|
|
17
|
+
const stack = await setupTestStackFromFeatures(features, {
|
|
18
|
+
...stackOptions,
|
|
19
|
+
includeBundled,
|
|
20
|
+
entityTables: registryTables,
|
|
21
|
+
});
|
|
18
22
|
if (!registryTables) return stack;
|
|
19
23
|
try {
|
|
20
24
|
await pushEntityProjectionTables(stack, stack.registry);
|