@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.
- package/.env.example +17 -5
- package/.github/workflows/coverall.cyberia.ci.yml +10 -0
- package/.github/workflows/docker-image.cyberia-client.ci.yml +1 -1
- package/.github/workflows/docker-image.cyberia-client.dev.ci.yml +1 -1
- package/.github/workflows/docker-image.cyberia-server.ci.yml +1 -1
- package/.github/workflows/docker-image.cyberia-server.dev.ci.yml +1 -1
- package/.github/workflows/docker-image.engine-cyberia.ci.yml +1 -1
- package/.github/workflows/docker-image.engine-cyberia.dev.ci.yml +1 -1
- package/.github/workflows/engine-cyberia.ci.yml +1 -1
- package/.github/workflows/publish.cyberia.ci.yml +44 -0
- package/AGENTS.md +61 -12
- package/CHANGELOG.md +159 -161
- package/CLI-HELP.md +20 -13
- package/Dockerfile +1 -1
- package/Dockerfile.dev +1 -1
- package/Dockerfile.test +2 -2
- package/README.md +28 -28
- package/bin/build.js +96 -21
- package/bin/cyberia.js +1247 -1373
- package/bin/index.js +1247 -1373
- package/compose.env +17 -5
- package/conf.js +7 -4
- package/deploy/cyberia-client/deploy.sh +10 -11
- package/deploy/cyberia-client/package.sh +0 -1
- package/deploy/cyberia-client/state.sh +3 -2
- package/deploy/cyberia-server/deploy.sh +10 -11
- package/deploy/cyberia-server/package.sh +0 -1
- package/deploy/cyberia-server/state.sh +3 -2
- package/deploy/dd-cyberia/deploy.sh +5 -5
- package/deploy/dd-cyberia/init.sh +15 -14
- package/deploy/dd-cyberia/package.sh +0 -1
- package/deploy/dd-cyberia/state.sh +2 -2
- package/deploy/dd-cyberia/sync-deploy.sh +139 -75
- package/deploy/lib/config.sh +29 -0
- package/deploy/lib/github-actions-logging.sh +92 -76
- package/deploy/lib/host.sh +28 -19
- package/deploy/lib/state.sh +4 -1
- package/deploy/pwa-microservices-template/deploy.sh +10 -8
- package/deploy/release/deploy.sh +0 -2
- package/deployment.yaml +1 -1
- package/docker-compose.yml +23 -17
- package/docs/coverage/cyberia/api/cyberia-instance/cyberia-fallback-capture.js.html +1591 -0
- package/docs/coverage/cyberia/api/cyberia-instance/index.html +116 -0
- package/docs/coverage/cyberia/api/cyberia-server-defaults/cyberia-server-defaults.js.html +4138 -0
- package/docs/coverage/cyberia/api/cyberia-server-defaults/index.html +116 -0
- package/docs/coverage/cyberia/api/object-layer/index.html +116 -0
- package/docs/coverage/cyberia/api/object-layer/object-layer.model.js.html +1393 -0
- package/docs/coverage/cyberia/base.css +362 -0
- package/docs/coverage/cyberia/block-navigation.js +82 -0
- package/docs/coverage/cyberia/favicon.png +0 -0
- package/docs/coverage/cyberia/index.html +161 -0
- package/docs/coverage/cyberia/prettify.css +101 -0
- package/docs/coverage/cyberia/prettify.js +937 -0
- package/docs/coverage/cyberia/projects/cyberia/atlas-sprite-sheet-generator.js.html +1315 -0
- package/docs/coverage/cyberia/projects/cyberia/index.html +176 -0
- package/docs/coverage/cyberia/projects/cyberia/instance-backup.js.html +715 -0
- package/docs/coverage/cyberia/projects/cyberia/instance-data.js.html +2182 -0
- package/docs/coverage/cyberia/projects/cyberia/shape-generator.js.html +3265 -0
- package/docs/coverage/cyberia/projects/cyberia/stat-balance.js.html +328 -0
- package/docs/coverage/cyberia/sort-arrow-sprite.png +0 -0
- package/docs/coverage/cyberia/sorter.js +205 -0
- package/hardhat/package-lock.json +2 -2
- package/hardhat/package.json +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +1 -1
- package/manifests/deployment/dd-cyberia-development/deployment.yaml +1 -1
- package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
- package/manifests/deployment/mongo-express/deployment.yaml +46 -27
- package/manifests/deployment/mongo-express/kustomization.yaml +9 -0
- package/manifests/deployment/mongo-express/mongo-express-nodeport.yaml +18 -0
- package/manifests/deployment/mongo-express/service.yaml +16 -0
- package/manifests/deployment/mongo-express-no-auth/disable-mongodb-auth.yaml +16 -0
- package/manifests/deployment/mongo-express-no-auth/kustomization.yaml +16 -0
- package/package.json +19 -17
- package/scripts/test-monitor.sh +3 -3
- package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.controller.js +14 -9
- package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.model.js +36 -5
- package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.router.js +5 -0
- package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.service.js +88 -184
- package/src/api/cyberia-action/cyberia-action.router.js +2 -2
- package/src/api/cyberia-action/cyberia-action.service.js +3 -4
- package/src/api/cyberia-audio/cyberia-audio.controller.js +6 -0
- package/src/api/cyberia-audio/cyberia-audio.model.js +61 -0
- package/src/api/cyberia-audio/cyberia-audio.router.js +21 -0
- package/src/api/cyberia-audio/cyberia-audio.service.js +220 -0
- package/src/api/cyberia-client-hints/cyberia-client-hints.controller.js +1 -3
- package/src/api/cyberia-client-hints/cyberia-client-hints.model.js +10 -19
- package/src/api/cyberia-client-hints/cyberia-client-hints.router.js +12 -34
- package/src/api/cyberia-client-hints/cyberia-client-hints.service.js +15 -32
- package/src/api/cyberia-dialogue/cyberia-dialogue.model.js +1 -1
- package/src/api/cyberia-dialogue/cyberia-dialogue.router.js +1 -1
- package/src/api/cyberia-entity/cyberia-entity.model.js +2 -0
- package/src/api/cyberia-entity/cyberia-entity.service.js +1 -1
- package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.controller.js +8 -2
- package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.model.js +31 -20
- package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.router.js +16 -1
- package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.service.js +466 -3
- package/src/api/cyberia-instance/cyberia-fallback-capture.js +87 -51
- package/src/api/cyberia-instance/cyberia-fallback-world.js +21 -40
- package/src/api/cyberia-instance/cyberia-instance-boot.service.js +5 -5
- package/src/api/cyberia-instance/cyberia-instance-items.js +98 -0
- package/src/api/cyberia-instance/cyberia-instance-map.service.js +34 -141
- package/src/api/cyberia-instance/cyberia-instance.controller.js +1 -4
- package/src/api/cyberia-instance/cyberia-instance.model.js +0 -14
- package/src/api/cyberia-instance/cyberia-instance.router.js +3 -16
- package/src/api/cyberia-instance/cyberia-instance.service.js +3 -43
- package/src/api/cyberia-instance/cyberia-world-generator.js +8 -13
- package/src/api/cyberia-instance-conf/cyberia-instance-conf.model.js +49 -75
- package/src/api/cyberia-instance-conf/cyberia-instance-conf.service.js +74 -1
- package/src/api/cyberia-map/cyberia-map.service.js +6 -2
- package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.controller.js +31 -0
- package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.model.js +56 -0
- package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.router.js +33 -0
- package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.service.js +189 -0
- package/src/api/cyberia-quest/cyberia-quest.router.js +3 -3
- package/src/api/cyberia-quest/cyberia-quest.service.js +3 -3
- package/src/api/cyberia-quest-progress/cyberia-quest-progress.model.js +1 -1
- package/src/api/cyberia-server-defaults/cyberia-server-defaults.js +403 -188
- package/src/api/cyberia-skill/cyberia-skill.model.js +6 -6
- package/src/api/file/file.ref.js +153 -0
- package/src/api/file/file.ref.json +11 -2
- package/src/api/object-layer/object-layer.model.js +47 -12
- package/src/api/object-layer/object-layer.router.js +10 -3
- package/src/api/object-layer/object-layer.service.js +113 -120
- package/src/cli/app.js +12 -1
- package/src/cli/client.js +2 -2
- package/src/cli/cluster.js +80 -18
- package/src/cli/deploy.js +215 -34
- package/src/cli/docker-compose.js +1 -1
- package/src/cli/fs-selection.js +115 -0
- package/src/cli/fs.js +158 -342
- package/src/cli/index.js +22 -10
- package/src/cli/release.js +3 -1
- package/src/cli/repository.js +35 -9
- package/src/cli/run.js +80 -199
- package/src/cli/test.js +4 -0
- package/src/cli/wireguard.js +32 -31
- package/src/client/components/core/Badge.js +0 -1
- package/src/client/components/core/CssCore.js +8 -1
- package/src/client/components/core/Docs.js +77 -100
- package/src/client/components/core/Modal.js +77 -23
- package/src/client/components/core/ToolTip.js +15 -0
- package/src/client/components/cryptokoyn/AppShellCryptokoyn.js +0 -2
- package/src/client/components/cyberia/ActionEngineCyberia.js +12 -36
- package/src/client/components/cyberia/EntityEngineCyberia.js +492 -121
- package/src/client/components/cyberia/InstanceEngineCyberia.js +129 -160
- package/src/client/components/cyberia/InstanceSelectionView.js +7 -1
- package/src/client/components/cyberia/MapEngineCyberia.js +175 -69
- package/src/client/components/cyberia/ObjectLayerEngine.js +1011 -97
- package/src/client/components/cyberia/ObjectLayerEngineModal.js +81 -155
- package/src/client/components/cyberia/ObjectLayerEngineViewer.js +113 -98
- package/src/client/components/cyberia/SharedDefaultsCyberia.js +215 -56
- package/src/client/components/cyberia-portal/AppShellCyberiaPortal.js +0 -34
- package/src/client/components/cyberia-portal/CssCyberiaPortal.js +1 -1
- package/src/client/components/cyberia-portal/MainBodyCyberiaPortal.js +712 -76
- package/src/client/components/cyberia-portal/RouterCyberiaPortal.js +0 -4
- package/src/client/components/cyberia-portal/TranslateCyberiaPortal.js +0 -4
- package/src/client/components/default/AppShellDefault.js +1 -4
- package/src/client/components/itemledger/AppShellItemledger.js +0 -2
- package/src/client/components/underpost/AppShellUnderpost.js +1 -1
- package/src/client/public/cyberia-docs/ARCHITECTURE.md +15 -15
- package/src/client/public/cyberia-docs/CYBERIA-CLI.md +280 -24
- package/src/client/public/cyberia-docs/CYBERIA-CLIENT.md +51 -29
- package/src/client/public/cyberia-docs/CYBERIA-SAGA.md +52 -53
- package/src/client/public/cyberia-docs/CYBERIA-SERVER.md +16 -11
- package/src/client/public/cyberia-docs/CYBERIA.md +28 -28
- package/src/client/public/cyberia-docs/ENTITY-PROFILE.md +11 -5
- package/src/client/public/cyberia-docs/ROADMAP.md +1 -1
- package/src/client/public/cyberia-docs/STATS-PROGRESSION.md +217 -0
- package/src/client/public/cyberia-docs/WHITE-PAPER.md +20 -12
- package/src/client/services/cyberia-audio/cyberia-audio.service.js +99 -0
- package/src/client/services/cyberia-entity-type-default/cyberia-entity-type-default.service.js +38 -0
- package/src/client/services/cyberia-instance/cyberia-instance.management.js +5 -4
- package/src/client/services/cyberia-instance/cyberia-instance.service.js +0 -40
- package/src/client/services/cyberia-map/cyberia-map.management.js +5 -4
- package/src/client/services/cyberia-map-audio-conf/cyberia-map-audio-conf.service.js +138 -0
- package/src/client/services/object-layer/object-layer.management.js +6 -10
- package/src/client/services/object-layer/object-layer.service.js +3 -1
- package/src/client/ssr/views/Cyberia404.js +3 -3
- package/src/client-builder/client-build-docs.js +52 -52
- package/src/client-builder/client-build.js +67 -44
- package/src/client-builder/client-bundle.js +347 -0
- package/src/db/mongo/MongoExpress.js +187 -0
- package/src/grpc/cyberia/grpc-server.js +5 -7
- package/src/index.js +1 -1
- package/src/projects/cyberia/atlas-sprite-sheet-generator.js +143 -146
- package/src/projects/cyberia/atlas-sprite-sheet-store.js +391 -0
- package/src/projects/cyberia/besu-genesis-generator.js +5 -14
- package/src/projects/cyberia/boot-contract-fixtures.js +32 -0
- package/src/projects/cyberia/catalog-cyberia.js +3 -2
- package/src/projects/cyberia/gemini-client.js +3 -3
- package/src/projects/cyberia/generate-saga.js +4 -11
- package/src/projects/cyberia/hot-reload-trigger.js +17 -28
- package/src/projects/cyberia/instance-backup.js +210 -0
- package/src/projects/cyberia/instance-data.js +177 -257
- package/src/projects/cyberia/map-preview-generator.js +32 -68
- package/src/projects/cyberia/object-layer.js +152 -319
- package/src/projects/cyberia/seed-audio.js +139 -0
- package/src/projects/cyberia/semantic-layer-generator-skin.js +2 -8
- package/src/projects/cyberia/semantic-layer-generator.js +2 -18
- package/src/projects/cyberia/stat-balance.js +81 -0
- package/src/projects/cyberia/stat-commands.js +23 -0
- package/src/projects/cyberia/stat-contract-generator.js +101 -0
- package/src/runtime/cyberia-client/Dockerfile +8 -4
- package/src/runtime/cyberia-client/Dockerfile.dev +5 -2
- package/src/runtime/cyberia-server/Dockerfile +9 -2
- package/src/runtime/cyberia-server/Dockerfile.dev +8 -1
- package/src/runtime/engine-cyberia/Dockerfile +1 -1
- package/src/runtime/engine-cyberia/Dockerfile.dev +1 -1
- package/src/runtime/engine-cyberia/Dockerfile.test +2 -2
- package/src/runtime/engine-cyberia/compose.env +17 -5
- package/src/runtime/engine-cyberia/docker-compose.yml +23 -17
- package/src/server/build/coverage.js +113 -42
- package/src/server/build/package.js +55 -12
- package/src/server/build/testing.js +59 -9
- package/src/server/network/middlewares.js +8 -2
- package/src/server/network/underpost-gateway.js +113 -14
- package/src/server/ops/logger.js +70 -35
- package/src/server/runtime/conf.js +40 -9
- package/src/server/runtime/start.js +11 -2
- package/src/server/storage/data-query.js +16 -0
- package/src/server/storage/downloader.js +62 -14
- package/src/server/storage/zip.js +153 -0
- package/test/integration/app/cyberia/atlas-sprite-sheet-store.test.js +402 -0
- package/test/integration/app/cyberia/atlas-sprite-sheet.test.js +86 -0
- package/test/integration/app/cyberia/cyberia-cli-plain-reads.test.js +34 -0
- package/test/integration/app/cyberia/cyberia-entity-type-default.test.js +842 -0
- package/test/integration/app/cyberia/cyberia-instance-conf-coerce.test.js +53 -0
- package/test/integration/app/cyberia/cyberia-instance-conf-defaults.test.js +16 -22
- package/test/integration/app/cyberia/cyberia-instance-items.test.js +90 -0
- package/test/integration/app/cyberia/cyberia-load.test.js +6 -7
- package/test/integration/app/cyberia/cyberia-map-audio-conf.test.js +256 -0
- package/test/integration/app/cyberia/cyberia-stats.test.js +150 -0
- package/test/integration/app/cyberia/fallback-world-capture.test.js +87 -2
- package/test/integration/app/cyberia/object-layer-item-selection.test.js +56 -0
- package/test/integration/app/cyberia/object-layer-natural-key.test.js +32 -0
- package/test/integration/app/cyberia/seed-audio.test.js +275 -0
- package/test/integration/infra/2-network/wireguard-cli.test.js +33 -9
- package/test/integration/infra/2-network/wireguard-edge.test.js +58 -18
- package/test/integration/infra/3-cluster/mongo-express-deploy.test.js +156 -0
- package/test/integration/infra/4-ingress/api-cross-origin.test.js +79 -0
- package/test/integration/infra/4-ingress/underpost-ingress.test.js +1 -1
- package/test/unit/catalog.test.js +13 -2
- package/test/unit/client-build-docs.test.js +97 -70
- package/test/unit/client-bundle.test.js +313 -0
- package/test/unit/conf-resolution.test.js +58 -7
- package/test/unit/coverage-artifact.test.js +120 -29
- package/test/unit/cyberia/instance-backup.test.js +183 -0
- package/test/unit/cyberia/instance-data.test.js +314 -0
- package/test/unit/cyberia/instance-object-layer-items.test.js +46 -0
- package/test/unit/cyberia/publish-workflow.test.js +22 -0
- package/test/unit/cyberia/stat-balance.test.js +69 -0
- package/test/unit/deploy-legacy-gateway-sweep.test.js +63 -0
- package/test/unit/deploy-log-table.test.js +76 -5
- package/test/unit/downloader-integrity.test.js +98 -0
- package/test/unit/file-reference-registry.test.js +141 -0
- package/test/unit/fs-storage-paths.test.js +596 -0
- package/test/unit/gateway-pod-fetch.test.js +95 -0
- package/test/unit/letsencrypt-issuer.test.js +101 -0
- package/test/unit/logger-redaction.test.js +29 -0
- package/test/unit/package.test.js +70 -16
- package/test/unit/prepare-host.test.js +84 -1
- package/test/unit/release-bump.test.js +3 -1
- package/test/unit/start-options.test.js +12 -0
- package/test/unit/test-tiers.test.js +27 -0
- package/test/unit/zip-archive.test.js +142 -0
- package/vitest.config.js +11 -3
- package/src/api/cyberia-instance/cyberia-fallback-default-items.js +0 -63
- package/src/client/components/cyberia/FallbackWorldEngineCyberia.js +0 -368
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
import { expect, vi } from 'vitest';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { Command } from 'commander';
|
|
6
|
+
import JSZip from 'jszip';
|
|
7
|
+
import { v2 as cloudinary } from 'cloudinary';
|
|
8
|
+
import UnderpostFileStorage from '../../src/cli/fs.js';
|
|
9
|
+
import UnderpostRepository from '../../src/cli/repository.js';
|
|
10
|
+
import Downloader from '../../src/server/storage/downloader.js';
|
|
11
|
+
import { program } from '../../src/cli/index.js';
|
|
12
|
+
import { clearTerminalStringColor } from '../../src/client/components/core/CommonJs.js';
|
|
13
|
+
|
|
14
|
+
vi.mock('cloudinary', () => ({
|
|
15
|
+
v2: {
|
|
16
|
+
config: vi.fn(),
|
|
17
|
+
uploader: { upload: vi.fn() },
|
|
18
|
+
api: { resource: vi.fn(), delete_resources: vi.fn() },
|
|
19
|
+
utils: { download_archive_url: vi.fn() },
|
|
20
|
+
},
|
|
21
|
+
}));
|
|
22
|
+
vi.mock('node:child_process', async (importOriginal) => {
|
|
23
|
+
const original = await importOriginal();
|
|
24
|
+
return { ...original, execFileSync: vi.fn(original.execFileSync) };
|
|
25
|
+
});
|
|
26
|
+
vi.mock('../../src/server/runtime/process.js', async (importOriginal) => ({
|
|
27
|
+
...(await importOriginal()),
|
|
28
|
+
shellExec: vi.fn(() => {
|
|
29
|
+
throw new Error('Storage must not run shell commands.');
|
|
30
|
+
}),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
const api = UnderpostFileStorage.API;
|
|
34
|
+
const options = { deployId: 'test' };
|
|
35
|
+
const inventory = {
|
|
36
|
+
'assets/z.png': { type: 'private', bytes: 3 },
|
|
37
|
+
'other/b.png': {},
|
|
38
|
+
'assets/deep/c.png': {},
|
|
39
|
+
'assets/a.png': {},
|
|
40
|
+
'assets/gone.png': {},
|
|
41
|
+
};
|
|
42
|
+
let fixture;
|
|
43
|
+
let previousCwd;
|
|
44
|
+
let download;
|
|
45
|
+
|
|
46
|
+
const writeInventory = (storage = inventory, extra = {}) =>
|
|
47
|
+
fs.outputJsonSync(api.resolveManifest({ ...options, ...extra }), storage);
|
|
48
|
+
const readInventory = (extra = {}) => api.readManifest({ ...options, ...extra }).storage;
|
|
49
|
+
const select = (scope, extra = {}) => api.resolveSelection(scope, readInventory(), extra);
|
|
50
|
+
const run = (scope, extra = {}) => api.callback(scope, { ...options, ...extra });
|
|
51
|
+
const git = (...args) => execFileSync('git', args, { encoding: 'utf8' });
|
|
52
|
+
const uploadPaths = () => cloudinary.uploader.upload.mock.calls.map(([key]) => key);
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
previousCwd = process.cwd();
|
|
56
|
+
fixture = fs.mkdtempSync('/tmp/engine-fs-storage-');
|
|
57
|
+
process.chdir(fixture);
|
|
58
|
+
for (const file of ['assets/z.png', 'assets/deep/c.png', 'assets/a.png', 'assets/untracked.png', 'other/b.png'])
|
|
59
|
+
fs.outputFileSync(file, 'local');
|
|
60
|
+
writeInventory();
|
|
61
|
+
vi.stubEnv('CLOUDINARY_CLOUD_NAME', 'test-cloud');
|
|
62
|
+
vi.stubEnv('CLOUDINARY_API_KEY', 'test-key');
|
|
63
|
+
vi.stubEnv('CLOUDINARY_API_SECRET', 'test-secret');
|
|
64
|
+
cloudinary.uploader.upload.mockImplementation(async (key) => ({ public_id: key, type: 'private', bytes: 6 }));
|
|
65
|
+
cloudinary.api.delete_resources.mockImplementation(async ([key]) => ({ deleted: { [key]: 'deleted' } }));
|
|
66
|
+
cloudinary.utils.download_archive_url.mockImplementation(({ type }) => `https://example.test/${type}`);
|
|
67
|
+
download = vi.spyOn(Downloader, 'downloadFile').mockImplementation(async (_url, target) => {
|
|
68
|
+
const zip = new JSZip();
|
|
69
|
+
zip.file('remote-asset', 'remote');
|
|
70
|
+
fs.writeFileSync(target, await zip.generateAsync({ type: 'nodebuffer' }));
|
|
71
|
+
});
|
|
72
|
+
for (const name of ['getChangedFiles', 'getDeleteFiles', 'initLocalRepo', 'isInsideWorkTree'])
|
|
73
|
+
vi.spyOn(UnderpostRepository.API, name).mockImplementation(() => {
|
|
74
|
+
throw new Error(`Unexpected Git call: ${name}`);
|
|
75
|
+
});
|
|
76
|
+
execFileSync.mockClear();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
afterEach(() => {
|
|
80
|
+
vi.restoreAllMocks();
|
|
81
|
+
vi.resetAllMocks();
|
|
82
|
+
vi.unstubAllEnvs();
|
|
83
|
+
process.chdir(previousCwd);
|
|
84
|
+
fs.removeSync(fixture);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('storage manifest resolution', () => {
|
|
88
|
+
it('uses the default manifest unless a sub-id is explicit', async () => {
|
|
89
|
+
expect(api.resolveManifest(options)).toBe('engine-private/conf/test/storage.json');
|
|
90
|
+
expect(api.resolveManifest({ ...options, storageId: 'assets' })).toBe(
|
|
91
|
+
'engine-private/conf/test/storage.assets.json',
|
|
92
|
+
);
|
|
93
|
+
writeInventory({ 'custom/file.png': {} }, { storageId: 'assets' });
|
|
94
|
+
const pull = vi.spyOn(api, 'pull').mockResolvedValue('private');
|
|
95
|
+
await run(undefined, { tracked: true, pull: true });
|
|
96
|
+
expect(pull.mock.calls.map(([key]) => key)).toEqual(Object.keys(inventory));
|
|
97
|
+
pull.mockClear();
|
|
98
|
+
await run(undefined, { tracked: true, pull: true, storageId: 'assets' });
|
|
99
|
+
expect(pull.mock.calls.map(([key]) => key)).toEqual(['custom/file.png']);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it.each(['', '../assets', '/tmp/a', 'storage.assets.json', 'assets.json', 'a/b', 'a\\b'])(
|
|
103
|
+
'rejects invalid sub-id %j',
|
|
104
|
+
(storageId) => {
|
|
105
|
+
expect(() => api.resolveManifest({ ...options, storageId })).toThrow('--storage-id');
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
it('requires a deploy id and rejects manifest path overrides', () => {
|
|
110
|
+
expect(() => api.resolveManifest({})).toThrow('--deploy-id');
|
|
111
|
+
expect(() => api.resolveManifest({ deployId: '../test' })).toThrow('--deploy-id');
|
|
112
|
+
expect(() => api.resolveManifest({ ...options, storageFilePath: '/tmp/storage.json' })).toThrow('--storage-id');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('does not create a missing manifest during selection', async () => {
|
|
116
|
+
fs.removeSync('engine-private');
|
|
117
|
+
expect(api.readManifest(options).storage).toEqual({});
|
|
118
|
+
await run(undefined, { tracked: true });
|
|
119
|
+
expect(fs.existsSync('engine-private')).toBe(false);
|
|
120
|
+
expect(cloudinary.config).not.toHaveBeenCalled();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it.each([[[]], [null], [{ a: null }], [{ a: { bytes: -1 } }], [{ a: { type: 'unknown' } }], [{ a: {}, './a': {} }]])(
|
|
124
|
+
'rejects an invalid manifest before remote access: %j',
|
|
125
|
+
async (storage) => {
|
|
126
|
+
writeInventory(storage);
|
|
127
|
+
await expect(run('assets')).rejects.toThrow(/Manifest|manifest/);
|
|
128
|
+
expect(cloudinary.config).not.toHaveBeenCalled();
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('filesystem storage selection', () => {
|
|
134
|
+
it('selects one file without reading its parent', async () => {
|
|
135
|
+
const walk = vi.spyOn(fs, 'readdirSync').mockImplementation(() => {
|
|
136
|
+
throw new Error('Unexpected traversal');
|
|
137
|
+
});
|
|
138
|
+
await run('./assets/a.png');
|
|
139
|
+
expect(uploadPaths()).toEqual(['assets/a.png']);
|
|
140
|
+
expect(walk).not.toHaveBeenCalled();
|
|
141
|
+
expect(execFileSync).not.toHaveBeenCalled();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('selects all files below a directory without Git or --recursive', async () => {
|
|
145
|
+
fs.outputFileSync('assets/.hidden', 'hidden');
|
|
146
|
+
await run('assets');
|
|
147
|
+
expect(uploadPaths().sort()).toEqual([
|
|
148
|
+
'assets/.hidden',
|
|
149
|
+
'assets/a.png',
|
|
150
|
+
'assets/deep/c.png',
|
|
151
|
+
'assets/untracked.png',
|
|
152
|
+
'assets/z.png',
|
|
153
|
+
]);
|
|
154
|
+
expect(execFileSync).not.toHaveBeenCalled();
|
|
155
|
+
expect(cloudinary.api.delete_resources).not.toHaveBeenCalled();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('keeps selection unchanged with --force or --recursive', () => {
|
|
159
|
+
expect(select('assets', { force: true })).toEqual(select('assets'));
|
|
160
|
+
expect(select('assets', { recursive: true })).toEqual(select('assets'));
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('normalizes absolute paths and reuses existing manifest keys', () => {
|
|
164
|
+
writeInventory({ './assets/a.png': {} });
|
|
165
|
+
expect(select(path.resolve('assets/a.png'))).toEqual(['./assets/a.png']);
|
|
166
|
+
expect(api.normalizeStoragePath('./assets/deep/../a.png')).toBe('assets/a.png');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('does not follow directory link cycles', () => {
|
|
170
|
+
fs.symlinkSync(path.resolve('assets'), 'assets/loop', 'dir');
|
|
171
|
+
expect(select('assets')).toHaveLength(4);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('requires --tracked to select missing files', async () => {
|
|
175
|
+
await expect(run('assets/gone.png', { rm: true })).rejects.toThrow('ENOENT');
|
|
176
|
+
expect(cloudinary.api.delete_resources).not.toHaveBeenCalled();
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe('Git storage selection', () => {
|
|
181
|
+
beforeEach(() => {
|
|
182
|
+
git('init', '-q');
|
|
183
|
+
fs.writeFileSync('.gitignore', 'assets/ignored.png\n');
|
|
184
|
+
fs.outputFileSync('assets/ignored.png', 'ignored');
|
|
185
|
+
fs.outputFileSync('assets/space and\nnewline.png', 'tracked');
|
|
186
|
+
git(
|
|
187
|
+
'add',
|
|
188
|
+
'--',
|
|
189
|
+
'.gitignore',
|
|
190
|
+
'assets/a.png',
|
|
191
|
+
'assets/z.png',
|
|
192
|
+
'assets/deep/c.png',
|
|
193
|
+
'assets/space and\nnewline.png',
|
|
194
|
+
);
|
|
195
|
+
git('-c', 'user.name=Storage Test', '-c', 'user.email=storage@example.test', 'commit', '-qm', 'fixture');
|
|
196
|
+
fs.writeFileSync('assets/a.png', 'changed');
|
|
197
|
+
fs.removeSync('assets/z.png');
|
|
198
|
+
execFileSync.mockClear();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('selects unchanged and changed tracked files, excluding ignored and untracked files', async () => {
|
|
202
|
+
await run('assets', { git: true });
|
|
203
|
+
expect(uploadPaths().sort()).toEqual(['assets/a.png', 'assets/deep/c.png', 'assets/space and\nnewline.png']);
|
|
204
|
+
expect(cloudinary.api.delete_resources).not.toHaveBeenCalled();
|
|
205
|
+
expect(readInventory()).toHaveProperty('assets/z.png');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('restricts single files and subdirectories', () => {
|
|
209
|
+
expect(select('assets/untracked.png', { git: true })).toEqual([]);
|
|
210
|
+
expect(select('assets/ignored.png', { git: true })).toEqual([]);
|
|
211
|
+
expect(select('assets/a.png', { git: true })).toEqual(['assets/a.png']);
|
|
212
|
+
expect(select(path.resolve('assets/deep'), { git: true })).toEqual(['assets/deep/c.png']);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it.each([{}, { pull: true, force: true }, { rm: true }])(
|
|
216
|
+
'never changes Git state during CRUD: %j',
|
|
217
|
+
async (operation) => {
|
|
218
|
+
const head = git('rev-parse', 'HEAD');
|
|
219
|
+
const index = fs.readFileSync('.git/index');
|
|
220
|
+
execFileSync.mockClear();
|
|
221
|
+
await run('assets/a.png', { git: true, ...operation });
|
|
222
|
+
expect(execFileSync.mock.calls.map(([command, args]) => [command, ...args])).toEqual([
|
|
223
|
+
['git', 'rev-parse', '--show-toplevel'],
|
|
224
|
+
['git', 'ls-files', '--cached', '--full-name', '-z', '--', '.'],
|
|
225
|
+
]);
|
|
226
|
+
expect(fs.readFileSync('.git/index')).toEqual(index);
|
|
227
|
+
expect(git('rev-parse', 'HEAD')).toBe(head);
|
|
228
|
+
},
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
it('intersects Git paths with manifest ranges and regex filters', () => {
|
|
232
|
+
expect(select('assets', { git: true, fromKey: 'other/b.png', toKey: 'assets/a.png', keyRegex: '\\.png$' })).toEqual(
|
|
233
|
+
['assets/a.png', 'assets/deep/c.png'],
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe('tracked storage selection and filters', () => {
|
|
239
|
+
it('selects only ordered manifest entries without filesystem or Git access', async () => {
|
|
240
|
+
vi.spyOn(fs, 'statSync').mockImplementation(() => {
|
|
241
|
+
throw new Error('Unexpected stat');
|
|
242
|
+
});
|
|
243
|
+
vi.spyOn(fs, 'readdirSync').mockImplementation(() => {
|
|
244
|
+
throw new Error('Unexpected traversal');
|
|
245
|
+
});
|
|
246
|
+
const upload = vi.spyOn(api, 'upload').mockResolvedValue({});
|
|
247
|
+
await run(undefined, { tracked: true });
|
|
248
|
+
expect(upload.mock.calls.map(([key]) => key)).toEqual(Object.keys(inventory));
|
|
249
|
+
expect(execFileSync).not.toHaveBeenCalled();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('scopes missing files using only manifest paths', () => {
|
|
253
|
+
expect(select('assets/gone.png', { tracked: true })).toEqual(['assets/gone.png']);
|
|
254
|
+
expect(select('./assets/', { tracked: true })).toEqual(
|
|
255
|
+
Object.keys(inventory).filter((key) => key.startsWith('assets/')),
|
|
256
|
+
);
|
|
257
|
+
expect(select('asset', { tracked: true })).toEqual([]);
|
|
258
|
+
expect(select('.', { tracked: true })).toEqual(Object.keys(inventory));
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it.each([
|
|
262
|
+
[{ fromKey: 'assets/deep/c.png' }, ['assets/deep/c.png', 'assets/a.png', 'assets/gone.png']],
|
|
263
|
+
[{ fromKey: 'other/b.png', toKey: 'assets/a.png' }, ['other/b.png', 'assets/deep/c.png', 'assets/a.png']],
|
|
264
|
+
[{ fromKey: 'assets/a.png', toKey: 'assets/a.png' }, ['assets/a.png']],
|
|
265
|
+
[{ toKey: 'other/b.png' }, ['assets/z.png', 'other/b.png']],
|
|
266
|
+
[{ key: 'assets/a.png' }, ['assets/a.png']],
|
|
267
|
+
[{ keyRegex: '^assets/' }, ['assets/z.png', 'assets/deep/c.png', 'assets/a.png', 'assets/gone.png']],
|
|
268
|
+
[{ keyRegex: 'absent' }, []],
|
|
269
|
+
])('filters in manifest order: %j', (filters, expected) => {
|
|
270
|
+
expect(select(undefined, { tracked: true, ...filters })).toEqual(expected);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('composes scope, range, regex, and exact predicates', () => {
|
|
274
|
+
const filters = { tracked: true, fromKey: 'other/b.png', toKey: 'assets/a.png', keyRegex: '^assets/' };
|
|
275
|
+
expect(select('assets', filters)).toEqual(['assets/deep/c.png', 'assets/a.png']);
|
|
276
|
+
expect(select('assets', { ...filters, key: 'assets/a.png' })).toEqual(['assets/a.png']);
|
|
277
|
+
expect(select('assets/deep/c.png', filters)).toEqual(['assets/deep/c.png']);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('applies manifest predicates to filesystem paths without adding missing files', () => {
|
|
281
|
+
expect(select('assets', { fromKey: 'assets/deep/c.png', keyRegex: '^assets/' })).toEqual([
|
|
282
|
+
'assets/a.png',
|
|
283
|
+
'assets/deep/c.png',
|
|
284
|
+
]);
|
|
285
|
+
expect(select('assets/a.png', { key: 'assets/a.png' })).toEqual(['assets/a.png']);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it.each([
|
|
289
|
+
{ fromKey: 'missing' },
|
|
290
|
+
{ toKey: 'missing' },
|
|
291
|
+
{ key: 'missing' },
|
|
292
|
+
{ keyRegex: '[' },
|
|
293
|
+
{ fromKey: 'assets/a.png', toKey: 'assets/z.png' },
|
|
294
|
+
{ key: 'assets/z.png', fromKey: 'assets/a.png' },
|
|
295
|
+
{ key: 'assets/a.png', keyRegex: '^other/' },
|
|
296
|
+
{ key: 'other/b.png' },
|
|
297
|
+
])('rejects invalid filters before CRUD: %j', async (filters) => {
|
|
298
|
+
await expect(run('assets', { tracked: true, ...filters })).rejects.toThrow();
|
|
299
|
+
expect(cloudinary.config).not.toHaveBeenCalled();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it.each([
|
|
303
|
+
[undefined, {}],
|
|
304
|
+
['assets', { tracked: true, git: true }],
|
|
305
|
+
['assets', { rm: true, pull: true }],
|
|
306
|
+
['assets', { omitUnzip: true }],
|
|
307
|
+
])('rejects conflicting or incomplete options: %j', async (scope, flags) => {
|
|
308
|
+
await expect(run(scope, flags)).rejects.toThrow();
|
|
309
|
+
expect(cloudinary.config).not.toHaveBeenCalled();
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
describe('storage synchronization', () => {
|
|
314
|
+
it('uploads private raw assets with token access and records remote metadata', async () => {
|
|
315
|
+
await run('assets/a.png');
|
|
316
|
+
expect(cloudinary.uploader.upload).toHaveBeenCalledWith('assets/a.png', {
|
|
317
|
+
public_id: 'assets/a.png',
|
|
318
|
+
resource_type: 'raw',
|
|
319
|
+
type: 'private',
|
|
320
|
+
access_control: [{ access_type: 'token' }],
|
|
321
|
+
overwrite: false,
|
|
322
|
+
});
|
|
323
|
+
expect(readInventory()['assets/a.png']).toEqual({ type: 'private', bytes: 6 });
|
|
324
|
+
expect(fs.statSync('assets/a.png').size).toBe(5);
|
|
325
|
+
expect(cloudinary.config).toHaveBeenCalledWith({
|
|
326
|
+
cloud_name: 'test-cloud',
|
|
327
|
+
api_key: 'test-key',
|
|
328
|
+
api_secret: 'test-secret',
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('returns the skip sentinel for a file already present, without reaching the network', async () => {
|
|
333
|
+
const manifest = api.readManifest(options);
|
|
334
|
+
expect(await api.pull('assets/a.png', options, manifest)).to.equal('skipped');
|
|
335
|
+
expect(download).not.toHaveBeenCalled();
|
|
336
|
+
expect(cloudinary.config).not.toHaveBeenCalled();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it.each([
|
|
340
|
+
{ mode: 'plain', colorEnv: { NO_COLOR: '1' } },
|
|
341
|
+
{ mode: 'forced color', colorEnv: { FORCE_COLOR: '1' } },
|
|
342
|
+
{ mode: 'CI color', colorEnv: { CI: 'true', GITHUB_ACTIONS: 'true' } },
|
|
343
|
+
])('reports one skipped-pull count with $mode output', ({ colorEnv }) => {
|
|
344
|
+
fs.outputFileSync('assets/gone.png', 'local');
|
|
345
|
+
const cli = path.resolve(previousCwd, 'bin/index.js');
|
|
346
|
+
const childEnv = { ...process.env };
|
|
347
|
+
delete childEnv.NO_COLOR;
|
|
348
|
+
delete childEnv.FORCE_COLOR;
|
|
349
|
+
Object.assign(childEnv, colorEnv);
|
|
350
|
+
const output = clearTerminalStringColor(
|
|
351
|
+
execFileSync(process.execPath, [cli, 'fs', 'assets', '--deploy-id', 'test', '--pull', '--tracked'], {
|
|
352
|
+
cwd: fixture,
|
|
353
|
+
env: childEnv,
|
|
354
|
+
encoding: 'utf8',
|
|
355
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
356
|
+
}),
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
expect(output).to.not.include('Pull skipped; local file exists');
|
|
360
|
+
const summaries = [...output.matchAll(/Pull skipped files that already exist: (\{[^}]*\})/g)];
|
|
361
|
+
expect(summaries).to.have.lengthOf(1);
|
|
362
|
+
expect(JSON.parse(summaries[0][1])).toEqual({ skipped: 4, selected: 4 });
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('uses --force only to set upload overwrite', async () => {
|
|
366
|
+
await run('assets/a.png', { force: true });
|
|
367
|
+
expect(cloudinary.uploader.upload.mock.calls[0][1].overwrite).toBe(true);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('reads remote metadata when overwrite is refused', async () => {
|
|
371
|
+
cloudinary.uploader.upload.mockResolvedValue({ existing: true });
|
|
372
|
+
cloudinary.api.resource.mockResolvedValue({ public_id: 'assets/a.png', type: 'private', bytes: 100 });
|
|
373
|
+
await run('assets/a.png');
|
|
374
|
+
expect(readInventory()['assets/a.png'].bytes).toBe(100);
|
|
375
|
+
expect(cloudinary.api.resource).toHaveBeenCalledWith('assets/a.png', { resource_type: 'raw', type: 'private' });
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('requires environment credentials before remote access', async () => {
|
|
379
|
+
vi.stubEnv('CLOUDINARY_API_SECRET', '');
|
|
380
|
+
await expect(run('assets/a.png')).rejects.toThrow('CLOUDINARY_API_SECRET');
|
|
381
|
+
expect(cloudinary.uploader.upload).not.toHaveBeenCalled();
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it.each([
|
|
385
|
+
{ error: 'failed' },
|
|
386
|
+
{ public_id: 'wrong', type: 'private', bytes: 4 },
|
|
387
|
+
{ public_id: 'assets/a.png', type: 'private' },
|
|
388
|
+
])('preserves the manifest for an invalid upload response: %j', async (result) => {
|
|
389
|
+
cloudinary.uploader.upload.mockResolvedValue(result);
|
|
390
|
+
await expect(run('assets/a.png')).rejects.toThrow('Invalid Cloudinary');
|
|
391
|
+
expect(readInventory()).toEqual(inventory);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('does not create a manifest after a failed first upload', async () => {
|
|
395
|
+
fs.removeSync('engine-private');
|
|
396
|
+
cloudinary.uploader.upload.mockRejectedValue(new Error('upload failed'));
|
|
397
|
+
await expect(run('assets/a.png')).rejects.toThrow('upload failed');
|
|
398
|
+
expect(fs.existsSync('engine-private')).toBe(false);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('persists each successful upload before a later failure', async () => {
|
|
402
|
+
cloudinary.uploader.upload
|
|
403
|
+
.mockReset()
|
|
404
|
+
.mockResolvedValueOnce({ public_id: 'assets/z.png', type: 'private', bytes: 99 })
|
|
405
|
+
.mockRejectedValueOnce(new Error('upload failed'));
|
|
406
|
+
await expect(run(undefined, { tracked: true })).rejects.toThrow('upload failed');
|
|
407
|
+
expect(readInventory()).toEqual({ ...inventory, 'assets/z.png': { type: 'private', bytes: 99 } });
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it('reports missing tracked upload files without removing their entries', async () => {
|
|
411
|
+
await expect(run('assets/gone.png', { tracked: true })).rejects.toThrow('ENOENT');
|
|
412
|
+
expect(readInventory()).toEqual(inventory);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('preserves the manifest when an atomic write fails', async () => {
|
|
416
|
+
const before = fs.readFileSync(api.resolveManifest(options), 'utf8');
|
|
417
|
+
vi.spyOn(fs, 'renameSync').mockImplementation(() => {
|
|
418
|
+
throw new Error('rename failed');
|
|
419
|
+
});
|
|
420
|
+
await expect(run('assets/a.png')).rejects.toThrow('rename failed');
|
|
421
|
+
expect(fs.readFileSync(api.resolveManifest(options), 'utf8')).toBe(before);
|
|
422
|
+
expect(fs.readdirSync('engine-private/conf/test')).toEqual(['storage.json']);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('pulls missing assets and corrects metadata after delivery fallback', async () => {
|
|
426
|
+
download.mockRejectedValueOnce(new Error('not found'));
|
|
427
|
+
await run('assets/gone.png', { tracked: true, pull: true });
|
|
428
|
+
expect(fs.readFileSync('assets/gone.png', 'utf8')).toBe('remote');
|
|
429
|
+
expect(download.mock.calls.map(([url]) => url)).toEqual([
|
|
430
|
+
'https://example.test/upload',
|
|
431
|
+
'https://example.test/private',
|
|
432
|
+
]);
|
|
433
|
+
expect(cloudinary.utils.download_archive_url.mock.calls[0][0]).toEqual({
|
|
434
|
+
public_ids: ['assets/gone.png'],
|
|
435
|
+
resource_type: 'raw',
|
|
436
|
+
type: 'upload',
|
|
437
|
+
});
|
|
438
|
+
expect(readInventory()['assets/gone.png']).toEqual({ type: 'private', bytes: 6 });
|
|
439
|
+
expect(execFileSync).not.toHaveBeenCalled();
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('preserves existing local content unless --force is set', async () => {
|
|
443
|
+
await run('assets/a.png', { tracked: true, pull: true });
|
|
444
|
+
expect(download).not.toHaveBeenCalled();
|
|
445
|
+
expect(fs.readFileSync('assets/a.png', 'utf8')).toBe('local');
|
|
446
|
+
await run('assets/a.png', { tracked: true, pull: true, force: true });
|
|
447
|
+
expect(fs.readFileSync('assets/a.png', 'utf8')).toBe('remote');
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it('rejects a directory at a tracked file target', async () => {
|
|
451
|
+
fs.ensureDirSync('assets/gone.png');
|
|
452
|
+
await expect(run('assets/gone.png', { tracked: true, pull: true })).rejects.toThrow('regular file');
|
|
453
|
+
expect(download).not.toHaveBeenCalled();
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it('keeps delivery corrections across a multi-file pull', async () => {
|
|
457
|
+
writeInventory({ 'assets/first.png': {}, 'assets/second.png': {} });
|
|
458
|
+
download.mockRejectedValueOnce(new Error('not found'));
|
|
459
|
+
await run(undefined, { tracked: true, pull: true });
|
|
460
|
+
expect(readInventory()).toEqual({
|
|
461
|
+
'assets/first.png': { type: 'private', bytes: 6 },
|
|
462
|
+
'assets/second.png': { type: 'upload', bytes: 6 },
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it('keeps existing local content and inventory after failed downloads', async () => {
|
|
467
|
+
download.mockRejectedValue(new Error('download failed'));
|
|
468
|
+
await expect(run('assets/a.png', { pull: true, force: true })).rejects.toThrow('download failed');
|
|
469
|
+
expect(fs.readFileSync('assets/a.png', 'utf8')).toBe('local');
|
|
470
|
+
expect(readInventory()).toEqual(inventory);
|
|
471
|
+
expect(fs.readdirSync('assets').some((name) => name.startsWith('.underpost-fs-'))).toBe(false);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('does not record a pull or replace a file when the archive is invalid', async () => {
|
|
475
|
+
download.mockImplementation(async (_url, target) => fs.writeFileSync(target, 'invalid zip'));
|
|
476
|
+
await expect(run('assets/a.png', { pull: true, force: true })).rejects.toThrow();
|
|
477
|
+
expect(fs.readFileSync('assets/a.png', 'utf8')).toBe('local');
|
|
478
|
+
expect(readInventory()).toEqual(inventory);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it('keeps the wrapper archive with --omit-unzip and preserves zip asset names', async () => {
|
|
482
|
+
writeInventory({ 'assets/bundle.zip': {} });
|
|
483
|
+
await run(undefined, { tracked: true, pull: true, omitUnzip: true });
|
|
484
|
+
expect(fs.existsSync('assets/bundle.zip.zip')).toBe(true);
|
|
485
|
+
expect(fs.existsSync('assets/bundle.zip')).toBe(false);
|
|
486
|
+
download.mockClear();
|
|
487
|
+
await run(undefined, { tracked: true, pull: true, omitUnzip: true });
|
|
488
|
+
expect(download).not.toHaveBeenCalled();
|
|
489
|
+
await run(undefined, { tracked: true, pull: true });
|
|
490
|
+
expect(fs.readFileSync('assets/bundle.zip', 'utf8')).toBe('remote');
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it('requires a manifest entry for pull', async () => {
|
|
494
|
+
await expect(run('assets/untracked.png', { pull: true })).rejects.toThrow('not in the selected manifest');
|
|
495
|
+
expect(download).not.toHaveBeenCalled();
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it('deletes missing local assets and removes entries after remote confirmation', async () => {
|
|
499
|
+
await run('assets/gone.png', { tracked: true, rm: true });
|
|
500
|
+
expect(readInventory()).not.toHaveProperty('assets/gone.png');
|
|
501
|
+
expect(cloudinary.api.delete_resources.mock.calls).toEqual([
|
|
502
|
+
[['assets/gone.png'], { type: 'upload', resource_type: 'raw' }],
|
|
503
|
+
[['assets/gone.png'], { type: 'private', resource_type: 'raw' }],
|
|
504
|
+
]);
|
|
505
|
+
expect(execFileSync).not.toHaveBeenCalled();
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it('preserves local files during remote deletion, including with --force', async () => {
|
|
509
|
+
await run('assets/a.png', { rm: true, force: true });
|
|
510
|
+
expect(fs.readFileSync('assets/a.png', 'utf8')).toBe('local');
|
|
511
|
+
expect(readInventory()).not.toHaveProperty('assets/a.png');
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it('accepts confirmed remote absence', async () => {
|
|
515
|
+
cloudinary.api.delete_resources.mockImplementation(async ([key]) => ({ deleted: { [key]: 'not_found' } }));
|
|
516
|
+
await run('assets/gone.png', { tracked: true, rm: true });
|
|
517
|
+
expect(readInventory()).not.toHaveProperty('assets/gone.png');
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it.each([{}, { deleted: { 'assets/a.png': 'error' } }])(
|
|
521
|
+
'preserves inventory for an unconfirmed delete: %j',
|
|
522
|
+
async (result) => {
|
|
523
|
+
cloudinary.api.delete_resources.mockResolvedValue(result);
|
|
524
|
+
await expect(run('assets/a.png', { rm: true })).rejects.toThrow('Cloudinary delete failed');
|
|
525
|
+
expect(readInventory()).toEqual(inventory);
|
|
526
|
+
},
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
it('preserves inventory after a partial remote delete and supports retry', async () => {
|
|
530
|
+
cloudinary.api.delete_resources
|
|
531
|
+
.mockResolvedValueOnce({ deleted: { 'assets/a.png': 'deleted' } })
|
|
532
|
+
.mockRejectedValueOnce(new Error('delete failed'));
|
|
533
|
+
await expect(run('assets/a.png', { rm: true })).rejects.toThrow('delete failed');
|
|
534
|
+
expect(readInventory()).toEqual(inventory);
|
|
535
|
+
await run('assets/a.png', { rm: true });
|
|
536
|
+
expect(readInventory()).not.toHaveProperty('assets/a.png');
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it('updates only the selected custom manifest', async () => {
|
|
540
|
+
writeInventory({}, { storageId: 'assets' });
|
|
541
|
+
await run('assets/a.png', { storageId: 'assets' });
|
|
542
|
+
expect(readInventory()).toEqual(inventory);
|
|
543
|
+
expect(readInventory({ storageId: 'assets' })).toEqual({ 'assets/a.png': { type: 'private', bytes: 6 } });
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
describe('storage CLI options', () => {
|
|
548
|
+
const command = () => {
|
|
549
|
+
const registered = program.commands.find((entry) => entry.name() === 'fs');
|
|
550
|
+
const cli = new Command('fs')
|
|
551
|
+
.argument('[path]')
|
|
552
|
+
.exitOverride()
|
|
553
|
+
.configureOutput({ writeErr: () => {} });
|
|
554
|
+
for (const option of registered.options) cli.addOption(option);
|
|
555
|
+
return cli.action(api.callback);
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
it('parses the manifest selector and filters through the registered options', async () => {
|
|
559
|
+
writeInventory({ 'assets/gone.png': {} }, { storageId: 'assets' });
|
|
560
|
+
await command().parseAsync(
|
|
561
|
+
['--tracked', '--deploy-id', 'test', '--storage-id', 'assets', '--key', 'assets/gone.png', '--pull'],
|
|
562
|
+
{ from: 'user' },
|
|
563
|
+
);
|
|
564
|
+
expect(fs.readFileSync('assets/gone.png', 'utf8')).toBe('remote');
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it('deletes an obsolete bundle part through its exact manifest key without local files', async () => {
|
|
568
|
+
const current = 'build/underpost.net-.zip.part022';
|
|
569
|
+
const obsolete = 'build/underpost.net-.zip.part023';
|
|
570
|
+
const sibling = 'build/underpost.net-peer.zip.part023';
|
|
571
|
+
const storage = { [current]: { type: 'private', bytes: 6 }, [obsolete]: {}, [sibling]: {} };
|
|
572
|
+
writeInventory(storage, { storageId: 'bundle' });
|
|
573
|
+
expect(fs.existsSync('build')).toBe(false);
|
|
574
|
+
|
|
575
|
+
await command().parseAsync(
|
|
576
|
+
['--tracked', '--key', obsolete, '--deploy-id', 'test', '--storage-id', 'bundle', '--rm'],
|
|
577
|
+
{ from: 'user' },
|
|
578
|
+
);
|
|
579
|
+
|
|
580
|
+
expect(cloudinary.api.delete_resources.mock.calls.map(([keys]) => keys)).toEqual([[obsolete], [obsolete]]);
|
|
581
|
+
expect(readInventory({ storageId: 'bundle' })).toEqual({ [current]: storage[current], [sibling]: {} });
|
|
582
|
+
expect(readInventory()).toEqual(inventory);
|
|
583
|
+
expect(fs.existsSync('build')).toBe(false);
|
|
584
|
+
expect(execFileSync).not.toHaveBeenCalled();
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it('rejects the removed path override and a missing deploy id', async () => {
|
|
588
|
+
await expect(
|
|
589
|
+
command().parseAsync(['assets', '--deploy-id', 'test', '--storage-file-path', '/tmp/other.json'], {
|
|
590
|
+
from: 'user',
|
|
591
|
+
}),
|
|
592
|
+
).rejects.toThrow('unknown option');
|
|
593
|
+
await expect(command().parseAsync(['assets'], { from: 'user' })).rejects.toThrow('required option');
|
|
594
|
+
expect(cloudinary.config).not.toHaveBeenCalled();
|
|
595
|
+
});
|
|
596
|
+
});
|