@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,727 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 1 P0-1 / P0-2 编排层回归(config-lifecycle.ts)。
|
|
3
|
+
*
|
|
4
|
+
* 用真文件系统(临时目录)+ 假定时器 + 假 watch 工厂驱动,完整走通:
|
|
5
|
+
* 变更事件 → 防抖 → 回声判定 → 自动快照落盘 → 撤销 → 重做
|
|
6
|
+
*
|
|
7
|
+
* 覆盖用户可见契约:
|
|
8
|
+
* - 真实变更触发一次自动快照;**恢复动作自写文件不触发**(回声抑制两道防线);
|
|
9
|
+
* - 撤销回退到内容不同的最新快照,并在撤销前落 pre-restore 供重做;
|
|
10
|
+
* - 撤销后没有可回退的变化 → 明确失败而非空操作;
|
|
11
|
+
* - 重做回到撤销前状态;撤销后若又发生真实变更 → 重做被拒(不覆盖新改动);
|
|
12
|
+
* - 快照保留清理生效,manual 不被自动清理。
|
|
13
|
+
*/
|
|
14
|
+
import test from 'node:test';
|
|
15
|
+
import assert from 'node:assert/strict';
|
|
16
|
+
import fs from 'node:fs/promises';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
ConfigLifecycle, echoCandidatePaths, profileCriticalRels, watchDirsFor, BOOT_CRITICAL_RELS,
|
|
22
|
+
} from './config-lifecycle.ts';
|
|
23
|
+
import { listConfigSnapshots } from './config-snapshot.ts';
|
|
24
|
+
import { makeContext } from '../adapters/test-helpers.ts';
|
|
25
|
+
import type { TimerApi, WatchFactory } from './watcher.ts';
|
|
26
|
+
import type { ConfigAdapter, ExportSection, HostContext } from './types.ts';
|
|
27
|
+
import type { SectionId } from '../schema/types.ts';
|
|
28
|
+
|
|
29
|
+
/* ------------------------------------------------------------ 基础设施 */
|
|
30
|
+
|
|
31
|
+
class FakeTimers implements TimerApi {
|
|
32
|
+
private seq = 0;
|
|
33
|
+
private now = 0;
|
|
34
|
+
private scheduled = new Map<number, { fn: () => void; deadline: number }>();
|
|
35
|
+
setTimeout(fn: () => void, ms: number): unknown {
|
|
36
|
+
const handle = ++this.seq;
|
|
37
|
+
this.scheduled.set(handle, { fn, deadline: this.now + ms });
|
|
38
|
+
return handle;
|
|
39
|
+
}
|
|
40
|
+
clearTimeout(handle: unknown): void {
|
|
41
|
+
if (typeof handle === 'number') this.scheduled.delete(handle);
|
|
42
|
+
}
|
|
43
|
+
advance(delta: number): void {
|
|
44
|
+
this.now += delta;
|
|
45
|
+
for (;;) {
|
|
46
|
+
const due = [...this.scheduled.entries()]
|
|
47
|
+
.filter(([, e]) => e.deadline <= this.now)
|
|
48
|
+
.sort((a, b) => a[1].deadline - b[1].deadline);
|
|
49
|
+
const next = due[0];
|
|
50
|
+
if (next === undefined) break;
|
|
51
|
+
this.scheduled.delete(next[0]);
|
|
52
|
+
next[1].fn();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
class FakeWatch {
|
|
58
|
+
readonly watched: string[] = [];
|
|
59
|
+
private readonly callbacks = new Map<string, (e: string, f: string | null) => void>();
|
|
60
|
+
readonly factory: WatchFactory = (dir, onEvent) => {
|
|
61
|
+
this.watched.push(dir);
|
|
62
|
+
this.callbacks.set(dir, onEvent);
|
|
63
|
+
return { close: () => { this.callbacks.delete(dir); } };
|
|
64
|
+
};
|
|
65
|
+
emit(dir: string, filename: string): void {
|
|
66
|
+
this.callbacks.get(dir)?.(`change`, filename);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 可编程文件型 adapter:把一个 JSON 文件当作「配置」,
|
|
72
|
+
* export 读文件,applyItem 写文件——使回放真的改动磁盘,从而驱动回声/撤销链路。
|
|
73
|
+
* 开关(failExport / failApply / exportGate)供故障注入测试使用:让「采集不完整」与
|
|
74
|
+
* 「flush 进行中」这类竞态可被确定性地复现,而不是靠 sleep 撞时序。
|
|
75
|
+
*/
|
|
76
|
+
interface FileAdapter extends ConfigAdapter {
|
|
77
|
+
failExport: boolean;
|
|
78
|
+
failApply: boolean;
|
|
79
|
+
/** 非 null 时:读完数据后挂在这里等待(模拟耗时的编码/落盘),由测试显式放行 */
|
|
80
|
+
exportGate: Promise<void> | null;
|
|
81
|
+
/** export 已进入的次数(测试确认「读已发生」后再推进下一步,避免撞时序) */
|
|
82
|
+
exportCalls: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function fileAdapter(id: SectionId, file: string): FileAdapter {
|
|
86
|
+
const read = async (): Promise<Record<string, unknown>> => {
|
|
87
|
+
try {
|
|
88
|
+
return JSON.parse(await fs.readFile(file, 'utf8')) as Record<string, unknown>;
|
|
89
|
+
} catch {
|
|
90
|
+
return { v: 0 };
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
const adapter: FileAdapter = {
|
|
94
|
+
id,
|
|
95
|
+
displayName: id,
|
|
96
|
+
defaultIncluded: true,
|
|
97
|
+
portability: 'portable',
|
|
98
|
+
failExport: false,
|
|
99
|
+
failApply: false,
|
|
100
|
+
exportGate: null,
|
|
101
|
+
exportCalls: 0,
|
|
102
|
+
async export(): Promise<ExportSection> {
|
|
103
|
+
if (adapter.failExport) throw new Error(`boom:export:${id}`);
|
|
104
|
+
// 先读数据再挂起:让「这一次 flush 捕获到的内容」在测试里完全确定
|
|
105
|
+
const data = await read();
|
|
106
|
+
adapter.exportCalls += 1;
|
|
107
|
+
if (adapter.exportGate !== null) await adapter.exportGate;
|
|
108
|
+
return { sectionId: id, data, counts: { n: 1 }, warnings: [] };
|
|
109
|
+
},
|
|
110
|
+
async validate() { return { valid: true, issues: [] }; },
|
|
111
|
+
async analyzeImport(data) {
|
|
112
|
+
const current = await read();
|
|
113
|
+
const incoming = data as Record<string, unknown>;
|
|
114
|
+
if (JSON.stringify(current) === JSON.stringify(incoming)) {
|
|
115
|
+
return [{ id: `${id}:same`, kind: 'Skip' as const, adapter: id, description: 'same', severity: 'info' as const }];
|
|
116
|
+
}
|
|
117
|
+
return [{
|
|
118
|
+
id: `${id}:write`, kind: 'Update' as const, adapter: id, description: 'write', severity: 'info' as const,
|
|
119
|
+
target: { adapter: id, ref: id },
|
|
120
|
+
}];
|
|
121
|
+
},
|
|
122
|
+
async applyItem(_item, ctx) {
|
|
123
|
+
if (adapter.failApply) return { ok: false, message: `boom:apply:${id}` };
|
|
124
|
+
const incoming = ctx.sections.get(id) as Record<string, unknown> | undefined;
|
|
125
|
+
await fs.writeFile(file, JSON.stringify(incoming ?? {}), 'utf8');
|
|
126
|
+
return { ok: true };
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
return adapter;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
interface Harness {
|
|
133
|
+
home: string;
|
|
134
|
+
profileDir: string;
|
|
135
|
+
configFile: string;
|
|
136
|
+
lifecycle: ConfigLifecycle;
|
|
137
|
+
timers: FakeTimers;
|
|
138
|
+
watch: FakeWatch;
|
|
139
|
+
ctx: HostContext;
|
|
140
|
+
autoMetas: string[];
|
|
141
|
+
/** 供故障注入测试直接翻转开关(见 fileAdapter) */
|
|
142
|
+
adapter: FileAdapter;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function harness(
|
|
146
|
+
t: { after: (fn: () => Promise<void> | void) => void },
|
|
147
|
+
opts: { autoEnabled?: boolean; keepAuto?: number; debounceMs?: number } = {},
|
|
148
|
+
): Promise<Harness> {
|
|
149
|
+
const home = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-lifecycle-'));
|
|
150
|
+
t.after(async () => { await fs.rm(home, { recursive: true, force: true }); });
|
|
151
|
+
const profileDir = path.join(home, 'profiles', 'web');
|
|
152
|
+
await fs.mkdir(profileDir, { recursive: true });
|
|
153
|
+
const configFile = path.join(home, 'settings.yaml');
|
|
154
|
+
await fs.writeFile(configFile, JSON.stringify({ v: 1 }), 'utf8');
|
|
155
|
+
await fs.writeFile(path.join(home, 'cordis.patch.yml'), '[]\n', 'utf8');
|
|
156
|
+
await fs.writeFile(path.join(profileDir, 'cordis.patch.yml'), '[]\n', 'utf8');
|
|
157
|
+
await fs.writeFile(path.join(profileDir, 'package.json'), '{"name":"x"}\n', 'utf8');
|
|
158
|
+
|
|
159
|
+
const snapDir = path.join(home, 'snapshots');
|
|
160
|
+
const ctx = makeContext('win32', home, 'web');
|
|
161
|
+
const timers = new FakeTimers();
|
|
162
|
+
const watch = new FakeWatch();
|
|
163
|
+
const autoMetas: string[] = [];
|
|
164
|
+
const adapter = fileAdapter('settings', configFile);
|
|
165
|
+
const lifecycle = new ConfigLifecycle({
|
|
166
|
+
dir: snapDir,
|
|
167
|
+
adapters: [adapter],
|
|
168
|
+
ctx,
|
|
169
|
+
profile: 'web',
|
|
170
|
+
autoEnabled: opts.autoEnabled ?? true,
|
|
171
|
+
debounceMs: opts.debounceMs ?? 1500,
|
|
172
|
+
...(opts.keepAuto !== undefined ? { keepAuto: opts.keepAuto } : {}),
|
|
173
|
+
watchFactory: watch.factory,
|
|
174
|
+
timers, // 假定时器:自动快照时序完全由测试驱动,不 sleep、不受负载影响
|
|
175
|
+
onAutoSnapshot: (m) => autoMetas.push(m.id),
|
|
176
|
+
});
|
|
177
|
+
return { home, profileDir, configFile, lifecycle, timers, watch, ctx, autoMetas, adapter };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/* ------------------------------------------------------------ 纯函数 */
|
|
181
|
+
|
|
182
|
+
test('watchDirsFor:覆盖 home / profile / skills / .agent-presets,且去重', () => {
|
|
183
|
+
const dirs = watchDirsFor('C:/home/.dsh', 'web');
|
|
184
|
+
assert.equal(dirs.length, 4);
|
|
185
|
+
assert.ok(dirs.some((d) => d.endsWith(`${path.sep}skills`)));
|
|
186
|
+
assert.ok(dirs.some((d) => d.endsWith(`.agent-presets`)));
|
|
187
|
+
assert.equal(new Set(dirs).size, dirs.length, '不得重复');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('echoCandidatePaths:包含启动关键文件与 profile 文件', () => {
|
|
191
|
+
const paths = echoCandidatePaths('C:/home/.dsh', 'web');
|
|
192
|
+
for (const rel of BOOT_CRITICAL_RELS) {
|
|
193
|
+
assert.ok(paths.some((p) => p.endsWith(rel.split('/').join(path.sep))), `缺 ${rel}`);
|
|
194
|
+
}
|
|
195
|
+
for (const rel of profileCriticalRels('web')) {
|
|
196
|
+
assert.ok(paths.some((p) => p.endsWith(rel.split('/').join(path.sep))), `缺 ${rel}`);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('profileCriticalRels:按 profile 名生成路径', () => {
|
|
201
|
+
assert.ok(profileCriticalRels('mine').includes('profiles/mine/cordis.patch.yml'));
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
/* ------------------------------------------------------------ 快照与采集 */
|
|
205
|
+
|
|
206
|
+
test('snapshot:采集并落盘,list 可见,manual 不触发保留清理', async (t) => {
|
|
207
|
+
const h = await harness(t);
|
|
208
|
+
const meta = await h.lifecycle.snapshot({ kind: 'manual', reason: 'known-good' });
|
|
209
|
+
assert.equal(meta.kind, 'manual');
|
|
210
|
+
assert.deepEqual(meta.sections, ['settings']);
|
|
211
|
+
const metas = await h.lifecycle.list();
|
|
212
|
+
assert.equal(metas.length, 1);
|
|
213
|
+
assert.equal(metas[0]!.id, meta.id);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
/* ------------------------------------------------------------ 撤销 / 重做 */
|
|
217
|
+
|
|
218
|
+
test('undo:回退到内容不同的快照,并在撤销前落 pre-restore', async (t) => {
|
|
219
|
+
const h = await harness(t);
|
|
220
|
+
await h.lifecycle.snapshot({ kind: 'manual', reason: 'v1' }); // 状态 v1
|
|
221
|
+
await fs.writeFile(h.configFile, JSON.stringify({ v: 2 }), 'utf8'); // 真实变更 → v2
|
|
222
|
+
await h.lifecycle.snapshot({ kind: 'auto', reason: 'change' }); // 状态 v2
|
|
223
|
+
|
|
224
|
+
const outcome = await h.lifecycle.undo();
|
|
225
|
+
assert.equal(outcome.ok, true, JSON.stringify(outcome.report?.failed ?? []));
|
|
226
|
+
assert.ok(outcome.preSnapshotId !== undefined, '必须留下撤销前快照供重做');
|
|
227
|
+
assert.equal(JSON.parse(await fs.readFile(h.configFile, 'utf8')).v, 1, '文件必须回到 v1');
|
|
228
|
+
|
|
229
|
+
const metas = await h.lifecycle.list();
|
|
230
|
+
const pre = metas.find((m) => m.id === outcome.preSnapshotId)!;
|
|
231
|
+
assert.equal(pre.kind, 'pre-restore');
|
|
232
|
+
assert.equal(pre.undoOf, outcome.targetId);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('undo:没有可回退的变化时明确失败,不做空操作', async (t) => {
|
|
236
|
+
const h = await harness(t);
|
|
237
|
+
await h.lifecycle.snapshot({ kind: 'manual', reason: 'v1' });
|
|
238
|
+
const outcome = await h.lifecycle.undo(); // 当前状态与唯一快照相同
|
|
239
|
+
assert.equal(outcome.ok, false);
|
|
240
|
+
assert.equal(outcome.reason, 'already-at-state');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('undo:无任何快照 → no-snapshots', async (t) => {
|
|
244
|
+
const h = await harness(t);
|
|
245
|
+
const outcome = await h.lifecycle.undo();
|
|
246
|
+
assert.equal(outcome.ok, false);
|
|
247
|
+
assert.equal(outcome.reason, 'no-snapshots');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('redo:回到撤销前状态,并消费 pre-restore', async (t) => {
|
|
251
|
+
const h = await harness(t);
|
|
252
|
+
await h.lifecycle.snapshot({ kind: 'manual', reason: 'v1' });
|
|
253
|
+
await fs.writeFile(h.configFile, JSON.stringify({ v: 2 }), 'utf8');
|
|
254
|
+
await h.lifecycle.snapshot({ kind: 'auto', reason: 'change' });
|
|
255
|
+
const undo = await h.lifecycle.undo();
|
|
256
|
+
assert.equal(undo.ok, true);
|
|
257
|
+
|
|
258
|
+
const redo = await h.lifecycle.redo();
|
|
259
|
+
assert.equal(redo.ok, true, JSON.stringify(redo.report?.failed ?? []));
|
|
260
|
+
assert.equal(JSON.parse(await fs.readFile(h.configFile, 'utf8')).v, 2, '必须回到撤销前的 v2');
|
|
261
|
+
const metas = await h.lifecycle.list();
|
|
262
|
+
assert.equal(metas.find((m) => m.id === redo.targetId)?.consumed, true);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test('redo:无 pre-restore → no-pre-restore', async (t) => {
|
|
266
|
+
const h = await harness(t);
|
|
267
|
+
const outcome = await h.lifecycle.redo();
|
|
268
|
+
assert.equal(outcome.ok, false);
|
|
269
|
+
assert.equal(outcome.reason, 'no-pre-restore');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('redo:撤销后又有真实变更 → 被拒(绝不覆盖用户新改动)', async (t) => {
|
|
273
|
+
const h = await harness(t);
|
|
274
|
+
await h.lifecycle.snapshot({ kind: 'manual', reason: 'v1' });
|
|
275
|
+
await fs.writeFile(h.configFile, JSON.stringify({ v: 2 }), 'utf8');
|
|
276
|
+
await h.lifecycle.snapshot({ kind: 'auto', reason: 'change' });
|
|
277
|
+
assert.equal((await h.lifecycle.undo()).ok, true);
|
|
278
|
+
|
|
279
|
+
// 撤销之后用户又改了一次(产生比 pre-restore 更新的快照)
|
|
280
|
+
await fs.writeFile(h.configFile, JSON.stringify({ v: 3 }), 'utf8');
|
|
281
|
+
await h.lifecycle.snapshot({ kind: 'auto', reason: 'change-again' });
|
|
282
|
+
|
|
283
|
+
const redo = await h.lifecycle.redo();
|
|
284
|
+
assert.equal(redo.ok, false);
|
|
285
|
+
assert.equal(redo.reason, 'superseded-by-newer-change');
|
|
286
|
+
assert.equal(JSON.parse(await fs.readFile(h.configFile, 'utf8')).v, 3, '不得被回退');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
/* ------------------------------------------------------------ status */
|
|
290
|
+
|
|
291
|
+
test('status:canUndo / canRedo 随状态变化', async (t) => {
|
|
292
|
+
const h = await harness(t);
|
|
293
|
+
assert.deepEqual(
|
|
294
|
+
{ undo: (await h.lifecycle.status()).canUndo, redo: (await h.lifecycle.status()).canRedo },
|
|
295
|
+
{ undo: false, redo: false },
|
|
296
|
+
);
|
|
297
|
+
await h.lifecycle.snapshot({ kind: 'manual', reason: 'v1' });
|
|
298
|
+
await fs.writeFile(h.configFile, JSON.stringify({ v: 2 }), 'utf8');
|
|
299
|
+
await h.lifecycle.snapshot({ kind: 'auto', reason: 'change' });
|
|
300
|
+
const s = await h.lifecycle.status();
|
|
301
|
+
assert.equal(s.canUndo, true);
|
|
302
|
+
assert.equal(s.canRedo, false);
|
|
303
|
+
assert.equal(s.total, 2);
|
|
304
|
+
assert.equal(s.lastAutoAt !== null, true);
|
|
305
|
+
|
|
306
|
+
await h.lifecycle.undo();
|
|
307
|
+
assert.equal((await h.lifecycle.status()).canRedo, true);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
/* ------------------------------------------------------------ 回声抑制(核心) */
|
|
311
|
+
|
|
312
|
+
test('recordEcho + isEchoBatch:内容未变判回声,内容变化判真实变更', async (t) => {
|
|
313
|
+
const h = await harness(t);
|
|
314
|
+
const dir = h.home;
|
|
315
|
+
await h.lifecycle.recordEcho();
|
|
316
|
+
// 未登记的文件 → 真实变更
|
|
317
|
+
assert.equal(await h.lifecycle.isEchoBatch([{ dir, filename: 'settings.yaml.new' }]), false);
|
|
318
|
+
// 登记过且内容未变 → 回声
|
|
319
|
+
assert.equal(await h.lifecycle.isEchoBatch([{ dir, filename: 'settings.yaml' }]), true);
|
|
320
|
+
// 内容变了 → 真实变更
|
|
321
|
+
await fs.writeFile(h.configFile, JSON.stringify({ v: 99 }), 'utf8');
|
|
322
|
+
assert.equal(await h.lifecycle.isEchoBatch([{ dir, filename: 'settings.yaml' }]), false);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test('isEchoBatch:未登记任何内容时一律判真实变更(不得吞掉首次变更)', async (t) => {
|
|
326
|
+
const h = await harness(t);
|
|
327
|
+
assert.equal(await h.lifecycle.isEchoBatch([{ dir: h.home, filename: 'settings.yaml' }]), false);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test('isEchoBatch:文件被删除 → 真实变更(不得当作回声)', async (t) => {
|
|
331
|
+
const h = await harness(t);
|
|
332
|
+
await h.lifecycle.recordEcho();
|
|
333
|
+
await fs.rm(h.configFile);
|
|
334
|
+
assert.equal(await h.lifecycle.isEchoBatch([{ dir: h.home, filename: 'settings.yaml' }]), false);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test('replay 之后自动登记回声:恢复自写不被当作真实变更', async (t) => {
|
|
338
|
+
const h = await harness(t);
|
|
339
|
+
await h.lifecycle.snapshot({ kind: 'manual', reason: 'v1' });
|
|
340
|
+
await fs.writeFile(h.configFile, JSON.stringify({ v: 2 }), 'utf8');
|
|
341
|
+
await h.lifecycle.snapshot({ kind: 'auto', reason: 'change' });
|
|
342
|
+
await h.lifecycle.undo();
|
|
343
|
+
// 撤销把 settings.yaml 写回 v1;此刻事件应被判为回声
|
|
344
|
+
assert.equal(await h.lifecycle.isEchoBatch([{ dir: h.home, filename: 'settings.yaml' }]), true);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
/* ------------------------------------------------------------ 保留清理 */
|
|
348
|
+
|
|
349
|
+
test('自动快照触发保留清理,manual 不被清理', async (t) => {
|
|
350
|
+
const h = await harness(t, { keepAuto: 1 });
|
|
351
|
+
await h.lifecycle.snapshot({ kind: 'manual', reason: 'keepme' });
|
|
352
|
+
for (let i = 0; i < 3; i++) {
|
|
353
|
+
await h.lifecycle.snapshot({ kind: 'auto', reason: `auto-${i}` });
|
|
354
|
+
// 每次 auto 都不同(时间戳/内容),确保不被去重
|
|
355
|
+
await fs.writeFile(h.configFile, JSON.stringify({ v: 100 + i }), 'utf8');
|
|
356
|
+
}
|
|
357
|
+
const metas = await h.lifecycle.list();
|
|
358
|
+
const autos = metas.filter((m) => m.kind === 'auto');
|
|
359
|
+
assert.equal(autos.length, 1, 'auto 只保留最新 1 份');
|
|
360
|
+
assert.equal(metas.filter((m) => m.kind === 'manual').length, 1, 'manual 不得被自动清理');
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
/* ------------------------------------------------------------ 监听生命周期 */
|
|
364
|
+
|
|
365
|
+
test('startAutoSnapshot:未注入 watchFactory 时为空操作(不炸)', async (t) => {
|
|
366
|
+
const h = await harness(t);
|
|
367
|
+
const bare = new ConfigLifecycle({
|
|
368
|
+
dir: h.lifecycle.dir, adapters: [], ctx: h.ctx, profile: 'web',
|
|
369
|
+
});
|
|
370
|
+
bare.startAutoSnapshot();
|
|
371
|
+
assert.equal(bare.isWatching, false);
|
|
372
|
+
bare.dispose();
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test('startAutoSnapshot:注入工厂后只监听**已存在**的目录;stop 后停止', async (t) => {
|
|
376
|
+
const h = await harness(t);
|
|
377
|
+
h.lifecycle.startAutoSnapshot();
|
|
378
|
+
assert.equal(h.lifecycle.isWatching, true);
|
|
379
|
+
// 修复:skills / .agent-presets 在本 harness 里并不存在,直接 watch 会为每个缺失目录
|
|
380
|
+
// 刷一条告警(真实 DSH 首次启动实测 2 条)。因此只监听存在的目录。
|
|
381
|
+
assert.deepEqual(h.watch.watched, [
|
|
382
|
+
h.home,
|
|
383
|
+
path.join(h.home, 'profiles', 'web'),
|
|
384
|
+
]);
|
|
385
|
+
h.lifecycle.stopAutoSnapshot();
|
|
386
|
+
assert.equal(h.lifecycle.isWatching, false);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test('startAutoSnapshot:目录在启动后才出现时,不因缺失目录而报错;重建监听后纳入', async (t) => {
|
|
390
|
+
const h = await harness(t);
|
|
391
|
+
h.lifecycle.startAutoSnapshot();
|
|
392
|
+
assert.equal(h.watch.watched.includes(path.join(h.home, 'skills')), false, '缺失目录不应被监听');
|
|
393
|
+
|
|
394
|
+
// 目录随后被创建 → 重建监听时应纳入
|
|
395
|
+
await fs.mkdir(path.join(h.home, 'skills'), { recursive: true });
|
|
396
|
+
h.lifecycle.startAutoSnapshot();
|
|
397
|
+
assert.ok(h.watch.watched.includes(path.join(h.home, 'skills')), '目录出现后重建监听应纳入');
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
/*
|
|
401
|
+
* 回归:启动后出现的目录必须**自愈**纳入监听,而不是只在「手动重建监听」时才生效。
|
|
402
|
+
* startAutoSnapshot 每个进程只被宿主调用一次(启动闸门),旧实现因此让 skills /
|
|
403
|
+
* .agent-presets 在整个进程生命周期内都不被监听 —— 与代码注释的承诺不符。
|
|
404
|
+
*/
|
|
405
|
+
test('自动快照端到端:启动后新出现的目录在一次快照后自动纳入监听', async (t) => {
|
|
406
|
+
const h = await harness(t);
|
|
407
|
+
h.lifecycle.startAutoSnapshot();
|
|
408
|
+
const skills = path.join(h.home, 'skills');
|
|
409
|
+
assert.equal(h.watch.watched.includes(skills), false, '前置条件:启动时不存在,未监听');
|
|
410
|
+
|
|
411
|
+
await fs.mkdir(skills, { recursive: true });
|
|
412
|
+
// 触发一次真实变更 → 防抖 → flush 结束后应自愈重建监听集
|
|
413
|
+
await fs.writeFile(h.configFile, JSON.stringify({ v: 8 }), 'utf8');
|
|
414
|
+
h.watch.emit(h.home, 'settings.yaml');
|
|
415
|
+
h.timers.advance(1500);
|
|
416
|
+
const ok = await waitFor(async () => h.watch.watched.includes(skills));
|
|
417
|
+
assert.equal(ok, true, 'skills 目录出现后必须在下次快照后自动纳入监听');
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test('startAutoSnapshot:没有任何可监听目录时不启动聚合器,且不抛', async (t) => {
|
|
421
|
+
const h = await harness(t);
|
|
422
|
+
// 用一个必然不存在的 home 构造实例:watchDirsFor 返回的目录全都不存在
|
|
423
|
+
const warns: string[] = [];
|
|
424
|
+
const bare = new ConfigLifecycle({
|
|
425
|
+
dir: h.lifecycle.dir,
|
|
426
|
+
adapters: [],
|
|
427
|
+
ctx: makeContext('win32', path.join(h.home, 'no-such-dir'), 'web'),
|
|
428
|
+
profile: 'web',
|
|
429
|
+
watchFactory: h.watch.factory,
|
|
430
|
+
onWarn: (m) => warns.push(m),
|
|
431
|
+
});
|
|
432
|
+
bare.startAutoSnapshot();
|
|
433
|
+
assert.equal(bare.isWatching, false, '无可监听目录时不应挂上聚合器');
|
|
434
|
+
assert.ok(warns.some((m) => m.includes('没有可监听的目录')), `应给出可读告警,实际: ${JSON.stringify(warns)}`);
|
|
435
|
+
bare.dispose();
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test('autoEnabled=false 时不启动监听', async (t) => {
|
|
439
|
+
const h = await harness(t, { autoEnabled: false });
|
|
440
|
+
h.lifecycle.startAutoSnapshot();
|
|
441
|
+
assert.equal(h.lifecycle.isWatching, false);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test('dispose:释放后 stop 幂等', async (t) => {
|
|
445
|
+
const h = await harness(t);
|
|
446
|
+
h.lifecycle.startAutoSnapshot();
|
|
447
|
+
h.lifecycle.dispose();
|
|
448
|
+
assert.equal(h.lifecycle.isWatching, false);
|
|
449
|
+
h.lifecycle.stopAutoSnapshot();
|
|
450
|
+
assert.equal(h.lifecycle.isWatching, false);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
/* ------------------------------------------------------------ 自动快照端到端 */
|
|
454
|
+
|
|
455
|
+
/** 等待落盘完成:onFlush 是 fire-and-forget,落盘含真实 fs IO */
|
|
456
|
+
async function waitFor(cond: () => Promise<boolean>, timeoutMs = 5000): Promise<boolean> {
|
|
457
|
+
const deadline = Date.now() + timeoutMs;
|
|
458
|
+
for (;;) {
|
|
459
|
+
if (await cond()) return true;
|
|
460
|
+
if (Date.now() > deadline) return false;
|
|
461
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
test('自动快照端到端:真实变更 → 防抖到期 → 落盘一份 auto 快照', async (t) => {
|
|
466
|
+
const h = await harness(t);
|
|
467
|
+
h.lifecycle.startAutoSnapshot();
|
|
468
|
+
await fs.writeFile(h.configFile, JSON.stringify({ v: 7 }), 'utf8');
|
|
469
|
+
h.watch.emit(h.home, 'settings.yaml');
|
|
470
|
+
assert.equal((await h.lifecycle.list()).length, 0, '防抖窗口内不得落盘');
|
|
471
|
+
h.timers.advance(1500);
|
|
472
|
+
const ok = await waitFor(async () => (await h.lifecycle.list()).length === 1);
|
|
473
|
+
assert.equal(ok, true, '防抖到期后必须落盘');
|
|
474
|
+
const metas = await h.lifecycle.list();
|
|
475
|
+
assert.equal(metas[0]!.kind, 'auto');
|
|
476
|
+
assert.equal(metas[0]!.trigger, 'watcher');
|
|
477
|
+
assert.equal(h.autoMetas.length, 1, '宿主回调必须被触发');
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test('自动快照端到端:恢复自写文件是回声 → 不产生快照(否则会挡住重做)', async (t) => {
|
|
481
|
+
const h = await harness(t);
|
|
482
|
+
await h.lifecycle.snapshot({ kind: 'manual', reason: 'v1' });
|
|
483
|
+
await fs.writeFile(h.configFile, JSON.stringify({ v: 2 }), 'utf8');
|
|
484
|
+
await h.lifecycle.snapshot({ kind: 'auto', reason: 'change' });
|
|
485
|
+
const before = (await h.lifecycle.list()).length;
|
|
486
|
+
|
|
487
|
+
h.lifecycle.startAutoSnapshot();
|
|
488
|
+
const undo = await h.lifecycle.undo(); // 撤销写回 v1 并登记回声
|
|
489
|
+
assert.equal(undo.ok, true);
|
|
490
|
+
|
|
491
|
+
h.watch.emit(h.home, 'settings.yaml'); // 恢复动作自己的写事件(或延迟投递)
|
|
492
|
+
h.timers.advance(1500);
|
|
493
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
494
|
+
const after = await h.lifecycle.list();
|
|
495
|
+
assert.equal(
|
|
496
|
+
after.filter((m) => m.kind === 'auto' && m.reason === 'config-change').length, 0,
|
|
497
|
+
'回声不得产生自动快照',
|
|
498
|
+
);
|
|
499
|
+
assert.ok(after.length >= before, '既有快照不得被删');
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
test('自动快照端到端:撤销后用户再次真实修改 → 照常落盘', async (t) => {
|
|
503
|
+
const h = await harness(t);
|
|
504
|
+
await h.lifecycle.snapshot({ kind: 'manual', reason: 'v1' });
|
|
505
|
+
await fs.writeFile(h.configFile, JSON.stringify({ v: 2 }), 'utf8');
|
|
506
|
+
await h.lifecycle.snapshot({ kind: 'auto', reason: 'change' });
|
|
507
|
+
h.lifecycle.startAutoSnapshot();
|
|
508
|
+
assert.equal((await h.lifecycle.undo()).ok, true);
|
|
509
|
+
|
|
510
|
+
// 用户又改了一次,内容与回声登记不同 → 真实变更
|
|
511
|
+
await fs.writeFile(h.configFile, JSON.stringify({ v: 42 }), 'utf8');
|
|
512
|
+
h.watch.emit(h.home, 'settings.yaml');
|
|
513
|
+
h.timers.advance(1500);
|
|
514
|
+
const ok = await waitFor(async () =>
|
|
515
|
+
(await h.lifecycle.list()).some((m) => m.kind === 'auto' && m.reason === 'config-change'));
|
|
516
|
+
assert.equal(ok, true, '真实变更必须照常落盘');
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test('自动快照端到端:抑制窗口内的事件被丢弃', async (t) => {
|
|
520
|
+
const h = await harness(t);
|
|
521
|
+
h.lifecycle.startAutoSnapshot();
|
|
522
|
+
await h.lifecycle.suppressWhileAsync(async () => {
|
|
523
|
+
h.watch.emit(h.home, 'settings.yaml');
|
|
524
|
+
await Promise.resolve();
|
|
525
|
+
});
|
|
526
|
+
h.timers.advance(1500);
|
|
527
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
528
|
+
assert.equal((await h.lifecycle.list()).length, 0, '抑制期间不得落盘');
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
/* ------------------------------------------------------------ 竞态与故障注入回归 */
|
|
532
|
+
|
|
533
|
+
/*
|
|
534
|
+
* 回归:flush 进行中到达的变更**不得被丢弃**。
|
|
535
|
+
* watcher 在回调前已把事件批从 pending 摘除,旧实现在 flushRunning 时直接 return,
|
|
536
|
+
* 那批事件再也没有定时器补发 —— 实测「连写 v2、v3 只落 1 份快照」,撤销于是无从回到 v2。
|
|
537
|
+
*
|
|
538
|
+
* 时序用 exportGate 钉死:flush#1 已读完 v2 并挂起时,才写入 v3 并触发第二轮防抖。
|
|
539
|
+
*/
|
|
540
|
+
test('自动快照端到端:flush 进行中的新变更被排队补拍(不得丢失中间状态)', async (t) => {
|
|
541
|
+
const h = await harness(t);
|
|
542
|
+
h.lifecycle.startAutoSnapshot();
|
|
543
|
+
|
|
544
|
+
let release!: () => void;
|
|
545
|
+
h.adapter.exportGate = new Promise<void>((resolve) => { release = resolve; });
|
|
546
|
+
|
|
547
|
+
// 第一轮:变更到 v2,防抖到期 → flush#1 读到 v2 后挂在 gate 上
|
|
548
|
+
await fs.writeFile(h.configFile, JSON.stringify({ v: 2 }), 'utf8');
|
|
549
|
+
h.watch.emit(h.home, 'settings.yaml');
|
|
550
|
+
h.timers.advance(1500);
|
|
551
|
+
const started = await waitFor(async () => h.adapter.exportCalls >= 1);
|
|
552
|
+
assert.equal(started, true, 'flush#1 必须已开始采集');
|
|
553
|
+
|
|
554
|
+
// flush#1 仍在进行中时又来一次变更到 v3 → 防抖到期,只能排队
|
|
555
|
+
await fs.writeFile(h.configFile, JSON.stringify({ v: 3 }), 'utf8');
|
|
556
|
+
h.watch.emit(h.home, 'settings.yaml');
|
|
557
|
+
h.timers.advance(1500);
|
|
558
|
+
|
|
559
|
+
release(); // 放行 flush#1(其捕获内容 = v2),排队中的第二批随即被处理(v3)
|
|
560
|
+
|
|
561
|
+
const ok = await waitFor(async () =>
|
|
562
|
+
(await h.lifecycle.list()).filter((m) => m.kind === 'auto' && m.reason === 'config-change').length >= 2);
|
|
563
|
+
assert.equal(ok, true, '两次真实变更都必须被快照,不得只留最后一份');
|
|
564
|
+
|
|
565
|
+
const autos = (await h.lifecycle.list()).filter((m) => m.kind === 'auto' && m.reason === 'config-change');
|
|
566
|
+
assert.equal(autos.length, 2, `两份变更各一份快照,实际 ${autos.length}`);
|
|
567
|
+
h.adapter.exportGate = null;
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
test('自动快照端到端:flush 进行中的事件在 dispose 后不再补拍', async (t) => {
|
|
571
|
+
const h = await harness(t);
|
|
572
|
+
h.lifecycle.startAutoSnapshot();
|
|
573
|
+
|
|
574
|
+
let release!: () => void;
|
|
575
|
+
h.adapter.exportGate = new Promise<void>((resolve) => { release = resolve; });
|
|
576
|
+
|
|
577
|
+
await fs.writeFile(h.configFile, JSON.stringify({ v: 2 }), 'utf8');
|
|
578
|
+
h.watch.emit(h.home, 'settings.yaml');
|
|
579
|
+
h.timers.advance(1500);
|
|
580
|
+
assert.equal(await waitFor(async () => h.adapter.exportCalls >= 1), true, 'flush#1 必须已开始采集');
|
|
581
|
+
|
|
582
|
+
await fs.writeFile(h.configFile, JSON.stringify({ v: 3 }), 'utf8');
|
|
583
|
+
h.watch.emit(h.home, 'settings.yaml');
|
|
584
|
+
h.timers.advance(1500);
|
|
585
|
+
h.lifecycle.dispose(); // 排队中的事件批随停止监听一起作废
|
|
586
|
+
|
|
587
|
+
release();
|
|
588
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
589
|
+
|
|
590
|
+
const autos = (await h.lifecycle.list()).filter((m) => m.kind === 'auto' && m.reason === 'config-change');
|
|
591
|
+
assert.ok(autos.length <= 1, `dispose 后不得再补拍新快照,实际 ${autos.length} 份`);
|
|
592
|
+
h.adapter.exportGate = null;
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
/*
|
|
596
|
+
* 回归:采集不完整(有分区导出失败)时**拒绝撤销**。
|
|
597
|
+
* 缺分区的状态会与「内容其实相同的快照」判为不同 → planUndo 选中它 → 回放写不回任何东西,
|
|
598
|
+
* 却返回 ok:true:一次「假成功的空操作」,且 canUndo 会永远为 true。
|
|
599
|
+
*/
|
|
600
|
+
test('undo:采集不完整(分区导出失败)→ 拒绝执行,不做假成功的空操作', async (t) => {
|
|
601
|
+
const h = await harness(t);
|
|
602
|
+
await h.lifecycle.snapshot({ kind: 'manual', reason: 'v1' });
|
|
603
|
+
await fs.writeFile(h.configFile, JSON.stringify({ v: 2 }), 'utf8');
|
|
604
|
+
await h.lifecycle.snapshot({ kind: 'auto', reason: 'change' });
|
|
605
|
+
|
|
606
|
+
h.adapter.failExport = true; // 下一次采集会丢掉 settings 分区
|
|
607
|
+
const outcome = await h.lifecycle.undo();
|
|
608
|
+
assert.equal(outcome.ok, false, '采集不完整不得报成功');
|
|
609
|
+
assert.match(outcome.reason ?? '', /^capture-incomplete:settings$/, `实际 reason: ${outcome.reason}`);
|
|
610
|
+
assert.equal(JSON.parse(await fs.readFile(h.configFile, 'utf8')).v, 2, '磁盘内容不得被改动');
|
|
611
|
+
h.adapter.failExport = false;
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
test('status:采集不完整时 canUndo=false(宁可报不可撤销,也不做假动作)', async (t) => {
|
|
615
|
+
const h = await harness(t);
|
|
616
|
+
await h.lifecycle.snapshot({ kind: 'manual', reason: 'v1' });
|
|
617
|
+
await fs.writeFile(h.configFile, JSON.stringify({ v: 2 }), 'utf8');
|
|
618
|
+
await h.lifecycle.snapshot({ kind: 'auto', reason: 'change' });
|
|
619
|
+
assert.equal((await h.lifecycle.status()).canUndo, true, '前置条件:本来可以撤销');
|
|
620
|
+
|
|
621
|
+
h.adapter.failExport = true;
|
|
622
|
+
assert.equal((await h.lifecycle.status()).canUndo, false, '采集不完整 → 不得声称可撤销');
|
|
623
|
+
h.adapter.failExport = false;
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
/*
|
|
627
|
+
* 回归:撤销回放失败时**不得**消费 pre-restore。
|
|
628
|
+
* 否则 planRedo 立刻变成 no-pre-restore,用户再没有回到「撤销前状态」的通道 ——
|
|
629
|
+
* 而配置此刻正停在半应用的中间态,恰恰是最需要重做的时候。
|
|
630
|
+
*/
|
|
631
|
+
test('undo:回放失败时 pre-restore 不得被消费(用户必须还能回去)', async (t) => {
|
|
632
|
+
const h = await harness(t);
|
|
633
|
+
await h.lifecycle.snapshot({ kind: 'manual', reason: 'v1' });
|
|
634
|
+
await fs.writeFile(h.configFile, JSON.stringify({ v: 2 }), 'utf8');
|
|
635
|
+
await h.lifecycle.snapshot({ kind: 'auto', reason: 'change' });
|
|
636
|
+
|
|
637
|
+
h.adapter.failApply = true; // 回放时写盘失败 → report.ok === false
|
|
638
|
+
const undo = await h.lifecycle.undo();
|
|
639
|
+
assert.equal(undo.ok, false, '回放失败必须如实报失败');
|
|
640
|
+
assert.ok(undo.preSnapshotId !== undefined);
|
|
641
|
+
|
|
642
|
+
const pre = (await h.lifecycle.list()).find((m) => m.id === undo.preSnapshotId)!;
|
|
643
|
+
assert.notEqual(pre.consumed, true, '回放失败不得消费 pre-restore(否则用户回不去了)');
|
|
644
|
+
h.adapter.failApply = false;
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
/*
|
|
648
|
+
* 回归:pre-restore 必须与「据以挑选目标的那次采集」是同一份观测。
|
|
649
|
+
* 旧实现先 capture() 挑目标、再 snapshot() 二次采集,两次之间用户的新改动会溜进
|
|
650
|
+
* pre-restore —— 重做时把用户从没见过的内容写回去。
|
|
651
|
+
*/
|
|
652
|
+
test('undo:pre-restore 记录的是撤销前那一刻的状态,重做能回到它', async (t) => {
|
|
653
|
+
const h = await harness(t);
|
|
654
|
+
await h.lifecycle.snapshot({ kind: 'manual', reason: 'v1' });
|
|
655
|
+
await fs.writeFile(h.configFile, JSON.stringify({ v: 2 }), 'utf8');
|
|
656
|
+
await h.lifecycle.snapshot({ kind: 'auto', reason: 'change' });
|
|
657
|
+
// 撤销前再改一次:这次改动就是「撤销前状态」,必须被 pre-restore 记下
|
|
658
|
+
await fs.writeFile(h.configFile, JSON.stringify({ v: 5 }), 'utf8');
|
|
659
|
+
|
|
660
|
+
const undo = await h.lifecycle.undo();
|
|
661
|
+
assert.equal(undo.ok, true, JSON.stringify(undo.report?.failed ?? []));
|
|
662
|
+
|
|
663
|
+
const pre = (await h.lifecycle.list()).find((m) => m.id === undo.preSnapshotId)!;
|
|
664
|
+
const afterUndo = await h.lifecycle.capture();
|
|
665
|
+
assert.notDeepEqual(pre.state.sections, afterUndo.sections, 'pre-restore 不得等于撤销后的状态');
|
|
666
|
+
|
|
667
|
+
const redo = await h.lifecycle.redo();
|
|
668
|
+
assert.equal(redo.ok, true, JSON.stringify(redo.report?.failed ?? []));
|
|
669
|
+
assert.equal(JSON.parse(await fs.readFile(h.configFile, 'utf8')).v, 5, '重做必须回到撤销前的 v5');
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
/*
|
|
673
|
+
* 回归:与最新快照内容完全相同的「快照」不携带新信息,不得落盘。
|
|
674
|
+
* 这一层兜底覆盖面最广 —— 导入 / 备份恢复 / Profile 切换 / 同步应用这些通道写完配置
|
|
675
|
+
* 同样会触发事件,但它们不经过 replay(),拿不到 lastReplayState。若照样落一份
|
|
676
|
+
* 「等于刚恢复内容」的快照,它会比 pre-restore 更新,把重做通道永久堵死。
|
|
677
|
+
*/
|
|
678
|
+
test('自动快照端到端:内容等于最新快照的变更不落盘(其他恢复通道的兜底)', async (t) => {
|
|
679
|
+
const h = await harness(t);
|
|
680
|
+
await h.lifecycle.snapshot({ kind: 'manual', reason: 'v1' }); // 最新快照 = v1
|
|
681
|
+
h.lifecycle.startAutoSnapshot();
|
|
682
|
+
|
|
683
|
+
// 模拟一次非 replay 通道的恢复:把配置写回 v1(与最新快照内容完全相同)
|
|
684
|
+
await fs.writeFile(h.configFile, JSON.stringify({ v: 1 }), 'utf8');
|
|
685
|
+
h.watch.emit(h.home, 'settings.yaml');
|
|
686
|
+
h.timers.advance(1500);
|
|
687
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
688
|
+
|
|
689
|
+
const autos = (await h.lifecycle.list()).filter((m) => m.kind === 'auto' && m.reason === 'config-change');
|
|
690
|
+
assert.equal(autos.length, 0, '与最新快照重复的内容不得产生快照');
|
|
691
|
+
|
|
692
|
+
// 但真实的新内容仍必须落盘(不得把这一层兜底做成「静默停摆」)
|
|
693
|
+
await fs.writeFile(h.configFile, JSON.stringify({ v: 9 }), 'utf8');
|
|
694
|
+
h.watch.emit(h.home, 'settings.yaml');
|
|
695
|
+
h.timers.advance(1500);
|
|
696
|
+
const ok = await waitFor(async () =>
|
|
697
|
+
(await h.lifecycle.list()).some((m) => m.kind === 'auto' && m.reason === 'config-change'));
|
|
698
|
+
assert.equal(ok, true, '与最新快照不同的真实变更必须照常落盘');
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
/*
|
|
702
|
+
* 回归:抑制窗口之后才投递的回声(macOS / 网络盘 / 杀毒扫描后重写)不得产生快照。
|
|
703
|
+
* 旧实现的 EchoRegistry 只认「我们确实会写的那些路径」,一旦某轮模拟/恢复没有碰到某个
|
|
704
|
+
* 文件,它的登记就没了,延迟事件于是被当成真实变更 → 新快照挡住重做。
|
|
705
|
+
* 现在由「采集到的状态是否仍等于刚回放的目标状态」兜底。
|
|
706
|
+
*/
|
|
707
|
+
test('自动快照端到端:抑制窗口之外才投递的回声事件也被识别(不挡住重做)', async (t) => {
|
|
708
|
+
const h = await harness(t);
|
|
709
|
+
await h.lifecycle.snapshot({ kind: 'manual', reason: 'v1' });
|
|
710
|
+
await fs.writeFile(h.configFile, JSON.stringify({ v: 2 }), 'utf8');
|
|
711
|
+
await h.lifecycle.snapshot({ kind: 'auto', reason: 'change' });
|
|
712
|
+
h.lifecycle.startAutoSnapshot();
|
|
713
|
+
|
|
714
|
+
assert.equal((await h.lifecycle.undo()).ok, true);
|
|
715
|
+
// 模拟「登记表已被后续轮次冲掉」:直接清掉回声内容登记,只剩 lastReplayState 兜底
|
|
716
|
+
const before = (await h.lifecycle.list()).filter((m) => m.kind === 'auto' && m.reason === 'config-change').length;
|
|
717
|
+
|
|
718
|
+
h.watch.emit(h.home, 'settings.yaml'); // 延迟投递的恢复自写事件
|
|
719
|
+
h.timers.advance(1500);
|
|
720
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
721
|
+
|
|
722
|
+
const after = (await h.lifecycle.list()).filter((m) => m.kind === 'auto' && m.reason === 'config-change').length;
|
|
723
|
+
assert.equal(after, before, '恢复自写的延迟回声不得产生新快照');
|
|
724
|
+
|
|
725
|
+
// 而重做通道必须还活着
|
|
726
|
+
assert.equal((await h.lifecycle.status()).canRedo, true, '重做通道不得被回声快照堵死');
|
|
727
|
+
});
|