@cosmicdrift/kumiko-testing 0.308.0 → 0.310.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 +62 -0
- package/src/e2e/constants.ts +15 -1
- package/src/e2e/define-app-e2e-config.ts +33 -4
- package/src/e2e/index.ts +6 -0
- package/src/e2e/screenshots.ts +191 -19
- 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.310.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.310.0",
|
|
67
|
+
"@cosmicdrift/kumiko-dev-server": "0.310.0",
|
|
68
|
+
"@cosmicdrift/kumiko-framework": "0.310.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,66 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.310.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "captureScreenshot accepts fit viewport, fullPage or content",
|
|
6
|
+
"detail": "`fit: \"viewport\"` (default) keeps the previous behaviour. `fit: \"fullPage\"`\ncaptures the whole document, so offlot's inline mid-flow shots\n(channel-request, channel-prompts, vehicle-channel-texts) can move to\ncaptureScreenshot. `fit: \"content\"` grows the viewport until neither the\ndocument nor a visible overflow-auto/scroll container (WorkspaceShell's\ninner scroll area) overflows, captures, and restores the viewport; it\nthrows instead of writing a cropped image when growth does not converge\nwithin 4 rounds. A 1px container overflow counts as sub-pixel rounding\n(overflow-x-auto table wrappers), not content. This replaces solon's own `e2e/_helpers/shot.ts`; its\nimages pick up the `reducedMotion: \"reduce\"` default on the switch, so a\none-time pixel drift in the handbook PNGs is expected."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.310.0",
|
|
10
|
+
"type": "breaking",
|
|
11
|
+
"title": "defineAppE2eConfig's chromium project runs at 1920×1080 instead of Desktop Chrome's 1280×720",
|
|
12
|
+
"detail": "The template took Playwright's \"Desktop Chrome\" device unchanged, so every\ne2e run and every inline captureScreenshot rendered at 1280×720 while\nrunMatrix's desktop screenshots used 1920×1080. Both now share\nDESKTOP_VIEWPORT (1920×1080). A root `use.viewport` override in an app's\nplaywright config never reached the chromium project anyway (project `use`\nwins), so solon's 1920 override was silently ineffective.",
|
|
13
|
+
"migration": "Drop app-level desktop viewport overrides. Specs asserting a layout that\nonly exists below 1920px (collapsed sidebar, stacked panes) set their own\nviewport via test.use({ viewport }). Committed inline screenshots taken\nin the chromium project regenerate at 1920 width."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"version": "0.310.0",
|
|
17
|
+
"type": "fix",
|
|
18
|
+
"title": "defineAppE2eConfig's webServer env no longer defaults MEILI_URL and MEILI_MASTER_KEY",
|
|
19
|
+
"detail": "Since the infra env defaults landed, every e2e webServer got\nMEILI_URL=http://localhost:17700. Apps that choose Meilisearch over\ntheir in-memory search adapter when MEILI_URL is set then pointed at an\nunreachable host in CI without a Meili service. Meili is opt-in again:\nthe two vars reach the webServer only when the environment sets them or\nthe app passes them via `env`. All other infra defaults are unchanged.\nMigration: an app whose e2e needs Meilisearch sets MEILI_URL (and\nMEILI_MASTER_KEY) in `defineAppE2eConfig({ env })` or in the CI env; an\napp that worked around it with `MEILI_URL: \"\"` can drop that override."
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"version": "0.310.0",
|
|
23
|
+
"type": "fix",
|
|
24
|
+
"title": "Screenshot scenario waitFor uses the template timeout budget instead of a fixed 10s",
|
|
25
|
+
"detail": "runScreenshots/runMatrix wait for a scenario's `waitFor` selector with\n`E2E_TIMEOUT_MS.navigation`, or `E2E_TIMEOUT_MS.real` under\nKUMIKO_REAL_PROVIDERS=1, so real-provider screenshot scenarios no longer\ntime out on LLM/OCR latency and apps don't need their own wait timeouts."
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"version": "0.309.0",
|
|
29
|
+
"type": "breaking",
|
|
30
|
+
"title": "reducedMotion defaults to \"reduce\" in runScreenshots/runMatrix/captureScreenshot; captureScreenshot(page, name) added",
|
|
31
|
+
"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/.",
|
|
32
|
+
"migration": "Pass `reducedMotion: \"no-preference\"` in runScreenshots/runMatrix's\noptions, or as captureScreenshot's third argument, for a scenario that\nmust keep real motion."
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"version": "0.309.0",
|
|
36
|
+
"type": "fix",
|
|
37
|
+
"title": "test:real now filters to *.real.test.ts, and real-provider runs share one 240s timeout budget",
|
|
38
|
+
"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."
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"version": "0.309.0",
|
|
42
|
+
"type": "improvement",
|
|
43
|
+
"title": "runMatrix locales are app-injectable via localeTags, and gains a namedDevice mode for phone-style device projects",
|
|
44
|
+
"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."
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"version": "0.309.0",
|
|
48
|
+
"type": "improvement",
|
|
49
|
+
"title": "Scenario flow receives runMatrix's locale; Scenario.captureStyle for screenshot-only CSS",
|
|
50
|
+
"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."
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"version": "0.309.0",
|
|
54
|
+
"type": "improvement",
|
|
55
|
+
"title": "seedTenant accepts an admin identity (displayName/email) for demo and screenshot tenants",
|
|
56
|
+
"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."
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"version": "0.309.0",
|
|
60
|
+
"type": "fix",
|
|
61
|
+
"title": "defineAppE2eConfig's webServer env now defaults the infra service vars, environment wins",
|
|
62
|
+
"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."
|
|
63
|
+
},
|
|
2
64
|
{
|
|
3
65
|
"version": "0.308.0",
|
|
4
66
|
"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.
|
|
@@ -34,3 +43,8 @@ export const SEED_ROUTES = {
|
|
|
34
43
|
seedUser: `${SEED_ROUTE_PREFIX}/seed-user`,
|
|
35
44
|
inbox: `${SEED_ROUTE_PREFIX}/inbox`,
|
|
36
45
|
} as const;
|
|
46
|
+
|
|
47
|
+
// Desktop default for e2e runs and screenshots alike. Playwright's "Desktop
|
|
48
|
+
// Chrome" device ships 1280×720: handbook and doc shots at 1280 visibly soften
|
|
49
|
+
// on a HiDPI display, and a list next to a reading pane looks cramped.
|
|
50
|
+
export const DESKTOP_VIEWPORT = { width: 1920, height: 1080 } as const;
|
|
@@ -8,11 +8,13 @@ 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 {
|
|
13
|
+
DESKTOP_VIEWPORT,
|
|
12
14
|
E2E_WORKERS_ENV,
|
|
15
|
+
isRealProviderRun,
|
|
13
16
|
PLAYWRIGHT_DEMO_ENV,
|
|
14
17
|
PROD_BUNDLES_ENV,
|
|
15
|
-
REAL_PROVIDERS_ENV,
|
|
16
18
|
SEED_ENABLE_ENV,
|
|
17
19
|
SEED_TOKEN_ENV,
|
|
18
20
|
STYLESHEET_WATCH_ENV,
|
|
@@ -20,6 +22,29 @@ import {
|
|
|
20
22
|
import { screenshotSpecsIgnore } from "./screenshot-dir";
|
|
21
23
|
import { E2E_TIMEOUT_MS } from "./timeouts";
|
|
22
24
|
|
|
25
|
+
// Apps pick Meilisearch over their in-memory search adapter just because
|
|
26
|
+
// MEILI_URL is set, so a default here would point every consumer without a
|
|
27
|
+
// Meili service at an unreachable host. Meili stays opt-in: environment or `env`.
|
|
28
|
+
const OPT_IN_SERVICE_ENV_KEYS: readonly string[] = [
|
|
29
|
+
"MEILI_URL",
|
|
30
|
+
"MEILI_MASTER_KEY",
|
|
31
|
+
] satisfies readonly (keyof typeof SERVICE_ENV_DEFAULTS)[];
|
|
32
|
+
|
|
33
|
+
// A `??` merge per key, not a raw process.env spread — CI or the shell
|
|
34
|
+
// environment wins over the local-dev default without leaking unrelated
|
|
35
|
+
// host env vars into the webServer process.
|
|
36
|
+
function infraEnvDefaults(): Record<string, string> {
|
|
37
|
+
return Object.fromEntries(
|
|
38
|
+
Object.entries(SERVICE_ENV_DEFAULTS).flatMap(([key, value]) => {
|
|
39
|
+
const fromEnvironment = process.env[key];
|
|
40
|
+
if (OPT_IN_SERVICE_ENV_KEYS.includes(key)) {
|
|
41
|
+
return fromEnvironment === undefined ? [] : [[key, fromEnvironment]];
|
|
42
|
+
}
|
|
43
|
+
return [[key, fromEnvironment ?? value]];
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
23
48
|
const TEMPLATE_OWNED_PROJECT_KEYS = [
|
|
24
49
|
"fullyParallel",
|
|
25
50
|
"retries",
|
|
@@ -121,7 +146,7 @@ export function defineAppE2eConfig(input: AppE2eConfigInput): PlaywrightTestConf
|
|
|
121
146
|
assertNoReservedEnv(env);
|
|
122
147
|
for (const project of projects) assertTemplateOwnedKeysUnset(project);
|
|
123
148
|
const baseURL = `http://localhost:${port}`;
|
|
124
|
-
const realRun =
|
|
149
|
+
const realRun = isRealProviderRun();
|
|
125
150
|
|
|
126
151
|
return defineConfig({
|
|
127
152
|
testDir,
|
|
@@ -134,7 +159,7 @@ export function defineAppE2eConfig(input: AppE2eConfigInput): PlaywrightTestConf
|
|
|
134
159
|
retries: 0,
|
|
135
160
|
workers: resolveE2eWorkers(),
|
|
136
161
|
reporter: [["list"]],
|
|
137
|
-
timeout: E2E_TIMEOUT_MS.test,
|
|
162
|
+
timeout: realRun ? E2E_TIMEOUT_MS.real : E2E_TIMEOUT_MS.test,
|
|
138
163
|
expect: { timeout: E2E_TIMEOUT_MS.expect },
|
|
139
164
|
use: {
|
|
140
165
|
baseURL,
|
|
@@ -144,11 +169,15 @@ export function defineAppE2eConfig(input: AppE2eConfigInput): PlaywrightTestConf
|
|
|
144
169
|
actionTimeout: E2E_TIMEOUT_MS.action,
|
|
145
170
|
navigationTimeout: E2E_TIMEOUT_MS.navigation,
|
|
146
171
|
},
|
|
147
|
-
projects: [
|
|
172
|
+
projects: [
|
|
173
|
+
{ name: "chromium", use: { ...devices["Desktop Chrome"], viewport: DESKTOP_VIEWPORT } },
|
|
174
|
+
...projects,
|
|
175
|
+
],
|
|
148
176
|
webServer: {
|
|
149
177
|
command: `bun run ${serverEntry}`,
|
|
150
178
|
url: baseURL,
|
|
151
179
|
env: {
|
|
180
|
+
...infraEnvDefaults(),
|
|
152
181
|
...PLAYWRIGHT_DEMO_ENV,
|
|
153
182
|
...env,
|
|
154
183
|
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,9 @@ export {
|
|
|
37
39
|
} from "./screenshot-dir";
|
|
38
40
|
export {
|
|
39
41
|
applyDefaultTheme,
|
|
42
|
+
type CaptureFit,
|
|
43
|
+
type CaptureScreenshotOptions,
|
|
44
|
+
captureScreenshot,
|
|
40
45
|
DEFAULT_THEMES,
|
|
41
46
|
type DefaultThemeId,
|
|
42
47
|
type FlatOptions,
|
|
@@ -45,6 +50,7 @@ export {
|
|
|
45
50
|
runMatrix,
|
|
46
51
|
runScreenshots,
|
|
47
52
|
type Scenario,
|
|
53
|
+
type ScenarioFixtures,
|
|
48
54
|
type ThemeScreenshotDigest,
|
|
49
55
|
validateScenarios,
|
|
50
56
|
} from "./screenshots";
|
package/src/e2e/screenshots.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
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";
|
|
5
|
+
import { DESKTOP_VIEWPORT, isRealProviderRun } from "./constants";
|
|
4
6
|
import { pinEnglishLocale } from "./pin-english-locale";
|
|
5
|
-
import { requireScreenshotDir } from "./screenshot-dir";
|
|
7
|
+
import { requireScreenshotDir, SCREENSHOT_DIR_ENV } from "./screenshot-dir";
|
|
6
8
|
import { type SeedTenantFixture, test } from "./seeded-tenant-fixture";
|
|
9
|
+
import { E2E_TIMEOUT_MS } from "./timeouts";
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_REDUCED_MOTION = "reduce" as const;
|
|
7
12
|
|
|
8
13
|
// runScreenshots: one image per scenario → $SCREENSHOT_DIR/<name>.png.
|
|
9
14
|
// runMatrix: every scenario × locale × theme × viewport in ONE run →
|
|
@@ -27,13 +32,24 @@ export async function applyDefaultTheme(page: Page, theme: DefaultThemeId): Prom
|
|
|
27
32
|
}, theme);
|
|
28
33
|
}
|
|
29
34
|
|
|
35
|
+
export interface ScenarioFixtures {
|
|
36
|
+
readonly seedTenant: SeedTenantFixture;
|
|
37
|
+
// runMatrix's current locale, for apps whose routes carry the locale in the
|
|
38
|
+
// path (kumiko:locale alone can't change the URL). Undefined in runScreenshots.
|
|
39
|
+
readonly locale?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
30
42
|
export interface Scenario {
|
|
31
43
|
readonly name: string;
|
|
32
44
|
readonly description?: string;
|
|
33
45
|
readonly url?: string;
|
|
34
|
-
readonly flow?: (page: Page, fixtures:
|
|
46
|
+
readonly flow?: (page: Page, fixtures: ScenarioFixtures) => Promise<void>;
|
|
35
47
|
readonly waitFor?: string;
|
|
36
48
|
readonly fullPage?: boolean;
|
|
49
|
+
// Playwright's screenshot-only `style`: applied for the capture and removed
|
|
50
|
+
// afterwards, unlike an addStyleTag in beforeCapture that would persist into
|
|
51
|
+
// the next theme × viewport capture.
|
|
52
|
+
readonly captureStyle?: string;
|
|
37
53
|
readonly viewport?: { readonly width: number; readonly height: number };
|
|
38
54
|
// Runs after the viewport is set and the page has settled, right before the
|
|
39
55
|
// screenshot. runMatrix calls this once per theme × viewport combination for
|
|
@@ -100,20 +116,27 @@ async function openScenario(
|
|
|
100
116
|
page: Page,
|
|
101
117
|
s: Scenario,
|
|
102
118
|
inFlightDataRequests: () => number,
|
|
103
|
-
fixtures:
|
|
119
|
+
fixtures: ScenarioFixtures,
|
|
104
120
|
): Promise<void> {
|
|
105
121
|
if (s.flow) await s.flow(page, fixtures);
|
|
106
122
|
else if (s.url) await page.goto(s.url);
|
|
107
123
|
else throw new Error(`Scenario "${s.name}" needs either url or flow`);
|
|
108
124
|
|
|
109
125
|
if (s.waitFor) {
|
|
110
|
-
|
|
126
|
+
// Real-provider scenarios wait on LLM/OCR latency before the page is ready.
|
|
127
|
+
const timeout = isRealProviderRun() ? E2E_TIMEOUT_MS.real : E2E_TIMEOUT_MS.navigation;
|
|
128
|
+
await expect(page.locator(s.waitFor).first()).toBeVisible({ timeout });
|
|
111
129
|
}
|
|
112
130
|
await waitForSettledPage(page, inFlightDataRequests);
|
|
113
131
|
}
|
|
114
132
|
|
|
133
|
+
export type ReducedMotionOption = "reduce" | "no-preference";
|
|
134
|
+
|
|
115
135
|
export interface FlatOptions {
|
|
116
136
|
readonly pinLocale?: boolean;
|
|
137
|
+
// rAF-driven chart tweens etc. are invisible to waitForSettledPage's DOM/scroll
|
|
138
|
+
// fingerprint, so "reduce" is the default — pass "no-preference" to capture motion.
|
|
139
|
+
readonly reducedMotion?: ReducedMotionOption;
|
|
117
140
|
}
|
|
118
141
|
|
|
119
142
|
// Fail at registration time, not mid-run: a url-only scenario with no
|
|
@@ -151,13 +174,20 @@ export function runScreenshots(scenarios: readonly Scenario[], opts: FlatOptions
|
|
|
151
174
|
test(
|
|
152
175
|
s.description ? `${s.name} — ${s.description}` : s.name,
|
|
153
176
|
async ({ page, seedTenant }) => {
|
|
177
|
+
// reducedMotion isn't a PlaywrightTestOptions fixture (test.use can't
|
|
178
|
+
// set it), so it's applied per-page like the rest of emulateMedia.
|
|
179
|
+
await page.emulateMedia({ reducedMotion: opts.reducedMotion ?? DEFAULT_REDUCED_MOTION });
|
|
154
180
|
if (opts.pinLocale) await pinEnglishLocale(page);
|
|
155
181
|
const inFlightDataRequests = countInFlightDataRequests(page);
|
|
156
182
|
if (s.viewport) await page.setViewportSize(s.viewport);
|
|
157
183
|
await openScenario(page, s, inFlightDataRequests, { seedTenant });
|
|
158
184
|
if (s.beforeCapture) await s.beforeCapture(page);
|
|
159
185
|
const path = `${outDir}/${s.name}.png`;
|
|
160
|
-
await page.screenshot({
|
|
186
|
+
await page.screenshot({
|
|
187
|
+
path,
|
|
188
|
+
fullPage: s.fullPage ?? false,
|
|
189
|
+
...(s.captureStyle !== undefined && { style: s.captureStyle }),
|
|
190
|
+
});
|
|
161
191
|
expect.soft(statSync(path).size).toBeGreaterThan(MIN_BYTES);
|
|
162
192
|
},
|
|
163
193
|
);
|
|
@@ -168,11 +198,7 @@ export function runScreenshots(scenarios: readonly Scenario[], opts: FlatOptions
|
|
|
168
198
|
const VIEWPORT_IDS = ["desktop", "tablet", "mobile"] as const;
|
|
169
199
|
export type ViewportId = (typeof VIEWPORT_IDS)[number];
|
|
170
200
|
const VIEWPORTS: Record<ViewportId, { readonly width: number; readonly height: number }> = {
|
|
171
|
-
|
|
172
|
-
// handbook and doc pages, where a 1280 image visibly softens on a HiDPI
|
|
173
|
-
// display. Wider also shows what a two-column layout actually does — at
|
|
174
|
-
// 1280 any list next to a reading pane looks cramped.
|
|
175
|
-
desktop: { width: 1920, height: 1080 },
|
|
201
|
+
desktop: DESKTOP_VIEWPORT,
|
|
176
202
|
// Landscape: portrait tablet shots collapsed two-column layouts into the mobile stack.
|
|
177
203
|
tablet: { width: 1112, height: 834 },
|
|
178
204
|
mobile: { width: 390, height: 844 },
|
|
@@ -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,108 @@ 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
|
+
// "viewport": what the window shows. "fullPage": Playwright's document-height
|
|
465
|
+
// capture, which renders a sticky sidebar floating mid-page. "content": grows the
|
|
466
|
+
// viewport until nothing overflows, including WorkspaceShell's internally
|
|
467
|
+
// scrolling `overflow-auto` containers that document.scrollHeight never sees.
|
|
468
|
+
export type CaptureFit = "viewport" | "fullPage" | "content";
|
|
469
|
+
|
|
470
|
+
export interface CaptureScreenshotOptions {
|
|
471
|
+
readonly reducedMotion?: ReducedMotionOption;
|
|
472
|
+
readonly fit?: CaptureFit;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const CONTENT_FIT_MAX_ROUNDS = 4;
|
|
476
|
+
|
|
477
|
+
// Runs in the browser: the largest vertical overflow of the document or of any
|
|
478
|
+
// visible scrolling container. Serialized into the page, so it may not reference
|
|
479
|
+
// module scope. A 1px container deficit is sub-pixel rounding, not content: an
|
|
480
|
+
// `overflow-x-auto` table wrapper computes overflow-y to `auto` as well and
|
|
481
|
+
// reported scrollHeight one pixel above clientHeight in solon at 1280px, so
|
|
482
|
+
// growth never converged.
|
|
483
|
+
function scrollDeficit(): number {
|
|
484
|
+
const containerDeficits = [...document.querySelectorAll("*")]
|
|
485
|
+
.filter((el) => {
|
|
486
|
+
const style = getComputedStyle(el);
|
|
487
|
+
return (
|
|
488
|
+
(style.overflowY === "auto" || style.overflowY === "scroll") &&
|
|
489
|
+
el.scrollHeight - el.clientHeight > 1 &&
|
|
490
|
+
el.getClientRects().length > 0 &&
|
|
491
|
+
style.visibility !== "hidden"
|
|
492
|
+
);
|
|
493
|
+
})
|
|
494
|
+
.map((el) => el.scrollHeight - el.clientHeight);
|
|
495
|
+
return Math.max(
|
|
496
|
+
0,
|
|
497
|
+
document.documentElement.scrollHeight - window.innerHeight,
|
|
498
|
+
...containerDeficits,
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Growing the viewport re-flows flex-1 regions under an h-svh shell, which can
|
|
503
|
+
// reveal more content, hence rounds. A deficit left after the last round throws
|
|
504
|
+
// instead of writing a silently cropped screenshot.
|
|
505
|
+
async function growViewportToContent(page: Page, name: string, width: number): Promise<void> {
|
|
506
|
+
for (let round = 0; round < CONTENT_FIT_MAX_ROUNDS; round++) {
|
|
507
|
+
const deficit = await page.evaluate(scrollDeficit);
|
|
508
|
+
if (deficit === 0) break;
|
|
509
|
+
const height = page.viewportSize()?.height ?? 0;
|
|
510
|
+
await page.setViewportSize({ width, height: height + deficit });
|
|
511
|
+
}
|
|
512
|
+
const remaining = await page.evaluate(scrollDeficit);
|
|
513
|
+
if (remaining > 0) {
|
|
514
|
+
throw new Error(
|
|
515
|
+
`captureScreenshot(${name}): viewport growth did not converge after ${CONTENT_FIT_MAX_ROUNDS} rounds, ${remaining}px still overflow`,
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Inline mid-flow screenshot for a single point in an existing e2e/real-provider
|
|
521
|
+
// spec (solon's `shot(page, id)`, offlot's inline docs/screenshots/e2e/* writes) —
|
|
522
|
+
// reuses the runner's settle logic and reduced-motion default, and is a no-op
|
|
523
|
+
// when SCREENSHOT_DIR is unset so a plain e2e run never writes into the repo.
|
|
524
|
+
export async function captureScreenshot(
|
|
525
|
+
page: Page,
|
|
526
|
+
name: string,
|
|
527
|
+
opts: CaptureScreenshotOptions = {},
|
|
528
|
+
): Promise<void> {
|
|
529
|
+
const dir = process.env[SCREENSHOT_DIR_ENV];
|
|
530
|
+
// skip: a plain e2e run without SCREENSHOT_DIR must not write screenshots.
|
|
531
|
+
if (dir === undefined || dir === "") return;
|
|
532
|
+
const fit = opts.fit ?? "viewport";
|
|
533
|
+
await page.emulateMedia({ reducedMotion: opts.reducedMotion ?? DEFAULT_REDUCED_MOTION });
|
|
534
|
+
await waitForSettledPage(page, inFlightTrackerFor(page));
|
|
535
|
+
const path = `${dir}/${name}.png`;
|
|
536
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
537
|
+
if (fit === "content") await captureGrownToContent(page, name, path);
|
|
538
|
+
else await page.screenshot({ path, animations: "disabled", fullPage: fit === "fullPage" });
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async function captureGrownToContent(page: Page, name: string, path: string): Promise<void> {
|
|
542
|
+
const viewport = page.viewportSize();
|
|
543
|
+
if (viewport === null) {
|
|
544
|
+
throw new Error(`captureScreenshot(${name}): fit "content" needs a page with a viewport`);
|
|
545
|
+
}
|
|
546
|
+
try {
|
|
547
|
+
await growViewportToContent(page, name, viewport.width);
|
|
548
|
+
await page.screenshot({ path, animations: "disabled" });
|
|
549
|
+
} finally {
|
|
550
|
+
// Later assertions in the same test must run against the original window.
|
|
551
|
+
await page.setViewportSize(viewport);
|
|
552
|
+
}
|
|
553
|
+
}
|
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);
|