@logictan/dsh-config-manager 0.1.60
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/LICENSE +21 -0
- package/README.md +105 -0
- package/cordis.patch.yml +21 -0
- package/lib/adapters/agent-instructions.d.ts +13 -0
- package/lib/adapters/agent-instructions.js +46 -0
- package/lib/adapters/agent-presets.d.ts +13 -0
- package/lib/adapters/agent-presets.js +14 -0
- package/lib/adapters/credentials.d.ts +37 -0
- package/lib/adapters/credentials.js +122 -0
- package/lib/adapters/file-collection.d.ts +15 -0
- package/lib/adapters/file-collection.js +127 -0
- package/lib/adapters/index.d.ts +57 -0
- package/lib/adapters/index.js +55 -0
- package/lib/adapters/link-report.d.ts +16 -0
- package/lib/adapters/link-report.js +47 -0
- package/lib/adapters/mcp.d.ts +28 -0
- package/lib/adapters/mcp.js +178 -0
- package/lib/adapters/plugin-files.d.ts +18 -0
- package/lib/adapters/plugin-files.js +142 -0
- package/lib/adapters/plugins.d.ts +41 -0
- package/lib/adapters/plugins.js +603 -0
- package/lib/adapters/pnpm-workspace.d.ts +26 -0
- package/lib/adapters/pnpm-workspace.js +147 -0
- package/lib/adapters/prompts.d.ts +30 -0
- package/lib/adapters/prompts.js +231 -0
- package/lib/adapters/providers.d.ts +34 -0
- package/lib/adapters/providers.js +155 -0
- package/lib/adapters/self.d.ts +17 -0
- package/lib/adapters/self.js +71 -0
- package/lib/adapters/sessions.d.ts +14 -0
- package/lib/adapters/sessions.js +15 -0
- package/lib/adapters/settings.d.ts +28 -0
- package/lib/adapters/settings.js +165 -0
- package/lib/adapters/skills.d.ts +13 -0
- package/lib/adapters/skills.js +14 -0
- package/lib/adapters/test-helpers.d.ts +125 -0
- package/lib/adapters/test-helpers.js +236 -0
- package/lib/adapters/ui.d.ts +27 -0
- package/lib/adapters/ui.js +89 -0
- package/lib/adapters/workspaces.d.ts +13 -0
- package/lib/adapters/workspaces.js +106 -0
- package/lib/client.d.ts +1026 -0
- package/lib/client.js +10047 -0
- package/lib/core/analyzer.d.ts +99 -0
- package/lib/core/analyzer.js +934 -0
- package/lib/core/backup.d.ts +138 -0
- package/lib/core/backup.js +513 -0
- package/lib/core/boot-rescue.d.ts +146 -0
- package/lib/core/boot-rescue.js +618 -0
- package/lib/core/cache-cleaner.d.ts +31 -0
- package/lib/core/cache-cleaner.js +95 -0
- package/lib/core/config-lifecycle.d.ts +180 -0
- package/lib/core/config-lifecycle.js +481 -0
- package/lib/core/config-snapshot.d.ts +135 -0
- package/lib/core/config-snapshot.js +424 -0
- package/lib/core/config-state.d.ts +70 -0
- package/lib/core/config-state.js +177 -0
- package/lib/core/consult-source.d.ts +58 -0
- package/lib/core/consult-source.js +260 -0
- package/lib/core/crash-report.d.ts +84 -0
- package/lib/core/crash-report.js +317 -0
- package/lib/core/exporter.d.ts +68 -0
- package/lib/core/exporter.js +375 -0
- package/lib/core/ghost-sweep.d.ts +19 -0
- package/lib/core/ghost-sweep.js +52 -0
- package/lib/core/importer.d.ts +60 -0
- package/lib/core/importer.js +29 -0
- package/lib/core/index.d.ts +14 -0
- package/lib/core/index.js +14 -0
- package/lib/core/journal.d.ts +197 -0
- package/lib/core/journal.js +418 -0
- package/lib/core/local-plugin-host.d.ts +35 -0
- package/lib/core/local-plugin-host.js +120 -0
- package/lib/core/local-plugin-pack.d.ts +135 -0
- package/lib/core/local-plugin-pack.js +278 -0
- package/lib/core/messages.d.ts +310 -0
- package/lib/core/messages.js +621 -0
- package/lib/core/migration-consult.d.ts +117 -0
- package/lib/core/migration-consult.js +363 -0
- package/lib/core/migration-history.d.ts +153 -0
- package/lib/core/migration-history.js +411 -0
- package/lib/core/model-tools.d.ts +50 -0
- package/lib/core/model-tools.js +148 -0
- package/lib/core/msg-types.d.ts +8 -0
- package/lib/core/msg-types.js +6 -0
- package/lib/core/phase3-child-crash.d.ts +1 -0
- package/lib/core/phase3-child-crash.js +87 -0
- package/lib/core/phase3-host.d.ts +138 -0
- package/lib/core/phase3-host.js +359 -0
- package/lib/core/phase3-prod-child.d.ts +1 -0
- package/lib/core/phase3-prod-child.js +35 -0
- package/lib/core/phase4-crash-child.d.ts +1 -0
- package/lib/core/phase4-crash-child.js +103 -0
- package/lib/core/plugin-cli.d.ts +150 -0
- package/lib/core/plugin-cli.js +497 -0
- package/lib/core/reconcile.d.ts +120 -0
- package/lib/core/reconcile.js +389 -0
- package/lib/core/recovery-orchestrator.d.ts +94 -0
- package/lib/core/recovery-orchestrator.js +327 -0
- package/lib/core/restore.d.ts +162 -0
- package/lib/core/restore.js +703 -0
- package/lib/core/rollback.d.ts +12 -0
- package/lib/core/rollback.js +145 -0
- package/lib/core/run-registry.d.ts +85 -0
- package/lib/core/run-registry.js +157 -0
- package/lib/core/startup-barrier.d.ts +53 -0
- package/lib/core/startup-barrier.js +77 -0
- package/lib/core/types.d.ts +515 -0
- package/lib/core/types.js +27 -0
- package/lib/core/undo.d.ts +73 -0
- package/lib/core/undo.js +87 -0
- package/lib/core/validator.d.ts +18 -0
- package/lib/core/validator.js +77 -0
- package/lib/core/verify-recovery.d.ts +21 -0
- package/lib/core/verify-recovery.js +256 -0
- package/lib/core/watcher.d.ts +101 -0
- package/lib/core/watcher.js +219 -0
- package/lib/index.d.ts +238 -0
- package/lib/index.js +2764 -0
- package/lib/migrations/index.d.ts +29 -0
- package/lib/migrations/index.js +56 -0
- package/lib/migrations/v1-to-v2.d.ts +9 -0
- package/lib/migrations/v1-to-v2.js +10 -0
- package/lib/profiles/index.d.ts +4 -0
- package/lib/profiles/index.js +5 -0
- package/lib/profiles/profile-manager.d.ts +106 -0
- package/lib/profiles/profile-manager.js +543 -0
- package/lib/schema/config.d.ts +28 -0
- package/lib/schema/config.js +165 -0
- package/lib/schema/index.d.ts +24 -0
- package/lib/schema/index.js +26 -0
- package/lib/schema/manifest.d.ts +28 -0
- package/lib/schema/manifest.js +109 -0
- package/lib/schema/tombstones.d.ts +46 -0
- package/lib/schema/tombstones.js +64 -0
- package/lib/schema/types.d.ts +232 -0
- package/lib/schema/types.js +8 -0
- package/lib/schema/versions.d.ts +27 -0
- package/lib/schema/versions.js +50 -0
- package/lib/security/encryption.d.ts +85 -0
- package/lib/security/encryption.js +279 -0
- package/lib/security/index.d.ts +16 -0
- package/lib/security/index.js +17 -0
- package/lib/security/integrity.d.ts +30 -0
- package/lib/security/integrity.js +78 -0
- package/lib/security/redaction.d.ts +26 -0
- package/lib/security/redaction.js +95 -0
- package/lib/security/secret-scanner.d.ts +86 -0
- package/lib/security/secret-scanner.js +404 -0
- package/lib/security/vault.d.ts +59 -0
- package/lib/security/vault.js +151 -0
- package/lib/security/zip-security.d.ts +27 -0
- package/lib/security/zip-security.js +154 -0
- package/lib/sync/autosync-config.d.ts +39 -0
- package/lib/sync/autosync-config.js +151 -0
- package/lib/sync/autosync-scheduler.d.ts +139 -0
- package/lib/sync/autosync-scheduler.js +564 -0
- package/lib/sync/fs.d.ts +18 -0
- package/lib/sync/fs.js +49 -0
- package/lib/sync/git/git-transport.d.ts +100 -0
- package/lib/sync/git/git-transport.js +437 -0
- package/lib/sync/github-auth.d.ts +131 -0
- package/lib/sync/github-auth.js +264 -0
- package/lib/sync/layout.d.ts +43 -0
- package/lib/sync/layout.js +203 -0
- package/lib/sync/snapshot-json.d.ts +9 -0
- package/lib/sync/snapshot-json.js +107 -0
- package/lib/sync/sync-config.d.ts +81 -0
- package/lib/sync/sync-config.js +281 -0
- package/lib/sync/sync-engine.d.ts +269 -0
- package/lib/sync/sync-engine.js +591 -0
- package/lib/sync/sync-history.d.ts +42 -0
- package/lib/sync/sync-history.js +78 -0
- package/lib/sync/sync-selection.d.ts +29 -0
- package/lib/sync/sync-selection.js +127 -0
- package/lib/sync/sync-session.d.ts +40 -0
- package/lib/sync/sync-session.js +54 -0
- package/lib/sync/sync-state.d.ts +37 -0
- package/lib/sync/sync-state.js +108 -0
- package/lib/sync/transport.d.ts +75 -0
- package/lib/sync/transport.js +55 -0
- package/lib/sync/ui-prefs.d.ts +34 -0
- package/lib/sync/ui-prefs.js +109 -0
- package/lib/sync/webdav/webdav-transport.d.ts +77 -0
- package/lib/sync/webdav/webdav-transport.js +451 -0
- package/lib/ui/errors.d.ts +19 -0
- package/lib/ui/errors.js +81 -0
- package/lib/ui/export-flow.d.ts +82 -0
- package/lib/ui/export-flow.js +98 -0
- package/lib/ui/i18n.d.ts +289 -0
- package/lib/ui/i18n.js +581 -0
- package/lib/ui/migration-consult-view.d.ts +91 -0
- package/lib/ui/migration-consult-view.js +115 -0
- package/lib/ui/progress.d.ts +38 -0
- package/lib/ui/progress.js +87 -0
- package/lib/ui/report.d.ts +23 -0
- package/lib/ui/report.js +153 -0
- package/lib/ui/test-helpers.d.ts +69 -0
- package/lib/ui/test-helpers.js +154 -0
- package/lib/ui/types.d.ts +257 -0
- package/lib/ui/types.js +21 -0
- package/lib/utils/atomic-write.d.ts +88 -0
- package/lib/utils/atomic-write.js +399 -0
- package/lib/utils/env-lock.d.ts +393 -0
- package/lib/utils/env-lock.js +930 -0
- package/lib/utils/hashing.d.ts +17 -0
- package/lib/utils/hashing.js +41 -0
- package/lib/utils/json.d.ts +24 -0
- package/lib/utils/json.js +76 -0
- package/lib/utils/logger.d.ts +39 -0
- package/lib/utils/logger.js +82 -0
- package/lib/utils/paths.d.ts +42 -0
- package/lib/utils/paths.js +189 -0
- package/lib/utils/proxy.d.ts +69 -0
- package/lib/utils/proxy.js +455 -0
- package/lib/utils/recursive-walk.d.ts +24 -0
- package/lib/utils/recursive-walk.js +134 -0
- package/lib/utils/zip.d.ts +54 -0
- package/lib/utils/zip.js +285 -0
- package/package.json +188 -0
- package/src/adapters/agent-instructions.ts +48 -0
- package/src/adapters/agent-presets.ts +14 -0
- package/src/adapters/credentials.test.ts +90 -0
- package/src/adapters/credentials.ts +143 -0
- package/src/adapters/file-collection.ts +137 -0
- package/src/adapters/files.test.ts +353 -0
- package/src/adapters/index.ts +94 -0
- package/src/adapters/link-report.test.ts +65 -0
- package/src/adapters/link-report.ts +61 -0
- package/src/adapters/mcp.test.ts +96 -0
- package/src/adapters/mcp.ts +185 -0
- package/src/adapters/plugin-files.ts +146 -0
- package/src/adapters/plugins.test.ts +326 -0
- package/src/adapters/plugins.ts +615 -0
- package/src/adapters/pnpm-workspace.test.ts +87 -0
- package/src/adapters/pnpm-workspace.ts +150 -0
- package/src/adapters/prompts.test.ts +110 -0
- package/src/adapters/prompts.ts +230 -0
- package/src/adapters/providers.test.ts +126 -0
- package/src/adapters/providers.ts +173 -0
- package/src/adapters/roundtrip.test.ts +190 -0
- package/src/adapters/self.test.ts +92 -0
- package/src/adapters/self.ts +75 -0
- package/src/adapters/sessions.ts +15 -0
- package/src/adapters/settings.test.ts +100 -0
- package/src/adapters/settings.ts +187 -0
- package/src/adapters/skills.ts +14 -0
- package/src/adapters/test-helpers.ts +248 -0
- package/src/adapters/ui.test.ts +57 -0
- package/src/adapters/ui.ts +108 -0
- package/src/adapters/workspaces.test.ts +106 -0
- package/src/adapters/workspaces.ts +109 -0
- package/src/client/ConfigManagerSection.tsx +90 -0
- package/src/client/api.ts +66 -0
- package/src/client/client-types.ts +18 -0
- package/src/client/common/ErrorBanner.tsx +73 -0
- package/src/client/common/Icon.tsx +129 -0
- package/src/client/common/Modal.tsx +173 -0
- package/src/client/common/ToastViewport.tsx +73 -0
- package/src/client/common/toast-store.test.ts +192 -0
- package/src/client/common/toast-store.ts +181 -0
- package/src/client/common/toast-types.ts +7 -0
- package/src/client/common/ui.tsx +342 -0
- package/src/client/config-manager.module.css +2971 -0
- package/src/client/css-modules.d.ts +8 -0
- package/src/client/index.ts +65 -0
- package/src/client/locales.ts +56 -0
- package/src/client/lucide-icons.d.ts +38 -0
- package/src/client/run-store.test.ts +223 -0
- package/src/client/run-store.ts +372 -0
- package/src/client/sync/SyncConfirmView.tsx +379 -0
- package/src/client/sync/SyncHistoryView.test.ts +40 -0
- package/src/client/sync/SyncHistoryView.tsx +220 -0
- package/src/client/sync/SyncSettingsView.tsx +1535 -0
- package/src/client/sync/history-model.test.ts +194 -0
- package/src/client/sync/history-model.ts +195 -0
- package/src/client/sync/sync-api.test.ts +585 -0
- package/src/client/sync/sync-api.ts +543 -0
- package/src/client/sync/sync-locales.ts +416 -0
- package/src/client/sync/sync-push-preview.test.ts +52 -0
- package/src/client/sync/sync-view-v2.test.ts +213 -0
- package/src/client/sync/sync-view.test.ts +487 -0
- package/src/client/sync/sync-view.ts +759 -0
- package/src/core/analyzer.ts +1068 -0
- package/src/core/backup.ts +605 -0
- package/src/core/boot-rescue.test.ts +847 -0
- package/src/core/boot-rescue.ts +737 -0
- package/src/core/cache-cleaner.test.ts +169 -0
- package/src/core/cache-cleaner.ts +121 -0
- package/src/core/config-lifecycle.test.ts +727 -0
- package/src/core/config-lifecycle.ts +573 -0
- package/src/core/config-snapshot.test.ts +369 -0
- package/src/core/config-snapshot.ts +522 -0
- package/src/core/config-state.test.ts +381 -0
- package/src/core/config-state.ts +207 -0
- package/src/core/consult-source.test.ts +199 -0
- package/src/core/consult-source.ts +293 -0
- package/src/core/crash-report.test.ts +380 -0
- package/src/core/crash-report.ts +369 -0
- package/src/core/exporter.ts +417 -0
- package/src/core/ghost-sweep.test.ts +94 -0
- package/src/core/ghost-sweep.ts +67 -0
- package/src/core/importer.ts +83 -0
- package/src/core/index.ts +56 -0
- package/src/core/journal.test.ts +257 -0
- package/src/core/journal.ts +518 -0
- package/src/core/local-plugin-host.ts +152 -0
- package/src/core/local-plugin-pack.test.ts +359 -0
- package/src/core/local-plugin-pack.ts +363 -0
- package/src/core/messages.test.ts +54 -0
- package/src/core/messages.ts +664 -0
- package/src/core/migration-consult.test.ts +315 -0
- package/src/core/migration-consult.ts +494 -0
- package/src/core/migration-history.test.ts +319 -0
- package/src/core/migration-history.ts +484 -0
- package/src/core/model-tools.test.ts +190 -0
- package/src/core/model-tools.ts +208 -0
- package/src/core/msg-types.ts +10 -0
- package/src/core/phase3-child-crash.ts +95 -0
- package/src/core/phase3-host.ts +411 -0
- package/src/core/phase3-p1.test.ts +212 -0
- package/src/core/phase3-p2b-fingerprint.test.ts +189 -0
- package/src/core/phase3-prod-child.ts +36 -0
- package/src/core/phase3-production-integration.test.ts +286 -0
- package/src/core/phase4-crash-child.ts +101 -0
- package/src/core/phase4-crash-injection.test.ts +166 -0
- package/src/core/plugin-cli.fs.test.ts +184 -0
- package/src/core/plugin-cli.test.ts +137 -0
- package/src/core/plugin-cli.ts +563 -0
- package/src/core/reconcile.test.ts +392 -0
- package/src/core/reconcile.ts +498 -0
- package/src/core/recovery-orchestrator.test.ts +145 -0
- package/src/core/recovery-orchestrator.ts +366 -0
- package/src/core/restore-manage.test.ts +94 -0
- package/src/core/restore.ts +849 -0
- package/src/core/rollback.ts +151 -0
- package/src/core/run-progress.test.ts +237 -0
- package/src/core/run-registry.test.ts +232 -0
- package/src/core/run-registry.ts +209 -0
- package/src/core/smoke.test.ts +842 -0
- package/src/core/startup-barrier.ts +99 -0
- package/src/core/types.ts +542 -0
- package/src/core/undo.ts +116 -0
- package/src/core/validator.ts +84 -0
- package/src/core/verify-recovery.ts +265 -0
- package/src/core/watcher.test.ts +326 -0
- package/src/core/watcher.ts +263 -0
- package/src/index.facade.test.ts +265 -0
- package/src/index.sync.test.ts +217 -0
- package/src/index.ts +2975 -0
- package/src/migrations/index.ts +71 -0
- package/src/migrations/v1-to-v2.ts +17 -0
- package/src/profiles/index.ts +8 -0
- package/src/profiles/profile-manager.test.ts +282 -0
- package/src/profiles/profile-manager.ts +630 -0
- package/src/schema/config.ts +165 -0
- package/src/schema/index.ts +71 -0
- package/src/schema/manifest-schema.test.ts +519 -0
- package/src/schema/manifest.ts +124 -0
- package/src/schema/tombstones.test.ts +131 -0
- package/src/schema/tombstones.ts +89 -0
- package/src/schema/types.ts +245 -0
- package/src/schema/versions.ts +55 -0
- package/src/security/encryption.ts +335 -0
- package/src/security/index.ts +16 -0
- package/src/security/integrity.ts +102 -0
- package/src/security/redaction.ts +101 -0
- package/src/security/secret-scanner.ts +445 -0
- package/src/security/security.test.ts +1215 -0
- package/src/security/vault.test.ts +164 -0
- package/src/security/vault.ts +198 -0
- package/src/security/zip-security.ts +180 -0
- package/src/sync/autosync-config.test.ts +147 -0
- package/src/sync/autosync-config.ts +179 -0
- package/src/sync/autosync-scheduler.test.ts +490 -0
- package/src/sync/autosync-scheduler.ts +646 -0
- package/src/sync/fs.ts +48 -0
- package/src/sync/git/git-transport.test.ts +673 -0
- package/src/sync/git/git-transport.ts +491 -0
- package/src/sync/github-auth.test.ts +280 -0
- package/src/sync/github-auth.ts +356 -0
- package/src/sync/layout.test.ts +270 -0
- package/src/sync/layout.ts +222 -0
- package/src/sync/snapshot-json.test.ts +119 -0
- package/src/sync/snapshot-json.ts +128 -0
- package/src/sync/sync-config.test.ts +310 -0
- package/src/sync/sync-config.ts +299 -0
- package/src/sync/sync-engine.test.ts +1074 -0
- package/src/sync/sync-engine.ts +754 -0
- package/src/sync/sync-history.test.ts +116 -0
- package/src/sync/sync-history.ts +120 -0
- package/src/sync/sync-selection.test.ts +186 -0
- package/src/sync/sync-selection.ts +143 -0
- package/src/sync/sync-session.test.ts +143 -0
- package/src/sync/sync-session.ts +77 -0
- package/src/sync/sync-state.test.ts +164 -0
- package/src/sync/sync-state.ts +131 -0
- package/src/sync/transport.test.ts +111 -0
- package/src/sync/transport.ts +118 -0
- package/src/sync/ui-prefs.test.ts +152 -0
- package/src/sync/ui-prefs.ts +135 -0
- package/src/sync/webdav/webdav-transport.test.ts +871 -0
- package/src/sync/webdav/webdav-transport.ts +535 -0
- package/src/ui/errors.test.ts +47 -0
- package/src/ui/errors.ts +100 -0
- package/src/ui/export-flow.test.ts +87 -0
- package/src/ui/export-flow.ts +156 -0
- package/src/ui/i18n.ts +590 -0
- package/src/ui/migration-consult-view.test.ts +151 -0
- package/src/ui/migration-consult-view.ts +178 -0
- package/src/ui/progress.test.ts +47 -0
- package/src/ui/progress.ts +102 -0
- package/src/ui/report.test.ts +101 -0
- package/src/ui/report.ts +146 -0
- package/src/ui/test-helpers.ts +184 -0
- package/src/ui/types.ts +325 -0
- package/src/utils/atomic-write.test.ts +312 -0
- package/src/utils/atomic-write.ts +422 -0
- package/src/utils/env-lock.test.ts +999 -0
- package/src/utils/env-lock.ts +1086 -0
- package/src/utils/hashing.ts +53 -0
- package/src/utils/json.ts +77 -0
- package/src/utils/logger.ts +114 -0
- package/src/utils/paths.ts +176 -0
- package/src/utils/proxy.test.ts +465 -0
- package/src/utils/proxy.ts +493 -0
- package/src/utils/recursive-walk.test.ts +148 -0
- package/src/utils/recursive-walk.ts +145 -0
- package/src/utils/zip.ts +332 -0
|
@@ -0,0 +1,847 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boot Rescue(启动救援)单测 —— 真实临时目录(os.tmpdir() / fs.mkdtemp),
|
|
3
|
+
* node:test + node:assert/strict。
|
|
4
|
+
*
|
|
5
|
+
* 覆盖:
|
|
6
|
+
* - B-01/B-02 rescuePaths 形状 / homeFingerprint 稳定性
|
|
7
|
+
* - B-02b..B-02d normalizeHomeDir + 指纹对「同一目录的不同写法」不敏感
|
|
8
|
+
* (C:/… vs C:\… 曾让 exitRescueMode 自判 stale,一个文件都不动)
|
|
9
|
+
* - B-03..B-07 computeSafeBundles:保留+剔除(保序)、非字符串、resolver 抛错、
|
|
10
|
+
* 非数组(inputWasArray=false)、空数组、异步 resolver、零 IO 纯函数
|
|
11
|
+
* - B-08..B-15 enterRescueMode:happy path(备份=原始字节 / 最小 patch / home patch 置空 /
|
|
12
|
+
* bundles 裁剪且**扁平** / 状态文件)、profile patch 原本缺失、
|
|
13
|
+
* 幂等 already-active(零字节改动)、package.json 非法(零改动)、
|
|
14
|
+
* 备份失败(原件不动)、无裁剪不重写、默认备份目录、neutralizeHomePatch=false
|
|
15
|
+
* - B-16..B-19 exitRescueMode:三处逐字节还原 + 状态文件删除、备份缺失(零改动)、
|
|
16
|
+
* 未激活、stale 状态拒绝还原
|
|
17
|
+
* - B-20 rescueModeStatus:active / stale(指纹不匹配)/ 缺失 / 损坏
|
|
18
|
+
* - B-21 全流程往返:enter → status active → exit → status inactive + 逐字节还原
|
|
19
|
+
* - B-22 rescueMount:本插件在 bundles 里 → patch 只写空列表(不得重复插入同 id 行)
|
|
20
|
+
* - B-23 rescueMount:package.json 缺失 / bundles 非数组 → 回退 patch 层挂载行
|
|
21
|
+
* - B-24..B-26 restrictToRescueBundles / disableUserBundles:真正把用户插件移出 bundles
|
|
22
|
+
* (保留 @deepseek-ai/* 与自身)、缺省仍走保守路径、退出可完全还原
|
|
23
|
+
*/
|
|
24
|
+
import test from 'node:test';
|
|
25
|
+
import assert from 'node:assert/strict';
|
|
26
|
+
import fs from 'node:fs/promises';
|
|
27
|
+
import os from 'node:os';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
|
|
30
|
+
import { sha256Hex } from '../utils/hashing.ts';
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
computeSafeBundles,
|
|
34
|
+
enterRescueMode,
|
|
35
|
+
exitRescueMode,
|
|
36
|
+
homeFingerprint,
|
|
37
|
+
normalizeHomeDir,
|
|
38
|
+
rescueModeStatus,
|
|
39
|
+
rescuePaths,
|
|
40
|
+
restrictToRescueBundles,
|
|
41
|
+
RESCUE_KEEP_BUNDLE_PREFIXES,
|
|
42
|
+
type RescueEnterOptions,
|
|
43
|
+
type RescueEnterResult,
|
|
44
|
+
type RescueExitResult,
|
|
45
|
+
type RescueSelfMount,
|
|
46
|
+
type RescueState,
|
|
47
|
+
} from './boot-rescue.ts';
|
|
48
|
+
|
|
49
|
+
/* ---------------------------------------------------------------- 常量与夹具 */
|
|
50
|
+
|
|
51
|
+
const PROFILE = 'web';
|
|
52
|
+
|
|
53
|
+
/** 救援插件自身的挂载描述(宿主传入;packageName 与 profile package.json 的 bundles 条目比对) */
|
|
54
|
+
const RESCUE_MOUNT: RescueSelfMount = {
|
|
55
|
+
packageName: 'dsh-config-manager',
|
|
56
|
+
row: { id: 'config-manager', name: 'dsh-config-manager' },
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** 救援 patch 头注释锚点(正文由 core 生成,测试只锚定首行 + 生效行) */
|
|
60
|
+
const RESCUE_HEAD = '# dsh-config-manager RESCUE MODE';
|
|
61
|
+
|
|
62
|
+
/** 三处原件(package.json 故意用单行/非 2 空格缩进,用于验证「无裁剪不重写」与逐字节还原) */
|
|
63
|
+
const ORIGINAL_PROFILE_PATCH = '# profile patch\n- name: config-manager\n config: { keepMe: true }\n';
|
|
64
|
+
const ORIGINAL_HOME_PATCH = '# home patch\n- name: config-manager\n';
|
|
65
|
+
const ORIGINAL_PACKAGE_JSON =
|
|
66
|
+
'{"name":"dsh-profile-web","private":true,"dsh":{"profile":{"bundles":["good-bundle","broken-bundle"]}}}\n';
|
|
67
|
+
|
|
68
|
+
/** bundles 里含本插件(bundle 层已挂载)的 package.json */
|
|
69
|
+
const PACKAGE_JSON_WITH_SELF_BUNDLE =
|
|
70
|
+
'{"name":"dsh-profile-web","private":true,"dsh":{"profile":{"bundles":["good-bundle","dsh-config-manager"]}}}\n';
|
|
71
|
+
|
|
72
|
+
/** 默认备份目录(相对 homeDir 的 posix 路径,与实现的缺省约定一致) */
|
|
73
|
+
const DEFAULT_BACKUP_DIR_REL = 'dsh-config-manager/transactions/rescue-backups';
|
|
74
|
+
|
|
75
|
+
type EnterFailure = Extract<RescueEnterResult, { ok: false }>;
|
|
76
|
+
type ExitFailure = Extract<RescueExitResult, { ok: false }>;
|
|
77
|
+
|
|
78
|
+
/** 真实临时 home 目录,测试结束自动清理 */
|
|
79
|
+
async function makeHome(t: test.TestContext): Promise<string> {
|
|
80
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-bootrescue-'));
|
|
81
|
+
t.after(async () => {
|
|
82
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
83
|
+
});
|
|
84
|
+
return dir;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 相对 homeDir 的 posix 相对路径 → 原生绝对路径(测试侧独立实现,不复用被测代码) */
|
|
88
|
+
function at(homeDir: string, relPosix: string): string {
|
|
89
|
+
return path.join(homeDir, ...relPosix.split('/'));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 三处受救援模式管理的绝对路径 */
|
|
93
|
+
function targets(homeDir: string): { profilePatch: string; homePatch: string; packageJson: string } {
|
|
94
|
+
const rel = rescuePaths(PROFILE);
|
|
95
|
+
return {
|
|
96
|
+
profilePatch: at(homeDir, rel.profilePatch),
|
|
97
|
+
homePatch: at(homeDir, rel.homePatch),
|
|
98
|
+
packageJson: at(homeDir, rel.profilePackageJson),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 写入(自动建父目录) */
|
|
103
|
+
async function writeFileDeep(file: string, data: string): Promise<void> {
|
|
104
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
105
|
+
await fs.writeFile(file, data, 'utf8');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function readText(file: string): Promise<string> {
|
|
109
|
+
return new TextDecoder().decode(await fs.readFile(file));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function fileExists(file: string): Promise<boolean> {
|
|
113
|
+
try {
|
|
114
|
+
await fs.stat(file);
|
|
115
|
+
return true;
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 递归快照整棵树:相对路径 → base64 字节。用于「字节级零改动」断言。 */
|
|
122
|
+
async function snapshotTree(root: string): Promise<Record<string, string>> {
|
|
123
|
+
const out: Record<string, string> = {};
|
|
124
|
+
async function walk(dir: string, prefix: string): Promise<void> {
|
|
125
|
+
let entries: Awaited<ReturnType<typeof fs.readdir>> extends unknown ? import('node:fs').Dirent[] : never;
|
|
126
|
+
try {
|
|
127
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
128
|
+
} catch {
|
|
129
|
+
return; // 目录不存在 → 该子树为空
|
|
130
|
+
}
|
|
131
|
+
for (const entry of entries) {
|
|
132
|
+
const abs = path.join(dir, entry.name);
|
|
133
|
+
const rel = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
|
|
134
|
+
if (entry.isDirectory()) await walk(abs, rel);
|
|
135
|
+
else out[rel] = (await fs.readFile(abs)).toString('base64');
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
await walk(root, '');
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 铺好三处原件 */
|
|
143
|
+
async function seedOriginals(homeDir: string): Promise<void> {
|
|
144
|
+
const p = targets(homeDir);
|
|
145
|
+
await writeFileDeep(p.profilePatch, ORIGINAL_PROFILE_PATCH);
|
|
146
|
+
await writeFileDeep(p.homePatch, ORIGINAL_HOME_PATCH);
|
|
147
|
+
await writeFileDeep(p.packageJson, ORIGINAL_PACKAGE_JSON);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function enterOpts(homeDir: string, backupDir: string, over: Partial<RescueEnterOptions> = {}): RescueEnterOptions {
|
|
151
|
+
return { homeDir, profile: PROFILE, rescueMount: RESCUE_MOUNT, backupDir, ...over };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 断言 profile patch 只挂载救援插件自身(去注释后的生效行)。
|
|
156
|
+
* selfInBundles=true → 必须为空列表:本插件已由 bundle 层挂载,patch 层再插一行同 id
|
|
157
|
+
* 会让 loader 抛 duplicate loader entry id,整棵插件树加载失败(回归护栏)。
|
|
158
|
+
*/
|
|
159
|
+
async function assertRescuePatch(file: string, selfInBundles: boolean): Promise<void> {
|
|
160
|
+
const text = await readText(file);
|
|
161
|
+
assert.ok(text.startsWith(RESCUE_HEAD), `profile patch 应以救援头注释开头,实际:${text.slice(0, 40)}`);
|
|
162
|
+
const body = text
|
|
163
|
+
.split('\n')
|
|
164
|
+
.filter((line) => line.trim() !== '' && !line.trim().startsWith('#'))
|
|
165
|
+
.join('\n');
|
|
166
|
+
if (selfInBundles) {
|
|
167
|
+
assert.equal(body, '[]', 'bundle 层已挂载本插件 → patch 层必须写空列表');
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
assert.equal(body, "- insert:\n - id: 'config-manager'\n name: 'dsh-config-manager'");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function enterOk(opts: RescueEnterOptions): Promise<RescueState> {
|
|
174
|
+
const res = await enterRescueMode(opts);
|
|
175
|
+
if (!res.ok) throw new Error(`expected enterRescueMode to succeed, got ${res.code}: ${res.message}`);
|
|
176
|
+
return res.state;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function enterFail(opts: RescueEnterOptions): Promise<EnterFailure> {
|
|
180
|
+
const res = await enterRescueMode(opts);
|
|
181
|
+
if (res.ok) throw new Error('expected enterRescueMode to fail, but it succeeded');
|
|
182
|
+
return res;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function exitOk(opts: { homeDir: string; backupDir?: string }): Promise<string[]> {
|
|
186
|
+
const res = await exitRescueMode(opts);
|
|
187
|
+
if (!res.ok) throw new Error(`expected exitRescueMode to succeed, got ${res.code}: ${res.message}`);
|
|
188
|
+
return res.restored;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function exitFail(opts: { homeDir: string; backupDir?: string }): Promise<ExitFailure> {
|
|
192
|
+
const res = await exitRescueMode(opts);
|
|
193
|
+
if (res.ok) throw new Error('expected exitRescueMode to fail, but it succeeded');
|
|
194
|
+
return res;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/* ---------------------------------------------------------------- 路径与指纹 */
|
|
198
|
+
|
|
199
|
+
test('B-01 rescuePaths:三处文件的 homeDir 相对 posix 路径', () => {
|
|
200
|
+
assert.deepEqual(rescuePaths('web'), {
|
|
201
|
+
profilePatch: 'profiles/web/cordis.patch.yml',
|
|
202
|
+
homePatch: 'cordis.patch.yml',
|
|
203
|
+
profilePackageJson: 'profiles/web/package.json',
|
|
204
|
+
});
|
|
205
|
+
assert.deepEqual(rescuePaths('other'), {
|
|
206
|
+
profilePatch: 'profiles/other/cordis.patch.yml',
|
|
207
|
+
homePatch: 'cordis.patch.yml',
|
|
208
|
+
profilePackageJson: 'profiles/other/package.json',
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('B-02 homeFingerprint:homeDir+profile 决定,稳定且可区分', () => {
|
|
213
|
+
const base = homeFingerprint('/home/u/.dsh', 'web');
|
|
214
|
+
assert.equal(base, homeFingerprint('/home/u/.dsh', 'web'), '同输入稳定');
|
|
215
|
+
assert.match(base, /^[0-9a-f]{64}$/, 'sha256 hex');
|
|
216
|
+
assert.notEqual(base, homeFingerprint('/home/u/.dsh', 'other'), 'profile 变化 → 指纹变化');
|
|
217
|
+
assert.notEqual(base, homeFingerprint('/home/u/other', 'web'), 'homeDir 变化 → 指纹变化');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
/*
|
|
221
|
+
* B-02b 回归:同一目录的不同**写法**必须得到同一指纹。
|
|
222
|
+
* 原始实现直接哈希调用方传入的字符串,于是 `C:/x/.dsh` 与 `C:\x\.dsh` 算出不同指纹,
|
|
223
|
+
* exitRescueMode 把自己的状态判成 stale 并拒绝还原(一个文件都不动,只能手改文件)。
|
|
224
|
+
* 从插件 UI 进出时两次都原样传 host.homeDir 所以看不出来,从 CLI / 脚本进出就会踩到。
|
|
225
|
+
*/
|
|
226
|
+
test('B-02b normalizeHomeDir:消掉分隔符混用 / 尾分隔符 / . 与 .. / win32 大小写', () => {
|
|
227
|
+
// 分隔符混用与尾分隔符、`.` 段:跨平台都必须归一(Linux 上正斜杠本就是规范形式)
|
|
228
|
+
assert.equal(
|
|
229
|
+
normalizeHomeDir('/home/u/.dsh'),
|
|
230
|
+
normalizeHomeDir('/home/u/.dsh/'),
|
|
231
|
+
'尾分隔符必须归一',
|
|
232
|
+
);
|
|
233
|
+
assert.equal(
|
|
234
|
+
normalizeHomeDir('/home/u/.dsh'),
|
|
235
|
+
normalizeHomeDir('/home/u/./.dsh'),
|
|
236
|
+
'`.` 段必须归一',
|
|
237
|
+
);
|
|
238
|
+
assert.equal(
|
|
239
|
+
normalizeHomeDir('/home/u/.dsh'),
|
|
240
|
+
normalizeHomeDir('/home/u/x/../.dsh'),
|
|
241
|
+
'`..` 段必须归一',
|
|
242
|
+
);
|
|
243
|
+
// win32 语义用显式 platform 断言,使该分支在非 win32 的 CI 上同样被守卫
|
|
244
|
+
assert.equal(
|
|
245
|
+
normalizeHomeDir('C:/Users/a/.dsh', 'win32'),
|
|
246
|
+
normalizeHomeDir('c:\\Users\\A\\.dsh', 'win32'),
|
|
247
|
+
'win32:分隔符混用 + 大小写差异必须归一到同一键',
|
|
248
|
+
);
|
|
249
|
+
assert.equal(normalizeHomeDir('C:/Users/a/.dsh', 'win32'), 'c:\\users\\a\\.dsh');
|
|
250
|
+
// 非 win32 不折叠大小写(区分大小写的文件系统上这是两个目录)
|
|
251
|
+
assert.notEqual(normalizeHomeDir('/Home/U/.dsh', 'linux'), normalizeHomeDir('/home/u/.dsh', 'linux'));
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('B-02c homeFingerprint:同一目录的不同写法 → 同一指纹', () => {
|
|
255
|
+
const homeDir = path.join(os.tmpdir(), 'dsh-fp', 'home');
|
|
256
|
+
assert.equal(
|
|
257
|
+
homeFingerprint(homeDir, PROFILE),
|
|
258
|
+
homeFingerprint(`${homeDir}${path.sep}`, PROFILE),
|
|
259
|
+
'尾分隔符不得改变指纹',
|
|
260
|
+
);
|
|
261
|
+
assert.equal(
|
|
262
|
+
homeFingerprint(homeDir, PROFILE),
|
|
263
|
+
homeFingerprint(homeDir.split(path.sep).join('/'), PROFILE),
|
|
264
|
+
'分隔符混用不得改变指纹(这正是 CLI 手写路径踩到的那个坑)',
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* B-02d 端到端:从「不同写法」的 homeDir 退出救援,必须真的还原文件。
|
|
270
|
+
* 这是用户实际卡住的场景 —— 进救援与出救援各自拼路径,写法不同。
|
|
271
|
+
*/
|
|
272
|
+
test('B-02d exitRescueMode:homeDir 换个写法进出,仍能还原(不再自判 stale)', async (t) => {
|
|
273
|
+
const homeDir = await makeHome(t);
|
|
274
|
+
const backupDir = path.join(homeDir, 'rescue-backups');
|
|
275
|
+
const p = targets(homeDir);
|
|
276
|
+
await seedOriginals(homeDir);
|
|
277
|
+
const original = await readText(p.profilePatch);
|
|
278
|
+
|
|
279
|
+
// 进入用原生形式
|
|
280
|
+
await enterOk(enterOpts(homeDir, backupDir));
|
|
281
|
+
// 退出用一个「等价但写法不同」的路径(模拟 CLI 手写 DSH_HOME)。
|
|
282
|
+
// 必须按平台选写法:Windows 用正斜杠(手写最常见),POSIX 用尾随分隔符——
|
|
283
|
+
// 不能统一写 split(path.sep).join('/'),POSIX 上 path.sep 本来就是 '/',
|
|
284
|
+
// 换完与原串逐字节相同,前置断言会在 Linux CI 上失败(v0.1.60 首次发布时实测踩到)。
|
|
285
|
+
const slashed = process.platform === 'win32'
|
|
286
|
+
? homeDir.split(path.sep).join('/')
|
|
287
|
+
: `${homeDir}${path.sep}`;
|
|
288
|
+
assert.notEqual(slashed, homeDir, '前置条件:两种写法确实是不同字符串');
|
|
289
|
+
assert.equal(path.resolve(slashed), path.resolve(homeDir), '前置条件:两种写法指向同一目录');
|
|
290
|
+
const restored = await exitOk({ homeDir: slashed, backupDir });
|
|
291
|
+
assert.ok(restored.length > 0, '必须真的还原了文件');
|
|
292
|
+
assert.equal(await readText(p.profilePatch), original, 'profile patch 必须逐字节还原');
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* B-02e 向后兼容:修复前写入的状态用的是「原始字符串」指纹。
|
|
297
|
+
* 用户可能停留在「已进入救援」与「升级插件」之间;只认新指纹会让他们**无法退出救援**,
|
|
298
|
+
* 等于把旧 bug 换成新 bug。历史形态必须仍被接受。
|
|
299
|
+
*/
|
|
300
|
+
test('B-02e exitRescueMode:接受历史(归一化前)指纹的状态,老状态也能退出', async (t) => {
|
|
301
|
+
const homeDir = await makeHome(t);
|
|
302
|
+
const backupDir = path.join(homeDir, 'rescue-backups');
|
|
303
|
+
const p = targets(homeDir);
|
|
304
|
+
await seedOriginals(homeDir);
|
|
305
|
+
const original = await readText(p.profilePatch);
|
|
306
|
+
|
|
307
|
+
await enterOk(enterOpts(homeDir, backupDir));
|
|
308
|
+
|
|
309
|
+
// 把状态文件的指纹改写成「旧算法」的结果(= 原始字符串直接哈希)
|
|
310
|
+
const statePath = path.join(backupDir, 'state.json');
|
|
311
|
+
const raw = JSON.parse(await fs.readFile(statePath, 'utf8')) as Record<string, unknown>;
|
|
312
|
+
raw['homeFingerprint'] = sha256Hex(`${homeDir}|${PROFILE}`);
|
|
313
|
+
await fs.writeFile(statePath, JSON.stringify(raw), 'utf8');
|
|
314
|
+
|
|
315
|
+
// status 仍须判为 active(否则 UI 不给「退出救援」按钮)
|
|
316
|
+
const status = await rescueModeStatus({ homeDir, profile: PROFILE, backupDir });
|
|
317
|
+
assert.equal(status.active, true, '历史指纹必须仍被判为 active');
|
|
318
|
+
assert.equal(status.stale, false);
|
|
319
|
+
|
|
320
|
+
const restored = await exitOk({ homeDir, backupDir });
|
|
321
|
+
assert.ok(restored.length > 0, '必须真的还原');
|
|
322
|
+
assert.equal(await readText(p.profilePatch), original);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
/** B-02f 安全性不因兼容而削弱:换一个 home 仍必须判 stale、拒绝还原。 */
|
|
326
|
+
test('B-02f exitRescueMode:指纹属于别的 home 时仍拒绝还原(兼容不得削弱保护)', async (t) => {
|
|
327
|
+
const homeDir = await makeHome(t);
|
|
328
|
+
const backupDir = path.join(homeDir, 'rescue-backups');
|
|
329
|
+
const p = targets(homeDir);
|
|
330
|
+
await seedOriginals(homeDir);
|
|
331
|
+
await enterOk(enterOpts(homeDir, backupDir));
|
|
332
|
+
const before = await readText(p.profilePatch);
|
|
333
|
+
|
|
334
|
+
const statePath = path.join(backupDir, 'state.json');
|
|
335
|
+
const raw = JSON.parse(await fs.readFile(statePath, 'utf8')) as Record<string, unknown>;
|
|
336
|
+
raw['homeFingerprint'] = sha256Hex(`${homeDir}-other-machine|${PROFILE}`);
|
|
337
|
+
await fs.writeFile(statePath, JSON.stringify(raw), 'utf8');
|
|
338
|
+
|
|
339
|
+
const res = await exitFail({ homeDir, backupDir });
|
|
340
|
+
assert.equal(res.code, 'not-active');
|
|
341
|
+
assert.match(res.message, /stale/);
|
|
342
|
+
assert.equal(await readText(p.profilePatch), before, '拒绝还原必须零改动');
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
/* ---------------------------------------------------------------- computeSafeBundles */
|
|
346
|
+
|
|
347
|
+
test('B-03 computeSafeBundles:保留可解析、剔除不可解析(保序,逐个探测)', async () => {
|
|
348
|
+
const seen: string[] = [];
|
|
349
|
+
const r = await computeSafeBundles(['a', 'b', 'c'], (name) => {
|
|
350
|
+
seen.push(name);
|
|
351
|
+
return name !== 'b';
|
|
352
|
+
});
|
|
353
|
+
assert.deepEqual(r.kept, ['a', 'c']);
|
|
354
|
+
assert.deepEqual(r.pruned, [{ name: 'b', reason: 'unresolved' }]);
|
|
355
|
+
assert.equal(r.inputWasArray, true);
|
|
356
|
+
assert.deepEqual(seen, ['a', 'b', 'c'], '按原顺序逐个探测');
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test('B-04 computeSafeBundles:非字符串条目 → non-string,且不调用 resolver', async () => {
|
|
360
|
+
let calls = 0;
|
|
361
|
+
const r = await computeSafeBundles(['ok', 42, null, { name: 'x' }, ['nested']], () => {
|
|
362
|
+
calls += 1;
|
|
363
|
+
return true;
|
|
364
|
+
});
|
|
365
|
+
assert.deepEqual(r.kept, ['ok']);
|
|
366
|
+
assert.deepEqual(
|
|
367
|
+
r.pruned.map((p) => p.reason),
|
|
368
|
+
['non-string', 'non-string', 'non-string', 'non-string'],
|
|
369
|
+
);
|
|
370
|
+
assert.equal(calls, 1, '非字符串条目不需要探测');
|
|
371
|
+
assert.equal(r.pruned[0]?.name, '42');
|
|
372
|
+
assert.equal(r.pruned[1]?.name, 'null');
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test('B-05 computeSafeBundles:resolver 抛错 → unresolved(异常不外泄)', async () => {
|
|
376
|
+
const r = await computeSafeBundles(['boom', 'fine'], (name) => {
|
|
377
|
+
if (name === 'boom') throw new Error('cannot resolve boom');
|
|
378
|
+
return true;
|
|
379
|
+
});
|
|
380
|
+
assert.deepEqual(r.kept, ['fine']);
|
|
381
|
+
assert.deepEqual(r.pruned, [{ name: 'boom', reason: 'unresolved' }]);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test('B-06 computeSafeBundles:异步 resolver + undefined/非布尔返回值一律保守剔除', async () => {
|
|
385
|
+
const r = await computeSafeBundles(['async-ok', 'async-bad'], async (name) => name === 'async-ok');
|
|
386
|
+
assert.deepEqual(r.kept, ['async-ok']);
|
|
387
|
+
assert.deepEqual(r.pruned, [{ name: 'async-bad', reason: 'unresolved' }]);
|
|
388
|
+
|
|
389
|
+
const loose = await computeSafeBundles(['weird'], (() => undefined) as unknown as () => boolean);
|
|
390
|
+
assert.deepEqual(loose.kept, []);
|
|
391
|
+
assert.deepEqual(loose.pruned, [{ name: 'weird', reason: 'unresolved' }]);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test('B-07 computeSafeBundles:非数组/缺失 → inputWasArray=false;空数组 → 空结果', async () => {
|
|
395
|
+
for (const bad of [undefined, null, 'good-bundle', 42, { 0: 'good-bundle' }, { bundles: ['good-bundle'] }]) {
|
|
396
|
+
const r = await computeSafeBundles(bad, () => true);
|
|
397
|
+
assert.deepEqual(r, { kept: [], pruned: [], inputWasArray: false });
|
|
398
|
+
}
|
|
399
|
+
assert.deepEqual(await computeSafeBundles([], () => true), { kept: [], pruned: [], inputWasArray: true });
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
/* ---------------------------------------------------------------- enterRescueMode */
|
|
403
|
+
|
|
404
|
+
test('B-08 enterRescueMode happy path:备份→裁剪 bundles→最小 patch→置空 home patch→状态', async (t) => {
|
|
405
|
+
const homeDir = await makeHome(t);
|
|
406
|
+
const backupDir = path.join(homeDir, 'rescue-backups');
|
|
407
|
+
const p = targets(homeDir);
|
|
408
|
+
await seedOriginals(homeDir);
|
|
409
|
+
|
|
410
|
+
const state = await enterOk(
|
|
411
|
+
enterOpts(homeDir, backupDir, {
|
|
412
|
+
resolveBundle: (name) => name === 'good-bundle',
|
|
413
|
+
now: () => new Date('2026-01-02T03:04:05.000Z'),
|
|
414
|
+
}),
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
// 状态字段
|
|
418
|
+
assert.equal(state.active, true);
|
|
419
|
+
assert.equal(state.profile, PROFILE);
|
|
420
|
+
assert.equal(state.enteredAt, '2026-01-02T03:04:05.000Z');
|
|
421
|
+
assert.equal(state.homeFingerprint, homeFingerprint(homeDir, PROFILE));
|
|
422
|
+
assert.equal(state.profilePatchWasAbsent, false);
|
|
423
|
+
assert.deepEqual(state.prunedBundles, [{ name: 'broken-bundle', reason: 'unresolved' }]);
|
|
424
|
+
|
|
425
|
+
// 备份 = 原始字节
|
|
426
|
+
assert.notEqual(state.backup.profilePatch, '');
|
|
427
|
+
assert.equal(await readText(state.backup.profilePatch), ORIGINAL_PROFILE_PATCH);
|
|
428
|
+
const homeBackup = state.backup.homePatch;
|
|
429
|
+
const pkgBackup = state.backup.profilePackageJson;
|
|
430
|
+
assert.ok(homeBackup !== null && pkgBackup !== null, '原件存在 → 备份路径非 null');
|
|
431
|
+
assert.equal(await readText(homeBackup), ORIGINAL_HOME_PATCH);
|
|
432
|
+
assert.equal(await readText(pkgBackup), ORIGINAL_PACKAGE_JSON);
|
|
433
|
+
|
|
434
|
+
// 目标文件:最小 patch + home patch 置空
|
|
435
|
+
await assertRescuePatch(p.profilePatch, false);
|
|
436
|
+
assert.equal(await readText(p.homePatch), '[]\n');
|
|
437
|
+
|
|
438
|
+
// package.json:只改 dsh.profile.bundles,且必须是扁平字符串数组
|
|
439
|
+
const raw = await readText(p.packageJson);
|
|
440
|
+
const pkg = JSON.parse(raw) as { name?: unknown; private?: unknown; dsh?: { profile?: { bundles?: unknown } } };
|
|
441
|
+
const bundles = pkg.dsh?.profile?.bundles;
|
|
442
|
+
assert.deepEqual(bundles, ['good-bundle'], 'bundles = 扁平字符串数组');
|
|
443
|
+
assert.notDeepEqual(bundles, [['good-bundle']], 'bundles 不得被二次包裹为 [[name]]');
|
|
444
|
+
assert.equal(raw.includes('[["good-bundle"]]'), false, '磁盘文本不得出现双层数组');
|
|
445
|
+
assert.equal(raw.includes('["good-bundle"]'), false, '磁盘文本不得出现(无空格)双层数组');
|
|
446
|
+
assert.equal(pkg.name, 'dsh-profile-web', '其余字段保持不变');
|
|
447
|
+
assert.equal(pkg.private, true);
|
|
448
|
+
assert.notEqual(raw, ORIGINAL_PACKAGE_JSON, '有裁剪 → 文件被重写');
|
|
449
|
+
assert.ok(raw.endsWith('\n'), '保留尾换行');
|
|
450
|
+
assert.ok(raw.includes('\n "dsh"'), '2 空格缩进重序列化');
|
|
451
|
+
|
|
452
|
+
// 状态文件
|
|
453
|
+
const stateFile = path.join(backupDir, 'state.json');
|
|
454
|
+
const written = JSON.parse(await readText(stateFile)) as { active?: unknown; profile?: unknown };
|
|
455
|
+
assert.equal(written.active, true);
|
|
456
|
+
assert.equal(written.profile, PROFILE);
|
|
457
|
+
|
|
458
|
+
// 进入后 status 应为 active
|
|
459
|
+
const status = await rescueModeStatus({ homeDir, profile: PROFILE, backupDir });
|
|
460
|
+
assert.equal(status.active, true);
|
|
461
|
+
assert.equal(status.stale, false);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test('B-09 enterRescueMode:profile patch 原本不存在 → profilePatchWasAbsent 且文件被创建', async (t) => {
|
|
465
|
+
const homeDir = await makeHome(t);
|
|
466
|
+
const backupDir = path.join(homeDir, 'rescue-backups');
|
|
467
|
+
const p = targets(homeDir);
|
|
468
|
+
await writeFileDeep(p.homePatch, ORIGINAL_HOME_PATCH); // 只有 home patch,无 profile patch / package.json
|
|
469
|
+
|
|
470
|
+
const state = await enterOk(enterOpts(homeDir, backupDir));
|
|
471
|
+
|
|
472
|
+
assert.equal(state.profilePatchWasAbsent, true);
|
|
473
|
+
assert.equal(state.backup.profilePatch, '', '原件不存在 → 无备份路径');
|
|
474
|
+
assert.equal(state.backup.homePatch === null, false, 'home patch 存在 → 有备份');
|
|
475
|
+
assert.equal(state.backup.profilePackageJson, null, 'package.json 不存在 → 无备份');
|
|
476
|
+
await assertRescuePatch(p.profilePatch, false); // 无 package.json → 无法判定 bundle 挂载 → 回退插入行
|
|
477
|
+
assert.equal(await fileExists(p.packageJson), false, '不创建不存在的 package.json');
|
|
478
|
+
assert.equal(state.prunedBundles.length, 0);
|
|
479
|
+
|
|
480
|
+
// 退出:profile patch 是本次创建的 → 删除而非还原
|
|
481
|
+
assert.deepEqual(await exitOk({ homeDir, backupDir }), [rescuePaths(PROFILE).profilePatch, rescuePaths(PROFILE).homePatch]);
|
|
482
|
+
assert.equal(await fileExists(p.profilePatch), false, '进入时不存在 → 退出时删除');
|
|
483
|
+
assert.equal(await readText(p.homePatch), ORIGINAL_HOME_PATCH, 'home patch 还原');
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test('B-10 enterRescueMode 幂等:第二次 already-active 且磁盘字节零改动', async (t) => {
|
|
487
|
+
const homeDir = await makeHome(t);
|
|
488
|
+
const backupDir = path.join(homeDir, 'rescue-backups');
|
|
489
|
+
await seedOriginals(homeDir);
|
|
490
|
+
|
|
491
|
+
await enterOk(enterOpts(homeDir, backupDir, { resolveBundle: (name) => name === 'good-bundle' }));
|
|
492
|
+
const before = await snapshotTree(homeDir);
|
|
493
|
+
|
|
494
|
+
const again = await enterFail(enterOpts(homeDir, backupDir, { resolveBundle: () => false }));
|
|
495
|
+
assert.equal(again.code, 'already-active');
|
|
496
|
+
assert.deepEqual(await snapshotTree(homeDir), before, 'already-active 必须一个字节都不改');
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test('B-11 enterRescueMode:package.json 存在但不可解析 → package-json-invalid 且零改动', async (t) => {
|
|
500
|
+
const homeDir = await makeHome(t);
|
|
501
|
+
const backupDir = path.join(homeDir, 'rescue-backups');
|
|
502
|
+
const p = targets(homeDir);
|
|
503
|
+
await seedOriginals(homeDir);
|
|
504
|
+
const malformed = '{"name":"web","dsh":{"profile":{"bundles":[';
|
|
505
|
+
await fs.writeFile(p.packageJson, malformed, 'utf8');
|
|
506
|
+
const before = await snapshotTree(homeDir);
|
|
507
|
+
|
|
508
|
+
const res = await enterFail(enterOpts(homeDir, backupDir));
|
|
509
|
+
|
|
510
|
+
assert.equal(res.code, 'package-json-invalid');
|
|
511
|
+
assert.equal(await readText(p.packageJson), malformed, 'package.json 逐字节不变');
|
|
512
|
+
assert.deepEqual(await snapshotTree(homeDir), before, '零改动(profile/home patch 也没被改)');
|
|
513
|
+
assert.equal(await fileExists(path.join(backupDir, 'state.json')), false, '未写状态文件');
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test('B-12 enterRescueMode:备份失败 → backup-failed 且原件不动', async (t) => {
|
|
517
|
+
const homeDir = await makeHome(t);
|
|
518
|
+
const p = targets(homeDir);
|
|
519
|
+
await seedOriginals(homeDir);
|
|
520
|
+
// 备份目录路径上放一个普通文件 → 无法创建目录
|
|
521
|
+
const blocked = path.join(homeDir, 'blocked-backup');
|
|
522
|
+
await fs.writeFile(blocked, 'not a directory\n', 'utf8');
|
|
523
|
+
const before = await snapshotTree(homeDir);
|
|
524
|
+
|
|
525
|
+
const res = await enterFail(enterOpts(homeDir, path.join(blocked, 'nested')));
|
|
526
|
+
|
|
527
|
+
assert.equal(res.code, 'backup-failed');
|
|
528
|
+
assert.deepEqual(await snapshotTree(homeDir), before, '备份失败 → 目标文件保持原样');
|
|
529
|
+
assert.equal(await readText(p.profilePatch), ORIGINAL_PROFILE_PATCH);
|
|
530
|
+
assert.equal(await readText(p.homePatch), ORIGINAL_HOME_PATCH);
|
|
531
|
+
assert.equal(await readText(p.packageJson), ORIGINAL_PACKAGE_JSON);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
test('B-13 enterRescueMode:无裁剪 → 保持原字节(不重写 package.json);缺省 resolver 保守', async (t) => {
|
|
535
|
+
const homeDir = await makeHome(t);
|
|
536
|
+
const backupDir = path.join(homeDir, 'rescue-backups');
|
|
537
|
+
const p = targets(homeDir);
|
|
538
|
+
await seedOriginals(homeDir);
|
|
539
|
+
|
|
540
|
+
// 全部可解析 → pruned 为空 → 不重写(单行 JSON 原样保留)
|
|
541
|
+
const state = await enterOk(enterOpts(homeDir, backupDir, { resolveBundle: () => true }));
|
|
542
|
+
assert.deepEqual(state.prunedBundles, []);
|
|
543
|
+
assert.equal(await readText(p.packageJson), ORIGINAL_PACKAGE_JSON, '无裁剪 → 不重序列化');
|
|
544
|
+
|
|
545
|
+
// 第二个 home:不传 resolver(缺省保守)→ 字符串名字全部保留,同样不重写
|
|
546
|
+
const homeDir2 = await makeHome(t);
|
|
547
|
+
const p2 = targets(homeDir2);
|
|
548
|
+
await writeFileDeep(p2.packageJson, ORIGINAL_PACKAGE_JSON);
|
|
549
|
+
await writeFileDeep(p2.profilePatch, ORIGINAL_PROFILE_PATCH);
|
|
550
|
+
const state2 = await enterOk(enterOpts(homeDir2, path.join(homeDir2, 'b')));
|
|
551
|
+
assert.deepEqual(state2.prunedBundles, []);
|
|
552
|
+
assert.equal(await readText(p2.packageJson), ORIGINAL_PACKAGE_JSON, '缺省 resolver = 不裁剪');
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
test('B-14 enterRescueMode:缺省备份目录 = <homeDir>/dsh-config-manager/transactions/rescue-backups', async (t) => {
|
|
556
|
+
const homeDir = await makeHome(t);
|
|
557
|
+
const p = targets(homeDir);
|
|
558
|
+
await seedOriginals(homeDir);
|
|
559
|
+
|
|
560
|
+
const state = await enterRescueMode({ homeDir, profile: PROFILE, rescueMount: RESCUE_MOUNT, resolveBundle: () => false });
|
|
561
|
+
assert.equal(state.ok, true);
|
|
562
|
+
if (!state.ok) return;
|
|
563
|
+
|
|
564
|
+
const defaultDir = at(homeDir, DEFAULT_BACKUP_DIR_REL);
|
|
565
|
+
assert.equal(await fileExists(path.join(defaultDir, 'state.json')), true, '状态文件落在缺省备份目录');
|
|
566
|
+
const status = await rescueModeStatus({ homeDir, profile: PROFILE });
|
|
567
|
+
assert.equal(status.active, true, '不传 backupDir 时状态同样可读');
|
|
568
|
+
|
|
569
|
+
// 走缺省目录退出
|
|
570
|
+
await exitOk({ homeDir });
|
|
571
|
+
assert.equal(await readText(p.profilePatch), ORIGINAL_PROFILE_PATCH);
|
|
572
|
+
assert.equal(await readText(p.packageJson), ORIGINAL_PACKAGE_JSON);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
test('B-15 enterRescueMode:neutralizeHomePatch=false → home patch 不被动,退出也不还原它', async (t) => {
|
|
576
|
+
const homeDir = await makeHome(t);
|
|
577
|
+
const backupDir = path.join(homeDir, 'rescue-backups');
|
|
578
|
+
const p = targets(homeDir);
|
|
579
|
+
await seedOriginals(homeDir);
|
|
580
|
+
|
|
581
|
+
await enterOk(enterOpts(homeDir, backupDir, { neutralizeHomePatch: false }));
|
|
582
|
+
assert.equal(await readText(p.homePatch), ORIGINAL_HOME_PATCH, '未置空');
|
|
583
|
+
|
|
584
|
+
const restored = await exitOk({ homeDir, backupDir });
|
|
585
|
+
assert.equal(restored.includes(rescuePaths(PROFILE).homePatch), true, '仍按备份还原 home patch');
|
|
586
|
+
assert.equal(await readText(p.homePatch), ORIGINAL_HOME_PATCH);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
test('B-22 rescueMount:本插件已在 bundles 里 → patch 只写空列表(禁止重复插入同 id 行)', async (t) => {
|
|
590
|
+
const homeDir = await makeHome(t);
|
|
591
|
+
const backupDir = path.join(homeDir, 'rescue-backups');
|
|
592
|
+
const p = targets(homeDir);
|
|
593
|
+
await writeFileDeep(p.profilePatch, ORIGINAL_PROFILE_PATCH);
|
|
594
|
+
await writeFileDeep(p.homePatch, ORIGINAL_HOME_PATCH);
|
|
595
|
+
await writeFileDeep(p.packageJson, PACKAGE_JSON_WITH_SELF_BUNDLE);
|
|
596
|
+
|
|
597
|
+
const state = await enterOk(enterOpts(homeDir, backupDir, { resolveBundle: () => true }));
|
|
598
|
+
|
|
599
|
+
// 回归护栏:bundle 层已挂载本插件 → patch 层必须是空列表。
|
|
600
|
+
// 旧实现无条件写 `- insert: id: config-manager`,与 bundle 提供的同 id 行冲突 →
|
|
601
|
+
// loader 抛 "duplicate loader entry id: config-manager" → 整棵插件树加载失败,DSH 起不来。
|
|
602
|
+
await assertRescuePatch(p.profilePatch, true);
|
|
603
|
+
const body = (await readText(p.profilePatch))
|
|
604
|
+
.split('\n')
|
|
605
|
+
.filter((line) => line.trim() !== '' && !line.trim().startsWith('#'))
|
|
606
|
+
.join('\n');
|
|
607
|
+
assert.equal(body.includes('insert'), false, '不得出现 insert 块');
|
|
608
|
+
assert.deepEqual(state.prunedBundles, [], '全部可解析 → 无裁剪');
|
|
609
|
+
assert.equal(await readText(p.packageJson), PACKAGE_JSON_WITH_SELF_BUNDLE, '无裁剪 → package.json 不重写');
|
|
610
|
+
assert.equal(await readText(p.homePatch), '[]\n', 'home patch 仍被置空');
|
|
611
|
+
|
|
612
|
+
// 退出后逐字节还原
|
|
613
|
+
await exitOk({ homeDir, backupDir });
|
|
614
|
+
assert.equal(await readText(p.profilePatch), ORIGINAL_PROFILE_PATCH);
|
|
615
|
+
assert.equal(await readText(p.homePatch), ORIGINAL_HOME_PATCH);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
test('B-23 rescueMount:package.json 缺失 / bundles 非数组 → 回退 patch 层挂载行', async (t) => {
|
|
619
|
+
const cases: (string | undefined)[] = [
|
|
620
|
+
undefined, // 无 package.json
|
|
621
|
+
'{"name":"dsh-profile-web","dsh":{"profile":{"bundles":"dsh-config-manager"}}}\n', // 字符串
|
|
622
|
+
'{"name":"dsh-profile-web","dsh":{"profile":{"bundles":{"0":"dsh-config-manager"}}}}\n', // 对象
|
|
623
|
+
];
|
|
624
|
+
for (const pkgText of cases) {
|
|
625
|
+
const homeDir = await makeHome(t);
|
|
626
|
+
const backupDir = path.join(homeDir, 'rescue-backups');
|
|
627
|
+
const p = targets(homeDir);
|
|
628
|
+
await writeFileDeep(p.profilePatch, ORIGINAL_PROFILE_PATCH);
|
|
629
|
+
if (pkgText !== undefined) await writeFileDeep(p.packageJson, pkgText);
|
|
630
|
+
|
|
631
|
+
await enterOk(enterOpts(homeDir, backupDir)); // 缺省 resolver = 不裁剪
|
|
632
|
+
|
|
633
|
+
// 无法确认 bundle 层挂载 → 必须由 patch 层挂载,否则救援模式里没有救援插件
|
|
634
|
+
await assertRescuePatch(p.profilePatch, false);
|
|
635
|
+
await exitOk({ homeDir, backupDir });
|
|
636
|
+
assert.equal(await readText(p.profilePatch), ORIGINAL_PROFILE_PATCH);
|
|
637
|
+
}
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
/* ---------------------------------------------------------------- 救援范围收窄(禁用其它插件) */
|
|
641
|
+
|
|
642
|
+
test('B-24 restrictToRescueBundles:只留 DSH 核心与救援插件自身', () => {
|
|
643
|
+
assert.deepEqual(RESCUE_KEEP_BUNDLE_PREFIXES, ['@deepseek-ai/']);
|
|
644
|
+
const r = restrictToRescueBundles(
|
|
645
|
+
['@deepseek-ai/dsh-base', 'dshmarket', 'dsh-config-manager', 'dsh-agy-link', '@deepseek-ai/dsh-web-app'],
|
|
646
|
+
'dsh-config-manager',
|
|
647
|
+
);
|
|
648
|
+
assert.deepEqual(r.kept, ['@deepseek-ai/dsh-base', 'dsh-config-manager', '@deepseek-ai/dsh-web-app'], '保序保留核心 + 自身');
|
|
649
|
+
assert.deepEqual(r.pruned, [
|
|
650
|
+
{ name: 'dshmarket', reason: 'rescue-disabled' },
|
|
651
|
+
{ name: 'dsh-agy-link', reason: 'rescue-disabled' },
|
|
652
|
+
]);
|
|
653
|
+
// 自定义保留前缀
|
|
654
|
+
const custom = restrictToRescueBundles(['@deepseek-ai/a', 'keep-me', 'drop-me'], 'self', ['keep-']);
|
|
655
|
+
assert.deepEqual(custom.kept, ['keep-me']);
|
|
656
|
+
assert.deepEqual(custom.pruned.map((p) => p.name), ['@deepseek-ai/a', 'drop-me']);
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* B-25 端到端:disableUserBundles=true 时必须真的把用户插件移出 bundles。
|
|
661
|
+
* 只中和 patch 层治不了「bundle 能解析但插件代码把 DSH 搞挂」——bundle 层会照旧挂载,
|
|
662
|
+
* 救援模式名不副实。本测试锁定「文案承诺 = 实际行为」。
|
|
663
|
+
*/
|
|
664
|
+
test('B-25 enterRescueMode(disableUserBundles):用户插件被移出 bundles,核心与自身保留', async (t) => {
|
|
665
|
+
const homeDir = await makeHome(t);
|
|
666
|
+
const backupDir = path.join(homeDir, 'rescue-backups');
|
|
667
|
+
const p = targets(homeDir);
|
|
668
|
+
await writeFileDeep(p.profilePatch, ORIGINAL_PROFILE_PATCH);
|
|
669
|
+
await writeFileDeep(p.homePatch, ORIGINAL_HOME_PATCH);
|
|
670
|
+
await writeFileDeep(p.packageJson, JSON.stringify({
|
|
671
|
+
name: 'dsh-profile-web',
|
|
672
|
+
dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', 'dshmarket', 'dsh-config-manager', 'dsh-agy-link'] } },
|
|
673
|
+
}) + '\n');
|
|
674
|
+
|
|
675
|
+
const state = await enterOk(enterOpts(homeDir, backupDir, {
|
|
676
|
+
resolveBundle: () => true, // 全部可解析:收窄必须靠 disableUserBundles,而不是靠「不可解析」
|
|
677
|
+
disableUserBundles: true,
|
|
678
|
+
}));
|
|
679
|
+
|
|
680
|
+
const written = JSON.parse(await readText(p.packageJson)) as { dsh: { profile: { bundles: unknown } } };
|
|
681
|
+
assert.deepEqual(
|
|
682
|
+
written.dsh.profile.bundles,
|
|
683
|
+
['@deepseek-ai/dsh-base', 'dsh-config-manager'],
|
|
684
|
+
'bundles 必须收窄为 DSH 核心 + 本插件(且是扁平 string[])',
|
|
685
|
+
);
|
|
686
|
+
assert.deepEqual(state.prunedBundles, [
|
|
687
|
+
{ name: 'dshmarket', reason: 'rescue-disabled' },
|
|
688
|
+
{ name: 'dsh-agy-link', reason: 'rescue-disabled' },
|
|
689
|
+
]);
|
|
690
|
+
// 本插件仍在 bundles → patch 层只能写空列表(否则 loader 抛 duplicate id)
|
|
691
|
+
await assertRescuePatch(p.profilePatch, true);
|
|
692
|
+
// patch 头注释必须如实说明 bundle 已被收窄(不得再写「只裁剪不可解析的 bundle」)
|
|
693
|
+
assert.ok((await readText(p.profilePatch)).includes('已被收窄'), '头注释必须说明 bundle 已收窄');
|
|
694
|
+
|
|
695
|
+
// 退出:逐字节还原(收窄完全可逆)
|
|
696
|
+
await exitOk({ homeDir, backupDir });
|
|
697
|
+
assert.ok((await readText(p.packageJson)).includes('dshmarket'), '退出后用户插件必须回来');
|
|
698
|
+
assert.equal(await readText(p.homePatch), ORIGINAL_HOME_PATCH);
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
test('B-26 disableUserBundles 缺省关闭:只剪不可解析的(保守路径不变)', async (t) => {
|
|
702
|
+
const homeDir = await makeHome(t);
|
|
703
|
+
const backupDir = path.join(homeDir, 'rescue-backups');
|
|
704
|
+
const p = targets(homeDir);
|
|
705
|
+
await writeFileDeep(p.profilePatch, ORIGINAL_PROFILE_PATCH);
|
|
706
|
+
await writeFileDeep(p.packageJson, JSON.stringify({
|
|
707
|
+
name: 'dsh-profile-web',
|
|
708
|
+
dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', 'good-bundle'] } },
|
|
709
|
+
}) + '\n');
|
|
710
|
+
|
|
711
|
+
const state = await enterOk(enterOpts(homeDir, backupDir, { resolveBundle: () => true }));
|
|
712
|
+
assert.deepEqual(state.prunedBundles, [], '缺省不按「非核心」收窄');
|
|
713
|
+
assert.ok((await readText(p.packageJson)).includes('good-bundle'), 'package.json 不得被重写');
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
/* ---------------------------------------------------------------- exitRescueMode */
|
|
717
|
+
|
|
718
|
+
test('B-16 exitRescueMode happy path:三处逐字节还原 + 状态文件删除', async (t) => {
|
|
719
|
+
const homeDir = await makeHome(t);
|
|
720
|
+
const backupDir = path.join(homeDir, 'rescue-backups');
|
|
721
|
+
const p = targets(homeDir);
|
|
722
|
+
await seedOriginals(homeDir);
|
|
723
|
+
|
|
724
|
+
await enterOk(enterOpts(homeDir, backupDir, { resolveBundle: (name) => name === 'good-bundle' }));
|
|
725
|
+
assert.notEqual(await readText(p.profilePatch), ORIGINAL_PROFILE_PATCH, '进入后已被改写');
|
|
726
|
+
|
|
727
|
+
const restored = await exitOk({ homeDir, backupDir });
|
|
728
|
+
|
|
729
|
+
assert.deepEqual(restored, [rescuePaths(PROFILE).profilePatch, rescuePaths(PROFILE).homePatch, rescuePaths(PROFILE).profilePackageJson]);
|
|
730
|
+
assert.equal(await readText(p.profilePatch), ORIGINAL_PROFILE_PATCH, 'profile patch 逐字节还原');
|
|
731
|
+
assert.equal(await readText(p.homePatch), ORIGINAL_HOME_PATCH, 'home patch 逐字节还原');
|
|
732
|
+
assert.equal(await readText(p.packageJson), ORIGINAL_PACKAGE_JSON, 'package.json 逐字节还原(含原单行格式)');
|
|
733
|
+
assert.equal(await fileExists(path.join(backupDir, 'state.json')), false, '状态文件已删除');
|
|
734
|
+
assert.deepEqual(await rescueModeStatus({ homeDir, profile: PROFILE, backupDir }), { active: false, stale: false, state: null });
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
test('B-17 exitRescueMode:备份缺失 → backup-missing 且先校验后还原(零改动)', async (t) => {
|
|
738
|
+
const homeDir = await makeHome(t);
|
|
739
|
+
const backupDir = path.join(homeDir, 'rescue-backups');
|
|
740
|
+
await seedOriginals(homeDir);
|
|
741
|
+
|
|
742
|
+
const state = await enterOk(enterOpts(homeDir, backupDir, { resolveBundle: (name) => name === 'good-bundle' }));
|
|
743
|
+
const homeBackup = state.backup.homePatch;
|
|
744
|
+
assert.ok(homeBackup !== null);
|
|
745
|
+
await fs.rm(homeBackup); // 破坏其中一个备份
|
|
746
|
+
|
|
747
|
+
const before = await snapshotTree(homeDir);
|
|
748
|
+
const res = await exitFail({ homeDir, backupDir });
|
|
749
|
+
|
|
750
|
+
assert.equal(res.code, 'backup-missing');
|
|
751
|
+
assert.deepEqual(await snapshotTree(homeDir), before, '校验失败必须先于任何还原/删除');
|
|
752
|
+
assert.equal(await fileExists(path.join(backupDir, 'state.json')), true, '状态文件保留(可修好后重试)');
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
test('B-18 exitRescueMode:未激活(无状态文件/状态损坏)→ not-active', async (t) => {
|
|
756
|
+
const homeDir = await makeHome(t);
|
|
757
|
+
const backupDir = path.join(homeDir, 'rescue-backups');
|
|
758
|
+
|
|
759
|
+
const missing = await exitFail({ homeDir, backupDir });
|
|
760
|
+
assert.equal(missing.code, 'not-active');
|
|
761
|
+
|
|
762
|
+
await writeFileDeep(path.join(backupDir, 'state.json'), '{ not json');
|
|
763
|
+
const corrupt = await exitFail({ homeDir, backupDir });
|
|
764
|
+
assert.equal(corrupt.code, 'not-active');
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
test('B-19 exitRescueMode:stale 状态(指纹不匹配)拒绝还原,零改动', async (t) => {
|
|
768
|
+
const homeDir = await makeHome(t);
|
|
769
|
+
const backupDir = path.join(homeDir, 'rescue-backups');
|
|
770
|
+
await seedOriginals(homeDir);
|
|
771
|
+
|
|
772
|
+
await enterOk(enterOpts(homeDir, backupDir));
|
|
773
|
+
const stateFile = path.join(backupDir, 'state.json');
|
|
774
|
+
const raw = JSON.parse(await readText(stateFile)) as Record<string, unknown>;
|
|
775
|
+
raw['homeFingerprint'] = 'f'.repeat(64);
|
|
776
|
+
await fs.writeFile(stateFile, `${JSON.stringify(raw, null, 2)}\n`, 'utf8');
|
|
777
|
+
|
|
778
|
+
const before = await snapshotTree(homeDir);
|
|
779
|
+
const res = await exitFail({ homeDir, backupDir });
|
|
780
|
+
|
|
781
|
+
assert.equal(res.code, 'not-active', 'stale 状态不激活');
|
|
782
|
+
assert.deepEqual(await snapshotTree(homeDir), before, '拒绝按 stale 状态改写本 home 的文件');
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
/* ---------------------------------------------------------------- rescueModeStatus */
|
|
786
|
+
|
|
787
|
+
test('B-20 rescueModeStatus:active / stale / 缺失 / 损坏', async (t) => {
|
|
788
|
+
const homeDir = await makeHome(t);
|
|
789
|
+
const backupDir = path.join(homeDir, 'rescue-backups');
|
|
790
|
+
const stateFile = path.join(backupDir, 'state.json');
|
|
791
|
+
await seedOriginals(homeDir);
|
|
792
|
+
|
|
793
|
+
// 缺失 → 非激活且非 stale
|
|
794
|
+
assert.deepEqual(await rescueModeStatus({ homeDir, profile: PROFILE, backupDir }), { active: false, stale: false, state: null });
|
|
795
|
+
|
|
796
|
+
// active
|
|
797
|
+
const entered = await enterOk(enterOpts(homeDir, backupDir));
|
|
798
|
+
const active = await rescueModeStatus({ homeDir, profile: PROFILE, backupDir });
|
|
799
|
+
assert.equal(active.active, true);
|
|
800
|
+
assert.equal(active.stale, false);
|
|
801
|
+
assert.equal(active.state?.profile, PROFILE);
|
|
802
|
+
assert.equal(active.state?.homeFingerprint, entered.homeFingerprint);
|
|
803
|
+
|
|
804
|
+
// stale:指纹不匹配 → active=false, stale=true(仍返回解析出的状态供展示)
|
|
805
|
+
const raw = JSON.parse(await readText(stateFile)) as Record<string, unknown>;
|
|
806
|
+
raw['homeFingerprint'] = '0'.repeat(64);
|
|
807
|
+
await fs.writeFile(stateFile, `${JSON.stringify(raw, null, 2)}\n`, 'utf8');
|
|
808
|
+
const stale = await rescueModeStatus({ homeDir, profile: PROFILE, backupDir });
|
|
809
|
+
assert.equal(stale.active, false);
|
|
810
|
+
assert.equal(stale.stale, true);
|
|
811
|
+
assert.equal(stale.state?.active, true, 'stale 分支返回磁盘上的状态');
|
|
812
|
+
// 换了 profile / 换了 home → 同样是 stale
|
|
813
|
+
assert.equal((await rescueModeStatus({ homeDir, profile: 'other', backupDir })).stale, true);
|
|
814
|
+
assert.equal((await rescueModeStatus({ homeDir: `${homeDir}-moved`, profile: PROFILE, backupDir })).stale, true);
|
|
815
|
+
|
|
816
|
+
// 损坏 → 与缺失同形
|
|
817
|
+
await fs.writeFile(stateFile, '{ not json', 'utf8');
|
|
818
|
+
assert.deepEqual(await rescueModeStatus({ homeDir, profile: PROFILE, backupDir }), { active: false, stale: false, state: null });
|
|
819
|
+
// 合法 JSON 但形状不对(如 active:false / 缺字段)→ 同样判为损坏
|
|
820
|
+
await fs.writeFile(stateFile, '{"active":false}\n', 'utf8');
|
|
821
|
+
assert.deepEqual(await rescueModeStatus({ homeDir, profile: PROFILE, backupDir }), { active: false, stale: false, state: null });
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
/* ---------------------------------------------------------------- 全流程往返 */
|
|
825
|
+
|
|
826
|
+
test('B-21 全流程往返:enter → status active → exit → status inactive,profile patch 逐字节相等', async (t) => {
|
|
827
|
+
const homeDir = await makeHome(t);
|
|
828
|
+
const backupDir = path.join(homeDir, 'rescue-backups');
|
|
829
|
+
const p = targets(homeDir);
|
|
830
|
+
await seedOriginals(homeDir);
|
|
831
|
+
|
|
832
|
+
assert.equal((await rescueModeStatus({ homeDir, profile: PROFILE, backupDir })).active, false);
|
|
833
|
+
|
|
834
|
+
await enterOk(enterOpts(homeDir, backupDir, { resolveBundle: (name) => name === 'good-bundle' }));
|
|
835
|
+
const active = await rescueModeStatus({ homeDir, profile: PROFILE, backupDir });
|
|
836
|
+
assert.equal(active.active, true);
|
|
837
|
+
await assertRescuePatch(p.profilePatch, false);
|
|
838
|
+
|
|
839
|
+
await exitOk({ homeDir, backupDir });
|
|
840
|
+
|
|
841
|
+
const inactive = await rescueModeStatus({ homeDir, profile: PROFILE, backupDir });
|
|
842
|
+
assert.equal(inactive.active, false);
|
|
843
|
+
assert.equal(inactive.stale, false);
|
|
844
|
+
assert.equal(await readText(p.profilePatch), ORIGINAL_PROFILE_PATCH, '往返后 profile patch 与原始字节完全相等');
|
|
845
|
+
assert.equal(await readText(p.homePatch), ORIGINAL_HOME_PATCH);
|
|
846
|
+
assert.equal(await readText(p.packageJson), ORIGINAL_PACKAGE_JSON);
|
|
847
|
+
});
|