@cosmicdrift/kumiko-framework 0.387.0 → 0.388.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (240) hide show
  1. package/AGENTS.md +6 -1
  2. package/agents/examples/anonymous-access.md +1 -1
  3. package/agents/examples/apex-landing.md +204 -0
  4. package/agents/examples/api-worker-split.md +64 -0
  5. package/agents/examples/app-config.md +14 -0
  6. package/agents/examples/auth-mfa.md +115 -0
  7. package/agents/examples/env-composition.md +28 -1
  8. package/agents/examples/legal-pages.md +1 -1
  9. package/agents/examples/prod-boot-options.md +16 -0
  10. package/agents/examples/rate-limiting.md +48 -0
  11. package/agents/examples/record-detail-layout.md +692 -0
  12. package/agents/examples/seed-migration.md +212 -0
  13. package/agents/examples/wizard-form.md +941 -0
  14. package/agents/examples/writeform-section.md +548 -0
  15. package/agents/features.md +19 -2
  16. package/agents/widgets.md +1 -0
  17. package/dist/api/client-error-route.js +8 -2
  18. package/dist/api/index.d.ts +3 -1
  19. package/dist/api/index.js +3 -1
  20. package/dist/api/request-context.d.ts +1 -1
  21. package/dist/api/request-context.js +1 -1
  22. package/dist/api/server-boot-checks.d.ts +10 -0
  23. package/dist/api/server-boot-checks.js +157 -0
  24. package/dist/api/server-event-dispatcher.d.ts +26 -0
  25. package/dist/api/server-event-dispatcher.js +208 -0
  26. package/dist/api/server-extra-routes.d.ts +11 -0
  27. package/dist/api/server-extra-routes.js +203 -0
  28. package/dist/api/server-middleware.d.ts +20 -0
  29. package/dist/api/server-middleware.js +211 -0
  30. package/dist/api/server-routes.d.ts +19 -0
  31. package/dist/api/server-routes.js +293 -0
  32. package/dist/api/server-websocket-routes.d.ts +4 -0
  33. package/dist/api/server-websocket-routes.js +108 -0
  34. package/dist/api/server-wiring.d.ts +32 -0
  35. package/dist/api/server-wiring.js +236 -0
  36. package/dist/api/server.d.ts +10 -15
  37. package/dist/api/server.js +48 -1251
  38. package/dist/api/session-cookies.js +4 -4
  39. package/dist/api/sse-route.js +16 -12
  40. package/dist/crypto/owner-transition-rekey.d.ts +5 -1
  41. package/dist/crypto/owner-transition-rekey.js +1 -1
  42. package/dist/crypto/pii-field-encryption.d.ts +5 -1
  43. package/dist/crypto/pii-field-encryption.js +3 -1
  44. package/dist/db/aggregate-sql.d.ts +21 -0
  45. package/dist/db/aggregate-sql.js +155 -0
  46. package/dist/db/closed-connection.d.ts +9 -0
  47. package/dist/db/closed-connection.js +95 -0
  48. package/dist/db/dialect.js +1 -1
  49. package/dist/db/distinct-on.d.ts +1 -1
  50. package/dist/db/driver-row-coercion.d.ts +2 -0
  51. package/dist/db/driver-row-coercion.js +186 -0
  52. package/dist/db/driver-value-preparation.d.ts +16 -0
  53. package/dist/db/driver-value-preparation.js +99 -0
  54. package/dist/db/entity-field-encryption.d.ts +1 -0
  55. package/dist/db/entity-field-encryption.js +1 -1
  56. package/dist/db/entity-table-meta.js +103 -110
  57. package/dist/db/event-store-executor-context.d.ts +2 -1
  58. package/dist/db/event-store-executor-read-aggregate.d.ts +8 -0
  59. package/dist/db/event-store-executor-read-aggregate.js +112 -0
  60. package/dist/db/event-store-executor-read-filter.d.ts +31 -0
  61. package/dist/db/event-store-executor-read-filter.js +203 -0
  62. package/dist/db/event-store-executor-read-paging.d.ts +14 -0
  63. package/dist/db/event-store-executor-read-paging.js +99 -0
  64. package/dist/db/event-store-executor-read-rows.d.ts +6 -0
  65. package/dist/db/event-store-executor-read-rows.js +32 -0
  66. package/dist/db/event-store-executor-read-search.d.ts +28 -0
  67. package/dist/db/event-store-executor-read-search.js +221 -0
  68. package/dist/db/event-store-executor-read-sort.d.ts +41 -0
  69. package/dist/db/event-store-executor-read-sort.js +130 -0
  70. package/dist/db/event-store-executor-read-where.d.ts +24 -0
  71. package/dist/db/event-store-executor-read-where.js +81 -0
  72. package/dist/db/event-store-executor-read.d.ts +2 -9
  73. package/dist/db/event-store-executor-read.js +48 -701
  74. package/dist/db/event-store-executor-write-steps.d.ts +20 -0
  75. package/dist/db/event-store-executor-write-steps.js +100 -0
  76. package/dist/db/event-store-executor-write.js +395 -499
  77. package/dist/db/index.d.ts +6 -2
  78. package/dist/db/index.js +5 -1
  79. package/dist/db/list-aggregate-labels.d.ts +14 -0
  80. package/dist/db/list-aggregate-labels.js +26 -0
  81. package/dist/db/migrate-generator.js +71 -86
  82. package/dist/db/migrate-runner.js +4 -1
  83. package/dist/db/query.d.ts +2 -15
  84. package/dist/db/query.js +8 -761
  85. package/dist/db/reference-data.js +6 -1
  86. package/dist/db/table-builder.js +113 -166
  87. package/dist/db/table-info.d.ts +10 -0
  88. package/dist/db/table-info.js +45 -0
  89. package/dist/db/temporal-value-guards.d.ts +6 -0
  90. package/dist/db/temporal-value-guards.js +15 -0
  91. package/dist/db/tenant-db-runner.d.ts +1 -0
  92. package/dist/db/tenant-db-runner.js +3 -0
  93. package/dist/db/tenant-db.js +39 -9
  94. package/dist/db/where-clause.d.ts +6 -0
  95. package/dist/db/where-clause.js +236 -0
  96. package/dist/derivatives/delete-stored-file.js +2 -1
  97. package/dist/engine/boot-validator/boot-check.js +1 -1
  98. package/dist/engine/boot-validator/detail-screens.js +2 -2
  99. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  100. package/dist/engine/boot-validator/entity-handler.js +17 -0
  101. package/dist/engine/boot-validator/form-derivations.js +35 -1
  102. package/dist/engine/boot-validator/i18n-keys.js +5 -3
  103. package/dist/engine/boot-validator/index.js +2 -0
  104. package/dist/engine/boot-validator/pii-retention.js +233 -155
  105. package/dist/engine/boot-validator/screens-actions.d.ts +1 -0
  106. package/dist/engine/boot-validator/screens-actions.js +8 -1
  107. package/dist/engine/boot-validator/screens-dashboard.js +2 -5
  108. package/dist/engine/boot-validator/screens-forms.js +2 -6
  109. package/dist/engine/boot-validator/screens-kind-config-edit.d.ts +3 -0
  110. package/dist/engine/boot-validator/screens-kind-config-edit.js +88 -0
  111. package/dist/engine/boot-validator/screens-kind-entity-edit.d.ts +3 -0
  112. package/dist/engine/boot-validator/screens-kind-entity-edit.js +55 -0
  113. package/dist/engine/boot-validator/screens-kind-entity-list.d.ts +3 -0
  114. package/dist/engine/boot-validator/screens-kind-entity-list.js +228 -0
  115. package/dist/engine/boot-validator/screens-kind-projection-detail.d.ts +3 -0
  116. package/dist/engine/boot-validator/screens-kind-projection-detail.js +235 -0
  117. package/dist/engine/boot-validator/screens-kind-projection-list.d.ts +3 -0
  118. package/dist/engine/boot-validator/screens-kind-projection-list.js +52 -0
  119. package/dist/engine/boot-validator/screens-kind-secret-mint.d.ts +3 -0
  120. package/dist/engine/boot-validator/screens-kind-secret-mint.js +28 -0
  121. package/dist/engine/boot-validator/screens-kind-shared.d.ts +37 -0
  122. package/dist/engine/boot-validator/screens-kind-shared.js +142 -0
  123. package/dist/engine/boot-validator/screens-lists.d.ts +2 -1
  124. package/dist/engine/boot-validator/screens-lists.js +12 -9
  125. package/dist/engine/boot-validator/screens.d.ts +0 -2
  126. package/dist/engine/boot-validator/screens.js +99 -860
  127. package/dist/engine/build-app-schema.js +56 -96
  128. package/dist/engine/define-feature.js +9 -2
  129. package/dist/engine/define-handler.js +1 -0
  130. package/dist/engine/entity-handlers.d.ts +13 -0
  131. package/dist/engine/entity-handlers.js +10 -21
  132. package/dist/engine/entity-list-runtime-options.d.ts +8 -0
  133. package/dist/engine/entity-list-runtime-options.js +18 -0
  134. package/dist/engine/feature-ast/extractors/handlers.js +1 -0
  135. package/dist/engine/feature-ast/extractors/round2.js +25 -6
  136. package/dist/engine/feature-ast/patch.js +4 -182
  137. package/dist/engine/feature-ast/pattern-change-schema.d.ts +3 -0
  138. package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
  139. package/dist/engine/feature-ast/pattern-id-matchers.d.ts +7 -0
  140. package/dist/engine/feature-ast/pattern-id-matchers.js +210 -0
  141. package/dist/engine/feature-ast/patterns.d.ts +9 -0
  142. package/dist/engine/feature-ast/patterns.js +43 -51
  143. package/dist/engine/feature-ast/render.js +48 -87
  144. package/dist/engine/feature-entity-handlers.js +3 -0
  145. package/dist/engine/form-derivations.js +18 -7
  146. package/dist/engine/handler-field-references.js +1 -1
  147. package/dist/engine/project-app-schema-for-roles.d.ts +2 -0
  148. package/dist/engine/project-app-schema-for-roles.js +6 -4
  149. package/dist/engine/registry-ingest.js +1 -1
  150. package/dist/engine/schema-builder.js +228 -212
  151. package/dist/engine/screen-helpers.js +1 -1
  152. package/dist/engine/screen-kind.d.ts +7 -0
  153. package/dist/engine/screen-kind.js +1 -0
  154. package/dist/engine/strip-disabled-features-from-app-schema.js +51 -14
  155. package/dist/engine/validate-projection-allowlist.js +2 -1
  156. package/dist/entrypoint/index.d.ts +4 -1
  157. package/dist/entrypoint/index.js +7 -1
  158. package/dist/errors/docs-url.d.ts +1 -1
  159. package/dist/errors/docs-url.js +22 -2
  160. package/dist/es-ops/context.js +1 -1
  161. package/dist/event-store/admin-api.js +1 -1
  162. package/dist/event-store/event-store.js +10 -4
  163. package/dist/files/bind-pending-file-refs.js +3 -3
  164. package/dist/files/create-file-ref-from-bytes.js +4 -1
  165. package/dist/files/file-handle.js +2 -3
  166. package/dist/files/file-routes.js +11 -8
  167. package/dist/files/resolve-content-type.js +43 -0
  168. package/dist/files/upload-ledger.js +7 -0
  169. package/dist/i18n/required-surface-keys.js +182 -206
  170. package/dist/internal/stack-support.d.ts +2 -1
  171. package/dist/internal/stack-support.js +1 -1
  172. package/dist/jobs/job-runner-context.d.ts +32 -0
  173. package/dist/jobs/job-runner-context.js +305 -0
  174. package/dist/jobs/job-runner-execution.d.ts +7 -0
  175. package/dist/jobs/job-runner-execution.js +160 -0
  176. package/dist/jobs/job-runner-gates.d.ts +19 -0
  177. package/dist/jobs/job-runner-gates.js +198 -0
  178. package/dist/jobs/job-runner-helpers.d.ts +17 -3
  179. package/dist/jobs/job-runner-helpers.js +61 -2
  180. package/dist/jobs/job-runner-scope.d.ts +44 -0
  181. package/dist/jobs/job-runner-scope.js +102 -0
  182. package/dist/jobs/job-runner-types.d.ts +12 -0
  183. package/dist/jobs/job-runner.d.ts +0 -1
  184. package/dist/jobs/job-runner.js +47 -657
  185. package/dist/observability/escape-hatch-report.d.ts +3 -0
  186. package/dist/observability/escape-hatch-report.js +12 -1
  187. package/dist/pipeline/cascade-handler.js +165 -138
  188. package/dist/pipeline/dispatch-query.js +10 -3
  189. package/dist/pipeline/dispatch-shared.d.ts +13 -3
  190. package/dist/pipeline/dispatch-shared.js +58 -710
  191. package/dist/pipeline/dispatch-write.js +296 -189
  192. package/dist/pipeline/event-dispatcher-admin.js +2 -1
  193. package/dist/pipeline/event-dispatcher-delivery.js +3 -5
  194. package/dist/pipeline/handler-context-auth.d.ts +9 -0
  195. package/dist/pipeline/handler-context-auth.js +54 -0
  196. package/dist/pipeline/handler-context-config.d.ts +14 -0
  197. package/dist/pipeline/handler-context-config.js +113 -0
  198. package/dist/pipeline/handler-context-db.d.ts +24 -0
  199. package/dist/pipeline/handler-context-db.js +149 -0
  200. package/dist/pipeline/handler-context-dispatch.d.ts +21 -0
  201. package/dist/pipeline/handler-context-dispatch.js +89 -0
  202. package/dist/pipeline/handler-context-events.d.ts +43 -0
  203. package/dist/pipeline/handler-context-events.js +237 -0
  204. package/dist/pipeline/handler-context-notify.d.ts +12 -0
  205. package/dist/pipeline/handler-context-notify.js +55 -0
  206. package/dist/pipeline/handler-context-observability.d.ts +7 -0
  207. package/dist/pipeline/handler-context-observability.js +21 -0
  208. package/dist/pipeline/handler-context-scope.d.ts +48 -0
  209. package/dist/pipeline/handler-context-scope.js +88 -0
  210. package/dist/pipeline/notify-scope.js +12 -1
  211. package/dist/pipeline/pre-handler-gates.js +20 -4
  212. package/dist/pipeline/pre-transaction-reservations.d.ts +1 -0
  213. package/dist/pipeline/pre-transaction-reservations.js +9 -3
  214. package/dist/pipeline/projection-rebuild.js +2 -1
  215. package/dist/schema-cli/apply-command.d.ts +2 -0
  216. package/dist/schema-cli/apply-command.js +97 -0
  217. package/dist/schema-cli/baseline-command.d.ts +2 -0
  218. package/dist/schema-cli/baseline-command.js +33 -0
  219. package/dist/schema-cli/generate-command.d.ts +2 -0
  220. package/dist/schema-cli/generate-command.js +90 -0
  221. package/dist/schema-cli/shared.d.ts +39 -0
  222. package/dist/schema-cli/shared.js +95 -0
  223. package/dist/schema-cli/status-command.d.ts +2 -0
  224. package/dist/schema-cli/status-command.js +73 -0
  225. package/dist/schema-cli/validate-boot-input.d.ts +5 -0
  226. package/dist/schema-cli/validate-boot-input.js +130 -0
  227. package/dist/schema-cli/validate-command.d.ts +2 -0
  228. package/dist/schema-cli/validate-command.js +130 -0
  229. package/dist/schema-cli.d.ts +2 -18
  230. package/dist/schema-cli.js +47 -604
  231. package/dist/search/meilisearch-adapter.js +2 -1
  232. package/dist/upgrade-cli.js +149 -121
  233. package/package.json +4 -4
  234. package/src/changes.json +310 -0
  235. package/src/scripts/codemod/feature-layout.ts +10 -2
  236. package/src/scripts/codemod/object-form.ts +41 -4
  237. package/src/scripts/codemod/package-merge.ts +58 -8
  238. package/src/scripts/codemod/pii-personal-migration.ts +175 -145
  239. package/src/scripts/codemod/testing-injection.ts +122 -42
  240. package/src/scripts/codemod/testing-move.ts +29 -7
