@underpostnet/cyberia 3.3.73 → 3.3.77

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (269) hide show
  1. package/.env.example +17 -5
  2. package/.github/workflows/coverall.cyberia.ci.yml +10 -0
  3. package/.github/workflows/docker-image.cyberia-client.ci.yml +1 -1
  4. package/.github/workflows/docker-image.cyberia-client.dev.ci.yml +1 -1
  5. package/.github/workflows/docker-image.cyberia-server.ci.yml +1 -1
  6. package/.github/workflows/docker-image.cyberia-server.dev.ci.yml +1 -1
  7. package/.github/workflows/docker-image.engine-cyberia.ci.yml +1 -1
  8. package/.github/workflows/docker-image.engine-cyberia.dev.ci.yml +1 -1
  9. package/.github/workflows/engine-cyberia.ci.yml +1 -1
  10. package/.github/workflows/publish.cyberia.ci.yml +44 -0
  11. package/AGENTS.md +61 -12
  12. package/CHANGELOG.md +159 -161
  13. package/CLI-HELP.md +20 -13
  14. package/Dockerfile +1 -1
  15. package/Dockerfile.dev +1 -1
  16. package/Dockerfile.test +2 -2
  17. package/README.md +28 -28
  18. package/bin/build.js +96 -21
  19. package/bin/cyberia.js +1247 -1373
  20. package/bin/index.js +1247 -1373
  21. package/compose.env +17 -5
  22. package/conf.js +7 -4
  23. package/deploy/cyberia-client/deploy.sh +10 -11
  24. package/deploy/cyberia-client/package.sh +0 -1
  25. package/deploy/cyberia-client/state.sh +3 -2
  26. package/deploy/cyberia-server/deploy.sh +10 -11
  27. package/deploy/cyberia-server/package.sh +0 -1
  28. package/deploy/cyberia-server/state.sh +3 -2
  29. package/deploy/dd-cyberia/deploy.sh +5 -5
  30. package/deploy/dd-cyberia/init.sh +15 -14
  31. package/deploy/dd-cyberia/package.sh +0 -1
  32. package/deploy/dd-cyberia/state.sh +2 -2
  33. package/deploy/dd-cyberia/sync-deploy.sh +139 -75
  34. package/deploy/lib/config.sh +29 -0
  35. package/deploy/lib/github-actions-logging.sh +92 -76
  36. package/deploy/lib/host.sh +28 -19
  37. package/deploy/lib/state.sh +4 -1
  38. package/deploy/pwa-microservices-template/deploy.sh +10 -8
  39. package/deploy/release/deploy.sh +0 -2
  40. package/deployment.yaml +1 -1
  41. package/docker-compose.yml +23 -17
  42. package/docs/coverage/cyberia/api/cyberia-instance/cyberia-fallback-capture.js.html +1591 -0
  43. package/docs/coverage/cyberia/api/cyberia-instance/index.html +116 -0
  44. package/docs/coverage/cyberia/api/cyberia-server-defaults/cyberia-server-defaults.js.html +4138 -0
  45. package/docs/coverage/cyberia/api/cyberia-server-defaults/index.html +116 -0
  46. package/docs/coverage/cyberia/api/object-layer/index.html +116 -0
  47. package/docs/coverage/cyberia/api/object-layer/object-layer.model.js.html +1393 -0
  48. package/docs/coverage/cyberia/base.css +362 -0
  49. package/docs/coverage/cyberia/block-navigation.js +82 -0
  50. package/docs/coverage/cyberia/favicon.png +0 -0
  51. package/docs/coverage/cyberia/index.html +161 -0
  52. package/docs/coverage/cyberia/prettify.css +101 -0
  53. package/docs/coverage/cyberia/prettify.js +937 -0
  54. package/docs/coverage/cyberia/projects/cyberia/atlas-sprite-sheet-generator.js.html +1315 -0
  55. package/docs/coverage/cyberia/projects/cyberia/index.html +176 -0
  56. package/docs/coverage/cyberia/projects/cyberia/instance-backup.js.html +715 -0
  57. package/docs/coverage/cyberia/projects/cyberia/instance-data.js.html +2182 -0
  58. package/docs/coverage/cyberia/projects/cyberia/shape-generator.js.html +3265 -0
  59. package/docs/coverage/cyberia/projects/cyberia/stat-balance.js.html +328 -0
  60. package/docs/coverage/cyberia/sort-arrow-sprite.png +0 -0
  61. package/docs/coverage/cyberia/sorter.js +205 -0
  62. package/hardhat/package-lock.json +2 -2
  63. package/hardhat/package.json +1 -1
  64. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
  65. package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +1 -1
  66. package/manifests/deployment/dd-cyberia-development/deployment.yaml +1 -1
  67. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  68. package/manifests/deployment/mongo-express/deployment.yaml +46 -27
  69. package/manifests/deployment/mongo-express/kustomization.yaml +9 -0
  70. package/manifests/deployment/mongo-express/mongo-express-nodeport.yaml +18 -0
  71. package/manifests/deployment/mongo-express/service.yaml +16 -0
  72. package/manifests/deployment/mongo-express-no-auth/disable-mongodb-auth.yaml +16 -0
  73. package/manifests/deployment/mongo-express-no-auth/kustomization.yaml +16 -0
  74. package/package.json +19 -17
  75. package/scripts/test-monitor.sh +3 -3
  76. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.controller.js +14 -9
  77. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.model.js +36 -5
  78. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.router.js +5 -0
  79. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.service.js +88 -184
  80. package/src/api/cyberia-action/cyberia-action.router.js +2 -2
  81. package/src/api/cyberia-action/cyberia-action.service.js +3 -4
  82. package/src/api/cyberia-audio/cyberia-audio.controller.js +6 -0
  83. package/src/api/cyberia-audio/cyberia-audio.model.js +61 -0
  84. package/src/api/cyberia-audio/cyberia-audio.router.js +21 -0
  85. package/src/api/cyberia-audio/cyberia-audio.service.js +220 -0
  86. package/src/api/cyberia-client-hints/cyberia-client-hints.controller.js +1 -3
  87. package/src/api/cyberia-client-hints/cyberia-client-hints.model.js +10 -19
  88. package/src/api/cyberia-client-hints/cyberia-client-hints.router.js +12 -34
  89. package/src/api/cyberia-client-hints/cyberia-client-hints.service.js +15 -32
  90. package/src/api/cyberia-dialogue/cyberia-dialogue.model.js +1 -1
  91. package/src/api/cyberia-dialogue/cyberia-dialogue.router.js +1 -1
  92. package/src/api/cyberia-entity/cyberia-entity.model.js +2 -0
  93. package/src/api/cyberia-entity/cyberia-entity.service.js +1 -1
  94. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.controller.js +8 -2
  95. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.model.js +31 -20
  96. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.router.js +16 -1
  97. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.service.js +466 -3
  98. package/src/api/cyberia-instance/cyberia-fallback-capture.js +87 -51
  99. package/src/api/cyberia-instance/cyberia-fallback-world.js +21 -40
  100. package/src/api/cyberia-instance/cyberia-instance-boot.service.js +5 -5
  101. package/src/api/cyberia-instance/cyberia-instance-items.js +98 -0
  102. package/src/api/cyberia-instance/cyberia-instance-map.service.js +34 -141
  103. package/src/api/cyberia-instance/cyberia-instance.controller.js +1 -4
  104. package/src/api/cyberia-instance/cyberia-instance.model.js +0 -14
  105. package/src/api/cyberia-instance/cyberia-instance.router.js +3 -16
  106. package/src/api/cyberia-instance/cyberia-instance.service.js +3 -43
  107. package/src/api/cyberia-instance/cyberia-world-generator.js +8 -13
  108. package/src/api/cyberia-instance-conf/cyberia-instance-conf.model.js +49 -75
  109. package/src/api/cyberia-instance-conf/cyberia-instance-conf.service.js +74 -1
  110. package/src/api/cyberia-map/cyberia-map.service.js +6 -2
  111. package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.controller.js +31 -0
  112. package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.model.js +56 -0
  113. package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.router.js +33 -0
  114. package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.service.js +189 -0
  115. package/src/api/cyberia-quest/cyberia-quest.router.js +3 -3
  116. package/src/api/cyberia-quest/cyberia-quest.service.js +3 -3
  117. package/src/api/cyberia-quest-progress/cyberia-quest-progress.model.js +1 -1
  118. package/src/api/cyberia-server-defaults/cyberia-server-defaults.js +403 -188
  119. package/src/api/cyberia-skill/cyberia-skill.model.js +6 -6
  120. package/src/api/file/file.ref.js +153 -0
  121. package/src/api/file/file.ref.json +11 -2
  122. package/src/api/object-layer/object-layer.model.js +47 -12
  123. package/src/api/object-layer/object-layer.router.js +10 -3
  124. package/src/api/object-layer/object-layer.service.js +113 -120
  125. package/src/cli/app.js +12 -1
  126. package/src/cli/client.js +2 -2
  127. package/src/cli/cluster.js +80 -18
  128. package/src/cli/deploy.js +215 -34
  129. package/src/cli/docker-compose.js +1 -1
  130. package/src/cli/fs-selection.js +115 -0
  131. package/src/cli/fs.js +158 -342
  132. package/src/cli/index.js +22 -10
  133. package/src/cli/release.js +3 -1
  134. package/src/cli/repository.js +35 -9
  135. package/src/cli/run.js +80 -199
  136. package/src/cli/test.js +4 -0
  137. package/src/cli/wireguard.js +32 -31
  138. package/src/client/components/core/Badge.js +0 -1
  139. package/src/client/components/core/CssCore.js +8 -1
  140. package/src/client/components/core/Docs.js +77 -100
  141. package/src/client/components/core/Modal.js +77 -23
  142. package/src/client/components/core/ToolTip.js +15 -0
  143. package/src/client/components/cryptokoyn/AppShellCryptokoyn.js +0 -2
  144. package/src/client/components/cyberia/ActionEngineCyberia.js +12 -36
  145. package/src/client/components/cyberia/EntityEngineCyberia.js +492 -121
  146. package/src/client/components/cyberia/InstanceEngineCyberia.js +129 -160
  147. package/src/client/components/cyberia/InstanceSelectionView.js +7 -1
  148. package/src/client/components/cyberia/MapEngineCyberia.js +175 -69
  149. package/src/client/components/cyberia/ObjectLayerEngine.js +1011 -97
  150. package/src/client/components/cyberia/ObjectLayerEngineModal.js +81 -155
  151. package/src/client/components/cyberia/ObjectLayerEngineViewer.js +113 -98
  152. package/src/client/components/cyberia/SharedDefaultsCyberia.js +215 -56
  153. package/src/client/components/cyberia-portal/AppShellCyberiaPortal.js +0 -34
  154. package/src/client/components/cyberia-portal/CssCyberiaPortal.js +1 -1
  155. package/src/client/components/cyberia-portal/MainBodyCyberiaPortal.js +712 -76
  156. package/src/client/components/cyberia-portal/RouterCyberiaPortal.js +0 -4
  157. package/src/client/components/cyberia-portal/TranslateCyberiaPortal.js +0 -4
  158. package/src/client/components/default/AppShellDefault.js +1 -4
  159. package/src/client/components/itemledger/AppShellItemledger.js +0 -2
  160. package/src/client/components/underpost/AppShellUnderpost.js +1 -1
  161. package/src/client/public/cyberia-docs/ARCHITECTURE.md +15 -15
  162. package/src/client/public/cyberia-docs/CYBERIA-CLI.md +280 -24
  163. package/src/client/public/cyberia-docs/CYBERIA-CLIENT.md +51 -29
  164. package/src/client/public/cyberia-docs/CYBERIA-SAGA.md +52 -53
  165. package/src/client/public/cyberia-docs/CYBERIA-SERVER.md +16 -11
  166. package/src/client/public/cyberia-docs/CYBERIA.md +28 -28
  167. package/src/client/public/cyberia-docs/ENTITY-PROFILE.md +11 -5
  168. package/src/client/public/cyberia-docs/ROADMAP.md +1 -1
  169. package/src/client/public/cyberia-docs/STATS-PROGRESSION.md +217 -0
  170. package/src/client/public/cyberia-docs/WHITE-PAPER.md +20 -12
  171. package/src/client/services/cyberia-audio/cyberia-audio.service.js +99 -0
  172. package/src/client/services/cyberia-entity-type-default/cyberia-entity-type-default.service.js +38 -0
  173. package/src/client/services/cyberia-instance/cyberia-instance.management.js +5 -4
  174. package/src/client/services/cyberia-instance/cyberia-instance.service.js +0 -40
  175. package/src/client/services/cyberia-map/cyberia-map.management.js +5 -4
  176. package/src/client/services/cyberia-map-audio-conf/cyberia-map-audio-conf.service.js +138 -0
  177. package/src/client/services/object-layer/object-layer.management.js +6 -10
  178. package/src/client/services/object-layer/object-layer.service.js +3 -1
  179. package/src/client/ssr/views/Cyberia404.js +3 -3
  180. package/src/client-builder/client-build-docs.js +52 -52
  181. package/src/client-builder/client-build.js +67 -44
  182. package/src/client-builder/client-bundle.js +347 -0
  183. package/src/db/mongo/MongoExpress.js +187 -0
  184. package/src/grpc/cyberia/grpc-server.js +5 -7
  185. package/src/index.js +1 -1
  186. package/src/projects/cyberia/atlas-sprite-sheet-generator.js +143 -146
  187. package/src/projects/cyberia/atlas-sprite-sheet-store.js +391 -0
  188. package/src/projects/cyberia/besu-genesis-generator.js +5 -14
  189. package/src/projects/cyberia/boot-contract-fixtures.js +32 -0
  190. package/src/projects/cyberia/catalog-cyberia.js +3 -2
  191. package/src/projects/cyberia/gemini-client.js +3 -3
  192. package/src/projects/cyberia/generate-saga.js +4 -11
  193. package/src/projects/cyberia/hot-reload-trigger.js +17 -28
  194. package/src/projects/cyberia/instance-backup.js +210 -0
  195. package/src/projects/cyberia/instance-data.js +177 -257
  196. package/src/projects/cyberia/map-preview-generator.js +32 -68
  197. package/src/projects/cyberia/object-layer.js +152 -319
  198. package/src/projects/cyberia/seed-audio.js +139 -0
  199. package/src/projects/cyberia/semantic-layer-generator-skin.js +2 -8
  200. package/src/projects/cyberia/semantic-layer-generator.js +2 -18
  201. package/src/projects/cyberia/stat-balance.js +81 -0
  202. package/src/projects/cyberia/stat-commands.js +23 -0
  203. package/src/projects/cyberia/stat-contract-generator.js +101 -0
  204. package/src/runtime/cyberia-client/Dockerfile +8 -4
  205. package/src/runtime/cyberia-client/Dockerfile.dev +5 -2
  206. package/src/runtime/cyberia-server/Dockerfile +9 -2
  207. package/src/runtime/cyberia-server/Dockerfile.dev +8 -1
  208. package/src/runtime/engine-cyberia/Dockerfile +1 -1
  209. package/src/runtime/engine-cyberia/Dockerfile.dev +1 -1
  210. package/src/runtime/engine-cyberia/Dockerfile.test +2 -2
  211. package/src/runtime/engine-cyberia/compose.env +17 -5
  212. package/src/runtime/engine-cyberia/docker-compose.yml +23 -17
  213. package/src/server/build/coverage.js +113 -42
  214. package/src/server/build/package.js +55 -12
  215. package/src/server/build/testing.js +59 -9
  216. package/src/server/network/middlewares.js +8 -2
  217. package/src/server/network/underpost-gateway.js +113 -14
  218. package/src/server/ops/logger.js +70 -35
  219. package/src/server/runtime/conf.js +40 -9
  220. package/src/server/runtime/start.js +11 -2
  221. package/src/server/storage/data-query.js +16 -0
  222. package/src/server/storage/downloader.js +62 -14
  223. package/src/server/storage/zip.js +153 -0
  224. package/test/integration/app/cyberia/atlas-sprite-sheet-store.test.js +402 -0
  225. package/test/integration/app/cyberia/atlas-sprite-sheet.test.js +86 -0
  226. package/test/integration/app/cyberia/cyberia-cli-plain-reads.test.js +34 -0
  227. package/test/integration/app/cyberia/cyberia-entity-type-default.test.js +842 -0
  228. package/test/integration/app/cyberia/cyberia-instance-conf-coerce.test.js +53 -0
  229. package/test/integration/app/cyberia/cyberia-instance-conf-defaults.test.js +16 -22
  230. package/test/integration/app/cyberia/cyberia-instance-items.test.js +90 -0
  231. package/test/integration/app/cyberia/cyberia-load.test.js +6 -7
  232. package/test/integration/app/cyberia/cyberia-map-audio-conf.test.js +256 -0
  233. package/test/integration/app/cyberia/cyberia-stats.test.js +150 -0
  234. package/test/integration/app/cyberia/fallback-world-capture.test.js +87 -2
  235. package/test/integration/app/cyberia/object-layer-item-selection.test.js +56 -0
  236. package/test/integration/app/cyberia/object-layer-natural-key.test.js +32 -0
  237. package/test/integration/app/cyberia/seed-audio.test.js +275 -0
  238. package/test/integration/infra/2-network/wireguard-cli.test.js +33 -9
  239. package/test/integration/infra/2-network/wireguard-edge.test.js +58 -18
  240. package/test/integration/infra/3-cluster/mongo-express-deploy.test.js +156 -0
  241. package/test/integration/infra/4-ingress/api-cross-origin.test.js +79 -0
  242. package/test/integration/infra/4-ingress/underpost-ingress.test.js +1 -1
  243. package/test/unit/catalog.test.js +13 -2
  244. package/test/unit/client-build-docs.test.js +97 -70
  245. package/test/unit/client-bundle.test.js +313 -0
  246. package/test/unit/conf-resolution.test.js +58 -7
  247. package/test/unit/coverage-artifact.test.js +120 -29
  248. package/test/unit/cyberia/instance-backup.test.js +183 -0
  249. package/test/unit/cyberia/instance-data.test.js +314 -0
  250. package/test/unit/cyberia/instance-object-layer-items.test.js +46 -0
  251. package/test/unit/cyberia/publish-workflow.test.js +22 -0
  252. package/test/unit/cyberia/stat-balance.test.js +69 -0
  253. package/test/unit/deploy-legacy-gateway-sweep.test.js +63 -0
  254. package/test/unit/deploy-log-table.test.js +76 -5
  255. package/test/unit/downloader-integrity.test.js +98 -0
  256. package/test/unit/file-reference-registry.test.js +141 -0
  257. package/test/unit/fs-storage-paths.test.js +596 -0
  258. package/test/unit/gateway-pod-fetch.test.js +95 -0
  259. package/test/unit/letsencrypt-issuer.test.js +101 -0
  260. package/test/unit/logger-redaction.test.js +29 -0
  261. package/test/unit/package.test.js +70 -16
  262. package/test/unit/prepare-host.test.js +84 -1
  263. package/test/unit/release-bump.test.js +3 -1
  264. package/test/unit/start-options.test.js +12 -0
  265. package/test/unit/test-tiers.test.js +27 -0
  266. package/test/unit/zip-archive.test.js +142 -0
  267. package/vitest.config.js +11 -3
  268. package/src/api/cyberia-instance/cyberia-fallback-default-items.js +0 -63
  269. package/src/client/components/cyberia/FallbackWorldEngineCyberia.js +0 -368
