@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.
- package/AGENTS.md +9 -1
- package/agents/examples/anonymous-access.md +1 -1
- package/agents/examples/apex-landing.md +204 -0
- package/agents/examples/api-worker-split.md +64 -0
- package/agents/examples/app-config.md +14 -0
- package/agents/examples/auth-mfa.md +115 -0
- package/agents/examples/env-composition.md +28 -1
- package/agents/examples/golden-path.md +13 -1
- package/agents/examples/legal-pages.md +1 -1
- package/agents/examples/lifecycle-hooks.md +55 -2
- package/agents/examples/list-table-options.md +466 -0
- package/agents/examples/pdf-audit-export.md +4 -2
- package/agents/examples/prod-boot-options.md +16 -0
- package/agents/examples/rate-limiting.md +48 -0
- package/agents/examples/record-detail-layout.md +692 -0
- package/agents/examples/seed-migration.md +212 -0
- package/agents/examples/state-machine.md +3 -2
- package/agents/examples/tenant-isolation.md +73 -1
- package/agents/examples/wizard-form.md +941 -0
- package/agents/examples/writeform-section.md +548 -0
- package/agents/examples.md +1 -0
- package/agents/features.md +19 -2
- package/agents/widgets.md +2 -0
- package/dist/api/api-constants.d.ts +1 -0
- package/dist/api/api-constants.js +2 -0
- package/dist/api/auth-routes-shared.d.ts +2 -0
- package/dist/api/auth-routes-shared.js +5 -0
- package/dist/api/client-error-route.js +10 -5
- package/dist/api/entity-read-access.d.ts +2 -1
- package/dist/api/entity-read-access.js +14 -0
- package/dist/api/export/export-cells-reference.d.ts +9 -0
- package/dist/api/export/export-cells-reference.js +21 -0
- package/dist/api/export/export-cells.d.ts +11 -0
- package/dist/api/export/export-cells.js +136 -0
- package/dist/api/export/export-ciphertext-guard.d.ts +2 -0
- package/dist/api/export/export-ciphertext-guard.js +27 -0
- package/dist/api/export/export-columns.d.ts +29 -0
- package/dist/api/export/export-columns.js +85 -0
- package/dist/api/export/export-errors.d.ts +4 -0
- package/dist/api/export/export-errors.js +14 -0
- package/dist/api/export/export-route.d.ts +4 -0
- package/dist/api/export/export-route.js +74 -0
- package/dist/api/export/export-rows.d.ts +13 -0
- package/dist/api/export/export-rows.js +62 -0
- package/dist/api/export/export-screen.d.ts +12 -0
- package/dist/api/export/export-screen.js +47 -0
- package/dist/api/export/export-xlsx-writer.d.ts +4 -0
- package/dist/api/export/export-xlsx-writer.js +21 -0
- package/dist/api/extra-route.d.ts +4 -0
- package/dist/api/index.d.ts +3 -1
- package/dist/api/index.js +3 -1
- package/dist/api/pii-leak-guard.d.ts +3 -0
- package/dist/api/pii-leak-guard.js +3 -2
- package/dist/api/request-context.d.ts +1 -1
- package/dist/api/request-context.js +1 -1
- package/dist/api/routes.d.ts +16 -0
- package/dist/api/routes.js +2 -2
- package/dist/api/server-boot-checks.d.ts +10 -0
- package/dist/api/server-boot-checks.js +157 -0
- package/dist/api/server-event-dispatcher.d.ts +26 -0
- package/dist/api/server-event-dispatcher.js +208 -0
- package/dist/api/server-extra-routes.d.ts +11 -0
- package/dist/api/server-extra-routes.js +206 -0
- package/dist/api/server-middleware.d.ts +20 -0
- package/dist/api/server-middleware.js +211 -0
- package/dist/api/server-routes.d.ts +19 -0
- package/dist/api/server-routes.js +299 -0
- package/dist/api/server-websocket-routes.d.ts +4 -0
- package/dist/api/server-websocket-routes.js +108 -0
- package/dist/api/server-wiring.d.ts +32 -0
- package/dist/api/server-wiring.js +238 -0
- package/dist/api/server.d.ts +10 -15
- package/dist/api/server.js +48 -1251
- package/dist/api/session-cookies.js +4 -4
- package/dist/api/sse-route.d.ts +2 -1
- package/dist/api/sse-route.js +27 -13
- package/dist/config/index.d.ts +3 -1
- package/dist/crypto/owner-transition-rekey.d.ts +5 -1
- package/dist/crypto/owner-transition-rekey.js +1 -1
- package/dist/crypto/pg-kms-adapter.d.ts +1 -0
- package/dist/crypto/pg-kms-adapter.js +12 -18
- package/dist/crypto/pii-field-encryption.d.ts +5 -1
- package/dist/crypto/pii-field-encryption.js +3 -1
- package/dist/db/aggregate-sql.d.ts +23 -0
- package/dist/db/aggregate-sql.js +165 -0
- package/dist/db/closed-connection.d.ts +9 -0
- package/dist/db/closed-connection.js +88 -0
- package/dist/db/connection.d.ts +4 -5
- package/dist/db/connection.js +29 -20
- package/dist/db/dialect.js +1 -1
- package/dist/db/distinct-on.d.ts +1 -1
- package/dist/db/driver-row-coercion.d.ts +2 -0
- package/dist/db/driver-row-coercion.js +186 -0
- package/dist/db/driver-value-preparation.d.ts +16 -0
- package/dist/db/driver-value-preparation.js +101 -0
- package/dist/db/entity-field-encryption.d.ts +1 -0
- package/dist/db/entity-field-encryption.js +1 -1
- package/dist/db/entity-table-meta.js +103 -110
- package/dist/db/event-store-executor-context.d.ts +2 -1
- package/dist/db/event-store-executor-read-aggregate-currencies.d.ts +17 -0
- package/dist/db/event-store-executor-read-aggregate-currencies.js +40 -0
- package/dist/db/event-store-executor-read-aggregate.d.ts +8 -0
- package/dist/db/event-store-executor-read-aggregate.js +143 -0
- package/dist/db/event-store-executor-read-filter.d.ts +31 -0
- package/dist/db/event-store-executor-read-filter.js +206 -0
- package/dist/db/event-store-executor-read-paging.d.ts +14 -0
- package/dist/db/event-store-executor-read-paging.js +99 -0
- package/dist/db/event-store-executor-read-rows.d.ts +6 -0
- package/dist/db/event-store-executor-read-rows.js +32 -0
- package/dist/db/event-store-executor-read-search.d.ts +28 -0
- package/dist/db/event-store-executor-read-search.js +221 -0
- package/dist/db/event-store-executor-read-sort.d.ts +41 -0
- package/dist/db/event-store-executor-read-sort.js +130 -0
- package/dist/db/event-store-executor-read-where.d.ts +24 -0
- package/dist/db/event-store-executor-read-where.js +81 -0
- package/dist/db/event-store-executor-read.d.ts +2 -9
- package/dist/db/event-store-executor-read.js +48 -701
- package/dist/db/event-store-executor-write-atomic.d.ts +4 -0
- package/dist/db/event-store-executor-write-atomic.js +41 -0
- package/dist/db/event-store-executor-write-steps.d.ts +17 -0
- package/dist/db/event-store-executor-write-steps.js +95 -0
- package/dist/db/event-store-executor-write.js +405 -509
- package/dist/db/event-store-executor.js +2 -1
- package/dist/db/executor-managed-tables.js +1 -1
- package/dist/db/index.d.ts +7 -3
- package/dist/db/index.js +6 -2
- package/dist/db/list-aggregate-labels.d.ts +14 -0
- package/dist/db/list-aggregate-labels.js +26 -0
- package/dist/db/migrate-generator.js +71 -86
- package/dist/db/migrate-runner.js +7 -4
- package/dist/db/pg-error.js +26 -21
- package/dist/db/queries/backfill-pii.js +3 -6
- package/dist/db/queries/database-now.js +2 -1
- package/dist/db/queries/shadow-swap.js +2 -3
- package/dist/db/query.d.ts +16 -15
- package/dist/db/query.js +76 -804
- package/dist/db/reference-data.js +19 -2
- package/dist/db/statement-timeout.js +2 -1
- package/dist/db/table-builder.js +113 -166
- package/dist/db/table-info.d.ts +10 -0
- package/dist/db/table-info.js +45 -0
- package/dist/db/temporal-value-guards.d.ts +6 -0
- package/dist/db/temporal-value-guards.js +15 -0
- package/dist/db/tenant-db-runner.d.ts +1 -0
- package/dist/db/tenant-db-runner.js +3 -0
- package/dist/db/tenant-db-upsert.d.ts +5 -0
- package/dist/db/tenant-db-upsert.js +91 -0
- package/dist/db/tenant-db.js +50 -10
- package/dist/db/where-clause.d.ts +6 -0
- package/dist/db/where-clause.js +236 -0
- package/dist/derivatives/delete-stored-file.js +2 -1
- package/dist/engine/boot-validator/boot-check.js +1 -1
- package/dist/engine/boot-validator/bundled-lists-table.d.ts +2 -0
- package/dist/engine/boot-validator/bundled-lists-table.js +36 -0
- package/dist/engine/boot-validator/detail-screens.js +2 -2
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +17 -0
- package/dist/engine/boot-validator/entity-list-aggregate-options.d.ts +3 -0
- package/dist/engine/boot-validator/entity-list-aggregate-options.js +23 -0
- package/dist/engine/boot-validator/form-derivations.js +35 -1
- package/dist/engine/boot-validator/i18n-keys.js +5 -3
- package/dist/engine/boot-validator/index.js +2 -0
- package/dist/engine/boot-validator/list-bulk-actions.d.ts +3 -0
- package/dist/engine/boot-validator/list-bulk-actions.js +49 -0
- package/dist/engine/boot-validator/list-column-layout.d.ts +8 -0
- package/dist/engine/boot-validator/list-column-layout.js +41 -0
- package/dist/engine/boot-validator/list-table-aggregate-options.d.ts +24 -0
- package/dist/engine/boot-validator/list-table-aggregate-options.js +145 -0
- package/dist/engine/boot-validator/list-table-aggregate-query.d.ts +10 -0
- package/dist/engine/boot-validator/list-table-aggregate-query.js +31 -0
- package/dist/engine/boot-validator/list-table-export-options.d.ts +1 -0
- package/dist/engine/boot-validator/list-table-export-options.js +27 -0
- package/dist/engine/boot-validator/list-table-options.d.ts +3 -0
- package/dist/engine/boot-validator/list-table-options.js +77 -0
- package/dist/engine/boot-validator/list-table-reject-unknown-keys.d.ts +1 -0
- package/dist/engine/boot-validator/list-table-reject-unknown-keys.js +7 -0
- package/dist/engine/boot-validator/pii-retention.js +233 -155
- package/dist/engine/boot-validator/projection-list-screens.d.ts +1 -1
- package/dist/engine/boot-validator/projection-list-screens.js +3 -13
- package/dist/engine/boot-validator/related-list-table-rules.d.ts +4 -0
- package/dist/engine/boot-validator/related-list-table-rules.js +42 -0
- package/dist/engine/boot-validator/screens-actions.d.ts +1 -0
- package/dist/engine/boot-validator/screens-actions.js +8 -1
- package/dist/engine/boot-validator/screens-dashboard.js +2 -5
- package/dist/engine/boot-validator/screens-forms.js +2 -6
- package/dist/engine/boot-validator/screens-kind-config-edit.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-config-edit.js +88 -0
- package/dist/engine/boot-validator/screens-kind-entity-edit.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-entity-edit.js +55 -0
- package/dist/engine/boot-validator/screens-kind-entity-list.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-entity-list.js +235 -0
- package/dist/engine/boot-validator/screens-kind-projection-detail.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-projection-detail.js +235 -0
- package/dist/engine/boot-validator/screens-kind-projection-list.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-projection-list.js +56 -0
- package/dist/engine/boot-validator/screens-kind-secret-mint.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-secret-mint.js +28 -0
- package/dist/engine/boot-validator/screens-kind-shared.d.ts +37 -0
- package/dist/engine/boot-validator/screens-kind-shared.js +142 -0
- package/dist/engine/boot-validator/screens-lists.d.ts +2 -1
- package/dist/engine/boot-validator/screens-lists.js +24 -11
- package/dist/engine/boot-validator/screens.d.ts +0 -2
- package/dist/engine/boot-validator/screens.js +99 -860
- package/dist/engine/build-app-schema.d.ts +2 -1
- package/dist/engine/build-app-schema.js +65 -97
- package/dist/engine/build-config-feature-schema.js +6 -1
- package/dist/engine/define-feature.js +13 -2
- package/dist/engine/define-handler.js +1 -0
- package/dist/engine/entity-handlers.d.ts +13 -0
- package/dist/engine/entity-handlers.js +10 -21
- package/dist/engine/entity-list-runtime-options.d.ts +8 -0
- package/dist/engine/entity-list-runtime-options.js +18 -0
- package/dist/engine/feature-ast/extractors/handlers.js +1 -0
- package/dist/engine/feature-ast/extractors/index.d.ts +1 -1
- package/dist/engine/feature-ast/extractors/index.js +1 -1
- package/dist/engine/feature-ast/extractors/round1.d.ts +2 -1
- package/dist/engine/feature-ast/extractors/round1.js +6 -0
- package/dist/engine/feature-ast/extractors/round2.js +25 -6
- package/dist/engine/feature-ast/index.d.ts +1 -1
- package/dist/engine/feature-ast/parse.js +3 -1
- package/dist/engine/feature-ast/patch.d.ts +2 -0
- package/dist/engine/feature-ast/patch.js +5 -182
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -245
- package/dist/engine/feature-ast/pattern-change-schema.js +10 -163
- package/dist/engine/feature-ast/pattern-id-matchers.d.ts +7 -0
- package/dist/engine/feature-ast/pattern-id-matchers.js +211 -0
- package/dist/engine/feature-ast/pattern-id-schema.d.ts +251 -0
- package/dist/engine/feature-ast/pattern-id-schema.js +166 -0
- package/dist/engine/feature-ast/patterns.d.ts +14 -1
- package/dist/engine/feature-ast/patterns.js +44 -51
- package/dist/engine/feature-ast/render.js +52 -87
- package/dist/engine/feature-builder-state.d.ts +1 -0
- package/dist/engine/feature-builder-state.js +1 -0
- package/dist/engine/feature-entity-handlers.js +3 -0
- package/dist/engine/feature-manifest.d.ts +1 -0
- package/dist/engine/feature-manifest.js +1 -0
- package/dist/engine/field-access.d.ts +1 -0
- package/dist/engine/field-access.js +12 -0
- package/dist/engine/form-derivations.js +18 -7
- package/dist/engine/handler-field-references.js +1 -1
- package/dist/engine/index.d.ts +2 -2
- package/dist/engine/index.js +1 -1
- package/dist/engine/pattern-library/library.js +2 -1
- package/dist/engine/pattern-library/static-schemas.d.ts +1 -0
- package/dist/engine/pattern-library/static-schemas.js +9 -0
- package/dist/engine/project-app-schema-for-roles.d.ts +2 -0
- package/dist/engine/project-app-schema-for-roles.js +6 -4
- package/dist/engine/registry-ingest.js +1 -1
- package/dist/engine/schema-builder.js +228 -212
- package/dist/engine/screen-filter-ops.js +2 -0
- package/dist/engine/screen-helpers.js +1 -1
- package/dist/engine/screen-kind.d.ts +7 -0
- package/dist/engine/screen-kind.js +1 -0
- package/dist/engine/strip-disabled-features-from-app-schema.js +58 -17
- package/dist/engine/types/feature.d.ts +1 -0
- package/dist/engine/types/index.d.ts +4 -3
- package/dist/engine/types/screen.d.ts +1 -1
- package/dist/engine/types/screen.js +1 -1
- package/dist/engine/validate-projection-allowlist.js +2 -1
- package/dist/entrypoint/index.d.ts +6 -1
- package/dist/entrypoint/index.js +10 -1
- package/dist/errors/docs-url.d.ts +1 -1
- package/dist/errors/docs-url.js +22 -2
- package/dist/es-ops/context.js +1 -1
- package/dist/event-store/admin-api.js +1 -1
- package/dist/event-store/event-store.d.ts +1 -0
- package/dist/event-store/event-store.js +17 -8
- package/dist/event-store/provenance-append.js +1 -1
- package/dist/files/bind-pending-file-refs.js +3 -3
- package/dist/files/create-file-ref-from-bytes.js +7 -1
- package/dist/files/create-file-ref.d.ts +3 -0
- package/dist/files/create-file-ref.js +11 -2
- package/dist/files/file-handle.js +2 -3
- package/dist/files/file-routes.d.ts +2 -0
- package/dist/files/file-routes.js +40 -10
- package/dist/files/provider-resolver.d.ts +1 -0
- package/dist/files/provider-resolver.js +1 -1
- package/dist/files/record-write-check.d.ts +6 -0
- package/dist/files/record-write-check.js +13 -0
- package/dist/files/resolve-content-type.js +43 -0
- package/dist/files/upload-ledger.d.ts +2 -2
- package/dist/files/upload-ledger.js +11 -4
- package/dist/i18n/required-surface-keys.js +189 -208
- package/dist/internal/stack-support.d.ts +2 -1
- package/dist/internal/stack-support.js +1 -1
- package/dist/internal/testing.js +1 -1
- package/dist/jobs/job-runner-context.d.ts +32 -0
- package/dist/jobs/job-runner-context.js +305 -0
- package/dist/jobs/job-runner-execution.d.ts +7 -0
- package/dist/jobs/job-runner-execution.js +160 -0
- package/dist/jobs/job-runner-gates.d.ts +19 -0
- package/dist/jobs/job-runner-gates.js +198 -0
- package/dist/jobs/job-runner-helpers.d.ts +17 -3
- package/dist/jobs/job-runner-helpers.js +61 -2
- package/dist/jobs/job-runner-scope.d.ts +44 -0
- package/dist/jobs/job-runner-scope.js +102 -0
- package/dist/jobs/job-runner-types.d.ts +12 -0
- package/dist/jobs/job-runner.d.ts +0 -1
- package/dist/jobs/job-runner.js +50 -657
- package/dist/observability/escape-hatch-report.d.ts +3 -0
- package/dist/observability/escape-hatch-report.js +21 -3
- package/dist/pipeline/after-rollback-sink.d.ts +6 -0
- package/dist/pipeline/after-rollback-sink.js +32 -0
- package/dist/pipeline/cascade-handler.js +165 -138
- package/dist/pipeline/dispatch-batch.js +8 -1
- package/dist/pipeline/dispatch-query.js +10 -3
- package/dist/pipeline/dispatch-shared.d.ts +13 -3
- package/dist/pipeline/dispatch-shared.js +62 -710
- package/dist/pipeline/dispatch-write.js +296 -189
- package/dist/pipeline/event-dispatcher-admin.js +2 -1
- package/dist/pipeline/event-dispatcher-delivery.js +6 -6
- package/dist/pipeline/event-dispatcher.d.ts +1 -0
- package/dist/pipeline/event-dispatcher.js +6 -9
- package/dist/pipeline/handler-context-auth.d.ts +9 -0
- package/dist/pipeline/handler-context-auth.js +54 -0
- package/dist/pipeline/handler-context-config.d.ts +14 -0
- package/dist/pipeline/handler-context-config.js +113 -0
- package/dist/pipeline/handler-context-db.d.ts +24 -0
- package/dist/pipeline/handler-context-db.js +149 -0
- package/dist/pipeline/handler-context-dispatch.d.ts +23 -0
- package/dist/pipeline/handler-context-dispatch.js +101 -0
- package/dist/pipeline/handler-context-events.d.ts +41 -0
- package/dist/pipeline/handler-context-events.js +239 -0
- package/dist/pipeline/handler-context-notify.d.ts +12 -0
- package/dist/pipeline/handler-context-notify.js +55 -0
- package/dist/pipeline/handler-context-observability.d.ts +7 -0
- package/dist/pipeline/handler-context-observability.js +21 -0
- package/dist/pipeline/handler-context-scope.d.ts +48 -0
- package/dist/pipeline/handler-context-scope.js +88 -0
- package/dist/pipeline/notify-scope.js +12 -1
- package/dist/pipeline/pre-handler-gates.js +20 -4
- package/dist/pipeline/pre-transaction-reservations.d.ts +1 -0
- package/dist/pipeline/pre-transaction-reservations.js +9 -3
- package/dist/pipeline/projection-rebuild.js +2 -1
- package/dist/pipeline/system-hooks.d.ts +3 -1
- package/dist/pipeline/system-hooks.js +55 -6
- package/dist/pipeline/write-result-masking.js +13 -10
- package/dist/schema-cli/apply-command.d.ts +2 -0
- package/dist/schema-cli/apply-command.js +97 -0
- package/dist/schema-cli/baseline-command.d.ts +2 -0
- package/dist/schema-cli/baseline-command.js +33 -0
- package/dist/schema-cli/generate-command.d.ts +2 -0
- package/dist/schema-cli/generate-command.js +90 -0
- package/dist/schema-cli/shared.d.ts +39 -0
- package/dist/schema-cli/shared.js +95 -0
- package/dist/schema-cli/status-command.d.ts +2 -0
- package/dist/schema-cli/status-command.js +73 -0
- package/dist/schema-cli/validate-boot-input.d.ts +5 -0
- package/dist/schema-cli/validate-boot-input.js +130 -0
- package/dist/schema-cli/validate-command.d.ts +2 -0
- package/dist/schema-cli/validate-command.js +130 -0
- package/dist/schema-cli.d.ts +2 -18
- package/dist/schema-cli.js +47 -604
- package/dist/search/meilisearch-adapter.js +2 -1
- package/dist/search/reindex-entity.js +15 -5
- package/dist/ui-types/index.d.ts +2 -2
- package/dist/ui-types/index.js +1 -1
- package/dist/upgrade-changelog.d.ts +2 -0
- package/dist/upgrade-changelog.js +14 -0
- package/dist/upgrade-cli.d.ts +3 -12
- package/dist/upgrade-cli.js +171 -197
- package/dist/upgrade-marker-parse.d.ts +32 -0
- package/dist/upgrade-marker-parse.js +73 -0
- package/package.json +9 -6
- package/src/changes.json +555 -0
- package/src/scripts/codemod/codemod-support.ts +1 -1
- package/src/scripts/codemod/db-factory.ts +1 -1
- package/src/scripts/codemod/feature-layout.ts +10 -2
- package/src/scripts/codemod/object-form.ts +41 -4
- package/src/scripts/codemod/package-merge.ts +58 -8
- package/src/scripts/codemod/pii-personal-migration.ts +175 -145
- package/src/scripts/codemod/testing-injection.ts +122 -42
- package/src/scripts/codemod/testing-move.ts +29 -7
- package/dist/engine/provider-plugin-context.d.ts +0 -3
- 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
|