cursor-guard 2.1.0 → 4.0.0

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.
@@ -0,0 +1,1459 @@
1
+ 'use strict';
2
+
3
+ const assert = require('assert');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const { execFileSync } = require('child_process');
8
+
9
+ const { runDiagnostics } = require('./doctor');
10
+ const { createGitSnapshot, createShadowCopy } = require('./snapshot');
11
+ const { listBackups, cleanShadowRetention } = require('./backups');
12
+ const { restoreFile, previewProjectRestore, executeProjectRestore, createPreRestoreSnapshot, validateShadowSource } = require('./restore');
13
+ const { runFixes } = require('./doctor-fix');
14
+ const { getBackupStatus } = require('./status');
15
+ const { createChangeTracker, recordChange, checkAnomaly, getAlertStatus, saveAlert, loadActiveAlert, clearExpiredAlert, clearAlert, alertFilePath } = require('./anomaly');
16
+ const { getDashboard, dirSizeBytes, formatBytes, relativeTime } = require('./dashboard');
17
+
18
+ let passed = 0;
19
+ let failed = 0;
20
+
21
+ function test(name, fn) {
22
+ try {
23
+ fn();
24
+ passed++;
25
+ console.log(` \x1b[32m✓\x1b[0m ${name}`);
26
+ } catch (e) {
27
+ failed++;
28
+ console.log(` \x1b[31m✗\x1b[0m ${name}`);
29
+ console.log(` ${e.message}`);
30
+ }
31
+ }
32
+
33
+ function createTempGitRepo() {
34
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'guard-core-test-'));
35
+ execFileSync('git', ['init'], { cwd: tmpDir, stdio: 'pipe' });
36
+ execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: tmpDir, stdio: 'pipe' });
37
+ execFileSync('git', ['config', 'user.name', 'Test'], { cwd: tmpDir, stdio: 'pipe' });
38
+ fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'hello world');
39
+ fs.mkdirSync(path.join(tmpDir, 'src'));
40
+ fs.writeFileSync(path.join(tmpDir, 'src', 'app.js'), 'console.log("app");');
41
+ execFileSync('git', ['add', '-A'], { cwd: tmpDir, stdio: 'pipe' });
42
+ execFileSync('git', ['commit', '-m', 'initial', '--no-verify'], { cwd: tmpDir, stdio: 'pipe' });
43
+ return tmpDir;
44
+ }
45
+
46
+ function createTempDir() {
47
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'guard-core-test-'));
48
+ }
49
+
50
+ function cleanupDir(dir) {
51
+ fs.rmSync(dir, { recursive: true, force: true });
52
+ }
53
+
54
+ // ── core/doctor.js ──────────────────────────────────────────────
55
+
56
+ console.log('\ncore/doctor:');
57
+
58
+ test('returns structured result with checks and summary', () => {
59
+ const tmpDir = createTempGitRepo();
60
+ try {
61
+ const result = runDiagnostics(tmpDir);
62
+ assert.ok(Array.isArray(result.checks), 'checks should be an array');
63
+ assert.ok(result.checks.length > 0, 'should have at least one check');
64
+ assert.ok(typeof result.summary === 'object', 'summary should be an object');
65
+ assert.ok(typeof result.summary.pass === 'number');
66
+ assert.ok(typeof result.summary.warn === 'number');
67
+ assert.ok(typeof result.summary.fail === 'number');
68
+ } finally {
69
+ cleanupDir(tmpDir);
70
+ }
71
+ });
72
+
73
+ test('each check has name, status, and optional detail', () => {
74
+ const tmpDir = createTempGitRepo();
75
+ try {
76
+ const { checks } = runDiagnostics(tmpDir);
77
+ for (const c of checks) {
78
+ assert.ok(typeof c.name === 'string', `check name should be string, got ${typeof c.name}`);
79
+ assert.ok(['PASS', 'WARN', 'FAIL'].includes(c.status), `invalid status: ${c.status}`);
80
+ }
81
+ } finally {
82
+ cleanupDir(tmpDir);
83
+ }
84
+ });
85
+
86
+ test('detects git repo correctly', () => {
87
+ const tmpDir = createTempGitRepo();
88
+ try {
89
+ const { checks } = runDiagnostics(tmpDir);
90
+ const repoCheck = checks.find(c => c.name === 'Git repository');
91
+ assert.ok(repoCheck, 'should have Git repository check');
92
+ assert.strictEqual(repoCheck.status, 'PASS');
93
+ } finally {
94
+ cleanupDir(tmpDir);
95
+ }
96
+ });
97
+
98
+ test('warns for non-git directory', () => {
99
+ const tmpDir = createTempDir();
100
+ try {
101
+ fs.writeFileSync(path.join(tmpDir, 'file.txt'), 'test');
102
+ const { checks } = runDiagnostics(tmpDir);
103
+ const repoCheck = checks.find(c => c.name === 'Git repository');
104
+ if (repoCheck) {
105
+ assert.strictEqual(repoCheck.status, 'WARN');
106
+ }
107
+ } finally {
108
+ cleanupDir(tmpDir);
109
+ }
110
+ });
111
+
112
+ test('summary counts match check statuses', () => {
113
+ const tmpDir = createTempGitRepo();
114
+ try {
115
+ const { checks, summary } = runDiagnostics(tmpDir);
116
+ let pass = 0, warn = 0, fail = 0;
117
+ for (const c of checks) {
118
+ if (c.status === 'PASS') pass++;
119
+ else if (c.status === 'WARN') warn++;
120
+ else if (c.status === 'FAIL') fail++;
121
+ }
122
+ assert.strictEqual(summary.pass, pass);
123
+ assert.strictEqual(summary.warn, warn);
124
+ assert.strictEqual(summary.fail, fail);
125
+ } finally {
126
+ cleanupDir(tmpDir);
127
+ }
128
+ });
129
+
130
+ test('includes MCP server status check', () => {
131
+ const tmpDir = createTempGitRepo();
132
+ try {
133
+ const { checks } = runDiagnostics(tmpDir);
134
+ const mcpCheck = checks.find(c => c.name === 'MCP server');
135
+ assert.ok(mcpCheck, 'should have MCP server check');
136
+ assert.ok(['PASS', 'WARN'].includes(mcpCheck.status), `status should be PASS or WARN, got ${mcpCheck.status}`);
137
+ assert.ok(mcpCheck.detail, 'should have detail');
138
+ } finally {
139
+ cleanupDir(tmpDir);
140
+ }
141
+ });
142
+
143
+ // ── core/snapshot.js ────────────────────────────────────────────
144
+
145
+ console.log('\ncore/snapshot (git):');
146
+
147
+ test('creates git snapshot and returns structured result', () => {
148
+ const tmpDir = createTempGitRepo();
149
+ try {
150
+ const { loadConfig } = require('../utils');
151
+ const { cfg } = loadConfig(tmpDir);
152
+ const result = createGitSnapshot(tmpDir, cfg);
153
+ assert.strictEqual(result.status, 'created');
154
+ assert.ok(result.commitHash, 'should have commitHash');
155
+ assert.ok(result.shortHash, 'should have shortHash');
156
+ assert.strictEqual(result.shortHash.length, 7);
157
+ assert.ok(typeof result.fileCount === 'number');
158
+ } finally {
159
+ cleanupDir(tmpDir);
160
+ }
161
+ });
162
+
163
+ test('skips when tree is unchanged', () => {
164
+ const tmpDir = createTempGitRepo();
165
+ try {
166
+ const { loadConfig } = require('../utils');
167
+ const { cfg } = loadConfig(tmpDir);
168
+ createGitSnapshot(tmpDir, cfg);
169
+ const result2 = createGitSnapshot(tmpDir, cfg);
170
+ assert.strictEqual(result2.status, 'skipped');
171
+ assert.strictEqual(result2.reason, 'tree unchanged');
172
+ } finally {
173
+ cleanupDir(tmpDir);
174
+ }
175
+ });
176
+
177
+ test('returns error for non-git directory', () => {
178
+ const tmpDir = createTempDir();
179
+ try {
180
+ const { loadConfig } = require('../utils');
181
+ const { cfg } = loadConfig(tmpDir);
182
+ const result = createGitSnapshot(tmpDir, cfg);
183
+ assert.strictEqual(result.status, 'error');
184
+ assert.ok(result.error);
185
+ } finally {
186
+ cleanupDir(tmpDir);
187
+ }
188
+ });
189
+
190
+ console.log('\ncore/snapshot (shadow):');
191
+
192
+ test('creates shadow copy and returns structured result', () => {
193
+ const tmpDir = createTempDir();
194
+ try {
195
+ fs.writeFileSync(path.join(tmpDir, 'file.js'), 'content');
196
+ const { loadConfig } = require('../utils');
197
+ const { cfg } = loadConfig(tmpDir);
198
+ const result = createShadowCopy(tmpDir, cfg);
199
+ assert.strictEqual(result.status, 'created');
200
+ assert.ok(result.timestamp);
201
+ assert.ok(result.fileCount > 0);
202
+ assert.ok(result.snapshotDir);
203
+ assert.ok(fs.existsSync(result.snapshotDir));
204
+ } finally {
205
+ cleanupDir(tmpDir);
206
+ }
207
+ });
208
+
209
+ // ── core/backups.js ─────────────────────────────────────────────
210
+
211
+ console.log('\ncore/backups:');
212
+
213
+ test('listBackups returns structured result with sources array', () => {
214
+ const tmpDir = createTempGitRepo();
215
+ try {
216
+ const result = listBackups(tmpDir);
217
+ assert.ok(Array.isArray(result.sources), 'sources should be an array');
218
+ } finally {
219
+ cleanupDir(tmpDir);
220
+ }
221
+ });
222
+
223
+ test('listBackups finds git auto-backup after snapshot', () => {
224
+ const tmpDir = createTempGitRepo();
225
+ try {
226
+ const { loadConfig } = require('../utils');
227
+ const { cfg } = loadConfig(tmpDir);
228
+ createGitSnapshot(tmpDir, cfg);
229
+ const result = listBackups(tmpDir);
230
+ const autoBackups = result.sources.filter(s => s.type === 'git-auto-backup');
231
+ assert.ok(autoBackups.length > 0, 'should find auto-backup after snapshot');
232
+ assert.ok(autoBackups[0].commitHash);
233
+ assert.ok(autoBackups[0].shortHash);
234
+ } finally {
235
+ cleanupDir(tmpDir);
236
+ }
237
+ });
238
+
239
+ test('listBackups finds shadow copies', () => {
240
+ const tmpDir = createTempDir();
241
+ try {
242
+ fs.writeFileSync(path.join(tmpDir, 'file.js'), 'content');
243
+ const { loadConfig } = require('../utils');
244
+ const { cfg } = loadConfig(tmpDir);
245
+ createShadowCopy(tmpDir, cfg);
246
+ const result = listBackups(tmpDir);
247
+ const shadows = result.sources.filter(s => s.type === 'shadow');
248
+ assert.ok(shadows.length > 0, 'should find shadow copy');
249
+ assert.ok(shadows[0].timestamp);
250
+ assert.ok(shadows[0].path);
251
+ } finally {
252
+ cleanupDir(tmpDir);
253
+ }
254
+ });
255
+
256
+ test('listBackups returns globally time-sorted results across sources', () => {
257
+ const tmpDir = createTempGitRepo();
258
+ try {
259
+ const { loadConfig } = require('../utils');
260
+ const { cfg } = loadConfig(tmpDir);
261
+ createGitSnapshot(tmpDir, cfg);
262
+
263
+ // Create a shadow copy (non-git timestamp dir)
264
+ const backupDir = path.join(tmpDir, '.cursor-guard-backup');
265
+ fs.mkdirSync(backupDir, { recursive: true });
266
+ const futureTs = '29990101_000000';
267
+ const futureDir = path.join(backupDir, futureTs);
268
+ fs.mkdirSync(futureDir);
269
+ fs.writeFileSync(path.join(futureDir, 'hello.txt'), 'x');
270
+
271
+ const result = listBackups(tmpDir);
272
+ assert.ok(result.sources.length >= 2, 'should have both git and shadow sources');
273
+
274
+ // Verify sorted descending by time
275
+ for (let i = 1; i < result.sources.length; i++) {
276
+ const cur = result.sources[i].timestamp;
277
+ const prev = result.sources[i - 1].timestamp;
278
+ if (cur && prev) {
279
+ assert.ok(Date.parse(prev) >= Date.parse(cur) || prev >= cur,
280
+ `sources[${i - 1}] (${prev}) should be >= sources[${i}] (${cur})`);
281
+ }
282
+ }
283
+ } finally {
284
+ cleanupDir(tmpDir);
285
+ }
286
+ });
287
+
288
+ test('listBackups before filter applies to snapshot ref', () => {
289
+ const tmpDir = createTempGitRepo();
290
+ try {
291
+ const { loadConfig } = require('../utils');
292
+ const { cfg } = loadConfig(tmpDir);
293
+ createGitSnapshot(tmpDir, cfg, { branchRef: 'refs/guard/snapshot', message: 'guard: manual snapshot' });
294
+
295
+ const result = listBackups(tmpDir, { before: '2020-01-01T00:00:00Z' });
296
+ const snaps = result.sources.filter(s => s.type === 'git-snapshot');
297
+ assert.strictEqual(snaps.length, 0, 'snapshot ref should be filtered out by before=2020');
298
+ } finally {
299
+ cleanupDir(tmpDir);
300
+ }
301
+ });
302
+
303
+ test('listBackups filters out non-guard commits from auto-backup ref', () => {
304
+ const tmpDir = createTempGitRepo();
305
+ try {
306
+ // Manually seed refs/guard/auto-backup from HEAD (simulating old behavior)
307
+ execFileSync('git', ['update-ref', 'refs/guard/auto-backup',
308
+ execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim()
309
+ ], { cwd: tmpDir, stdio: 'pipe' });
310
+
311
+ const result = listBackups(tmpDir);
312
+ const autoBackups = result.sources.filter(s => s.type === 'git-auto-backup');
313
+ assert.strictEqual(autoBackups.length, 0, 'should NOT list user commits as auto-backups');
314
+ } finally {
315
+ cleanupDir(tmpDir);
316
+ }
317
+ });
318
+
319
+ test('createGitSnapshot creates orphan commit when ref does not exist', () => {
320
+ const tmpDir = createTempGitRepo();
321
+ try {
322
+ const { loadConfig } = require('../utils');
323
+ const { cfg } = loadConfig(tmpDir);
324
+ const ref = 'refs/guard/test-orphan';
325
+
326
+ const snap = createGitSnapshot(tmpDir, cfg, { branchRef: ref });
327
+ assert.strictEqual(snap.status, 'created');
328
+ assert.ok(snap.commitHash);
329
+
330
+ const parents = execFileSync('git', ['rev-parse', `${ref}^`], {
331
+ cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8',
332
+ }).trim();
333
+ assert.fail('orphan commit should have no parent, but rev-parse succeeded');
334
+ } catch (e) {
335
+ if (e.code === 'ERR_ASSERTION') throw e;
336
+ // Expected: git rev-parse fails because orphan has no parent
337
+ } finally {
338
+ cleanupDir(tmpDir);
339
+ }
340
+ });
341
+
342
+ test('createGitSnapshot drops files when protect scope narrows', () => {
343
+ const tmpDir = createTempGitRepo();
344
+ try {
345
+ const { loadConfig } = require('../utils');
346
+ fs.mkdirSync(path.join(tmpDir, 'docs'), { recursive: true });
347
+ fs.writeFileSync(path.join(tmpDir, 'docs', 'readme.md'), 'docs');
348
+
349
+ const wideCfg = { ...loadConfig(tmpDir).cfg, protect: ['src/**', 'docs/**'] };
350
+ const snap1 = createGitSnapshot(tmpDir, wideCfg);
351
+ assert.strictEqual(snap1.status, 'created');
352
+
353
+ const tree1Files = execFileSync('git', ['ls-tree', '--name-only', '-r', snap1.commitHash], {
354
+ cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8',
355
+ }).trim().split('\n');
356
+ assert.ok(tree1Files.includes('docs/readme.md'), 'wide protect should include docs/readme.md');
357
+
358
+ const narrowCfg = { ...loadConfig(tmpDir).cfg, protect: ['src/**'] };
359
+ const snap2 = createGitSnapshot(tmpDir, narrowCfg);
360
+ assert.strictEqual(snap2.status, 'created');
361
+
362
+ const tree2Files = execFileSync('git', ['ls-tree', '--name-only', '-r', snap2.commitHash], {
363
+ cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8',
364
+ }).trim().split('\n');
365
+ assert.ok(!tree2Files.includes('docs/readme.md'), 'narrow protect must NOT include docs/readme.md');
366
+ assert.ok(tree2Files.includes('src/app.js'), 'narrow protect should still include src/app.js');
367
+ } finally {
368
+ cleanupDir(tmpDir);
369
+ }
370
+ });
371
+
372
+ test('createGitSnapshot with basename-only protect matches nested files', () => {
373
+ const tmpDir = createTempGitRepo();
374
+ try {
375
+ const { loadConfig } = require('../utils');
376
+ const cfg = { ...loadConfig(tmpDir).cfg, protect: ['app.js'] };
377
+ const result = createGitSnapshot(tmpDir, cfg);
378
+ assert.strictEqual(result.status, 'created');
379
+
380
+ const treeFiles = execFileSync('git', ['ls-tree', '--name-only', '-r', result.commitHash], {
381
+ cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8',
382
+ }).trim().split('\n');
383
+ assert.ok(treeFiles.includes('src/app.js'), 'basename "app.js" should match nested src/app.js');
384
+ assert.ok(!treeFiles.includes('hello.txt'), 'unprotected files should be excluded');
385
+ } finally {
386
+ cleanupDir(tmpDir);
387
+ }
388
+ });
389
+
390
+ test('createGitSnapshot with basename-only ignore excludes nested files', () => {
391
+ const tmpDir = createTempGitRepo();
392
+ try {
393
+ const { loadConfig } = require('../utils');
394
+ const cfg = { ...loadConfig(tmpDir).cfg, ignore: ['app.js'] };
395
+ const result = createGitSnapshot(tmpDir, cfg);
396
+ assert.strictEqual(result.status, 'created');
397
+
398
+ const treeFiles = execFileSync('git', ['ls-tree', '--name-only', '-r', result.commitHash], {
399
+ cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8',
400
+ }).trim().split('\n');
401
+ assert.ok(!treeFiles.includes('src/app.js'), 'basename ignore should exclude nested src/app.js');
402
+ assert.ok(treeFiles.includes('hello.txt'), 'non-ignored files should remain');
403
+ } finally {
404
+ cleanupDir(tmpDir);
405
+ }
406
+ });
407
+
408
+ test('createShadowCopy avoids collision within same second', () => {
409
+ const tmpDir = createTempDir();
410
+ try {
411
+ fs.writeFileSync(path.join(tmpDir, 'file.txt'), 'content');
412
+ const { loadConfig } = require('../utils');
413
+ const { cfg } = loadConfig(tmpDir);
414
+
415
+ const r1 = createShadowCopy(tmpDir, cfg);
416
+ assert.strictEqual(r1.status, 'created');
417
+
418
+ const r2 = createShadowCopy(tmpDir, cfg);
419
+ assert.strictEqual(r2.status, 'created');
420
+
421
+ assert.notStrictEqual(r1.timestamp, r2.timestamp, 'timestamps should differ');
422
+ assert.ok(fs.existsSync(r1.snapshotDir), 'first snapshot should still exist');
423
+ assert.ok(fs.existsSync(r2.snapshotDir), 'second snapshot should exist');
424
+ } finally {
425
+ cleanupDir(tmpDir);
426
+ }
427
+ });
428
+
429
+ test('createShadowCopy retries beyond single ms fallback', () => {
430
+ const tmpDir = createTempDir();
431
+ try {
432
+ fs.writeFileSync(path.join(tmpDir, 'file.txt'), 'content');
433
+ const { loadConfig } = require('../utils');
434
+ const { cfg } = loadConfig(tmpDir);
435
+
436
+ const r1 = createShadowCopy(tmpDir, cfg);
437
+ assert.strictEqual(r1.status, 'created');
438
+ const r2 = createShadowCopy(tmpDir, cfg);
439
+ assert.strictEqual(r2.status, 'created');
440
+ const r3 = createShadowCopy(tmpDir, cfg);
441
+ assert.strictEqual(r3.status, 'created');
442
+
443
+ const allTs = [r1.timestamp, r2.timestamp, r3.timestamp];
444
+ const unique = new Set(allTs);
445
+ assert.strictEqual(unique.size, 3, `all 3 timestamps must be unique, got: ${allTs.join(', ')}`);
446
+ } finally {
447
+ cleanupDir(tmpDir);
448
+ }
449
+ });
450
+
451
+ test('cleanShadowRetention respects count mode', () => {
452
+ const tmpDir = createTempDir();
453
+ const backupDir = path.join(tmpDir, '.cursor-guard-backup');
454
+ fs.mkdirSync(backupDir, { recursive: true });
455
+ try {
456
+ // Create 5 fake snapshot dirs
457
+ for (let i = 0; i < 5; i++) {
458
+ const name = `20260301_10000${i}`;
459
+ const dir = path.join(backupDir, name);
460
+ fs.mkdirSync(dir);
461
+ fs.writeFileSync(path.join(dir, 'f.txt'), 'x');
462
+ }
463
+ const cfg = {
464
+ retention: { mode: 'count', max_count: 2, days: 30, max_size_mb: 500 },
465
+ };
466
+ const result = cleanShadowRetention(backupDir, cfg);
467
+ assert.strictEqual(result.removed, 3);
468
+ assert.strictEqual(result.mode, 'count');
469
+ // Should have 2 dirs left
470
+ const remaining = fs.readdirSync(backupDir).filter(d => /^\d{8}_\d{6}$/.test(d));
471
+ assert.strictEqual(remaining.length, 2);
472
+ } finally {
473
+ cleanupDir(tmpDir);
474
+ }
475
+ });
476
+
477
+ // ── core/restore.js ─────────────────────────────────────────────
478
+
479
+ console.log('\ncore/restore:');
480
+
481
+ test('restoreFile restores from git source with pre-restore snapshot', () => {
482
+ const tmpDir = createTempGitRepo();
483
+ try {
484
+ const headHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
485
+
486
+ // Modify file and leave it uncommitted (realistic scenario: user has unsaved work)
487
+ fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'uncommitted changes');
488
+
489
+ const result = restoreFile(tmpDir, 'hello.txt', headHash, { preserveCurrent: true });
490
+ assert.strictEqual(result.status, 'restored');
491
+ assert.strictEqual(result.sourceType, 'git');
492
+ assert.ok(result.preRestoreRef, 'should have pre-restore ref when uncommitted changes exist');
493
+
494
+ const content = fs.readFileSync(path.join(tmpDir, 'hello.txt'), 'utf-8');
495
+ assert.strictEqual(content, 'hello world');
496
+ } finally {
497
+ cleanupDir(tmpDir);
498
+ }
499
+ });
500
+
501
+ test('restoreFile skips pre-restore when working tree is clean', () => {
502
+ const tmpDir = createTempGitRepo();
503
+ try {
504
+ const headHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
505
+
506
+ fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'modified');
507
+ execFileSync('git', ['add', '-A'], { cwd: tmpDir, stdio: 'pipe' });
508
+ execFileSync('git', ['commit', '-m', 'modify', '--no-verify'], { cwd: tmpDir, stdio: 'pipe' });
509
+
510
+ const result = restoreFile(tmpDir, 'hello.txt', headHash, { preserveCurrent: true });
511
+ assert.strictEqual(result.status, 'restored');
512
+ assert.strictEqual(result.sourceType, 'git');
513
+ assert.ok(!result.preRestoreRef, 'no pre-restore ref when tree is clean (HEAD is the restore point)');
514
+
515
+ const content = fs.readFileSync(path.join(tmpDir, 'hello.txt'), 'utf-8');
516
+ assert.strictEqual(content, 'hello world');
517
+ } finally {
518
+ cleanupDir(tmpDir);
519
+ }
520
+ });
521
+
522
+ test('restoreFile restores from shadow copy', () => {
523
+ const tmpDir = createTempDir();
524
+ try {
525
+ fs.writeFileSync(path.join(tmpDir, 'data.txt'), 'original');
526
+ const { loadConfig } = require('../utils');
527
+ const { cfg } = loadConfig(tmpDir);
528
+ const snap = createShadowCopy(tmpDir, cfg);
529
+
530
+ // Modify file
531
+ fs.writeFileSync(path.join(tmpDir, 'data.txt'), 'changed');
532
+
533
+ const result = restoreFile(tmpDir, 'data.txt', snap.timestamp, { preserveCurrent: false });
534
+ assert.strictEqual(result.status, 'restored');
535
+ assert.strictEqual(result.sourceType, 'shadow');
536
+
537
+ const content = fs.readFileSync(path.join(tmpDir, 'data.txt'), 'utf-8');
538
+ assert.strictEqual(content, 'original');
539
+ } finally {
540
+ cleanupDir(tmpDir);
541
+ }
542
+ });
543
+
544
+ test('restoreFile creates shadow pre-restore for non-git project', () => {
545
+ const tmpDir = createTempDir();
546
+ try {
547
+ fs.writeFileSync(path.join(tmpDir, 'data.txt'), 'original');
548
+ const { loadConfig } = require('../utils');
549
+ const { cfg } = loadConfig(tmpDir);
550
+ const snap = createShadowCopy(tmpDir, cfg);
551
+
552
+ fs.writeFileSync(path.join(tmpDir, 'data.txt'), 'modified-later');
553
+
554
+ const result = restoreFile(tmpDir, 'data.txt', snap.timestamp, { preserveCurrent: true });
555
+ assert.strictEqual(result.status, 'restored');
556
+ assert.ok(result.preRestoreShadow, 'should have preRestoreShadow for non-git project');
557
+ assert.ok(result.preRestoreShadow.startsWith('pre-restore-'), 'shadow dir should start with pre-restore-');
558
+
559
+ const preRestoreDir = path.join(tmpDir, '.cursor-guard-backup', result.preRestoreShadow);
560
+ assert.ok(fs.existsSync(path.join(preRestoreDir, 'data.txt')), 'pre-restore should contain the file');
561
+ const preserved = fs.readFileSync(path.join(preRestoreDir, 'data.txt'), 'utf-8');
562
+ assert.strictEqual(preserved, 'modified-later', 'pre-restore should preserve the current version');
563
+
564
+ const restored = fs.readFileSync(path.join(tmpDir, 'data.txt'), 'utf-8');
565
+ assert.strictEqual(restored, 'original', 'file should be restored to original');
566
+ } finally {
567
+ cleanupDir(tmpDir);
568
+ }
569
+ });
570
+
571
+ test('restoreFile returns error for invalid source', () => {
572
+ const tmpDir = createTempGitRepo();
573
+ try {
574
+ const result = restoreFile(tmpDir, 'hello.txt', 'nonexistent-ref-abc123', { preserveCurrent: false });
575
+ assert.strictEqual(result.status, 'error');
576
+ assert.ok(result.error);
577
+ } finally {
578
+ cleanupDir(tmpDir);
579
+ }
580
+ });
581
+
582
+ test('restoreFile rejects path-traversal shadow source', () => {
583
+ const tmpDir = createTempDir();
584
+ try {
585
+ fs.writeFileSync(path.join(tmpDir, 'data.txt'), 'original');
586
+ const { loadConfig } = require('../utils');
587
+ const { cfg } = loadConfig(tmpDir);
588
+ createShadowCopy(tmpDir, cfg);
589
+
590
+ const result = restoreFile(tmpDir, 'data.txt', '../../etc', { preserveCurrent: false });
591
+ assert.strictEqual(result.status, 'error', 'path-traversal source should be rejected');
592
+ } finally {
593
+ cleanupDir(tmpDir);
594
+ }
595
+ });
596
+
597
+ test('validateShadowSource accepts valid timestamps and rejects traversals', () => {
598
+ assert.strictEqual(validateShadowSource('20260321_143205').valid, true);
599
+ assert.strictEqual(validateShadowSource('20260321_143205_042').valid, true);
600
+ assert.strictEqual(validateShadowSource('pre-restore-20260321_143205').valid, true);
601
+ assert.strictEqual(validateShadowSource('pre-restore-20260321_143205_999').valid, true);
602
+ assert.strictEqual(validateShadowSource('../../etc').valid, false);
603
+ assert.strictEqual(validateShadowSource('..\\..\\Windows').valid, false);
604
+ assert.strictEqual(validateShadowSource('some-arbitrary-name').valid, false);
605
+ });
606
+
607
+ test('restoreFile respects pre_restore_backup=never from config', () => {
608
+ const tmpDir = createTempGitRepo();
609
+ try {
610
+ fs.writeFileSync(path.join(tmpDir, '.cursor-guard.json'),
611
+ JSON.stringify({ pre_restore_backup: 'never', backup_strategy: 'git' }));
612
+ const headHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
613
+
614
+ fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'uncommitted changes');
615
+
616
+ const result = restoreFile(tmpDir, 'hello.txt', headHash);
617
+ assert.strictEqual(result.status, 'restored');
618
+ assert.ok(!result.preRestoreRef, 'should NOT create pre-restore when config says never');
619
+ } finally {
620
+ cleanupDir(tmpDir);
621
+ }
622
+ });
623
+
624
+ test('createPreRestoreSnapshot creates ref under refs/guard/pre-restore/', () => {
625
+ const tmpDir = createTempGitRepo();
626
+ try {
627
+ // Make a change so snapshot is not skipped
628
+ fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'changed');
629
+ const result = createPreRestoreSnapshot(tmpDir);
630
+ assert.strictEqual(result.status, 'created');
631
+ assert.ok(result.ref.startsWith('refs/guard/pre-restore/'));
632
+ assert.ok(result.shortHash);
633
+ } finally {
634
+ cleanupDir(tmpDir);
635
+ }
636
+ });
637
+
638
+ test('createPreRestoreSnapshot avoids same-ms ref collision', () => {
639
+ const tmpDir = createTempGitRepo();
640
+ try {
641
+ fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'v1');
642
+ const r1 = createPreRestoreSnapshot(tmpDir);
643
+ assert.strictEqual(r1.status, 'created');
644
+
645
+ fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'v2');
646
+ const r2 = createPreRestoreSnapshot(tmpDir);
647
+ assert.strictEqual(r2.status, 'created');
648
+
649
+ fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'v3');
650
+ const r3 = createPreRestoreSnapshot(tmpDir);
651
+ assert.strictEqual(r3.status, 'created');
652
+
653
+ const refs = [r1.ref, r2.ref, r3.ref];
654
+ const unique = new Set(refs);
655
+ assert.strictEqual(unique.size, 3, `all 3 pre-restore refs must be unique, got: ${refs.join(', ')}`);
656
+ } finally {
657
+ cleanupDir(tmpDir);
658
+ }
659
+ });
660
+
661
+ test('createPreRestoreSnapshot excludes secrets from snapshot', () => {
662
+ const tmpDir = createTempGitRepo();
663
+ try {
664
+ fs.writeFileSync(path.join(tmpDir, '.env'), 'SECRET_KEY=abc123');
665
+ fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'changed');
666
+
667
+ const result = createPreRestoreSnapshot(tmpDir);
668
+ assert.strictEqual(result.status, 'created');
669
+
670
+ const filesInSnapshot = execFileSync('git', ['ls-tree', '--name-only', '-r', result.ref], {
671
+ cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8',
672
+ }).trim().split('\n');
673
+ assert.ok(!filesInSnapshot.includes('.env'), '.env should be excluded from pre-restore snapshot');
674
+ assert.ok(filesInSnapshot.includes('hello.txt'), 'non-secret files should be included');
675
+ } finally {
676
+ cleanupDir(tmpDir);
677
+ }
678
+ });
679
+
680
+ test('createPreRestoreSnapshot skips when no changes', () => {
681
+ const tmpDir = createTempGitRepo();
682
+ try {
683
+ const result = createPreRestoreSnapshot(tmpDir);
684
+ assert.strictEqual(result.status, 'skipped');
685
+ } finally {
686
+ cleanupDir(tmpDir);
687
+ }
688
+ });
689
+
690
+ test('previewProjectRestore returns file list', () => {
691
+ const tmpDir = createTempGitRepo();
692
+ try {
693
+ const headHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
694
+
695
+ fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'changed');
696
+ fs.writeFileSync(path.join(tmpDir, 'new.txt'), 'new file');
697
+ execFileSync('git', ['add', '-A'], { cwd: tmpDir, stdio: 'pipe' });
698
+ execFileSync('git', ['commit', '-m', 'changes', '--no-verify'], { cwd: tmpDir, stdio: 'pipe' });
699
+
700
+ const result = previewProjectRestore(tmpDir, headHash);
701
+ assert.strictEqual(result.status, 'ok');
702
+ assert.ok(Array.isArray(result.files));
703
+ assert.ok(result.totalChanged > 0);
704
+ const filePaths = result.files.map(f => f.path);
705
+ assert.ok(filePaths.includes('hello.txt'));
706
+ } finally {
707
+ cleanupDir(tmpDir);
708
+ }
709
+ });
710
+
711
+ test('previewProjectRestore handles rename entries', () => {
712
+ const tmpDir = createTempGitRepo();
713
+ try {
714
+ const headHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
715
+
716
+ execFileSync('git', ['mv', 'hello.txt', 'greeting.txt'], { cwd: tmpDir, stdio: 'pipe' });
717
+ execFileSync('git', ['add', '-A'], { cwd: tmpDir, stdio: 'pipe' });
718
+ execFileSync('git', ['commit', '-m', 'rename', '--no-verify'], { cwd: tmpDir, stdio: 'pipe' });
719
+
720
+ const result = previewProjectRestore(tmpDir, headHash);
721
+ assert.strictEqual(result.status, 'ok');
722
+
723
+ const renamed = result.files.filter(f => f.change === 'renamed');
724
+ if (renamed.length > 0) {
725
+ assert.ok(renamed[0].path, 'renamed entry should have path');
726
+ assert.ok(renamed[0].oldPath, 'renamed entry should have oldPath');
727
+ assert.ok(!renamed[0].path.includes('\t'), 'path must not contain raw tab');
728
+ } else {
729
+ const added = result.files.filter(f => f.change === 'added');
730
+ const deleted = result.files.filter(f => f.change === 'deleted');
731
+ assert.ok(added.length > 0 || deleted.length > 0, 'should show changes even if rename detection is off');
732
+ }
733
+ } finally {
734
+ cleanupDir(tmpDir);
735
+ }
736
+ });
737
+
738
+ // ── core/restore (executeProjectRestore) ─────────────────────────
739
+
740
+ console.log('\ncore/restore (executeProjectRestore):');
741
+
742
+ test('executeProjectRestore restores all changed files with uncommitted changes', () => {
743
+ const tmpDir = createTempGitRepo();
744
+ try {
745
+ const headHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
746
+
747
+ fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'changed-v2');
748
+ fs.writeFileSync(path.join(tmpDir, 'extra.txt'), 'extra');
749
+ execFileSync('git', ['add', '-A'], { cwd: tmpDir, stdio: 'pipe' });
750
+ execFileSync('git', ['commit', '-m', 'v2 changes', '--no-verify'], { cwd: tmpDir, stdio: 'pipe' });
751
+
752
+ // Add uncommitted change so pre-restore snapshot is created
753
+ fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'uncommitted');
754
+
755
+ const result = executeProjectRestore(tmpDir, headHash, { preserveCurrent: true });
756
+ assert.strictEqual(result.status, 'restored');
757
+ assert.ok(result.filesRestored > 0, 'should restore at least 1 file');
758
+ assert.ok(result.preRestoreRef, 'should have pre-restore ref');
759
+
760
+ const restored = fs.readFileSync(path.join(tmpDir, 'hello.txt'), 'utf-8');
761
+ assert.strictEqual(restored, 'hello world');
762
+ } finally {
763
+ cleanupDir(tmpDir);
764
+ }
765
+ });
766
+
767
+ test('executeProjectRestore restores with clean tree (pre-restore skipped)', () => {
768
+ const tmpDir = createTempGitRepo();
769
+ try {
770
+ const headHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
771
+
772
+ fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'changed-v2');
773
+ execFileSync('git', ['add', '-A'], { cwd: tmpDir, stdio: 'pipe' });
774
+ execFileSync('git', ['commit', '-m', 'v2 changes', '--no-verify'], { cwd: tmpDir, stdio: 'pipe' });
775
+
776
+ const result = executeProjectRestore(tmpDir, headHash, { preserveCurrent: true });
777
+ assert.strictEqual(result.status, 'restored');
778
+ assert.ok(result.filesRestored > 0, 'should restore at least 1 file');
779
+ // pre-restore skipped because working tree is clean — HEAD itself is the restore point
780
+ } finally {
781
+ cleanupDir(tmpDir);
782
+ }
783
+ });
784
+
785
+ test('executeProjectRestore detects dirty working tree against HEAD', () => {
786
+ const tmpDir = createTempGitRepo();
787
+ try {
788
+ const headHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
789
+ fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'dirty content');
790
+
791
+ const preview = previewProjectRestore(tmpDir, headHash);
792
+ assert.ok(preview.totalChanged > 0, 'preview should detect dirty file vs HEAD');
793
+
794
+ const result = executeProjectRestore(tmpDir, headHash, { preserveCurrent: false });
795
+ assert.strictEqual(result.status, 'restored');
796
+ assert.ok(result.filesRestored > 0, 'should restore dirty files');
797
+ assert.strictEqual(fs.readFileSync(path.join(tmpDir, 'hello.txt'), 'utf-8'), 'hello world');
798
+ } finally {
799
+ cleanupDir(tmpDir);
800
+ }
801
+ });
802
+
803
+ test('executeProjectRestore returns 0 files when already at target', () => {
804
+ const tmpDir = createTempGitRepo();
805
+ try {
806
+ const headHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
807
+ const result = executeProjectRestore(tmpDir, headHash, { preserveCurrent: false });
808
+ assert.strictEqual(result.status, 'restored');
809
+ assert.strictEqual(result.filesRestored, 0);
810
+ } finally {
811
+ cleanupDir(tmpDir);
812
+ }
813
+ });
814
+
815
+ test('executeProjectRestore errors on invalid source', () => {
816
+ const tmpDir = createTempGitRepo();
817
+ try {
818
+ const result = executeProjectRestore(tmpDir, 'nonexistent-ref');
819
+ assert.strictEqual(result.status, 'error');
820
+ } finally {
821
+ cleanupDir(tmpDir);
822
+ }
823
+ });
824
+
825
+ test('previewProjectRestore includes untracked files', () => {
826
+ const tmpDir = createTempGitRepo();
827
+ try {
828
+ const headHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
829
+ fs.writeFileSync(path.join(tmpDir, 'untracked.txt'), 'not added to git');
830
+
831
+ const result = previewProjectRestore(tmpDir, headHash);
832
+ assert.strictEqual(result.status, 'ok');
833
+ const untracked = result.files.filter(f => f.change === 'untracked');
834
+ assert.ok(untracked.length >= 1, 'should list untracked files');
835
+ assert.ok(untracked.some(f => f.path === 'untracked.txt'), 'should include untracked.txt');
836
+ } finally {
837
+ cleanupDir(tmpDir);
838
+ }
839
+ });
840
+
841
+ test('executeProjectRestore cleans untracked files by default', () => {
842
+ const tmpDir = createTempGitRepo();
843
+ try {
844
+ const headHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
845
+ fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'changed');
846
+ execFileSync('git', ['add', '-A'], { cwd: tmpDir, stdio: 'pipe' });
847
+ execFileSync('git', ['commit', '-m', 'change', '--no-verify'], { cwd: tmpDir, stdio: 'pipe' });
848
+
849
+ fs.writeFileSync(path.join(tmpDir, 'leftover.txt'), 'untracked leftover');
850
+
851
+ const result = executeProjectRestore(tmpDir, headHash, { preserveCurrent: false });
852
+ assert.strictEqual(result.status, 'restored');
853
+ assert.ok(result.untrackedCleaned >= 1, 'should clean untracked files');
854
+ assert.ok(!fs.existsSync(path.join(tmpDir, 'leftover.txt')), 'untracked file should be removed');
855
+ } finally {
856
+ cleanupDir(tmpDir);
857
+ }
858
+ });
859
+
860
+ test('executeProjectRestore filesRestored excludes untracked cleanup count', () => {
861
+ const tmpDir = createTempGitRepo();
862
+ try {
863
+ const headHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
864
+ fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'changed');
865
+ execFileSync('git', ['add', '-A'], { cwd: tmpDir, stdio: 'pipe' });
866
+ execFileSync('git', ['commit', '-m', 'change', '--no-verify'], { cwd: tmpDir, stdio: 'pipe' });
867
+
868
+ fs.writeFileSync(path.join(tmpDir, 'untracked1.txt'), 'u1');
869
+ fs.writeFileSync(path.join(tmpDir, 'untracked2.txt'), 'u2');
870
+
871
+ const result = executeProjectRestore(tmpDir, headHash, { preserveCurrent: false });
872
+ assert.strictEqual(result.status, 'restored');
873
+ assert.ok(result.untrackedCleaned >= 2, 'should clean untracked files');
874
+ assert.ok(result.filesRestored > 0, 'should have restored tracked files');
875
+ assert.ok(result.filesRestored <= result.files.filter(f => f.change !== 'untracked').length,
876
+ 'filesRestored should not include untracked count');
877
+ } finally {
878
+ cleanupDir(tmpDir);
879
+ }
880
+ });
881
+
882
+ test('executeProjectRestore respects cleanUntracked=false', () => {
883
+ const tmpDir = createTempGitRepo();
884
+ try {
885
+ const headHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
886
+ fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'changed');
887
+ execFileSync('git', ['add', '-A'], { cwd: tmpDir, stdio: 'pipe' });
888
+ execFileSync('git', ['commit', '-m', 'change', '--no-verify'], { cwd: tmpDir, stdio: 'pipe' });
889
+
890
+ fs.writeFileSync(path.join(tmpDir, 'keep-me.txt'), 'should stay');
891
+
892
+ const result = executeProjectRestore(tmpDir, headHash, { preserveCurrent: false, cleanUntracked: false });
893
+ assert.strictEqual(result.status, 'restored');
894
+ assert.strictEqual(result.untrackedCleaned, 0, 'should not clean when disabled');
895
+ assert.ok(fs.existsSync(path.join(tmpDir, 'keep-me.txt')), 'untracked file should remain');
896
+ } finally {
897
+ cleanupDir(tmpDir);
898
+ }
899
+ });
900
+
901
+ // ── core/doctor-fix ─────────────────────────────────────────────
902
+
903
+ console.log('\ncore/doctor-fix:');
904
+
905
+ test('runFixes dry-run reports actions without modifying', () => {
906
+ const tmpDir = createTempDir();
907
+ try {
908
+ const result = runFixes(tmpDir, { dryRun: true });
909
+ assert.ok(Array.isArray(result.actions));
910
+ assert.strictEqual(result.totalFixed, 0, 'dry-run should not fix anything');
911
+ const configAction = result.actions.find(a => a.name === 'Create config');
912
+ assert.ok(configAction, 'should report config action');
913
+ assert.strictEqual(configAction.status, 'skipped');
914
+ } finally {
915
+ cleanupDir(tmpDir);
916
+ }
917
+ });
918
+
919
+ test('runFixes creates config and inits git on empty dir', () => {
920
+ const tmpDir = createTempDir();
921
+ try {
922
+ const result = runFixes(tmpDir, { dryRun: false });
923
+ assert.ok(result.totalFixed > 0, 'should fix at least 1 issue');
924
+
925
+ const configExists = fs.existsSync(path.join(tmpDir, '.cursor-guard.json'));
926
+ assert.ok(configExists, 'should create .cursor-guard.json');
927
+
928
+ const initAction = result.actions.find(a => a.name === 'Init Git repo');
929
+ assert.ok(initAction, 'should have init git action');
930
+ assert.strictEqual(initAction.status, 'fixed');
931
+ } finally {
932
+ cleanupDir(tmpDir);
933
+ }
934
+ });
935
+
936
+ test('runFixes init git excludes secrets via .gitignore', () => {
937
+ const tmpDir = createTempDir();
938
+ try {
939
+ fs.writeFileSync(path.join(tmpDir, 'app.js'), 'console.log("ok")');
940
+ fs.writeFileSync(path.join(tmpDir, '.env'), 'SECRET=value');
941
+ fs.writeFileSync(path.join(tmpDir, 'credentials.json'), '{}');
942
+
943
+ const result = runFixes(tmpDir, { dryRun: false });
944
+ const initAction = result.actions.find(a => a.name === 'Init Git repo');
945
+ assert.strictEqual(initAction.status, 'fixed');
946
+
947
+ const tracked = execFileSync('git', ['ls-files'], {
948
+ cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8',
949
+ }).trim().split('\n');
950
+ assert.ok(!tracked.includes('.env'), '.env should not be tracked');
951
+ assert.ok(!tracked.includes('credentials.json'), 'credentials.json should not be tracked');
952
+ assert.ok(tracked.includes('app.js'), 'normal files should be tracked');
953
+
954
+ const gitignore = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8');
955
+ assert.ok(gitignore.includes('.env'), '.gitignore should contain .env pattern');
956
+ assert.ok(gitignore.includes('.cursor-guard-backup/'), '.gitignore should contain backup dir');
957
+ } finally {
958
+ cleanupDir(tmpDir);
959
+ }
960
+ });
961
+
962
+ test('runFixes init git excludes secrets even with pre-existing .gitignore', () => {
963
+ const tmpDir = createTempDir();
964
+ try {
965
+ fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'dist/\n');
966
+ fs.writeFileSync(path.join(tmpDir, 'app.js'), 'console.log("ok")');
967
+ fs.writeFileSync(path.join(tmpDir, '.env'), 'SECRET=leak');
968
+
969
+ const result = runFixes(tmpDir, { dryRun: false });
970
+ const initAction = result.actions.find(a => a.name === 'Init Git repo');
971
+ assert.strictEqual(initAction.status, 'fixed');
972
+
973
+ const tracked = execFileSync('git', ['ls-files'], {
974
+ cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8',
975
+ }).trim().split('\n');
976
+ assert.ok(!tracked.includes('.env'), '.env should not be tracked even when .gitignore pre-exists');
977
+ assert.ok(tracked.includes('app.js'), 'normal files should be tracked');
978
+
979
+ const gitignore = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8');
980
+ assert.ok(gitignore.includes('dist/'), 'original entries should be preserved');
981
+ assert.ok(gitignore.includes('.env'), 'secrets should be appended');
982
+ } finally {
983
+ cleanupDir(tmpDir);
984
+ }
985
+ });
986
+
987
+ test('runFixes is idempotent on already-configured repo', () => {
988
+ const tmpDir = createTempGitRepo();
989
+ try {
990
+ fs.writeFileSync(path.join(tmpDir, '.cursor-guard.json'), '{"backup_strategy":"git"}');
991
+ fs.writeFileSync(path.join(tmpDir, '.gitignore'), '.cursor-guard-backup/\n');
992
+
993
+ const result = runFixes(tmpDir, { dryRun: false });
994
+ const fixed = result.actions.filter(a => a.status === 'fixed');
995
+ assert.strictEqual(fixed.length, 0, `should fix nothing, but fixed: ${JSON.stringify(fixed)}`);
996
+ } finally {
997
+ cleanupDir(tmpDir);
998
+ }
999
+ });
1000
+
1001
+ test('runFixes adds gitignore entry when missing', () => {
1002
+ const tmpDir = createTempGitRepo();
1003
+ try {
1004
+ fs.writeFileSync(path.join(tmpDir, '.cursor-guard.json'), '{"backup_strategy":"git"}');
1005
+
1006
+ const result = runFixes(tmpDir, { dryRun: false });
1007
+ const gitignoreAction = result.actions.find(a => a.name === 'Gitignore backup dir');
1008
+ assert.ok(gitignoreAction);
1009
+ assert.strictEqual(gitignoreAction.status, 'fixed');
1010
+
1011
+ const gitignore = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8');
1012
+ assert.ok(gitignore.includes('.cursor-guard-backup/'));
1013
+ } finally {
1014
+ cleanupDir(tmpDir);
1015
+ }
1016
+ });
1017
+
1018
+ test('runFixes removes stale lock file', () => {
1019
+ const tmpDir = createTempGitRepo();
1020
+ try {
1021
+ const gDir = execFileSync('git', ['rev-parse', '--git-dir'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
1022
+ const lockPath = path.join(tmpDir, gDir, 'cursor-guard.lock');
1023
+ fs.writeFileSync(lockPath, 'pid: 99999999');
1024
+ fs.writeFileSync(path.join(tmpDir, '.cursor-guard.json'), '{"backup_strategy":"git"}');
1025
+ fs.writeFileSync(path.join(tmpDir, '.gitignore'), '.cursor-guard-backup/\n');
1026
+
1027
+ const result = runFixes(tmpDir, { dryRun: false });
1028
+ const lockAction = result.actions.find(a => a.name === 'Remove stale lock');
1029
+ assert.ok(lockAction, 'should have lock removal action');
1030
+ assert.strictEqual(lockAction.status, 'fixed');
1031
+ assert.ok(!fs.existsSync(lockPath), 'lock file should be removed');
1032
+ } finally {
1033
+ cleanupDir(tmpDir);
1034
+ }
1035
+ });
1036
+
1037
+ // ── core/status ─────────────────────────────────────────────────
1038
+
1039
+ console.log('\ncore/status:');
1040
+
1041
+ test('getBackupStatus returns structured result for git repo', () => {
1042
+ const tmpDir = createTempGitRepo();
1043
+ try {
1044
+ const result = getBackupStatus(tmpDir);
1045
+ assert.ok(typeof result.watcher === 'object', 'should have watcher');
1046
+ assert.strictEqual(result.watcher.running, false);
1047
+ assert.ok(typeof result.config === 'object', 'should have config');
1048
+ assert.strictEqual(result.config.strategy, 'git');
1049
+ assert.ok(typeof result.lastBackup === 'object', 'should have lastBackup');
1050
+ assert.ok(typeof result.refs === 'object', 'should have refs');
1051
+ assert.ok(typeof result.disk === 'object', 'should have disk');
1052
+ assert.ok(result.disk.freeGB === null || typeof result.disk.freeGB === 'number');
1053
+ } finally {
1054
+ cleanupDir(tmpDir);
1055
+ }
1056
+ });
1057
+
1058
+ test('getBackupStatus detects running watcher via lock file', () => {
1059
+ const tmpDir = createTempGitRepo();
1060
+ try {
1061
+ const gDir = execFileSync('git', ['rev-parse', '--git-dir'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
1062
+ const lockPath = path.join(tmpDir, gDir, 'cursor-guard.lock');
1063
+ // Use current PID to simulate running process
1064
+ fs.writeFileSync(lockPath, `pid=${process.pid}\nstarted=2026-03-21T12:00:00Z`);
1065
+
1066
+ const result = getBackupStatus(tmpDir);
1067
+ assert.strictEqual(result.watcher.running, true);
1068
+ assert.strictEqual(result.watcher.pid, process.pid);
1069
+ assert.strictEqual(result.watcher.startedAt, '2026-03-21T12:00:00Z');
1070
+ } finally {
1071
+ cleanupDir(tmpDir);
1072
+ }
1073
+ });
1074
+
1075
+ test('getBackupStatus detects stale lock file', () => {
1076
+ const tmpDir = createTempGitRepo();
1077
+ try {
1078
+ const gDir = execFileSync('git', ['rev-parse', '--git-dir'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
1079
+ const lockPath = path.join(tmpDir, gDir, 'cursor-guard.lock');
1080
+ fs.writeFileSync(lockPath, 'pid=99999999\nstarted=2026-03-21T12:00:00Z');
1081
+
1082
+ const result = getBackupStatus(tmpDir);
1083
+ assert.strictEqual(result.watcher.running, false);
1084
+ assert.strictEqual(result.watcher.stale, true);
1085
+ } finally {
1086
+ cleanupDir(tmpDir);
1087
+ }
1088
+ });
1089
+
1090
+ test('getBackupStatus finds last git backup after snapshot', () => {
1091
+ const tmpDir = createTempGitRepo();
1092
+ try {
1093
+ const cfg = { protect: [], ignore: [], secrets_patterns: [], backup_strategy: 'git' };
1094
+ createGitSnapshot(tmpDir, cfg, { branchRef: 'refs/guard/auto-backup' });
1095
+
1096
+ const result = getBackupStatus(tmpDir);
1097
+ assert.ok(result.lastBackup.git, 'should have git lastBackup');
1098
+ assert.ok(result.lastBackup.git.shortHash, 'should have shortHash');
1099
+ assert.ok(result.refs.autoBackup, 'should have autoBackup ref info');
1100
+ assert.ok(result.refs.autoBackup.commitCount > 0);
1101
+ } finally {
1102
+ cleanupDir(tmpDir);
1103
+ }
1104
+ });
1105
+
1106
+ test('getBackupStatus finds last shadow backup', () => {
1107
+ const tmpDir = createTempGitRepo();
1108
+ try {
1109
+ const cfg = { protect: [], ignore: [], secrets_patterns: [], backup_strategy: 'shadow' };
1110
+ createShadowCopy(tmpDir, cfg);
1111
+
1112
+ const result = getBackupStatus(tmpDir);
1113
+ assert.ok(result.lastBackup.shadow, 'should have shadow lastBackup');
1114
+ assert.ok(result.lastBackup.shadow.timestamp, 'should have timestamp');
1115
+ assert.ok(result.lastBackup.shadow.fileCount > 0, 'should have files');
1116
+ } finally {
1117
+ cleanupDir(tmpDir);
1118
+ }
1119
+ });
1120
+
1121
+ test('getBackupStatus works for non-git directory', () => {
1122
+ const tmpDir = createTempDir();
1123
+ try {
1124
+ const result = getBackupStatus(tmpDir);
1125
+ assert.strictEqual(result.watcher.running, false);
1126
+ assert.ok(typeof result.config === 'object');
1127
+ assert.ok(typeof result.refs === 'object');
1128
+ assert.strictEqual(result.refs.preRestoreCount, 0);
1129
+ } finally {
1130
+ cleanupDir(tmpDir);
1131
+ }
1132
+ });
1133
+
1134
+ // ── core/anomaly.js ─────────────────────────────────────────────
1135
+
1136
+ console.log('\ncore/anomaly:');
1137
+
1138
+ test('createChangeTracker returns tracker with config', () => {
1139
+ const cfg = {
1140
+ proactive_alert: true,
1141
+ alert_thresholds: { files_per_window: 20, window_seconds: 10, cooldown_seconds: 60 },
1142
+ };
1143
+ const tracker = createChangeTracker(cfg);
1144
+ assert.ok(Array.isArray(tracker.events));
1145
+ assert.ok(Array.isArray(tracker.alerts));
1146
+ assert.strictEqual(tracker.config.enabled, true);
1147
+ assert.strictEqual(tracker.config.filesPerWindow, 20);
1148
+ assert.strictEqual(tracker.config.windowSeconds, 10);
1149
+ });
1150
+
1151
+ test('createChangeTracker respects proactive_alert=false', () => {
1152
+ const cfg = {
1153
+ proactive_alert: false,
1154
+ alert_thresholds: { files_per_window: 20, window_seconds: 10, cooldown_seconds: 60 },
1155
+ };
1156
+ const tracker = createChangeTracker(cfg);
1157
+ assert.strictEqual(tracker.config.enabled, false);
1158
+ });
1159
+
1160
+ test('recordChange adds events to tracker', () => {
1161
+ const cfg = {
1162
+ proactive_alert: true,
1163
+ alert_thresholds: { files_per_window: 20, window_seconds: 10, cooldown_seconds: 60 },
1164
+ };
1165
+ const tracker = createChangeTracker(cfg);
1166
+ recordChange(tracker, 5, ['a.js', 'b.js']);
1167
+ assert.strictEqual(tracker.events.length, 1);
1168
+ assert.strictEqual(tracker.events[0].fileCount, 5);
1169
+ recordChange(tracker, 3);
1170
+ assert.strictEqual(tracker.events.length, 2);
1171
+ });
1172
+
1173
+ test('recordChange skips when disabled', () => {
1174
+ const cfg = {
1175
+ proactive_alert: false,
1176
+ alert_thresholds: { files_per_window: 20, window_seconds: 10, cooldown_seconds: 60 },
1177
+ };
1178
+ const tracker = createChangeTracker(cfg);
1179
+ recordChange(tracker, 5);
1180
+ assert.strictEqual(tracker.events.length, 0);
1181
+ });
1182
+
1183
+ test('checkAnomaly detects high velocity', () => {
1184
+ const cfg = {
1185
+ proactive_alert: true,
1186
+ alert_thresholds: { files_per_window: 5, window_seconds: 60, cooldown_seconds: 1 },
1187
+ };
1188
+ const tracker = createChangeTracker(cfg);
1189
+ recordChange(tracker, 3);
1190
+ recordChange(tracker, 3);
1191
+ const result = checkAnomaly(tracker);
1192
+ assert.strictEqual(result.anomaly, true);
1193
+ assert.ok(result.alert);
1194
+ assert.strictEqual(result.alert.type, 'high_change_velocity');
1195
+ assert.strictEqual(result.alert.fileCount, 6);
1196
+ });
1197
+
1198
+ test('checkAnomaly returns false when below threshold', () => {
1199
+ const cfg = {
1200
+ proactive_alert: true,
1201
+ alert_thresholds: { files_per_window: 20, window_seconds: 60, cooldown_seconds: 60 },
1202
+ };
1203
+ const tracker = createChangeTracker(cfg);
1204
+ recordChange(tracker, 3);
1205
+ const result = checkAnomaly(tracker);
1206
+ assert.strictEqual(result.anomaly, false);
1207
+ });
1208
+
1209
+ test('checkAnomaly suppresses during cooldown', () => {
1210
+ const cfg = {
1211
+ proactive_alert: true,
1212
+ alert_thresholds: { files_per_window: 5, window_seconds: 60, cooldown_seconds: 600 },
1213
+ };
1214
+ const tracker = createChangeTracker(cfg);
1215
+ recordChange(tracker, 10);
1216
+ const first = checkAnomaly(tracker);
1217
+ assert.strictEqual(first.anomaly, true);
1218
+ assert.ok(!first.suppressed);
1219
+ recordChange(tracker, 10);
1220
+ const second = checkAnomaly(tracker);
1221
+ assert.strictEqual(second.anomaly, true);
1222
+ assert.strictEqual(second.suppressed, true);
1223
+ });
1224
+
1225
+ test('getAlertStatus returns summary', () => {
1226
+ const cfg = {
1227
+ proactive_alert: true,
1228
+ alert_thresholds: { files_per_window: 5, window_seconds: 60, cooldown_seconds: 1 },
1229
+ };
1230
+ const tracker = createChangeTracker(cfg);
1231
+ recordChange(tracker, 10);
1232
+ checkAnomaly(tracker);
1233
+ const status = getAlertStatus(tracker);
1234
+ assert.strictEqual(status.enabled, true);
1235
+ assert.strictEqual(status.hasActiveAlert, true);
1236
+ assert.ok(status.latestAlert);
1237
+ assert.strictEqual(status.alertCount, 1);
1238
+ });
1239
+
1240
+ test('getAlertStatus with disabled tracker', () => {
1241
+ const cfg = {
1242
+ proactive_alert: false,
1243
+ alert_thresholds: { files_per_window: 5, window_seconds: 60, cooldown_seconds: 60 },
1244
+ };
1245
+ const tracker = createChangeTracker(cfg);
1246
+ const status = getAlertStatus(tracker);
1247
+ assert.strictEqual(status.enabled, false);
1248
+ assert.strictEqual(status.hasActiveAlert, false);
1249
+ });
1250
+
1251
+ test('saveAlert and loadActiveAlert round-trip', () => {
1252
+ const tmpDir = createTempGitRepo();
1253
+ try {
1254
+ const alert = {
1255
+ type: 'high_change_velocity',
1256
+ timestamp: new Date().toISOString(),
1257
+ fileCount: 25,
1258
+ windowSeconds: 10,
1259
+ expiresAt: new Date(Date.now() + 300000).toISOString(),
1260
+ };
1261
+ saveAlert(tmpDir, alert);
1262
+ const loaded = loadActiveAlert(tmpDir);
1263
+ assert.ok(loaded);
1264
+ assert.strictEqual(loaded.type, 'high_change_velocity');
1265
+ assert.strictEqual(loaded.fileCount, 25);
1266
+ } finally {
1267
+ cleanupDir(tmpDir);
1268
+ }
1269
+ });
1270
+
1271
+ test('loadActiveAlert returns null for expired alert without deleting file', () => {
1272
+ const tmpDir = createTempGitRepo();
1273
+ try {
1274
+ const alert = {
1275
+ type: 'high_change_velocity',
1276
+ timestamp: new Date(Date.now() - 600000).toISOString(),
1277
+ expiresAt: new Date(Date.now() - 1000).toISOString(),
1278
+ };
1279
+ saveAlert(tmpDir, alert);
1280
+ const loaded = loadActiveAlert(tmpDir);
1281
+ assert.strictEqual(loaded, null);
1282
+ assert.ok(fs.existsSync(alertFilePath(tmpDir)), 'expired file should still exist (load is read-only)');
1283
+ } finally {
1284
+ cleanupDir(tmpDir);
1285
+ }
1286
+ });
1287
+
1288
+ test('clearAlert removes alert file', () => {
1289
+ const tmpDir = createTempGitRepo();
1290
+ try {
1291
+ const alert = {
1292
+ type: 'test',
1293
+ expiresAt: new Date(Date.now() + 300000).toISOString(),
1294
+ };
1295
+ saveAlert(tmpDir, alert);
1296
+ assert.ok(loadActiveAlert(tmpDir));
1297
+ clearAlert(tmpDir);
1298
+ assert.strictEqual(loadActiveAlert(tmpDir), null);
1299
+ } finally {
1300
+ cleanupDir(tmpDir);
1301
+ }
1302
+ });
1303
+
1304
+ test('clearExpiredAlert removes expired file but leaves active ones', () => {
1305
+ const tmpDir = createTempGitRepo();
1306
+ try {
1307
+ const expired = {
1308
+ type: 'high_change_velocity',
1309
+ expiresAt: new Date(Date.now() - 1000).toISOString(),
1310
+ };
1311
+ saveAlert(tmpDir, expired);
1312
+ assert.ok(fs.existsSync(alertFilePath(tmpDir)));
1313
+ const removed = clearExpiredAlert(tmpDir);
1314
+ assert.strictEqual(removed, true);
1315
+ assert.ok(!fs.existsSync(alertFilePath(tmpDir)));
1316
+
1317
+ const active = {
1318
+ type: 'high_change_velocity',
1319
+ expiresAt: new Date(Date.now() + 300000).toISOString(),
1320
+ };
1321
+ saveAlert(tmpDir, active);
1322
+ const removedActive = clearExpiredAlert(tmpDir);
1323
+ assert.strictEqual(removedActive, false);
1324
+ assert.ok(fs.existsSync(alertFilePath(tmpDir)));
1325
+ } finally {
1326
+ cleanupDir(tmpDir);
1327
+ }
1328
+ });
1329
+
1330
+ // ── core/dashboard.js ───────────────────────────────────────────
1331
+
1332
+ console.log('\ncore/dashboard:');
1333
+
1334
+ test('formatBytes formats correctly', () => {
1335
+ assert.strictEqual(formatBytes(500), '500B');
1336
+ assert.strictEqual(formatBytes(1536), '1.5KB');
1337
+ assert.strictEqual(formatBytes(1048576), '1.0MB');
1338
+ assert.strictEqual(formatBytes(1073741824), '1.0GB');
1339
+ });
1340
+
1341
+ test('relativeTime returns human-readable time', () => {
1342
+ const now = new Date().toISOString();
1343
+ const result = relativeTime(now);
1344
+ assert.ok(result.endsWith('s ago') || result === 'just now');
1345
+ assert.strictEqual(relativeTime(null), null);
1346
+ });
1347
+
1348
+ test('getDashboard returns structured result for git repo', () => {
1349
+ const tmpDir = createTempGitRepo();
1350
+ try {
1351
+ const cfg = { protect: [], ignore: [], secrets_patterns: [], backup_strategy: 'git' };
1352
+ createGitSnapshot(tmpDir, cfg, { branchRef: 'refs/guard/auto-backup' });
1353
+
1354
+ const dash = getDashboard(tmpDir);
1355
+ assert.ok(typeof dash.strategy === 'string');
1356
+ assert.ok(typeof dash.counts === 'object');
1357
+ assert.ok(typeof dash.counts.git.commits === 'number');
1358
+ assert.ok(typeof dash.counts.shadow.snapshots === 'number');
1359
+ assert.ok(typeof dash.diskUsage === 'object');
1360
+ assert.ok(typeof dash.diskUsage.git.display === 'string');
1361
+ assert.ok(typeof dash.protectionScope === 'object');
1362
+ assert.ok(typeof dash.protectionScope.fileCount === 'number');
1363
+ assert.ok(typeof dash.health === 'object');
1364
+ assert.ok(['healthy', 'warning', 'critical'].includes(dash.health.status));
1365
+ assert.ok(Array.isArray(dash.health.issues));
1366
+ assert.ok(typeof dash.alerts === 'object');
1367
+ assert.ok(typeof dash.watcher === 'object');
1368
+ assert.ok(typeof dash.disk === 'object');
1369
+ } finally {
1370
+ cleanupDir(tmpDir);
1371
+ }
1372
+ });
1373
+
1374
+ test('getDashboard works for non-git directory', () => {
1375
+ const tmpDir = createTempDir();
1376
+ try {
1377
+ fs.writeFileSync(path.join(tmpDir, 'test.txt'), 'hello');
1378
+ const dash = getDashboard(tmpDir);
1379
+ assert.ok(typeof dash.strategy === 'string');
1380
+ assert.ok(typeof dash.health === 'object');
1381
+ assert.ok(dash.protectionScope.fileCount >= 0);
1382
+ } finally {
1383
+ cleanupDir(tmpDir);
1384
+ }
1385
+ });
1386
+
1387
+ test('getDashboard includes active alert in health issues', () => {
1388
+ const tmpDir = createTempGitRepo();
1389
+ try {
1390
+ const alert = {
1391
+ type: 'high_change_velocity',
1392
+ timestamp: new Date().toISOString(),
1393
+ fileCount: 30,
1394
+ windowSeconds: 10,
1395
+ expiresAt: new Date(Date.now() + 300000).toISOString(),
1396
+ recommendation: 'Check recent changes',
1397
+ };
1398
+ saveAlert(tmpDir, alert);
1399
+
1400
+ const dash = getDashboard(tmpDir);
1401
+ assert.strictEqual(dash.alerts.active, true);
1402
+ assert.ok(dash.alerts.latest);
1403
+ assert.ok(dash.health.issues.some(i => i.includes('Active alert')));
1404
+ } finally {
1405
+ cleanupDir(tmpDir);
1406
+ }
1407
+ });
1408
+
1409
+ test('getDashboard reports shadow copy count', () => {
1410
+ const tmpDir = createTempGitRepo();
1411
+ try {
1412
+ const cfg = { protect: [], ignore: [], secrets_patterns: [], backup_strategy: 'shadow' };
1413
+ createShadowCopy(tmpDir, cfg);
1414
+ createShadowCopy(tmpDir, cfg);
1415
+
1416
+ const dash = getDashboard(tmpDir);
1417
+ assert.ok(dash.counts.shadow.snapshots >= 1);
1418
+ } finally {
1419
+ cleanupDir(tmpDir);
1420
+ }
1421
+ });
1422
+
1423
+ test('dirSizeBytes returns size for directory', () => {
1424
+ const tmpDir = createTempDir();
1425
+ try {
1426
+ fs.writeFileSync(path.join(tmpDir, 'a.txt'), 'hello world');
1427
+ const size = dirSizeBytes(tmpDir);
1428
+ assert.ok(size > 0);
1429
+ } finally {
1430
+ cleanupDir(tmpDir);
1431
+ }
1432
+ });
1433
+
1434
+ // ── Summary ─────────────────────────────────────────────────────
1435
+
1436
+ test('executeProjectRestore with only untracked files and cleanUntracked=false is a no-op', () => {
1437
+ const tmpDir = createTempGitRepo();
1438
+ try {
1439
+ const headHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
1440
+ fs.writeFileSync(path.join(tmpDir, 'keep-me.txt'), 'should stay');
1441
+
1442
+ const result = executeProjectRestore(tmpDir, headHash, { preserveCurrent: true, cleanUntracked: false });
1443
+ assert.strictEqual(result.status, 'restored');
1444
+ assert.strictEqual(result.filesRestored, 0);
1445
+ assert.strictEqual(result.files.length, 0, 'no files should be reported as restored');
1446
+ assert.strictEqual(result.preRestoreRef, null, 'no pre-restore snapshot should be created for a no-op');
1447
+ assert.ok(fs.existsSync(path.join(tmpDir, 'keep-me.txt')), 'untracked file should remain');
1448
+
1449
+ const refs = execFileSync('git', ['for-each-ref', 'refs/guard/pre-restore/', '--format=%(refname)'], {
1450
+ cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8',
1451
+ }).trim();
1452
+ assert.strictEqual(refs, '', 'no pre-restore refs should be created');
1453
+ } finally {
1454
+ cleanupDir(tmpDir);
1455
+ }
1456
+ });
1457
+
1458
+ console.log(`\n${passed + failed} tests: \x1b[32m${passed} passed\x1b[0m` + (failed ? `, \x1b[31m${failed} failed\x1b[0m` : ''));
1459
+ process.exit(failed > 0 ? 1 : 0);