@@ -8,112 +8,148 @@ import { buildCoverage, buildDocs, buildSwaggerUiOptions } from '../../src/clien
8
8
 
9
9
  // swagger-autogen is a code generator that walks the real router files and
10
10
  // writes into ./public. The doc it is handed is what this module is responsible
11
- // for, so the generator itself is replaced and its arguments are asserted.
11
+ // for, so the generator itself is replaced and its arguments are asserted; it
12
+ // writes the document a test stages as its output, so the post-process has one.
12
13
  const generated = vi.hoisted(() => []);
13
- vi.mock('swagger-autogen', () => ({
14
- default: (options) => async (outputFile, routes, doc) => {
15
- generated.push({ options, outputFile, routes, doc });
16
- },
17
- }));
14
+ const staged = vi.hoisted(() => ({ swaggerJson: null }));
15
+ vi.mock('swagger-autogen', async () => {
16
+ const fs = (await import('fs-extra')).default;
17
+ return {
18
+ default: (options) => async (outputFile, routes, doc) => {
19
+ generated.push({ options, outputFile, routes, doc });
20
+ if (staged.swaggerJson) fs.outputJsonSync(outputFile, staged.swaggerJson);
21
+ },
22
+ };
23
+ });
18
24
 
19
25
  describe('client coverage build', () => {
20
26
  let fixturePath;
27
+ let cwd;
28
+
29
+ beforeEach(() => {
30
+ fixturePath = fs.mkdtempSync('/tmp/engine-coverage-');
31
+ // Suite reports resolve against the engine root, so the fixture stands in for it.
32
+ cwd = process.cwd();
33
+ process.chdir(fixturePath);
34
+ });
21
35
 
22
36
  afterEach(() => {
23
- if (fixturePath) fs.removeSync(fixturePath);
24
- fixturePath = undefined;
37
+ process.chdir(cwd);
38
+ fs.removeSync(fixturePath);
25
39
  vi.restoreAllMocks();
26
40
  });
27
41
 
28
- it('publishes the Vitest lcov HTML report at the coverage route root', async () => {
29
- fixturePath = fs.mkdtempSync('/tmp/engine-coverage-');
30
- const reportPath = `${fixturePath}/coverage/lcov-report`;
42
+ it('publishes each declared report at its own coverage route', async () => {
43
+ fs.outputFileSync(`${fixturePath}/coverage/cyberia/index.html`, '<!doctype html><title>cyberia</title>');
44
+ fs.outputFileSync(`${fixturePath}/hardhat/coverage/html/index.html`, '<!doctype html><title>hardhat</title>');
45
+ fs.outputFileSync(`${fixturePath}/hardhat/coverage/html/base.css`, 'body {}');
31
46
  const docsDestination = `${fixturePath}/public/docs/`;
32
- fs.outputFileSync(`${reportPath}/index.html`, '<!doctype html><title>Coverage</title>');
33
- fs.outputFileSync(`${reportPath}/base.css`, 'body {}');
34
47
 
35
- await buildCoverage({ docs: { coveragePath: fixturePath }, docsDestination });
48
+ await buildCoverage({
49
+ docs: {
50
+ coverage: [
51
+ { id: 'cyberia', suite: 'cyberia' },
52
+ { id: 'hardhat', path: `${fixturePath}/hardhat` },
53
+ ],
54
+ },
55
+ docsDestination,
56
+ });
36
57
 
37
- expect(fs.existsSync(`${docsDestination}coverage/index.html`)).to.equal(true);
38
- expect(fs.existsSync(`${docsDestination}coverage/base.css`)).to.equal(true);
39
- expect(fs.existsSync(`${docsDestination}coverage/lcov-report`)).to.equal(false);
58
+ expect(fs.readFileSync(`${docsDestination}coverage/cyberia/index.html`, 'utf8')).to.include('cyberia');
59
+ expect(fs.readFileSync(`${docsDestination}coverage/hardhat/index.html`, 'utf8')).to.include('hardhat');
60
+ expect(fs.existsSync(`${docsDestination}coverage/hardhat/base.css`)).to.equal(true);
61
+ expect(fs.existsSync(`${docsDestination}coverage/hardhat/html`)).to.equal(false);
40
62
  });
41
63
 
42
- it('prefers an html report directory over the lcov one', async () => {
43
- fixturePath = fs.mkdtempSync('/tmp/engine-coverage-');
44
- fs.outputFileSync(`${fixturePath}/coverage/html/index.html`, '<!doctype html><title>html</title>');
45
- fs.outputFileSync(`${fixturePath}/coverage/lcov-report/index.html`, '<!doctype html><title>lcov</title>');
64
+ it('drops a previously published report the conf no longer declares', async () => {
65
+ fs.outputFileSync(`${fixturePath}/out/coverage/legacy/index.html`, '<!doctype html>');
66
+ fs.outputFileSync(`${fixturePath}/coverage/unit/index.html`, '<!doctype html><title>unit</title>');
46
67
 
47
- await buildCoverage({ docs: { coveragePath: fixturePath }, docsDestination: `${fixturePath}/out/` });
68
+ await buildCoverage({
69
+ docs: { coverage: [{ id: 'engine', suite: 'unit' }] },
70
+ docsDestination: `${fixturePath}/out/`,
71
+ });
48
72
 
49
- expect(fs.readFileSync(`${fixturePath}/out/coverage/index.html`, 'utf8')).to.include('html');
73
+ expect(fs.existsSync(`${fixturePath}/out/coverage/legacy`)).to.equal(false);
74
+ expect(fs.existsSync(`${fixturePath}/out/coverage/engine/index.html`)).to.equal(true);
50
75
  });
51
76
 
52
- it('publishes a flat report directory that carries the index itself', async () => {
53
- fixturePath = fs.mkdtempSync('/tmp/engine-coverage-');
54
- fs.outputFileSync(`${fixturePath}/coverage/index.html`, '<!doctype html><title>flat</title>');
77
+ it("publishes the run a report names, never another selection's", async () => {
78
+ fs.outputFileSync(`${fixturePath}/coverage/unit-infra-app-cyberia/index.html`, '<!doctype html><title>all</title>');
55
79
 
56
80
  await buildCoverage({
57
- docs: { coveragePath: fixturePath, coverageOutputDir: 'report' },
81
+ docs: { coverage: [{ id: 'engine', suite: 'unit,infra,app' }] },
58
82
  docsDestination: `${fixturePath}/out/`,
59
83
  });
60
84
 
61
- expect(fs.existsSync(`${fixturePath}/out/report/index.html`)).to.equal(true);
85
+ const page = fs.readFileSync(`${fixturePath}/out/coverage/engine/index.html`, 'utf8');
86
+ expect(page).to.include('Coverage report unavailable');
87
+ expect(page).to.include('node bin test unit,infra,app');
62
88
  });
63
89
 
64
90
  it('publishes the unavailable page for a coverage directory with no HTML index', async () => {
65
- fixturePath = fs.mkdtempSync('/tmp/engine-coverage-');
66
- fs.outputFileSync(`${fixturePath}/coverage/lcov.info`, 'TN:\n');
91
+ fs.outputFileSync(`${fixturePath}/hardhat/coverage/lcov.info`, 'TN:\n');
67
92
 
68
- await buildCoverage({ docs: { coveragePath: fixturePath }, docsDestination: `${fixturePath}/out/` });
93
+ await buildCoverage({
94
+ docs: { coverage: [{ id: 'hardhat', path: `${fixturePath}/hardhat` }] },
95
+ docsDestination: `${fixturePath}/out/`,
96
+ });
69
97
 
70
- expect(fs.readFileSync(`${fixturePath}/out/coverage/index.html`, 'utf8')).to.include('Coverage report unavailable');
98
+ expect(fs.readFileSync(`${fixturePath}/out/coverage/hardhat/index.html`, 'utf8')).to.include(
99
+ 'Coverage report unavailable',
100
+ );
71
101
  });
72
102
 
73
103
  it('publishes the report an assembled deploy artifact bundled', async () => {
74
- fixturePath = fs.mkdtempSync('/tmp/engine-coverage-');
75
104
  fs.outputFileSync(
76
- `${fixturePath}/${COVERAGE_BUNDLE_DIRECTORY}/index.html`,
105
+ `${fixturePath}/${COVERAGE_BUNDLE_DIRECTORY}/engine/index.html`,
77
106
  '<!doctype html><title>bundled</title>',
78
107
  );
79
108
 
80
- await buildCoverage({ docs: { coveragePath: fixturePath }, docsDestination: `${fixturePath}/out/` });
109
+ await buildCoverage({
110
+ docs: { coverage: [{ id: 'engine', suite: 'unit' }] },
111
+ docsDestination: `${fixturePath}/out/`,
112
+ });
81
113
 
82
- expect(fs.readFileSync(`${fixturePath}/out/coverage/index.html`, 'utf8')).to.include('bundled');
114
+ expect(fs.readFileSync(`${fixturePath}/out/coverage/engine/index.html`, 'utf8')).to.include('bundled');
83
115
  });
84
116
 
85
117
  it('prefers a freshly generated report over the bundled artifact', async () => {
86
- fixturePath = fs.mkdtempSync('/tmp/engine-coverage-');
87
- fs.outputFileSync(`${fixturePath}/coverage/lcov-report/index.html`, '<!doctype html><title>fresh</title>');
118
+ fs.outputFileSync(`${fixturePath}/coverage/unit/index.html`, '<!doctype html><title>fresh</title>');
88
119
  fs.outputFileSync(
89
- `${fixturePath}/${COVERAGE_BUNDLE_DIRECTORY}/index.html`,
120
+ `${fixturePath}/${COVERAGE_BUNDLE_DIRECTORY}/engine/index.html`,
90
121
  '<!doctype html><title>bundled</title>',
91
122
  );
92
123
 
93
- await buildCoverage({ docs: { coveragePath: fixturePath }, docsDestination: `${fixturePath}/out/` });
124
+ await buildCoverage({
125
+ docs: { coverage: [{ id: 'engine', suite: 'unit' }] },
126
+ docsDestination: `${fixturePath}/out/`,
127
+ });
94
128
 
95
- expect(fs.readFileSync(`${fixturePath}/out/coverage/index.html`, 'utf8')).to.include('fresh');
129
+ expect(fs.readFileSync(`${fixturePath}/out/coverage/engine/index.html`, 'utf8')).to.include('fresh');
96
130
  });
97
131
 
98
132
  // Regression: the build used to shell out to `npm test` when no report was present. Inside a
99
133
  // pod that spent minutes of the build phase on a test runner, and the suite's expected
100
134
  // non-zero exits latched `container-status=error`, failing a healthy rollout.
101
135
  it('never runs a test runner to produce a missing report', async () => {
102
- fixturePath = fs.mkdtempSync('/tmp/engine-coverage-');
103
136
  fs.outputJsonSync(`${fixturePath}/package.json`, {
104
137
  scripts: { coverage: 'vitest run --coverage', test: 'vitest run' },
105
138
  });
106
139
  const exec = vi.spyOn(shell, 'exec');
107
140
 
108
- await buildCoverage({ docs: { coveragePath: fixturePath }, docsDestination: `${fixturePath}/out/` });
141
+ await buildCoverage({
142
+ docs: { coverage: [{ id: 'engine', suite: 'unit' }] },
143
+ docsDestination: `${fixturePath}/out/`,
144
+ });
109
145
 
110
146
  expect(exec.mock.calls.length).to.equal(0);
111
- expect(fs.readFileSync(`${fixturePath}/out/coverage/index.html`, 'utf8')).to.include('Coverage report unavailable');
147
+ expect(fs.readFileSync(`${fixturePath}/out/coverage/engine/index.html`, 'utf8')).to.include(
148
+ 'Coverage report unavailable',
149
+ );
112
150
  });
113
151
 
114
- it('publishes nothing for a deploy that declares no coverage source', async () => {
115
- fixturePath = fs.mkdtempSync('/tmp/engine-coverage-');
116
-
152
+ it('publishes nothing for a deploy that declares no coverage report', async () => {
117
153
  await buildCoverage({ docs: {}, docsDestination: `${fixturePath}/out/` });
118
154
 
119
155
  expect(fs.existsSync(`${fixturePath}/out`)).to.equal(false);
@@ -137,7 +173,7 @@ describe('docs build', () => {
137
173
  publicClientId: 'fixture',
138
174
  rootClientPath: `${fixturePath}/client`,
139
175
  packageData: PACKAGE_DATA,
140
- docs: { jsJsonPath: `${fixturePath}/typedoc.json`, coveragePath: `${fixturePath}/nothing-here` },
176
+ docs: { jsJsonPath: `${fixturePath}/typedoc.json` },
141
177
  ...overrides,
142
178
  });
143
179
 
@@ -146,21 +182,16 @@ describe('docs build', () => {
146
182
  fixturePath = fs.mkdtempSync('/tmp/engine-docs-');
147
183
  publicRoot = `./public/${HOST}`;
148
184
  if (fs.existsSync(publicRoot)) throw new Error(`Refusing to write fixtures into an existing site: ${publicRoot}`);
149
- vi.useFakeTimers();
185
+ staged.swaggerJson = null;
150
186
  });
151
187
 
152
188
  afterEach(() => {
153
- vi.useRealTimers();
154
189
  vi.restoreAllMocks();
155
190
  fs.removeSync(fixturePath);
156
191
  fs.removeSync(publicRoot);
157
192
  fs.removeSync('.typedoc.tmp.json');
158
193
  });
159
194
 
160
- const flushApiDocs = async () => {
161
- await vi.advanceTimersByTimeAsync(1);
162
- };
163
-
164
195
  it('renders the typedoc runtime config without mutating the base config on disk', async () => {
165
196
  const baseConfig = { tsconfig: './tsconfig.docs.json', name: 'Base', entryPoints: ['./src'] };
166
197
  fs.outputJsonSync(`${fixturePath}/typedoc.json`, baseConfig);
@@ -174,11 +205,9 @@ describe('docs build', () => {
174
205
  await runBuildDocs({
175
206
  docs: {
176
207
  jsJsonPath: `${fixturePath}/typedoc.json`,
177
- coveragePath: `${fixturePath}/nothing-here`,
178
208
  references: [`${fixturePath}/reference-a.md`, `${fixturePath}/missing.md`],
179
209
  },
180
210
  });
181
- await flushApiDocs();
182
211
 
183
212
  expect(tmpConfig.name).to.equal('Fixture API');
184
213
  expect(tmpConfig.out).to.equal(`./public/${HOST}/docs/engine/1.2.3/`);
@@ -198,7 +227,6 @@ describe('docs build', () => {
198
227
  });
199
228
 
200
229
  await runBuildDocs({ metadata: {}, docs: { jsJsonPath: `${fixturePath}/typedoc.json`, references: [] } });
201
- await flushApiDocs();
202
230
 
203
231
  expect(tmpConfig.name).to.equal('Base');
204
232
  expect(tmpConfig).not.to.have.property('projectDocuments');
@@ -207,14 +235,12 @@ describe('docs build', () => {
207
235
  it('skips the typedoc build when the deploy declares no config', async () => {
208
236
  const exec = vi.spyOn(shell, 'exec');
209
237
  await runBuildDocs({ docs: { jsJsonPath: `${fixturePath}/absent.json` } });
210
- await flushApiDocs();
211
238
  expect(exec.mock.calls.length).to.equal(0);
212
239
  });
213
240
 
214
241
  it('documents only the routers it has schemas for, at the deploy version', async () => {
215
242
  const exec = vi.spyOn(shell, 'exec').mockReturnValue({ code: 0, stdout: '', stderr: '' });
216
243
  await runBuildDocs();
217
- await flushApiDocs();
218
244
 
219
245
  expect(exec.mock.calls.length).to.equal(0);
220
246
  expect(generated.length).to.equal(1);
@@ -228,7 +254,6 @@ describe('docs build', () => {
228
254
 
229
255
  it('falls back to a generic title and empty description without metadata', async () => {
230
256
  await runBuildDocs({ metadata: {}, docs: { jsJsonPath: `${fixturePath}/absent.json` } });
231
- await flushApiDocs();
232
257
  expect(generated[0].doc.info.title).to.equal('REST API');
233
258
  expect(generated[0].doc.info.description).to.equal('');
234
259
  });
@@ -240,14 +265,12 @@ describe('docs build', () => {
240
265
  try {
241
266
  process.env.NODE_ENV = 'development';
242
267
  await runBuildDocs({ path: '/store', docs: { jsJsonPath: `${fixturePath}/absent.json` } });
243
- await flushApiDocs();
244
268
  expect(generated[0].doc.servers[0].url).to.equal('http://localhost:4000/store/api');
245
269
  expect(generated[0].outputFile).to.equal(`./public/${HOST}/store/swagger-output.json`);
246
270
 
247
271
  generated.length = 0;
248
272
  process.env.NODE_ENV = 'production';
249
273
  await runBuildDocs({ docs: { jsJsonPath: `${fixturePath}/absent.json` } });
250
- await flushApiDocs();
251
274
  expect(generated[0].doc.servers[0].url).to.equal(`https://${HOST}/api`);
252
275
  } finally {
253
276
  process.env.NODE_ENV = previousEnv;
@@ -267,7 +290,6 @@ describe('docs build', () => {
267
290
  apiBaseProxyPath: '/',
268
291
  docs: { jsJsonPath: `${fixturePath}/absent.json` },
269
292
  });
270
- await flushApiDocs();
271
293
  expect(generated[0].doc.servers[0].url).to.equal('http://localhost:4017/api');
272
294
 
273
295
  generated.length = 0;
@@ -278,7 +300,6 @@ describe('docs build', () => {
278
300
  apiBaseProxyPath: '/store',
279
301
  docs: { jsJsonPath: `${fixturePath}/absent.json` },
280
302
  });
281
- await flushApiDocs();
282
303
  expect(generated[0].doc.servers[0].url).to.equal('https://api.fixture.test/store/api');
283
304
  } finally {
284
305
  process.env.NODE_ENV = previousEnv;
@@ -292,8 +313,7 @@ describe('docs build', () => {
292
313
  // did not emit, and has to strip the OAS-2 `in: body` parameter it did.
293
314
  it('injects the request bodies into the generated document', async () => {
294
315
  const outputFile = `./public/${HOST}/swagger-output.json`;
295
- await runBuildDocs({ docs: { jsJsonPath: `${fixturePath}/absent.json` } });
296
- fs.outputJsonSync(outputFile, {
316
+ staged.swaggerJson = {
297
317
  paths: {
298
318
  '/user': {
299
319
  post: {
@@ -305,8 +325,8 @@ describe('docs build', () => {
305
325
  },
306
326
  '/user/{id}': { get: {} },
307
327
  },
308
- });
309
- await flushApiDocs();
328
+ };
329
+ await runBuildDocs({ docs: { jsJsonPath: `${fixturePath}/absent.json` } });
310
330
 
311
331
  const patched = fs.readJsonSync(outputFile);
312
332
  expect(patched.paths['/user'].post.requestBody.content['application/json'].schema.$ref).to.equal(
@@ -318,11 +338,18 @@ describe('docs build', () => {
318
338
 
319
339
  it('leaves the generated document alone when no operation matches', async () => {
320
340
  const outputFile = `./public/${HOST}/swagger-output.json`;
341
+ staged.swaggerJson = { paths: { '/health': { get: {} } } };
321
342
  await runBuildDocs({ docs: { jsJsonPath: `${fixturePath}/absent.json` } });
322
- fs.outputJsonSync(outputFile, { paths: { '/health': { get: {} } } });
323
- await flushApiDocs();
324
343
  expect(fs.readJsonSync(outputFile)).to.deep.equal({ paths: { '/health': { get: {} } } });
325
344
  });
345
+
346
+ // Regression: the spec was generated on a timer the build never awaited, so a bundle zipped
347
+ // right after the build shipped without it and the pod restoring it served no api-docs.
348
+ it('has written the spec by the time the build returns', async () => {
349
+ staged.swaggerJson = { paths: {} };
350
+ await runBuildDocs({ docs: { jsJsonPath: `${fixturePath}/absent.json` } });
351
+ expect(fs.existsSync(`./public/${HOST}/swagger-output.json`)).to.equal(true);
352
+ });
326
353
  });
327
354
 
328
355
  describe('swagger UI options', () => {
@@ -0,0 +1,313 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @module client-bundle.test
5
+ * @description Covers the client bundle transport: the route set push and pull agree on, the
6
+ * uniform host filter and split option both runners parse, and the pull path that merges split
7
+ * parts, extracts them in-process, and installs the result under the served public directory.
8
+ *
9
+ * The shell boundary is mocked so the deterministic parts — route selection, artifact
10
+ * resolution, and the local zip round trip — run for real.
11
+ *
12
+ * Uses 'chai' for assertions.
13
+ */
14
+
15
+ import { expect } from 'chai';
16
+ import fs from 'fs-extra';
17
+ import { vi } from 'vitest';
18
+
19
+ const shellCommands = [];
20
+ vi.mock('../../src/server/runtime/process.js', async (importOriginal) => ({
21
+ ...(await importOriginal()),
22
+ shellExec: (command) => {
23
+ shellCommands.push(command);
24
+ return '';
25
+ },
26
+ }));
27
+
28
+ const {
29
+ CLIENT_BUNDLE_DIRECTORY,
30
+ clientBundleTrackedKeys,
31
+ clientBundleManifestKey,
32
+ clientBundleArtifactPaths,
33
+ clientBundleEntriesFactory,
34
+ clientBundleHostFilter,
35
+ clientBundleSplitFlag,
36
+ clientBundleStorageFilePath,
37
+ pullClientBundle,
38
+ pushClientBundle,
39
+ } = await import('../../src/client-builder/client-bundle.js');
40
+ const { zipFromLocalFiles } = await import('../../src/server/storage/zip.js');
41
+ const { program } = await import('../../src/cli/index.js');
42
+
43
+ const CONF_SERVER = {
44
+ 'a.com': {
45
+ '/': { client: 'A' },
46
+ '/peer': { client: 'P' },
47
+ '/old': { redirect: '/' },
48
+ '/frozen': { disabledRebuild: true },
49
+ '/multi': { client: 'M', replicas: ['/multi-1', '/multi-2'] },
50
+ '/single': { client: 'S', singleReplica: true, replicas: ['/single-1'] },
51
+ },
52
+ 'b.com': { '/': { client: 'B' } },
53
+ };
54
+
55
+ describe('client bundle runner input', () => {
56
+ it('parses the comma-separated host filter both runners share', () => {
57
+ expect(clientBundleHostFilter(' a.com , b.com ,, ')).to.deep.equal(['a.com', 'b.com']);
58
+ expect(clientBundleHostFilter('')).to.deep.equal([]);
59
+ expect(clientBundleHostFilter(undefined)).to.deep.equal([]);
60
+ });
61
+
62
+ it('resolves the split option, defaulting an unusable value rather than failing the build', () => {
63
+ expect(clientBundleSplitFlag(undefined)).to.equal('--split 8');
64
+ expect(clientBundleSplitFlag('16')).to.equal('--split 16');
65
+ expect(clientBundleSplitFlag('none')).to.equal('');
66
+ expect(clientBundleSplitFlag('0')).to.equal('--split 8');
67
+ expect(clientBundleSplitFlag('abc')).to.equal('--split 8');
68
+ });
69
+
70
+ it('names one storage manifest per deployment', () => {
71
+ expect(clientBundleStorageFilePath('dd-test')).to.equal('engine-private/conf/dd-test/storage.bundle.json');
72
+ });
73
+ });
74
+
75
+ describe('client bundle route selection', () => {
76
+ let fixturePath;
77
+ let previousCwd;
78
+
79
+ beforeEach(() => {
80
+ previousCwd = process.cwd();
81
+ fixturePath = fs.mkdtempSync('/tmp/engine-client-bundle-');
82
+ process.chdir(fixturePath);
83
+ fs.mkdirSync('engine-private/conf/dd-test', { recursive: true });
84
+ fs.writeFileSync('engine-private/conf/dd-test/conf.server.json', JSON.stringify(CONF_SERVER), 'utf8');
85
+ shellCommands.length = 0;
86
+ });
87
+
88
+ afterEach(() => {
89
+ process.chdir(previousCwd);
90
+ fs.removeSync(fixturePath);
91
+ });
92
+
93
+ const buildIds = (options) => clientBundleEntriesFactory(options).map((entry) => entry.buildId);
94
+
95
+ it('selects every route the client build emits a bundle for, replicas included', () => {
96
+ expect(buildIds({ deployId: 'dd-test' })).to.deep.equal([
97
+ 'a.com-',
98
+ 'a.com-peer',
99
+ 'a.com-multi',
100
+ 'a.com-multi-1',
101
+ 'a.com-multi-2',
102
+ 'b.com-',
103
+ ]);
104
+ });
105
+
106
+ it('skips the routes the client build skips', () => {
107
+ const selected = buildIds({ deployId: 'dd-test' });
108
+ expect(selected).to.not.include('a.com-old');
109
+ expect(selected).to.not.include('a.com-frozen');
110
+ expect(selected).to.not.include('a.com-single');
111
+ expect(selected).to.not.include('a.com-single-1');
112
+ });
113
+
114
+ it('maps a route to its served public directory', () => {
115
+ const entries = clientBundleEntriesFactory({ deployId: 'dd-test', hosts: ['a.com'] });
116
+ const byId = Object.fromEntries(entries.map((entry) => [entry.buildId, entry.publicPath]));
117
+ expect(byId['a.com-']).to.equal('public/a.com');
118
+ expect(byId['a.com-peer']).to.equal('public/a.com/peer');
119
+ });
120
+
121
+ it('applies the host filter, and resolves nothing for an unknown host or deployment', () => {
122
+ expect(buildIds({ deployId: 'dd-test', hosts: ['b.com'] })).to.deep.equal(['b.com-']);
123
+ expect(buildIds({ deployId: 'dd-test', hosts: ['absent.com'] })).to.deep.equal([]);
124
+ expect(buildIds({ deployId: 'dd-absent' })).to.deep.equal([]);
125
+ });
126
+
127
+ it('resolves split parts ahead of a single zip, and nothing when neither is present', () => {
128
+ fs.mkdirSync(CLIENT_BUNDLE_DIRECTORY, { recursive: true });
129
+ for (const part of ['001', '002']) fs.writeFileSync(`${CLIENT_BUNDLE_DIRECTORY}/a.com-.zip.part${part}`, 'x');
130
+ fs.writeFileSync(`${CLIENT_BUNDLE_DIRECTORY}/b.com-.zip`, 'y');
131
+
132
+ expect(clientBundleArtifactPaths(`${CLIENT_BUNDLE_DIRECTORY}/a.com-.zip`).artifactPaths).to.have.lengthOf(2);
133
+ expect(clientBundleArtifactPaths(`${CLIENT_BUNDLE_DIRECTORY}/b.com-.zip`).artifactPaths).to.deep.equal([
134
+ `${CLIENT_BUNDLE_DIRECTORY}/b.com-.zip`,
135
+ ]);
136
+ expect(clientBundleArtifactPaths(`${CLIENT_BUNDLE_DIRECTORY}/absent-.zip`).artifactPaths).to.deep.equal([]);
137
+ expect(clientBundleArtifactPaths('./no-such-dir/x.zip').artifactPaths).to.deep.equal([]);
138
+ });
139
+
140
+ it('uploads every artifact of every resolved route, and reports the routes with none', () => {
141
+ fs.mkdirSync(CLIENT_BUNDLE_DIRECTORY, { recursive: true });
142
+ for (const part of ['001', '002']) fs.writeFileSync(`${CLIENT_BUNDLE_DIRECTORY}/b.com-.zip.part${part}`, 'x');
143
+
144
+ const result = pushClientBundle({ deployId: 'dd-test', hosts: ['b.com'], split: '4' });
145
+
146
+ expect(result).to.deep.equal({ pushed: 1, skipped: 0 });
147
+ expect(shellCommands.some((command) => command.includes('--build-zip --split 4'))).to.equal(true);
148
+ const uploads = shellCommands.filter((command) => command.includes(' fs '));
149
+ expect(uploads).to.have.lengthOf(2);
150
+ expect(uploads.every((command) => command.includes('--storage-id bundle') && command.includes('--force'))).to.equal(
151
+ true,
152
+ );
153
+
154
+ shellCommands.length = 0;
155
+ expect(pushClientBundle({ deployId: 'dd-test', hosts: ['a.com'] })).to.deep.equal({ pushed: 0, skipped: 5 });
156
+ });
157
+
158
+ it('shells out only to subcommands the CLI still registers', async () => {
159
+ // Regression: both directions called `underpost env`, a deprecated alias removed when the
160
+ // env and config commands were folded into the host configuration store. Nothing failed at
161
+ // import time, so bundle mode stayed broken until a deployment ran it.
162
+ fs.mkdirSync(CLIENT_BUNDLE_DIRECTORY, { recursive: true });
163
+ fs.writeFileSync('bundled.html', '<h1>bundled</h1>', 'utf8');
164
+ fs.writeFileSync(
165
+ `${CLIENT_BUNDLE_DIRECTORY}/b.com-.zip`,
166
+ await zipFromLocalFiles([{ localPath: 'bundled.html', entryName: 'index.html' }]),
167
+ );
168
+ pushClientBundle({ deployId: 'dd-test', hosts: ['b.com'] });
169
+ await pullClientBundle({ deployId: 'dd-test', hosts: ['b.com'] });
170
+
171
+ const pull = shellCommands.find((command) => command.includes(' fs ') && command.includes('--pull'));
172
+ expect(pull).to.include('--tracked').and.to.include('--storage-id bundle');
173
+ expect(pull).not.to.include('--storage-file-path');
174
+
175
+ const registered = program.commands.map((command) => command.name());
176
+ const invoked = [
177
+ ...new Set(
178
+ shellCommands
179
+ .map((command) => /(?:^|\s)(?:node\s+\S*bin\S*|underpost)\s+([a-z-]+)/.exec(command)?.[1])
180
+ .filter(Boolean),
181
+ ),
182
+ ];
183
+
184
+ expect(invoked).to.not.be.empty;
185
+ for (const subcommand of invoked) expect(registered, subcommand).to.include(subcommand);
186
+ });
187
+
188
+ it('untracks bundle parts a smaller build no longer produces', async () => {
189
+ const manifest = {
190
+ 'build/b.com-.zip.part001': { bytes: 1 },
191
+ 'build/b.com-.zip.part002': { bytes: 1 },
192
+ 'build/b.com-.zip.part003': { bytes: 1 },
193
+ 'build/other.com-.zip.part003': { bytes: 1 },
194
+ };
195
+ expect(clientBundleTrackedKeys(manifest, 'b.com-')).to.deep.equal([
196
+ 'build/b.com-.zip.part001',
197
+ 'build/b.com-.zip.part002',
198
+ 'build/b.com-.zip.part003',
199
+ ]);
200
+
201
+ fs.mkdirSync(CLIENT_BUNDLE_DIRECTORY, { recursive: true });
202
+ for (const part of ['001', '002']) fs.writeFileSync(`${CLIENT_BUNDLE_DIRECTORY}/b.com-.zip.part${part}`, 'x');
203
+ fs.writeFileSync('engine-private/conf/dd-test/storage.bundle.json', JSON.stringify(manifest), 'utf8');
204
+
205
+ shellCommands.length = 0;
206
+ expect(fs.existsSync('build/b.com-.zip.part003')).to.equal(false);
207
+ expect(pushClientBundle({ deployId: 'dd-test', hosts: ['b.com'] })).to.deep.equal({ pushed: 1, skipped: 0 });
208
+
209
+ const removals = shellCommands.filter((command) => command.includes(' --rm'));
210
+ expect(removals, 'exactly the part this build did not produce').to.have.lengthOf(1);
211
+ expect(removals[0]).to.include(" fs --tracked --key 'build/b.com-.zip.part003'");
212
+ expect(removals[0]).to.include('--deploy-id dd-test --storage-id bundle --rm');
213
+ expect(removals[0]).to.not.include('other.com');
214
+ const uploads = shellCommands.filter((command) => command.includes(' fs ') && command.includes('--force'));
215
+ expect(uploads).to.have.lengthOf(2);
216
+ expect(shellCommands.indexOf(removals[0])).to.be.greaterThan(shellCommands.indexOf(uploads[1]));
217
+ });
218
+
219
+ it('merges, extracts, and installs a split bundle into the public directory', async () => {
220
+ fs.mkdirSync('source/nested', { recursive: true });
221
+ fs.writeFileSync('source/index.html', '<h1>bundle</h1>'.repeat(100), 'utf8');
222
+ fs.writeFileSync('source/nested/app.js', 'console.log(1);'.repeat(100), 'utf8');
223
+ const archive = await zipFromLocalFiles([
224
+ { localPath: 'source/index.html', entryName: 'index.html' },
225
+ { localPath: 'source/nested/app.js', entryName: 'nested/app.js' },
226
+ ]);
227
+
228
+ fs.mkdirSync(CLIENT_BUNDLE_DIRECTORY, { recursive: true });
229
+ const partSize = Math.ceil(archive.length / 3);
230
+ for (let offset = 0, index = 1; offset < archive.length; offset += partSize, index++) {
231
+ const name = `${CLIENT_BUNDLE_DIRECTORY}/b.com-.zip.part${String(index).padStart(3, '0')}`;
232
+ fs.writeFileSync(name, archive.subarray(offset, offset + partSize));
233
+ }
234
+
235
+ const result = await pullClientBundle({ deployId: 'dd-test', hosts: ['b.com'] });
236
+
237
+ expect(result).to.deep.equal({ pulled: 1, skipped: 0 });
238
+ expect(fs.readFileSync('public/b.com/nested/app.js', 'utf8')).to.equal(
239
+ fs.readFileSync('source/nested/app.js', 'utf8'),
240
+ );
241
+ expect(fs.existsSync(`${CLIENT_BUNDLE_DIRECTORY}/b.com-.zip`)).to.equal(false);
242
+ expect(fs.readdirSync(CLIENT_BUNDLE_DIRECTORY).filter((name) => name.includes('.part'))).to.deep.equal([]);
243
+ });
244
+
245
+ it('maps a pulled part back to the storage key that produced it', () => {
246
+ // A pull keeping the downloaded archive writes `<key>.zip`; an unwrapped part is the key.
247
+ expect(clientBundleManifestKey('build/a.com-.zip.part001.zip')).to.equal('build/a.com-.zip.part001');
248
+ expect(clientBundleManifestKey('./build/a.com-.zip.part001.zip')).to.equal('build/a.com-.zip.part001');
249
+ expect(clientBundleManifestKey('build/a.com-.zip.part001')).to.equal('build/a.com-.zip.part001');
250
+ });
251
+
252
+ it('refuses a bundle that reassembles to a different size than was pushed', async () => {
253
+ // Regression: one part's upload failed silently, so the store kept the previous build's copy.
254
+ // The parts merged without complaint and only the assembled archive was corrupt, 190MB later.
255
+ fs.writeFileSync('source.html', '<h1>bundle</h1>'.repeat(100), 'utf8');
256
+ const archive = await zipFromLocalFiles([{ localPath: 'source.html', entryName: 'index.html' }]);
257
+ fs.mkdirSync(CLIENT_BUNDLE_DIRECTORY, { recursive: true });
258
+
259
+ const partSize = Math.ceil(archive.length / 2);
260
+ const partPaths = [];
261
+ for (let offset = 0, index = 1; offset < archive.length; offset += partSize, index++) {
262
+ const name = `${CLIENT_BUNDLE_DIRECTORY}/b.com-.zip.part${String(index).padStart(3, '0')}`;
263
+ fs.writeFileSync(name, archive.subarray(offset, offset + partSize));
264
+ partPaths.push(name.replace(/^\.\//, ''));
265
+ }
266
+
267
+ // The manifest records a larger second part, as a build whose last part never replaced the
268
+ // previous one would.
269
+ fs.writeFileSync(
270
+ 'engine-private/conf/dd-test/storage.bundle.json',
271
+ JSON.stringify({
272
+ [partPaths[0]]: { bytes: fs.statSync(partPaths[0]).size },
273
+ [partPaths[1]]: { bytes: fs.statSync(partPaths[1]).size + 4096 },
274
+ }),
275
+ 'utf8',
276
+ );
277
+
278
+ let thrown;
279
+ await pullClientBundle({ deployId: 'dd-test', hosts: ['b.com'] }).catch((error) => (thrown = error));
280
+
281
+ expect(thrown?.message).to.match(/reassembled to \d+ bytes, expected \d+/);
282
+ expect(fs.existsSync('public/b.com'), 'a bundle that failed verification must not be installed').to.equal(false);
283
+ });
284
+
285
+ it('installs a bundle whose parts match the pushed sizes', async () => {
286
+ fs.writeFileSync('source.html', '<h1>bundle</h1>'.repeat(100), 'utf8');
287
+ const archive = await zipFromLocalFiles([{ localPath: 'source.html', entryName: 'index.html' }]);
288
+ fs.mkdirSync(CLIENT_BUNDLE_DIRECTORY, { recursive: true });
289
+
290
+ const partSize = Math.ceil(archive.length / 2);
291
+ const manifest = {};
292
+ for (let offset = 0, index = 1; offset < archive.length; offset += partSize, index++) {
293
+ const name = `${CLIENT_BUNDLE_DIRECTORY}/b.com-.zip.part${String(index).padStart(3, '0')}`;
294
+ fs.writeFileSync(name, archive.subarray(offset, offset + partSize));
295
+ manifest[name.replace(/^\.\//, '')] = { bytes: fs.statSync(name).size };
296
+ }
297
+ fs.writeFileSync('engine-private/conf/dd-test/storage.bundle.json', JSON.stringify(manifest), 'utf8');
298
+
299
+ expect(await pullClientBundle({ deployId: 'dd-test', hosts: ['b.com'] })).to.deep.equal({ pulled: 1, skipped: 0 });
300
+ expect(fs.existsSync('public/b.com/index.html')).to.equal(true);
301
+ });
302
+
303
+ it('skips a route whose bundle never arrived rather than clearing its public directory', async () => {
304
+ fs.mkdirSync('public/b.com', { recursive: true });
305
+ fs.writeFileSync('public/b.com/index.html', 'live', 'utf8');
306
+
307
+ const result = await pullClientBundle({ deployId: 'dd-test', hosts: ['b.com'] });
308
+
309
+ expect(result).to.deep.equal({ pulled: 0, skipped: 1 });
310
+ expect(fs.readFileSync('public/b.com/index.html', 'utf8')).to.equal('live');
311
+ expect(shellCommands.some((command) => command.startsWith('sudo rm -rf'))).to.equal(false);
312
+ });
313
+ });