@cosmicdrift/kumiko-framework 0.387.0 → 0.389.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 (375) hide show
  1. package/AGENTS.md +9 -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/golden-path.md +13 -1
  9. package/agents/examples/legal-pages.md +1 -1
  10. package/agents/examples/lifecycle-hooks.md +55 -2
  11. package/agents/examples/list-table-options.md +466 -0
  12. package/agents/examples/pdf-audit-export.md +4 -2
  13. package/agents/examples/prod-boot-options.md +16 -0
  14. package/agents/examples/rate-limiting.md +48 -0
  15. package/agents/examples/record-detail-layout.md +692 -0
  16. package/agents/examples/seed-migration.md +212 -0
  17. package/agents/examples/state-machine.md +3 -2
  18. package/agents/examples/tenant-isolation.md +73 -1
  19. package/agents/examples/wizard-form.md +941 -0
  20. package/agents/examples/writeform-section.md +548 -0
  21. package/agents/examples.md +1 -0
  22. package/agents/features.md +19 -2
  23. package/agents/widgets.md +2 -0
  24. package/dist/api/api-constants.d.ts +1 -0
  25. package/dist/api/api-constants.js +2 -0
  26. package/dist/api/auth-routes-shared.d.ts +2 -0
  27. package/dist/api/auth-routes-shared.js +5 -0
  28. package/dist/api/client-error-route.js +10 -5
  29. package/dist/api/entity-read-access.d.ts +2 -1
  30. package/dist/api/entity-read-access.js +14 -0
  31. package/dist/api/export/export-cells-reference.d.ts +9 -0
  32. package/dist/api/export/export-cells-reference.js +21 -0
  33. package/dist/api/export/export-cells.d.ts +11 -0
  34. package/dist/api/export/export-cells.js +136 -0
  35. package/dist/api/export/export-ciphertext-guard.d.ts +2 -0
  36. package/dist/api/export/export-ciphertext-guard.js +27 -0
  37. package/dist/api/export/export-columns.d.ts +29 -0
  38. package/dist/api/export/export-columns.js +85 -0
  39. package/dist/api/export/export-errors.d.ts +4 -0
  40. package/dist/api/export/export-errors.js +14 -0
  41. package/dist/api/export/export-route.d.ts +4 -0
  42. package/dist/api/export/export-route.js +74 -0
  43. package/dist/api/export/export-rows.d.ts +13 -0
  44. package/dist/api/export/export-rows.js +62 -0
  45. package/dist/api/export/export-screen.d.ts +12 -0
  46. package/dist/api/export/export-screen.js +47 -0
  47. package/dist/api/export/export-xlsx-writer.d.ts +4 -0
  48. package/dist/api/export/export-xlsx-writer.js +21 -0
  49. package/dist/api/extra-route.d.ts +4 -0
  50. package/dist/api/index.d.ts +3 -1
  51. package/dist/api/index.js +3 -1
  52. package/dist/api/pii-leak-guard.d.ts +3 -0
  53. package/dist/api/pii-leak-guard.js +3 -2
  54. package/dist/api/request-context.d.ts +1 -1
  55. package/dist/api/request-context.js +1 -1
  56. package/dist/api/routes.d.ts +16 -0
  57. package/dist/api/routes.js +2 -2
  58. package/dist/api/server-boot-checks.d.ts +10 -0
  59. package/dist/api/server-boot-checks.js +157 -0
  60. package/dist/api/server-event-dispatcher.d.ts +26 -0
  61. package/dist/api/server-event-dispatcher.js +208 -0
  62. package/dist/api/server-extra-routes.d.ts +11 -0
  63. package/dist/api/server-extra-routes.js +206 -0
  64. package/dist/api/server-middleware.d.ts +20 -0
  65. package/dist/api/server-middleware.js +211 -0
  66. package/dist/api/server-routes.d.ts +19 -0
  67. package/dist/api/server-routes.js +299 -0
  68. package/dist/api/server-websocket-routes.d.ts +4 -0
  69. package/dist/api/server-websocket-routes.js +108 -0
  70. package/dist/api/server-wiring.d.ts +32 -0
  71. package/dist/api/server-wiring.js +238 -0
  72. package/dist/api/server.d.ts +10 -15
  73. package/dist/api/server.js +48 -1251
  74. package/dist/api/session-cookies.js +4 -4
  75. package/dist/api/sse-route.d.ts +2 -1
  76. package/dist/api/sse-route.js +27 -13
  77. package/dist/config/index.d.ts +3 -1
  78. package/dist/crypto/owner-transition-rekey.d.ts +5 -1
  79. package/dist/crypto/owner-transition-rekey.js +1 -1
  80. package/dist/crypto/pg-kms-adapter.d.ts +1 -0
  81. package/dist/crypto/pg-kms-adapter.js +12 -18
  82. package/dist/crypto/pii-field-encryption.d.ts +5 -1
  83. package/dist/crypto/pii-field-encryption.js +3 -1
  84. package/dist/db/aggregate-sql.d.ts +23 -0
  85. package/dist/db/aggregate-sql.js +165 -0
  86. package/dist/db/closed-connection.d.ts +9 -0
  87. package/dist/db/closed-connection.js +88 -0
  88. package/dist/db/connection.d.ts +4 -5
  89. package/dist/db/connection.js +29 -20
  90. package/dist/db/dialect.js +1 -1
  91. package/dist/db/distinct-on.d.ts +1 -1
  92. package/dist/db/driver-row-coercion.d.ts +2 -0
  93. package/dist/db/driver-row-coercion.js +186 -0
  94. package/dist/db/driver-value-preparation.d.ts +16 -0
  95. package/dist/db/driver-value-preparation.js +101 -0
  96. package/dist/db/entity-field-encryption.d.ts +1 -0
  97. package/dist/db/entity-field-encryption.js +1 -1
  98. package/dist/db/entity-table-meta.js +103 -110
  99. package/dist/db/event-store-executor-context.d.ts +2 -1
  100. package/dist/db/event-store-executor-read-aggregate-currencies.d.ts +17 -0
  101. package/dist/db/event-store-executor-read-aggregate-currencies.js +40 -0
  102. package/dist/db/event-store-executor-read-aggregate.d.ts +8 -0
  103. package/dist/db/event-store-executor-read-aggregate.js +143 -0
  104. package/dist/db/event-store-executor-read-filter.d.ts +31 -0
  105. package/dist/db/event-store-executor-read-filter.js +206 -0
  106. package/dist/db/event-store-executor-read-paging.d.ts +14 -0
  107. package/dist/db/event-store-executor-read-paging.js +99 -0
  108. package/dist/db/event-store-executor-read-rows.d.ts +6 -0
  109. package/dist/db/event-store-executor-read-rows.js +32 -0
  110. package/dist/db/event-store-executor-read-search.d.ts +28 -0
  111. package/dist/db/event-store-executor-read-search.js +221 -0
  112. package/dist/db/event-store-executor-read-sort.d.ts +41 -0
  113. package/dist/db/event-store-executor-read-sort.js +130 -0
  114. package/dist/db/event-store-executor-read-where.d.ts +24 -0
  115. package/dist/db/event-store-executor-read-where.js +81 -0
  116. package/dist/db/event-store-executor-read.d.ts +2 -9
  117. package/dist/db/event-store-executor-read.js +48 -701
  118. package/dist/db/event-store-executor-write-atomic.d.ts +4 -0
  119. package/dist/db/event-store-executor-write-atomic.js +41 -0
  120. package/dist/db/event-store-executor-write-steps.d.ts +17 -0
  121. package/dist/db/event-store-executor-write-steps.js +95 -0
  122. package/dist/db/event-store-executor-write.js +405 -509
  123. package/dist/db/event-store-executor.js +2 -1
  124. package/dist/db/executor-managed-tables.js +1 -1
  125. package/dist/db/index.d.ts +7 -3
  126. package/dist/db/index.js +6 -2
  127. package/dist/db/list-aggregate-labels.d.ts +14 -0
  128. package/dist/db/list-aggregate-labels.js +26 -0
  129. package/dist/db/migrate-generator.js +71 -86
  130. package/dist/db/migrate-runner.js +7 -4
  131. package/dist/db/pg-error.js +26 -21
  132. package/dist/db/queries/backfill-pii.js +3 -6
  133. package/dist/db/queries/database-now.js +2 -1
  134. package/dist/db/queries/shadow-swap.js +2 -3
  135. package/dist/db/query.d.ts +16 -15
  136. package/dist/db/query.js +76 -804
  137. package/dist/db/reference-data.js +19 -2
  138. package/dist/db/statement-timeout.js +2 -1
  139. package/dist/db/table-builder.js +113 -166
  140. package/dist/db/table-info.d.ts +10 -0
  141. package/dist/db/table-info.js +45 -0
  142. package/dist/db/temporal-value-guards.d.ts +6 -0
  143. package/dist/db/temporal-value-guards.js +15 -0
  144. package/dist/db/tenant-db-runner.d.ts +1 -0
  145. package/dist/db/tenant-db-runner.js +3 -0
  146. package/dist/db/tenant-db-upsert.d.ts +5 -0
  147. package/dist/db/tenant-db-upsert.js +91 -0
  148. package/dist/db/tenant-db.js +50 -10
  149. package/dist/db/where-clause.d.ts +6 -0
  150. package/dist/db/where-clause.js +236 -0
  151. package/dist/derivatives/delete-stored-file.js +2 -1
  152. package/dist/engine/boot-validator/boot-check.js +1 -1
  153. package/dist/engine/boot-validator/bundled-lists-table.d.ts +2 -0
  154. package/dist/engine/boot-validator/bundled-lists-table.js +36 -0
  155. package/dist/engine/boot-validator/detail-screens.js +2 -2
  156. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  157. package/dist/engine/boot-validator/entity-handler.js +17 -0
  158. package/dist/engine/boot-validator/entity-list-aggregate-options.d.ts +3 -0
  159. package/dist/engine/boot-validator/entity-list-aggregate-options.js +23 -0
  160. package/dist/engine/boot-validator/form-derivations.js +35 -1
  161. package/dist/engine/boot-validator/i18n-keys.js +5 -3
  162. package/dist/engine/boot-validator/index.js +2 -0
  163. package/dist/engine/boot-validator/list-bulk-actions.d.ts +3 -0
  164. package/dist/engine/boot-validator/list-bulk-actions.js +49 -0
  165. package/dist/engine/boot-validator/list-column-layout.d.ts +8 -0
  166. package/dist/engine/boot-validator/list-column-layout.js +41 -0
  167. package/dist/engine/boot-validator/list-table-aggregate-options.d.ts +24 -0
  168. package/dist/engine/boot-validator/list-table-aggregate-options.js +145 -0
  169. package/dist/engine/boot-validator/list-table-aggregate-query.d.ts +10 -0
  170. package/dist/engine/boot-validator/list-table-aggregate-query.js +31 -0
  171. package/dist/engine/boot-validator/list-table-export-options.d.ts +1 -0
  172. package/dist/engine/boot-validator/list-table-export-options.js +27 -0
  173. package/dist/engine/boot-validator/list-table-options.d.ts +3 -0
  174. package/dist/engine/boot-validator/list-table-options.js +77 -0
  175. package/dist/engine/boot-validator/list-table-reject-unknown-keys.d.ts +1 -0
  176. package/dist/engine/boot-validator/list-table-reject-unknown-keys.js +7 -0
  177. package/dist/engine/boot-validator/pii-retention.js +233 -155
  178. package/dist/engine/boot-validator/projection-list-screens.d.ts +1 -1
  179. package/dist/engine/boot-validator/projection-list-screens.js +3 -13
  180. package/dist/engine/boot-validator/related-list-table-rules.d.ts +4 -0
  181. package/dist/engine/boot-validator/related-list-table-rules.js +42 -0
  182. package/dist/engine/boot-validator/screens-actions.d.ts +1 -0
  183. package/dist/engine/boot-validator/screens-actions.js +8 -1
  184. package/dist/engine/boot-validator/screens-dashboard.js +2 -5
  185. package/dist/engine/boot-validator/screens-forms.js +2 -6
  186. package/dist/engine/boot-validator/screens-kind-config-edit.d.ts +3 -0
  187. package/dist/engine/boot-validator/screens-kind-config-edit.js +88 -0
  188. package/dist/engine/boot-validator/screens-kind-entity-edit.d.ts +3 -0
  189. package/dist/engine/boot-validator/screens-kind-entity-edit.js +55 -0
  190. package/dist/engine/boot-validator/screens-kind-entity-list.d.ts +3 -0
  191. package/dist/engine/boot-validator/screens-kind-entity-list.js +235 -0
  192. package/dist/engine/boot-validator/screens-kind-projection-detail.d.ts +3 -0
  193. package/dist/engine/boot-validator/screens-kind-projection-detail.js +235 -0
  194. package/dist/engine/boot-validator/screens-kind-projection-list.d.ts +3 -0
  195. package/dist/engine/boot-validator/screens-kind-projection-list.js +56 -0
  196. package/dist/engine/boot-validator/screens-kind-secret-mint.d.ts +3 -0
  197. package/dist/engine/boot-validator/screens-kind-secret-mint.js +28 -0
  198. package/dist/engine/boot-validator/screens-kind-shared.d.ts +37 -0
  199. package/dist/engine/boot-validator/screens-kind-shared.js +142 -0
  200. package/dist/engine/boot-validator/screens-lists.d.ts +2 -1
  201. package/dist/engine/boot-validator/screens-lists.js +24 -11
  202. package/dist/engine/boot-validator/screens.d.ts +0 -2
  203. package/dist/engine/boot-validator/screens.js +99 -860
  204. package/dist/engine/build-app-schema.d.ts +2 -1
  205. package/dist/engine/build-app-schema.js +65 -97
  206. package/dist/engine/build-config-feature-schema.js +6 -1
  207. package/dist/engine/define-feature.js +13 -2
  208. package/dist/engine/define-handler.js +1 -0
  209. package/dist/engine/entity-handlers.d.ts +13 -0
  210. package/dist/engine/entity-handlers.js +10 -21
  211. package/dist/engine/entity-list-runtime-options.d.ts +8 -0
  212. package/dist/engine/entity-list-runtime-options.js +18 -0
  213. package/dist/engine/feature-ast/extractors/handlers.js +1 -0
  214. package/dist/engine/feature-ast/extractors/index.d.ts +1 -1
  215. package/dist/engine/feature-ast/extractors/index.js +1 -1
  216. package/dist/engine/feature-ast/extractors/round1.d.ts +2 -1
  217. package/dist/engine/feature-ast/extractors/round1.js +6 -0
  218. package/dist/engine/feature-ast/extractors/round2.js +25 -6
  219. package/dist/engine/feature-ast/index.d.ts +1 -1
  220. package/dist/engine/feature-ast/parse.js +3 -1
  221. package/dist/engine/feature-ast/patch.d.ts +2 -0
  222. package/dist/engine/feature-ast/patch.js +5 -182
  223. package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -245
  224. package/dist/engine/feature-ast/pattern-change-schema.js +10 -163
  225. package/dist/engine/feature-ast/pattern-id-matchers.d.ts +7 -0
  226. package/dist/engine/feature-ast/pattern-id-matchers.js +211 -0
  227. package/dist/engine/feature-ast/pattern-id-schema.d.ts +251 -0
  228. package/dist/engine/feature-ast/pattern-id-schema.js +166 -0
  229. package/dist/engine/feature-ast/patterns.d.ts +14 -1
  230. package/dist/engine/feature-ast/patterns.js +44 -51
  231. package/dist/engine/feature-ast/render.js +52 -87
  232. package/dist/engine/feature-builder-state.d.ts +1 -0
  233. package/dist/engine/feature-builder-state.js +1 -0
  234. package/dist/engine/feature-entity-handlers.js +3 -0
  235. package/dist/engine/feature-manifest.d.ts +1 -0
  236. package/dist/engine/feature-manifest.js +1 -0
  237. package/dist/engine/field-access.d.ts +1 -0
  238. package/dist/engine/field-access.js +12 -0
  239. package/dist/engine/form-derivations.js +18 -7
  240. package/dist/engine/handler-field-references.js +1 -1
  241. package/dist/engine/index.d.ts +2 -2
  242. package/dist/engine/index.js +1 -1
  243. package/dist/engine/pattern-library/library.js +2 -1
  244. package/dist/engine/pattern-library/static-schemas.d.ts +1 -0
  245. package/dist/engine/pattern-library/static-schemas.js +9 -0
  246. package/dist/engine/project-app-schema-for-roles.d.ts +2 -0
  247. package/dist/engine/project-app-schema-for-roles.js +6 -4
  248. package/dist/engine/registry-ingest.js +1 -1
  249. package/dist/engine/schema-builder.js +228 -212
  250. package/dist/engine/screen-filter-ops.js +2 -0
  251. package/dist/engine/screen-helpers.js +1 -1
  252. package/dist/engine/screen-kind.d.ts +7 -0
  253. package/dist/engine/screen-kind.js +1 -0
  254. package/dist/engine/strip-disabled-features-from-app-schema.js +58 -17
  255. package/dist/engine/types/feature.d.ts +1 -0
  256. package/dist/engine/types/index.d.ts +4 -3
  257. package/dist/engine/types/screen.d.ts +1 -1
  258. package/dist/engine/types/screen.js +1 -1
  259. package/dist/engine/validate-projection-allowlist.js +2 -1
  260. package/dist/entrypoint/index.d.ts +6 -1
  261. package/dist/entrypoint/index.js +10 -1
  262. package/dist/errors/docs-url.d.ts +1 -1
  263. package/dist/errors/docs-url.js +22 -2
  264. package/dist/es-ops/context.js +1 -1
  265. package/dist/event-store/admin-api.js +1 -1
  266. package/dist/event-store/event-store.d.ts +1 -0
  267. package/dist/event-store/event-store.js +17 -8
  268. package/dist/event-store/provenance-append.js +1 -1
  269. package/dist/files/bind-pending-file-refs.js +3 -3
  270. package/dist/files/create-file-ref-from-bytes.js +7 -1
  271. package/dist/files/create-file-ref.d.ts +3 -0
  272. package/dist/files/create-file-ref.js +11 -2
  273. package/dist/files/file-handle.js +2 -3
  274. package/dist/files/file-routes.d.ts +2 -0
  275. package/dist/files/file-routes.js +40 -10
  276. package/dist/files/provider-resolver.d.ts +1 -0
  277. package/dist/files/provider-resolver.js +1 -1
  278. package/dist/files/record-write-check.d.ts +6 -0
  279. package/dist/files/record-write-check.js +13 -0
  280. package/dist/files/resolve-content-type.js +43 -0
  281. package/dist/files/upload-ledger.d.ts +2 -2
  282. package/dist/files/upload-ledger.js +11 -4
  283. package/dist/i18n/required-surface-keys.js +189 -208
  284. package/dist/internal/stack-support.d.ts +2 -1
  285. package/dist/internal/stack-support.js +1 -1
  286. package/dist/internal/testing.js +1 -1
  287. package/dist/jobs/job-runner-context.d.ts +32 -0
  288. package/dist/jobs/job-runner-context.js +305 -0
  289. package/dist/jobs/job-runner-execution.d.ts +7 -0
  290. package/dist/jobs/job-runner-execution.js +160 -0
  291. package/dist/jobs/job-runner-gates.d.ts +19 -0
  292. package/dist/jobs/job-runner-gates.js +198 -0
  293. package/dist/jobs/job-runner-helpers.d.ts +17 -3
  294. package/dist/jobs/job-runner-helpers.js +61 -2
  295. package/dist/jobs/job-runner-scope.d.ts +44 -0
  296. package/dist/jobs/job-runner-scope.js +102 -0
  297. package/dist/jobs/job-runner-types.d.ts +12 -0
  298. package/dist/jobs/job-runner.d.ts +0 -1
  299. package/dist/jobs/job-runner.js +50 -657
  300. package/dist/observability/escape-hatch-report.d.ts +3 -0
  301. package/dist/observability/escape-hatch-report.js +21 -3
  302. package/dist/pipeline/after-rollback-sink.d.ts +6 -0
  303. package/dist/pipeline/after-rollback-sink.js +32 -0
  304. package/dist/pipeline/cascade-handler.js +165 -138
  305. package/dist/pipeline/dispatch-batch.js +8 -1
  306. package/dist/pipeline/dispatch-query.js +10 -3
  307. package/dist/pipeline/dispatch-shared.d.ts +13 -3
  308. package/dist/pipeline/dispatch-shared.js +62 -710
  309. package/dist/pipeline/dispatch-write.js +296 -189
  310. package/dist/pipeline/event-dispatcher-admin.js +2 -1
  311. package/dist/pipeline/event-dispatcher-delivery.js +6 -6
  312. package/dist/pipeline/event-dispatcher.d.ts +1 -0
  313. package/dist/pipeline/event-dispatcher.js +6 -9
  314. package/dist/pipeline/handler-context-auth.d.ts +9 -0
  315. package/dist/pipeline/handler-context-auth.js +54 -0
  316. package/dist/pipeline/handler-context-config.d.ts +14 -0
  317. package/dist/pipeline/handler-context-config.js +113 -0
  318. package/dist/pipeline/handler-context-db.d.ts +24 -0
  319. package/dist/pipeline/handler-context-db.js +149 -0
  320. package/dist/pipeline/handler-context-dispatch.d.ts +23 -0
  321. package/dist/pipeline/handler-context-dispatch.js +101 -0
  322. package/dist/pipeline/handler-context-events.d.ts +41 -0
  323. package/dist/pipeline/handler-context-events.js +239 -0
  324. package/dist/pipeline/handler-context-notify.d.ts +12 -0
  325. package/dist/pipeline/handler-context-notify.js +55 -0
  326. package/dist/pipeline/handler-context-observability.d.ts +7 -0
  327. package/dist/pipeline/handler-context-observability.js +21 -0
  328. package/dist/pipeline/handler-context-scope.d.ts +48 -0
  329. package/dist/pipeline/handler-context-scope.js +88 -0
  330. package/dist/pipeline/notify-scope.js +12 -1
  331. package/dist/pipeline/pre-handler-gates.js +20 -4
  332. package/dist/pipeline/pre-transaction-reservations.d.ts +1 -0
  333. package/dist/pipeline/pre-transaction-reservations.js +9 -3
  334. package/dist/pipeline/projection-rebuild.js +2 -1
  335. package/dist/pipeline/system-hooks.d.ts +3 -1
  336. package/dist/pipeline/system-hooks.js +55 -6
  337. package/dist/pipeline/write-result-masking.js +13 -10
  338. package/dist/schema-cli/apply-command.d.ts +2 -0
  339. package/dist/schema-cli/apply-command.js +97 -0
  340. package/dist/schema-cli/baseline-command.d.ts +2 -0
  341. package/dist/schema-cli/baseline-command.js +33 -0
  342. package/dist/schema-cli/generate-command.d.ts +2 -0
  343. package/dist/schema-cli/generate-command.js +90 -0
  344. package/dist/schema-cli/shared.d.ts +39 -0
  345. package/dist/schema-cli/shared.js +95 -0
  346. package/dist/schema-cli/status-command.d.ts +2 -0
  347. package/dist/schema-cli/status-command.js +73 -0
  348. package/dist/schema-cli/validate-boot-input.d.ts +5 -0
  349. package/dist/schema-cli/validate-boot-input.js +130 -0
  350. package/dist/schema-cli/validate-command.d.ts +2 -0
  351. package/dist/schema-cli/validate-command.js +130 -0
  352. package/dist/schema-cli.d.ts +2 -18
  353. package/dist/schema-cli.js +47 -604
  354. package/dist/search/meilisearch-adapter.js +2 -1
  355. package/dist/search/reindex-entity.js +15 -5
  356. package/dist/ui-types/index.d.ts +2 -2
  357. package/dist/ui-types/index.js +1 -1
  358. package/dist/upgrade-changelog.d.ts +2 -0
  359. package/dist/upgrade-changelog.js +14 -0
  360. package/dist/upgrade-cli.d.ts +3 -12
  361. package/dist/upgrade-cli.js +171 -197
  362. package/dist/upgrade-marker-parse.d.ts +32 -0
  363. package/dist/upgrade-marker-parse.js +73 -0
  364. package/package.json +9 -6
  365. package/src/changes.json +555 -0
  366. package/src/scripts/codemod/codemod-support.ts +1 -1
  367. package/src/scripts/codemod/db-factory.ts +1 -1
  368. package/src/scripts/codemod/feature-layout.ts +10 -2
  369. package/src/scripts/codemod/object-form.ts +41 -4
  370. package/src/scripts/codemod/package-merge.ts +58 -8
  371. package/src/scripts/codemod/pii-personal-migration.ts +175 -145
  372. package/src/scripts/codemod/testing-injection.ts +122 -42
  373. package/src/scripts/codemod/testing-move.ts +29 -7
  374. package/dist/engine/provider-plugin-context.d.ts +0 -3
  375. package/dist/engine/provider-plugin-context.js +0 -28
