spine-framework 0.3.145 → 0.4.1
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/.env.example +76 -0
- package/.framework/.spine-manifest.json +19 -0
- package/.framework/cli/commands/agents.ts +61 -33
- package/.framework/cli/commands/auth.ts +60 -28
- package/.framework/cli/commands/create-app.ts +212 -80
- package/.framework/cli/commands/dev.ts +184 -68
- package/.framework/cli/commands/doctor.ts +324 -152
- package/.framework/cli/commands/generate.ts +119 -68
- package/.framework/cli/commands/init.ts +141 -242
- package/.framework/cli/commands/install-app.ts +1336 -314
- package/.framework/cli/commands/items.ts +31 -29
- package/.framework/cli/commands/migrate.ts +299 -156
- package/.framework/cli/commands/migrations.ts +62 -30
- package/.framework/cli/commands/pipelines.ts +31 -29
- package/.framework/cli/commands/status.ts +105 -45
- package/.framework/cli/commands/system.ts +47 -42
- package/.framework/cli/commands/test.ts +161 -102
- package/.framework/cli/commands/upgrade.ts +260 -0
- package/.framework/cli/commands/validate-app.ts +517 -161
- package/.framework/cli/context.ts +117 -85
- package/.framework/cli/env-loader.ts +74 -43
- package/.framework/cli/index.ts +70 -46
- package/.framework/cli/lib/project-files.ts +404 -0
- package/.framework/docs/guides/testing-guide.md +1 -1
- package/.framework/docs/runtime-types.md +72 -72
- package/.framework/functions/_shared/access.ts +84 -0
- package/.framework/functions/_shared/agent-runner.ts +5067 -1026
- package/.framework/functions/_shared/app-manifest.ts +214 -94
- package/.framework/functions/_shared/ast.ts +214 -0
- package/.framework/functions/_shared/audit.ts +91 -55
- package/.framework/functions/_shared/cil.ts +368 -127
- package/.framework/functions/_shared/db.ts +337 -68
- package/.framework/functions/_shared/funnel.ts +228 -0
- package/.framework/functions/_shared/geo.ts +105 -0
- package/.framework/functions/_shared/hash-api-key.ts +94 -17
- package/.framework/functions/_shared/index.ts +22 -224
- package/.framework/functions/_shared/kb.ts +832 -58
- package/.framework/functions/_shared/llm.ts +118 -18
- package/.framework/functions/_shared/logging.ts +317 -19
- package/.framework/functions/_shared/middleware.ts +701 -131
- package/.framework/functions/_shared/notify.ts +70 -0
- package/.framework/functions/_shared/outcomes.ts +34 -19
- package/.framework/functions/_shared/permissions.ts +501 -315
- package/.framework/functions/_shared/pipeline-runner.ts +704 -155
- package/.framework/functions/_shared/principal.ts +436 -263
- package/.framework/functions/_shared/rate-limit.ts +233 -0
- package/.framework/functions/_shared/resolve-credentials.ts +86 -19
- package/.framework/functions/_shared/resolve-ids.ts +203 -139
- package/.framework/functions/_shared/retry-deliveries.ts +51 -16
- package/.framework/functions/_shared/schema-utils.ts +602 -521
- package/.framework/functions/_shared/security.ts +345 -0
- package/.framework/functions/_shared/testing.ts +197 -172
- package/.framework/functions/_shared/tracing.ts +351 -21
- package/.framework/functions/_shared/trigger-engine.ts +425 -136
- package/.framework/functions/_shared/webhook-registration.ts +71 -70
- package/.framework/functions/_shared/webhook-registry.ts +126 -83
- package/.framework/functions/account-nodes.ts +30 -30
- package/.framework/functions/adapters/generic-webhook-inbound.ts +49 -16
- package/.framework/functions/adapters/generic-webhook-outbound.ts +17 -17
- package/.framework/functions/adapters/openai-outbound.ts +15 -16
- package/.framework/functions/admin-data.ts +596 -173
- package/.framework/functions/admin-funnel.ts +64 -60
- package/.framework/functions/admin-install-funnel.ts +84 -66
- package/.framework/functions/admin-signals.ts +69 -51
- package/.framework/functions/ai-agents.ts +431 -106
- package/.framework/functions/ai-interactions.ts +186 -112
- package/.framework/functions/api-keys.ts +107 -107
- package/.framework/functions/apps.ts +154 -136
- package/.framework/functions/auth.ts +83 -65
- package/.framework/functions/cil-agent-ingest.ts +189 -232
- package/.framework/functions/cil-agent.ts +433 -177
- package/.framework/functions/cil-callmap.ts +642 -430
- package/.framework/functions/cil-config.ts +139 -135
- package/.framework/functions/cil-context.ts +214 -127
- package/.framework/functions/cil-doc.ts +587 -458
- package/.framework/functions/cil-embeddings.ts +453 -364
- package/.framework/functions/cil-graph.ts +427 -142
- package/.framework/functions/cil-ingest.ts +1418 -1154
- package/.framework/functions/cil-pipeline.ts +276 -21
- package/.framework/functions/cil-project.ts +659 -132
- package/.framework/functions/cil-validate.ts +198 -164
- package/.framework/functions/debug-auth.ts +57 -55
- package/.framework/functions/deliveries.ts +80 -21
- package/.framework/functions/embeddings.ts +1023 -318
- package/.framework/functions/eval-harness.ts +146 -108
- package/.framework/functions/integration-outbound.ts +205 -75
- package/.framework/functions/integration-routes.ts +128 -23
- package/.framework/functions/integrations.ts +125 -100
- package/.framework/functions/invites.ts +1095 -460
- package/.framework/functions/item-progress.ts +158 -102
- package/.framework/functions/kb.ts +34 -10
- package/.framework/functions/logs.ts +133 -133
- package/.framework/functions/observability.ts +152 -122
- package/.framework/functions/pipeline-executions.ts +147 -132
- package/.framework/functions/pipelines.ts +134 -119
- package/.framework/functions/prompt-configs.ts +96 -80
- package/.framework/functions/roles.ts +112 -82
- package/.framework/functions/security.ts +450 -0
- package/.framework/functions/system-cron.ts +117 -80
- package/.framework/functions/system.ts +158 -98
- package/.framework/functions/tests.ts +29 -29
- package/.framework/functions/timers.ts +94 -95
- package/.framework/functions/trigger-scheduler.ts +10 -0
- package/.framework/functions/triggers.ts +179 -149
- package/.framework/functions/types.ts +137 -125
- package/.framework/mcp/index.ts +133 -0
- package/.framework/mcp/load-env.ts +60 -0
- package/.framework/mcp/shared/app-manifest.ts +275 -0
- package/.framework/mcp/shared/db.ts +477 -0
- package/.framework/mcp/shared/kb.ts +118 -0
- package/.framework/mcp/tools/architect.ts +431 -0
- package/.framework/mcp/tools/cil/context.ts +345 -0
- package/.framework/mcp/tools/cil/graph.ts +242 -0
- package/.framework/mcp/tools/cil/ingest.ts +1217 -0
- package/.framework/mcp/tools/cil/pipeline-orchestrator.ts +693 -0
- package/.framework/mcp/tools/cil/pipeline.ts +767 -0
- package/.framework/mcp/tools/cil/project.ts +791 -0
- package/.framework/mcp/tools/cil/retrieval.ts +336 -0
- package/.framework/mcp/tools/cil/search-core.ts +454 -0
- package/.framework/mcp/tools/cil/search.ts +97 -0
- package/.framework/mcp/tools/cil/suggest.ts +186 -0
- package/.framework/mcp/tools/cil/validate.ts +1369 -0
- package/.framework/mcp/tools/kb/delete.ts +139 -0
- package/.framework/mcp/tools/kb/index.ts +52 -0
- package/.framework/mcp/tools/kb/ingest.ts +84 -0
- package/.framework/mcp/tools/kb/list-types.ts +102 -0
- package/.framework/mcp/tools/kb/publish.ts +100 -0
- package/.framework/mcp/tools/kb/reindex.ts +86 -0
- package/.framework/mcp/tools/kb/revoke.ts +85 -0
- package/.framework/mcp/tools/kb/search.ts +101 -0
- package/.framework/mcp/tools/kb/upsert.ts +125 -0
- package/.framework/mcp/tools/observability.ts +510 -0
- package/.framework/migrations/001_core_schema.sql +15 -0
- package/.framework/migrations/002_core_rls_and_functions.sql +15 -0
- package/.framework/migrations/003_core_system_data.sql +16 -1
- package/.framework/migrations/004_core_rpc_and_broker.sql +17 -1
- package/.framework/migrations/005_core_observability.sql +17 -1
- package/.framework/migrations/006_security_hardening_rls.sql +15 -0
- package/.framework/migrations/007_kb_article_list.sql +17 -1
- package/.framework/migrations/008_items_target.sql +15 -0
- package/.framework/migrations/009_roles_slug_unique.sql +15 -0
- package/.framework/migrations/010_auth_user_trigger.sql +15 -0
- package/.framework/migrations/011_spine_system_account.sql +16 -0
- package/.framework/migrations/012_service_role_grants.sql +16 -0
- package/.framework/migrations/013_get_account_apps_role_filter.sql +15 -0
- package/.framework/migrations/014_apps_required_roles.sql +16 -0
- package/.framework/migrations/015_types_kind_slug_unique.sql +15 -0
- package/.framework/migrations/016_cil_types.sql +281 -0
- package/.framework/migrations/017_cil_stable_ids.sql +37 -0
- package/.framework/migrations/018_runtime_tables.sql +129 -0
- package/.framework/migrations/019_agent_prompt_columns.sql +56 -0
- package/.framework/migrations/020_schema_reconciliation.sql +67 -0
- package/.framework/migrations/021_kb_embedding_lifecycle.sql +31 -0
- package/.framework/migrations/032_cil_status_migration.sql +73 -0
- package/.framework/migrations/033_cil_versioning.sql +73 -0
- package/.framework/migrations/034_match_embeddings_knn.sql +115 -0
- package/.framework/migrations/035_rate_limits.sql +55 -0
- package/.framework/migrations/036_demo_record_flags.sql +72 -0
- package/.framework/migrations/037_kb_types_canonical.sql +244 -0
- package/.framework/migrations/038_drop_demo_record_flags.sql +38 -0
- package/.framework/migrations/042_registration_hierarchy.sql +309 -0
- package/.framework/migrations/043_policy_actor_cleanup.sql +39 -0
- package/.framework/migrations/044_match_embeddings_filtered_knn.sql +151 -0
- package/.framework/migrations/045_security_critical_rls.sql +139 -0
- package/.framework/migrations/046_security_missing_rls.sql +79 -0
- package/.framework/migrations/047_people_updated_by.sql +27 -0
- package/.framework/migrations/048_watcher_last_seen_at.sql +24 -0
- package/.framework/migrations/049_security_blocklist.sql +148 -0
- package/.framework/migrations/050_notifications.sql +49 -0
- package/.framework/migrations/051_message_agent_fields.sql +74 -0
- package/.framework/migrations/052_registration_security.sql +421 -0
- package/.framework/migrations/053_builtin_action_seeds.sql +66 -0
- package/.framework/migrations/054_kb_ingest_lease.sql +157 -0
- package/.framework/migrations/055_type_id_drift_reconciliation.sql +33 -0
- package/.framework/migrations/056_slug_column_reconciliation.sql +38 -0
- package/.framework/migrations/057_pipelines_slug_identity.sql +14 -0
- package/.framework/migrations/058_account_data_field_declarations.sql +53 -0
- package/.framework/migrations/059_registration_placement_fixes.sql +269 -0
- package/.framework/migrations/060_account_person_record_permissions.sql +38 -0
- package/.framework/migrations/061_fk_dedup.sql +32 -0
- package/.framework/public/spine-logo.svg +10 -3
- package/.framework/scripts/app-install-cli.ts +190 -93
- package/.framework/scripts/assemble-frontend.sh +127 -44
- package/.framework/scripts/assemble-functions.sh +128 -30
- package/.framework/scripts/boundary-check.sh +6 -2
- package/.framework/scripts/build-manifest.sh +24 -68
- package/.framework/scripts/check-core-integrity.sh +15 -76
- package/.framework/scripts/checksum-manifest.mts +343 -0
- package/.framework/scripts/cil-migrate-markers.ts +403 -0
- package/.framework/scripts/fix-cil-markers.mjs +176 -0
- package/.framework/scripts/verify-integrity.sh +21 -57
- package/.framework/scripts/verify-manifest.mts +305 -0
- package/.framework/seeds/types/funnel_signal_use.json +74 -21
- package/.framework/seeds/types/spine_instance.json +73 -21
- package/.framework/src/App.tsx +118 -72
- package/.framework/src/apps/admin/index.tsx +855 -58
- package/.framework/src/components/AppWrapper.tsx +43 -45
- package/.framework/src/components/CustomAppLoader.tsx +160 -116
- package/.framework/src/components/admin/AdminListPage.tsx +46 -44
- package/.framework/src/components/admin/AdminSidebar.tsx +57 -47
- package/.framework/src/components/admin/AdminStatsCard.tsx +46 -44
- package/.framework/src/components/admin/PageInstructions.tsx +98 -2
- package/.framework/src/components/admin/SortableTableHeader.tsx +44 -45
- package/.framework/src/components/app-shell/GenericAppShell.tsx +64 -65
- package/.framework/src/components/app-shell/GenericDetailPage.tsx +44 -47
- package/.framework/src/components/app-shell/GenericListPage.tsx +45 -49
- package/.framework/src/components/app-sidebar.tsx +42 -45
- package/.framework/src/components/auth/ProtectedRoute.tsx +48 -41
- package/.framework/src/components/auth/TurnstileWidget.tsx +27 -0
- package/.framework/src/components/kb/KBArticleEditor.tsx +231 -22
- package/.framework/src/components/kb/KBArticleList.tsx +251 -10
- package/.framework/src/components/layout/AppShell.tsx +51 -45
- package/.framework/src/components/layout/Header.tsx +42 -43
- package/.framework/src/components/layout/Layout.tsx +46 -46
- package/.framework/src/components/layout/Sidebar.tsx +196 -96
- package/.framework/src/components/runtime/DataDetailHeader.tsx +55 -30
- package/.framework/src/components/runtime/DataDetailPage.tsx +31 -269
- package/.framework/src/components/runtime/DataFilters.tsx +49 -74
- package/.framework/src/components/runtime/DataHeader.tsx +53 -32
- package/.framework/src/components/runtime/DataListPage.tsx +51 -155
- package/.framework/src/components/runtime/DataStats.tsx +66 -44
- package/.framework/src/components/runtime/DataTable.tsx +49 -149
- package/.framework/src/components/runtime/SchemaDetailForm.tsx +45 -75
- package/.framework/src/components/runtime/index.ts +119 -16
- package/.framework/src/components/search-form.tsx +46 -29
- package/.framework/src/components/shared/AgentView.tsx +65 -48
- package/.framework/src/components/shared/FieldRenderer.tsx +144 -50
- package/.framework/src/components/shared/InviteManager.tsx +207 -97
- package/.framework/src/components/shared/MemberList.tsx +97 -0
- package/.framework/src/components/shared/SchemaFields.tsx +107 -86
- package/.framework/src/components/ui/DataTable.tsx +80 -68
- package/.framework/src/components/ui/Form.tsx +157 -146
- package/.framework/src/components/ui/ItemCard.tsx +74 -67
- package/.framework/src/components/ui/ItemListView.tsx +73 -68
- package/.framework/src/components/ui/LoadingSpinner.tsx +43 -48
- package/.framework/src/components/ui/Modal.tsx +78 -68
- package/.framework/src/components/ui/RichTextEditor.tsx +89 -69
- package/.framework/src/components/ui/accordion.tsx +85 -86
- package/.framework/src/components/ui/alert-dialog.tsx +197 -184
- package/.framework/src/components/ui/alert.tsx +105 -94
- package/.framework/src/components/ui/aspect-ratio.tsx +57 -43
- package/.framework/src/components/ui/avatar.tsx +122 -107
- package/.framework/src/components/ui/badge.tsx +60 -55
- package/.framework/src/components/ui/breadcrumb.tsx +127 -128
- package/.framework/src/components/ui/button-group.tsx +86 -72
- package/.framework/src/components/ui/button.tsx +71 -50
- package/.framework/src/components/ui/calendar.tsx +72 -57
- package/.framework/src/components/ui/card.tsx +136 -129
- package/.framework/src/components/ui/carousel.tsx +226 -107
- package/.framework/src/components/ui/chart.tsx +252 -147
- package/.framework/src/components/ui/checkbox.tsx +43 -44
- package/.framework/src/components/ui/collapsible.tsx +86 -71
- package/.framework/src/components/ui/combobox.tsx +282 -249
- package/.framework/src/components/ui/command.tsx +158 -146
- package/.framework/src/components/ui/context-menu.tsx +239 -240
- package/.framework/src/components/ui/dialog.tsx +174 -157
- package/.framework/src/components/ui/direction.tsx +72 -56
- package/.framework/src/components/ui/drawer.tsx +168 -169
- package/.framework/src/components/ui/dropdown-menu.tsx +254 -223
- package/.framework/src/components/ui/empty.tsx +131 -120
- package/.framework/src/components/ui/field.tsx +205 -173
- package/.framework/src/components/ui/hover-card.tsx +71 -72
- package/.framework/src/components/ui/input-group.tsx +148 -133
- package/.framework/src/components/ui/input-otp.tsx +99 -86
- package/.framework/src/components/ui/input.tsx +43 -41
- package/.framework/src/components/ui/item.tsx +207 -185
- package/.framework/src/components/ui/kbd.tsx +57 -56
- package/.framework/src/components/ui/label.tsx +45 -41
- package/.framework/src/components/ui/menubar.tsx +268 -236
- package/.framework/src/components/ui/native-select.tsx +85 -86
- package/.framework/src/components/ui/navigation-menu.tsx +166 -156
- package/.framework/src/components/ui/pagination.tsx +131 -141
- package/.framework/src/components/ui/popover.tsx +125 -128
- package/.framework/src/components/ui/progress.tsx +47 -44
- package/.framework/src/components/ui/radio-group.tsx +60 -55
- package/.framework/src/components/ui/resizable.tsx +85 -72
- package/.framework/src/components/ui/scroll-area.tsx +57 -58
- package/.framework/src/components/ui/select.tsx +182 -169
- package/.framework/src/components/ui/separator.tsx +42 -43
- package/.framework/src/components/ui/sheet.tsx +170 -159
- package/.framework/src/components/ui/sidebar.tsx +395 -398
- package/.framework/src/components/ui/skeleton.tsx +42 -43
- package/.framework/src/components/ui/slider.tsx +61 -44
- package/.framework/src/components/ui/sonner.tsx +44 -44
- package/.framework/src/components/ui/spinner.tsx +47 -42
- package/.framework/src/components/ui/switch.tsx +56 -42
- package/.framework/src/components/ui/table-primitives.tsx +272 -132
- package/.framework/src/components/ui/table.tsx +140 -142
- package/.framework/src/components/ui/tabs.tsx +110 -93
- package/.framework/src/components/ui/textarea.tsx +43 -44
- package/.framework/src/components/ui/toggle-group.tsx +87 -71
- package/.framework/src/components/ui/toggle.tsx +56 -56
- package/.framework/src/components/ui/tooltip.tsx +99 -85
- package/.framework/src/contexts/AppContext.tsx +166 -114
- package/.framework/src/contexts/AuthContext.tsx +232 -137
- package/.framework/src/hooks/use-mobile.ts +43 -44
- package/.framework/src/hooks/useApi.ts +185 -172
- package/.framework/src/hooks/useAppPath.ts +31 -29
- package/.framework/src/hooks/useApps.ts +77 -42
- package/.framework/src/hooks/useEntityList.ts +66 -29
- package/.framework/src/hooks/useEntityRecord.ts +76 -32
- package/.framework/src/hooks/useForm.ts +62 -30
- package/.framework/src/hooks/useListSchema.ts +60 -43
- package/.framework/src/hooks/useSchemaRecord.ts +115 -95
- package/.framework/src/lib/api.ts +258 -134
- package/.framework/src/lib/registration.ts +181 -0
- package/.framework/src/lib/supabase.ts +118 -81
- package/.framework/src/lib/utils.ts +266 -266
- package/.framework/src/main.tsx +43 -44
- package/.framework/src/pages/DashboardPage.tsx +44 -43
- package/.framework/src/pages/NoAppPage.tsx +50 -0
- package/.framework/src/pages/NotFoundPage.tsx +32 -29
- package/.framework/src/pages/SetupPage.tsx +34 -33
- package/.framework/src/pages/admin/AIAgentDetailPage.tsx +69 -58
- package/.framework/src/pages/admin/AIAgentsPage.tsx +43 -40
- package/.framework/src/pages/admin/APIKeyDetailPage.tsx +63 -55
- package/.framework/src/pages/admin/APIKeysPage.tsx +44 -42
- package/.framework/src/pages/admin/AlertsConfigPage.tsx +117 -94
- package/.framework/src/pages/admin/AppDetailPage.tsx +48 -42
- package/.framework/src/pages/admin/AppsPage.tsx +45 -42
- package/.framework/src/pages/admin/DeliveriesPage.tsx +49 -17
- package/.framework/src/pages/admin/DesignedPage.tsx +31 -30
- package/.framework/src/pages/admin/EmbeddingDetailPage.tsx +58 -0
- package/.framework/src/pages/admin/EmbeddingsPage.tsx +49 -0
- package/.framework/src/pages/admin/ExtendedShadcnTestPage.tsx +31 -29
- package/.framework/src/pages/admin/IncrementalShadcnTestPage.tsx +46 -29
- package/.framework/src/pages/admin/IntegratedDashboard.tsx +32 -29
- package/.framework/src/pages/admin/IntegrationDetailPage.tsx +68 -55
- package/.framework/src/pages/admin/IntegrationsPage.tsx +45 -42
- package/.framework/src/pages/admin/InvitesPage.tsx +60 -44
- package/.framework/src/pages/admin/LogsPage.tsx +87 -137
- package/.framework/src/pages/admin/MinimalShadcnTestPage.tsx +31 -29
- package/.framework/src/pages/admin/ObservabilityDashboard.tsx +150 -89
- package/.framework/src/pages/admin/PipelineDetailPage.tsx +77 -104
- package/.framework/src/pages/admin/PipelineExecutionsPage.tsx +44 -43
- package/.framework/src/pages/admin/PipelinesPage.tsx +59 -145
- package/.framework/src/pages/admin/PromptConfigDetailPage.tsx +53 -42
- package/.framework/src/pages/admin/PromptConfigsPage.tsx +47 -117
- package/.framework/src/pages/admin/ProperlyDesignedPage.tsx +31 -44
- package/.framework/src/pages/admin/RoleDetailPage.tsx +49 -92
- package/.framework/src/pages/admin/RolesPage.tsx +47 -164
- package/.framework/src/pages/admin/SecurityPage.tsx +310 -0
- package/.framework/src/pages/admin/SelectTestPage.tsx +30 -32
- package/.framework/src/pages/admin/ShadcnTestPage.tsx +37 -44
- package/.framework/src/pages/admin/SimpleDashboard.tsx +32 -44
- package/.framework/src/pages/admin/TestRunDetailPage.tsx +97 -44
- package/.framework/src/pages/admin/TestingDashboard.tsx +126 -71
- package/.framework/src/pages/admin/TimerDetailPage.tsx +67 -55
- package/.framework/src/pages/admin/TimersPage.tsx +44 -42
- package/.framework/src/pages/admin/TriggerDetailPage.tsx +47 -42
- package/.framework/src/pages/admin/TriggersPage.tsx +58 -57
- package/.framework/src/pages/admin/TypeDetailPage.tsx +91 -55
- package/.framework/src/pages/admin/TypesPage.tsx +45 -42
- package/.framework/src/pages/admin/cil/CILAgentPage.tsx +258 -128
- package/.framework/src/pages/admin/cil/CILChunkDetailPage.tsx +127 -123
- package/.framework/src/pages/admin/cil/CILDashboardPage.tsx +308 -235
- package/.framework/src/pages/admin/cil/CILFileListPage.tsx +159 -110
- package/.framework/src/pages/admin/cil/CILFileSelectPage.tsx +432 -0
- package/.framework/src/pages/admin/cil/CILGraphPage.tsx +200 -178
- package/.framework/src/pages/admin/cil/CILIngestionPage.tsx +286 -239
- package/.framework/src/pages/admin/cil/CILProjectsPage.tsx +211 -90
- package/.framework/src/pages/admin/cil/CILSearchPage.tsx +156 -143
- package/.framework/src/pages/admin/cil/CILSuggestionsPage.tsx +379 -0
- package/.framework/src/pages/admin/cil/CILVersionsPage.tsx +337 -0
- package/.framework/src/pages/admin/cil/FlexiblePipelinePage.tsx +219 -214
- package/.framework/src/pages/admin/kb/KBAdminPage.tsx +64 -0
- package/.framework/src/pages/auth/AuthConfirmPage.tsx +135 -65
- package/.framework/src/pages/auth/ForgotPasswordPage.tsx +39 -31
- package/.framework/src/pages/auth/LoginPage.tsx +38 -32
- package/.framework/src/pages/auth/PendingApprovalPage.tsx +31 -28
- package/.framework/src/pages/auth/RegisterPage.tsx +429 -155
- package/.framework/src/pages/spine-framework/APIPage.tsx +33 -30
- package/.framework/src/pages/spine-framework/CLIPage.tsx +32 -30
- package/.framework/src/types/auth.ts +101 -93
- package/.framework/src/types/types.ts +283 -262
- package/.framework/templates/project.json +87 -0
- package/.framework/templates/spine.config.neutral.json +7 -0
- package/.framework/tests/api/api-surface.test.ts +381 -0
- package/.framework/tests/fixtures/seed.ts +427 -0
- package/.framework/tests/fixtures/teardown.ts +228 -0
- package/.framework/tests/reporter.ts +351 -0
- package/.framework/tests/setup.ts +135 -0
- package/.framework/tests/ui/ui-sweep.ts +278 -0
- package/.framework/tests/unit/admin-data-logger.test.ts +310 -0
- package/.framework/tests/unit/agent-runner.test.ts +2389 -0
- package/.framework/tests/unit/ai-interactions.test.ts +81 -0
- package/.framework/tests/unit/app-manifest.test.ts +359 -0
- package/.framework/tests/unit/assembly-scripts.test.ts +172 -0
- package/.framework/tests/unit/cil-determinism.test.ts +85 -0
- package/.framework/tests/unit/core-isolation.test.ts +153 -0
- package/.framework/tests/unit/funnel-signals.test.ts +135 -0
- package/.framework/tests/unit/install-app.test.ts +1759 -0
- package/.framework/tests/unit/invites-security.test.ts +572 -0
- package/.framework/tests/unit/kb-ingest-lease.test.ts +287 -0
- package/.framework/tests/unit/kb-search.test.ts +119 -0
- package/.framework/tests/unit/migrate.test.ts +509 -0
- package/.framework/tests/unit/notify.test.ts +136 -0
- package/.framework/tests/unit/permissions.test.ts +269 -0
- package/.framework/tests/unit/pipeline-runner.test.ts +755 -0
- package/.framework/tests/unit/principal-cache.test.ts +138 -0
- package/.framework/tests/unit/retrieval-golden.test.ts +520 -0
- package/.framework/tests/unit/runtime-isolation.test.ts +642 -0
- package/.framework/tests/unit/schema-utils.test.ts +225 -0
- package/.framework/tests/unit/trigger-engine.test.ts +513 -0
- package/.framework/tests/unit/validate-app.test.ts +527 -0
- package/README.md +34 -3
- package/bin/spine-framework.cjs +1 -0
- package/config/tailwind.config.cjs +7 -0
- package/config/tsconfig.functions.json +42 -0
- package/config/tsconfig.json +37 -9
- package/config/vite.config.ts +31 -2
- package/netlify.toml +31 -3
- package/package.json +46 -29
- package/scripts/assemble-frontend.sh +127 -44
- package/scripts/assemble-functions.sh +127 -29
- package/scripts/boundary-check.sh +6 -2
- package/scripts/build-manifest.sh +24 -68
- package/scripts/checksum-manifest.mts +345 -0
- package/scripts/dev-clean.sh +86 -0
- package/scripts/dev-netlify.sh +49 -0
- package/scripts/netlify-dev-wrapper.sh +2 -0
- package/scripts/verify-install.ts +358 -0
- package/scripts/verify-integrity.sh +21 -57
- package/scripts/verify-manifest.mts +305 -0
- package/templates/project/gitignore +45 -0
- package/{config → templates/project}/package-lock.json +22335 -9366
- package/vitest.config.ts +46 -0
- package/.framework/public/spine.config.json +0 -91
- package/config/deno.lock +0 -108
- package/config/tsconfig.node.tsbuildinfo +0 -1
- package/config/tsconfig.tsbuildinfo +0 -1
- package/config/vite.config.d.ts +0 -2
- package/custom/.devin/AGENT.md +0 -357
- package/custom/.devin/skills/install-app/SKILL.md +0 -39
- package/custom/.devin/skills/spine-workbench-discovery/SKILL.md +0 -39
- package/custom/.devin/skills/spine-workbench-idea/SKILL.md +0 -36
- package/custom/.devin/skills/spine-workbench-implement/SKILL.md +0 -46
- package/custom/.devin/skills/spine-workbench-initiative/SKILL.md +0 -28
- package/custom/.devin/skills/spine-workbench-plan/SKILL.md +0 -38
- package/custom/.devin/workflows/app-install.yaml +0 -83
- package/custom/README.md +0 -79
- package/docs/README.md +0 -15
- package/scripts/app-install-cli.ts +0 -286
- package/scripts/check-core-integrity.sh +0 -82
- package/scripts/ingest-chunks.cjs +0 -202
- package/scripts/kb-chunk-parser.cjs +0 -312
- package/scripts/kb-chunk-parser.ts +0 -330
- package/scripts/kb-ingest-docs.ts +0 -203
- package/scripts/load-test-app-install.ts +0 -484
- package/scripts/test-login.js +0 -33
- package/spine.config.json +0 -91
|
@@ -0,0 +1,1759 @@
|
|
|
1
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:module_header:module_header" type="module_header" identity="module_header" parent="~/.framework/tests/unit/install-app.test.ts"]
|
|
2
|
+
// CIL[DOC-MACRO purpose="Import Vitest and filesystem fixture utilities for isolated CLI tests."]
|
|
3
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Import Vitest and filesystem fixture utilities for isolated CLI tests."]
|
|
4
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
|
|
5
|
+
// CIL[DOC-CONTRACT inputs="none" outputs="none" errors="none" side_effects="none"]
|
|
6
|
+
// CIL[DOC-MICRO logic_summary="Import Vitest and filesystem fixture utilities for isolated CLI tests." notable_decisions=""]
|
|
7
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
8
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
9
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
10
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
11
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from 'node:fs'
|
|
12
|
+
import { tmpdir } from 'node:os'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
import { Command } from 'commander'
|
|
15
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:module_header:module_header"]
|
|
16
|
+
|
|
17
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:dbcall:type" type="type" identity="DbCall" parent="none"]
|
|
18
|
+
// CIL[DOC-MACRO purpose="Describe an operation recorded by a fake query builder."]
|
|
19
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Describe an operation recorded by a fake query builder."]
|
|
20
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
|
|
21
|
+
// CIL[DOC-CONTRACT inputs="none" outputs="DbCall type" errors="none" side_effects="none"]
|
|
22
|
+
// CIL[DOC-MICRO logic_summary="Describe an operation recorded by a fake query builder." notable_decisions=""]
|
|
23
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
24
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
25
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
26
|
+
type DbCall = { op: string; args: any[] }
|
|
27
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:dbcall:type"]
|
|
28
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:dbquery:type" type="type" identity="DbQuery" parent="none"]
|
|
29
|
+
// CIL[DOC-MACRO purpose="Describe a table and its recorded fake query operations."]
|
|
30
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Describe a table and its recorded fake query operations."]
|
|
31
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
|
|
32
|
+
// CIL[DOC-CONTRACT inputs="none" outputs="DbQuery type" errors="none" side_effects="none"]
|
|
33
|
+
// CIL[DOC-MICRO logic_summary="Describe a table and its recorded fake query operations." notable_decisions=""]
|
|
34
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
35
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
36
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
37
|
+
type DbQuery = { table: string; calls: DbCall[] }
|
|
38
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:dbquery:type"]
|
|
39
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:dbresult:type" type="type" identity="DbResult" parent="none"]
|
|
40
|
+
// CIL[DOC-MACRO purpose="Describe data and error returned by a fake database responder."]
|
|
41
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Describe data and error returned by a fake database responder."]
|
|
42
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
|
|
43
|
+
// CIL[DOC-CONTRACT inputs="none" outputs="DbResult type" errors="none" side_effects="none"]
|
|
44
|
+
// CIL[DOC-MICRO logic_summary="Describe data and error returned by a fake database responder." notable_decisions=""]
|
|
45
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
46
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
47
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
48
|
+
type DbResult = { data?: any; error?: any }
|
|
49
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:dbresult:type"]
|
|
50
|
+
|
|
51
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:supastate:constant" type="constant" identity="supaState" parent="none"]
|
|
52
|
+
// CIL[DOC-MACRO purpose="Initialize hoisted state for fake Supabase clients and query history."]
|
|
53
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Initialize hoisted state for fake Supabase clients and query history."]
|
|
54
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
|
|
55
|
+
// CIL[DOC-CONTRACT inputs="none" outputs="Mock state" errors="none" side_effects="Initializes local mock state"]
|
|
56
|
+
// CIL[DOC-MICRO logic_summary="Initialize hoisted state for fake Supabase clients and query history." notable_decisions=""]
|
|
57
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
58
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
59
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
60
|
+
const supaState = vi.hoisted(() => ({
|
|
61
|
+
createClientCalls: [] as any[],
|
|
62
|
+
responder: null as null | ((q: DbQuery) => DbResult),
|
|
63
|
+
history: [] as DbQuery[],
|
|
64
|
+
}))
|
|
65
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:supastate:constant"]
|
|
66
|
+
|
|
67
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:supabase_mock:block" type="block" identity="supabase_mock" parent="none"]
|
|
68
|
+
// CIL[DOC-MACRO purpose="Register a chainable fake Supabase client that records operations without network access."]
|
|
69
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Register a chainable fake Supabase client that records operations without network access."]
|
|
70
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
|
|
71
|
+
// CIL[DOC-CONTRACT inputs="supaState" outputs="Vitest module mock" errors="none" side_effects="Registers a mock"]
|
|
72
|
+
// CIL[DOC-MICRO logic_summary="Register a chainable fake Supabase client that records operations without network access." notable_decisions=""]
|
|
73
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
74
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
75
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
76
|
+
vi.mock('@supabase/supabase-js', () => ({
|
|
77
|
+
createClient: (...args: any[]) => {
|
|
78
|
+
supaState.createClientCalls.push(args)
|
|
79
|
+
return {
|
|
80
|
+
from(table: string) {
|
|
81
|
+
const calls: DbCall[] = []
|
|
82
|
+
const query: DbQuery = { table, calls }
|
|
83
|
+
const builder: any = {
|
|
84
|
+
select(...a: any[]) { calls.push({ op: 'select', args: a }); return builder },
|
|
85
|
+
upsert(...a: any[]) { calls.push({ op: 'upsert', args: a }); return builder },
|
|
86
|
+
update(...a: any[]) { calls.push({ op: 'update', args: a }); return builder },
|
|
87
|
+
insert(...a: any[]) { calls.push({ op: 'insert', args: a }); return builder },
|
|
88
|
+
eq(...a: any[]) { calls.push({ op: 'eq', args: a }); return builder },
|
|
89
|
+
single() { supaState.history.push(query); return Promise.resolve(supaState.responder!(query)) },
|
|
90
|
+
maybeSingle() { supaState.history.push(query); return Promise.resolve(supaState.responder!(query)) },
|
|
91
|
+
then(res: any, rej: any) {
|
|
92
|
+
supaState.history.push(query)
|
|
93
|
+
return Promise.resolve(supaState.responder!(query)).then(res, rej)
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
return builder
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
}))
|
|
101
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:supabase_mock:block"]
|
|
102
|
+
|
|
103
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:pgstate:constant" type="constant" identity="pgState" parent="none"]
|
|
104
|
+
// CIL[DOC-MACRO purpose="Initialize hoisted state for fake migration clients."]
|
|
105
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Initialize hoisted state for fake migration clients."]
|
|
106
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
|
|
107
|
+
// CIL[DOC-CONTRACT inputs="none" outputs="Mock state" errors="none" side_effects="Initializes local mock state"]
|
|
108
|
+
// CIL[DOC-MICRO logic_summary="Initialize hoisted state for fake migration clients." notable_decisions=""]
|
|
109
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
110
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
111
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
112
|
+
const pgState = vi.hoisted(() => ({
|
|
113
|
+
instances: [] as any[],
|
|
114
|
+
handler: null as null | ((sql: string, params?: any[]) => any),
|
|
115
|
+
}))
|
|
116
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:pgstate:constant"]
|
|
117
|
+
|
|
118
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:pg_mock:block" type="block" identity="pg_mock" parent="none"]
|
|
119
|
+
// CIL[DOC-MACRO purpose="Register a fake Postgres client used by mocked installer migrations."]
|
|
120
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Register a fake Postgres client used by mocked installer migrations."]
|
|
121
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
|
|
122
|
+
// CIL[DOC-CONTRACT inputs="pgState" outputs="Vitest module mock" errors="none" side_effects="Registers a mock"]
|
|
123
|
+
// CIL[DOC-MICRO logic_summary="Register a fake Postgres client used by mocked installer migrations." notable_decisions=""]
|
|
124
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
125
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
126
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
127
|
+
vi.mock('pg', () => ({
|
|
128
|
+
default: {
|
|
129
|
+
Client: class {
|
|
130
|
+
config: any
|
|
131
|
+
calls: { sql: string; params?: any[] }[] = []
|
|
132
|
+
ended = false
|
|
133
|
+
constructor(config: any) { this.config = config; pgState.instances.push(this) }
|
|
134
|
+
async connect() {}
|
|
135
|
+
async end() { this.ended = true }
|
|
136
|
+
async query(sql: string, params?: any[]) {
|
|
137
|
+
this.calls.push({ sql, params })
|
|
138
|
+
if (pgState.handler) return pgState.handler(sql, params)
|
|
139
|
+
return { rows: [] }
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
}))
|
|
144
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:pg_mock:block"]
|
|
145
|
+
|
|
146
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:execstate:constant" type="constant" identity="execState" parent="none"]
|
|
147
|
+
// CIL[DOC-MACRO purpose="Hold recorded shell commands for the fake npm executor."]
|
|
148
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Hold recorded shell commands for the fake npm executor."]
|
|
149
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
|
|
150
|
+
// CIL[DOC-CONTRACT inputs="none" outputs="Mock command list" errors="none" side_effects="Initializes local mock state"]
|
|
151
|
+
// CIL[DOC-MICRO logic_summary="Hold recorded shell commands for the fake npm executor." notable_decisions=""]
|
|
152
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
153
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
154
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
155
|
+
const execState = vi.hoisted(() => ({ calls: [] as string[] }))
|
|
156
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:execstate:constant"]
|
|
157
|
+
|
|
158
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:exec_mock:block" type="block" identity="exec_mock" parent="none"]
|
|
159
|
+
// CIL[DOC-MACRO purpose="Register a fake command executor so installer tests never run npm."]
|
|
160
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Register a fake command executor so installer tests never run npm."]
|
|
161
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
|
|
162
|
+
// CIL[DOC-CONTRACT inputs="execState" outputs="Vitest module mock" errors="none" side_effects="Registers a mock"]
|
|
163
|
+
// CIL[DOC-MICRO logic_summary="Register a fake command executor so installer tests never run npm." notable_decisions=""]
|
|
164
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
165
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
166
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
167
|
+
vi.mock('child_process', () => ({
|
|
168
|
+
execSync: (cmd: string) => { execState.calls.push(cmd); return Buffer.from('') },
|
|
169
|
+
}))
|
|
170
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:exec_mock:block"]
|
|
171
|
+
|
|
172
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:rlstate:constant" type="constant" identity="rlState" parent="none"]
|
|
173
|
+
// CIL[DOC-MACRO purpose="Hold the answer the fake readline prompt returns."]
|
|
174
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Hold the answer the fake readline prompt returns."]
|
|
175
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
|
|
176
|
+
// CIL[DOC-CONTRACT inputs="none" outputs="Mock prompt state" errors="none" side_effects="Initializes local mock state"]
|
|
177
|
+
// CIL[DOC-MICRO logic_summary="Hold the answer the fake readline prompt returns." notable_decisions=""]
|
|
178
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
179
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
180
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
181
|
+
const rlState = vi.hoisted(() => ({ answer: 'r' }))
|
|
182
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:rlstate:constant"]
|
|
183
|
+
|
|
184
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:readline_mock:block" type="block" identity="readline_mock" parent="none"]
|
|
185
|
+
// CIL[DOC-MACRO purpose="Register a fake prompt that returns the rlState answer."]
|
|
186
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Register a fake prompt that returns the rlState answer."]
|
|
187
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
|
|
188
|
+
// CIL[DOC-CONTRACT inputs="none" outputs="Vitest module mock" errors="none" side_effects="Registers a mock"]
|
|
189
|
+
// CIL[DOC-MICRO logic_summary="Register a fake prompt that answers with rlState.answer — 'r' (Replace) by default; tests may set 'u' or 'k' for owned-type reconcile prompts." notable_decisions=""]
|
|
190
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
191
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
192
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
193
|
+
vi.mock('readline', () => ({
|
|
194
|
+
default: {
|
|
195
|
+
createInterface: () => ({
|
|
196
|
+
question: (_q: string, cb: (answer: string) => void) => cb(rlState.answer),
|
|
197
|
+
close: () => {},
|
|
198
|
+
}),
|
|
199
|
+
},
|
|
200
|
+
createInterface: () => ({
|
|
201
|
+
question: (_q: string, cb: (answer: string) => void) => cb(rlState.answer),
|
|
202
|
+
close: () => {},
|
|
203
|
+
}),
|
|
204
|
+
}))
|
|
205
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:readline_mock:block"]
|
|
206
|
+
|
|
207
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:orig_cwd:constant" type="constant" identity="ORIG_CWD" parent="none"]
|
|
208
|
+
// CIL[DOC-MACRO purpose="Capture cwd for restoring fixture isolation."]
|
|
209
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Capture cwd for restoring fixture isolation."]
|
|
210
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
|
|
211
|
+
// CIL[DOC-CONTRACT inputs="none" outputs="Original cwd" errors="none" side_effects="Reads process cwd"]
|
|
212
|
+
// CIL[DOC-MICRO logic_summary="Capture cwd for restoring fixture isolation." notable_decisions=""]
|
|
213
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
214
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
215
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
216
|
+
const ORIG_CWD = process.cwd()
|
|
217
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:orig_cwd:constant"]
|
|
218
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:env_keys:constant" type="constant" identity="ENV_KEYS" parent="none"]
|
|
219
|
+
// CIL[DOC-MACRO purpose="List environment values saved and restored by installer fixtures."]
|
|
220
|
+
// CIL[DOC-RESPONSIBILITY responsibility="List environment values saved and restored by installer fixtures."]
|
|
221
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
|
|
222
|
+
// CIL[DOC-CONTRACT inputs="none" outputs="Readonly key list" errors="none" side_effects="none"]
|
|
223
|
+
// CIL[DOC-MICRO logic_summary="List environment values saved and restored by installer fixtures." notable_decisions=""]
|
|
224
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
225
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
226
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
227
|
+
const ENV_KEYS = ['SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY', 'SUPABASE_DB_PASSWORD'] as const
|
|
228
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:env_keys:constant"]
|
|
229
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:savedenv:variable" type="variable" identity="savedEnv" parent="none"]
|
|
230
|
+
// CIL[DOC-MACRO purpose="Hold original environment values for restoration."]
|
|
231
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Hold original environment values for restoration."]
|
|
232
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
|
|
233
|
+
// CIL[DOC-CONTRACT inputs="none" outputs="Mutable saved environment map" errors="none" side_effects="Initializes local state"]
|
|
234
|
+
// CIL[DOC-MICRO logic_summary="Hold original environment values for restoration." notable_decisions=""]
|
|
235
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
236
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
237
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
238
|
+
let savedEnv: Record<string, string | undefined> = {}
|
|
239
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:savedenv:variable"]
|
|
240
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:workdir:variable" type="variable" identity="workdir" parent="none"]
|
|
241
|
+
// CIL[DOC-MACRO purpose="Hold the temporary installer project path."]
|
|
242
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Hold the temporary installer project path."]
|
|
243
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
|
|
244
|
+
// CIL[DOC-CONTRACT inputs="none" outputs="Mutable fixture path" errors="none" side_effects="Initializes local state"]
|
|
245
|
+
// CIL[DOC-MICRO logic_summary="Hold the temporary installer project path." notable_decisions=""]
|
|
246
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
247
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
248
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
249
|
+
let workdir = ''
|
|
250
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:workdir:variable"]
|
|
251
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:exitspy:variable" type="variable" identity="exitSpy" parent="none"]
|
|
252
|
+
// CIL[DOC-MACRO purpose="Hold the process-exit spy used by CLI failure tests."]
|
|
253
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Hold the process-exit spy used by CLI failure tests."]
|
|
254
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
|
|
255
|
+
// CIL[DOC-CONTRACT inputs="none" outputs="Spy reference" errors="none" side_effects="Declares local state"]
|
|
256
|
+
// CIL[DOC-MICRO logic_summary="Hold the process-exit spy used by CLI failure tests." notable_decisions=""]
|
|
257
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
258
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
259
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
260
|
+
let exitSpy: any
|
|
261
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:exitspy:variable"]
|
|
262
|
+
|
|
263
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:writejson:function" type="function" identity="writeJson" parent="none"]
|
|
264
|
+
// CIL[DOC-MACRO purpose="Create parent fixture directories and write a JSON fixture."]
|
|
265
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Create parent fixture directories and write a JSON fixture."]
|
|
266
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
|
|
267
|
+
// CIL[DOC-CONTRACT inputs="path: string, value: any" outputs="void" errors="none" side_effects="Writes temporary fixture files"]
|
|
268
|
+
// CIL[DOC-MICRO logic_summary="Create parent fixture directories and write a JSON fixture." notable_decisions=""]
|
|
269
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
270
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
271
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
272
|
+
function writeJson(path: string, value: any) {
|
|
273
|
+
mkdirSync(join(path, '..'), { recursive: true })
|
|
274
|
+
writeFileSync(path, JSON.stringify(value, null, 2))
|
|
275
|
+
}
|
|
276
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:writejson:function"]
|
|
277
|
+
|
|
278
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:basemanifest:function" type="function" identity="baseManifest" parent="none"]
|
|
279
|
+
// CIL[DOC-MACRO purpose="Build a minimal data-app manifest with supplied overrides."]
|
|
280
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Build a minimal data-app manifest with supplied overrides."]
|
|
281
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
|
|
282
|
+
// CIL[DOC-CONTRACT inputs="overrides: Record<string, any> default empty object" outputs="Manifest fixture" errors="none" side_effects="none"]
|
|
283
|
+
// CIL[DOC-MICRO logic_summary="Build a minimal data-app manifest with supplied overrides." notable_decisions=""]
|
|
284
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
285
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
286
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
287
|
+
function baseManifest(overrides: Record<string, any> = {}) {
|
|
288
|
+
return {
|
|
289
|
+
name: 'Test App',
|
|
290
|
+
slug: 'testapp',
|
|
291
|
+
version: '1.0.0',
|
|
292
|
+
app_type: 'data',
|
|
293
|
+
...overrides,
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:basemanifest:function"]
|
|
297
|
+
|
|
298
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:manifestwithregistration:function" type="function" identity="manifestWithRegistration" parent="none"]
|
|
299
|
+
// CIL[DOC-MACRO purpose="Build an enabled new-account registration fixture with optional overrides."]
|
|
300
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Build an enabled new-account registration fixture with optional overrides."]
|
|
301
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
|
|
302
|
+
// CIL[DOC-CONTRACT inputs="overrides: Record<string, any> default empty object" outputs="Manifest fixture" errors="none" side_effects="none"]
|
|
303
|
+
// CIL[DOC-MICRO logic_summary="Build an enabled new-account registration fixture with optional overrides." notable_decisions=""]
|
|
304
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
305
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
306
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
307
|
+
function manifestWithRegistration(overrides: Record<string, any> = {}) {
|
|
308
|
+
return baseManifest({
|
|
309
|
+
registration: {
|
|
310
|
+
enabled: true,
|
|
311
|
+
default_role: 'member',
|
|
312
|
+
redirect_path: '/testapp',
|
|
313
|
+
account_strategy: 'new',
|
|
314
|
+
},
|
|
315
|
+
required_roles: ['member', 'admin'],
|
|
316
|
+
...overrides,
|
|
317
|
+
})
|
|
318
|
+
}
|
|
319
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:manifestwithregistration:function"]
|
|
320
|
+
|
|
321
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:makeapp:function" type="function" identity="makeApp" parent="none"]
|
|
322
|
+
// CIL[DOC-MACRO purpose="Write an isolated app, package, and optional seed fixtures."]
|
|
323
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Write an isolated app, package, and optional seed fixtures."]
|
|
324
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
|
|
325
|
+
// CIL[DOC-CONTRACT inputs="dir: string, manifest: Record<string, any>, files: Record<string, string> default empty object" outputs="App directory path" errors="none" side_effects="Creates temporary fixtures"]
|
|
326
|
+
// CIL[DOC-MICRO logic_summary="Write an isolated app, package, and optional seed fixtures." notable_decisions=""]
|
|
327
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
328
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
329
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
330
|
+
function makeApp(dir: string, manifest: Record<string, any>, files: Record<string, string> = {}) {
|
|
331
|
+
mkdirSync(dir, { recursive: true })
|
|
332
|
+
writeJson(join(dir, 'manifest.json'), manifest)
|
|
333
|
+
writeJson(join(dir, 'package.json'), { name: 'testapp', version: manifest.version })
|
|
334
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
335
|
+
const p = join(dir, rel)
|
|
336
|
+
mkdirSync(join(p, '..'), { recursive: true })
|
|
337
|
+
writeFileSync(p, content)
|
|
338
|
+
}
|
|
339
|
+
return dir
|
|
340
|
+
}
|
|
341
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:makeapp:function"]
|
|
342
|
+
|
|
343
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:freshmodule:function" type="function" identity="freshModule" parent="none"]
|
|
344
|
+
// CIL[DOC-MACRO purpose="Reset module cache and import installer code against fixture cwd."]
|
|
345
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Reset module cache and import installer code against fixture cwd."]
|
|
346
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
|
|
347
|
+
// CIL[DOC-CONTRACT inputs="none" outputs="Promise of installer module" errors="none" side_effects="Resets module cache and imports source"]
|
|
348
|
+
// CIL[DOC-MICRO logic_summary="Reset module cache and import installer code against fixture cwd." notable_decisions=""]
|
|
349
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
350
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
351
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
352
|
+
async function freshModule() {
|
|
353
|
+
vi.resetModules()
|
|
354
|
+
return await import('../../cli/commands/install-app.ts')
|
|
355
|
+
}
|
|
356
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:freshmodule:function"]
|
|
357
|
+
|
|
358
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:setseedenv:function" type="function" identity="setSeedEnv" parent="none"]
|
|
359
|
+
// CIL[DOC-MACRO purpose="Set fake backend Supabase values for isolated seeding tests."]
|
|
360
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Set fake backend Supabase values for isolated seeding tests."]
|
|
361
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
|
|
362
|
+
// CIL[DOC-CONTRACT inputs="none" outputs="void" errors="none" side_effects="Sets fixture environment"]
|
|
363
|
+
// CIL[DOC-MICRO logic_summary="Set fake backend Supabase values for isolated seeding tests." notable_decisions=""]
|
|
364
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
365
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
366
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
367
|
+
function setSeedEnv() {
|
|
368
|
+
process.env.SUPABASE_URL = 'https://abc123.supabase.co'
|
|
369
|
+
process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-service-key'
|
|
370
|
+
}
|
|
371
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:setseedenv:function"]
|
|
372
|
+
|
|
373
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:setmigrateenv:function" type="function" identity="setMigrateEnv" parent="none"]
|
|
374
|
+
// CIL[DOC-MACRO purpose="Set fake seed and Postgres values for installer tests."]
|
|
375
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Set fake seed and Postgres values for installer tests."]
|
|
376
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
|
|
377
|
+
// CIL[DOC-CONTRACT inputs="none" outputs="void" errors="none" side_effects="Sets fixture environment"]
|
|
378
|
+
// CIL[DOC-MICRO logic_summary="Set fake seed and Postgres values for installer tests." notable_decisions=""]
|
|
379
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
380
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
381
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
382
|
+
function setMigrateEnv() {
|
|
383
|
+
setSeedEnv()
|
|
384
|
+
process.env.SUPABASE_DB_PASSWORD = 'dbpass'
|
|
385
|
+
}
|
|
386
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:setmigrateenv:function"]
|
|
387
|
+
|
|
388
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:dbresponder:function" type="function" identity="dbResponder" parent="none"]
|
|
389
|
+
// CIL[DOC-MACRO purpose="Install the fake query responder for a test."]
|
|
390
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Install the fake query responder for a test."]
|
|
391
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
|
|
392
|
+
// CIL[DOC-CONTRACT inputs="handler: (q: DbQuery) => DbResult" outputs="void" errors="none" side_effects="Mutates mock state"]
|
|
393
|
+
// CIL[DOC-MICRO logic_summary="Install the fake query responder for a test." notable_decisions=""]
|
|
394
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
395
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
396
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
397
|
+
function dbResponder(handler: (q: DbQuery) => DbResult) {
|
|
398
|
+
supaState.responder = handler
|
|
399
|
+
}
|
|
400
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:dbresponder:function"]
|
|
401
|
+
|
|
402
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:defaultresponder:function" type="function" identity="defaultResponder" parent="none"]
|
|
403
|
+
// CIL[DOC-MACRO purpose="Return standard fake app reads, app IDs, and success results."]
|
|
404
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Return standard fake app reads, app IDs, and success results."]
|
|
405
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
|
|
406
|
+
// CIL[DOC-CONTRACT inputs="q: DbQuery" outputs="DbResult" errors="none" side_effects="none"]
|
|
407
|
+
// CIL[DOC-MICRO logic_summary="Return standard fake app reads, app IDs, and success results." notable_decisions=""]
|
|
408
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
409
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
410
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
411
|
+
function defaultResponder(q: DbQuery): DbResult {
|
|
412
|
+
const first = q.calls[0]?.op
|
|
413
|
+
if (q.table === 'apps' && first === 'select') return { data: null, error: null }
|
|
414
|
+
if (first === 'upsert' && q.table === 'apps') return { data: { id: 'app-1' }, error: null }
|
|
415
|
+
return { data: null, error: null }
|
|
416
|
+
}
|
|
417
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:defaultresponder:function"]
|
|
418
|
+
|
|
419
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:queriesto:function" type="function" identity="queriesTo" parent="none"]
|
|
420
|
+
// CIL[DOC-MACRO purpose="Select recorded mock queries by table and optional operation."]
|
|
421
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Select recorded mock queries by table and optional operation."]
|
|
422
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
|
|
423
|
+
// CIL[DOC-CONTRACT inputs="table: string, op?: string" outputs="DbQuery array" errors="none" side_effects="Reads mock history"]
|
|
424
|
+
// CIL[DOC-MICRO logic_summary="Select recorded mock queries by table and optional operation." notable_decisions=""]
|
|
425
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
426
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
427
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
428
|
+
function queriesTo(table: string, op?: string) {
|
|
429
|
+
return supaState.history.filter(q => q.table === table && (!op || q.calls.some(c => c.op === op)))
|
|
430
|
+
}
|
|
431
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:queriesto:function"]
|
|
432
|
+
|
|
433
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:makeprogram:function" type="function" identity="makeProgram" parent="none"]
|
|
434
|
+
// CIL[DOC-MACRO purpose="Create an in-memory Commander program with installer commands."]
|
|
435
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Create an in-memory Commander program with installer commands."]
|
|
436
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
|
|
437
|
+
// CIL[DOC-CONTRACT inputs="none" outputs="Promise of Command" errors="none" side_effects="Resets module cache and registers commands"]
|
|
438
|
+
// CIL[DOC-MICRO logic_summary="Create an in-memory Commander program with installer commands." notable_decisions=""]
|
|
439
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
440
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
441
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
442
|
+
async function makeProgram() {
|
|
443
|
+
const mod = await freshModule()
|
|
444
|
+
const program = new Command()
|
|
445
|
+
program.exitOverride()
|
|
446
|
+
mod.registerInstallAppCommands(program)
|
|
447
|
+
return program
|
|
448
|
+
}
|
|
449
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:makeprogram:function"]
|
|
450
|
+
|
|
451
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:writespineconfig:function" type="function" identity="writeSpineConfig" parent="none"]
|
|
452
|
+
// CIL[DOC-MACRO purpose="Write a fixture configuration as JSON or raw malformed text."]
|
|
453
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Write a fixture configuration as JSON or raw malformed text."]
|
|
454
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
|
|
455
|
+
// CIL[DOC-CONTRACT inputs="content: any" outputs="void" errors="none" side_effects="Writes fixture config"]
|
|
456
|
+
// CIL[DOC-MICRO logic_summary="Write a fixture configuration as JSON or raw malformed text." notable_decisions=""]
|
|
457
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
458
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
459
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
460
|
+
function writeSpineConfig(content: any) {
|
|
461
|
+
writeFileSync(join(workdir, 'spine.config.json'), typeof content === 'string' ? content : JSON.stringify(content))
|
|
462
|
+
}
|
|
463
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:writespineconfig:function"]
|
|
464
|
+
|
|
465
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:readspineconfig:function" type="function" identity="readSpineConfig" parent="none"]
|
|
466
|
+
// CIL[DOC-MACRO purpose="Read and parse a fixture configuration."]
|
|
467
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Read and parse a fixture configuration."]
|
|
468
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
|
|
469
|
+
// CIL[DOC-CONTRACT inputs="path: string default fixture spine.config.json" outputs="Parsed config" errors="none" side_effects="Reads fixture config"]
|
|
470
|
+
// CIL[DOC-MICRO logic_summary="Read and parse a fixture configuration." notable_decisions=""]
|
|
471
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
472
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
473
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
474
|
+
function readSpineConfig(path = join(workdir, 'spine.config.json')) {
|
|
475
|
+
return JSON.parse(readFileSync(path, 'utf8'))
|
|
476
|
+
}
|
|
477
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:readspineconfig:function"]
|
|
478
|
+
|
|
479
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:fixture_setup:block" type="block" identity="fixture_setup" parent="none"]
|
|
480
|
+
// CIL[DOC-MACRO purpose="Register isolated cwd, environment, client, process-exit, and console setup."]
|
|
481
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Register isolated cwd, environment, client, process-exit, and console setup."]
|
|
482
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
|
|
483
|
+
// CIL[DOC-CONTRACT inputs="Vitest lifecycle" outputs="Fixture hook" errors="none" side_effects="Registers setup that creates a temp root, changes cwd, and installs mocks"]
|
|
484
|
+
// CIL[DOC-MICRO logic_summary="Register isolated cwd, environment, client, process-exit, and console setup." notable_decisions=""]
|
|
485
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
486
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
487
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
488
|
+
beforeEach(() => {
|
|
489
|
+
savedEnv = {}
|
|
490
|
+
for (const k of ENV_KEYS) savedEnv[k] = process.env[k]
|
|
491
|
+
workdir = mkdtempSync(join(tmpdir(), 'spine-install-test-'))
|
|
492
|
+
process.chdir(workdir)
|
|
493
|
+
supaState.createClientCalls = []
|
|
494
|
+
supaState.history = []
|
|
495
|
+
supaState.responder = defaultResponder
|
|
496
|
+
pgState.instances = []
|
|
497
|
+
pgState.handler = null
|
|
498
|
+
execState.calls = []
|
|
499
|
+
rlState.answer = 'r'
|
|
500
|
+
exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
|
501
|
+
throw new Error(`process.exit(${code})`)
|
|
502
|
+
}) as any)
|
|
503
|
+
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
504
|
+
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
505
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
506
|
+
vi.spyOn(process.stdout, 'write').mockImplementation((() => true) as any)
|
|
507
|
+
})
|
|
508
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:fixture_setup:block"]
|
|
509
|
+
|
|
510
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:fixture_cleanup:block" type="block" identity="fixture_cleanup" parent="none"]
|
|
511
|
+
// CIL[DOC-MACRO purpose="Register fixture removal and cwd, environment, and spy restoration."]
|
|
512
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Register fixture removal and cwd, environment, and spy restoration."]
|
|
513
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
|
|
514
|
+
// CIL[DOC-CONTRACT inputs="Fixture state" outputs="Cleanup hook" errors="none" side_effects="Registers fixture cleanup"]
|
|
515
|
+
// CIL[DOC-MICRO logic_summary="Register fixture removal and cwd, environment, and spy restoration." notable_decisions=""]
|
|
516
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
517
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
518
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
519
|
+
afterEach(() => {
|
|
520
|
+
process.chdir(ORIG_CWD)
|
|
521
|
+
rmSync(workdir, { recursive: true, force: true })
|
|
522
|
+
for (const k of ENV_KEYS) {
|
|
523
|
+
if (savedEnv[k] === undefined) delete process.env[k]
|
|
524
|
+
else process.env[k] = savedEnv[k]
|
|
525
|
+
}
|
|
526
|
+
vi.restoreAllMocks()
|
|
527
|
+
})
|
|
528
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:fixture_cleanup:block"]
|
|
529
|
+
|
|
530
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:seed_preflight_suite:block" type="block" identity="seed_preflight_suite" parent="none"]
|
|
531
|
+
// CIL[DOC-MACRO purpose="Register missing credentials, mandatory seed, malformed fallback, path escape, and slug tests."]
|
|
532
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Register missing credentials, mandatory seed, malformed fallback, path escape, and slug tests."]
|
|
533
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
|
|
534
|
+
// CIL[DOC-CONTRACT inputs="Fixture helpers and seedApp" outputs="Vitest assertions" errors="none" side_effects="Registers fixture tests with fake clients"]
|
|
535
|
+
// CIL[DOC-MICRO logic_summary="Register missing credentials, mandatory seed, malformed fallback, path escape, and slug tests." notable_decisions=""]
|
|
536
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
537
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
538
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
539
|
+
describe('seedApp preflight', () => {
|
|
540
|
+
it('throws when Supabase env is missing without creating a client', async () => {
|
|
541
|
+
const { seedApp } = await freshModule()
|
|
542
|
+
delete process.env.SUPABASE_URL
|
|
543
|
+
delete process.env.SUPABASE_SERVICE_ROLE_KEY
|
|
544
|
+
const appDir = makeApp(join(workdir, 'app'), baseManifest())
|
|
545
|
+
await expect(seedApp(appDir, 'testapp', join(workdir, 'spine.config.json'))).rejects.toThrow(/SUPABASE_URL/)
|
|
546
|
+
expect(supaState.createClientCalls.length).toBe(0)
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
it('rejects a missing declared seed file before creating a client', async () => {
|
|
550
|
+
const { seedApp } = await freshModule()
|
|
551
|
+
setSeedEnv()
|
|
552
|
+
const appDir = makeApp(join(workdir, 'app'), baseManifest({
|
|
553
|
+
seed: [{ file: 'seed/missing.json', table: 'roles', conflict: 'slug' }],
|
|
554
|
+
}))
|
|
555
|
+
await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow()
|
|
556
|
+
expect(supaState.createClientCalls.length).toBe(0)
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
it('rejects a seed path escaping the app root before creating a client', async () => {
|
|
560
|
+
const { seedApp } = await freshModule()
|
|
561
|
+
setSeedEnv()
|
|
562
|
+
writeJson(join(workdir, 'outside.json'), [])
|
|
563
|
+
const appDir = makeApp(join(workdir, 'app'), baseManifest({
|
|
564
|
+
seed: [{ file: '../outside.json', table: 'roles', conflict: 'slug' }],
|
|
565
|
+
}))
|
|
566
|
+
await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow()
|
|
567
|
+
expect(supaState.createClientCalls.length).toBe(0)
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
it('rejects a malformed declared seed file before creating a client', async () => {
|
|
571
|
+
const { seedApp } = await freshModule()
|
|
572
|
+
setSeedEnv()
|
|
573
|
+
const appDir = makeApp(join(workdir, 'app'), baseManifest({
|
|
574
|
+
seed: [{ file: 'seed/roles.json', table: 'roles', conflict: 'slug' }],
|
|
575
|
+
}), { 'seed/roles.json': JSON.stringify([null]) })
|
|
576
|
+
await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow()
|
|
577
|
+
expect(supaState.createClientCalls.length).toBe(0)
|
|
578
|
+
})
|
|
579
|
+
|
|
580
|
+
it('rejects a malformed optional fallback seed file before creating a client', async () => {
|
|
581
|
+
const { seedApp } = await freshModule()
|
|
582
|
+
setSeedEnv()
|
|
583
|
+
const appDir = makeApp(join(workdir, 'app'), baseManifest(), {
|
|
584
|
+
'seed/roles.json': JSON.stringify({ not: 'an array' }),
|
|
585
|
+
})
|
|
586
|
+
await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow()
|
|
587
|
+
expect(supaState.createClientCalls.length).toBe(0)
|
|
588
|
+
})
|
|
589
|
+
|
|
590
|
+
it('throws on manifest slug mismatch before creating a client', async () => {
|
|
591
|
+
const { seedApp } = await freshModule()
|
|
592
|
+
setSeedEnv()
|
|
593
|
+
const appDir = makeApp(join(workdir, 'app'), baseManifest({ slug: 'other-app' }))
|
|
594
|
+
await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow(/slug/i)
|
|
595
|
+
expect(supaState.createClientCalls.length).toBe(0)
|
|
596
|
+
})
|
|
597
|
+
})
|
|
598
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:seed_preflight_suite:block"]
|
|
599
|
+
|
|
600
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:config_preflight_suite:block" type="block" identity="config_preflight_suite" parent="none"]
|
|
601
|
+
// CIL[DOC-MACRO purpose="Register config shape, slug grammar, enabled policy, and zero-client preflight tests."]
|
|
602
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Register config shape, slug grammar, enabled policy, and zero-client preflight tests."]
|
|
603
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
|
|
604
|
+
// CIL[DOC-CONTRACT inputs="Fixture helpers and seedApp" outputs="Vitest assertions" errors="none" side_effects="Registers fake-client tests"]
|
|
605
|
+
// CIL[DOC-MICRO logic_summary="Register config shape, slug grammar, enabled policy, and zero-client preflight tests." notable_decisions=""]
|
|
606
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
607
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
608
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
609
|
+
describe('seedApp config and manifest preflight', () => {
|
|
610
|
+
it.each([
|
|
611
|
+
'{ not json',
|
|
612
|
+
'null',
|
|
613
|
+
'[]',
|
|
614
|
+
'"text"',
|
|
615
|
+
'42',
|
|
616
|
+
JSON.stringify({ registration: 'x' }),
|
|
617
|
+
JSON.stringify({ registration: [] }),
|
|
618
|
+
JSON.stringify({ registration: null }),
|
|
619
|
+
JSON.stringify({ registration: { apps: 'x' } }),
|
|
620
|
+
JSON.stringify({ registration: { apps: [] } }),
|
|
621
|
+
JSON.stringify({ registration: { apps: null } }),
|
|
622
|
+
])('rejects malformed local config %s before creating a client', async (content) => {
|
|
623
|
+
const { seedApp } = await freshModule()
|
|
624
|
+
setSeedEnv()
|
|
625
|
+
const configPath = join(workdir, 'spine.config.json')
|
|
626
|
+
writeSpineConfig(content)
|
|
627
|
+
const appDir = makeApp(join(workdir, 'app'), manifestWithRegistration())
|
|
628
|
+
await expect(seedApp(appDir, 'testapp', configPath)).rejects.toThrow()
|
|
629
|
+
expect(supaState.createClientCalls.length).toBe(0)
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
it('never reads the local config when the manifest has no registration block', async () => {
|
|
633
|
+
const { seedApp } = await freshModule()
|
|
634
|
+
setSeedEnv()
|
|
635
|
+
const configPath = join(workdir, 'spine.config.json')
|
|
636
|
+
writeSpineConfig('{ not json')
|
|
637
|
+
const appDir = makeApp(join(workdir, 'app'), baseManifest())
|
|
638
|
+
await seedApp(appDir, 'testapp', configPath)
|
|
639
|
+
expect(supaState.createClientCalls.length).toBe(1)
|
|
640
|
+
expect(readFileSync(configPath, 'utf8')).toBe('{ not json')
|
|
641
|
+
})
|
|
642
|
+
|
|
643
|
+
it.each(['"plain"', '[1,2]', '5'])('rejects a non-object manifest.config %s before creating a client', async (cfg) => {
|
|
644
|
+
const { seedApp } = await freshModule()
|
|
645
|
+
setSeedEnv()
|
|
646
|
+
const appDir = makeApp(join(workdir, 'app'), baseManifest({ config: JSON.parse(cfg) }))
|
|
647
|
+
await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow()
|
|
648
|
+
expect(supaState.createClientCalls.length).toBe(0)
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
it('treats manifest.config null as absent', async () => {
|
|
652
|
+
const { seedApp } = await freshModule()
|
|
653
|
+
setSeedEnv()
|
|
654
|
+
const appDir = makeApp(join(workdir, 'app'), baseManifest({ config: null }))
|
|
655
|
+
await seedApp(appDir, 'testapp', join(workdir, 'c.json'))
|
|
656
|
+
const upsert = queriesTo('apps', 'upsert')[0].calls.find(c => c.op === 'upsert')!.args[0]
|
|
657
|
+
expect(upsert.config).toBeUndefined()
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
it('rejects a malformed existing apps.config before the app upsert', async () => {
|
|
661
|
+
const { seedApp } = await freshModule()
|
|
662
|
+
setSeedEnv()
|
|
663
|
+
dbResponder(q => {
|
|
664
|
+
if (q.table === 'apps' && q.calls[0]?.op === 'select') return { data: { config: 'broken' }, error: null }
|
|
665
|
+
if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
|
|
666
|
+
return { data: null, error: null }
|
|
667
|
+
})
|
|
668
|
+
const appDir = makeApp(join(workdir, 'app'), baseManifest())
|
|
669
|
+
await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow()
|
|
670
|
+
expect(queriesTo('apps', 'upsert').length).toBe(0)
|
|
671
|
+
})
|
|
672
|
+
|
|
673
|
+
it.each(['__proto__', 'Bad_Slug', 'UPPER', '-lead', 'has space', 'test_app', '9start'])(
|
|
674
|
+
'rejects invalid app slug %s before validation and client creation',
|
|
675
|
+
async (slug) => {
|
|
676
|
+
const { seedApp } = await freshModule()
|
|
677
|
+
setSeedEnv()
|
|
678
|
+
const appDir = makeApp(join(workdir, 'app'), manifestWithRegistration())
|
|
679
|
+
await expect(seedApp(appDir, slug as any, join(workdir, 'c.json'))).rejects.toThrow(/slug/i)
|
|
680
|
+
expect(supaState.createClientCalls.length).toBe(0)
|
|
681
|
+
},
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
it('rejects a non-string app slug before creating a client', async () => {
|
|
685
|
+
const { seedApp } = await freshModule()
|
|
686
|
+
setSeedEnv()
|
|
687
|
+
const appDir = makeApp(join(workdir, 'app'), manifestWithRegistration())
|
|
688
|
+
await expect(seedApp(appDir, 42 as any, join(workdir, 'c.json'))).rejects.toThrow()
|
|
689
|
+
expect(supaState.createClientCalls.length).toBe(0)
|
|
690
|
+
})
|
|
691
|
+
|
|
692
|
+
it.each([{ enabled: 'yes' }, { enabled: 1 }, { enabled: {} }])(
|
|
693
|
+
'rejects non-boolean registration.enabled %j before creating a client',
|
|
694
|
+
async (registration) => {
|
|
695
|
+
const { seedApp } = await freshModule()
|
|
696
|
+
setSeedEnv()
|
|
697
|
+
const appDir = makeApp(join(workdir, 'app'), baseManifest({
|
|
698
|
+
registration: { ...registration, default_role: 'member', redirect_path: '/x' },
|
|
699
|
+
}))
|
|
700
|
+
await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow()
|
|
701
|
+
expect(supaState.createClientCalls.length).toBe(0)
|
|
702
|
+
},
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
it.each([
|
|
706
|
+
{ enabled: true, redirect_path: '/x' },
|
|
707
|
+
{ enabled: true, default_role: 'member' },
|
|
708
|
+
{ enabled: true, default_role: ' ', redirect_path: '/x' },
|
|
709
|
+
{ enabled: true, default_role: 'member', redirect_path: '' },
|
|
710
|
+
])('requires nonempty default_role and redirect_path when enabled %j before creating a client', async (registration) => {
|
|
711
|
+
const { seedApp } = await freshModule()
|
|
712
|
+
setSeedEnv()
|
|
713
|
+
const appDir = makeApp(join(workdir, 'app'), baseManifest({ registration }))
|
|
714
|
+
await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow()
|
|
715
|
+
expect(supaState.createClientCalls.length).toBe(0)
|
|
716
|
+
})
|
|
717
|
+
})
|
|
718
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:config_preflight_suite:block"]
|
|
719
|
+
|
|
720
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:permissions_preflight_suite:block" type="block" identity="permissions_preflight_suite" parent="none"]
|
|
721
|
+
// CIL[DOC-MACRO purpose="Register valid empty grants and malformed permissions-patch preflight tests."]
|
|
722
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Register valid empty grants and malformed permissions-patch preflight tests."]
|
|
723
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
|
|
724
|
+
// CIL[DOC-CONTRACT inputs="Fixture helpers, seedApp, records: parameterized permission-patch fixture rows" outputs="Vitest assertions" errors="none" side_effects="Registers fake-client tests"]
|
|
725
|
+
// CIL[DOC-MICRO logic_summary="Register valid empty grants and malformed permissions-patch preflight tests." notable_decisions=""]
|
|
726
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
727
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
728
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
729
|
+
describe('permissions patch preflight', () => {
|
|
730
|
+
const patchEntry = {
|
|
731
|
+
file: 'seed/type_permissions.json',
|
|
732
|
+
table: 'types',
|
|
733
|
+
conflict: 'kind,slug',
|
|
734
|
+
inject_app_id: false,
|
|
735
|
+
permissions_patch: true,
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function patchApp(records: any[]) {
|
|
739
|
+
return makeApp(join(workdir, 'app'), baseManifest({ seed: [patchEntry] }), {
|
|
740
|
+
'seed/type_permissions.json': JSON.stringify(records),
|
|
741
|
+
})
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
it.each<{ records: any[] }>([
|
|
745
|
+
{ records: [{ slug: 'task', record_permissions: { admin: ['read'] } }] },
|
|
746
|
+
{ records: [{ kind: '', slug: 'task', record_permissions: {} }] },
|
|
747
|
+
{ records: [{ kind: 'item', slug: ' ', record_permissions: {} }] },
|
|
748
|
+
{ records: [{ kind: 'item', slug: 'task' }] },
|
|
749
|
+
{ records: [{ kind: 'item', slug: 'task', record_permissions: null }] },
|
|
750
|
+
{ records: [{ kind: 'item', slug: 'task', record_permissions: ['read'] }] },
|
|
751
|
+
{ records: [{ kind: 'item', slug: 'task', record_permissions: 'x' }] },
|
|
752
|
+
{ records: [{ kind: 'item', slug: 'task', record_permissions: { admin: 'read' } }] },
|
|
753
|
+
{ records: [{ kind: 'item', slug: 'task', record_permissions: { admin: ['read', 'drop'] } }] },
|
|
754
|
+
])('rejects malformed patch row %# before creating a client', async ({ records }) => {
|
|
755
|
+
const { seedApp } = await freshModule()
|
|
756
|
+
setSeedEnv()
|
|
757
|
+
await expect(seedApp(patchApp(records), 'testapp', join(workdir, 'c.json'))).rejects.toThrow()
|
|
758
|
+
expect(supaState.createClientCalls.length).toBe(0)
|
|
759
|
+
})
|
|
760
|
+
|
|
761
|
+
it('accepts an empty record_permissions map and treats it as an explicit no-grant', async () => {
|
|
762
|
+
const { seedApp } = await freshModule()
|
|
763
|
+
setSeedEnv()
|
|
764
|
+
dbResponder(q => {
|
|
765
|
+
if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
|
|
766
|
+
if (q.table === 'types' && q.calls[0]?.op === 'select') {
|
|
767
|
+
return { data: { id: 't-1', design_schema: { record_permissions: {} } }, error: null }
|
|
768
|
+
}
|
|
769
|
+
return { data: null, error: null }
|
|
770
|
+
})
|
|
771
|
+
await seedApp(patchApp([{ kind: 'item', slug: 'task', record_permissions: {} }]), 'testapp', join(workdir, 'c.json'))
|
|
772
|
+
expect(supaState.createClientCalls.length).toBe(1)
|
|
773
|
+
expect(queriesTo('types', 'update').length).toBe(0)
|
|
774
|
+
})
|
|
775
|
+
})
|
|
776
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:permissions_preflight_suite:block"]
|
|
777
|
+
|
|
778
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:seed_writes_suite:block" type="block" identity="seed_writes_suite" parent="none"]
|
|
779
|
+
// CIL[DOC-MACRO purpose="Register seed write failures, pipeline resolution, generated validation, and permission-patch tests."]
|
|
780
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Register seed write failures, pipeline resolution, generated validation, and permission-patch tests."]
|
|
781
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
|
|
782
|
+
// CIL[DOC-CONTRACT inputs="Mock responders and seedApp" outputs="Vitest assertions" errors="none" side_effects="Registers isolated fake-database tests"]
|
|
783
|
+
// CIL[DOC-MICRO logic_summary="Register seed write failures, pipeline resolution, generated validation, and permission-patch tests." notable_decisions=""]
|
|
784
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
785
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
786
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
787
|
+
describe('seedApp fail-closed writes', () => {
|
|
788
|
+
it('seeds an empty declared seed array successfully', async () => {
|
|
789
|
+
const { seedApp } = await freshModule()
|
|
790
|
+
setSeedEnv()
|
|
791
|
+
const appDir = makeApp(join(workdir, 'app'), baseManifest({
|
|
792
|
+
seed: [{ file: 'seed/roles.json', table: 'roles', conflict: 'slug' }],
|
|
793
|
+
}), { 'seed/roles.json': '[]' })
|
|
794
|
+
await seedApp(appDir, 'testapp', join(workdir, 'c.json'))
|
|
795
|
+
expect(supaState.createClientCalls.length).toBe(1)
|
|
796
|
+
expect(queriesTo('apps', 'upsert').length).toBe(1)
|
|
797
|
+
expect(queriesTo('roles').length).toBe(0)
|
|
798
|
+
})
|
|
799
|
+
|
|
800
|
+
it('throws when the app upsert errors', async () => {
|
|
801
|
+
const { seedApp } = await freshModule()
|
|
802
|
+
setSeedEnv()
|
|
803
|
+
dbResponder(q => {
|
|
804
|
+
if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: null, error: { message: 'upsert blew up' } }
|
|
805
|
+
return { data: null, error: null }
|
|
806
|
+
})
|
|
807
|
+
const appDir = makeApp(join(workdir, 'app'), baseManifest())
|
|
808
|
+
await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow('upsert blew up')
|
|
809
|
+
expect(queriesTo('roles').length).toBe(0)
|
|
810
|
+
})
|
|
811
|
+
|
|
812
|
+
it('throws when the app upsert returns no id', async () => {
|
|
813
|
+
const { seedApp } = await freshModule()
|
|
814
|
+
setSeedEnv()
|
|
815
|
+
dbResponder(q => {
|
|
816
|
+
if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: null, error: null }
|
|
817
|
+
return { data: null, error: null }
|
|
818
|
+
})
|
|
819
|
+
const appDir = makeApp(join(workdir, 'app'), baseManifest())
|
|
820
|
+
await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow(/no id/i)
|
|
821
|
+
})
|
|
822
|
+
|
|
823
|
+
it('throws on an ordinary seed row upsert failure', async () => {
|
|
824
|
+
const { seedApp } = await freshModule()
|
|
825
|
+
setSeedEnv()
|
|
826
|
+
dbResponder(q => {
|
|
827
|
+
if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
|
|
828
|
+
if (q.table === 'roles' && q.calls[0]?.op === 'upsert') return { data: null, error: { message: 'row conflict' } }
|
|
829
|
+
return { data: null, error: null }
|
|
830
|
+
})
|
|
831
|
+
const appDir = makeApp(join(workdir, 'app'), baseManifest({
|
|
832
|
+
seed: [{ file: 'seed/roles.json', table: 'roles', conflict: 'slug' }],
|
|
833
|
+
}), { 'seed/roles.json': JSON.stringify([{ slug: 'member', name: 'Member' }]) })
|
|
834
|
+
await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow('row conflict')
|
|
835
|
+
})
|
|
836
|
+
|
|
837
|
+
it('throws when a trigger seed references a missing pipeline', async () => {
|
|
838
|
+
const { seedApp } = await freshModule()
|
|
839
|
+
setSeedEnv()
|
|
840
|
+
dbResponder(q => {
|
|
841
|
+
if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
|
|
842
|
+
if (q.table === 'pipelines') return { data: null, error: null }
|
|
843
|
+
return { data: null, error: null }
|
|
844
|
+
})
|
|
845
|
+
const appDir = makeApp(join(workdir, 'app'), baseManifest({
|
|
846
|
+
seed: [{ file: 'seed/triggers.json', table: 'triggers', conflict: 'slug' }],
|
|
847
|
+
}), { 'seed/triggers.json': JSON.stringify([{ slug: 't1', pipeline_slug: 'missing-pipe' }]) })
|
|
848
|
+
await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow(/pipeline/i)
|
|
849
|
+
})
|
|
850
|
+
|
|
851
|
+
it('generates validation_schema for types rows and keeps insert-only conflict contract', async () => {
|
|
852
|
+
const { seedApp } = await freshModule()
|
|
853
|
+
setSeedEnv()
|
|
854
|
+
dbResponder(q => {
|
|
855
|
+
if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
|
|
856
|
+
return { data: null, error: null }
|
|
857
|
+
})
|
|
858
|
+
const design = { fields: { title: { data_type: 'text', required: true } } }
|
|
859
|
+
const appDir = makeApp(join(workdir, 'app'), baseManifest({
|
|
860
|
+
seed: [{ file: 'seed/types.json', table: 'types', conflict: 'kind,slug', inject_app_id: false }],
|
|
861
|
+
}), { 'seed/types.json': JSON.stringify([{ kind: 'item', slug: 'task', design_schema: design }]) })
|
|
862
|
+
await seedApp(appDir, 'testapp', join(workdir, 'c.json'))
|
|
863
|
+
const typeUpserts = queriesTo('types', 'upsert')
|
|
864
|
+
expect(typeUpserts.length).toBe(1)
|
|
865
|
+
const [row, opts] = typeUpserts[0].calls.find(c => c.op === 'upsert')!.args
|
|
866
|
+
expect(row.app_id).toBeUndefined()
|
|
867
|
+
expect(row.validation_schema).toEqual({ fields: { title: { data_type: 'text', required: true } } })
|
|
868
|
+
expect(opts).toEqual({ onConflict: 'kind,slug', ignoreDuplicates: true })
|
|
869
|
+
})
|
|
870
|
+
|
|
871
|
+
it('throws when a types row carries a non-object design_schema before creating a client', async () => {
|
|
872
|
+
const { seedApp } = await freshModule()
|
|
873
|
+
setSeedEnv()
|
|
874
|
+
dbResponder(q => {
|
|
875
|
+
if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
|
|
876
|
+
return { data: null, error: null }
|
|
877
|
+
})
|
|
878
|
+
const appDir = makeApp(join(workdir, 'app'), baseManifest({
|
|
879
|
+
seed: [{ file: 'seed/types.json', table: 'types', conflict: 'kind,slug', inject_app_id: false }],
|
|
880
|
+
}), { 'seed/types.json': JSON.stringify([{ kind: 'item', slug: 'task', design_schema: 'nope' }]) })
|
|
881
|
+
await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow(/design_schema/)
|
|
882
|
+
expect(supaState.createClientCalls.length).toBe(0)
|
|
883
|
+
expect(queriesTo('apps', 'upsert').length).toBe(0)
|
|
884
|
+
expect(queriesTo('types', 'upsert').length).toBe(0)
|
|
885
|
+
})
|
|
886
|
+
|
|
887
|
+
it('throws when an explicit permissions patch targets a missing type', async () => {
|
|
888
|
+
const { seedApp } = await freshModule()
|
|
889
|
+
setSeedEnv()
|
|
890
|
+
dbResponder(q => {
|
|
891
|
+
if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
|
|
892
|
+
if (q.table === 'types' && q.calls[0]?.op === 'select') return { data: null, error: null }
|
|
893
|
+
return { data: null, error: null }
|
|
894
|
+
})
|
|
895
|
+
const appDir = makeApp(join(workdir, 'app'), baseManifest({
|
|
896
|
+
seed: [{ file: 'seed/type_permissions.json', table: 'types', conflict: 'kind,slug', inject_app_id: false, permissions_patch: true }],
|
|
897
|
+
}), { 'seed/type_permissions.json': JSON.stringify([{ kind: 'item', slug: 'ghost', record_permissions: { admin: ['read'] } }]) })
|
|
898
|
+
await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow(/ghost|not found/i)
|
|
899
|
+
})
|
|
900
|
+
|
|
901
|
+
it('throws on a malformed permissions patch row', async () => {
|
|
902
|
+
const { seedApp } = await freshModule()
|
|
903
|
+
setSeedEnv()
|
|
904
|
+
dbResponder(q => {
|
|
905
|
+
if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
|
|
906
|
+
return { data: null, error: null }
|
|
907
|
+
})
|
|
908
|
+
const appDir = makeApp(join(workdir, 'app'), baseManifest({
|
|
909
|
+
seed: [{ file: 'seed/type_permissions.json', table: 'types', conflict: 'kind,slug', inject_app_id: false, permissions_patch: true }],
|
|
910
|
+
}), { 'seed/type_permissions.json': JSON.stringify([{ kind: 'item' }]) })
|
|
911
|
+
await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow()
|
|
912
|
+
})
|
|
913
|
+
|
|
914
|
+
it('regenerates validation_schema from the merged design schema on patch replace and throws on patch DB failure', async () => {
|
|
915
|
+
const { seedApp } = await freshModule()
|
|
916
|
+
setSeedEnv()
|
|
917
|
+
const existingSchema = { fields: { title: { data_type: 'text', required: true } }, record_permissions: { admin: ['read'] } }
|
|
918
|
+
dbResponder(q => {
|
|
919
|
+
if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
|
|
920
|
+
if (q.table === 'types' && q.calls[0]?.op === 'select') return { data: { id: 't-1', design_schema: existingSchema }, error: null }
|
|
921
|
+
if (q.table === 'types' && q.calls[0]?.op === 'update') return { data: null, error: { message: 'patch write failed' } }
|
|
922
|
+
return { data: null, error: null }
|
|
923
|
+
})
|
|
924
|
+
const appDir = makeApp(join(workdir, 'app'), baseManifest({
|
|
925
|
+
seed: [{ file: 'seed/type_permissions.json', table: 'types', conflict: 'kind,slug', inject_app_id: false, permissions_patch: true }],
|
|
926
|
+
}), { 'seed/type_permissions.json': JSON.stringify([{ kind: 'item', slug: 'task', record_permissions: { admin: ['read', 'update'] } }]) })
|
|
927
|
+
await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow('patch write failed')
|
|
928
|
+
const updateCall = queriesTo('types', 'update')[0].calls.find(c => c.op === 'update')!.args[0]
|
|
929
|
+
expect(updateCall.design_schema.record_permissions).toEqual({ admin: ['read', 'update'] })
|
|
930
|
+
expect(updateCall.validation_schema).toEqual({ fields: { title: { data_type: 'text', required: true } } })
|
|
931
|
+
})
|
|
932
|
+
})
|
|
933
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:seed_writes_suite:block"]
|
|
934
|
+
|
|
935
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:registration_suite:block" type="block" identity="registration_suite" parent="none"]
|
|
936
|
+
// CIL[DOC-MACRO purpose="Register policy parity, config preservation, disabled policy, and error handling tests."]
|
|
937
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Register policy parity, config preservation, disabled policy, and error handling tests."]
|
|
938
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
|
|
939
|
+
// CIL[DOC-CONTRACT inputs="Mock responders, fixture config, overrides: local manifest helper options" outputs="Vitest assertions" errors="none" side_effects="Registers fake DB and temporary file tests"]
|
|
940
|
+
// CIL[DOC-MICRO logic_summary="Register policy parity, config preservation, disabled policy, and error handling tests." notable_decisions=""]
|
|
941
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
942
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
943
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
944
|
+
describe('registration sync', () => {
|
|
945
|
+
function seededRegManifest(overrides: Record<string, any> = {}) {
|
|
946
|
+
return baseManifest({
|
|
947
|
+
registration: {
|
|
948
|
+
enabled: true,
|
|
949
|
+
default_role: 'member',
|
|
950
|
+
redirect_path: '/testapp',
|
|
951
|
+
account_strategy: 'new',
|
|
952
|
+
},
|
|
953
|
+
required_roles: ['member', 'admin'],
|
|
954
|
+
...overrides,
|
|
955
|
+
})
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
it('writes server registration policy and the local config file after seeds', async () => {
|
|
959
|
+
const { seedApp } = await freshModule()
|
|
960
|
+
setSeedEnv()
|
|
961
|
+
const configPath = join(workdir, 'spine.config.json')
|
|
962
|
+
const appDir = makeApp(join(workdir, 'app'), seededRegManifest(), { 'seed/roles.json': '[]' })
|
|
963
|
+
await seedApp(appDir, 'testapp', configPath)
|
|
964
|
+
const updates = queriesTo('apps', 'update')
|
|
965
|
+
expect(updates.length).toBe(1)
|
|
966
|
+
const updatePayload = updates[0].calls.find(c => c.op === 'update')!.args[0]
|
|
967
|
+
expect(updatePayload.config.registration).toEqual({
|
|
968
|
+
roles: ['member', 'admin'],
|
|
969
|
+
default_role: 'member',
|
|
970
|
+
path: '/testapp',
|
|
971
|
+
enabled: true,
|
|
972
|
+
account_strategy: 'new',
|
|
973
|
+
target_account: undefined,
|
|
974
|
+
parent_account: undefined,
|
|
975
|
+
default_workspace_name: undefined,
|
|
976
|
+
allow_domain_join: undefined,
|
|
977
|
+
})
|
|
978
|
+
const fileCfg = readSpineConfig(configPath)
|
|
979
|
+
expect(fileCfg.registration.apps.testapp.default_role).toBe('member')
|
|
980
|
+
expect(fileCfg.registration.default_app).toBe('testapp')
|
|
981
|
+
expect(fileCfg.registration.allow_url_override).toBe(true)
|
|
982
|
+
})
|
|
983
|
+
|
|
984
|
+
it('merges existing apps.config instead of erasing unrelated keys', async () => {
|
|
985
|
+
const { seedApp } = await freshModule()
|
|
986
|
+
setSeedEnv()
|
|
987
|
+
dbResponder(q => {
|
|
988
|
+
if (q.table === 'apps' && q.calls[0]?.op === 'select') {
|
|
989
|
+
return { data: { config: { other_key: 'keep', feature_flags: { x: 1 } } }, error: null }
|
|
990
|
+
}
|
|
991
|
+
if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
|
|
992
|
+
return { data: null, error: null }
|
|
993
|
+
})
|
|
994
|
+
const appDir = makeApp(join(workdir, 'app'), seededRegManifest({ config: { manifest_key: true } }))
|
|
995
|
+
await seedApp(appDir, 'testapp', join(workdir, 'c.json'))
|
|
996
|
+
const upsertPayload = queriesTo('apps', 'upsert')[0].calls.find(c => c.op === 'upsert')!.args[0]
|
|
997
|
+
expect(upsertPayload.config).toEqual({ other_key: 'keep', feature_flags: { x: 1 }, manifest_key: true })
|
|
998
|
+
const updatePayload = queriesTo('apps', 'update')[0].calls.find(c => c.op === 'update')!.args[0]
|
|
999
|
+
expect(updatePayload.config.other_key).toBe('keep')
|
|
1000
|
+
expect(updatePayload.config.registration.default_role).toBe('member')
|
|
1001
|
+
})
|
|
1002
|
+
|
|
1003
|
+
it('does not write server registration when the manifest has no registration block', async () => {
|
|
1004
|
+
const { seedApp } = await freshModule()
|
|
1005
|
+
setSeedEnv()
|
|
1006
|
+
const appDir = makeApp(join(workdir, 'app'), baseManifest())
|
|
1007
|
+
await seedApp(appDir, 'testapp', join(workdir, 'c.json'))
|
|
1008
|
+
expect(queriesTo('apps', 'update').length).toBe(0)
|
|
1009
|
+
expect(existsSync(join(workdir, 'c.json'))).toBe(false)
|
|
1010
|
+
})
|
|
1011
|
+
|
|
1012
|
+
it('preserves a preexisting default_app and unrelated config entries on reseed', async () => {
|
|
1013
|
+
const { seedApp } = await freshModule()
|
|
1014
|
+
setSeedEnv()
|
|
1015
|
+
const configPath = join(workdir, 'spine.config.json')
|
|
1016
|
+
writeSpineConfig({
|
|
1017
|
+
registration: {
|
|
1018
|
+
default_app: 'other-app',
|
|
1019
|
+
default_role: 'admin',
|
|
1020
|
+
allow_url_override: false,
|
|
1021
|
+
apps: { 'other-app': { roles: ['admin'], default_role: 'admin', path: '/other', enabled: true, account_strategy: 'new' } },
|
|
1022
|
+
},
|
|
1023
|
+
unrelated_root: 'keep-me',
|
|
1024
|
+
})
|
|
1025
|
+
const appDir = makeApp(join(workdir, 'app'), seededRegManifest())
|
|
1026
|
+
await seedApp(appDir, 'testapp', configPath)
|
|
1027
|
+
const fileCfg = readSpineConfig(configPath)
|
|
1028
|
+
expect(fileCfg.unrelated_root).toBe('keep-me')
|
|
1029
|
+
expect(fileCfg.registration.default_app).toBe('other-app')
|
|
1030
|
+
expect(fileCfg.registration.default_role).toBe('admin')
|
|
1031
|
+
expect(fileCfg.registration.allow_url_override).toBe(false)
|
|
1032
|
+
expect(fileCfg.registration.apps['other-app'].path).toBe('/other')
|
|
1033
|
+
expect(fileCfg.registration.apps.testapp.default_role).toBe('member')
|
|
1034
|
+
})
|
|
1035
|
+
|
|
1036
|
+
it('throws when the server registration policy write fails', async () => {
|
|
1037
|
+
const { seedApp } = await freshModule()
|
|
1038
|
+
setSeedEnv()
|
|
1039
|
+
dbResponder(q => {
|
|
1040
|
+
if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
|
|
1041
|
+
if (q.table === 'apps' && q.calls[0]?.op === 'update') return { data: null, error: { message: 'policy write denied' } }
|
|
1042
|
+
return { data: null, error: null }
|
|
1043
|
+
})
|
|
1044
|
+
const appDir = makeApp(join(workdir, 'app'), seededRegManifest())
|
|
1045
|
+
await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow('policy write denied')
|
|
1046
|
+
})
|
|
1047
|
+
|
|
1048
|
+
it('throws when the local spine.config.json is corrupt before creating a client', async () => {
|
|
1049
|
+
const { seedApp } = await freshModule()
|
|
1050
|
+
setSeedEnv()
|
|
1051
|
+
const configPath = join(workdir, 'spine.config.json')
|
|
1052
|
+
writeSpineConfig('{ not json')
|
|
1053
|
+
const appDir = makeApp(join(workdir, 'app'), seededRegManifest())
|
|
1054
|
+
await expect(seedApp(appDir, 'testapp', configPath)).rejects.toThrow()
|
|
1055
|
+
expect(supaState.createClientCalls.length).toBe(0)
|
|
1056
|
+
})
|
|
1057
|
+
|
|
1058
|
+
it('persists an explicit enabled:false policy to server and file with neutral fallback', async () => {
|
|
1059
|
+
const { seedApp } = await freshModule()
|
|
1060
|
+
setSeedEnv()
|
|
1061
|
+
const configPath = join(workdir, 'spine.config.json')
|
|
1062
|
+
const appDir = makeApp(join(workdir, 'app'), manifestWithRegistration({
|
|
1063
|
+
route_prefix: '/testapp',
|
|
1064
|
+
registration: { enabled: false },
|
|
1065
|
+
required_roles: [],
|
|
1066
|
+
}))
|
|
1067
|
+
await seedApp(appDir, 'testapp', configPath)
|
|
1068
|
+
const updates = queriesTo('apps', 'update')
|
|
1069
|
+
expect(updates.length).toBe(1)
|
|
1070
|
+
const updatePayload = updates[0].calls.find(c => c.op === 'update')!.args[0]
|
|
1071
|
+
expect(updatePayload.config.registration).toEqual({
|
|
1072
|
+
roles: ['member'],
|
|
1073
|
+
default_role: 'member',
|
|
1074
|
+
path: '/testapp',
|
|
1075
|
+
enabled: false,
|
|
1076
|
+
account_strategy: 'new',
|
|
1077
|
+
target_account: undefined,
|
|
1078
|
+
parent_account: undefined,
|
|
1079
|
+
default_workspace_name: undefined,
|
|
1080
|
+
allow_domain_join: undefined,
|
|
1081
|
+
})
|
|
1082
|
+
const fileCfg = readSpineConfig(configPath)
|
|
1083
|
+
expect(fileCfg.registration.apps.testapp).toEqual(updatePayload.config.registration)
|
|
1084
|
+
expect(fileCfg.registration.default_app).toBeUndefined()
|
|
1085
|
+
})
|
|
1086
|
+
|
|
1087
|
+
it('preserves previous server and local policy fields when toggling enabled:false', async () => {
|
|
1088
|
+
const { seedApp } = await freshModule()
|
|
1089
|
+
setSeedEnv()
|
|
1090
|
+
const configPath = join(workdir, 'spine.config.json')
|
|
1091
|
+
const prior = {
|
|
1092
|
+
roles: ['admin', 'member'],
|
|
1093
|
+
default_role: 'admin',
|
|
1094
|
+
path: '/prior',
|
|
1095
|
+
enabled: true,
|
|
1096
|
+
account_strategy: 'existing',
|
|
1097
|
+
target_account: 'acme',
|
|
1098
|
+
allow_domain_join: true,
|
|
1099
|
+
}
|
|
1100
|
+
writeSpineConfig({ registration: { default_app: 'testapp', apps: { testapp: prior } } })
|
|
1101
|
+
dbResponder(q => {
|
|
1102
|
+
if (q.table === 'apps' && q.calls[0]?.op === 'select') {
|
|
1103
|
+
return { data: { config: { registration: prior } }, error: null }
|
|
1104
|
+
}
|
|
1105
|
+
if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
|
|
1106
|
+
return { data: null, error: null }
|
|
1107
|
+
})
|
|
1108
|
+
const appDir = makeApp(join(workdir, 'app'), manifestWithRegistration({
|
|
1109
|
+
registration: { enabled: false },
|
|
1110
|
+
required_roles: [],
|
|
1111
|
+
}))
|
|
1112
|
+
await seedApp(appDir, 'testapp', configPath)
|
|
1113
|
+
const updatePayload = queriesTo('apps', 'update')[0].calls.find(c => c.op === 'update')!.args[0]
|
|
1114
|
+
expect(updatePayload.config.registration).toEqual({ ...prior, enabled: false })
|
|
1115
|
+
const fileCfg = readSpineConfig(configPath)
|
|
1116
|
+
expect(fileCfg.registration.apps.testapp).toEqual({ ...prior, enabled: false })
|
|
1117
|
+
expect(fileCfg.registration.default_app).toBe('testapp')
|
|
1118
|
+
})
|
|
1119
|
+
|
|
1120
|
+
it('writes one canonical disabled entry to both sides when prior server/local policies diverge', async () => {
|
|
1121
|
+
const { seedApp } = await freshModule()
|
|
1122
|
+
setSeedEnv()
|
|
1123
|
+
const configPath = join(workdir, 'spine.config.json')
|
|
1124
|
+
const localPrior = {
|
|
1125
|
+
roles: ['local-role'],
|
|
1126
|
+
default_role: 'local-role',
|
|
1127
|
+
path: '/local-path',
|
|
1128
|
+
enabled: true,
|
|
1129
|
+
account_strategy: 'existing',
|
|
1130
|
+
target_account: 'local-acct',
|
|
1131
|
+
}
|
|
1132
|
+
const serverPrior = {
|
|
1133
|
+
roles: ['server-role'],
|
|
1134
|
+
default_role: 'server-role',
|
|
1135
|
+
path: '/server-path',
|
|
1136
|
+
enabled: true,
|
|
1137
|
+
account_strategy: 'new',
|
|
1138
|
+
}
|
|
1139
|
+
writeSpineConfig({ registration: { apps: { testapp: localPrior } } })
|
|
1140
|
+
dbResponder(q => {
|
|
1141
|
+
if (q.table === 'apps' && q.calls[0]?.op === 'select') {
|
|
1142
|
+
return { data: { config: { registration: serverPrior } }, error: null }
|
|
1143
|
+
}
|
|
1144
|
+
if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
|
|
1145
|
+
return { data: null, error: null }
|
|
1146
|
+
})
|
|
1147
|
+
const appDir = makeApp(join(workdir, 'app'), manifestWithRegistration({
|
|
1148
|
+
registration: { enabled: false },
|
|
1149
|
+
required_roles: [],
|
|
1150
|
+
}))
|
|
1151
|
+
await seedApp(appDir, 'testapp', configPath)
|
|
1152
|
+
const expected = {
|
|
1153
|
+
roles: ['server-role'],
|
|
1154
|
+
default_role: 'server-role',
|
|
1155
|
+
path: '/server-path',
|
|
1156
|
+
enabled: false,
|
|
1157
|
+
account_strategy: 'new',
|
|
1158
|
+
target_account: 'local-acct',
|
|
1159
|
+
parent_account: undefined,
|
|
1160
|
+
default_workspace_name: undefined,
|
|
1161
|
+
allow_domain_join: undefined,
|
|
1162
|
+
}
|
|
1163
|
+
const updatePayload = queriesTo('apps', 'update')[0].calls.find(c => c.op === 'update')!.args[0]
|
|
1164
|
+
expect(updatePayload.config.registration).toEqual(expected)
|
|
1165
|
+
const fileCfg = readSpineConfig(configPath)
|
|
1166
|
+
expect(fileCfg.registration.apps.testapp).toEqual(expected)
|
|
1167
|
+
expect(fileCfg.registration.default_app).toBeUndefined()
|
|
1168
|
+
})
|
|
1169
|
+
|
|
1170
|
+
it('copies allow_domain_join when declared without forcing it on other apps', async () => {
|
|
1171
|
+
const { seedApp } = await freshModule()
|
|
1172
|
+
setSeedEnv()
|
|
1173
|
+
const configPath = join(workdir, 'spine.config.json')
|
|
1174
|
+
writeSpineConfig({
|
|
1175
|
+
registration: {
|
|
1176
|
+
default_app: 'other-app',
|
|
1177
|
+
apps: { 'other-app': { roles: ['admin'], default_role: 'admin', path: '/o', enabled: true, account_strategy: 'new' } },
|
|
1178
|
+
},
|
|
1179
|
+
})
|
|
1180
|
+
const manifest = seededRegManifest()
|
|
1181
|
+
manifest.registration.allow_domain_join = true
|
|
1182
|
+
const appDir = makeApp(join(workdir, 'app'), manifest)
|
|
1183
|
+
await seedApp(appDir, 'testapp', configPath)
|
|
1184
|
+
const fileCfg = readSpineConfig(configPath)
|
|
1185
|
+
expect(fileCfg.registration.apps.testapp.allow_domain_join).toBe(true)
|
|
1186
|
+
expect(fileCfg.registration.apps['other-app'].allow_domain_join).toBeUndefined()
|
|
1187
|
+
})
|
|
1188
|
+
})
|
|
1189
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:registration_suite:block"]
|
|
1190
|
+
|
|
1191
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:update_app_suite:block" type="block" identity="update_app_suite" parent="none"]
|
|
1192
|
+
// CIL[DOC-MACRO purpose="Register update-db-app policy parity and preflight failure tests."]
|
|
1193
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Register update-db-app policy parity and preflight failure tests."]
|
|
1194
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
|
|
1195
|
+
// CIL[DOC-CONTRACT inputs="Commander and fixture helpers" outputs="Vitest assertions" errors="none" side_effects="Registers isolated CLI tests"]
|
|
1196
|
+
// CIL[DOC-MICRO logic_summary="Register update-db-app policy parity and preflight failure tests." notable_decisions=""]
|
|
1197
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
1198
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
1199
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
1200
|
+
describe('update-db-app', () => {
|
|
1201
|
+
it('produces the same server registration as a fresh seed', async () => {
|
|
1202
|
+
setSeedEnv()
|
|
1203
|
+
const manifest = baseManifest({
|
|
1204
|
+
registration: { enabled: true, default_role: 'member', redirect_path: '/testapp', account_strategy: 'new' },
|
|
1205
|
+
required_roles: ['member'],
|
|
1206
|
+
})
|
|
1207
|
+
makeApp(join(workdir, 'custom/apps/testapp'), manifest)
|
|
1208
|
+
const program = await makeProgram()
|
|
1209
|
+
await program.parseAsync(['node', 'cli', 'update-db-app', 'testapp'])
|
|
1210
|
+
const updatePayload = queriesTo('apps', 'update')[0].calls.find(c => c.op === 'update')!.args[0]
|
|
1211
|
+
expect(updatePayload.config.registration).toEqual({
|
|
1212
|
+
roles: ['member'],
|
|
1213
|
+
default_role: 'member',
|
|
1214
|
+
path: '/testapp',
|
|
1215
|
+
enabled: true,
|
|
1216
|
+
account_strategy: 'new',
|
|
1217
|
+
target_account: undefined,
|
|
1218
|
+
parent_account: undefined,
|
|
1219
|
+
default_workspace_name: undefined,
|
|
1220
|
+
allow_domain_join: undefined,
|
|
1221
|
+
})
|
|
1222
|
+
const fileCfg = readSpineConfig()
|
|
1223
|
+
expect(fileCfg.registration.apps.testapp.roles).toEqual(['member'])
|
|
1224
|
+
})
|
|
1225
|
+
|
|
1226
|
+
it('exits nonzero when the registration policy write fails', async () => {
|
|
1227
|
+
setSeedEnv()
|
|
1228
|
+
dbResponder(q => {
|
|
1229
|
+
if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
|
|
1230
|
+
if (q.table === 'apps' && q.calls[0]?.op === 'update') return { data: null, error: { message: 'denied' } }
|
|
1231
|
+
return { data: null, error: null }
|
|
1232
|
+
})
|
|
1233
|
+
const manifest = baseManifest({
|
|
1234
|
+
registration: { enabled: true, default_role: 'member', redirect_path: '/testapp' },
|
|
1235
|
+
})
|
|
1236
|
+
makeApp(join(workdir, 'custom/apps/testapp'), manifest)
|
|
1237
|
+
const program = await makeProgram()
|
|
1238
|
+
await expect(program.parseAsync(['node', 'cli', 'update-db-app', 'testapp'])).rejects.toThrow('process.exit(1)')
|
|
1239
|
+
expect(exitSpy).toHaveBeenCalledWith(1)
|
|
1240
|
+
})
|
|
1241
|
+
|
|
1242
|
+
it('exits before creating a client when manifest slug does not match the command slug', async () => {
|
|
1243
|
+
setSeedEnv()
|
|
1244
|
+
makeApp(join(workdir, 'custom/apps/testapp'), baseManifest({ slug: 'other-app' }))
|
|
1245
|
+
const program = await makeProgram()
|
|
1246
|
+
await expect(program.parseAsync(['node', 'cli', 'update-db-app', 'testapp'])).rejects.toThrow('process.exit(1)')
|
|
1247
|
+
expect(supaState.createClientCalls.length).toBe(0)
|
|
1248
|
+
})
|
|
1249
|
+
|
|
1250
|
+
it('exits before creating a client when the local config is corrupt and registration is declared', async () => {
|
|
1251
|
+
setSeedEnv()
|
|
1252
|
+
writeSpineConfig('{ not json')
|
|
1253
|
+
makeApp(join(workdir, 'custom/apps/testapp'), manifestWithRegistration())
|
|
1254
|
+
const program = await makeProgram()
|
|
1255
|
+
await expect(program.parseAsync(['node', 'cli', 'update-db-app', 'testapp'])).rejects.toThrow('process.exit(1)')
|
|
1256
|
+
expect(supaState.createClientCalls.length).toBe(0)
|
|
1257
|
+
})
|
|
1258
|
+
|
|
1259
|
+
it('syncs an explicit enabled:false policy like seedApp does', async () => {
|
|
1260
|
+
setSeedEnv()
|
|
1261
|
+
makeApp(join(workdir, 'custom/apps/testapp'), manifestWithRegistration({
|
|
1262
|
+
route_prefix: '/testapp',
|
|
1263
|
+
registration: { enabled: false },
|
|
1264
|
+
required_roles: [],
|
|
1265
|
+
}))
|
|
1266
|
+
const program = await makeProgram()
|
|
1267
|
+
await program.parseAsync(['node', 'cli', 'update-db-app', 'testapp'])
|
|
1268
|
+
const updatePayload = queriesTo('apps', 'update')[0].calls.find(c => c.op === 'update')!.args[0]
|
|
1269
|
+
expect(updatePayload.config.registration.enabled).toBe(false)
|
|
1270
|
+
expect(updatePayload.config.registration.default_role).toBe('member')
|
|
1271
|
+
expect(updatePayload.config.registration.path).toBe('/testapp')
|
|
1272
|
+
const fileCfg = readSpineConfig()
|
|
1273
|
+
expect(fileCfg.registration.apps.testapp).toEqual(updatePayload.config.registration)
|
|
1274
|
+
})
|
|
1275
|
+
})
|
|
1276
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:update_app_suite:block"]
|
|
1277
|
+
|
|
1278
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:install_cli_suite:block" type="block" identity="install_cli_suite" parent="none"]
|
|
1279
|
+
// CIL[DOC-MACRO purpose="Register default seeding, no-seed, migration failure, and file-only installer tests."]
|
|
1280
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Register default seeding, no-seed, migration failure, and file-only installer tests."]
|
|
1281
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/cli/commands/migrate.ts,.framework/cli/commands/validate-app.ts"]
|
|
1282
|
+
// CIL[DOC-CONTRACT inputs="Mock npm, Postgres, Commander, manifest: package fixture metadata, files: package fixture contents" outputs="Vitest assertions" errors="none" side_effects="Registers isolated CLI tests"]
|
|
1283
|
+
// CIL[DOC-MICRO logic_summary="Register default seeding, no-seed, migration failure, and file-only installer tests." notable_decisions=""]
|
|
1284
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
1285
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
1286
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
1287
|
+
describe('install-app CLI', () => {
|
|
1288
|
+
function makePkg(manifest: Record<string, any>, files: Record<string, string> = {}) {
|
|
1289
|
+
const pkgDir = join(workdir, 'node_modules/spine-framework-testapp')
|
|
1290
|
+
mkdirSync(join(pkgDir, 'seed'), { recursive: true })
|
|
1291
|
+
writeJson(join(pkgDir, 'manifest.json'), manifest)
|
|
1292
|
+
writeJson(join(pkgDir, 'package.json'), { name: 'spine-framework-testapp', version: manifest.version })
|
|
1293
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
1294
|
+
const p = join(pkgDir, rel)
|
|
1295
|
+
mkdirSync(join(p, '..'), { recursive: true })
|
|
1296
|
+
writeFileSync(p, content)
|
|
1297
|
+
}
|
|
1298
|
+
return pkgDir
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
const regManifest = () => baseManifest({
|
|
1302
|
+
seed: [{ file: 'seed/roles.json', table: 'roles', conflict: 'slug' }],
|
|
1303
|
+
registration: { enabled: true, default_role: 'member', redirect_path: '/testapp' },
|
|
1304
|
+
})
|
|
1305
|
+
|
|
1306
|
+
it('seeds by default after successful migrations', async () => {
|
|
1307
|
+
setMigrateEnv()
|
|
1308
|
+
makePkg(regManifest(), { 'seed/roles.json': JSON.stringify([{ slug: 'member', name: 'Member' }]) })
|
|
1309
|
+
const program = await makeProgram()
|
|
1310
|
+
await program.parseAsync(['node', 'cli', 'install-app', 'spine-framework-testapp'])
|
|
1311
|
+
expect(execState.calls.some(c => c.startsWith('npm install'))).toBe(true)
|
|
1312
|
+
expect(supaState.createClientCalls.length).toBe(1)
|
|
1313
|
+
expect(queriesTo('roles', 'upsert').length).toBe(1)
|
|
1314
|
+
const fileCfg = readSpineConfig()
|
|
1315
|
+
expect(fileCfg.registration.apps.testapp.enabled).toBe(true)
|
|
1316
|
+
})
|
|
1317
|
+
|
|
1318
|
+
it('--no-seed skips all DB seed work but still updates local registration config', async () => {
|
|
1319
|
+
setMigrateEnv()
|
|
1320
|
+
makePkg(regManifest(), { 'seed/roles.json': '[]' })
|
|
1321
|
+
const program = await makeProgram()
|
|
1322
|
+
await program.parseAsync(['node', 'cli', 'install-app', 'spine-framework-testapp', '--no-seed'])
|
|
1323
|
+
expect(supaState.createClientCalls.length).toBe(0)
|
|
1324
|
+
expect(queriesTo('roles').length).toBe(0)
|
|
1325
|
+
const fileCfg = readSpineConfig()
|
|
1326
|
+
expect(fileCfg.registration.apps.testapp.enabled).toBe(true)
|
|
1327
|
+
})
|
|
1328
|
+
|
|
1329
|
+
it('aborts before seeding when a migration fails', async () => {
|
|
1330
|
+
setMigrateEnv()
|
|
1331
|
+
mkdirSync(join(workdir, 'custom/migrations'), { recursive: true })
|
|
1332
|
+
writeFileSync(join(workdir, 'custom/migrations/001_mig.sql'), 'BOOM;')
|
|
1333
|
+
pgState.handler = (sql: string) => {
|
|
1334
|
+
if (sql === 'BOOM;') throw new Error('migration exploded')
|
|
1335
|
+
return { rows: [] }
|
|
1336
|
+
}
|
|
1337
|
+
makePkg(regManifest(), { 'seed/roles.json': '[]' })
|
|
1338
|
+
const program = await makeProgram()
|
|
1339
|
+
await expect(program.parseAsync(['node', 'cli', 'install-app', 'spine-framework-testapp'])).rejects.toThrow('process.exit(1)')
|
|
1340
|
+
expect(supaState.createClientCalls.length).toBe(0)
|
|
1341
|
+
expect(queriesTo('roles').length).toBe(0)
|
|
1342
|
+
expect(existsSync(join(workdir, 'spine.config.json'))).toBe(false)
|
|
1343
|
+
})
|
|
1344
|
+
|
|
1345
|
+
it('aborts with a clear error when DB credentials are absent and seeding is requested', async () => {
|
|
1346
|
+
setSeedEnv()
|
|
1347
|
+
delete process.env.SUPABASE_DB_PASSWORD
|
|
1348
|
+
makePkg(regManifest(), { 'seed/roles.json': '[]' })
|
|
1349
|
+
const program = await makeProgram()
|
|
1350
|
+
await expect(program.parseAsync(['node', 'cli', 'install-app', 'spine-framework-testapp'])).rejects.toThrow('process.exit(1)')
|
|
1351
|
+
expect(supaState.createClientCalls.length).toBe(0)
|
|
1352
|
+
})
|
|
1353
|
+
|
|
1354
|
+
it('installs files only with --no-seed when DB credentials are absent', async () => {
|
|
1355
|
+
setSeedEnv()
|
|
1356
|
+
delete process.env.SUPABASE_DB_PASSWORD
|
|
1357
|
+
makePkg(regManifest(), { 'seed/roles.json': '[]' })
|
|
1358
|
+
const program = await makeProgram()
|
|
1359
|
+
await program.parseAsync(['node', 'cli', 'install-app', 'spine-framework-testapp', '--no-seed'])
|
|
1360
|
+
expect(pgState.instances.length).toBe(0)
|
|
1361
|
+
expect(supaState.createClientCalls.length).toBe(0)
|
|
1362
|
+
expect(existsSync(join(workdir, 'custom/apps/testapp/manifest.json'))).toBe(true)
|
|
1363
|
+
expect(readSpineConfig().registration.apps.testapp.enabled).toBe(true)
|
|
1364
|
+
})
|
|
1365
|
+
})
|
|
1366
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:install_cli_suite:block"]
|
|
1367
|
+
|
|
1368
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:seed_ordering_suite:block" type="block" identity="seed_ordering_suite" parent="none"]
|
|
1369
|
+
// CIL[DOC-MACRO purpose="Register deterministic seed-ordering tests independent of manifest declaration order."]
|
|
1370
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Register deterministic seed-ordering tests independent of manifest declaration order."]
|
|
1371
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts"]
|
|
1372
|
+
// CIL[DOC-CONTRACT inputs="Fixture helpers and seedApp" outputs="Vitest assertions" errors="none" side_effects="Registers isolated fake-database tests"]
|
|
1373
|
+
// CIL[DOC-MICRO logic_summary="Register deterministic seed-ordering tests independent of manifest declaration order." notable_decisions=""]
|
|
1374
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
1375
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
1376
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
1377
|
+
describe('seedApp deterministic ordering', () => {
|
|
1378
|
+
it('seeds in dependency order regardless of manifest declaration order', async () => {
|
|
1379
|
+
const { seedApp } = await freshModule()
|
|
1380
|
+
setSeedEnv()
|
|
1381
|
+
dbResponder(q => {
|
|
1382
|
+
if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
|
|
1383
|
+
if (q.table === 'actions' && q.calls[0]?.op === 'select') return { data: [{ slug: 'update_item' }], error: null }
|
|
1384
|
+
if (q.table === 'pipelines' && q.calls[0]?.op === 'select') return { data: { id: 'pipe-1' }, error: null }
|
|
1385
|
+
return { data: null, error: null }
|
|
1386
|
+
})
|
|
1387
|
+
const appDir = makeApp(join(workdir, 'app'), baseManifest({
|
|
1388
|
+
seed: [
|
|
1389
|
+
{ file: 'seed/triggers.json', table: 'triggers', conflict: 'app_id,slug' },
|
|
1390
|
+
{ file: 'seed/pipelines.json', table: 'pipelines', conflict: 'app_id,slug' },
|
|
1391
|
+
{ file: 'seed/roles.json', table: 'roles', conflict: 'slug' },
|
|
1392
|
+
],
|
|
1393
|
+
}), {
|
|
1394
|
+
'seed/triggers.json': JSON.stringify([{ slug: 'trg', pipeline_slug: 'pipe' }]),
|
|
1395
|
+
'seed/pipelines.json': JSON.stringify([{ slug: 'pipe', stages: [{ stage_type: 'update_item', config: {} }] }]),
|
|
1396
|
+
'seed/roles.json': JSON.stringify([{ slug: 'member', name: 'Member' }]),
|
|
1397
|
+
})
|
|
1398
|
+
await seedApp(appDir, 'testapp', join(workdir, 'c.json'))
|
|
1399
|
+
const upsertOrder = supaState.history
|
|
1400
|
+
.filter(q => q.calls.some(c => c.op === 'upsert'))
|
|
1401
|
+
.map(q => q.table)
|
|
1402
|
+
expect(upsertOrder).toEqual(['apps', 'roles', 'pipelines', 'triggers'])
|
|
1403
|
+
})
|
|
1404
|
+
|
|
1405
|
+
it('runs permissions_patch entries after types even when declared first', async () => {
|
|
1406
|
+
const { seedApp } = await freshModule()
|
|
1407
|
+
setSeedEnv()
|
|
1408
|
+
dbResponder(q => {
|
|
1409
|
+
if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
|
|
1410
|
+
if (q.table === 'types' && q.calls[0]?.op === 'select') {
|
|
1411
|
+
const slugEq = q.calls.find(c => c.op === 'eq' && c.args[0] === 'slug')
|
|
1412
|
+
if (slugEq?.args[1] === 'task-patch') {
|
|
1413
|
+
return { data: { id: 't-9', design_schema: { record_permissions: { admin: ['read'] } } }, error: null }
|
|
1414
|
+
}
|
|
1415
|
+
return { data: null, error: null }
|
|
1416
|
+
}
|
|
1417
|
+
return { data: null, error: null }
|
|
1418
|
+
})
|
|
1419
|
+
const appDir = makeApp(join(workdir, 'app'), baseManifest({
|
|
1420
|
+
seed: [
|
|
1421
|
+
{ file: 'seed/type_permissions.json', table: 'types', conflict: 'kind,slug', inject_app_id: false, permissions_patch: true },
|
|
1422
|
+
{ file: 'seed/types.json', table: 'types', conflict: 'kind,slug', inject_app_id: false },
|
|
1423
|
+
],
|
|
1424
|
+
}), {
|
|
1425
|
+
'seed/type_permissions.json': JSON.stringify([{ kind: 'item', slug: 'task-patch', record_permissions: { admin: ['read', 'update'] } }]),
|
|
1426
|
+
'seed/types.json': JSON.stringify([{ kind: 'item', slug: 'task-seed', design_schema: { fields: { title: { data_type: 'text', required: true } } } }]),
|
|
1427
|
+
})
|
|
1428
|
+
await seedApp(appDir, 'testapp', join(workdir, 'c.json'))
|
|
1429
|
+
const typeUpsertIdx = supaState.history.findIndex(q => q.table === 'types' && q.calls.some(c => c.op === 'upsert'))
|
|
1430
|
+
const patchUpdateIdx = supaState.history.findIndex(q => q.table === 'types' && q.calls.some(c => c.op === 'update'))
|
|
1431
|
+
expect(typeUpsertIdx).toBeGreaterThanOrEqual(0)
|
|
1432
|
+
expect(patchUpdateIdx).toBeGreaterThan(typeUpsertIdx)
|
|
1433
|
+
})
|
|
1434
|
+
})
|
|
1435
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:seed_ordering_suite:block"]
|
|
1436
|
+
|
|
1437
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:type_conflict_suite:block" type="block" identity="type_conflict_suite" parent="none"]
|
|
1438
|
+
// CIL[DOC-MACRO purpose="Register strict type-conflict detection tests for the types seed path."]
|
|
1439
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Register strict type-conflict detection tests for the types seed path."]
|
|
1440
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts"]
|
|
1441
|
+
// CIL[DOC-CONTRACT inputs="Mock responders and seedApp" outputs="Vitest assertions" errors="none" side_effects="Registers isolated fake-database tests"]
|
|
1442
|
+
// CIL[DOC-MICRO logic_summary="Register strict type-conflict detection tests for the types seed path." notable_decisions=""]
|
|
1443
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
1444
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
1445
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
1446
|
+
describe('seedApp type conflict detection', () => {
|
|
1447
|
+
const typesEntry = { file: 'seed/types.json', table: 'types', conflict: 'kind,slug', inject_app_id: false }
|
|
1448
|
+
|
|
1449
|
+
it('skips an identical existing type row without upserting, ignoring key order', async () => {
|
|
1450
|
+
const { seedApp } = await freshModule()
|
|
1451
|
+
setSeedEnv()
|
|
1452
|
+
dbResponder(q => {
|
|
1453
|
+
if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
|
|
1454
|
+
if (q.table === 'types' && q.calls[0]?.op === 'select') {
|
|
1455
|
+
// Same schema, different key order — jsonb key order is not significant.
|
|
1456
|
+
return { data: { id: 't-1', design_schema: { record_permissions: { admin: ['read'] }, fields: { title: { required: true, data_type: 'text' } } } }, error: null }
|
|
1457
|
+
}
|
|
1458
|
+
return { data: null, error: null }
|
|
1459
|
+
})
|
|
1460
|
+
const design = { fields: { title: { data_type: 'text', required: true } }, record_permissions: { admin: ['read'] } }
|
|
1461
|
+
const appDir = makeApp(join(workdir, 'app'), baseManifest({ seed: [typesEntry] }), {
|
|
1462
|
+
'seed/types.json': JSON.stringify([{ kind: 'item', slug: 'task', design_schema: design }]),
|
|
1463
|
+
})
|
|
1464
|
+
await seedApp(appDir, 'testapp', join(workdir, 'c.json'))
|
|
1465
|
+
expect(queriesTo('types', 'upsert').length).toBe(0)
|
|
1466
|
+
expect(console.log).toHaveBeenCalledWith(expect.stringContaining('item/task already seeded — identical'))
|
|
1467
|
+
})
|
|
1468
|
+
|
|
1469
|
+
it('throws naming kind/slug and --reconcile-owned-types when the DB row differs', async () => {
|
|
1470
|
+
const { seedApp } = await freshModule()
|
|
1471
|
+
setSeedEnv()
|
|
1472
|
+
dbResponder(q => {
|
|
1473
|
+
if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
|
|
1474
|
+
if (q.table === 'types' && q.calls[0]?.op === 'select') {
|
|
1475
|
+
return { data: { id: 't-1', design_schema: { fields: { title: { data_type: 'textarea' } } } }, error: null }
|
|
1476
|
+
}
|
|
1477
|
+
return { data: null, error: null }
|
|
1478
|
+
})
|
|
1479
|
+
const appDir = makeApp(join(workdir, 'app'), baseManifest({ seed: [typesEntry] }), {
|
|
1480
|
+
'seed/types.json': JSON.stringify([{ kind: 'item', slug: 'task', design_schema: { fields: { title: { data_type: 'text' } } } }]),
|
|
1481
|
+
})
|
|
1482
|
+
await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow(/item\/task/)
|
|
1483
|
+
await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow(/--reconcile-owned-types/)
|
|
1484
|
+
expect(queriesTo('types', 'upsert').length).toBe(0)
|
|
1485
|
+
expect(queriesTo('types', 'update').length).toBe(0)
|
|
1486
|
+
})
|
|
1487
|
+
|
|
1488
|
+
it('still inserts absent types with the insert-only conflict contract', async () => {
|
|
1489
|
+
const { seedApp } = await freshModule()
|
|
1490
|
+
setSeedEnv()
|
|
1491
|
+
dbResponder(q => {
|
|
1492
|
+
if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
|
|
1493
|
+
return { data: null, error: null }
|
|
1494
|
+
})
|
|
1495
|
+
const appDir = makeApp(join(workdir, 'app'), baseManifest({ seed: [typesEntry] }), {
|
|
1496
|
+
'seed/types.json': JSON.stringify([{ kind: 'item', slug: 'task', design_schema: { fields: {} } }]),
|
|
1497
|
+
})
|
|
1498
|
+
await seedApp(appDir, 'testapp', join(workdir, 'c.json'))
|
|
1499
|
+
const upserts = queriesTo('types', 'upsert')
|
|
1500
|
+
expect(upserts.length).toBe(1)
|
|
1501
|
+
const [, opts] = upserts[0].calls.find(c => c.op === 'upsert')!.args
|
|
1502
|
+
expect(opts).toEqual({ onConflict: 'kind,slug', ignoreDuplicates: true })
|
|
1503
|
+
})
|
|
1504
|
+
})
|
|
1505
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:type_conflict_suite:block"]
|
|
1506
|
+
|
|
1507
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:reconcile_owned_types_suite:block" type="block" identity="reconcile_owned_types_suite" parent="none"]
|
|
1508
|
+
// CIL[DOC-MACRO purpose="Register --reconcile-owned-types declaration, diff-prompt, merge, and non-owned tests."]
|
|
1509
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Register --reconcile-owned-types declaration, diff-prompt, merge, and non-owned tests."]
|
|
1510
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts"]
|
|
1511
|
+
// CIL[DOC-CONTRACT inputs="Mock responders, rlState answers, seedApp" outputs="Vitest assertions" errors="none" side_effects="Registers isolated fake-database tests"]
|
|
1512
|
+
// CIL[DOC-MICRO logic_summary="Register --reconcile-owned-types declaration, diff-prompt, merge, and non-owned tests." notable_decisions=""]
|
|
1513
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
1514
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
1515
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
1516
|
+
describe('seedApp --reconcile-owned-types', () => {
|
|
1517
|
+
const typesEntry = { file: 'seed/types.json', table: 'types', conflict: 'kind,slug', inject_app_id: false }
|
|
1518
|
+
|
|
1519
|
+
function typesApp(records: any[], overrides: Record<string, any> = {}) {
|
|
1520
|
+
return makeApp(join(workdir, 'app'), baseManifest({
|
|
1521
|
+
seed: [typesEntry],
|
|
1522
|
+
...overrides,
|
|
1523
|
+
}), { 'seed/types.json': JSON.stringify(records) })
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
function typeSelectResponder(bySlug: Record<string, any>) {
|
|
1527
|
+
dbResponder(q => {
|
|
1528
|
+
if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
|
|
1529
|
+
if (q.table === 'types' && q.calls[0]?.op === 'select') {
|
|
1530
|
+
const slugEq = q.calls.find(c => c.op === 'eq' && c.args[0] === 'slug')
|
|
1531
|
+
const found = bySlug[slugEq?.args[1]]
|
|
1532
|
+
return { data: found ? { id: `t-${found}`, design_schema: found } : null, error: null }
|
|
1533
|
+
}
|
|
1534
|
+
return { data: null, error: null }
|
|
1535
|
+
})
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
it('fails before creating a client when the manifest does not declare owned_types', async () => {
|
|
1539
|
+
const { seedApp } = await freshModule()
|
|
1540
|
+
setSeedEnv()
|
|
1541
|
+
const appDir = typesApp([{ kind: 'item', slug: 'task', design_schema: { fields: {} } }])
|
|
1542
|
+
await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'), { reconcileOwnedTypes: true })).rejects.toThrow(/owned_types/)
|
|
1543
|
+
expect(supaState.createClientCalls.length).toBe(0)
|
|
1544
|
+
})
|
|
1545
|
+
|
|
1546
|
+
it('fails before creating a client when an owned slug is absent from the types seed', async () => {
|
|
1547
|
+
const { seedApp } = await freshModule()
|
|
1548
|
+
setSeedEnv()
|
|
1549
|
+
const appDir = typesApp(
|
|
1550
|
+
[{ kind: 'item', slug: 'task', design_schema: { fields: {} } }],
|
|
1551
|
+
{ owned_types: ['ghost'] },
|
|
1552
|
+
)
|
|
1553
|
+
await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'), { reconcileOwnedTypes: true })).rejects.toThrow(/ghost/)
|
|
1554
|
+
expect(supaState.createClientCalls.length).toBe(0)
|
|
1555
|
+
})
|
|
1556
|
+
|
|
1557
|
+
it('still throws for a differing type the manifest does not own', async () => {
|
|
1558
|
+
const { seedApp } = await freshModule()
|
|
1559
|
+
setSeedEnv()
|
|
1560
|
+
const design = { fields: { title: { data_type: 'text', required: true } } }
|
|
1561
|
+
typeSelectResponder({ task: design, other: { fields: { title: { data_type: 'textarea' } } } })
|
|
1562
|
+
const appDir = typesApp(
|
|
1563
|
+
[
|
|
1564
|
+
{ kind: 'item', slug: 'task', design_schema: design },
|
|
1565
|
+
{ kind: 'item', slug: 'other', design_schema: { fields: { title: { data_type: 'text' } } } },
|
|
1566
|
+
],
|
|
1567
|
+
{ owned_types: ['task'] },
|
|
1568
|
+
)
|
|
1569
|
+
await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'), { reconcileOwnedTypes: true })).rejects.toThrow(/item\/other/)
|
|
1570
|
+
expect(queriesTo('types', 'update').length).toBe(0)
|
|
1571
|
+
})
|
|
1572
|
+
|
|
1573
|
+
it('updates the owned type on Update, preserving DB-only fields and regenerating validation_schema', async () => {
|
|
1574
|
+
const { seedApp } = await freshModule()
|
|
1575
|
+
setSeedEnv()
|
|
1576
|
+
rlState.answer = 'u'
|
|
1577
|
+
const dbSchema = {
|
|
1578
|
+
fields: {
|
|
1579
|
+
title: { data_type: 'text', required: true },
|
|
1580
|
+
legacy: { data_type: 'number', required: false },
|
|
1581
|
+
},
|
|
1582
|
+
record_permissions: { admin: ['read'] },
|
|
1583
|
+
}
|
|
1584
|
+
const seedSchema = {
|
|
1585
|
+
fields: {
|
|
1586
|
+
title: { data_type: 'textarea', required: false },
|
|
1587
|
+
extra: { data_type: 'text', required: true },
|
|
1588
|
+
},
|
|
1589
|
+
record_permissions: { admin: ['read', 'update'] },
|
|
1590
|
+
}
|
|
1591
|
+
typeSelectResponder({ task: dbSchema })
|
|
1592
|
+
const appDir = typesApp(
|
|
1593
|
+
[{ kind: 'item', slug: 'task', design_schema: seedSchema }],
|
|
1594
|
+
{ owned_types: ['task'] },
|
|
1595
|
+
)
|
|
1596
|
+
await seedApp(appDir, 'testapp', join(workdir, 'c.json'), { reconcileOwnedTypes: true })
|
|
1597
|
+
expect(queriesTo('types', 'upsert').length).toBe(0)
|
|
1598
|
+
const updates = queriesTo('types', 'update')
|
|
1599
|
+
expect(updates.length).toBe(1)
|
|
1600
|
+
const updatePayload = updates[0].calls.find(c => c.op === 'update')!.args[0]
|
|
1601
|
+
expect(updatePayload.design_schema.fields.title).toEqual({ data_type: 'textarea', required: false })
|
|
1602
|
+
expect(updatePayload.design_schema.fields.legacy).toEqual({ data_type: 'number', required: false })
|
|
1603
|
+
expect(updatePayload.design_schema.fields.extra).toEqual({ data_type: 'text', required: true })
|
|
1604
|
+
expect(updatePayload.design_schema.record_permissions).toEqual({ admin: ['read', 'update'] })
|
|
1605
|
+
expect(updatePayload.validation_schema).toEqual({
|
|
1606
|
+
fields: {
|
|
1607
|
+
title: { data_type: 'textarea', required: false },
|
|
1608
|
+
legacy: { data_type: 'number', required: false },
|
|
1609
|
+
extra: { data_type: 'text', required: true },
|
|
1610
|
+
},
|
|
1611
|
+
})
|
|
1612
|
+
})
|
|
1613
|
+
|
|
1614
|
+
it('keeps the DB row untouched on Keep', async () => {
|
|
1615
|
+
const { seedApp } = await freshModule()
|
|
1616
|
+
setSeedEnv()
|
|
1617
|
+
rlState.answer = 'k'
|
|
1618
|
+
typeSelectResponder({ task: { fields: { title: { data_type: 'textarea' } } } })
|
|
1619
|
+
const appDir = typesApp(
|
|
1620
|
+
[{ kind: 'item', slug: 'task', design_schema: { fields: { title: { data_type: 'text' } } } }],
|
|
1621
|
+
{ owned_types: ['task'] },
|
|
1622
|
+
)
|
|
1623
|
+
await seedApp(appDir, 'testapp', join(workdir, 'c.json'), { reconcileOwnedTypes: true })
|
|
1624
|
+
expect(queriesTo('types', 'update').length).toBe(0)
|
|
1625
|
+
expect(queriesTo('types', 'upsert').length).toBe(0)
|
|
1626
|
+
})
|
|
1627
|
+
|
|
1628
|
+
it('propagates the flag through the seed-app command', async () => {
|
|
1629
|
+
setSeedEnv()
|
|
1630
|
+
makeApp(join(workdir, 'custom/apps/testapp'), baseManifest({
|
|
1631
|
+
seed: [{ file: 'seed/types.json', table: 'types', conflict: 'kind,slug', inject_app_id: false }],
|
|
1632
|
+
}), { 'seed/types.json': JSON.stringify([{ kind: 'item', slug: 'task', design_schema: { fields: {} } }]) })
|
|
1633
|
+
const program = await makeProgram()
|
|
1634
|
+
await expect(program.parseAsync(['node', 'cli', 'seed-app', 'testapp', '--reconcile-owned-types'])).rejects.toThrow('process.exit(1)')
|
|
1635
|
+
expect(exitSpy).toHaveBeenCalledWith(1)
|
|
1636
|
+
expect(supaState.createClientCalls.length).toBe(0)
|
|
1637
|
+
})
|
|
1638
|
+
})
|
|
1639
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:reconcile_owned_types_suite:block"]
|
|
1640
|
+
|
|
1641
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/install-app.test.ts:pipeline_stage_validation_suite:block" type="block" identity="pipeline_stage_validation_suite" parent="none"]
|
|
1642
|
+
// CIL[DOC-MACRO purpose="Register install-time pipeline stage_type and send_notification recipient validation tests."]
|
|
1643
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Register install-time pipeline stage_type and send_notification recipient validation tests."]
|
|
1644
|
+
// CIL[DOC-CONTEXT module="tests/unit" layer="test-unit" related_files=".framework/cli/commands/install-app.ts,.framework/functions/_shared/notify.ts"]
|
|
1645
|
+
// CIL[DOC-CONTRACT inputs="Mock responders and seedApp" outputs="Vitest assertions" errors="none" side_effects="Registers isolated fake-database tests"]
|
|
1646
|
+
// CIL[DOC-MICRO logic_summary="Register install-time pipeline stage_type and send_notification recipient validation tests." notable_decisions=""]
|
|
1647
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
1648
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
1649
|
+
// CIL[DOC-AUDIENCE audience="framework contributors"]
|
|
1650
|
+
describe('seedApp pipeline stage validation', () => {
|
|
1651
|
+
function pipelineApp(pipeline: Record<string, any>) {
|
|
1652
|
+
return makeApp(join(workdir, 'app'), baseManifest({
|
|
1653
|
+
seed: [{ file: 'seed/pipelines.json', table: 'pipelines', conflict: 'app_id,slug' }],
|
|
1654
|
+
}), { 'seed/pipelines.json': JSON.stringify([pipeline]) })
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
it('throws naming the pipeline slug, stage index, and bad stage_type', async () => {
|
|
1658
|
+
const { seedApp } = await freshModule()
|
|
1659
|
+
setSeedEnv()
|
|
1660
|
+
dbResponder(q => {
|
|
1661
|
+
if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
|
|
1662
|
+
if (q.table === 'actions' && q.calls[0]?.op === 'select') return { data: [{ slug: 'update_item' }], error: null }
|
|
1663
|
+
return { data: null, error: null }
|
|
1664
|
+
})
|
|
1665
|
+
const appDir = pipelineApp({
|
|
1666
|
+
slug: 'pipe',
|
|
1667
|
+
stages: [
|
|
1668
|
+
{ stage_type: 'update_item', config: {} },
|
|
1669
|
+
{ stage_type: 'bogus_stage', config: {} },
|
|
1670
|
+
],
|
|
1671
|
+
})
|
|
1672
|
+
await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow(/"pipe"[\s\S]*stage 1[\s\S]*bogus_stage/)
|
|
1673
|
+
expect(queriesTo('pipelines', 'upsert').length).toBe(0)
|
|
1674
|
+
expect(queriesTo('actions').length).toBe(1)
|
|
1675
|
+
})
|
|
1676
|
+
|
|
1677
|
+
it('throws when the actions table cannot be read (migrations not run)', async () => {
|
|
1678
|
+
const { seedApp } = await freshModule()
|
|
1679
|
+
setSeedEnv()
|
|
1680
|
+
dbResponder(q => {
|
|
1681
|
+
if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
|
|
1682
|
+
if (q.table === 'actions' && q.calls[0]?.op === 'select') return { data: null, error: { message: 'relation public.actions does not exist' } }
|
|
1683
|
+
return { data: null, error: null }
|
|
1684
|
+
})
|
|
1685
|
+
const appDir = pipelineApp({ slug: 'pipe', stages: [{ stage_type: 'update_item', config: {} }] })
|
|
1686
|
+
await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow(/public\.actions/)
|
|
1687
|
+
expect(queriesTo('pipelines', 'upsert').length).toBe(0)
|
|
1688
|
+
})
|
|
1689
|
+
|
|
1690
|
+
it('rejects a send_notification stage whose recipients is not an array', async () => {
|
|
1691
|
+
const { seedApp } = await freshModule()
|
|
1692
|
+
setSeedEnv()
|
|
1693
|
+
dbResponder(q => {
|
|
1694
|
+
if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
|
|
1695
|
+
if (q.table === 'actions' && q.calls[0]?.op === 'select') return { data: [{ slug: 'send_notification' }], error: null }
|
|
1696
|
+
return { data: null, error: null }
|
|
1697
|
+
})
|
|
1698
|
+
const appDir = pipelineApp({
|
|
1699
|
+
slug: 'notify-pipe',
|
|
1700
|
+
stages: [{ stage_type: 'send_notification', config: { message: 'hi', recipients: 'role:member' } }],
|
|
1701
|
+
})
|
|
1702
|
+
await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow(/recipients must be an array/)
|
|
1703
|
+
})
|
|
1704
|
+
|
|
1705
|
+
it('rejects a recipient that is not role:<slug>, self, or a UUID', async () => {
|
|
1706
|
+
const { seedApp } = await freshModule()
|
|
1707
|
+
setSeedEnv()
|
|
1708
|
+
dbResponder(q => {
|
|
1709
|
+
if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
|
|
1710
|
+
if (q.table === 'actions' && q.calls[0]?.op === 'select') return { data: [{ slug: 'send_notification' }], error: null }
|
|
1711
|
+
return { data: null, error: null }
|
|
1712
|
+
})
|
|
1713
|
+
const appDir = pipelineApp({
|
|
1714
|
+
slug: 'notify-pipe',
|
|
1715
|
+
stages: [{ stage_type: 'send_notification', config: { message: 'hi', recipients: ['bogus-value'] } }],
|
|
1716
|
+
})
|
|
1717
|
+
await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow(/bogus-value/)
|
|
1718
|
+
expect(queriesTo('pipelines', 'upsert').length).toBe(0)
|
|
1719
|
+
})
|
|
1720
|
+
|
|
1721
|
+
it('rejects a role: recipient whose role does not exist', async () => {
|
|
1722
|
+
const { seedApp } = await freshModule()
|
|
1723
|
+
setSeedEnv()
|
|
1724
|
+
dbResponder(q => {
|
|
1725
|
+
if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
|
|
1726
|
+
if (q.table === 'actions' && q.calls[0]?.op === 'select') return { data: [{ slug: 'send_notification' }], error: null }
|
|
1727
|
+
if (q.table === 'roles' && q.calls[0]?.op === 'select') return { data: [{ slug: 'member' }], error: null }
|
|
1728
|
+
return { data: null, error: null }
|
|
1729
|
+
})
|
|
1730
|
+
const appDir = pipelineApp({
|
|
1731
|
+
slug: 'notify-pipe',
|
|
1732
|
+
stages: [{ stage_type: 'send_notification', config: { message: 'hi', recipients: ['role:ghost'] } }],
|
|
1733
|
+
})
|
|
1734
|
+
await expect(seedApp(appDir, 'testapp', join(workdir, 'c.json'))).rejects.toThrow(/role:ghost/)
|
|
1735
|
+
expect(queriesTo('pipelines', 'upsert').length).toBe(0)
|
|
1736
|
+
})
|
|
1737
|
+
|
|
1738
|
+
it('accepts role:, self, and UUID recipients and seeds the pipeline', async () => {
|
|
1739
|
+
const { seedApp } = await freshModule()
|
|
1740
|
+
setSeedEnv()
|
|
1741
|
+
dbResponder(q => {
|
|
1742
|
+
if (q.table === 'apps' && q.calls[0]?.op === 'upsert') return { data: { id: 'app-1' }, error: null }
|
|
1743
|
+
if (q.table === 'actions' && q.calls[0]?.op === 'select') return { data: [{ slug: 'send_notification' }], error: null }
|
|
1744
|
+
if (q.table === 'roles' && q.calls[0]?.op === 'select') return { data: [{ slug: 'member' }], error: null }
|
|
1745
|
+
return { data: null, error: null }
|
|
1746
|
+
})
|
|
1747
|
+
const appDir = pipelineApp({
|
|
1748
|
+
slug: 'notify-pipe',
|
|
1749
|
+
stages: [{
|
|
1750
|
+
stage_type: 'send_notification',
|
|
1751
|
+
config: { message: 'hi', recipients: ['role:member', 'self', '12345678-1234-4234-9234-123456789012'] },
|
|
1752
|
+
}],
|
|
1753
|
+
})
|
|
1754
|
+
await seedApp(appDir, 'testapp', join(workdir, 'c.json'))
|
|
1755
|
+
expect(queriesTo('pipelines', 'upsert').length).toBe(1)
|
|
1756
|
+
expect(queriesTo('roles').length).toBe(1)
|
|
1757
|
+
})
|
|
1758
|
+
})
|
|
1759
|
+
// CIL[END stable_id="~/.framework/tests/unit/install-app.test.ts:pipeline_stage_validation_suite:block"]
|