@@ -34,6 +34,698 @@ bun test
34
34
 
35
35
  ## Source files
36
36
 
37
+ ### e2e/build-server.ts
38
+
39
+ ```ts
40
+ // Tiny Bun server that builds and serves the e2e bundle — same shape as
41
+ // writeform-section/e2e/build-server.ts, radically stripped down (no
42
+ // Postgres, no auth). The MockDispatcher (fixtures/mock-dispatcher.ts)
43
+ // replaces the whole stack.
44
+ //
45
+ // The AppSchema is NOT duplicated in the client bundle (drift risk against
46
+ // the real feature code). Instead this server process builds the schema
47
+ // from the real src/feature.ts via createRegistry + buildAppSchema (both
48
+ // pure, no DB/HTTP) and serves it as JSON over /schema.json — client.tsx
49
+ // fetches it on boot.
50
+ //
51
+ // Port 4190: 4172-4189 are taken by other samples/apps (see their
52
+ // playwright.config.ts/package.json), 4190 is free.
53
+ //
54
+ // /styles.css is REALLY compiled from Tailwind (not stubbed) — this recipe
55
+ // exists to prove a real box-model fix (fw#2778: a relatedList tab panel
56
+ // must size to its content, not stretch to fill). Without real CSS the
57
+ // getBoundingClientRect() measurements in height.spec.ts would be against
58
+ // unstyled browser default flow, not the actual flex chain under test.
59
+ // Entry stylesheet = fixtures/styles.css, a local wrapper that re-exports
60
+ // renderer/web's styles.css and adds an @source covering this e2e dir.
61
+ //
62
+ // One-shot build, no watcher (the server only runs for the test duration)
63
+ // — dies hard on a Tailwind CLI/build failure instead of degrading to an
64
+ // empty stylesheet and letting the layout assertions pass vacuously.
65
+
66
+ import { mkdtempSync, rmSync } from "node:fs";
67
+ import { tmpdir } from "node:os";
68
+ import { join, resolve } from "node:path";
69
+ import { buildAppSchema, createRegistry } from "@cosmicdrift/kumiko-framework/engine";
70
+ import {
71
+ canResolveTailwindStylesheet,
72
+ resolveTailwindCli,
73
+ } from "@cosmicdrift/kumiko-server-runtime/resolve-tailwind-cli";
74
+ import { createOrderDeskFeature } from "../src/feature";
75
+
76
+ const HERE = resolve(import.meta.dir);
77
+ const ENTRY = resolve(HERE, "fixtures/client.tsx");
78
+ const HTML_PATH = resolve(HERE, "fixtures/index.html");
79
+
80
+ async function buildStylesheet(): Promise<string> {
81
+ const cliPath = resolveTailwindCli({ bun: Bun, cwd: HERE });
82
+ if (cliPath === undefined) {
83
+ throw new Error(
84
+ "record-detail-layout/e2e: @tailwindcss/cli not resolvable — run `bun install` at the repo root.",
85
+ );
86
+ }
87
+ const entryCss = resolve(HERE, "fixtures/styles.css");
88
+ if (!canResolveTailwindStylesheet(entryCss, { bun: Bun, cwd: HERE })) {
89
+ throw new Error(
90
+ `record-detail-layout/e2e: tailwindcss not resolvable for ${entryCss} — peer dependency missing at the stylesheet's location.`,
91
+ );
92
+ }
93
+ const outDir = mkdtempSync(join(tmpdir(), "record-detail-layout-e2e-tw-"));
94
+ try {
95
+ const outPath = join(outDir, "styles.css");
96
+ const bunBin = process.argv[0] ?? "bun";
97
+ const build = Bun.spawnSync([bunBin, "run", cliPath, "-i", entryCss, "-o", outPath], {
98
+ cwd: HERE,
99
+ });
100
+ if (!build.success) {
101
+ throw new Error(
102
+ `record-detail-layout/e2e: tailwind build failed (exit ${build.exitCode})\n${build.stderr.toString()}`,
103
+ );
104
+ }
105
+ return await Bun.file(outPath).text();
106
+ } finally {
107
+ rmSync(outDir, { recursive: true, force: true });
108
+ }
109
+ }
110
+
111
+ const css = await buildStylesheet();
112
+
113
+ const port = Number(process.env["PORT"] ?? 4190);
114
+
115
+ const registry = createRegistry([createOrderDeskFeature()]);
116
+ const schema = buildAppSchema(registry);
117
+ const schemaJson = JSON.stringify(schema);
118
+
119
+ const built = await Bun.build({
120
+ entrypoints: [ENTRY],
121
+ target: "browser",
122
+ sourcemap: "linked",
123
+ });
124
+
125
+ if (!built.success) {
126
+ // biome-ignore lint/suspicious/noConsole: e2e build script — no logger wired
127
+ for (const log of built.logs) console.error(log);
128
+ throw new Error("record-detail-layout/e2e: client bundle failed");
129
+ }
130
+
131
+ const jsOutput = built.outputs.find((o) => o.path.endsWith(".js"));
132
+ const mapOutput = built.outputs.find((o) => o.path.endsWith(".js.map"));
133
+ if (!jsOutput) throw new Error("record-detail-layout/e2e: bundle has no .js output");
134
+
135
+ const js = await jsOutput.text();
136
+ const map = mapOutput ? await mapOutput.text() : "";
137
+ const html = await Bun.file(HTML_PATH).text();
138
+
139
+ Bun.serve({
140
+ port,
141
+ fetch(req: Request): Response {
142
+ const url = new URL(req.url);
143
+ if (url.pathname === "/client.js") {
144
+ return new Response(js, {
145
+ headers: { "Content-Type": "application/javascript; charset=utf-8" },
146
+ });
147
+ }
148
+ if (url.pathname === "/client.js.map") {
149
+ return new Response(map, { headers: { "Content-Type": "application/json; charset=utf-8" } });
150
+ }
151
+ if (url.pathname === "/schema.json") {
152
+ return new Response(schemaJson, {
153
+ headers: { "Content-Type": "application/json; charset=utf-8" },
154
+ });
155
+ }
156
+ if (url.pathname === "/styles.css") {
157
+ return new Response(css, { headers: { "Content-Type": "text/css; charset=utf-8" } });
158
+ }
159
+ if (url.pathname === "/api/sse" || url.pathname.startsWith("/sse")) {
160
+ const stream = new ReadableStream<Uint8Array>({
161
+ start(controller) {
162
+ controller.enqueue(new TextEncoder().encode(": connected\n\n"));
163
+ },
164
+ });
165
+ return new Response(stream, {
166
+ headers: {
167
+ "Content-Type": "text/event-stream",
168
+ "Cache-Control": "no-cache",
169
+ Connection: "keep-alive",
170
+ },
171
+ });
172
+ }
173
+ return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
174
+ },
175
+ });
176
+
177
+ // biome-ignore lint/suspicious/noConsole: e2e build script — no logger wired
178
+ console.log(`record-detail-layout/e2e build-server listening on http://localhost:${port}`);
179
+ ```
180
+
181
+ ### e2e/fixtures/client.tsx
182
+
183
+ ```tsx
184
+ // Browser entry for record-detail-layout/e2e (fw#2778). Fetches the
185
+ // server-built AppSchema (built from the real src/feature.ts, see
186
+ // build-server.ts) and mounts createKumikoApp with the MockDispatcher.
187
+ //
188
+ // Pattern copied from writeform-section/e2e/fixtures/client.tsx: the shell
189
+ // renders its own explicitly-keyed KumikoScreen (entityId="order-1") instead
190
+ // of createKumikoApp's routed `children` — that's the only way to pin a
191
+ // projectionDetail screen's entityId without forking the URL-routing
192
+ // internals. `screenQn` is still required (createKumikoApp throws without a
193
+ // resolvable initial route), but the routed content it produces is unused.
194
+ //
195
+ // DefaultAppShell wraps the screen in the same viewport-height chrome
196
+ // (`h-svh` shell, `main` scrolls internally) a real app gives it — the
197
+ // fillHeight/scrollBody chain under test only resolves against a genuinely
198
+ // height-constrained ancestor, not document-flow. `user` grants the
199
+ // "Admin" role order-detail's screen access requires.
200
+ //
201
+ // order-detail is a projectionDetail screen in `layout.mode: "tabs"` — the
202
+ // first tab ("items", a relatedList section) is active by default (no `tab`
203
+ // search param), which is exactly the fw#2778 scenario: a lone relatedList
204
+ // tab that must size to its content when short and scroll internally when
205
+ // long, instead of stretching the tab panel to the bottom.
206
+
207
+ import type { AppSchema } from "@cosmicdrift/kumiko-renderer";
208
+ import { ExtensionSectionsProvider } from "@cosmicdrift/kumiko-renderer";
209
+ import { createKumikoApp, DefaultAppShell, KumikoScreen } from "@cosmicdrift/kumiko-renderer/web";
210
+ import type { ReactNode } from "react";
211
+ import { createMockDispatcher } from "./mock-dispatcher";
212
+
213
+ const ORDER_DETAIL_SCREEN_QN = "order-desk:screen:order-detail";
214
+
215
+ // Plugin-mounted "extension" tab (fw#3234 padding-parity screenshots) — no
216
+ // own Card/Section, exactly what guard-no-framed-extension-sections
217
+ // enforces: the host's own tabs-mode Card is the only frame this gets.
218
+ function OrderInternalNote(): ReactNode {
219
+ const tallPx = Number.parseInt(
220
+ new URLSearchParams(window.location.search).get("noteHeight") ?? "",
221
+ 10,
222
+ );
223
+ return (
224
+ <div>
225
+ <p data-testid="order-internal-note">
226
+ Handled by Jonas Weber, escalate if unpaid past Sep 1.
227
+ </p>
228
+ {Number.isFinite(tallPx) && tallPx > 0 && (
229
+ <>
230
+ <div style={{ height: tallPx }} />
231
+ <p data-testid="order-internal-note-end">End of note</p>
232
+ </>
233
+ )}
234
+ </div>
235
+ );
236
+ }
237
+
238
+ function Shell({
239
+ schema,
240
+ }: {
241
+ readonly children: ReactNode;
242
+ readonly schema: AppSchema;
243
+ }): ReactNode {
244
+ const feature = schema.features[0];
245
+ // KumikoScreen sits directly under DefaultAppShell's <main> — same as the
246
+ // real RoutedScreen (create-app.tsx) — so RenderEdit's `fillHeight` chain
247
+ // (h-full all the way down) resolves against `main`'s own, genuinely
248
+ // height-constrained box. A wrapper div here without its own h-full would
249
+ // break that percentage-height chain at the very first link.
250
+ return (
251
+ <DefaultAppShell
252
+ brand={<span>Order Desk</span>}
253
+ schema={schema}
254
+ user={{ id: "u1", roles: ["Admin"] }}
255
+ >
256
+ {feature !== undefined && (
257
+ <ExtensionSectionsProvider value={{ OrderInternalNote }}>
258
+ <KumikoScreen schema={feature} qn={ORDER_DETAIL_SCREEN_QN} entityId="order-1" />
259
+ </ExtensionSectionsProvider>
260
+ )}
261
+ </DefaultAppShell>
262
+ );
263
+ }
264
+
265
+ async function boot(): Promise<void> {
266
+ const root = document.getElementById("root");
267
+ if (root === null) throw new Error("record-detail-layout/e2e: #root not found");
268
+
269
+ const response = await fetch("/schema.json");
270
+ if (!response.ok) {
271
+ throw new Error(`record-detail-layout/e2e: /schema.json fetch failed with ${response.status}`);
272
+ }
273
+ const schema = (await response.json()) as AppSchema;
274
+
275
+ createKumikoApp({
276
+ schema,
277
+ dispatcher: createMockDispatcher(),
278
+ shell: Shell,
279
+ screenQn: ORDER_DETAIL_SCREEN_QN,
280
+ });
281
+ }
282
+
283
+ boot().catch((error: unknown) => {
284
+ const root = document.getElementById("root");
285
+ if (root !== null) {
286
+ root.textContent = `record-detail-layout/e2e: boot failed — ${String(error)}`;
287
+ }
288
+ throw error;
289
+ });
290
+ ```
291
+
292
+ ### e2e/fixtures/mock-dispatcher.ts
293
+
294
+ ```ts
295
+ // In-memory dispatcher for record-detail-layout/e2e (fw#2778). Same shape as
296
+ // writeform-section/e2e/fixtures/mock-dispatcher.ts — implements only the
297
+ // three query QNs order-desk's projectionDetail screen needs:
298
+ //
299
+ // - "order-desk:query:order:detail" — the screen's own header/metrics row.
300
+ // - "order-desk:query:order:items" — the "items" relatedList tab.
301
+ // - "order-desk:query:order:payments" — the "payments" relatedList tab.
302
+ //
303
+ // The "items" row count is driven by the page's own `?items=<n>` URL param
304
+ // (read at query time, not baked into the fixture) so the same server can
305
+ // back both the short-list and long-list layout measurements the
306
+ // fw#2778 fix needs — a fixed row count would need two fixtures.
307
+
308
+ import type {
309
+ BatchResult,
310
+ Command,
311
+ Dispatcher,
312
+ DispatcherStatus,
313
+ PendingFile,
314
+ PendingWrite,
315
+ QueryOpts,
316
+ QueryResult,
317
+ Store,
318
+ WriteOpts,
319
+ WriteResult,
320
+ } from "@cosmicdrift/kumiko-renderer/headless";
321
+
322
+ const QUERY_ORDER_DETAIL = "order-desk:query:order:detail";
323
+ const QUERY_ORDER_ITEMS = "order-desk:query:order:items";
324
+ const QUERY_ORDER_PAYMENTS = "order-desk:query:order:payments";
325
+
326
+ const ORDER_DETAIL_FIELDS = {
327
+ customerName: "Aiko Tanaka",
328
+ orderNumber: "SO-10482",
329
+ status: "processing",
330
+ totalAmount: "€ 1,248.00",
331
+ outstandingAmount: "€ 312.00",
332
+ itemCount: "3",
333
+ placedAt: "2026-08-21",
334
+ };
335
+
336
+ function itemsCountFromUrl(): number {
337
+ const raw = new URLSearchParams(window.location.search).get("items");
338
+ const parsed = raw === null ? Number.NaN : Number.parseInt(raw, 10);
339
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 3;
340
+ }
341
+
342
+ function buildItemRows(count: number): Array<Record<string, unknown>> {
343
+ return Array.from({ length: count }, (_, i) => ({
344
+ sku: `KB-${100 + i}`,
345
+ description: `Item ${i + 1}`,
346
+ quantity: 1,
347
+ unitPrice: 10 + i,
348
+ }));
349
+ }
350
+
351
+ const ONLINE_STORE: Store<DispatcherStatus> = {
352
+ getSnapshot: () => "online",
353
+ subscribe: () => () => {},
354
+ };
355
+
356
+ export function createMockDispatcher(): Dispatcher {
357
+ async function write<TData = unknown>(type: string): Promise<WriteResult<TData>> {
358
+ throw new Error(`mock-dispatcher: unsupported write qn "${type}"`);
359
+ }
360
+
361
+ async function query<TData = unknown>(
362
+ type: string,
363
+ payload: unknown,
364
+ _opts?: QueryOpts,
365
+ ): Promise<QueryResult<TData>> {
366
+ if (type === QUERY_ORDER_DETAIL) {
367
+ const data = (payload ?? {}) as Record<string, unknown>; // @cast-boundary mock-dispatcher wire payload
368
+ const id = (data["id"] as string | undefined) ?? "order-1";
369
+ return { isSuccess: true, data: { id, ...ORDER_DETAIL_FIELDS } as unknown as TData };
370
+ }
371
+ if (type === QUERY_ORDER_ITEMS) {
372
+ return { isSuccess: true, data: { rows: buildItemRows(itemsCountFromUrl()) } as TData };
373
+ }
374
+ if (type === QUERY_ORDER_PAYMENTS) {
375
+ return {
376
+ isSuccess: true,
377
+ data: {
378
+ rows: [
379
+ { paidAt: "2026-08-21", amount: 936, method: "card", status: "settled" },
380
+ { paidAt: "2026-08-25", amount: 312, method: "invoice", status: "pending" },
381
+ ],
382
+ } as TData,
383
+ };
384
+ }
385
+ throw new Error(`mock-dispatcher: unsupported query qn "${type}"`);
386
+ }
387
+
388
+ async function batch(commands: readonly Command[], opts?: WriteOpts): Promise<BatchResult> {
389
+ const results: WriteResult[] = [];
390
+ for (const cmd of commands) results.push(await write(cmd.type, cmd.payload, opts));
391
+ return { isSuccess: true, results };
392
+ }
393
+
394
+ return {
395
+ write,
396
+ query,
397
+ batch,
398
+ statusStore: ONLINE_STORE,
399
+ async *stream() {},
400
+ pendingWrites: (): readonly PendingWrite[] => [],
401
+ pendingFiles: (): readonly PendingFile[] => [],
402
+ };
403
+ }
404
+ ```
405
+
406
+ ### e2e/height.spec.ts
407
+
408
+ ```ts
409
+ // Tab-panel height spec (fw#2778): a relatedList tab in a projectionDetail
410
+ // "Akte" (order-detail's default "items" tab) must size to its content when
411
+ // the list is short, and cap at the panel's available height (scrolling
412
+ // internally) when the list is long — never stretch the card to the bottom
413
+ // of the panel regardless of row count. No hardcoded pixel positions: every
414
+ // assertion compares the card's live box model against `main`'s (the real,
415
+ // viewport-height-constrained ancestor from DefaultAppShell) and against
416
+ // itself across the two row-count scenarios.
417
+ //
418
+ // `?items=<n>` (read by fixtures/mock-dispatcher.ts) switches between the
419
+ // short-list and long-list scenario without needing two fixtures.
420
+
421
+ import { expect, boundaryGuardedTest as test } from "@cosmicdrift/kumiko-testing/e2e";
422
+ import type { Page } from "@playwright/test";
423
+
424
+ async function gotoOrderDetail(page: Page, items: number): Promise<void> {
425
+ await page.goto(`/?items=${items}`);
426
+ await expect(page.getByTestId("render-edit-form")).toBeVisible();
427
+ await expect(page.getByTestId("render-list-table")).toBeVisible();
428
+ }
429
+
430
+ function mainLocator(page: Page) {
431
+ return page.locator("main").first();
432
+ }
433
+
434
+ // Structural, not class-name-coupled: FormScreenShell renders exactly one
435
+ // wrapping div holding [headerRegion-wrapper?, card] as direct children —
436
+ // the card (cardSurface()) is always the last of those (see DefaultForm in
437
+ // packages/renderer/src/web/primitives/index.tsx).
438
+ function cardLocator(page: Page) {
439
+ return page.locator('[data-testid="render-edit-form"] > div > div').last();
440
+ }
441
+
442
+ function tabsLocator(page: Page) {
443
+ return page.getByTestId("kumiko-screen-projection-detail-tabs");
444
+ }
445
+
446
+ test.describe("record-detail-layout — tab-panel height (fw#2778)", () => {
447
+ // The design refresh (#3381) made detail tabs a full-bleed "board": the
448
+ // relatedList scroll region fills the panel down to the footer instead of
449
+ // the card sizing to its rows (fw#2778's original behavior). What must
450
+ // still hold: the rows themselves keep their natural height, and the
451
+ // region never exceeds the panel.
452
+ test("short list: the scroll region fills the panel while the table keeps its content height", async ({
453
+ page,
454
+ }) => {
455
+ await gotoOrderDetail(page, 3);
456
+ const main = await mainLocator(page).boundingBox();
457
+ const region = await page.getByTestId("render-list-table-scroll").boundingBox();
458
+ const table = await page.getByTestId("render-list-table").boundingBox();
459
+ if (main === null || region === null || table === null) throw new Error("missing bounding box");
460
+ expect(region.y + region.height).toBeLessThanOrEqual(main.y + main.height + 4);
461
+ expect(table.height).toBeLessThan(region.height);
462
+ expect(table.height).toBeLessThan(main.height * 0.4);
463
+ await expect(page.getByTestId("render-list-table-footer")).toBeVisible();
464
+ });
465
+
466
+ test("long list: the tab-panel card is capped at the panel's available height and the table scrolls internally", async ({
467
+ page,
468
+ }) => {
469
+ await gotoOrderDetail(page, 60);
470
+ const main = await mainLocator(page).boundingBox();
471
+ const card = await cardLocator(page).boundingBox();
472
+ if (main === null || card === null) throw new Error("missing bounding box");
473
+ // Card never exceeds the panel height it's confined to (small tolerance
474
+ // for the header card's own margin above it).
475
+ expect(card.height).toBeLessThanOrEqual(main.height + 4);
476
+
477
+ // The document itself does not grow with the row count — the table
478
+ // scrolls INSIDE the card, the page never gets a body scrollbar.
479
+ const scrollHeight = await page.evaluate(() => document.documentElement.scrollHeight);
480
+ const viewportHeight = await page.evaluate(() => window.innerHeight);
481
+ expect(scrollHeight).toBeLessThanOrEqual(viewportHeight + 4);
482
+
483
+ // Some ancestor between the <table> and the card actually clips and
484
+ // scrolls its content (tableInner's own `overflow-y-auto` div — the
485
+ // `<table>` sits inside an extra `overflow-x-auto` wrapper from
486
+ // ui/table.tsx first, whose reported `overflow-y` computed value is
487
+ // "auto" too per the CSS overflow-x/y coupling rule even though it
488
+ // never actually clips vertically, so this checks the real effect
489
+ // (scrollHeight > clientHeight) rather than the computed style). That's
490
+ // the "scrolls in place" half of the fix — the previous assertion
491
+ // already proved the card doesn't grow to make room for it instead.
492
+ const tableWrapperOverflows = await page.getByTestId("render-list-table").evaluate((table) => {
493
+ let el: HTMLElement | null = table.parentElement;
494
+ while (el !== null && el.scrollHeight <= el.clientHeight + 1) {
495
+ el = el.parentElement;
496
+ }
497
+ return el !== null;
498
+ });
499
+ expect(tableWrapperOverflows).toBe(true);
500
+ });
501
+
502
+ test("record header (subtitle, metrics) and tab strip stay fully visible, uncompressed, in both scenarios", async ({
503
+ page,
504
+ }) => {
505
+ const subtitle = page.getByTestId("kumiko-screen-projection-detail-subtitle");
506
+ const metrics = page.getByTestId("kumiko-screen-projection-detail-metrics");
507
+ const heights: { tabs: number; subtitle: number; metrics: number }[] = [];
508
+ for (const items of [3, 60]) {
509
+ await gotoOrderDetail(page, items);
510
+ const tabs = await tabsLocator(page).boundingBox();
511
+ if (tabs === null) throw new Error(`missing tabs bounding box (items=${items})`);
512
+ expect(tabs.height).toBeGreaterThan(20);
513
+ // The title now lives in the shell's page header, not in the screen.
514
+ await expect(subtitle).toBeVisible();
515
+ await expect(metrics).toBeVisible();
516
+ const subtitleBox = await subtitle.boundingBox();
517
+ const metricsBox = await metrics.boundingBox();
518
+ if (subtitleBox === null || metricsBox === null) {
519
+ throw new Error(`missing header bounding box (items=${items})`);
520
+ }
521
+ heights.push({ tabs: tabs.height, subtitle: subtitleBox.height, metrics: metricsBox.height });
522
+ for (const region of [subtitle, metrics]) {
523
+ const clipped = await region.evaluate((el) => el.scrollHeight > el.clientHeight + 1);
524
+ expect(clipped).toBe(false);
525
+ }
526
+ }
527
+ const [few, many] = heights;
528
+ if (few === undefined || many === undefined) throw new Error("missing height samples");
529
+ expect(many.tabs).toBeCloseTo(few.tabs, 0);
530
+ expect(many.subtitle).toBeCloseTo(few.subtitle, 0);
531
+ expect(many.metrics).toBeCloseTo(few.metrics, 0);
532
+ });
533
+ });
534
+
535
+ test.describe("record-detail-layout — tall tab panel scrolls", () => {
536
+ test.use({ viewport: { width: 1440, height: 772 } });
537
+
538
+ test("an extension tab taller than the viewport scrolls its sections wrapper to the last element", async ({
539
+ page,
540
+ }) => {
541
+ await page.goto("/?tab=internal-note&noteHeight=1400");
542
+ const note = page.getByTestId("order-internal-note");
543
+ await expect(note).toBeVisible();
544
+ const end = page.getByTestId("order-internal-note-end");
545
+
546
+ // Nearest scrollable ancestor of the panel content: the sections wrapper.
547
+ const scroller = await note.evaluateHandle((el) => {
548
+ let node: HTMLElement | null = el.parentElement;
549
+ while (node !== null && !/(auto|scroll)/.test(getComputedStyle(node).overflowY)) {
550
+ node = node.parentElement;
551
+ }
552
+ if (node === null) throw new Error("no scrollable ancestor");
553
+ return node;
554
+ });
555
+ const metrics = () =>
556
+ scroller.evaluate((el) => ({
557
+ scrollHeight: el.scrollHeight,
558
+ clientHeight: el.clientHeight,
559
+ scrollTop: el.scrollTop,
560
+ }));
561
+
562
+ const before = await metrics();
563
+ expect(before.scrollHeight).toBeGreaterThan(before.clientHeight);
564
+
565
+ const box = await note.boundingBox();
566
+ if (box === null) throw new Error("missing note bounding box");
567
+ await page.mouse.move(box.x + 10, box.y + 10);
568
+ await page.mouse.wheel(0, 3000);
569
+ await expect.poll(async () => (await metrics()).scrollTop).toBeGreaterThan(0);
570
+
571
+ await expect(end).toBeInViewport();
572
+ });
573
+ });
574
+ ```
575
+
576
+ ### e2e/padding-parity-screenshots.spec.ts
577
+
578
+ ```ts
579
+ // Manual visual evidence for fw#3234 review round 2 (padding parity) — one
580
+ // screenshot per tab kind (fields, relatedList, extension) plus the head
581
+ // card + screen actions, Desktop and Phone (see playwright.config.ts
582
+ // "chromium-desktop-screenshots" / "chromium-phone" projects, enabled only
583
+ // with PADDING_PARITY_SCREENSHOTS=1). Not an
584
+ // assertion-bearing regression test (the DOM-level padding assertions live
585
+ // in projection-detail.test.tsx) — this only captures what a reviewer sees.
586
+
587
+ import { expect, boundaryGuardedTest as test } from "@cosmicdrift/kumiko-testing/e2e";
588
+
589
+ test("record-detail-layout — all tab kinds + screen actions, one screenshot per tab", async ({
590
+ page,
591
+ }, testInfo) => {
592
+ const tabIds = ["items", "payments", "details", "internal-note"];
593
+ for (const tabId of tabIds) {
594
+ await page.goto(`/?tab=${tabId}`);
595
+ await expect(page.getByTestId("render-edit-form")).toBeVisible();
596
+ await expect(page.getByTestId("kumiko-screen-projection-detail-tabs")).toBeVisible();
597
+ if (tabId === "internal-note") {
598
+ await expect(page.getByTestId("order-internal-note")).toBeVisible();
599
+ }
600
+ await page.screenshot({
601
+ path: testInfo.outputPath(`${tabId}.png`),
602
+ fullPage: true,
603
+ });
604
+ }
605
+ });
606
+ ```
607
+
608
+ ### e2e/phone-viewport.spec.ts
609
+
610
+ ```ts
611
+ // Phone-viewport tab strip (fw#3234): order-detail's tabs Card frame must
612
+ // stay within the panel's own width on a narrow phone viewport — no
613
+ // horizontal document scroll from the tab strip or the Card chrome around
614
+ // it. Runs against the "chromium-phone" project (see playwright.config.ts),
615
+ // a real iPhone-sized viewport, not a resized desktop window.
616
+
617
+ import { expect, boundaryGuardedTest as test } from "@cosmicdrift/kumiko-testing/e2e";
618
+
619
+ test("record-detail-layout — tab strip fits the phone viewport width (fw#3234)", async ({
620
+ page,
621
+ }) => {
622
+ await page.goto("/");
623
+ await expect(page.getByTestId("render-edit-form")).toBeVisible();
624
+ await expect(page.getByTestId("kumiko-screen-projection-detail-tabs")).toBeVisible();
625
+
626
+ const overflowsHorizontally = await page.evaluate(
627
+ () => document.documentElement.scrollWidth > document.documentElement.clientWidth,
628
+ );
629
+ expect(overflowsHorizontally).toBe(false);
630
+
631
+ const tabsBox = await page
632
+ .getByTestId("kumiko-screen-projection-detail-tabs")
633
+ .evaluate((el) => ({ scrollWidth: el.scrollWidth, clientWidth: el.clientWidth }));
634
+ expect(tabsBox.scrollWidth).toBeLessThanOrEqual(tabsBox.clientWidth);
635
+ });
636
+ ```
637
+
638
+ ### e2e/playwright.config.ts
639
+
640
+ ```ts
641
+ // Playwright config for record-detail-layout/e2e. Boots the minimal
642
+ // build-server (see e2e/build-server.ts) — a real browser + real React
643
+ // bundle against a MockDispatcher, no full Kumiko stack. Pattern copied
644
+ // from samples/recipes/writeform-section/e2e/playwright.config.ts.
645
+ //
646
+ // Port 4190: see the build-server.ts comment for the port allocation
647
+ // overview of the other samples/apps ports.
648
+ //
649
+ // Config lives IN e2e/ itself — Playwright's webServer.command runs by
650
+ // default with cwd = the config's directory, i.e. relative to e2e/ itself
651
+ // (not to the package root). testDir therefore stays "." instead of
652
+ // "./e2e", and webServer.command calls build-server.ts without an "e2e/"
653
+ // prefix.
654
+
655
+ import { defineConfig, devices } from "@playwright/test";
656
+
657
+ // The padding-parity spec only captures screenshots for manual review (no
658
+ // regression assertions), so it stays out of regular runs and costs no runner
659
+ // minutes unless this flag is set.
660
+ const WITH_PARITY_SCREENSHOTS = !!process.env["PADDING_PARITY_SCREENSHOTS"];
661
+
662
+ const PORT = 4190;
663
+ const BASE_URL = `http://localhost:${PORT}`;
664
+
665
+ export default defineConfig({
666
+ testDir: ".",
667
+ testIgnore: ["**/fixtures/**", "build-server.ts"],
668
+ fullyParallel: false,
669
+ forbidOnly: !!process.env["CI"],
670
+ retries: 0,
671
+ workers: 1,
672
+ reporter: [["list"]],
673
+ timeout: 15_000,
674
+ expect: { timeout: 3_000 },
675
+
676
+ use: {
677
+ baseURL: BASE_URL,
678
+ trace: "retain-on-failure",
679
+ screenshot: "only-on-failure",
680
+ actionTimeout: 5_000,
681
+ navigationTimeout: 10_000,
682
+ },
683
+
684
+ projects: [
685
+ {
686
+ name: "chromium",
687
+ testIgnore: /(phone-viewport|padding-parity-screenshots)\.spec\.ts$/,
688
+ use: { ...devices["Desktop Chrome"], viewport: { width: 1440, height: 900 } },
689
+ },
690
+ // Desktop copy of padding-parity-screenshots.spec.ts, distinct from the
691
+ // "chromium" project above (that one ignores it) so the screenshot spec
692
+ // gets its own explicit project name instead of hiding in "chromium"'s
693
+ // catch-all — matches the phone project's split for the same reason.
694
+ ...(WITH_PARITY_SCREENSHOTS
695
+ ? [
696
+ {
697
+ name: "chromium-desktop-screenshots",
698
+ testMatch: /padding-parity-screenshots\.spec\.ts$/,
699
+ use: { ...devices["Desktop Chrome"], viewport: { width: 1440, height: 900 } },
700
+ },
701
+ ]
702
+ : []),
703
+ // Phone viewport (fw#3234): the tab strip must stay within the panel
704
+ // width — no horizontal document scroll — down to a narrow phone frame.
705
+ // Plain Desktop Chrome with an iPhone-sized viewport, not the "iPhone
706
+ // 13" device preset (isMobile/hasTouch touch emulation is flaky in this
707
+ // sandbox's headless chromium) — width is what fw#3234 cares about.
708
+ {
709
+ name: "chromium-phone",
710
+ testMatch: WITH_PARITY_SCREENSHOTS
711
+ ? /(phone-viewport|padding-parity-screenshots)\.spec\.ts$/
712
+ : /phone-viewport\.spec\.ts$/,
713
+ use: { ...devices["Desktop Chrome"], viewport: { width: 390, height: 844 } },
714
+ },
715
+ ],
716
+
717
+ webServer: {
718
+ command: "bun build-server.ts",
719
+ url: BASE_URL,
720
+ env: { PORT: String(PORT) },
721
+ reuseExistingServer: !process.env["CI"],
722
+ timeout: 30_000,
723
+ stdout: "pipe",
724
+ stderr: "pipe",
725
+ },
726
+ });
727
+ ```
728
+
37
729
  ### src/__tests__/feature.test.ts
38
730
 
39
731
  ```ts