@@ -88,6 +88,947 @@ the draft (reopening the wizard starts fresh on step 1).
88
88
 
89
89
  ## Source files
90
90
 
91
+ ### e2e/_helpers/select-combobox.ts
92
+
93
+ ```ts
94
+ // Select fields with ≤4 options render as a SegmentedSelect
95
+ // (role="radiogroup"/role="radio", data-testid="segmented-${id}") instead
96
+ // of the ComboboxInput dropdown (data-testid="combobox-${id}"). This
97
+ // recipe's `category` (4 options) and `condition` (2 options) both hit the
98
+ // segmented path today, but the helper stays dual-mode so it keeps working
99
+ // if either field's option count crosses the dropdown threshold.
100
+
101
+ import { expect, type Page } from "@playwright/test";
102
+
103
+ export async function selectCombobox(
104
+ page: Page,
105
+ field: string,
106
+ optionLabel: string,
107
+ ): Promise<void> {
108
+ const segmented = page.getByTestId(`segmented-kumiko-edit-${field}`);
109
+ if ((await segmented.count()) > 0) {
110
+ await segmented.getByRole("radio", { name: optionLabel, exact: true }).click();
111
+ return;
112
+ }
113
+ await page.getByTestId(`combobox-kumiko-edit-${field}`).click();
114
+ await page.getByRole("option", { name: optionLabel, exact: true }).click();
115
+ }
116
+
117
+ export async function expectSelectedOption(
118
+ page: Page,
119
+ field: string,
120
+ optionLabel: string,
121
+ ): Promise<void> {
122
+ const segmented = page.getByTestId(`segmented-kumiko-edit-${field}`);
123
+ if ((await segmented.count()) > 0) {
124
+ await expect(segmented.getByRole("radio", { name: optionLabel, exact: true })).toHaveAttribute(
125
+ "aria-checked",
126
+ "true",
127
+ );
128
+ return;
129
+ }
130
+ await expect(page.getByTestId(`combobox-kumiko-edit-${field}`)).toContainText(optionLabel);
131
+ }
132
+ ```
133
+
134
+ ### e2e/build-server.ts
135
+
136
+ ```ts
137
+ // Tiny Bun server that builds and serves the e2e bundle — counterpart to
138
+ // the build-server in packages/renderer/e2e, radically stripped down
139
+ // (no Postgres, no auth). The MockDispatcher (fixtures/mock-dispatcher.ts)
140
+ // replaces the whole stack.
141
+ //
142
+ // Difference from the renderer/web blueprint: the AppSchema is NOT
143
+ // duplicated in the client bundle (drift risk against the real feature
144
+ // code). Instead this server process builds the schema from the real
145
+ // src/feature.ts via createRegistry + buildAppSchema (both pure, no
146
+ // DB/HTTP) and serves it as JSON over /schema.json — client.tsx fetches
147
+ // it on boot. That keeps server-side framework code (defineFeature,
148
+ // r.crud, r.screen) out of the browser bundle.
149
+ //
150
+ // Port 4188: 4172-4181/4186 are taken by other samples/apps (see their
151
+ // playwright.config.ts/package.json), 4188 is free.
152
+ //
153
+ // /styles.css is REALLY compiled from Tailwind (not stubbed) — the
154
+ // 375px-viewport specs (wizard-mobile.spec.ts) check real layout
155
+ // properties (overlap, horizontal overflow, whether the Next button
156
+ // disappears under the simulated keyboard). Without real CSS those would
157
+ // be fake assertions against unstyled browser default flow. Entry
158
+ // stylesheet = fixtures/styles.css, a local wrapper that re-exports
159
+ // renderer/web's styles.css and adds an @source covering this e2e dir —
160
+ // renderer/web's own @source globs only scan samples/**/src/**, which
161
+ // misses e2e/fixtures/client.tsx.
162
+ // One-shot build, no watcher (the server only runs for the test duration)
163
+ // — same CLI-resolve mechanism as create-kumiko-server.ts, but without
164
+ // its non-fatal degradation: if the CLI is missing or the build fails,
165
+ // the server must die hard instead of serving an empty stylesheet stub
166
+ // and letting the layout assertions pass vacuously.
167
+
168
+ import { mkdtempSync, rmSync } from "node:fs";
169
+ import { tmpdir } from "node:os";
170
+ import { join, resolve } from "node:path";
171
+ import { createConfigFeature } from "@cosmicdrift/kumiko-bundled-features/config";
172
+ import { createFormDraftFeature } from "@cosmicdrift/kumiko-bundled-features/form-draft";
173
+ import { buildAppSchema, createRegistry } from "@cosmicdrift/kumiko-framework/engine";
174
+ import {
175
+ canResolveTailwindStylesheet,
176
+ resolveTailwindCli,
177
+ } from "@cosmicdrift/kumiko-server-runtime/resolve-tailwind-cli";
178
+ import { listingsFeature } from "../src/feature";
179
+
180
+ const HERE = resolve(import.meta.dir);
181
+ const ENTRY = resolve(HERE, "fixtures/client.tsx");
182
+ const HTML_PATH = resolve(HERE, "fixtures/index.html");
183
+
184
+ async function buildStylesheet(): Promise<string> {
185
+ const cliPath = resolveTailwindCli({ bun: Bun, cwd: HERE });
186
+ if (cliPath === undefined) {
187
+ throw new Error(
188
+ "wizard-form/e2e: @tailwindcss/cli not resolvable — run `bun install` at the repo root.",
189
+ );
190
+ }
191
+ const entryCss = resolve(HERE, "fixtures/styles.css");
192
+ if (!canResolveTailwindStylesheet(entryCss, { bun: Bun, cwd: HERE })) {
193
+ throw new Error(
194
+ `wizard-form/e2e: tailwindcss not resolvable for ${entryCss} — peer dependency missing at the stylesheet's location.`,
195
+ );
196
+ }
197
+ const outDir = mkdtempSync(join(tmpdir(), "wizard-form-e2e-tw-"));
198
+ try {
199
+ const outPath = join(outDir, "styles.css");
200
+ const bunBin = process.argv[0] ?? "bun";
201
+ const build = Bun.spawnSync([bunBin, "run", cliPath, "-i", entryCss, "-o", outPath], {
202
+ cwd: HERE,
203
+ });
204
+ if (!build.success) {
205
+ throw new Error(
206
+ `wizard-form/e2e: tailwind build failed (exit ${build.exitCode})\n${build.stderr.toString()}`,
207
+ );
208
+ }
209
+ return await Bun.file(outPath).text();
210
+ } finally {
211
+ rmSync(outDir, { recursive: true, force: true });
212
+ }
213
+ }
214
+
215
+ const css = await buildStylesheet();
216
+
217
+ const port = Number(process.env["PORT"] ?? 4188);
218
+
219
+ const registry = createRegistry([listingsFeature, createFormDraftFeature(), createConfigFeature()]);
220
+ const schema = buildAppSchema(registry);
221
+ const schemaJson = JSON.stringify(schema);
222
+
223
+ const built = await Bun.build({
224
+ entrypoints: [ENTRY],
225
+ target: "browser",
226
+ sourcemap: "linked",
227
+ });
228
+
229
+ if (!built.success) {
230
+ // biome-ignore lint/suspicious/noConsole: e2e build script — no logger wired
231
+ for (const log of built.logs) console.error(log);
232
+ throw new Error("wizard-form/e2e: client bundle failed");
233
+ }
234
+
235
+ const jsOutput = built.outputs.find((o) => o.path.endsWith(".js"));
236
+ const mapOutput = built.outputs.find((o) => o.path.endsWith(".js.map"));
237
+ if (!jsOutput) throw new Error("wizard-form/e2e: bundle has no .js output");
238
+
239
+ const js = await jsOutput.text();
240
+ const map = mapOutput ? await mapOutput.text() : "";
241
+ const html = await Bun.file(HTML_PATH).text();
242
+
243
+ Bun.serve({
244
+ port,
245
+ fetch(req: Request): Response {
246
+ const url = new URL(req.url);
247
+ if (url.pathname === "/client.js") {
248
+ return new Response(js, {
249
+ headers: { "Content-Type": "application/javascript; charset=utf-8" },
250
+ });
251
+ }
252
+ if (url.pathname === "/client.js.map") {
253
+ return new Response(map, { headers: { "Content-Type": "application/json; charset=utf-8" } });
254
+ }
255
+ if (url.pathname === "/schema.json") {
256
+ return new Response(schemaJson, {
257
+ headers: { "Content-Type": "application/json; charset=utf-8" },
258
+ });
259
+ }
260
+ if (url.pathname === "/styles.css") {
261
+ return new Response(css, { headers: { "Content-Type": "text/css; charset=utf-8" } });
262
+ }
263
+ if (url.pathname === "/api/sse" || url.pathname.startsWith("/sse")) {
264
+ // Long-lived empty stream in case some primitive/hook opens an
265
+ // EventSource after all — without a correct SSE response this
266
+ // would produce a "MIME type text/html" console error instead of a
267
+ // clean no-op.
268
+ const stream = new ReadableStream<Uint8Array>({
269
+ start(controller) {
270
+ controller.enqueue(new TextEncoder().encode(": connected\n\n"));
271
+ },
272
+ });
273
+ return new Response(stream, {
274
+ headers: {
275
+ "Content-Type": "text/event-stream",
276
+ "Cache-Control": "no-cache",
277
+ Connection: "keep-alive",
278
+ },
279
+ });
280
+ }
281
+ return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
282
+ },
283
+ });
284
+
285
+ // biome-ignore lint/suspicious/noConsole: e2e build script — no logger wired
286
+ console.log(`wizard-form/e2e build-server listening on http://localhost:${port}`);
287
+ ```
288
+
289
+ ### e2e/fixtures/client.tsx
290
+
291
+ ```tsx
292
+ // Browser entry for wizard-form/e2e. Fetches the server-built AppSchema
293
+ // (see build-server.ts — built from the real src/feature.ts, not
294
+ // duplicated here) and mounts createKumikoApp with the MockDispatcher.
295
+ //
296
+ // screenQn points explicitly at the wizard screen: the only screen in
297
+ // this schema requires roles: ["Admin", "User"] (no openToAll screen
298
+ // exists), createKumikoApp would throw at boot without an explicit
299
+ // screenQn ("no screen reachable without role restriction"). The
300
+ // DefaultAppShell `user` prop supplies the role that screenAccessAllows
301
+ // checks client-side.
302
+ //
303
+ // listingsClient is the REAL client registration from src/web/ — the
304
+ // recipe's own wiring, not an e2e-only duplicate — so this fixture proves
305
+ // the shipped registration actually resolves ListingReviewSection.
306
+
307
+ import type { AppSchema } from "@cosmicdrift/kumiko-renderer";
308
+ import { createKumikoApp, DefaultAppShell } from "@cosmicdrift/kumiko-renderer/web";
309
+ import type { ReactNode } from "react";
310
+ import { listingsClient } from "../../src/web";
311
+ import { createMockDispatcher } from "./mock-dispatcher";
312
+
313
+ const WIZARD_SCREEN_QN = "listings:screen:listing-wizard";
314
+ const E2E_USER = { id: "e2e-user", roles: ["Admin"] };
315
+
316
+ function Brand(): ReactNode {
317
+ return <strong className="text-foreground tracking-tight">Wizard Form E2E</strong>;
318
+ }
319
+
320
+ function AppShell({
321
+ children,
322
+ schema,
323
+ }: {
324
+ readonly children: ReactNode;
325
+ readonly schema: AppSchema;
326
+ }): ReactNode {
327
+ return (
328
+ <DefaultAppShell brand={<Brand />} schema={schema} user={E2E_USER}>
329
+ {children}
330
+ </DefaultAppShell>
331
+ );
332
+ }
333
+
334
+ async function boot(): Promise<void> {
335
+ const root = document.getElementById("root");
336
+ if (root === null) throw new Error("wizard-form/e2e: #root not found");
337
+
338
+ const response = await fetch("/schema.json");
339
+ if (!response.ok) {
340
+ throw new Error(`wizard-form/e2e: /schema.json fetch failed with ${response.status}`);
341
+ }
342
+ const schema = (await response.json()) as AppSchema;
343
+
344
+ createKumikoApp({
345
+ schema,
346
+ dispatcher: createMockDispatcher(),
347
+ shell: AppShell,
348
+ screenQn: WIZARD_SCREEN_QN,
349
+ clientFeatures: [listingsClient],
350
+ });
351
+ }
352
+
353
+ boot().catch((error: unknown) => {
354
+ // A rejected boot() would otherwise become a silent unhandled rejection —
355
+ // surface it in the DOM so a failing e2e run shows the real cause instead
356
+ // of a blank #root.
357
+ const root = document.getElementById("root");
358
+ if (root !== null) {
359
+ root.textContent = `wizard-form/e2e: boot failed — ${String(error)}`;
360
+ }
361
+ throw error;
362
+ });
363
+ ```
364
+
365
+ ### e2e/fixtures/mock-dispatcher.ts
366
+
367
+ ```ts
368
+ // In-memory dispatcher for wizard-form/e2e. Implements the dispatcher
369
+ // interface from @cosmicdrift/kumiko-renderer/headless without the HTTP layer, its
370
+ // own lean variant (no import from renderer/web/e2e — its fixtures are
371
+ // package-internal, not exported).
372
+ //
373
+ // Serves two QN formats:
374
+ // - 4-segment CRUD (`<feature>:<write|query>:<entity>:<verb>`) — only
375
+ // `listings:write:listing:create` is needed (no list/detail/
376
+ // update/delete for this spec, YAGNI).
377
+ // - 3-segment form-draft QNs (FormDraftHandlers/FormDraftQueries) —
378
+ // save/discard/get, exactly as packages/bundled-features/src/
379
+ // form-draft/constants.ts defines them.
380
+ //
381
+ // CRITICAL: the draft state does NOT live in the in-memory closure — a
382
+ // real page.reload() throws away the whole JS heap, so an in-memory mock
383
+ // would always return null after reload. The mock plays the server here,
384
+ // and persistence belongs on the server. Draft blobs land in localStorage
385
+ // (Playwright gives each test a fresh BrowserContext — automatically
386
+ // test-isolated). The listing CRUD table stays in-memory on purpose: it
387
+ // doesn't need to survive a reload, only the draft does.
388
+
389
+ import type {
390
+ BatchResult,
391
+ Command,
392
+ Dispatcher,
393
+ DispatcherStatus,
394
+ PendingFile,
395
+ PendingWrite,
396
+ QueryOpts,
397
+ QueryResult,
398
+ Store,
399
+ WriteOpts,
400
+ WriteResult,
401
+ } from "@cosmicdrift/kumiko-renderer/headless";
402
+
403
+ type Row = Record<string, unknown> & { id: string };
404
+
405
+ type FormDraftBlob = {
406
+ readonly values: Record<string, unknown>;
407
+ readonly stepIndex: number;
408
+ readonly savedAt: string;
409
+ };
410
+
411
+ const FORM_DRAFT_SAVE = "form-draft:write:save";
412
+ const FORM_DRAFT_DISCARD = "form-draft:write:discard";
413
+ const FORM_DRAFT_GET = "form-draft:query:get";
414
+ const FORM_DRAFT_LIST = "form-draft:query:list";
415
+ const DRAFT_STORAGE_PREFIX = "mock-form-draft:";
416
+ export const CREATED_LISTINGS_KEY = "mock-created-listings";
417
+
418
+ let nextId = 1;
419
+
420
+ function generateId(): string {
421
+ return `mock-${nextId++}`;
422
+ }
423
+
424
+ // Exported so the spec builds the same localStorage key instead of
425
+ // hardcoding the prefix — a rename here would otherwise silently break
426
+ // wizard.spec.ts's draft assertions without any test going red (fw#1911).
427
+ export function draftStorageKey(draftKey: string): string {
428
+ return `${DRAFT_STORAGE_PREFIX}${draftKey}`;
429
+ }
430
+
431
+ const ONLINE_STORE: Store<DispatcherStatus> = {
432
+ getSnapshot: () => "online",
433
+ subscribe: () => () => {},
434
+ };
435
+
436
+ export function createMockDispatcher(): Dispatcher {
437
+ const tables = new Map<string, Map<string, Row>>();
438
+
439
+ function getTable(entity: string): Map<string, Row> {
440
+ let table = tables.get(entity);
441
+ if (!table) {
442
+ table = new Map();
443
+ tables.set(entity, table);
444
+ }
445
+ return table;
446
+ }
447
+
448
+ async function write<TData = unknown>(
449
+ type: string,
450
+ payload: unknown,
451
+ _opts?: WriteOpts,
452
+ ): Promise<WriteResult<TData>> {
453
+ const data = (payload ?? {}) as Record<string, unknown>; // @cast-boundary mock-dispatcher wire payload
454
+
455
+ if (type === FORM_DRAFT_SAVE) {
456
+ const draftKey = data["draftKey"] as string;
457
+ const blob: FormDraftBlob = {
458
+ values: (data["values"] ?? {}) as Record<string, unknown>,
459
+ stepIndex: (data["stepIndex"] as number | undefined) ?? 0,
460
+ savedAt: Temporal.Now.instant().toString(),
461
+ };
462
+ localStorage.setItem(draftStorageKey(draftKey), JSON.stringify(blob));
463
+ return { isSuccess: true, data: {} as unknown as TData };
464
+ }
465
+ if (type === FORM_DRAFT_DISCARD) {
466
+ const draftKey = data["draftKey"] as string;
467
+ localStorage.removeItem(draftStorageKey(draftKey));
468
+ return { isSuccess: true, data: {} as unknown as TData };
469
+ }
470
+
471
+ const parts = type.split(":");
472
+ if (parts.length < 4) {
473
+ throw new Error(
474
+ `mock-dispatcher: invalid write qn "${type}" — expected <feature>:write:<entity>:<verb>`,
475
+ );
476
+ }
477
+ const [, , entity, verb] = parts as [string, string, string, string];
478
+ if (verb !== "create") {
479
+ throw new Error(`mock-dispatcher: unsupported write verb "${verb}" (qn=${type})`);
480
+ }
481
+ const table = getTable(entity);
482
+ const id = (data["id"] as string | undefined) ?? generateId();
483
+ const row: Row = { ...data, id };
484
+ table.set(id, row);
485
+ // Mirrored to localStorage so e2e specs can assert the create payload
486
+ // from Playwright's page.evaluate() without reaching into this closure.
487
+ const created = JSON.parse(localStorage.getItem(CREATED_LISTINGS_KEY) ?? "[]") as Row[];
488
+ created.push(row);
489
+ localStorage.setItem(CREATED_LISTINGS_KEY, JSON.stringify(created));
490
+ return { isSuccess: true, data: row as unknown as TData };
491
+ }
492
+
493
+ async function query<TData = unknown>(
494
+ type: string,
495
+ payload: unknown,
496
+ _opts?: QueryOpts,
497
+ ): Promise<QueryResult<TData>> {
498
+ if (type === FORM_DRAFT_GET) {
499
+ const data = (payload ?? {}) as Record<string, unknown>; // @cast-boundary mock-dispatcher wire payload
500
+ const draftKey = data["draftKey"] as string;
501
+ const raw = localStorage.getItem(draftStorageKey(draftKey));
502
+ const draft = raw === null ? null : (JSON.parse(raw) as FormDraftBlob);
503
+ return { isSuccess: true, data: { draft } as unknown as TData };
504
+ }
505
+ if (type === FORM_DRAFT_LIST) {
506
+ // Mirrors the real handler's LIKE-prefix scan (issue #1913's
507
+ // RenderEdit mount-time fallback needs this for create-mode wizards
508
+ // whose sessionStorage-held draftId was lost — new tab, cleared
509
+ // storage) — same source of truth as FORM_DRAFT_SAVE/GET above.
510
+ const data = (payload ?? {}) as Record<string, unknown>; // @cast-boundary mock-dispatcher wire payload
511
+ const screenId = data["screenId"] as string;
512
+ const prefix = draftStorageKey(`${screenId}:`);
513
+ const drafts: Array<{
514
+ readonly id: string;
515
+ readonly draftKey: string;
516
+ readonly stepIndex: number;
517
+ readonly savedAt: string;
518
+ }> = [];
519
+ for (let i = 0; i < localStorage.length; i += 1) {
520
+ const key = localStorage.key(i);
521
+ if (key === null || !key.startsWith(prefix)) continue;
522
+ const raw = localStorage.getItem(key);
523
+ if (raw === null) continue;
524
+ const blob = JSON.parse(raw) as FormDraftBlob;
525
+ const draftKey = key.slice(DRAFT_STORAGE_PREFIX.length);
526
+ drafts.push({ id: draftKey, draftKey, stepIndex: blob.stepIndex, savedAt: blob.savedAt });
527
+ }
528
+ return { isSuccess: true, data: { drafts } as unknown as TData };
529
+ }
530
+ throw new Error(`mock-dispatcher: unsupported query qn "${type}"`);
531
+ }
532
+
533
+ async function batch(commands: readonly Command[], opts?: WriteOpts): Promise<BatchResult> {
534
+ const results: WriteResult[] = [];
535
+ for (const cmd of commands) results.push(await write(cmd.type, cmd.payload, opts));
536
+ return { isSuccess: true, results };
537
+ }
538
+
539
+ return {
540
+ write,
541
+ query,
542
+ batch,
543
+ statusStore: ONLINE_STORE,
544
+ async *stream() {},
545
+ pendingWrites: (): readonly PendingWrite[] => [],
546
+ pendingFiles: (): readonly PendingFile[] => [],
547
+ };
548
+ }
549
+ ```
550
+
551
+ ### e2e/playwright.config.ts
552
+
553
+ ```ts
554
+ // Playwright config for wizard-form/e2e. Boots the minimal build-server
555
+ // (see e2e/build-server.ts) — a real browser + real React bundle against
556
+ // a MockDispatcher, no full Kumiko stack. Pattern copied from
557
+ // packages/renderer/playwright.config.ts.
558
+ //
559
+ // Port 4188: see the build-server.ts comment for the port allocation
560
+ // overview of the other samples/apps ports.
561
+ //
562
+ // Unlike the renderer/web blueprint (config sits at the package root, e2e/
563
+ // is only testDir), this config lives IN e2e/ itself — Playwright's
564
+ // webServer.command runs by default with cwd = the config's directory,
565
+ // i.e. relative to e2e/ itself (not to the package root). testDir
566
+ // therefore stays "." instead of "./e2e", and webServer.command calls
567
+ // build-server.ts without an "e2e/" prefix.
568
+
569
+ import { defineConfig, devices } from "@playwright/test";
570
+
571
+ const PORT = 4188;
572
+ const BASE_URL = `http://localhost:${PORT}`;
573
+
574
+ export default defineConfig({
575
+ testDir: ".",
576
+ // build-server.ts + fixtures/* are not a test — Playwright must ignore the build path.
577
+ testIgnore: ["**/fixtures/**", "build-server.ts"],
578
+ fullyParallel: false,
579
+ forbidOnly: !!process.env["CI"],
580
+ retries: 0,
581
+ workers: 1,
582
+ reporter: [["list"]],
583
+ timeout: 15_000,
584
+ expect: { timeout: 3_000 },
585
+
586
+ use: {
587
+ baseURL: BASE_URL,
588
+ trace: "retain-on-failure",
589
+ screenshot: "only-on-failure",
590
+ actionTimeout: 5_000,
591
+ navigationTimeout: 10_000,
592
+ },
593
+
594
+ projects: [
595
+ {
596
+ name: "chromium",
597
+ // Chromium runs desktop wizard specs only — mobile-375 owns wizard-mobile.spec.ts.
598
+ testMatch: ["**/wizard.spec.ts"],
599
+ use: { ...devices["Desktop Chrome"] },
600
+ },
601
+ // iPhone SE/Mini class (375px) — acceptance criterion from #1917:
602
+ // Chrome (progress/back/next/step title) renders correctly at the
603
+ // narrowest common phone width, and the Next button stays reachable
604
+ // when the virtual keyboard shrinks the viewport. Desktop Chrome base
605
+ // instead of a mobile device descriptor — avoids isMobile/hasTouch
606
+ // side effects (touch events instead of click) that pure layout
607
+ // assertions don't need.
608
+ {
609
+ name: "mobile-375",
610
+ testMatch: ["wizard-mobile.spec.ts"],
611
+ use: { ...devices["Desktop Chrome"], viewport: { width: 375, height: 667 } },
612
+ },
613
+ ],
614
+
615
+ webServer: {
616
+ command: "bun build-server.ts",
617
+ url: BASE_URL,
618
+ env: { PORT: String(PORT) },
619
+ reuseExistingServer: !process.env["CI"],
620
+ timeout: 30_000,
621
+ stdout: "pipe",
622
+ stderr: "pipe",
623
+ },
624
+ });
625
+ ```
626
+
627
+ ### e2e/wizard-mobile.spec.ts
628
+
629
+ ```ts
630
+ // Wizard Form Sample — 375px mobile chrome (CosmicDriftGameStudio/kumiko-framework#1917)
631
+ //
632
+ // Acceptance from the Offlot counter-check (#1908): at the narrowest common
633
+ // phone width (iPhone SE/mini class, 375px), the progress indicator,
634
+ // back/next buttons, and step title must render correctly (no horizontal
635
+ // overflow, no overlap), and the next button must not disappear under the
636
+ // virtual keyboard after a text field gains focus. Pure test-gap fix against
637
+ // chrome already merged in #1886 — no new functionality.
638
+ //
639
+ // Runs in its own "mobile-375" Playwright project (see playwright.config.ts)
640
+ // against the same build-server as wizard.spec.ts, now with REAL Tailwind
641
+ // CSS instead of the earlier empty /styles.css stub (see the build-server.ts
642
+ // comment) — without real CSS, the layout assertions here would be fake
643
+ // assertions against unstyled browser default flow.
644
+
645
+ import { expect, boundaryGuardedTest as test } from "@cosmicdrift/kumiko-testing/e2e";
646
+ import type { Locator, Page } from "@playwright/test";
647
+ import { selectCombobox } from "./_helpers/select-combobox";
648
+
649
+ async function gotoWizard(page: Page): Promise<void> {
650
+ await page.goto("/listing-wizard");
651
+ await expect(page.getByTestId("render-edit-form")).toBeVisible();
652
+ }
653
+
654
+ function rectsOverlap(a: DOMRectLike, b: DOMRectLike): boolean {
655
+ return !(a.right <= b.left || b.right <= a.left || a.bottom <= b.top || b.bottom <= a.top);
656
+ }
657
+
658
+ type DOMRectLike = { top: number; bottom: number; left: number; right: number };
659
+
660
+ async function boundingRect(locator: Locator): Promise<DOMRectLike> {
661
+ const box = await locator.boundingBox();
662
+ if (box === null) throw new Error("boundingRect: element has no box (not rendered/visible)");
663
+ return { top: box.y, bottom: box.y + box.height, left: box.x, right: box.x + box.width };
664
+ }
665
+
666
+ async function assertNoOverlap(a: Locator, b: Locator): Promise<void> {
667
+ const [rectA, rectB] = await Promise.all([boundingRect(a), boundingRect(b)]);
668
+ expect(rectsOverlap(rectA, rectB)).toBe(false);
669
+ }
670
+
671
+ // Guard against a vacuously-green run: cardFooter (packages/renderer/
672
+ // src/web/primitives/index.tsx) sets "flex" on the actions footer. Without
673
+ // compiled Tailwind CSS this would stay the browser default "block" — the
674
+ // following layout assertions would then run against unstyled markup
675
+ // instead of the real chrome.
676
+ async function assertCssIsLive(page: Page): Promise<void> {
677
+ const display = await page
678
+ .getByTestId("render-edit-form-actions")
679
+ .evaluate((el) => getComputedStyle(el).display);
680
+ expect(display).toBe("flex");
681
+ }
682
+
683
+ test.describe("wizard-form — 375px mobile chrome (#1917)", () => {
684
+ test("step 1: step label and Next fit the viewport without overlap", async ({ page }) => {
685
+ await gotoWizard(page);
686
+ await assertCssIsLive(page);
687
+
688
+ // Below lg the step rail collapses to the compact step label; the
689
+ // progress bar no longer exists on screen-form wizards.
690
+ await expect(page.getByTestId("render-edit-wizard-progress")).toHaveCount(0);
691
+ const stepLabel = page.getByTestId("render-edit-wizard-step-label");
692
+ const next = page.getByTestId("render-edit-wizard-next");
693
+
694
+ await expect(stepLabel).toBeVisible();
695
+ await expect(stepLabel).toHaveText("Step 1 of 3 · Basics");
696
+ await expect(next).toBeVisible();
697
+ // Step 1 has no Back button — asserted by the desktop spec too.
698
+ await expect(page.getByTestId("render-edit-wizard-back")).toHaveCount(0);
699
+
700
+ const overflowsHorizontally = await page.evaluate(
701
+ () => document.documentElement.scrollWidth > document.documentElement.clientWidth,
702
+ );
703
+ expect(overflowsHorizontally).toBe(false);
704
+
705
+ await assertNoOverlap(stepLabel, next);
706
+ });
707
+
708
+ test("step 2: Back and Next render side by side without overlapping", async ({ page }) => {
709
+ await gotoWizard(page);
710
+ await assertCssIsLive(page);
711
+ await page.getByTestId("field-title").locator("input").fill("Vintage desk lamp");
712
+ await selectCombobox(page, "category", "furniture");
713
+ await page.getByTestId("render-edit-wizard-next").click();
714
+ await expect(page.getByTestId("render-edit-wizard-step-label")).toHaveText(
715
+ "Step 2 of 3 · Pricing",
716
+ );
717
+
718
+ const back = page.getByTestId("render-edit-wizard-back");
719
+ const next = page.getByTestId("render-edit-wizard-next");
720
+ await expect(back).toBeVisible();
721
+ await expect(next).toBeVisible();
722
+ await assertNoOverlap(back, next);
723
+ });
724
+
725
+ test("Next stays reachable after focusing a field and the viewport shrinking (simulated keyboard)", async ({
726
+ page,
727
+ }) => {
728
+ await gotoWizard(page);
729
+ await assertCssIsLive(page);
730
+
731
+ const titleInput = page.getByTestId("field-title").locator("input");
732
+ await titleInput.fill("Vintage desk lamp");
733
+ await selectCombobox(page, "category", "furniture");
734
+ await titleInput.focus();
735
+
736
+ // Simulates a virtual keyboard covering roughly the bottom half of an
737
+ // iPhone SE screen (667 -> 300 visible height) — the standard Playwright
738
+ // pattern for mobile-keyboard testing (real devices don't expose keyboard
739
+ // height to the page, so viewport-shrink is the closest approximation).
740
+ await page.setViewportSize({ width: 375, height: 300 });
741
+
742
+ const next = page.getByTestId("render-edit-wizard-next");
743
+ // No scrollIntoView before this assertion — that would make it trivially
744
+ // pass regardless of layout. toBeInViewport() checks the button is
745
+ // actually inside the now-shrunk visual area, unassisted.
746
+ await expect(next).toBeInViewport();
747
+
748
+ // Functional confirmation: the button isn't just geometrically in
749
+ // bounds, it's actually clickable and advances the wizard.
750
+ await next.click();
751
+ await expect(page.getByTestId("render-edit-wizard-step-label")).toHaveText(
752
+ "Step 2 of 3 · Pricing",
753
+ );
754
+ });
755
+ });
756
+ ```
757
+
758
+ ### e2e/wizard.spec.ts
759
+
760
+ ```ts
761
+ // Wizard Form Sample — E2E
762
+ //
763
+ // Boots the real client bundle (real React tree, real `EditLayout.mode:
764
+ // "wizard"` step transitions, real form-draft save/resume wiring in
765
+ // RenderEdit) against a MockDispatcher — see e2e/build-server.ts +
766
+ // e2e/fixtures/*. Proves what an integration test against the handler
767
+ // contract can't: that a user can actually click through the wizard, that
768
+ // a blocked step really keeps its error on screen instead of advancing,
769
+ // and that a page reload resumes exactly where the draft left off.
770
+ //
771
+ // draftKey for create-mode = `${screen.id}:new:${draftId}` (issue #1913) —
772
+ // `draftId` is a client-minted UUID (see render-edit.tsx's `draftKey`
773
+ // useMemo), so this spec matches on the `listing-wizard:new:` prefix
774
+ // rather than a single literal key.
775
+
776
+ import { expect, boundaryGuardedTest as test } from "@cosmicdrift/kumiko-testing/e2e";
777
+ import type { Page } from "@playwright/test";
778
+ import { expectSelectedOption, selectCombobox } from "./_helpers/select-combobox";
779
+ import { CREATED_LISTINGS_KEY, draftStorageKey } from "./fixtures/mock-dispatcher";
780
+
781
+ const DRAFT_STORAGE_PREFIX = draftStorageKey("listing-wizard:new:");
782
+
783
+ async function gotoWizard(page: Page): Promise<void> {
784
+ await page.goto("/listing-wizard");
785
+ await expect(page.getByTestId("render-edit-form")).toBeVisible();
786
+ }
787
+
788
+ async function draftInStorage(page: Page): Promise<unknown> {
789
+ // The draftId is a client-minted UUID (issue #1913), unknown ahead of
790
+ // time — scan for the one key under this screen's create-mode prefix
791
+ // instead of matching a fixed key.
792
+ return page.evaluate((prefix) => {
793
+ for (let i = 0; i < localStorage.length; i += 1) {
794
+ const key = localStorage.key(i);
795
+ if (key?.startsWith(prefix)) return localStorage.getItem(key);
796
+ }
797
+ return null;
798
+ }, DRAFT_STORAGE_PREFIX);
799
+ }
800
+
801
+ async function createdListings(page: Page): Promise<Record<string, unknown>[]> {
802
+ const raw = await page.evaluate((key) => localStorage.getItem(key), CREATED_LISTINGS_KEY);
803
+ return raw === null ? [] : (JSON.parse(raw) as Record<string, unknown>[]);
804
+ }
805
+
806
+ test.describe("wizard-form — step navigation, validation, draft resume", () => {
807
+ test("clicking Next/Back moves forward and back through all 3 steps", async ({ page }) => {
808
+ await gotoWizard(page);
809
+
810
+ // Step 1 — Basics
811
+ await expect(page.getByTestId("render-edit-wizard-step-label")).toHaveText(
812
+ "Step 1 of 3 · Basics",
813
+ );
814
+ await expect(page.getByTestId("render-edit-wizard-back")).toHaveCount(0);
815
+ await page.getByTestId("field-title").locator("input").fill("Vintage desk lamp");
816
+ await selectCombobox(page, "category", "furniture");
817
+ await expect(page.getByTestId("field-title").locator("input")).toHaveValue("Vintage desk lamp");
818
+ await expectSelectedOption(page, "category", "furniture");
819
+ await page.getByTestId("render-edit-wizard-next").click();
820
+
821
+ // Step 2 — Pricing
822
+ await expect(page.getByTestId("render-edit-wizard-step-label")).toHaveText(
823
+ "Step 2 of 3 · Pricing",
824
+ );
825
+ // Regression for the `hidden` prop fix in primitives/index.tsx's
826
+ // DefaultSection: the prop used to be accepted but never wired into the
827
+ // DOM at all, so an inactive step stayed fully visible/interactive.
828
+ // toBeHidden() checks computed CSS visibility, not just DOM attributes.
829
+ await expect(page.getByTestId("field-title")).toBeHidden();
830
+ await page.getByTestId("field-price").locator("input").fill("42");
831
+ await selectCombobox(page, "condition", "used");
832
+ await expect(page.getByTestId("field-price").locator("input")).toHaveValue("42");
833
+ await expectSelectedOption(page, "condition", "used");
834
+ await page.getByTestId("render-edit-wizard-next").click();
835
+
836
+ // Step 3 — Review
837
+ await expect(page.getByTestId("render-edit-wizard-step-label")).toHaveText(
838
+ "Step 3 of 3 · Review",
839
+ );
840
+ await expect(page.getByTestId("listing-review")).toBeVisible();
841
+ await expect(page.getByTestId("render-edit-submit")).toBeVisible();
842
+ await expect(page.getByTestId("render-edit-wizard-next")).toHaveCount(0);
843
+ // Back to Pricing, back to Basics.
844
+ await page.getByTestId("render-edit-wizard-back").click();
845
+ await expect(page.getByTestId("render-edit-wizard-step-label")).toHaveText(
846
+ "Step 2 of 3 · Pricing",
847
+ );
848
+ await page.getByTestId("render-edit-wizard-back").click();
849
+ await expect(page.getByTestId("render-edit-wizard-step-label")).toHaveText(
850
+ "Step 1 of 3 · Basics",
851
+ );
852
+ });
853
+
854
+ test("desktop viewport shows wizard step chips with aria-current", async ({ page }) => {
855
+ await page.setViewportSize({ width: 1280, height: 800 });
856
+ await gotoWizard(page);
857
+ const step0 = page.getByTestId("render-edit-wizard-steps-step-0");
858
+ await expect(step0).toBeVisible();
859
+ await expect(step0).toHaveAttribute("aria-current", "step");
860
+ await expect(page.getByTestId("render-edit-wizard-step-label")).toBeHidden();
861
+
862
+ await page.getByTestId("field-title").locator("input").fill("Vintage desk lamp");
863
+ await selectCombobox(page, "category", "furniture");
864
+ await page.getByTestId("render-edit-wizard-next").click();
865
+
866
+ await expect(page.getByTestId("render-edit-wizard-steps-step-1")).toHaveAttribute(
867
+ "aria-current",
868
+ "step",
869
+ );
870
+ });
871
+
872
+ test("the step rail keeps its own 36px row height inside the wizard's padded chrome and follows the step", async ({
873
+ page,
874
+ }) => {
875
+ // Screen-form wizards render a vertical step rail instead of the
876
+ // progress bar. Same regression class as fw#1963/#1967 (the progress bar's
877
+ // `h-2` used to be eaten by the chrome's padding): a rail row (`h-9`) must
878
+ // keep its own box inside the padded chrome. A jsdom unit test can't catch
879
+ // this (layout returns 0 there); only a real browser box model does.
880
+ await gotoWizard(page);
881
+ const rail = page.getByTestId("render-edit-wizard-steps");
882
+ const step0 = page.getByTestId("render-edit-wizard-steps-step-0");
883
+ const step1 = page.getByTestId("render-edit-wizard-steps-step-1");
884
+
885
+ await expect(rail).toBeVisible();
886
+ await expect(page.getByTestId("render-edit-wizard-progress")).toHaveCount(0);
887
+ await expect(step0).toHaveAttribute("aria-current", "step");
888
+ await expect(step0).toHaveCSS("height", "36px");
889
+ await expect(step1).toHaveCSS("height", "36px");
890
+
891
+ await page.getByTestId("field-title").locator("input").fill("Vintage desk lamp");
892
+ await selectCombobox(page, "category", "furniture");
893
+ await page.getByTestId("render-edit-wizard-next").click();
894
+
895
+ await expect(step1).toHaveAttribute("aria-current", "step");
896
+ await expect(step0).not.toHaveAttribute("aria-current", "step");
897
+ await expect(step1).toHaveCSS("height", "36px");
898
+ });
899
+
900
+ test("an empty required field blocks Next and shows a field error", async ({ page }) => {
901
+ await gotoWizard(page);
902
+ // Fill category (also required) so the block is attributable to title
903
+ // alone, not "any required field in the step is empty".
904
+ await selectCombobox(page, "category", "electronics");
905
+
906
+ await page.getByTestId("render-edit-wizard-next").click();
907
+
908
+ await expect(page.getByTestId("field-title-errors")).toBeVisible();
909
+ await expect(page.getByTestId("field-title-errors")).toHaveAttribute("role", "alert");
910
+ await expect(page.getByTestId("render-edit-wizard-step-label")).toHaveText(
911
+ "Step 1 of 3 · Basics",
912
+ );
913
+ });
914
+
915
+ // Per-step JS validation (handleWizardNext -> controller.validate(currentStepFields))
916
+ // blocking a middle step, not just step 1 — a real-browser twin of the
917
+ // jsdom unit-test coverage for fw#1910.
918
+ test("a later step's own empty required field blocks Next from advancing past it", async ({
919
+ page,
920
+ }) => {
921
+ await gotoWizard(page);
922
+ await page.getByTestId("field-title").locator("input").fill("Vintage desk lamp");
923
+ await selectCombobox(page, "category", "furniture");
924
+ await page.getByTestId("render-edit-wizard-next").click();
925
+ await expect(page.getByTestId("render-edit-wizard-step-label")).toHaveText(
926
+ "Step 2 of 3 · Pricing",
927
+ );
928
+
929
+ // price defaults to 0 (a valid value for a required number field) —
930
+ // must be actively cleared to exercise the empty-required-field path,
931
+ // not just left untouched.
932
+ await page.getByTestId("field-price").locator("input").fill("");
933
+ await selectCombobox(page, "condition", "new");
934
+ await page.getByTestId("render-edit-wizard-next").click();
935
+
936
+ // Still on step 2 — Review (step 3) stays mounted-but-hidden, same as
937
+ // an inactive step's fields elsewhere in this spec, not visible.
938
+ await expect(page.getByTestId("render-edit-wizard-step-label")).toHaveText(
939
+ "Step 2 of 3 · Pricing",
940
+ );
941
+ await expect(page.getByTestId("listing-review")).toBeHidden();
942
+ await expect(page.getByTestId("field-price-errors")).toBeVisible();
943
+ });
944
+
945
+ test("values survive navigating back to a previous step", async ({ page }) => {
946
+ await gotoWizard(page);
947
+ await page.getByTestId("field-title").locator("input").fill("Old bicycle");
948
+ await selectCombobox(page, "category", "vehicles");
949
+ await page.getByTestId("render-edit-wizard-next").click();
950
+ await expect(page.getByTestId("render-edit-wizard-step-label")).toHaveText(
951
+ "Step 2 of 3 · Pricing",
952
+ );
953
+
954
+ await page.getByTestId("render-edit-wizard-back").click();
955
+
956
+ await expect(page.getByTestId("render-edit-wizard-step-label")).toHaveText(
957
+ "Step 1 of 3 · Basics",
958
+ );
959
+ await expect(page.getByTestId("field-title").locator("input")).toHaveValue("Old bicycle");
960
+ });
961
+
962
+ test("a reload mid-wizard resumes at the same step with the same values", async ({ page }) => {
963
+ await gotoWizard(page);
964
+ await page.getByTestId("field-title").locator("input").fill("Draft desk lamp");
965
+ await selectCombobox(page, "category", "furniture");
966
+ await page.getByTestId("render-edit-wizard-next").click();
967
+ // saveDraft() is fire-and-forget (`void dispatcher.write(...)` in
968
+ // render-edit.tsx) — wait for the step to actually change before
969
+ // reloading so the reload doesn't race an in-flight draft save.
970
+ await expect(page.getByTestId("render-edit-wizard-step-label")).toHaveText(
971
+ "Step 2 of 3 · Pricing",
972
+ );
973
+
974
+ await page.reload();
975
+
976
+ await expect(page.getByTestId("render-edit-form")).toBeVisible();
977
+ await expect(page.getByTestId("render-edit-wizard-step-label")).toHaveText(
978
+ "Step 2 of 3 · Pricing",
979
+ );
980
+ await page.getByTestId("render-edit-wizard-back").click();
981
+ await expect(page.getByTestId("field-title").locator("input")).toHaveValue("Draft desk lamp");
982
+ });
983
+
984
+ test("submit creates the listing and discards the draft — reopening starts fresh", async ({
985
+ page,
986
+ }) => {
987
+ await gotoWizard(page);
988
+ await page.getByTestId("field-title").locator("input").fill("Submit test lamp");
989
+ await selectCombobox(page, "category", "furniture");
990
+ await page.getByTestId("render-edit-wizard-next").click();
991
+ await expect(page.getByTestId("render-edit-wizard-step-label")).toHaveText(
992
+ "Step 2 of 3 · Pricing",
993
+ );
994
+
995
+ await page.getByTestId("field-price").locator("input").fill("99");
996
+ await selectCombobox(page, "condition", "new");
997
+ await page.getByTestId("render-edit-wizard-next").click();
998
+ await expect(page.getByTestId("render-edit-wizard-step-label")).toHaveText(
999
+ "Step 3 of 3 · Review",
1000
+ );
1001
+
1002
+ await expect.poll(() => draftInStorage(page)).not.toBeNull();
1003
+ await page.getByTestId("render-edit-submit").click();
1004
+ // discardDraft() is awaited before onSubmit fires (render-edit.tsx:
1005
+ // `await discardDraft()` runs before `onSubmit?.(result)`) — poll
1006
+ // localStorage directly instead of a UI signal, this recipe registers
1007
+ // no entityList screen so there's no post-submit navigation to wait out.
1008
+ await expect.poll(() => draftInStorage(page)).toBeNull();
1009
+
1010
+ // The create payload actually reached the dispatcher with values
1011
+ // accumulated across all three steps — not just that the draft got
1012
+ // discarded (discard also fires on a failed write in other flows).
1013
+ const listings = await createdListings(page);
1014
+ expect(listings).toHaveLength(1);
1015
+ expect(listings[0]).toMatchObject({
1016
+ title: "Submit test lamp",
1017
+ category: "furniture",
1018
+ price: 99,
1019
+ condition: "new",
1020
+ });
1021
+
1022
+ await page.reload();
1023
+
1024
+ await expect(page.getByTestId("render-edit-wizard-step-label")).toHaveText(
1025
+ "Step 1 of 3 · Basics",
1026
+ );
1027
+ await expect(page.getByTestId("field-title").locator("input")).toHaveValue("");
1028
+ });
1029
+ });
1030
+ ```
1031
+
91
1032
  ### src/__tests__/feature.integration.test.ts
92
1033
 
93
1034
  ```ts