@malloy-publisher/server 0.0.235 → 0.0.237
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/dist/app/api-doc.yaml +83 -27
- package/dist/app/assets/{EnvironmentPage-BsAnavYN.js → EnvironmentPage-DaP6CpgK.js} +1 -1
- package/dist/app/assets/{HomePage-CADE138j.js → HomePage-C9yNw3iu.js} +1 -1
- package/dist/app/assets/{LightMode-Cfh7KzN8.js → LightMode-Be4-G-dX.js} +1 -1
- package/dist/app/assets/{MainPage-CO3pRlnV.js → MainPage-J_rU2SgI.js} +2 -2
- package/dist/app/assets/{MaterializationsPage-p9YjkRXZ.js → MaterializationsPage-DHgPI4MO.js} +1 -1
- package/dist/app/assets/ModelPage-BzXfjTu5.js +1 -0
- package/dist/app/assets/{PackagePage-e4kN75YR.js → PackagePage-ByoSTLJ4.js} +1 -1
- package/dist/app/assets/RouteError-CrSeKN2r.js +1 -0
- package/dist/app/assets/{ThemeEditorPage-CciagFTq.js → ThemeEditorPage-Btxnmhtk.js} +1 -1
- package/dist/app/assets/{WorkbookPage-DNWmkCXa.js → WorkbookPage-D6JLPnWO.js} +1 -1
- package/dist/app/assets/{core-Rj_4rRnA.es-BZyvITuO.js → core-BSRgt1mG.es-Bc_D2Lq4.js} +1 -1
- package/dist/app/assets/{index-CH2AcDzc.js → index-BRijWj6K.js} +15 -15
- package/dist/app/assets/{index-DQa463gC.js → index-BX4D94xw.js} +1 -1
- package/dist/app/assets/{index-VBbcc8s6.js → index-Cvy0xtb7.js} +1 -1
- package/dist/app/assets/{index-DQpV7MyA.js → index-DMAhIJNC.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/package_load_worker.mjs +59 -20
- package/dist/server.mjs +1342 -616
- package/package.json +8 -2
- package/.eslintrc.json +0 -44
- package/.prettierignore +0 -1
- package/.prettierrc +0 -4
- package/build.ts +0 -97
- package/dist/app/assets/ModelPage-C1OSTv-x.js +0 -1
- package/dist/app/assets/RouteError-CzbfOkng.js +0 -1
- package/eslint.config.mjs +0 -76
- package/k6-tests/README.md +0 -144
- package/k6-tests/bun.lock +0 -797
- package/k6-tests/load-test/load-test-crud-connections.ts +0 -263
- package/k6-tests/load-test/load-test-crud-packages.ts +0 -461
- package/k6-tests/load-test/load-test-crud-projects.ts +0 -160
- package/k6-tests/load-test/load-test-crud.ts +0 -142
- package/k6-tests/load-test/load-test.ts +0 -784
- package/k6-tests/package.json +0 -27
- package/k6-tests/scripts/run-with-otel.sh +0 -50
- package/k6-tests/smoke-test/smoke-test.ts +0 -158
- package/k6-tests/tsconfig.json +0 -29
- package/k6-tests/utils/client_factory.ts +0 -128
- package/k6-tests/utils/common.ts +0 -489
- package/k6-tests/utils/logger.ts +0 -62
- package/k6-tests/utils/types.d.ts +0 -35
- package/malloy_mcp.dxt +0 -0
- package/publisher.config.example.bigquery.json +0 -33
- package/publisher.config.example.duckdb.json +0 -23
- package/publisher.config.json +0 -23
- package/scripts/bake-duckdb-extensions.js +0 -110
- package/src/config.spec.ts +0 -1535
- package/src/config.theme.spec.ts +0 -178
- package/src/config.ts +0 -1142
- package/src/constants.ts +0 -105
- package/src/controller/compile.controller.ts +0 -40
- package/src/controller/connection.controller.spec.ts +0 -849
- package/src/controller/connection.controller.ts +0 -901
- package/src/controller/database.controller.ts +0 -24
- package/src/controller/materialization.controller.spec.ts +0 -293
- package/src/controller/materialization.controller.ts +0 -319
- package/src/controller/model.controller.ts +0 -164
- package/src/controller/package.controller.spec.ts +0 -273
- package/src/controller/package.controller.ts +0 -306
- package/src/controller/query.controller.ts +0 -152
- package/src/controller/theme.controller.ts +0 -83
- package/src/controller/watch-mode.controller.ts +0 -243
- package/src/data_styles.ts +0 -71
- package/src/default-publisher.config.json +0 -23
- package/src/ducklake_version.spec.ts +0 -163
- package/src/ducklake_version.ts +0 -153
- package/src/errors.spec.ts +0 -119
- package/src/errors.ts +0 -237
- package/src/filter_deprecation.spec.ts +0 -64
- package/src/filter_deprecation.ts +0 -42
- package/src/health.spec.ts +0 -123
- package/src/health.ts +0 -233
- package/src/heap_check.spec.ts +0 -144
- package/src/heap_check.ts +0 -144
- package/src/instrumentation.ts +0 -214
- package/src/json_utils.spec.ts +0 -51
- package/src/json_utils.ts +0 -33
- package/src/logger.spec.ts +0 -210
- package/src/logger.ts +0 -251
- package/src/malloy_pin_prereqs.spec.ts +0 -25
- package/src/materialization_metrics.spec.ts +0 -169
- package/src/materialization_metrics.ts +0 -346
- package/src/mcp/error_messages.spec.ts +0 -35
- package/src/mcp/error_messages.ts +0 -183
- package/src/mcp/handler_utils.spec.ts +0 -108
- package/src/mcp/handler_utils.ts +0 -251
- package/src/mcp/mcp_constants.ts +0 -6
- package/src/mcp/query_envelope.spec.ts +0 -229
- package/src/mcp/query_envelope.ts +0 -240
- package/src/mcp/server.protocol.spec.ts +0 -250
- package/src/mcp/server.ts +0 -87
- package/src/mcp/skills/build_skills_bundle.spec.ts +0 -51
- package/src/mcp/skills/build_skills_bundle.ts +0 -176
- package/src/mcp/skills/skills_bundle.json +0 -1
- package/src/mcp/skills/skills_bundle.spec.ts +0 -171
- package/src/mcp/tool_response.spec.ts +0 -108
- package/src/mcp/tool_response.ts +0 -138
- package/src/mcp/tools/compile_tool.spec.ts +0 -315
- package/src/mcp/tools/compile_tool.ts +0 -208
- package/src/mcp/tools/docs_search/build_docs_index.ts +0 -132
- package/src/mcp/tools/docs_search/malloy_docs_index.json +0 -1
- package/src/mcp/tools/docs_search_tool.spec.ts +0 -32
- package/src/mcp/tools/docs_search_tool.ts +0 -128
- package/src/mcp/tools/embedding_index.spec.ts +0 -1236
- package/src/mcp/tools/embedding_index.ts +0 -808
- package/src/mcp/tools/execute_query_tool.spec.ts +0 -294
- package/src/mcp/tools/execute_query_tool.ts +0 -316
- package/src/mcp/tools/get_context_eval.ts +0 -275
- package/src/mcp/tools/get_context_tool.spec.ts +0 -592
- package/src/mcp/tools/get_context_tool.ts +0 -585
- package/src/mcp/tools/reload_package_tool.spec.ts +0 -232
- package/src/mcp/tools/reload_package_tool.ts +0 -132
- package/src/mcp_config.spec.ts +0 -919
- package/src/mcp_config.ts +0 -425
- package/src/oom_guards.integration.spec.ts +0 -269
- package/src/package_load/package_load_pool.spec.ts +0 -252
- package/src/package_load/package_load_pool.ts +0 -931
- package/src/package_load/package_load_worker.ts +0 -1034
- package/src/package_load/protocol.ts +0 -364
- package/src/package_load/rpc_wait_accountant.spec.ts +0 -109
- package/src/package_load/rpc_wait_accountant.ts +0 -76
- package/src/package_load_metrics.spec.ts +0 -114
- package/src/package_load_metrics.ts +0 -127
- package/src/path_safety.spec.ts +0 -158
- package/src/path_safety.ts +0 -146
- package/src/pg_helpers.spec.ts +0 -223
- package/src/pg_helpers.ts +0 -52
- package/src/query_cap_metrics.spec.ts +0 -89
- package/src/query_cap_metrics.ts +0 -116
- package/src/query_concurrency.spec.ts +0 -247
- package/src/query_concurrency.ts +0 -237
- package/src/query_metadata_metrics.ts +0 -49
- package/src/query_param_utils.ts +0 -29
- package/src/query_timeout.spec.ts +0 -224
- package/src/query_timeout.ts +0 -179
- package/src/runtime/publisher.js +0 -328
- package/src/server-old.ts +0 -988
- package/src/server.ts +0 -2059
- package/src/service/annotations.spec.ts +0 -118
- package/src/service/annotations.ts +0 -91
- package/src/service/authorize.spec.ts +0 -154
- package/src/service/authorize.ts +0 -498
- package/src/service/authorize_integration.spec.ts +0 -2041
- package/src/service/build_plan.spec.ts +0 -685
- package/src/service/build_plan.ts +0 -797
- package/src/service/compile_authorize.spec.ts +0 -85
- package/src/service/compile_fragment_techniques.spec.ts +0 -156
- package/src/service/connection.spec.ts +0 -2360
- package/src/service/connection.ts +0 -2187
- package/src/service/connection_config.spec.ts +0 -734
- package/src/service/connection_config.ts +0 -899
- package/src/service/connection_federation.spec.ts +0 -184
- package/src/service/connection_fingerprint.spec.ts +0 -102
- package/src/service/connection_service.spec.ts +0 -711
- package/src/service/connection_service.ts +0 -291
- package/src/service/cron_evaluator.spec.ts +0 -79
- package/src/service/cron_evaluator.ts +0 -105
- package/src/service/db_utils.spec.ts +0 -1085
- package/src/service/db_utils.ts +0 -1502
- package/src/service/duckdb_instance_isolation.spec.ts +0 -137
- package/src/service/ducklake_lazy_attach.spec.ts +0 -110
- package/src/service/embedding_provider.spec.ts +0 -329
- package/src/service/embedding_provider.ts +0 -236
- package/src/service/environment.ts +0 -2191
- package/src/service/environment_admission.spec.ts +0 -344
- package/src/service/environment_compile.spec.ts +0 -197
- package/src/service/environment_store.spec.ts +0 -2274
- package/src/service/environment_store.ts +0 -2434
- package/src/service/environment_store_anchoring.spec.ts +0 -107
- package/src/service/environment_store_clone.spec.ts +0 -350
- package/src/service/explore_visibility.spec.ts +0 -434
- package/src/service/exports_probe.spec.ts +0 -107
- package/src/service/extension_fetch_policy.spec.ts +0 -256
- package/src/service/filter.spec.ts +0 -458
- package/src/service/filter.ts +0 -341
- package/src/service/filter_bypass.spec.ts +0 -418
- package/src/service/filter_integration.spec.ts +0 -1004
- package/src/service/freshness.spec.ts +0 -183
- package/src/service/freshness.ts +0 -112
- package/src/service/gcs_s3_utils.ts +0 -427
- package/src/service/given.ts +0 -105
- package/src/service/givens_integration.spec.ts +0 -219
- package/src/service/manifest_loader.spec.ts +0 -189
- package/src/service/manifest_loader.ts +0 -156
- package/src/service/materialization_build_session.spec.ts +0 -435
- package/src/service/materialization_build_session.ts +0 -681
- package/src/service/materialization_config_validation.spec.ts +0 -99
- package/src/service/materialization_config_validation.ts +0 -120
- package/src/service/materialization_eligibility.spec.ts +0 -158
- package/src/service/materialization_eligibility.ts +0 -305
- package/src/service/materialization_schedule_surface.spec.ts +0 -256
- package/src/service/materialization_scheduler.spec.ts +0 -289
- package/src/service/materialization_scheduler.ts +0 -304
- package/src/service/materialization_serve_transform.spec.ts +0 -1003
- package/src/service/materialization_serve_transform.ts +0 -779
- package/src/service/materialization_service.spec.ts +0 -2373
- package/src/service/materialization_service.ts +0 -2342
- package/src/service/materialization_test_fixtures.ts +0 -249
- package/src/service/model.spec.ts +0 -1157
- package/src/service/model.ts +0 -3370
- package/src/service/model_limits.spec.ts +0 -209
- package/src/service/model_limits.ts +0 -131
- package/src/service/model_storage_serve.spec.ts +0 -193
- package/src/service/model_storage_serve_joins.spec.ts +0 -193
- package/src/service/package.spec.ts +0 -612
- package/src/service/package.ts +0 -1799
- package/src/service/package_manifest.spec.ts +0 -230
- package/src/service/package_manifest.ts +0 -241
- package/src/service/package_memory_governor.spec.ts +0 -173
- package/src/service/package_memory_governor.ts +0 -233
- package/src/service/package_quote_bound_tables.spec.ts +0 -171
- package/src/service/package_race.spec.ts +0 -208
- package/src/service/package_reload_safety.spec.ts +0 -193
- package/src/service/package_rollback.spec.ts +0 -190
- package/src/service/package_worker_path.spec.ts +0 -645
- package/src/service/path_injection.spec.ts +0 -39
- package/src/service/persist_annotation_validation.spec.ts +0 -89
- package/src/service/persist_annotation_validation.ts +0 -59
- package/src/service/persistence_policy.spec.ts +0 -374
- package/src/service/proxy.spec.ts +0 -414
- package/src/service/proxy.ts +0 -248
- package/src/service/query_boundary.spec.ts +0 -470
- package/src/service/query_metadata.spec.ts +0 -408
- package/src/service/query_metadata.ts +0 -492
- package/src/service/query_metadata_identity.spec.ts +0 -149
- package/src/service/query_text.spec.ts +0 -79
- package/src/service/query_text.ts +0 -43
- package/src/service/quoting.spec.ts +0 -135
- package/src/service/quoting.ts +0 -78
- package/src/service/resolve_environment.ts +0 -15
- package/src/service/restricted_mode.spec.ts +0 -299
- package/src/service/source_extraction.ts +0 -225
- package/src/service/theme_store.ts +0 -199
- package/src/storage/DatabaseInterface.ts +0 -198
- package/src/storage/StorageManager.ts +0 -113
- package/src/storage/duckdb/ConnectionRepository.ts +0 -143
- package/src/storage/duckdb/DuckDBConnection.ts +0 -123
- package/src/storage/duckdb/DuckDBRepository.ts +0 -208
- package/src/storage/duckdb/EnvironmentRepository.ts +0 -148
- package/src/storage/duckdb/MaterializationRepository.spec.ts +0 -191
- package/src/storage/duckdb/MaterializationRepository.ts +0 -336
- package/src/storage/duckdb/PackageRepository.ts +0 -135
- package/src/storage/duckdb/schema.ts +0 -229
- package/src/stream_helpers.spec.ts +0 -280
- package/src/stream_helpers.ts +0 -162
- package/src/telemetry.ts +0 -20
- package/src/test_helpers/metrics_harness.ts +0 -166
- package/src/theme_key_parity.spec.ts +0 -57
- package/src/utils.ts +0 -29
- package/tests/fixtures/authorize-compile/model.malloy +0 -9
- package/tests/fixtures/authorize-compile/publisher.json +0 -4
- package/tests/fixtures/html-pages-nopublic/model.malloy +0 -1
- package/tests/fixtures/html-pages-nopublic/publisher.json +0 -5
- package/tests/fixtures/html-pages-test/data.csv +0 -3
- package/tests/fixtures/html-pages-test/public/assets/app.css +0 -3
- package/tests/fixtures/html-pages-test/public/barehtml.html +0 -4
- package/tests/fixtures/html-pages-test/public/bodymeta.html +0 -8
- package/tests/fixtures/html-pages-test/public/data.json +0 -1
- package/tests/fixtures/html-pages-test/public/fitcomment.html +0 -11
- package/tests/fixtures/html-pages-test/public/index.html +0 -9
- package/tests/fixtures/html-pages-test/public/nohead.html +0 -8
- package/tests/fixtures/html-pages-test/public/notfit.html +0 -13
- package/tests/fixtures/html-pages-test/public/slides.html +0 -12
- package/tests/fixtures/html-pages-test/public/sub/page2.html +0 -9
- package/tests/fixtures/html-pages-test/public/unterminated.html +0 -10
- package/tests/fixtures/html-pages-test/publisher.json +0 -5
- package/tests/fixtures/html-pages-test/report.malloy +0 -1
- package/tests/fixtures/persist-multi-level/data/orders.csv +0 -5
- package/tests/fixtures/persist-multi-level/multi_level.malloy +0 -18
- package/tests/fixtures/persist-multi-level/publisher.json +0 -5
- package/tests/fixtures/persist-schedule-test/data/orders.csv +0 -5
- package/tests/fixtures/persist-schedule-test/persist_schedule_test.malloy +0 -11
- package/tests/fixtures/persist-schedule-test/publisher.json +0 -7
- package/tests/fixtures/persist-schedule-warn-test/data/orders.csv +0 -5
- package/tests/fixtures/persist-schedule-warn-test/persist_warn_test.malloy +0 -11
- package/tests/fixtures/persist-schedule-warn-test/publisher.json +0 -6
- package/tests/fixtures/persist-test/data/orders.csv +0 -5
- package/tests/fixtures/persist-test/persist_test.malloy +0 -11
- package/tests/fixtures/persist-test/publisher.json +0 -5
- package/tests/fixtures/publisher.config.json +0 -15
- package/tests/fixtures/query-givens/data/orders.csv +0 -7
- package/tests/fixtures/query-givens/model.malloy +0 -34
- package/tests/fixtures/query-givens/publisher.json +0 -5
- package/tests/fixtures/xlsx/database.xlsx +0 -0
- package/tests/harness/mcp_test_setup.ts +0 -332
- package/tests/harness/rest_e2e.ts +0 -68
- package/tests/integration/authorize/compile_authorize_http.integration.spec.ts +0 -92
- package/tests/integration/concurrent_package/concurrent_package.integration.spec.ts +0 -280
- package/tests/integration/duckdb_storage/duckdb_storage.integration.spec.ts +0 -138
- package/tests/integration/first_boot/readiness_line.integration.spec.ts +0 -177
- package/tests/integration/html_pages/html_pages.integration.spec.ts +0 -440
- package/tests/integration/legacy_routes/legacy_routes.integration.spec.ts +0 -259
- package/tests/integration/materialization/environment_list.integration.spec.ts +0 -133
- package/tests/integration/materialization/freshness_gate.integration.spec.ts +0 -292
- package/tests/integration/materialization/manifest_binding.integration.spec.ts +0 -430
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +0 -441
- package/tests/integration/materialization/orchestrated_rebind.integration.spec.ts +0 -150
- package/tests/integration/materialization/reference_manifest.integration.spec.ts +0 -251
- package/tests/integration/materialization/schedule_edit.integration.spec.ts +0 -126
- package/tests/integration/materialization/scheduler.integration.spec.ts +0 -200
- package/tests/integration/materialization/scheduler_recovery.integration.spec.ts +0 -172
- package/tests/integration/materialization/scheduler_transitions.integration.spec.ts +0 -256
- package/tests/integration/materialization/scheduler_wiring.integration.spec.ts +0 -143
- package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +0 -438
- package/tests/integration/mcp/mcp_get_context_semantic.integration.spec.ts +0 -235
- package/tests/integration/mcp/mcp_transport.integration.spec.ts +0 -98
- package/tests/integration/query_givens/query_givens.integration.spec.ts +0 -146
- package/tests/integration/query_givens/query_givens_authorize.integration.spec.ts +0 -121
- package/tests/integration/sdk_givens/sdk_givens.integration.spec.ts +0 -110
- package/tests/integration/watch-mode/watch_mode.integration.spec.ts +0 -421
- package/tests/unit/controller/theme.controller.spec.ts +0 -141
- package/tests/unit/duckdb/attached_databases.test.ts +0 -1243
- package/tests/unit/duckdb/duckdb_connection.test.ts +0 -181
- package/tests/unit/duckdb/legacy_schema_migration.test.ts +0 -197
- package/tests/unit/duckdb/repositories.test.ts +0 -208
- package/tests/unit/ducklake/ducklake.test.ts +0 -952
- package/tests/unit/service/theme_store.spec.ts +0 -139
- package/tsconfig.json +0 -32
|
@@ -1,2274 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
afterEach,
|
|
3
|
-
beforeEach,
|
|
4
|
-
describe,
|
|
5
|
-
expect,
|
|
6
|
-
it,
|
|
7
|
-
mock,
|
|
8
|
-
spyOn,
|
|
9
|
-
} from "bun:test";
|
|
10
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
11
|
-
import { promises as fsPromises } from "fs";
|
|
12
|
-
import * as path from "path";
|
|
13
|
-
import * as sinon from "sinon";
|
|
14
|
-
import { components } from "../api";
|
|
15
|
-
import { isPublisherConfigFrozen } from "../config";
|
|
16
|
-
import { TEMP_DIR_PATH } from "../constants";
|
|
17
|
-
import { BadRequestError } from "../errors";
|
|
18
|
-
import { _resetEmbeddingIndexStateForTests } from "../mcp/tools/embedding_index";
|
|
19
|
-
import { Environment, PackageStatus } from "./environment";
|
|
20
|
-
import {
|
|
21
|
-
CloneProgressReporter,
|
|
22
|
-
cloneProgressLabel,
|
|
23
|
-
EnvironmentStore,
|
|
24
|
-
formatReadinessLine,
|
|
25
|
-
GIT_CLONE_OPTIONS,
|
|
26
|
-
parseGitHubUrl,
|
|
27
|
-
resolvePackageLocation,
|
|
28
|
-
stripGitProgressNoise,
|
|
29
|
-
} from "./environment_store";
|
|
30
|
-
|
|
31
|
-
type MockData = Record<string, unknown>;
|
|
32
|
-
|
|
33
|
-
// Environments the mock database reports at boot. Empty by default, which makes
|
|
34
|
-
// initialize() fall back to the config file — the path every other test here
|
|
35
|
-
// exercises. Set it to reach the database-restore branch instead, which is the
|
|
36
|
-
// one a real restart against an existing publisher.db takes. Read at call time,
|
|
37
|
-
// so a test can assign it before constructing the store.
|
|
38
|
-
let mockDbEnvironments: unknown[] = [];
|
|
39
|
-
|
|
40
|
-
// Recorder for the embeddings-cleanup wiring tests: every SQL statement
|
|
41
|
-
// the store's cleanup path issues lands here. When `blocks` is true the
|
|
42
|
-
// statements never resolve, which pins that the delete APIs do not await
|
|
43
|
-
// the cleanup (it can queue for minutes behind a bulk embed).
|
|
44
|
-
const embeddingCleanupRuns: Array<{ sql: string; params: unknown[] }> = [];
|
|
45
|
-
let embeddingCleanupBlocks = false;
|
|
46
|
-
|
|
47
|
-
mock.module("../storage/StorageManager", () => {
|
|
48
|
-
return {
|
|
49
|
-
StorageManager: class MockStorageManager {
|
|
50
|
-
async initialize(_reInit?: boolean): Promise<void> {
|
|
51
|
-
return;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
getDuckDbConnection() {
|
|
55
|
-
return {
|
|
56
|
-
run: (sql: string, params?: unknown[]) => {
|
|
57
|
-
embeddingCleanupRuns.push({ sql, params: params ?? [] });
|
|
58
|
-
return embeddingCleanupBlocks
|
|
59
|
-
? new Promise<void>(() => {})
|
|
60
|
-
: Promise.resolve();
|
|
61
|
-
},
|
|
62
|
-
all: async () => [],
|
|
63
|
-
get: async () => null,
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
getRepository() {
|
|
68
|
-
return {
|
|
69
|
-
// ===== PROJECT METHODS =====
|
|
70
|
-
listEnvironments: async (): Promise<unknown[]> =>
|
|
71
|
-
mockDbEnvironments,
|
|
72
|
-
|
|
73
|
-
getEnvironmentById: async (
|
|
74
|
-
id: string,
|
|
75
|
-
): Promise<MockData | null> => ({
|
|
76
|
-
id,
|
|
77
|
-
name: "test-project",
|
|
78
|
-
path: "/test/path",
|
|
79
|
-
description: "Test description",
|
|
80
|
-
metadata: {},
|
|
81
|
-
createdAt: new Date(),
|
|
82
|
-
updatedAt: new Date(),
|
|
83
|
-
}),
|
|
84
|
-
|
|
85
|
-
getEnvironmentByName: async (
|
|
86
|
-
_name: string,
|
|
87
|
-
): Promise<MockData | null> => {
|
|
88
|
-
// Return null to simulate "project doesn't exist yet"
|
|
89
|
-
return null;
|
|
90
|
-
},
|
|
91
|
-
|
|
92
|
-
createEnvironment: async (
|
|
93
|
-
data: MockData,
|
|
94
|
-
): Promise<MockData> => ({
|
|
95
|
-
id: "test-project-id",
|
|
96
|
-
name: data.name,
|
|
97
|
-
path: data.path,
|
|
98
|
-
description: data.description,
|
|
99
|
-
metadata: data.metadata,
|
|
100
|
-
createdAt: new Date(),
|
|
101
|
-
updatedAt: new Date(),
|
|
102
|
-
}),
|
|
103
|
-
|
|
104
|
-
updateEnvironment: async (
|
|
105
|
-
id: string,
|
|
106
|
-
data: MockData,
|
|
107
|
-
): Promise<MockData> => ({
|
|
108
|
-
id,
|
|
109
|
-
name: "test-project",
|
|
110
|
-
path: "/test/path",
|
|
111
|
-
description: data.description,
|
|
112
|
-
metadata: {
|
|
113
|
-
...(data.metadata || {}),
|
|
114
|
-
readme: data.readme,
|
|
115
|
-
},
|
|
116
|
-
createdAt: new Date(),
|
|
117
|
-
updatedAt: new Date(),
|
|
118
|
-
}),
|
|
119
|
-
|
|
120
|
-
deleteEnvironment: async (_id: string): Promise<void> => {},
|
|
121
|
-
|
|
122
|
-
// ===== PACKAGE METHODS =====
|
|
123
|
-
listPackages: async (
|
|
124
|
-
_environmentId: string,
|
|
125
|
-
): Promise<unknown[]> => [],
|
|
126
|
-
|
|
127
|
-
getPackageById: async (
|
|
128
|
-
id: string,
|
|
129
|
-
): Promise<MockData | null> => ({
|
|
130
|
-
id,
|
|
131
|
-
environmentId: "test-project-id",
|
|
132
|
-
name: "test-package",
|
|
133
|
-
description: "Test package",
|
|
134
|
-
manifestPath: "/test/manifest.json",
|
|
135
|
-
metadata: {},
|
|
136
|
-
createdAt: new Date(),
|
|
137
|
-
updatedAt: new Date(),
|
|
138
|
-
}),
|
|
139
|
-
|
|
140
|
-
getPackageByName: async (
|
|
141
|
-
_environmentId: string,
|
|
142
|
-
_name: string,
|
|
143
|
-
): Promise<MockData | null> => null,
|
|
144
|
-
|
|
145
|
-
createPackage: async (data: MockData): Promise<MockData> => ({
|
|
146
|
-
id: "test-package-id",
|
|
147
|
-
environmentId: data.environmentId,
|
|
148
|
-
name: data.name,
|
|
149
|
-
description: data.description,
|
|
150
|
-
manifestPath: data.manifestPath,
|
|
151
|
-
metadata: data.metadata,
|
|
152
|
-
createdAt: new Date(),
|
|
153
|
-
updatedAt: new Date(),
|
|
154
|
-
}),
|
|
155
|
-
|
|
156
|
-
updatePackage: async (
|
|
157
|
-
id: string,
|
|
158
|
-
data: MockData,
|
|
159
|
-
): Promise<MockData> => ({
|
|
160
|
-
id,
|
|
161
|
-
environmentId: "test-project-id",
|
|
162
|
-
name: "test-package",
|
|
163
|
-
description: data.description,
|
|
164
|
-
manifestPath: "/test/manifest.json",
|
|
165
|
-
metadata: data.metadata || {},
|
|
166
|
-
createdAt: new Date(),
|
|
167
|
-
updatedAt: new Date(),
|
|
168
|
-
}),
|
|
169
|
-
|
|
170
|
-
deletePackage: async (_id: string): Promise<void> => {},
|
|
171
|
-
|
|
172
|
-
// ===== CONNECTION METHODS =====
|
|
173
|
-
listConnections: async (
|
|
174
|
-
_environmentId: string,
|
|
175
|
-
): Promise<unknown[]> => [],
|
|
176
|
-
|
|
177
|
-
getConnectionById: async (
|
|
178
|
-
id: string,
|
|
179
|
-
): Promise<MockData | null> => ({
|
|
180
|
-
id,
|
|
181
|
-
environmentId: "test-project-id",
|
|
182
|
-
name: "test-connection",
|
|
183
|
-
type: "postgres",
|
|
184
|
-
config: {},
|
|
185
|
-
createdAt: new Date(),
|
|
186
|
-
updatedAt: new Date(),
|
|
187
|
-
}),
|
|
188
|
-
|
|
189
|
-
getConnectionByName: async (
|
|
190
|
-
_environmentId: string,
|
|
191
|
-
_name: string,
|
|
192
|
-
): Promise<MockData | null> => null,
|
|
193
|
-
|
|
194
|
-
createConnection: async (data: MockData): Promise<MockData> => ({
|
|
195
|
-
id: "test-connection-id",
|
|
196
|
-
environmentId: data.environmentId,
|
|
197
|
-
name: data.name,
|
|
198
|
-
type: data.type,
|
|
199
|
-
config: data.config,
|
|
200
|
-
createdAt: new Date(),
|
|
201
|
-
updatedAt: new Date(),
|
|
202
|
-
}),
|
|
203
|
-
|
|
204
|
-
updateConnection: async (
|
|
205
|
-
id: string,
|
|
206
|
-
data: MockData,
|
|
207
|
-
): Promise<MockData> => ({
|
|
208
|
-
id,
|
|
209
|
-
environmentId: "test-project-id",
|
|
210
|
-
name: "test-connection",
|
|
211
|
-
type: "postgres",
|
|
212
|
-
config: data.config || {},
|
|
213
|
-
createdAt: new Date(),
|
|
214
|
-
updatedAt: new Date(),
|
|
215
|
-
}),
|
|
216
|
-
|
|
217
|
-
deleteConnection: async (_id: string): Promise<void> => {},
|
|
218
|
-
};
|
|
219
|
-
}
|
|
220
|
-
},
|
|
221
|
-
StorageConfig: {} as Record<string, unknown>,
|
|
222
|
-
};
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
type Connection = components["schemas"]["Connection"];
|
|
226
|
-
|
|
227
|
-
const serverRootPath = path.join(
|
|
228
|
-
TEMP_DIR_PATH,
|
|
229
|
-
"pathways-worker-publisher-project-store-test",
|
|
230
|
-
);
|
|
231
|
-
const projectName = "organizationName-projectName";
|
|
232
|
-
|
|
233
|
-
let sandbox: sinon.SinonSandbox;
|
|
234
|
-
|
|
235
|
-
describe("EnvironmentStore Service", () => {
|
|
236
|
-
let environmentStore: EnvironmentStore;
|
|
237
|
-
|
|
238
|
-
beforeEach(async () => {
|
|
239
|
-
// Clean up any existing test directory
|
|
240
|
-
if (existsSync(serverRootPath)) {
|
|
241
|
-
rmSync(serverRootPath, { recursive: true, force: true });
|
|
242
|
-
}
|
|
243
|
-
mkdirSync(serverRootPath);
|
|
244
|
-
// Default every test back to the config-file boot path. bun runs all specs
|
|
245
|
-
// in one process, so a leaked value would silently switch later tests onto
|
|
246
|
-
// the database-restore branch.
|
|
247
|
-
mockDbEnvironments = [];
|
|
248
|
-
sandbox = sinon.createSandbox();
|
|
249
|
-
|
|
250
|
-
// Mock the configuration to prevent initialization errors
|
|
251
|
-
mock(isPublisherConfigFrozen).mockReturnValue(false);
|
|
252
|
-
mock.module("../config", () => ({
|
|
253
|
-
isPublisherConfigFrozen: () => false,
|
|
254
|
-
}));
|
|
255
|
-
|
|
256
|
-
// Create project store after mocking
|
|
257
|
-
environmentStore = new EnvironmentStore(serverRootPath);
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
afterEach(async () => {
|
|
261
|
-
// Clean up the test directory after each test
|
|
262
|
-
if (existsSync(serverRootPath)) {
|
|
263
|
-
rmSync(serverRootPath, { recursive: true, force: true });
|
|
264
|
-
}
|
|
265
|
-
mkdirSync(serverRootPath);
|
|
266
|
-
mockDbEnvironments = [];
|
|
267
|
-
sandbox.restore();
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
it("should not load a package if the project does not exist", async () => {
|
|
271
|
-
await expect(
|
|
272
|
-
environmentStore.getEnvironment("non-existent-project"),
|
|
273
|
-
).rejects.toThrow();
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
it(
|
|
277
|
-
"should create and manage projects with connections",
|
|
278
|
-
async () => {
|
|
279
|
-
// Create a project directory
|
|
280
|
-
const projectPath = path.join(serverRootPath, projectName);
|
|
281
|
-
mkdirSync(projectPath, { recursive: true });
|
|
282
|
-
// Create publisher.json manifest file
|
|
283
|
-
writeFileSync(
|
|
284
|
-
path.join(projectPath, "publisher.json"),
|
|
285
|
-
JSON.stringify({
|
|
286
|
-
name: projectName,
|
|
287
|
-
description: "Test package",
|
|
288
|
-
}),
|
|
289
|
-
);
|
|
290
|
-
|
|
291
|
-
// Create publisher config
|
|
292
|
-
const publisherConfigPath = path.join(
|
|
293
|
-
serverRootPath,
|
|
294
|
-
"publisher.config.json",
|
|
295
|
-
);
|
|
296
|
-
writeFileSync(
|
|
297
|
-
publisherConfigPath,
|
|
298
|
-
JSON.stringify({
|
|
299
|
-
frozenConfig: false,
|
|
300
|
-
environments: [
|
|
301
|
-
{
|
|
302
|
-
name: projectName,
|
|
303
|
-
packages: [
|
|
304
|
-
{
|
|
305
|
-
name: projectName,
|
|
306
|
-
location: projectPath,
|
|
307
|
-
},
|
|
308
|
-
],
|
|
309
|
-
connections: [
|
|
310
|
-
{
|
|
311
|
-
name: "testConnection",
|
|
312
|
-
type: "postgres",
|
|
313
|
-
},
|
|
314
|
-
],
|
|
315
|
-
},
|
|
316
|
-
],
|
|
317
|
-
}),
|
|
318
|
-
);
|
|
319
|
-
|
|
320
|
-
// Test that the project can be retrieved
|
|
321
|
-
const project = await environmentStore.getEnvironment(projectName);
|
|
322
|
-
expect(project).toBeInstanceOf(Environment);
|
|
323
|
-
expect(project.metadata.name).toBe(projectName);
|
|
324
|
-
},
|
|
325
|
-
{ timeout: 30000 },
|
|
326
|
-
);
|
|
327
|
-
|
|
328
|
-
it("should handle multiple projects", async () => {
|
|
329
|
-
const projectName1 = "project1";
|
|
330
|
-
const projectName2 = "project2";
|
|
331
|
-
const projectPath1 = path.join(serverRootPath, projectName1);
|
|
332
|
-
const projectPath2 = path.join(serverRootPath, projectName2);
|
|
333
|
-
|
|
334
|
-
// Create project directories
|
|
335
|
-
mkdirSync(projectPath1, { recursive: true });
|
|
336
|
-
mkdirSync(projectPath2, { recursive: true });
|
|
337
|
-
|
|
338
|
-
// Create publisher config
|
|
339
|
-
const publisherConfigPath = path.join(
|
|
340
|
-
serverRootPath,
|
|
341
|
-
"publisher.config.json",
|
|
342
|
-
);
|
|
343
|
-
writeFileSync(
|
|
344
|
-
publisherConfigPath,
|
|
345
|
-
JSON.stringify({
|
|
346
|
-
frozenConfig: false,
|
|
347
|
-
environments: [
|
|
348
|
-
{
|
|
349
|
-
name: projectName1,
|
|
350
|
-
packages: [
|
|
351
|
-
{
|
|
352
|
-
name: projectName1,
|
|
353
|
-
location: projectPath1,
|
|
354
|
-
},
|
|
355
|
-
],
|
|
356
|
-
connections: [
|
|
357
|
-
{
|
|
358
|
-
name: "testConnection",
|
|
359
|
-
type: "postgres",
|
|
360
|
-
},
|
|
361
|
-
],
|
|
362
|
-
},
|
|
363
|
-
{
|
|
364
|
-
name: projectName2,
|
|
365
|
-
packages: [
|
|
366
|
-
{
|
|
367
|
-
name: projectName2,
|
|
368
|
-
location: projectPath2,
|
|
369
|
-
},
|
|
370
|
-
],
|
|
371
|
-
connections: [
|
|
372
|
-
{
|
|
373
|
-
name: "testConnection2",
|
|
374
|
-
type: "bigquery",
|
|
375
|
-
bigqueryConnection: {},
|
|
376
|
-
},
|
|
377
|
-
],
|
|
378
|
-
},
|
|
379
|
-
],
|
|
380
|
-
}),
|
|
381
|
-
);
|
|
382
|
-
|
|
383
|
-
// Create a new project store that will read the configuration
|
|
384
|
-
const newEnvironmentStore = new EnvironmentStore(serverRootPath);
|
|
385
|
-
await newEnvironmentStore.finishedInitialization;
|
|
386
|
-
|
|
387
|
-
// Test that both projects can be listed
|
|
388
|
-
const projects = await newEnvironmentStore.listEnvironments();
|
|
389
|
-
expect(projects).toBeInstanceOf(Array);
|
|
390
|
-
expect(projects.length).toBe(2);
|
|
391
|
-
expect(projects.map((p) => p.name)).toContain(projectName1);
|
|
392
|
-
expect(projects.map((p) => p.name)).toContain(projectName2);
|
|
393
|
-
});
|
|
394
|
-
|
|
395
|
-
it("should skip a project with invalid startup connection config", async () => {
|
|
396
|
-
const validProjectName = "valid-project";
|
|
397
|
-
const invalidProjectName = "invalid-motherduck-project";
|
|
398
|
-
const validProjectPath = path.join(serverRootPath, validProjectName);
|
|
399
|
-
const invalidProjectPath = path.join(serverRootPath, invalidProjectName);
|
|
400
|
-
|
|
401
|
-
mkdirSync(validProjectPath, { recursive: true });
|
|
402
|
-
mkdirSync(invalidProjectPath, { recursive: true });
|
|
403
|
-
writeFileSync(
|
|
404
|
-
path.join(validProjectPath, "publisher.json"),
|
|
405
|
-
JSON.stringify({
|
|
406
|
-
name: validProjectName,
|
|
407
|
-
description: "Valid project",
|
|
408
|
-
}),
|
|
409
|
-
);
|
|
410
|
-
writeFileSync(
|
|
411
|
-
path.join(invalidProjectPath, "publisher.json"),
|
|
412
|
-
JSON.stringify({
|
|
413
|
-
name: invalidProjectName,
|
|
414
|
-
description: "Invalid project",
|
|
415
|
-
}),
|
|
416
|
-
);
|
|
417
|
-
|
|
418
|
-
const publisherConfigPath = path.join(
|
|
419
|
-
serverRootPath,
|
|
420
|
-
"publisher.config.json",
|
|
421
|
-
);
|
|
422
|
-
writeFileSync(
|
|
423
|
-
publisherConfigPath,
|
|
424
|
-
JSON.stringify({
|
|
425
|
-
frozenConfig: false,
|
|
426
|
-
environments: [
|
|
427
|
-
{
|
|
428
|
-
name: invalidProjectName,
|
|
429
|
-
packages: [
|
|
430
|
-
{
|
|
431
|
-
name: invalidProjectName,
|
|
432
|
-
location: invalidProjectPath,
|
|
433
|
-
},
|
|
434
|
-
],
|
|
435
|
-
connections: [
|
|
436
|
-
{
|
|
437
|
-
name: "motherduck",
|
|
438
|
-
type: "motherduck",
|
|
439
|
-
motherduckConnection: {},
|
|
440
|
-
},
|
|
441
|
-
],
|
|
442
|
-
},
|
|
443
|
-
{
|
|
444
|
-
name: validProjectName,
|
|
445
|
-
packages: [
|
|
446
|
-
{
|
|
447
|
-
name: validProjectName,
|
|
448
|
-
location: validProjectPath,
|
|
449
|
-
},
|
|
450
|
-
],
|
|
451
|
-
connections: [],
|
|
452
|
-
},
|
|
453
|
-
],
|
|
454
|
-
}),
|
|
455
|
-
);
|
|
456
|
-
|
|
457
|
-
const newEnvironmentStore = new EnvironmentStore(serverRootPath);
|
|
458
|
-
await newEnvironmentStore.finishedInitialization;
|
|
459
|
-
|
|
460
|
-
const projects = await newEnvironmentStore.listEnvironments();
|
|
461
|
-
expect(projects.map((p) => p.name)).toEqual([validProjectName]);
|
|
462
|
-
await expect(
|
|
463
|
-
newEnvironmentStore.getEnvironment(invalidProjectName),
|
|
464
|
-
).rejects.toThrow();
|
|
465
|
-
|
|
466
|
-
// The skip is not fatal, so the server still serves. getStatus is the
|
|
467
|
-
// only place that says the environment is missing rather than never
|
|
468
|
-
// configured: without loadErrors the two are indistinguishable.
|
|
469
|
-
const status = await newEnvironmentStore.getStatus();
|
|
470
|
-
expect(status.operationalState).toBe("serving");
|
|
471
|
-
expect(status.environments.map((e) => e.name)).toEqual([
|
|
472
|
-
validProjectName,
|
|
473
|
-
]);
|
|
474
|
-
expect(status.loadErrors).toHaveLength(1);
|
|
475
|
-
expect(status.loadErrors?.[0]?.environment).toBe(invalidProjectName);
|
|
476
|
-
expect(status.loadErrors?.[0]?.package).toBeUndefined();
|
|
477
|
-
expect(status.loadErrors?.[0]?.message).toBeTruthy();
|
|
478
|
-
});
|
|
479
|
-
|
|
480
|
-
it("should omit loadErrors when every environment loads", async () => {
|
|
481
|
-
const projectPath = path.join(serverRootPath, projectName);
|
|
482
|
-
mkdirSync(projectPath, { recursive: true });
|
|
483
|
-
writeFileSync(
|
|
484
|
-
path.join(projectPath, "publisher.json"),
|
|
485
|
-
JSON.stringify({ name: projectName, description: "Test package" }),
|
|
486
|
-
);
|
|
487
|
-
writeFileSync(
|
|
488
|
-
path.join(serverRootPath, "publisher.config.json"),
|
|
489
|
-
JSON.stringify({
|
|
490
|
-
frozenConfig: false,
|
|
491
|
-
environments: [
|
|
492
|
-
{
|
|
493
|
-
name: projectName,
|
|
494
|
-
packages: [{ name: projectName, location: projectPath }],
|
|
495
|
-
connections: [],
|
|
496
|
-
},
|
|
497
|
-
],
|
|
498
|
-
}),
|
|
499
|
-
);
|
|
500
|
-
|
|
501
|
-
const newEnvironmentStore = new EnvironmentStore(serverRootPath);
|
|
502
|
-
await newEnvironmentStore.finishedInitialization;
|
|
503
|
-
|
|
504
|
-
// Absent, not an empty array: a healthy status is unchanged by this
|
|
505
|
-
// field's existence.
|
|
506
|
-
const status = await newEnvironmentStore.getStatus();
|
|
507
|
-
expect(status.operationalState).toBe("serving");
|
|
508
|
-
expect(status.loadErrors).toBeUndefined();
|
|
509
|
-
expect("loadErrors" in status).toBe(false);
|
|
510
|
-
});
|
|
511
|
-
|
|
512
|
-
it("keeps good packages serving when a sibling's location is bad", async () => {
|
|
513
|
-
// The headline case: one bad location in an environment must not take
|
|
514
|
-
// down its healthy siblings. Two good packages plus one whose location
|
|
515
|
-
// does not exist -> both good ones serve, the bad one is a per-package
|
|
516
|
-
// loadError, and the environment is intact.
|
|
517
|
-
const goodA = path.join(serverRootPath, "good-a");
|
|
518
|
-
const goodB = path.join(serverRootPath, "good-b");
|
|
519
|
-
for (const dir of [goodA, goodB]) {
|
|
520
|
-
mkdirSync(dir, { recursive: true });
|
|
521
|
-
writeFileSync(
|
|
522
|
-
path.join(dir, "publisher.json"),
|
|
523
|
-
JSON.stringify({ name: path.basename(dir) }),
|
|
524
|
-
);
|
|
525
|
-
}
|
|
526
|
-
writeFileSync(
|
|
527
|
-
path.join(serverRootPath, "publisher.config.json"),
|
|
528
|
-
JSON.stringify({
|
|
529
|
-
environments: [
|
|
530
|
-
{
|
|
531
|
-
name: projectName,
|
|
532
|
-
packages: [
|
|
533
|
-
{ name: "good-a", location: goodA },
|
|
534
|
-
{ name: "bad", location: "/non/existent/path" },
|
|
535
|
-
{ name: "good-b", location: goodB },
|
|
536
|
-
],
|
|
537
|
-
connections: [],
|
|
538
|
-
},
|
|
539
|
-
],
|
|
540
|
-
}),
|
|
541
|
-
);
|
|
542
|
-
|
|
543
|
-
const newEnvironmentStore = new EnvironmentStore(serverRootPath);
|
|
544
|
-
await newEnvironmentStore.finishedInitialization;
|
|
545
|
-
|
|
546
|
-
const status = await newEnvironmentStore.getStatus();
|
|
547
|
-
expect(status.operationalState).toBe("serving");
|
|
548
|
-
expect(status.environments.map((e) => e.name)).toEqual([projectName]);
|
|
549
|
-
|
|
550
|
-
const environment = await newEnvironmentStore.getEnvironment(projectName);
|
|
551
|
-
const packages = await environment.listPackages();
|
|
552
|
-
expect(packages.map((p) => p.name).sort()).toEqual(["good-a", "good-b"]);
|
|
553
|
-
|
|
554
|
-
expect(status.loadErrors).toHaveLength(1);
|
|
555
|
-
expect(status.loadErrors?.[0]?.environment).toBe(projectName);
|
|
556
|
-
expect(status.loadErrors?.[0]?.package).toBe("bad");
|
|
557
|
-
// The message has to name the location that is actually wrong. The
|
|
558
|
-
// un-mounted package also fails its lazy load on the manifest that was
|
|
559
|
-
// never copied, and reporting that instead points the reader at
|
|
560
|
-
// publisher_data/ for what is really a typo in the config.
|
|
561
|
-
expect(status.loadErrors?.[0]?.message).toContain("/non/existent/path");
|
|
562
|
-
expect(status.loadErrors?.[0]?.message).not.toContain("publisher_data");
|
|
563
|
-
});
|
|
564
|
-
|
|
565
|
-
it("keeps sibling packages serving when one sharing their location fails to extract", async () => {
|
|
566
|
-
// Packages grouped under ONE location share a single download, then each
|
|
567
|
-
// is extracted separately. A failure in one extract must not strand the
|
|
568
|
-
// siblings queued behind it: the group-level catch used to wrap the whole
|
|
569
|
-
// loop, so with three packages from one repo a throw on the second meant
|
|
570
|
-
// the third was never copied even though nothing was wrong with it.
|
|
571
|
-
const shared = path.join(serverRootPath, "shared-src");
|
|
572
|
-
mkdirSync(shared, { recursive: true });
|
|
573
|
-
writeFileSync(
|
|
574
|
-
path.join(shared, "publisher.json"),
|
|
575
|
-
JSON.stringify({ name: "shared" }),
|
|
576
|
-
);
|
|
577
|
-
|
|
578
|
-
writeFileSync(
|
|
579
|
-
path.join(serverRootPath, "publisher.config.json"),
|
|
580
|
-
JSON.stringify({
|
|
581
|
-
environments: [
|
|
582
|
-
{
|
|
583
|
-
name: projectName,
|
|
584
|
-
packages: [
|
|
585
|
-
{ name: "first", location: shared },
|
|
586
|
-
{ name: "middle", location: shared },
|
|
587
|
-
{ name: "last", location: shared },
|
|
588
|
-
],
|
|
589
|
-
connections: [],
|
|
590
|
-
},
|
|
591
|
-
],
|
|
592
|
-
}),
|
|
593
|
-
);
|
|
594
|
-
|
|
595
|
-
// Fail exactly one package's copy. Everything reachable from config is
|
|
596
|
-
// shared across the group (same location, hence the same source path), so
|
|
597
|
-
// the copy is the only per-package step that can realistically fail on
|
|
598
|
-
// its own — the same shape as a GitHub subdirectory that isn't there.
|
|
599
|
-
const realCp = fsPromises.cp;
|
|
600
|
-
const cpStub = sinon.stub(fsPromises, "cp").callsFake((async (
|
|
601
|
-
src: Parameters<typeof realCp>[0],
|
|
602
|
-
dest: Parameters<typeof realCp>[1],
|
|
603
|
-
opts?: Parameters<typeof realCp>[2],
|
|
604
|
-
) => {
|
|
605
|
-
if (String(dest).endsWith(`${path.sep}middle`)) {
|
|
606
|
-
throw new Error("simulated extract failure for middle");
|
|
607
|
-
}
|
|
608
|
-
return realCp(src, dest, opts);
|
|
609
|
-
}) as typeof realCp);
|
|
610
|
-
|
|
611
|
-
try {
|
|
612
|
-
const newEnvironmentStore = new EnvironmentStore(serverRootPath);
|
|
613
|
-
await newEnvironmentStore.finishedInitialization;
|
|
614
|
-
|
|
615
|
-
const environment =
|
|
616
|
-
await newEnvironmentStore.getEnvironment(projectName);
|
|
617
|
-
const packages = await environment.listPackages();
|
|
618
|
-
// "last" is the one that regressed: it is queued behind the failure.
|
|
619
|
-
expect(packages.map((p) => p.name).sort()).toEqual(["first", "last"]);
|
|
620
|
-
|
|
621
|
-
const status = await newEnvironmentStore.getStatus();
|
|
622
|
-
expect(status.operationalState).toBe("serving");
|
|
623
|
-
expect(status.loadErrors).toHaveLength(1);
|
|
624
|
-
expect(status.loadErrors?.[0]?.package).toBe("middle");
|
|
625
|
-
expect(status.loadErrors?.[0]?.message).toContain(
|
|
626
|
-
"simulated extract failure",
|
|
627
|
-
);
|
|
628
|
-
} finally {
|
|
629
|
-
cpStub.restore();
|
|
630
|
-
}
|
|
631
|
-
});
|
|
632
|
-
|
|
633
|
-
it("does not delete the environment directory when a package name is rejected", async () => {
|
|
634
|
-
// A package name that fails the allowlist throws as the FIRST statement of
|
|
635
|
-
// the extract, so the per-package catch runs before any path for it
|
|
636
|
-
// exists. safeJoinUnderRoot deliberately allows a name that resolves to
|
|
637
|
-
// the root, so re-deriving the cleanup target from the name would hand it
|
|
638
|
-
// the whole environment directory and take the healthy siblings with it.
|
|
639
|
-
const good = path.join(serverRootPath, "good-src");
|
|
640
|
-
mkdirSync(good, { recursive: true });
|
|
641
|
-
writeFileSync(
|
|
642
|
-
path.join(good, "publisher.json"),
|
|
643
|
-
JSON.stringify({ name: "good" }),
|
|
644
|
-
);
|
|
645
|
-
|
|
646
|
-
writeFileSync(
|
|
647
|
-
path.join(serverRootPath, "publisher.config.json"),
|
|
648
|
-
JSON.stringify({
|
|
649
|
-
environments: [
|
|
650
|
-
{
|
|
651
|
-
name: projectName,
|
|
652
|
-
packages: [
|
|
653
|
-
{ name: "good", location: good },
|
|
654
|
-
{ name: ".", location: good },
|
|
655
|
-
],
|
|
656
|
-
connections: [],
|
|
657
|
-
},
|
|
658
|
-
],
|
|
659
|
-
}),
|
|
660
|
-
);
|
|
661
|
-
|
|
662
|
-
const newEnvironmentStore = new EnvironmentStore(serverRootPath);
|
|
663
|
-
await newEnvironmentStore.finishedInitialization;
|
|
664
|
-
|
|
665
|
-
// Boot pre-validates the names and skips the environment, so reach the
|
|
666
|
-
// lazy path, which passes the config's package names through unchecked.
|
|
667
|
-
await newEnvironmentStore.getEnvironment(projectName).catch(() => {});
|
|
668
|
-
|
|
669
|
-
const envDir = path.join(serverRootPath, "publisher_data", projectName);
|
|
670
|
-
// The environment directory and the healthy sibling must both survive.
|
|
671
|
-
expect(existsSync(envDir)).toBe(true);
|
|
672
|
-
expect(existsSync(path.join(envDir, "good"))).toBe(true);
|
|
673
|
-
});
|
|
674
|
-
|
|
675
|
-
it("should not report an environment that is serving even if its database sync fails", async () => {
|
|
676
|
-
// An environment reaches this.environments before addEnvironmentToDatabase
|
|
677
|
-
// runs, so a throw from that tail is caught by the same handler that
|
|
678
|
-
// records load failures. The environment is live and listed, so reporting
|
|
679
|
-
// it as a load failure would make the status contradict itself.
|
|
680
|
-
const projectPath = path.join(serverRootPath, projectName);
|
|
681
|
-
mkdirSync(projectPath, { recursive: true });
|
|
682
|
-
writeFileSync(
|
|
683
|
-
path.join(projectPath, "publisher.json"),
|
|
684
|
-
JSON.stringify({ name: projectName }),
|
|
685
|
-
);
|
|
686
|
-
writeFileSync(
|
|
687
|
-
path.join(serverRootPath, "publisher.config.json"),
|
|
688
|
-
JSON.stringify({
|
|
689
|
-
frozenConfig: false,
|
|
690
|
-
environments: [
|
|
691
|
-
{
|
|
692
|
-
name: projectName,
|
|
693
|
-
packages: [{ name: projectName, location: projectPath }],
|
|
694
|
-
connections: [],
|
|
695
|
-
},
|
|
696
|
-
],
|
|
697
|
-
}),
|
|
698
|
-
);
|
|
699
|
-
|
|
700
|
-
// Stub the prototype before constructing: the constructor starts
|
|
701
|
-
// initialize() immediately, so stubbing the instance would race it.
|
|
702
|
-
sandbox
|
|
703
|
-
.stub(EnvironmentStore.prototype, "addEnvironmentToDatabase")
|
|
704
|
-
.rejects(new Error("database write failed"));
|
|
705
|
-
|
|
706
|
-
const newEnvironmentStore = new EnvironmentStore(serverRootPath);
|
|
707
|
-
await newEnvironmentStore.finishedInitialization;
|
|
708
|
-
|
|
709
|
-
const status = await newEnvironmentStore.getStatus();
|
|
710
|
-
expect(status.operationalState).toBe("serving");
|
|
711
|
-
// Still serving it, so it must not also be reported as not loaded.
|
|
712
|
-
expect(status.environments.map((e) => e.name)).toEqual([projectName]);
|
|
713
|
-
expect(status.loadErrors).toBeUndefined();
|
|
714
|
-
});
|
|
715
|
-
|
|
716
|
-
it("should report an environment that failed to restore from the database", async () => {
|
|
717
|
-
// The database-restore branch, not the config branch: initialize() takes
|
|
718
|
-
// this one whenever INITIALIZE_STORAGE is not "true" and the database
|
|
719
|
-
// already holds environments, which is every restart of a server with a
|
|
720
|
-
// persisted publisher.db. It has its own catch, so a failure recorded only
|
|
721
|
-
// on the config path would be dropped on the path production actually
|
|
722
|
-
// takes.
|
|
723
|
-
const envName = "restored-env";
|
|
724
|
-
const envPath = path.join(serverRootPath, envName);
|
|
725
|
-
|
|
726
|
-
// Exists, so the "files missing" branch is skipped, but is a file rather
|
|
727
|
-
// than a directory, so Environment.create throws. Stands in for any stored
|
|
728
|
-
// path that goes bad between restarts.
|
|
729
|
-
writeFileSync(envPath, "not a directory");
|
|
730
|
-
|
|
731
|
-
writeFileSync(
|
|
732
|
-
path.join(serverRootPath, "publisher.config.json"),
|
|
733
|
-
JSON.stringify({
|
|
734
|
-
frozenConfig: false,
|
|
735
|
-
environments: [
|
|
736
|
-
{
|
|
737
|
-
name: envName,
|
|
738
|
-
packages: [{ name: envName, location: envPath }],
|
|
739
|
-
connections: [],
|
|
740
|
-
},
|
|
741
|
-
],
|
|
742
|
-
}),
|
|
743
|
-
);
|
|
744
|
-
|
|
745
|
-
mockDbEnvironments = [
|
|
746
|
-
{
|
|
747
|
-
id: "restored-env-id",
|
|
748
|
-
name: envName,
|
|
749
|
-
path: envPath,
|
|
750
|
-
metadata: {},
|
|
751
|
-
},
|
|
752
|
-
];
|
|
753
|
-
|
|
754
|
-
const newEnvironmentStore = new EnvironmentStore(serverRootPath);
|
|
755
|
-
await newEnvironmentStore.finishedInitialization;
|
|
756
|
-
|
|
757
|
-
const status = await newEnvironmentStore.getStatus();
|
|
758
|
-
expect(status.operationalState).toBe("serving");
|
|
759
|
-
expect(status.environments.map((e) => e.name)).toEqual([]);
|
|
760
|
-
expect(status.loadErrors).toHaveLength(1);
|
|
761
|
-
expect(status.loadErrors?.[0]?.environment).toBe(envName);
|
|
762
|
-
expect(status.loadErrors?.[0]?.message).toBeTruthy();
|
|
763
|
-
});
|
|
764
|
-
|
|
765
|
-
it("should report a package that failed to load while its siblings serve", async () => {
|
|
766
|
-
const goodPackageName = "good-package";
|
|
767
|
-
const badPackageName = "bad-package";
|
|
768
|
-
const goodPackagePath = path.join(serverRootPath, goodPackageName);
|
|
769
|
-
const badPackagePath = path.join(serverRootPath, badPackageName);
|
|
770
|
-
|
|
771
|
-
mkdirSync(goodPackagePath, { recursive: true });
|
|
772
|
-
writeFileSync(
|
|
773
|
-
path.join(goodPackagePath, "publisher.json"),
|
|
774
|
-
JSON.stringify({ name: goodPackageName }),
|
|
775
|
-
);
|
|
776
|
-
// The directory exists, so the environment mounts; the package inside it
|
|
777
|
-
// has no manifest, so only that package fails. This is the second, and
|
|
778
|
-
// quieter, failure mode: the environment is present and serving, with a
|
|
779
|
-
// package missing from it.
|
|
780
|
-
mkdirSync(badPackagePath, { recursive: true });
|
|
781
|
-
|
|
782
|
-
writeFileSync(
|
|
783
|
-
path.join(serverRootPath, "publisher.config.json"),
|
|
784
|
-
JSON.stringify({
|
|
785
|
-
frozenConfig: false,
|
|
786
|
-
environments: [
|
|
787
|
-
{
|
|
788
|
-
name: projectName,
|
|
789
|
-
packages: [
|
|
790
|
-
{ name: goodPackageName, location: goodPackagePath },
|
|
791
|
-
{ name: badPackageName, location: badPackagePath },
|
|
792
|
-
],
|
|
793
|
-
connections: [],
|
|
794
|
-
},
|
|
795
|
-
],
|
|
796
|
-
}),
|
|
797
|
-
);
|
|
798
|
-
|
|
799
|
-
const newEnvironmentStore = new EnvironmentStore(serverRootPath);
|
|
800
|
-
await newEnvironmentStore.finishedInitialization;
|
|
801
|
-
|
|
802
|
-
const environment = await newEnvironmentStore.getEnvironment(projectName);
|
|
803
|
-
const packages = await environment.listPackages();
|
|
804
|
-
expect(packages.map((p) => p.name)).toEqual([goodPackageName]);
|
|
805
|
-
|
|
806
|
-
const status = await newEnvironmentStore.getStatus();
|
|
807
|
-
expect(status.operationalState).toBe("serving");
|
|
808
|
-
expect(status.loadErrors).toHaveLength(1);
|
|
809
|
-
expect(status.loadErrors?.[0]?.environment).toBe(projectName);
|
|
810
|
-
expect(status.loadErrors?.[0]?.package).toBe(badPackageName);
|
|
811
|
-
expect(status.loadErrors?.[0]?.message).toBeTruthy();
|
|
812
|
-
});
|
|
813
|
-
|
|
814
|
-
it("should stop reporting a failed package once it is deleted", async () => {
|
|
815
|
-
const goodPackageName = "good-package";
|
|
816
|
-
const badPackageName = "bad-package";
|
|
817
|
-
const goodPackagePath = path.join(serverRootPath, goodPackageName);
|
|
818
|
-
const badPackagePath = path.join(serverRootPath, badPackageName);
|
|
819
|
-
|
|
820
|
-
mkdirSync(goodPackagePath, { recursive: true });
|
|
821
|
-
writeFileSync(
|
|
822
|
-
path.join(goodPackagePath, "publisher.json"),
|
|
823
|
-
JSON.stringify({ name: goodPackageName }),
|
|
824
|
-
);
|
|
825
|
-
mkdirSync(badPackagePath, { recursive: true });
|
|
826
|
-
|
|
827
|
-
writeFileSync(
|
|
828
|
-
path.join(serverRootPath, "publisher.config.json"),
|
|
829
|
-
JSON.stringify({
|
|
830
|
-
frozenConfig: false,
|
|
831
|
-
environments: [
|
|
832
|
-
{
|
|
833
|
-
name: projectName,
|
|
834
|
-
packages: [
|
|
835
|
-
{ name: goodPackageName, location: goodPackagePath },
|
|
836
|
-
{ name: badPackageName, location: badPackagePath },
|
|
837
|
-
],
|
|
838
|
-
connections: [],
|
|
839
|
-
},
|
|
840
|
-
],
|
|
841
|
-
}),
|
|
842
|
-
);
|
|
843
|
-
|
|
844
|
-
const newEnvironmentStore = new EnvironmentStore(serverRootPath);
|
|
845
|
-
await newEnvironmentStore.finishedInitialization;
|
|
846
|
-
|
|
847
|
-
const environment = await newEnvironmentStore.getEnvironment(projectName);
|
|
848
|
-
expect((await newEnvironmentStore.getStatus()).loadErrors).toHaveLength(
|
|
849
|
-
1,
|
|
850
|
-
);
|
|
851
|
-
|
|
852
|
-
// A package that failed to load was evicted from `packages`, so the
|
|
853
|
-
// delete takes deletePackage's early return. The failure entry has to be
|
|
854
|
-
// cleared anyway: the caller goes on to drop the package's config row,
|
|
855
|
-
// and a loadError naming a package that is no longer configured sends
|
|
856
|
-
// whoever reads /status hunting for something that isn't there.
|
|
857
|
-
await environment.deletePackage(badPackageName);
|
|
858
|
-
|
|
859
|
-
const status = await newEnvironmentStore.getStatus();
|
|
860
|
-
expect(status.operationalState).toBe("serving");
|
|
861
|
-
expect(status.loadErrors).toBeUndefined();
|
|
862
|
-
expect((await environment.listPackages()).map((p) => p.name)).toEqual([
|
|
863
|
-
goodPackageName,
|
|
864
|
-
]);
|
|
865
|
-
});
|
|
866
|
-
|
|
867
|
-
it("should stop reporting a failed package once it loads", async () => {
|
|
868
|
-
const badPackageName = "bad-package";
|
|
869
|
-
const badPackagePath = path.join(serverRootPath, badPackageName);
|
|
870
|
-
|
|
871
|
-
// No manifest, so it fails to load and lands in loadErrors.
|
|
872
|
-
mkdirSync(badPackagePath, { recursive: true });
|
|
873
|
-
|
|
874
|
-
writeFileSync(
|
|
875
|
-
path.join(serverRootPath, "publisher.config.json"),
|
|
876
|
-
JSON.stringify({
|
|
877
|
-
frozenConfig: false,
|
|
878
|
-
environments: [
|
|
879
|
-
{
|
|
880
|
-
name: projectName,
|
|
881
|
-
packages: [
|
|
882
|
-
{ name: badPackageName, location: badPackagePath },
|
|
883
|
-
],
|
|
884
|
-
connections: [],
|
|
885
|
-
},
|
|
886
|
-
],
|
|
887
|
-
}),
|
|
888
|
-
);
|
|
889
|
-
|
|
890
|
-
const newEnvironmentStore = new EnvironmentStore(serverRootPath);
|
|
891
|
-
await newEnvironmentStore.finishedInitialization;
|
|
892
|
-
|
|
893
|
-
const environment = await newEnvironmentStore.getEnvironment(projectName);
|
|
894
|
-
expect((await newEnvironmentStore.getStatus()).loadErrors).toHaveLength(
|
|
895
|
-
1,
|
|
896
|
-
);
|
|
897
|
-
|
|
898
|
-
// Fix the package where the environment actually reads it, then re-add
|
|
899
|
-
// it. A package that is serving must not still be reported as failed.
|
|
900
|
-
writeFileSync(
|
|
901
|
-
path.join(
|
|
902
|
-
environment.metadata.location as string,
|
|
903
|
-
badPackageName,
|
|
904
|
-
"publisher.json",
|
|
905
|
-
),
|
|
906
|
-
JSON.stringify({ name: badPackageName }),
|
|
907
|
-
);
|
|
908
|
-
await environment.addPackage(badPackageName);
|
|
909
|
-
|
|
910
|
-
const status = await newEnvironmentStore.getStatus();
|
|
911
|
-
expect(status.operationalState).toBe("serving");
|
|
912
|
-
expect(status.loadErrors).toBeUndefined();
|
|
913
|
-
expect((await environment.listPackages()).map((p) => p.name)).toEqual([
|
|
914
|
-
badPackageName,
|
|
915
|
-
]);
|
|
916
|
-
});
|
|
917
|
-
|
|
918
|
-
it("should handle project updates", async () => {
|
|
919
|
-
// Create a project directory
|
|
920
|
-
const projectPath = path.join(serverRootPath, projectName);
|
|
921
|
-
mkdirSync(projectPath, { recursive: true });
|
|
922
|
-
// Create publisher.json manifest file
|
|
923
|
-
writeFileSync(
|
|
924
|
-
path.join(projectPath, "publisher.json"),
|
|
925
|
-
JSON.stringify({
|
|
926
|
-
name: projectName,
|
|
927
|
-
description: "Test package",
|
|
928
|
-
}),
|
|
929
|
-
);
|
|
930
|
-
// Create publisher config
|
|
931
|
-
const publisherConfigPath = path.join(
|
|
932
|
-
serverRootPath,
|
|
933
|
-
"publisher.config.json",
|
|
934
|
-
);
|
|
935
|
-
writeFileSync(
|
|
936
|
-
publisherConfigPath,
|
|
937
|
-
JSON.stringify({
|
|
938
|
-
frozenConfig: false,
|
|
939
|
-
environments: [
|
|
940
|
-
{
|
|
941
|
-
name: projectName,
|
|
942
|
-
packages: [
|
|
943
|
-
{
|
|
944
|
-
name: projectName,
|
|
945
|
-
location: projectPath,
|
|
946
|
-
},
|
|
947
|
-
],
|
|
948
|
-
},
|
|
949
|
-
],
|
|
950
|
-
}),
|
|
951
|
-
);
|
|
952
|
-
|
|
953
|
-
await environmentStore.finishedInitialization;
|
|
954
|
-
|
|
955
|
-
// Get the project
|
|
956
|
-
const project = await environmentStore.getEnvironment(projectName);
|
|
957
|
-
|
|
958
|
-
// Update the project
|
|
959
|
-
await project.update({
|
|
960
|
-
name: projectName,
|
|
961
|
-
readme: "Updated README content",
|
|
962
|
-
});
|
|
963
|
-
|
|
964
|
-
const readmePath = path.join(
|
|
965
|
-
serverRootPath,
|
|
966
|
-
"publisher_data",
|
|
967
|
-
projectName,
|
|
968
|
-
"README.md",
|
|
969
|
-
);
|
|
970
|
-
|
|
971
|
-
expect(existsSync(readmePath)).toBe(true);
|
|
972
|
-
const readmeContent = readFileSync(readmePath, "utf-8");
|
|
973
|
-
expect(readmeContent).toBe("Updated README content");
|
|
974
|
-
});
|
|
975
|
-
|
|
976
|
-
it(
|
|
977
|
-
"should handle project reload",
|
|
978
|
-
async () => {
|
|
979
|
-
// Create a project directory
|
|
980
|
-
const projectPath = path.join(serverRootPath, projectName);
|
|
981
|
-
mkdirSync(projectPath, { recursive: true });
|
|
982
|
-
// Create publisher.json manifest file
|
|
983
|
-
writeFileSync(
|
|
984
|
-
path.join(projectPath, "publisher.json"),
|
|
985
|
-
JSON.stringify({
|
|
986
|
-
name: projectName,
|
|
987
|
-
description: "Test package",
|
|
988
|
-
}),
|
|
989
|
-
);
|
|
990
|
-
|
|
991
|
-
// Create publisher config
|
|
992
|
-
const publisherConfigPath = path.join(
|
|
993
|
-
serverRootPath,
|
|
994
|
-
"publisher.config.json",
|
|
995
|
-
);
|
|
996
|
-
writeFileSync(
|
|
997
|
-
publisherConfigPath,
|
|
998
|
-
JSON.stringify({
|
|
999
|
-
environments: [
|
|
1000
|
-
{
|
|
1001
|
-
name: projectName,
|
|
1002
|
-
packages: [
|
|
1003
|
-
{
|
|
1004
|
-
name: projectName,
|
|
1005
|
-
location: projectPath,
|
|
1006
|
-
},
|
|
1007
|
-
],
|
|
1008
|
-
},
|
|
1009
|
-
],
|
|
1010
|
-
}),
|
|
1011
|
-
);
|
|
1012
|
-
|
|
1013
|
-
// Get the project
|
|
1014
|
-
const project1 = await environmentStore.getEnvironment(projectName);
|
|
1015
|
-
|
|
1016
|
-
// Get the project again with reload=true
|
|
1017
|
-
const project2 = await environmentStore.getEnvironment(
|
|
1018
|
-
projectName,
|
|
1019
|
-
true,
|
|
1020
|
-
);
|
|
1021
|
-
|
|
1022
|
-
expect(project1).toBeInstanceOf(Environment);
|
|
1023
|
-
expect(project2).toBeInstanceOf(Environment);
|
|
1024
|
-
expect(project1.metadata.name).toBe(project2.metadata.name as string);
|
|
1025
|
-
},
|
|
1026
|
-
{ timeout: 30000 },
|
|
1027
|
-
);
|
|
1028
|
-
|
|
1029
|
-
it("isolates a bad package location to that package, keeping the environment", async () => {
|
|
1030
|
-
// A package whose location does not exist used to abort the whole
|
|
1031
|
-
// environment. It now behaves like a bad manifest: the package is
|
|
1032
|
-
// dropped and reported per-package, and the environment still loads.
|
|
1033
|
-
const publisherConfigPath = path.join(
|
|
1034
|
-
serverRootPath,
|
|
1035
|
-
"publisher.config.json",
|
|
1036
|
-
);
|
|
1037
|
-
writeFileSync(
|
|
1038
|
-
publisherConfigPath,
|
|
1039
|
-
JSON.stringify({
|
|
1040
|
-
environments: [
|
|
1041
|
-
{
|
|
1042
|
-
name: projectName,
|
|
1043
|
-
packages: [
|
|
1044
|
-
{
|
|
1045
|
-
name: projectName,
|
|
1046
|
-
location: "/non/existent/path",
|
|
1047
|
-
},
|
|
1048
|
-
],
|
|
1049
|
-
},
|
|
1050
|
-
],
|
|
1051
|
-
}),
|
|
1052
|
-
);
|
|
1053
|
-
|
|
1054
|
-
const newEnvironmentStore = new EnvironmentStore(serverRootPath);
|
|
1055
|
-
await newEnvironmentStore.finishedInitialization;
|
|
1056
|
-
|
|
1057
|
-
// The environment loaded rather than being skipped.
|
|
1058
|
-
const environment = await newEnvironmentStore.getEnvironment(projectName);
|
|
1059
|
-
expect(environment.metadata.name).toBe(projectName);
|
|
1060
|
-
|
|
1061
|
-
// And the failure is reported at the package level, not the env level.
|
|
1062
|
-
const status = await newEnvironmentStore.getStatus();
|
|
1063
|
-
expect(status.operationalState).toBe("serving");
|
|
1064
|
-
expect(status.loadErrors).toHaveLength(1);
|
|
1065
|
-
expect(status.loadErrors?.[0]?.environment).toBe(projectName);
|
|
1066
|
-
expect(status.loadErrors?.[0]?.package).toBe(projectName);
|
|
1067
|
-
expect(status.loadErrors?.[0]?.message).toBeTruthy();
|
|
1068
|
-
});
|
|
1069
|
-
|
|
1070
|
-
it("should handle invalid publisher config", async () => {
|
|
1071
|
-
// Create invalid publisher config
|
|
1072
|
-
const publisherConfigPath = path.join(
|
|
1073
|
-
serverRootPath,
|
|
1074
|
-
"publisher.config.json",
|
|
1075
|
-
);
|
|
1076
|
-
writeFileSync(publisherConfigPath, "invalid json");
|
|
1077
|
-
|
|
1078
|
-
// Create a new project store that will read the invalid config
|
|
1079
|
-
const newEnvironmentStore = new EnvironmentStore(serverRootPath);
|
|
1080
|
-
|
|
1081
|
-
// Test that the project store handles invalid JSON gracefully by falling back to empty config
|
|
1082
|
-
await newEnvironmentStore.finishedInitialization;
|
|
1083
|
-
const projects = await newEnvironmentStore.listEnvironments();
|
|
1084
|
-
expect(projects).toEqual([]);
|
|
1085
|
-
});
|
|
1086
|
-
|
|
1087
|
-
it("should handle invalid field names in publisher config without crashing", async () => {
|
|
1088
|
-
// Create publisher config with invalid field names (ramen instead of name, papa instead of packages)
|
|
1089
|
-
const publisherConfigPath = path.join(
|
|
1090
|
-
serverRootPath,
|
|
1091
|
-
"publisher.config.json",
|
|
1092
|
-
);
|
|
1093
|
-
writeFileSync(
|
|
1094
|
-
publisherConfigPath,
|
|
1095
|
-
JSON.stringify({
|
|
1096
|
-
frozenConfig: false,
|
|
1097
|
-
environments: [
|
|
1098
|
-
{
|
|
1099
|
-
invalidKey1: "malloy-samples", // Invalid: should be "name"
|
|
1100
|
-
invalidKey2: [
|
|
1101
|
-
// Invalid: should be "packages"
|
|
1102
|
-
{
|
|
1103
|
-
name: "ecommerce",
|
|
1104
|
-
location:
|
|
1105
|
-
"https://github.com/credibledata/malloy-samples/tree/main/ecommerce",
|
|
1106
|
-
},
|
|
1107
|
-
],
|
|
1108
|
-
connections: [
|
|
1109
|
-
{
|
|
1110
|
-
name: "bigquery",
|
|
1111
|
-
type: "bigquery",
|
|
1112
|
-
},
|
|
1113
|
-
],
|
|
1114
|
-
},
|
|
1115
|
-
],
|
|
1116
|
-
}),
|
|
1117
|
-
);
|
|
1118
|
-
|
|
1119
|
-
// Create a new project store that will read the invalid config
|
|
1120
|
-
const newEnvironmentStore = new EnvironmentStore(serverRootPath);
|
|
1121
|
-
|
|
1122
|
-
// Test that the project store handles invalid fields gracefully without crashing
|
|
1123
|
-
await newEnvironmentStore.finishedInitialization;
|
|
1124
|
-
const projects = await newEnvironmentStore.listEnvironments();
|
|
1125
|
-
|
|
1126
|
-
// Should not crash and should return empty array since invalid projects are filtered out
|
|
1127
|
-
expect(projects).toEqual([]);
|
|
1128
|
-
});
|
|
1129
|
-
|
|
1130
|
-
it("should filter out invalid projects from publisher config", async () => {
|
|
1131
|
-
// Create publisher config with mix of valid and invalid projects
|
|
1132
|
-
const publisherConfigPath = path.join(
|
|
1133
|
-
serverRootPath,
|
|
1134
|
-
"publisher.config.json",
|
|
1135
|
-
);
|
|
1136
|
-
const validProjectPath = path.join(serverRootPath, "valid-project");
|
|
1137
|
-
mkdirSync(validProjectPath, { recursive: true });
|
|
1138
|
-
writeFileSync(
|
|
1139
|
-
path.join(validProjectPath, "publisher.json"),
|
|
1140
|
-
JSON.stringify({
|
|
1141
|
-
name: "valid-project",
|
|
1142
|
-
description: "Valid project",
|
|
1143
|
-
}),
|
|
1144
|
-
);
|
|
1145
|
-
|
|
1146
|
-
writeFileSync(
|
|
1147
|
-
publisherConfigPath,
|
|
1148
|
-
JSON.stringify({
|
|
1149
|
-
frozenConfig: false,
|
|
1150
|
-
environments: [
|
|
1151
|
-
{
|
|
1152
|
-
// Invalid project: missing "name" field
|
|
1153
|
-
packages: [
|
|
1154
|
-
{
|
|
1155
|
-
name: "package1",
|
|
1156
|
-
location: "./invalid-project",
|
|
1157
|
-
},
|
|
1158
|
-
],
|
|
1159
|
-
},
|
|
1160
|
-
{
|
|
1161
|
-
// Invalid project: "invalidKey1" instead of "name"
|
|
1162
|
-
invalidKey1: "invalid-project-2",
|
|
1163
|
-
packages: [
|
|
1164
|
-
{
|
|
1165
|
-
name: "package2",
|
|
1166
|
-
location: "./invalid-project-2",
|
|
1167
|
-
},
|
|
1168
|
-
],
|
|
1169
|
-
},
|
|
1170
|
-
{
|
|
1171
|
-
// Invalid project: "invalidKey2" instead of "packages"
|
|
1172
|
-
name: "invalid-project-3",
|
|
1173
|
-
invalidKey2: [
|
|
1174
|
-
{
|
|
1175
|
-
name: "package3",
|
|
1176
|
-
location: "./invalid-project-3",
|
|
1177
|
-
},
|
|
1178
|
-
],
|
|
1179
|
-
},
|
|
1180
|
-
{
|
|
1181
|
-
// Valid project
|
|
1182
|
-
name: "valid-project",
|
|
1183
|
-
packages: [
|
|
1184
|
-
{
|
|
1185
|
-
name: "valid-project",
|
|
1186
|
-
location: "./valid-project",
|
|
1187
|
-
},
|
|
1188
|
-
],
|
|
1189
|
-
},
|
|
1190
|
-
],
|
|
1191
|
-
}),
|
|
1192
|
-
);
|
|
1193
|
-
|
|
1194
|
-
// Create a new project store that will read the config
|
|
1195
|
-
const newEnvironmentStore = new EnvironmentStore(serverRootPath);
|
|
1196
|
-
|
|
1197
|
-
// Test that invalid projects are filtered out
|
|
1198
|
-
await newEnvironmentStore.finishedInitialization;
|
|
1199
|
-
const projects = await newEnvironmentStore.listEnvironments();
|
|
1200
|
-
|
|
1201
|
-
// Should only have the valid project
|
|
1202
|
-
expect(projects.length).toBe(1);
|
|
1203
|
-
expect(projects[0].name).toBe("valid-project");
|
|
1204
|
-
});
|
|
1205
|
-
|
|
1206
|
-
it(
|
|
1207
|
-
"should handle concurrent project access",
|
|
1208
|
-
async () => {
|
|
1209
|
-
// Create a project directory
|
|
1210
|
-
const projectPath = path.join(serverRootPath, projectName);
|
|
1211
|
-
mkdirSync(projectPath, { recursive: true });
|
|
1212
|
-
// Create publisher.json manifest file
|
|
1213
|
-
writeFileSync(
|
|
1214
|
-
path.join(projectPath, "publisher.json"),
|
|
1215
|
-
JSON.stringify({
|
|
1216
|
-
name: projectName,
|
|
1217
|
-
description: "Test package",
|
|
1218
|
-
}),
|
|
1219
|
-
);
|
|
1220
|
-
|
|
1221
|
-
const publisherConfigPath = path.join(
|
|
1222
|
-
serverRootPath,
|
|
1223
|
-
"publisher.config.json",
|
|
1224
|
-
);
|
|
1225
|
-
writeFileSync(
|
|
1226
|
-
publisherConfigPath,
|
|
1227
|
-
JSON.stringify({
|
|
1228
|
-
frozenConfig: false,
|
|
1229
|
-
environments: [
|
|
1230
|
-
{
|
|
1231
|
-
name: projectName,
|
|
1232
|
-
packages: [
|
|
1233
|
-
{
|
|
1234
|
-
name: projectName,
|
|
1235
|
-
location: projectPath,
|
|
1236
|
-
},
|
|
1237
|
-
],
|
|
1238
|
-
connections: [
|
|
1239
|
-
{
|
|
1240
|
-
name: "testConnection",
|
|
1241
|
-
type: "postgres",
|
|
1242
|
-
},
|
|
1243
|
-
],
|
|
1244
|
-
},
|
|
1245
|
-
],
|
|
1246
|
-
}),
|
|
1247
|
-
);
|
|
1248
|
-
|
|
1249
|
-
await environmentStore.finishedInitialization;
|
|
1250
|
-
|
|
1251
|
-
// Test concurrent access to the same project
|
|
1252
|
-
const promises = Array.from({ length: 5 }, () =>
|
|
1253
|
-
environmentStore.getEnvironment(projectName),
|
|
1254
|
-
);
|
|
1255
|
-
|
|
1256
|
-
const projects = await Promise.all(promises);
|
|
1257
|
-
|
|
1258
|
-
expect(projects).toHaveLength(5);
|
|
1259
|
-
projects.forEach((project) => {
|
|
1260
|
-
expect(project).toBeInstanceOf(Environment);
|
|
1261
|
-
expect(project.metadata.name).toBe(projectName);
|
|
1262
|
-
});
|
|
1263
|
-
},
|
|
1264
|
-
{ timeout: 30000 },
|
|
1265
|
-
);
|
|
1266
|
-
});
|
|
1267
|
-
|
|
1268
|
-
describe("Project Service Error Recovery", () => {
|
|
1269
|
-
let sandbox: sinon.SinonSandbox;
|
|
1270
|
-
let environmentStore: EnvironmentStore;
|
|
1271
|
-
const serverRootPath = path.join(
|
|
1272
|
-
TEMP_DIR_PATH,
|
|
1273
|
-
"pathways-worker-publisher-error-recovery-test",
|
|
1274
|
-
);
|
|
1275
|
-
const projectName = "organizationName-projectName-error-recovery";
|
|
1276
|
-
const testConnections: Connection[] = [
|
|
1277
|
-
{
|
|
1278
|
-
name: "testConnection",
|
|
1279
|
-
type: "postgres",
|
|
1280
|
-
postgresConnection: {
|
|
1281
|
-
host: "host",
|
|
1282
|
-
port: 1234,
|
|
1283
|
-
databaseName: "databaseName",
|
|
1284
|
-
userName: "userName",
|
|
1285
|
-
password: "password",
|
|
1286
|
-
},
|
|
1287
|
-
},
|
|
1288
|
-
];
|
|
1289
|
-
|
|
1290
|
-
beforeEach(async () => {
|
|
1291
|
-
sandbox = sinon.createSandbox();
|
|
1292
|
-
mkdirSync(serverRootPath, { recursive: true });
|
|
1293
|
-
|
|
1294
|
-
// Mock the configuration to prevent initialization errors
|
|
1295
|
-
mock(isPublisherConfigFrozen).mockReturnValue(false);
|
|
1296
|
-
mock.module("../config", () => ({
|
|
1297
|
-
isPublisherConfigFrozen: () => false,
|
|
1298
|
-
}));
|
|
1299
|
-
|
|
1300
|
-
// Create project store after mocking
|
|
1301
|
-
environmentStore = new EnvironmentStore(serverRootPath);
|
|
1302
|
-
});
|
|
1303
|
-
|
|
1304
|
-
afterEach(async () => {
|
|
1305
|
-
sandbox.restore();
|
|
1306
|
-
if (existsSync(serverRootPath)) {
|
|
1307
|
-
rmSync(serverRootPath, { recursive: true, force: true });
|
|
1308
|
-
}
|
|
1309
|
-
});
|
|
1310
|
-
|
|
1311
|
-
describe("Project Loading Error Recovery", () => {
|
|
1312
|
-
it("keeps the environment when a package directory is missing, reporting per-package", async () => {
|
|
1313
|
-
// Same isolation as above, for a relative-to-serverRoot missing dir:
|
|
1314
|
-
// the environment loads and the missing package is a per-package
|
|
1315
|
-
// loadError rather than an environment-level skip.
|
|
1316
|
-
const publisherConfigPath = path.join(
|
|
1317
|
-
serverRootPath,
|
|
1318
|
-
"publisher.config.json",
|
|
1319
|
-
);
|
|
1320
|
-
writeFileSync(
|
|
1321
|
-
publisherConfigPath,
|
|
1322
|
-
JSON.stringify({
|
|
1323
|
-
environments: [
|
|
1324
|
-
{
|
|
1325
|
-
name: projectName,
|
|
1326
|
-
packages: [
|
|
1327
|
-
{
|
|
1328
|
-
name: projectName,
|
|
1329
|
-
location: path.join(
|
|
1330
|
-
serverRootPath,
|
|
1331
|
-
"missing-project",
|
|
1332
|
-
),
|
|
1333
|
-
},
|
|
1334
|
-
],
|
|
1335
|
-
},
|
|
1336
|
-
],
|
|
1337
|
-
}),
|
|
1338
|
-
);
|
|
1339
|
-
|
|
1340
|
-
const newEnvironmentStore = new EnvironmentStore(serverRootPath);
|
|
1341
|
-
await newEnvironmentStore.finishedInitialization;
|
|
1342
|
-
|
|
1343
|
-
const environment =
|
|
1344
|
-
await newEnvironmentStore.getEnvironment(projectName);
|
|
1345
|
-
expect(environment.metadata.name).toBe(projectName);
|
|
1346
|
-
|
|
1347
|
-
const status = await newEnvironmentStore.getStatus();
|
|
1348
|
-
expect(status.operationalState).toBe("serving");
|
|
1349
|
-
expect(status.loadErrors).toHaveLength(1);
|
|
1350
|
-
expect(status.loadErrors?.[0]?.package).toBe(projectName);
|
|
1351
|
-
});
|
|
1352
|
-
|
|
1353
|
-
it(
|
|
1354
|
-
"should handle corrupted connection files",
|
|
1355
|
-
async () => {
|
|
1356
|
-
// Create a project directory
|
|
1357
|
-
const projectPath = path.join(serverRootPath, projectName);
|
|
1358
|
-
mkdirSync(projectPath, { recursive: true });
|
|
1359
|
-
// Create publisher.json manifest file
|
|
1360
|
-
writeFileSync(
|
|
1361
|
-
path.join(projectPath, "publisher.json"),
|
|
1362
|
-
JSON.stringify({
|
|
1363
|
-
name: projectName,
|
|
1364
|
-
description: "Test package",
|
|
1365
|
-
}),
|
|
1366
|
-
);
|
|
1367
|
-
|
|
1368
|
-
// Create corrupted connections file
|
|
1369
|
-
const connectionsPath = path.join(
|
|
1370
|
-
projectPath,
|
|
1371
|
-
"publisher.connections.json",
|
|
1372
|
-
);
|
|
1373
|
-
writeFileSync(connectionsPath, "invalid json");
|
|
1374
|
-
|
|
1375
|
-
// Create publisher config
|
|
1376
|
-
const publisherConfigPath = path.join(
|
|
1377
|
-
serverRootPath,
|
|
1378
|
-
"publisher.config.json",
|
|
1379
|
-
);
|
|
1380
|
-
writeFileSync(
|
|
1381
|
-
publisherConfigPath,
|
|
1382
|
-
JSON.stringify({
|
|
1383
|
-
environments: [
|
|
1384
|
-
{
|
|
1385
|
-
name: projectName,
|
|
1386
|
-
packages: [
|
|
1387
|
-
{
|
|
1388
|
-
name: projectName,
|
|
1389
|
-
location: projectPath,
|
|
1390
|
-
},
|
|
1391
|
-
],
|
|
1392
|
-
},
|
|
1393
|
-
],
|
|
1394
|
-
}),
|
|
1395
|
-
);
|
|
1396
|
-
|
|
1397
|
-
// Test that the project store handles corrupted connection files gracefully
|
|
1398
|
-
// (The current implementation loads the project even with corrupted connection files)
|
|
1399
|
-
const project = await environmentStore.getEnvironment(projectName);
|
|
1400
|
-
expect(project).toBeInstanceOf(Environment);
|
|
1401
|
-
expect(project.metadata.name).toBe(projectName);
|
|
1402
|
-
},
|
|
1403
|
-
{ timeout: 30000 },
|
|
1404
|
-
);
|
|
1405
|
-
});
|
|
1406
|
-
|
|
1407
|
-
describe("Project Store State Management", () => {
|
|
1408
|
-
it(
|
|
1409
|
-
"should maintain consistent state after errors",
|
|
1410
|
-
async () => {
|
|
1411
|
-
// Create a valid project first
|
|
1412
|
-
const projectPath = path.join(serverRootPath, projectName);
|
|
1413
|
-
mkdirSync(projectPath, { recursive: true });
|
|
1414
|
-
// Create publisher.json manifest file
|
|
1415
|
-
writeFileSync(
|
|
1416
|
-
path.join(projectPath, "publisher.json"),
|
|
1417
|
-
JSON.stringify({
|
|
1418
|
-
name: projectName,
|
|
1419
|
-
description: "Test package",
|
|
1420
|
-
}),
|
|
1421
|
-
);
|
|
1422
|
-
writeFileSync(
|
|
1423
|
-
path.join(projectPath, "publisher.connections.json"),
|
|
1424
|
-
JSON.stringify(testConnections),
|
|
1425
|
-
);
|
|
1426
|
-
|
|
1427
|
-
const publisherConfigPath = path.join(
|
|
1428
|
-
serverRootPath,
|
|
1429
|
-
"publisher.config.json",
|
|
1430
|
-
);
|
|
1431
|
-
writeFileSync(
|
|
1432
|
-
publisherConfigPath,
|
|
1433
|
-
JSON.stringify({
|
|
1434
|
-
environments: [
|
|
1435
|
-
{
|
|
1436
|
-
name: projectName,
|
|
1437
|
-
packages: [
|
|
1438
|
-
{
|
|
1439
|
-
name: projectName,
|
|
1440
|
-
location: projectPath,
|
|
1441
|
-
},
|
|
1442
|
-
],
|
|
1443
|
-
},
|
|
1444
|
-
],
|
|
1445
|
-
}),
|
|
1446
|
-
);
|
|
1447
|
-
|
|
1448
|
-
// Get the project successfully
|
|
1449
|
-
const project = await environmentStore.getEnvironment(projectName);
|
|
1450
|
-
expect(project).toBeInstanceOf(Environment);
|
|
1451
|
-
|
|
1452
|
-
// Try to get a non-existent project
|
|
1453
|
-
await expect(
|
|
1454
|
-
environmentStore.getEnvironment("non-existent"),
|
|
1455
|
-
).rejects.toThrow();
|
|
1456
|
-
|
|
1457
|
-
// Verify the original project is still accessible
|
|
1458
|
-
const projectAgain =
|
|
1459
|
-
await environmentStore.getEnvironment(projectName);
|
|
1460
|
-
expect(projectAgain).toBeInstanceOf(Environment);
|
|
1461
|
-
expect(projectAgain.metadata.name).toBe(projectName);
|
|
1462
|
-
},
|
|
1463
|
-
{ timeout: 30000 },
|
|
1464
|
-
);
|
|
1465
|
-
});
|
|
1466
|
-
});
|
|
1467
|
-
|
|
1468
|
-
const TRAVERSAL_NAMES: ReadonlyArray<readonly [string, string]> = [
|
|
1469
|
-
["leading traversal", "../etc"],
|
|
1470
|
-
["embedded traversal", "foo/../../bar"],
|
|
1471
|
-
["slash in name", "foo/bar"],
|
|
1472
|
-
["backslash in name", "foo\\bar"],
|
|
1473
|
-
["leading dot", ".staging"],
|
|
1474
|
-
["bare dot-dot", ".."],
|
|
1475
|
-
["bare dot", "."],
|
|
1476
|
-
["empty", ""],
|
|
1477
|
-
["NUL byte", "foo\0bar"],
|
|
1478
|
-
["oversized", "a".repeat(256)],
|
|
1479
|
-
["absolute", "/etc/passwd"],
|
|
1480
|
-
] as const;
|
|
1481
|
-
|
|
1482
|
-
describe("EnvironmentStore path-injection guards", () => {
|
|
1483
|
-
let environmentStore: EnvironmentStore;
|
|
1484
|
-
|
|
1485
|
-
beforeEach(async () => {
|
|
1486
|
-
if (existsSync(serverRootPath)) {
|
|
1487
|
-
rmSync(serverRootPath, { recursive: true, force: true });
|
|
1488
|
-
}
|
|
1489
|
-
mkdirSync(serverRootPath);
|
|
1490
|
-
mock(isPublisherConfigFrozen).mockReturnValue(false);
|
|
1491
|
-
mock.module("../config", () => ({
|
|
1492
|
-
isPublisherConfigFrozen: () => false,
|
|
1493
|
-
}));
|
|
1494
|
-
environmentStore = new EnvironmentStore(serverRootPath);
|
|
1495
|
-
await environmentStore.finishedInitialization;
|
|
1496
|
-
});
|
|
1497
|
-
|
|
1498
|
-
afterEach(() => {
|
|
1499
|
-
if (existsSync(serverRootPath)) {
|
|
1500
|
-
rmSync(serverRootPath, { recursive: true, force: true });
|
|
1501
|
-
}
|
|
1502
|
-
mkdirSync(serverRootPath);
|
|
1503
|
-
});
|
|
1504
|
-
|
|
1505
|
-
describe("addEnvironment", () => {
|
|
1506
|
-
it.each(TRAVERSAL_NAMES)(
|
|
1507
|
-
"rejects %s as environment.name (%p)",
|
|
1508
|
-
async (_label, name) => {
|
|
1509
|
-
await expect(
|
|
1510
|
-
environmentStore.addEnvironment({ name } as never, true),
|
|
1511
|
-
).rejects.toBeInstanceOf(BadRequestError);
|
|
1512
|
-
},
|
|
1513
|
-
);
|
|
1514
|
-
|
|
1515
|
-
it.each(TRAVERSAL_NAMES)(
|
|
1516
|
-
"rejects %s as packages[].name (%p)",
|
|
1517
|
-
async (_label, packageName) => {
|
|
1518
|
-
await expect(
|
|
1519
|
-
environmentStore.addEnvironment(
|
|
1520
|
-
{
|
|
1521
|
-
name: "ok-env",
|
|
1522
|
-
packages: [
|
|
1523
|
-
{
|
|
1524
|
-
name: packageName,
|
|
1525
|
-
location: "https://github.com/example/repo",
|
|
1526
|
-
},
|
|
1527
|
-
],
|
|
1528
|
-
} as never,
|
|
1529
|
-
true,
|
|
1530
|
-
),
|
|
1531
|
-
).rejects.toBeInstanceOf(BadRequestError);
|
|
1532
|
-
},
|
|
1533
|
-
);
|
|
1534
|
-
});
|
|
1535
|
-
|
|
1536
|
-
describe("updateEnvironment", () => {
|
|
1537
|
-
it.each(TRAVERSAL_NAMES)(
|
|
1538
|
-
"rejects %s as environment.name (%p)",
|
|
1539
|
-
async (_label, name) => {
|
|
1540
|
-
await expect(
|
|
1541
|
-
environmentStore.updateEnvironment({ name } as never),
|
|
1542
|
-
).rejects.toBeInstanceOf(BadRequestError);
|
|
1543
|
-
},
|
|
1544
|
-
);
|
|
1545
|
-
});
|
|
1546
|
-
|
|
1547
|
-
describe("deleteEnvironment", () => {
|
|
1548
|
-
it.each(TRAVERSAL_NAMES)(
|
|
1549
|
-
"rejects %s as environmentName (%p)",
|
|
1550
|
-
async (_label, name) => {
|
|
1551
|
-
await expect(
|
|
1552
|
-
environmentStore.deleteEnvironment(name),
|
|
1553
|
-
).rejects.toBeInstanceOf(BadRequestError);
|
|
1554
|
-
},
|
|
1555
|
-
);
|
|
1556
|
-
});
|
|
1557
|
-
|
|
1558
|
-
describe("getEnvironment", () => {
|
|
1559
|
-
it.each(TRAVERSAL_NAMES)(
|
|
1560
|
-
"rejects %s as environmentName (%p)",
|
|
1561
|
-
async (_label, name) => {
|
|
1562
|
-
await expect(
|
|
1563
|
-
environmentStore.getEnvironment(name),
|
|
1564
|
-
).rejects.toBeInstanceOf(BadRequestError);
|
|
1565
|
-
},
|
|
1566
|
-
);
|
|
1567
|
-
});
|
|
1568
|
-
});
|
|
1569
|
-
|
|
1570
|
-
describe("resolvePackageLocation", () => {
|
|
1571
|
-
// Built with `path`, not literals: the helper joins with the platform
|
|
1572
|
-
// separator and these specs run on windows-latest too (cross-platform-tests).
|
|
1573
|
-
const HOME = path.join(path.sep, "home", "tester");
|
|
1574
|
-
const CONFIG_DIR = path.join(path.sep, "etc", "publisher");
|
|
1575
|
-
|
|
1576
|
-
it("expands ~/ against the home directory", () => {
|
|
1577
|
-
expect(
|
|
1578
|
-
resolvePackageLocation("~/my-packages/sales", CONFIG_DIR, HOME),
|
|
1579
|
-
).toBe(path.join(HOME, "my-packages", "sales"));
|
|
1580
|
-
});
|
|
1581
|
-
|
|
1582
|
-
it("does not anchor an expanded ~/ path at the config dir", () => {
|
|
1583
|
-
// Regression: `~/` satisfies isLocalPath but not path.isAbsolute, so an
|
|
1584
|
-
// unexpanded tilde used to be joined onto the anchor, yielding
|
|
1585
|
-
// `<anchor>/~/my-packages/sales`.
|
|
1586
|
-
const resolved = resolvePackageLocation("~/x", CONFIG_DIR, HOME);
|
|
1587
|
-
expect(resolved).not.toContain("~");
|
|
1588
|
-
expect(resolved.startsWith(CONFIG_DIR)).toBe(false);
|
|
1589
|
-
});
|
|
1590
|
-
|
|
1591
|
-
it("returns an absolute location untouched", () => {
|
|
1592
|
-
const absolute = path.join(path.sep, "srv", "packages", "sales");
|
|
1593
|
-
expect(resolvePackageLocation(absolute, CONFIG_DIR, HOME)).toBe(absolute);
|
|
1594
|
-
});
|
|
1595
|
-
|
|
1596
|
-
it("anchors a relative location at the config dir, not the cwd", () => {
|
|
1597
|
-
expect(resolvePackageLocation("./sales", CONFIG_DIR, HOME)).toBe(
|
|
1598
|
-
path.join(CONFIG_DIR, "sales"),
|
|
1599
|
-
);
|
|
1600
|
-
});
|
|
1601
|
-
|
|
1602
|
-
it("anchors a ../ location at the config dir", () => {
|
|
1603
|
-
expect(
|
|
1604
|
-
resolvePackageLocation("../examples/storefront", CONFIG_DIR, HOME),
|
|
1605
|
-
).toBe(path.join(CONFIG_DIR, "..", "examples", "storefront"));
|
|
1606
|
-
});
|
|
1607
|
-
|
|
1608
|
-
it("throws when ~/ cannot be expanded because no home directory is set", () => {
|
|
1609
|
-
// An empty home must fail loudly: `path.join("", "x")` would otherwise
|
|
1610
|
-
// yield a relative "x" that silently anchors under the config dir.
|
|
1611
|
-
expect(() => resolvePackageLocation("~/x", CONFIG_DIR, "")).toThrow(
|
|
1612
|
-
/home directory is not set/,
|
|
1613
|
-
);
|
|
1614
|
-
});
|
|
1615
|
-
|
|
1616
|
-
it("never consults the home directory for non-tilde locations", () => {
|
|
1617
|
-
// The same empty home that makes a ~/ location throw must be irrelevant
|
|
1618
|
-
// to ./ and absolute locations; home is resolved lazily, tilde-only.
|
|
1619
|
-
expect(resolvePackageLocation("./sales", CONFIG_DIR, "")).toBe(
|
|
1620
|
-
path.join(CONFIG_DIR, "sales"),
|
|
1621
|
-
);
|
|
1622
|
-
const absolute = path.join(path.sep, "srv", "packages", "sales");
|
|
1623
|
-
expect(resolvePackageLocation(absolute, CONFIG_DIR, "")).toBe(absolute);
|
|
1624
|
-
});
|
|
1625
|
-
|
|
1626
|
-
it("expands only the POSIX ~/ prefix; bare ~ and ~user are not home paths", () => {
|
|
1627
|
-
// Neither form satisfies isLocalPath, so in practice they are rejected
|
|
1628
|
-
// upstream as non-local locations; pin here that the resolver itself
|
|
1629
|
-
// never treats them as home-relative either.
|
|
1630
|
-
expect(resolvePackageLocation("~", CONFIG_DIR, HOME)).toBe(
|
|
1631
|
-
path.join(CONFIG_DIR, "~"),
|
|
1632
|
-
);
|
|
1633
|
-
expect(resolvePackageLocation("~user/foo", CONFIG_DIR, HOME)).toBe(
|
|
1634
|
-
path.join(CONFIG_DIR, "~user", "foo"),
|
|
1635
|
-
);
|
|
1636
|
-
});
|
|
1637
|
-
});
|
|
1638
|
-
|
|
1639
|
-
describe("Environment.getServingPackageCount", () => {
|
|
1640
|
-
const envRoot = path.join(TEMP_DIR_PATH, "serving-count-env");
|
|
1641
|
-
|
|
1642
|
-
beforeEach(() => {
|
|
1643
|
-
rmSync(envRoot, { recursive: true, force: true });
|
|
1644
|
-
mkdirSync(envRoot, { recursive: true });
|
|
1645
|
-
});
|
|
1646
|
-
|
|
1647
|
-
afterEach(() => {
|
|
1648
|
-
rmSync(envRoot, { recursive: true, force: true });
|
|
1649
|
-
});
|
|
1650
|
-
|
|
1651
|
-
it("excludes a package that is registered SERVING but recorded as failed", async () => {
|
|
1652
|
-
// A mount-failed package is seeded SERVING at boot and only pruned by a
|
|
1653
|
-
// later side-effect load; if that prune is skipped (a transient DB or
|
|
1654
|
-
// memory-pressure error), packages= must still not count it, and it
|
|
1655
|
-
// must not overlap load_errors=. The serving count is registered-minus-
|
|
1656
|
-
// failed, so it holds regardless of whether the prune ran.
|
|
1657
|
-
//
|
|
1658
|
-
// Two good and one bad, not one-and-one: this catches both size-only
|
|
1659
|
-
// (would say 3) and a full inversion (would say 1), so the count really
|
|
1660
|
-
// is "registered minus failed".
|
|
1661
|
-
const env = await Environment.create("e", envRoot, []);
|
|
1662
|
-
env.setPackageStatus("good-1", PackageStatus.SERVING);
|
|
1663
|
-
env.setPackageStatus("good-2", PackageStatus.SERVING);
|
|
1664
|
-
env.setPackageStatus("bad", PackageStatus.SERVING);
|
|
1665
|
-
env.setPackageMountError("bad", "location does not exist");
|
|
1666
|
-
|
|
1667
|
-
expect(env.getServingPackageCount()).toBe(2);
|
|
1668
|
-
// Disjoint from the failure set, so packages= + load_errors= never
|
|
1669
|
-
// double-counts "bad".
|
|
1670
|
-
expect(env.getFailedPackages().has("bad")).toBe(true);
|
|
1671
|
-
});
|
|
1672
|
-
});
|
|
1673
|
-
|
|
1674
|
-
describe("GIT_CLONE_OPTIONS", () => {
|
|
1675
|
-
it("clones shallow and single-branch", () => {
|
|
1676
|
-
// The load-bearing content: packages serve the default branch's working
|
|
1677
|
-
// tree only, so history is pure download cost. simple-git serializes
|
|
1678
|
-
// this to `git clone --depth=1 --single-branch`.
|
|
1679
|
-
expect(GIT_CLONE_OPTIONS["--depth"]).toBe(1);
|
|
1680
|
-
expect(GIT_CLONE_OPTIONS).toHaveProperty("--single-branch", null);
|
|
1681
|
-
});
|
|
1682
|
-
});
|
|
1683
|
-
|
|
1684
|
-
describe("stripGitProgressNoise", () => {
|
|
1685
|
-
it("drops progress lines and keeps the error", () => {
|
|
1686
|
-
const message = [
|
|
1687
|
-
"Cloning into '/tmp/x'...",
|
|
1688
|
-
"remote: Counting objects: 45% (9/20)",
|
|
1689
|
-
"Receiving objects: 99% (990/1000)",
|
|
1690
|
-
"fatal: early EOF",
|
|
1691
|
-
"fatal: fetch-pack: invalid index-pack output",
|
|
1692
|
-
].join("\n");
|
|
1693
|
-
expect(stripGitProgressNoise(message)).toBe(
|
|
1694
|
-
"fatal: early EOF\nfatal: fetch-pack: invalid index-pack output",
|
|
1695
|
-
);
|
|
1696
|
-
});
|
|
1697
|
-
|
|
1698
|
-
it("handles carriage-return separated progress spew", () => {
|
|
1699
|
-
// git rewrites progress in place with \r, so the captured stderr is one
|
|
1700
|
-
// long line unless split on \r too.
|
|
1701
|
-
const message =
|
|
1702
|
-
"Receiving objects: 10% (1/10)\rReceiving objects: 50% (5/10)\rfatal: the remote end hung up unexpectedly";
|
|
1703
|
-
expect(stripGitProgressNoise(message)).toBe(
|
|
1704
|
-
"fatal: the remote end hung up unexpectedly",
|
|
1705
|
-
);
|
|
1706
|
-
});
|
|
1707
|
-
|
|
1708
|
-
it("returns the input when every line is progress noise", () => {
|
|
1709
|
-
const message = "Receiving objects: 10% (1/10)";
|
|
1710
|
-
expect(stripGitProgressNoise(message)).toBe(message);
|
|
1711
|
-
});
|
|
1712
|
-
});
|
|
1713
|
-
|
|
1714
|
-
describe("cloneProgressLabel", () => {
|
|
1715
|
-
it("names the environment, repo, and packages", () => {
|
|
1716
|
-
expect(
|
|
1717
|
-
cloneProgressLabel("malloydata/publisher", {
|
|
1718
|
-
environmentName: "examples",
|
|
1719
|
-
packageNames: ["storefront", "governed-analytics"],
|
|
1720
|
-
}),
|
|
1721
|
-
).toBe(
|
|
1722
|
-
"[examples] cloning malloydata/publisher (storefront, governed-analytics)",
|
|
1723
|
-
);
|
|
1724
|
-
});
|
|
1725
|
-
|
|
1726
|
-
it("truncates long package lists", () => {
|
|
1727
|
-
expect(
|
|
1728
|
-
cloneProgressLabel("o/r", {
|
|
1729
|
-
environmentName: "e",
|
|
1730
|
-
packageNames: ["a", "b", "c", "d", "e", "f"],
|
|
1731
|
-
}),
|
|
1732
|
-
).toBe("[e] cloning o/r (a, b, c, +3 more)");
|
|
1733
|
-
});
|
|
1734
|
-
|
|
1735
|
-
it("labels by repo alone without context", () => {
|
|
1736
|
-
// The controller add-package path passes no context.
|
|
1737
|
-
expect(cloneProgressLabel("o/r")).toBe("cloning o/r");
|
|
1738
|
-
});
|
|
1739
|
-
});
|
|
1740
|
-
|
|
1741
|
-
describe("CloneProgressReporter", () => {
|
|
1742
|
-
const event = (stage: string, progress: number, processed = 0, total = 0) =>
|
|
1743
|
-
({ method: "clone", stage, progress, processed, total }) as Parameters<
|
|
1744
|
-
CloneProgressReporter["onProgress"]
|
|
1745
|
-
>[0];
|
|
1746
|
-
|
|
1747
|
-
const fakeOut = (isTTY: boolean) => {
|
|
1748
|
-
const writes: string[] = [];
|
|
1749
|
-
const out = {
|
|
1750
|
-
isTTY,
|
|
1751
|
-
write: (chunk: string) => {
|
|
1752
|
-
writes.push(chunk);
|
|
1753
|
-
return true;
|
|
1754
|
-
},
|
|
1755
|
-
} as unknown as NodeJS.WriteStream;
|
|
1756
|
-
return { writes, out };
|
|
1757
|
-
};
|
|
1758
|
-
|
|
1759
|
-
it("prints a line per stage and per 25-point step off a TTY", () => {
|
|
1760
|
-
const { writes, out } = fakeOut(false);
|
|
1761
|
-
const reporter = new CloneProgressReporter("cloning o/r", out);
|
|
1762
|
-
reporter.onProgress(event("receiving", 0));
|
|
1763
|
-
reporter.onProgress(event("receiving", 10)); // same milestone, suppressed
|
|
1764
|
-
reporter.onProgress(event("receiving", 24)); // same milestone, suppressed
|
|
1765
|
-
reporter.onProgress(event("receiving", 25, 25, 100));
|
|
1766
|
-
reporter.onProgress(event("receiving", 90));
|
|
1767
|
-
reporter.onProgress(event("resolving", 5)); // stage change, printed
|
|
1768
|
-
reporter.done(); // no in-place line to finish
|
|
1769
|
-
expect(writes).toEqual([
|
|
1770
|
-
"cloning o/r: receiving 0%\n",
|
|
1771
|
-
"cloning o/r: receiving 25% (25/100)\n",
|
|
1772
|
-
"cloning o/r: receiving 90%\n",
|
|
1773
|
-
"cloning o/r: resolving 5%\n",
|
|
1774
|
-
]);
|
|
1775
|
-
});
|
|
1776
|
-
|
|
1777
|
-
it("tolerates arbitrary stage strings", () => {
|
|
1778
|
-
// Server-side progress lines parse with stage "remote:".
|
|
1779
|
-
const { writes, out } = fakeOut(false);
|
|
1780
|
-
const reporter = new CloneProgressReporter("cloning o/r", out);
|
|
1781
|
-
reporter.onProgress(event("remote:", 50));
|
|
1782
|
-
expect(writes).toEqual(["cloning o/r: remote: 50%\n"]);
|
|
1783
|
-
});
|
|
1784
|
-
|
|
1785
|
-
it("rewrites one line in place on a TTY and finishes it on done", () => {
|
|
1786
|
-
const { writes, out } = fakeOut(true);
|
|
1787
|
-
const reporter = new CloneProgressReporter("cloning o/r", out);
|
|
1788
|
-
reporter.onProgress(event("receiving", 10, 1, 10));
|
|
1789
|
-
reporter.onProgress(event("receiving", 11, 2, 10));
|
|
1790
|
-
reporter.done();
|
|
1791
|
-
expect(writes).toEqual([
|
|
1792
|
-
"\rcloning o/r: receiving 10% (1/10)\x1b[K",
|
|
1793
|
-
"\rcloning o/r: receiving 11% (2/10)\x1b[K",
|
|
1794
|
-
"\n",
|
|
1795
|
-
]);
|
|
1796
|
-
});
|
|
1797
|
-
|
|
1798
|
-
it("clamps the in-place line to the terminal width", () => {
|
|
1799
|
-
// An auto-wrapped line breaks the \r rewrite: the cursor lands on the
|
|
1800
|
-
// continuation row and every event scrolls a stale fragment. Too
|
|
1801
|
-
// narrow for even the suffix, so a plain head slice applies.
|
|
1802
|
-
const { writes, out } = fakeOut(true);
|
|
1803
|
-
(out as unknown as { columns: number }).columns = 20;
|
|
1804
|
-
const reporter = new CloneProgressReporter("cloning owner/repo", out);
|
|
1805
|
-
reporter.onProgress(event("receiving", 45, 100, 220));
|
|
1806
|
-
reporter.done();
|
|
1807
|
-
expect(writes[0]).toBe(
|
|
1808
|
-
"\r" + "cloning owner/repo:".slice(0, 19) + "\x1b[K",
|
|
1809
|
-
);
|
|
1810
|
-
expect(writes[0].length).toBe(1 + 19 + 3);
|
|
1811
|
-
});
|
|
1812
|
-
|
|
1813
|
-
it("keeps the moving percentage visible when clamping a long label", () => {
|
|
1814
|
-
// The default npx label is longer than an 80-column terminal; the
|
|
1815
|
-
// label shortens, the stage and percentage stay on screen.
|
|
1816
|
-
const { writes, out } = fakeOut(true);
|
|
1817
|
-
(out as unknown as { columns: number }).columns = 60;
|
|
1818
|
-
const reporter = new CloneProgressReporter(
|
|
1819
|
-
"[examples] cloning malloydata/publisher (storefront, governed-analytics)",
|
|
1820
|
-
out,
|
|
1821
|
-
);
|
|
1822
|
-
reporter.onProgress(event("receiving", 45, 100, 220));
|
|
1823
|
-
reporter.done();
|
|
1824
|
-
const visible = writes[0].slice(1, -3); // strip \r and \x1b[K
|
|
1825
|
-
expect(visible.length).toBe(59);
|
|
1826
|
-
expect(visible.endsWith(": receiving 45% (100/220)")).toBe(true);
|
|
1827
|
-
expect(visible).toContain("...");
|
|
1828
|
-
});
|
|
1829
|
-
|
|
1830
|
-
it("resets the milestone when the percentage restarts within a stage", () => {
|
|
1831
|
-
// git's server-side counting and compressing phases both parse as
|
|
1832
|
-
// stage "remote:"; the second starts back at 0%.
|
|
1833
|
-
const { writes, out } = fakeOut(false);
|
|
1834
|
-
const reporter = new CloneProgressReporter("cloning o/r", out);
|
|
1835
|
-
reporter.onProgress(event("remote:", 100));
|
|
1836
|
-
reporter.onProgress(event("remote:", 10));
|
|
1837
|
-
reporter.onProgress(event("remote:", 60));
|
|
1838
|
-
reporter.done();
|
|
1839
|
-
expect(writes).toEqual([
|
|
1840
|
-
"cloning o/r: remote: 100%\n",
|
|
1841
|
-
"cloning o/r: remote: 10%\n",
|
|
1842
|
-
"cloning o/r: remote: 60%\n",
|
|
1843
|
-
]);
|
|
1844
|
-
});
|
|
1845
|
-
|
|
1846
|
-
it("ignores a progress event that arrives after done()", () => {
|
|
1847
|
-
// simple-git can deliver a trailing event after the completion
|
|
1848
|
-
// callback ran done(); it must not re-write the row the finishing
|
|
1849
|
-
// newline scrolled past, nor re-claim TTY ownership.
|
|
1850
|
-
const { writes, out } = fakeOut(true);
|
|
1851
|
-
const reporter = new CloneProgressReporter("cloning o/r", out);
|
|
1852
|
-
reporter.onProgress(event("receiving", 50));
|
|
1853
|
-
reporter.done();
|
|
1854
|
-
const writesAfterDone = writes.length;
|
|
1855
|
-
reporter.onProgress(event("receiving", 90));
|
|
1856
|
-
expect(writes.length).toBe(writesAfterDone);
|
|
1857
|
-
});
|
|
1858
|
-
|
|
1859
|
-
it("only one reporter owns a TTY's in-place line at a time", () => {
|
|
1860
|
-
// Environments load concurrently at boot; a second clone must not
|
|
1861
|
-
// rewrite the first one's line. It falls back to milestone lines.
|
|
1862
|
-
const { writes, out } = fakeOut(true);
|
|
1863
|
-
const first = new CloneProgressReporter("cloning a/a", out);
|
|
1864
|
-
const second = new CloneProgressReporter("cloning b/b", out);
|
|
1865
|
-
first.onProgress(event("receiving", 10));
|
|
1866
|
-
second.onProgress(event("receiving", 20));
|
|
1867
|
-
first.onProgress(event("receiving", 30));
|
|
1868
|
-
first.done();
|
|
1869
|
-
// After the owner finishes, the line is free to claim again.
|
|
1870
|
-
second.onProgress(event("receiving", 90));
|
|
1871
|
-
second.done();
|
|
1872
|
-
expect(writes).toEqual([
|
|
1873
|
-
"\rcloning a/a: receiving 10%\x1b[K",
|
|
1874
|
-
// The non-owner takes over the row cleanly before scrolling it, so
|
|
1875
|
-
// its milestone never concatenates onto the owner's in-place text.
|
|
1876
|
-
"\rcloning b/b: receiving 20%\x1b[K\n",
|
|
1877
|
-
"\rcloning a/a: receiving 30%\x1b[K",
|
|
1878
|
-
"\n",
|
|
1879
|
-
"\rcloning b/b: receiving 90%\x1b[K",
|
|
1880
|
-
"\n",
|
|
1881
|
-
]);
|
|
1882
|
-
});
|
|
1883
|
-
});
|
|
1884
|
-
|
|
1885
|
-
describe("formatReadinessLine", () => {
|
|
1886
|
-
const SAVED = ["PUBLISHER_HOST", "PUBLISHER_PORT", "MCP_PORT"] as const;
|
|
1887
|
-
let saved: Record<string, string | undefined>;
|
|
1888
|
-
|
|
1889
|
-
beforeEach(() => {
|
|
1890
|
-
saved = {};
|
|
1891
|
-
for (const key of SAVED) {
|
|
1892
|
-
saved[key] = process.env[key];
|
|
1893
|
-
delete process.env[key];
|
|
1894
|
-
}
|
|
1895
|
-
});
|
|
1896
|
-
|
|
1897
|
-
afterEach(() => {
|
|
1898
|
-
for (const key of SAVED) {
|
|
1899
|
-
if (saved[key] === undefined) delete process.env[key];
|
|
1900
|
-
else process.env[key] = saved[key];
|
|
1901
|
-
}
|
|
1902
|
-
});
|
|
1903
|
-
|
|
1904
|
-
it("uses the server defaults and displays the wildcard bind as localhost", () => {
|
|
1905
|
-
expect(
|
|
1906
|
-
formatReadinessLine({ environments: 1, packages: 3, loadErrors: 0 }),
|
|
1907
|
-
).toBe(
|
|
1908
|
-
"PUBLISHER_READY url=http://localhost:4000 mcp=http://localhost:4040 " +
|
|
1909
|
-
"environments=1 packages=3 load_errors=0",
|
|
1910
|
-
);
|
|
1911
|
-
});
|
|
1912
|
-
|
|
1913
|
-
it("brackets an IPv6 literal host so the URL is dialable", () => {
|
|
1914
|
-
process.env.PUBLISHER_HOST = "::1";
|
|
1915
|
-
expect(
|
|
1916
|
-
formatReadinessLine({ environments: 1, packages: 1, loadErrors: 0 }),
|
|
1917
|
-
).toBe(
|
|
1918
|
-
"PUBLISHER_READY url=http://[::1]:4000 mcp=http://[::1]:4040 " +
|
|
1919
|
-
"environments=1 packages=1 load_errors=0",
|
|
1920
|
-
);
|
|
1921
|
-
});
|
|
1922
|
-
|
|
1923
|
-
it("displays the :: wildcard bind as localhost", () => {
|
|
1924
|
-
process.env.PUBLISHER_HOST = "::";
|
|
1925
|
-
expect(
|
|
1926
|
-
formatReadinessLine({ environments: 1, packages: 1, loadErrors: 0 }),
|
|
1927
|
-
).toBe(
|
|
1928
|
-
"PUBLISHER_READY url=http://localhost:4000 mcp=http://localhost:4040 " +
|
|
1929
|
-
"environments=1 packages=1 load_errors=0",
|
|
1930
|
-
);
|
|
1931
|
-
});
|
|
1932
|
-
|
|
1933
|
-
it("respects the flag-derived env vars", () => {
|
|
1934
|
-
// parseArgs writes --host/--port/--mcp_port here before the store exists.
|
|
1935
|
-
process.env.PUBLISHER_HOST = "127.0.0.1";
|
|
1936
|
-
process.env.PUBLISHER_PORT = "4321";
|
|
1937
|
-
process.env.MCP_PORT = "4361";
|
|
1938
|
-
expect(
|
|
1939
|
-
formatReadinessLine({ environments: 2, packages: 5, loadErrors: 1 }),
|
|
1940
|
-
).toBe(
|
|
1941
|
-
"PUBLISHER_READY url=http://127.0.0.1:4321 mcp=http://127.0.0.1:4361 " +
|
|
1942
|
-
"environments=2 packages=5 load_errors=1",
|
|
1943
|
-
);
|
|
1944
|
-
});
|
|
1945
|
-
});
|
|
1946
|
-
|
|
1947
|
-
describe("readiness line emission", () => {
|
|
1948
|
-
const readinessRoot = path.join(TEMP_DIR_PATH, "readiness-line-test");
|
|
1949
|
-
let stderrWrites: string[];
|
|
1950
|
-
let stderrSpy: ReturnType<typeof spyOn>;
|
|
1951
|
-
|
|
1952
|
-
const readyLines = () =>
|
|
1953
|
-
stderrWrites
|
|
1954
|
-
.join("")
|
|
1955
|
-
.split("\n")
|
|
1956
|
-
.filter((line) => line.startsWith("PUBLISHER_READY"));
|
|
1957
|
-
|
|
1958
|
-
const writePackage = (name: string) => {
|
|
1959
|
-
const dir = path.join(readinessRoot, "src-packages", name);
|
|
1960
|
-
mkdirSync(dir, { recursive: true });
|
|
1961
|
-
writeFileSync(path.join(dir, "publisher.json"), JSON.stringify({ name }));
|
|
1962
|
-
return dir;
|
|
1963
|
-
};
|
|
1964
|
-
|
|
1965
|
-
beforeEach(() => {
|
|
1966
|
-
if (existsSync(readinessRoot)) {
|
|
1967
|
-
rmSync(readinessRoot, { recursive: true, force: true });
|
|
1968
|
-
}
|
|
1969
|
-
mkdirSync(readinessRoot, { recursive: true });
|
|
1970
|
-
mock.module("../config", () => ({
|
|
1971
|
-
isPublisherConfigFrozen: () => false,
|
|
1972
|
-
}));
|
|
1973
|
-
stderrWrites = [];
|
|
1974
|
-
stderrSpy = spyOn(process.stderr, "write").mockImplementation(
|
|
1975
|
-
(chunk: string | Uint8Array) => {
|
|
1976
|
-
stderrWrites.push(String(chunk));
|
|
1977
|
-
return true;
|
|
1978
|
-
},
|
|
1979
|
-
);
|
|
1980
|
-
});
|
|
1981
|
-
|
|
1982
|
-
afterEach(() => {
|
|
1983
|
-
stderrSpy.mockRestore();
|
|
1984
|
-
rmSync(readinessRoot, { recursive: true, force: true });
|
|
1985
|
-
});
|
|
1986
|
-
|
|
1987
|
-
it("emits exactly one line with counts after a successful boot", async () => {
|
|
1988
|
-
const pkgA = writePackage("pkg-a");
|
|
1989
|
-
const pkgB = writePackage("pkg-b");
|
|
1990
|
-
writeFileSync(
|
|
1991
|
-
path.join(readinessRoot, "publisher.config.json"),
|
|
1992
|
-
JSON.stringify({
|
|
1993
|
-
frozenConfig: false,
|
|
1994
|
-
environments: [
|
|
1995
|
-
{
|
|
1996
|
-
name: "readiness-env",
|
|
1997
|
-
packages: [
|
|
1998
|
-
{ name: "pkg-a", location: pkgA },
|
|
1999
|
-
{ name: "pkg-b", location: pkgB },
|
|
2000
|
-
],
|
|
2001
|
-
connections: [],
|
|
2002
|
-
},
|
|
2003
|
-
],
|
|
2004
|
-
}),
|
|
2005
|
-
);
|
|
2006
|
-
|
|
2007
|
-
const store = new EnvironmentStore(readinessRoot);
|
|
2008
|
-
await store.finishedInitialization;
|
|
2009
|
-
|
|
2010
|
-
const lines = readyLines();
|
|
2011
|
-
expect(lines).toHaveLength(1);
|
|
2012
|
-
expect(lines[0]).toContain("environments=1");
|
|
2013
|
-
expect(lines[0]).toContain("packages=2");
|
|
2014
|
-
expect(lines[0]).toContain("load_errors=0");
|
|
2015
|
-
expect(lines[0]).toMatch(
|
|
2016
|
-
/^PUBLISHER_READY url=http:\/\/\S+ mcp=http:\/\/\S+ environments=\d+ packages=\d+ load_errors=\d+$/,
|
|
2017
|
-
);
|
|
2018
|
-
});
|
|
2019
|
-
|
|
2020
|
-
it("counts a mount failure in load_errors and still emits", async () => {
|
|
2021
|
-
// Post-#903 a bad location drops the package, not the boot; the line
|
|
2022
|
-
// must report the failure rather than stay silent or claim a clean load.
|
|
2023
|
-
const pkgA = writePackage("pkg-a");
|
|
2024
|
-
writeFileSync(
|
|
2025
|
-
path.join(readinessRoot, "publisher.config.json"),
|
|
2026
|
-
JSON.stringify({
|
|
2027
|
-
frozenConfig: false,
|
|
2028
|
-
environments: [
|
|
2029
|
-
{
|
|
2030
|
-
name: "readiness-env",
|
|
2031
|
-
packages: [
|
|
2032
|
-
{ name: "pkg-a", location: pkgA },
|
|
2033
|
-
{
|
|
2034
|
-
name: "pkg-missing",
|
|
2035
|
-
location: path.join(readinessRoot, "does-not-exist"),
|
|
2036
|
-
},
|
|
2037
|
-
],
|
|
2038
|
-
connections: [],
|
|
2039
|
-
},
|
|
2040
|
-
],
|
|
2041
|
-
}),
|
|
2042
|
-
);
|
|
2043
|
-
|
|
2044
|
-
const store = new EnvironmentStore(readinessRoot);
|
|
2045
|
-
await store.finishedInitialization;
|
|
2046
|
-
|
|
2047
|
-
const lines = readyLines();
|
|
2048
|
-
expect(lines).toHaveLength(1);
|
|
2049
|
-
expect(lines[0]).toContain("environments=1");
|
|
2050
|
-
// packages= is what the server actually serves: the boot-time database
|
|
2051
|
-
// sync prunes a package whose load failed (environment.ts getPackage
|
|
2052
|
-
// catch), so the failed one appears in load_errors, not in packages.
|
|
2053
|
-
expect(lines[0]).toContain("packages=1");
|
|
2054
|
-
expect(lines[0]).toContain("load_errors=1");
|
|
2055
|
-
});
|
|
2056
|
-
|
|
2057
|
-
it("counts a failed environment in load_errors", async () => {
|
|
2058
|
-
// An environment that fails initialization outright lands in
|
|
2059
|
-
// failedEnvironments, not in environments; the line must count it
|
|
2060
|
-
// alongside per-package failures. Failure lever: a FILE squatting the
|
|
2061
|
-
// environment's publisher_data directory path makes the initial mkdir
|
|
2062
|
-
// throw before any package mounts.
|
|
2063
|
-
const pkgA = writePackage("pkg-a");
|
|
2064
|
-
mkdirSync(path.join(readinessRoot, "publisher_data"), {
|
|
2065
|
-
recursive: true,
|
|
2066
|
-
});
|
|
2067
|
-
writeFileSync(path.join(readinessRoot, "publisher_data", "bad-env"), "");
|
|
2068
|
-
writeFileSync(
|
|
2069
|
-
path.join(readinessRoot, "publisher.config.json"),
|
|
2070
|
-
JSON.stringify({
|
|
2071
|
-
frozenConfig: false,
|
|
2072
|
-
environments: [
|
|
2073
|
-
{
|
|
2074
|
-
name: "good-env",
|
|
2075
|
-
packages: [{ name: "pkg-a", location: pkgA }],
|
|
2076
|
-
connections: [],
|
|
2077
|
-
},
|
|
2078
|
-
{
|
|
2079
|
-
name: "bad-env",
|
|
2080
|
-
packages: [{ name: "pkg-a", location: pkgA }],
|
|
2081
|
-
connections: [],
|
|
2082
|
-
},
|
|
2083
|
-
],
|
|
2084
|
-
}),
|
|
2085
|
-
);
|
|
2086
|
-
|
|
2087
|
-
const store = new EnvironmentStore(readinessRoot);
|
|
2088
|
-
await store.finishedInitialization;
|
|
2089
|
-
|
|
2090
|
-
const lines = readyLines();
|
|
2091
|
-
expect(lines).toHaveLength(1);
|
|
2092
|
-
expect(lines[0]).toContain("environments=1");
|
|
2093
|
-
expect(lines[0]).toContain("packages=1");
|
|
2094
|
-
expect(lines[0]).toContain("load_errors=1");
|
|
2095
|
-
});
|
|
2096
|
-
});
|
|
2097
|
-
|
|
2098
|
-
describe("parseGitHubUrl", () => {
|
|
2099
|
-
// The exact regexes parseGitHubUrl replaced, kept here ONLY as a
|
|
2100
|
-
// behavioral oracle. The product code no longer runs them (they were
|
|
2101
|
-
// flagged as polynomial-ReDoS); the fuzz below proves the linear
|
|
2102
|
-
// string parser returns identical results. Never feed these long input.
|
|
2103
|
-
const oldParseGitHubUrl = (
|
|
2104
|
-
githubUrl: string,
|
|
2105
|
-
): { owner: string; repoName: string; packagePath?: string } | null => {
|
|
2106
|
-
const httpsRegex =
|
|
2107
|
-
/github\.com\/(?<owner>[^/]+)\/(?<repoName>[^/]+)(?<packagePath>\/[^/]+)*/;
|
|
2108
|
-
const httpsMatch = githubUrl.match(httpsRegex);
|
|
2109
|
-
if (httpsMatch) {
|
|
2110
|
-
const { owner, repoName, packagePath } = httpsMatch.groups!;
|
|
2111
|
-
return { owner, repoName, packagePath };
|
|
2112
|
-
}
|
|
2113
|
-
const sshRegex =
|
|
2114
|
-
/git@github\.com:(?<owner>[^/]+)\/(?<repoName>[^/\s]+?)(?:\.git)?(?<packagePath>\/[^/]+)*$/;
|
|
2115
|
-
const sshMatch = githubUrl.match(sshRegex);
|
|
2116
|
-
if (sshMatch) {
|
|
2117
|
-
const { owner, repoName, packagePath } = sshMatch.groups!;
|
|
2118
|
-
return { owner, repoName, packagePath };
|
|
2119
|
-
}
|
|
2120
|
-
return null;
|
|
2121
|
-
};
|
|
2122
|
-
|
|
2123
|
-
// Absent and undefined packagePath are the same result; normalize so the
|
|
2124
|
-
// comparison ignores that representational difference.
|
|
2125
|
-
const norm = (r: ReturnType<typeof parseGitHubUrl>) =>
|
|
2126
|
-
r === null
|
|
2127
|
-
? null
|
|
2128
|
-
: {
|
|
2129
|
-
owner: r.owner,
|
|
2130
|
-
repoName: r.repoName,
|
|
2131
|
-
packagePath: r.packagePath ?? null,
|
|
2132
|
-
};
|
|
2133
|
-
|
|
2134
|
-
it("matches the documented cases", () => {
|
|
2135
|
-
expect(
|
|
2136
|
-
parseGitHubUrl(
|
|
2137
|
-
"https://github.com/malloydata/publisher/tree/main/examples/storefront",
|
|
2138
|
-
),
|
|
2139
|
-
).toEqual({
|
|
2140
|
-
owner: "malloydata",
|
|
2141
|
-
repoName: "publisher",
|
|
2142
|
-
// Preserved quirk: only the LAST path segment.
|
|
2143
|
-
packagePath: "/storefront",
|
|
2144
|
-
});
|
|
2145
|
-
expect(parseGitHubUrl("https://github.com/owner/repo")).toEqual({
|
|
2146
|
-
owner: "owner",
|
|
2147
|
-
repoName: "repo",
|
|
2148
|
-
});
|
|
2149
|
-
expect(parseGitHubUrl("git@github.com:owner/repo.git")).toEqual({
|
|
2150
|
-
owner: "owner",
|
|
2151
|
-
repoName: "repo",
|
|
2152
|
-
});
|
|
2153
|
-
expect(parseGitHubUrl("https://gitlab.com/owner/repo")).toBeNull();
|
|
2154
|
-
});
|
|
2155
|
-
|
|
2156
|
-
it("is identical to the old regexes across a fuzzed input space", () => {
|
|
2157
|
-
const prefixes = [
|
|
2158
|
-
"https://github.com/",
|
|
2159
|
-
"http://github.com/",
|
|
2160
|
-
"github.com/",
|
|
2161
|
-
"git@github.com:",
|
|
2162
|
-
"ssh://git@github.com:",
|
|
2163
|
-
"before github.com/",
|
|
2164
|
-
"",
|
|
2165
|
-
"https://gitlab.com/",
|
|
2166
|
-
];
|
|
2167
|
-
const owners = ["owner", "o", "", "own er", "a.b"];
|
|
2168
|
-
const repos = [
|
|
2169
|
-
"repo",
|
|
2170
|
-
"r",
|
|
2171
|
-
"repo.git",
|
|
2172
|
-
".git",
|
|
2173
|
-
"a.git",
|
|
2174
|
-
"re po",
|
|
2175
|
-
"",
|
|
2176
|
-
"repo.gitx",
|
|
2177
|
-
];
|
|
2178
|
-
const tails = [
|
|
2179
|
-
"",
|
|
2180
|
-
"/tree/main/sub",
|
|
2181
|
-
"/tree/main",
|
|
2182
|
-
"/a/b/c",
|
|
2183
|
-
"/",
|
|
2184
|
-
"//",
|
|
2185
|
-
"/a//b",
|
|
2186
|
-
"/sub dir",
|
|
2187
|
-
"/tree/branch/a/b",
|
|
2188
|
-
];
|
|
2189
|
-
let cases = 0;
|
|
2190
|
-
for (const prefix of prefixes) {
|
|
2191
|
-
for (const owner of owners) {
|
|
2192
|
-
for (const repo of repos) {
|
|
2193
|
-
for (const tail of tails) {
|
|
2194
|
-
const input = `${prefix}${owner}/${repo}${tail}`;
|
|
2195
|
-
expect(norm(parseGitHubUrl(input))).toEqual(
|
|
2196
|
-
norm(oldParseGitHubUrl(input)),
|
|
2197
|
-
);
|
|
2198
|
-
cases++;
|
|
2199
|
-
}
|
|
2200
|
-
}
|
|
2201
|
-
}
|
|
2202
|
-
}
|
|
2203
|
-
expect(cases).toBeGreaterThan(2000);
|
|
2204
|
-
});
|
|
2205
|
-
|
|
2206
|
-
it("runs in linear time on adversarial input (no ReDoS)", () => {
|
|
2207
|
-
// The payloads CodeQL flagged: a long run that made the old
|
|
2208
|
-
// `(\/[^/]+)*` regexes backtrack polynomially. The string parser is
|
|
2209
|
-
// linear, so this returns effectively instantly.
|
|
2210
|
-
const start = performance.now();
|
|
2211
|
-
parseGitHubUrl("git@github.com:" + ".".repeat(200000));
|
|
2212
|
-
parseGitHubUrl("git@github.com:" + "a/".repeat(200000));
|
|
2213
|
-
parseGitHubUrl(
|
|
2214
|
-
"git@github.com:./!/" + "git@github.com:./!/".repeat(5000),
|
|
2215
|
-
);
|
|
2216
|
-
parseGitHubUrl("https://github.com/" + "a/".repeat(200000));
|
|
2217
|
-
expect(performance.now() - start).toBeLessThan(500);
|
|
2218
|
-
});
|
|
2219
|
-
});
|
|
2220
|
-
|
|
2221
|
-
describe("EnvironmentStore embeddings cleanup wiring", () => {
|
|
2222
|
-
const serverRootPath = path.join(TEMP_DIR_PATH, "cleanup-wiring-tests");
|
|
2223
|
-
|
|
2224
|
-
beforeEach(() => {
|
|
2225
|
-
if (existsSync(serverRootPath)) {
|
|
2226
|
-
rmSync(serverRootPath, { recursive: true, force: true });
|
|
2227
|
-
}
|
|
2228
|
-
mkdirSync(serverRootPath, { recursive: true });
|
|
2229
|
-
mockDbEnvironments = [];
|
|
2230
|
-
embeddingCleanupRuns.length = 0;
|
|
2231
|
-
embeddingCleanupBlocks = false;
|
|
2232
|
-
_resetEmbeddingIndexStateForTests();
|
|
2233
|
-
});
|
|
2234
|
-
|
|
2235
|
-
afterEach(() => {
|
|
2236
|
-
embeddingCleanupBlocks = false;
|
|
2237
|
-
_resetEmbeddingIndexStateForTests();
|
|
2238
|
-
if (existsSync(serverRootPath)) {
|
|
2239
|
-
rmSync(serverRootPath, { recursive: true, force: true });
|
|
2240
|
-
}
|
|
2241
|
-
});
|
|
2242
|
-
|
|
2243
|
-
it("deletePackageFromDatabase fires cleanup without awaiting it, even with no metadata row", async () => {
|
|
2244
|
-
const store = new EnvironmentStore(serverRootPath);
|
|
2245
|
-
await store.finishedInitialization;
|
|
2246
|
-
|
|
2247
|
-
// The cleanup's SQL never resolves: were the store awaiting it,
|
|
2248
|
-
// this call would hang past the test timeout. The mock repository
|
|
2249
|
-
// reports no environment row, so resolving promptly ALSO pins
|
|
2250
|
-
// that the cleanup runs before the missing-row early return.
|
|
2251
|
-
embeddingCleanupBlocks = true;
|
|
2252
|
-
await store.deletePackageFromDatabase("wiring-env", "wiring-pkg");
|
|
2253
|
-
|
|
2254
|
-
const deletes = embeddingCleanupRuns.filter((r) =>
|
|
2255
|
-
r.sql.includes("DELETE FROM entity_embeddings"),
|
|
2256
|
-
);
|
|
2257
|
-
expect(deletes.length).toBe(1);
|
|
2258
|
-
expect(deletes[0].params).toEqual(["wiring-env", "wiring-pkg"]);
|
|
2259
|
-
});
|
|
2260
|
-
|
|
2261
|
-
it("deleteEnvironmentFromDatabase fires the env-wide cleanup without awaiting it", async () => {
|
|
2262
|
-
const store = new EnvironmentStore(serverRootPath);
|
|
2263
|
-
await store.finishedInitialization;
|
|
2264
|
-
|
|
2265
|
-
embeddingCleanupBlocks = true;
|
|
2266
|
-
await store.deleteEnvironmentFromDatabase("wiring-env");
|
|
2267
|
-
|
|
2268
|
-
const deletes = embeddingCleanupRuns.filter((r) =>
|
|
2269
|
-
r.sql.includes("DELETE FROM entity_embeddings"),
|
|
2270
|
-
);
|
|
2271
|
-
expect(deletes.length).toBe(1);
|
|
2272
|
-
expect(deletes[0].params).toEqual(["wiring-env"]);
|
|
2273
|
-
});
|
|
2274
|
-
});
|