@exaudeus/memory-mcp 0.1.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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +264 -0
  3. package/dist/__tests__/clock-and-validators.test.d.ts +1 -0
  4. package/dist/__tests__/clock-and-validators.test.js +237 -0
  5. package/dist/__tests__/config-manager.test.d.ts +1 -0
  6. package/dist/__tests__/config-manager.test.js +142 -0
  7. package/dist/__tests__/config.test.d.ts +1 -0
  8. package/dist/__tests__/config.test.js +236 -0
  9. package/dist/__tests__/crash-journal.test.d.ts +1 -0
  10. package/dist/__tests__/crash-journal.test.js +203 -0
  11. package/dist/__tests__/e2e.test.d.ts +1 -0
  12. package/dist/__tests__/e2e.test.js +788 -0
  13. package/dist/__tests__/ephemeral-benchmark.test.d.ts +1 -0
  14. package/dist/__tests__/ephemeral-benchmark.test.js +651 -0
  15. package/dist/__tests__/ephemeral.test.d.ts +1 -0
  16. package/dist/__tests__/ephemeral.test.js +435 -0
  17. package/dist/__tests__/git-service.test.d.ts +1 -0
  18. package/dist/__tests__/git-service.test.js +43 -0
  19. package/dist/__tests__/normalize.test.d.ts +1 -0
  20. package/dist/__tests__/normalize.test.js +161 -0
  21. package/dist/__tests__/store.test.d.ts +1 -0
  22. package/dist/__tests__/store.test.js +1153 -0
  23. package/dist/config-manager.d.ts +49 -0
  24. package/dist/config-manager.js +126 -0
  25. package/dist/config.d.ts +32 -0
  26. package/dist/config.js +162 -0
  27. package/dist/crash-journal.d.ts +38 -0
  28. package/dist/crash-journal.js +198 -0
  29. package/dist/ephemeral-weights.json +1847 -0
  30. package/dist/ephemeral.d.ts +20 -0
  31. package/dist/ephemeral.js +516 -0
  32. package/dist/formatters.d.ts +10 -0
  33. package/dist/formatters.js +92 -0
  34. package/dist/git-service.d.ts +5 -0
  35. package/dist/git-service.js +39 -0
  36. package/dist/index.d.ts +2 -0
  37. package/dist/index.js +1197 -0
  38. package/dist/normalize.d.ts +2 -0
  39. package/dist/normalize.js +69 -0
  40. package/dist/store.d.ts +84 -0
  41. package/dist/store.js +813 -0
  42. package/dist/text-analyzer.d.ts +32 -0
  43. package/dist/text-analyzer.js +190 -0
  44. package/dist/thresholds.d.ts +39 -0
  45. package/dist/thresholds.js +75 -0
  46. package/dist/types.d.ts +186 -0
  47. package/dist/types.js +33 -0
  48. package/package.json +57 -0
@@ -0,0 +1,236 @@
1
+ // Tests for config.ts — configuration loading with 3-tier fallback.
2
+ // Tests the public API: getLobeConfigs() with different env/file setups.
3
+ //
4
+ // Strategy: We can't easily test file-based config (it reads relative to
5
+ // the module), but we CAN test env-based and default-based config.
6
+ import { describe, it, beforeEach, afterEach } from 'node:test';
7
+ import assert from 'node:assert';
8
+ import { promises as fs } from 'fs';
9
+ import path from 'path';
10
+ import os from 'os';
11
+ import { getLobeConfigs, parseBehaviorConfig } from '../config.js';
12
+ // Save original env vars so we can restore after each test
13
+ const originalEnv = {};
14
+ const envKeys = [
15
+ 'MEMORY_MCP_WORKSPACES',
16
+ 'MEMORY_MCP_DIR',
17
+ 'MEMORY_MCP_BUDGET',
18
+ 'MEMORY_MCP_REPO_ROOT',
19
+ ];
20
+ function saveEnv() {
21
+ for (const key of envKeys) {
22
+ originalEnv[key] = process.env[key];
23
+ }
24
+ }
25
+ function restoreEnv() {
26
+ for (const key of envKeys) {
27
+ if (originalEnv[key] === undefined) {
28
+ delete process.env[key];
29
+ }
30
+ else {
31
+ process.env[key] = originalEnv[key];
32
+ }
33
+ }
34
+ }
35
+ function clearConfigEnv() {
36
+ for (const key of envKeys) {
37
+ delete process.env[key];
38
+ }
39
+ }
40
+ describe('getLobeConfigs', () => {
41
+ let tempDir;
42
+ beforeEach(async () => {
43
+ saveEnv();
44
+ tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memory-mcp-config-test-'));
45
+ });
46
+ afterEach(async () => {
47
+ restoreEnv();
48
+ await fs.rm(tempDir, { recursive: true, force: true }).catch(() => { });
49
+ });
50
+ describe('env var config (tier 2)', () => {
51
+ it('loads from MEMORY_MCP_WORKSPACES json', () => {
52
+ clearConfigEnv();
53
+ process.env.MEMORY_MCP_WORKSPACES = JSON.stringify({
54
+ 'my-repo': tempDir,
55
+ });
56
+ process.env.MEMORY_MCP_DIR = '.test-memory';
57
+ const { configs, origin } = getLobeConfigs();
58
+ // May load file config first if memory-config.json exists
59
+ // But if we're here, it should have at least one lobe
60
+ assert.ok(configs.size >= 1, 'Should have at least one lobe');
61
+ if (origin.source === 'env') {
62
+ const config = configs.get('my-repo');
63
+ assert.ok(config, 'Should have my-repo lobe');
64
+ assert.strictEqual(config.repoRoot, tempDir);
65
+ assert.ok(config.memoryPath.includes('.test-memory'), `Memory path should use MEMORY_MCP_DIR: ${config.memoryPath}`);
66
+ }
67
+ });
68
+ it('loads multiple lobes from env', () => {
69
+ clearConfigEnv();
70
+ const dir2 = tempDir + '-2';
71
+ process.env.MEMORY_MCP_WORKSPACES = JSON.stringify({
72
+ 'repo-a': tempDir,
73
+ 'repo-b': dir2,
74
+ });
75
+ const { configs, origin } = getLobeConfigs();
76
+ if (origin.source === 'env') {
77
+ assert.strictEqual(configs.size, 2, 'Should have 2 lobes');
78
+ assert.ok(configs.has('repo-a'));
79
+ assert.ok(configs.has('repo-b'));
80
+ }
81
+ });
82
+ it('respects custom budget from env', () => {
83
+ clearConfigEnv();
84
+ process.env.MEMORY_MCP_WORKSPACES = JSON.stringify({
85
+ 'my-repo': tempDir,
86
+ });
87
+ process.env.MEMORY_MCP_BUDGET = String(5 * 1024 * 1024); // 5MB
88
+ const { configs, origin } = getLobeConfigs();
89
+ if (origin.source === 'env') {
90
+ const config = configs.get('my-repo');
91
+ assert.strictEqual(config.storageBudgetBytes, 5 * 1024 * 1024);
92
+ }
93
+ });
94
+ it('falls through on invalid MEMORY_MCP_WORKSPACES JSON', () => {
95
+ clearConfigEnv();
96
+ process.env.MEMORY_MCP_WORKSPACES = 'not valid json';
97
+ process.env.MEMORY_MCP_REPO_ROOT = tempDir;
98
+ // Should not throw — falls through to default
99
+ const { configs } = getLobeConfigs();
100
+ assert.ok(configs.size >= 1, 'Should fall through to at least one lobe');
101
+ });
102
+ });
103
+ describe('default fallback config (tier 3)', () => {
104
+ it('uses MEMORY_MCP_REPO_ROOT when no config file or env workspaces', () => {
105
+ clearConfigEnv();
106
+ process.env.MEMORY_MCP_REPO_ROOT = tempDir;
107
+ const { configs, origin } = getLobeConfigs();
108
+ // If there's no memory-config.json and no MEMORY_MCP_WORKSPACES,
109
+ // it should fall back to default
110
+ if (origin.source === 'default') {
111
+ assert.ok(configs.has('default'), 'Should have a default lobe');
112
+ assert.strictEqual(configs.get('default').repoRoot, tempDir);
113
+ }
114
+ });
115
+ it('uses cwd when no env vars at all', () => {
116
+ clearConfigEnv();
117
+ // No env vars set — should use process.cwd()
118
+ const { configs } = getLobeConfigs();
119
+ assert.ok(configs.size >= 1, 'Should always return at least one lobe');
120
+ });
121
+ it('uses explicit MEMORY_MCP_DIR for default lobe', () => {
122
+ clearConfigEnv();
123
+ process.env.MEMORY_MCP_REPO_ROOT = tempDir;
124
+ process.env.MEMORY_MCP_DIR = '.custom-memory';
125
+ const { configs, origin } = getLobeConfigs();
126
+ if (origin.source === 'default') {
127
+ const config = configs.get('default');
128
+ assert.ok(config.memoryPath.includes('.custom-memory'), `Should use custom dir: ${config.memoryPath}`);
129
+ }
130
+ });
131
+ });
132
+ describe('behavior config parsing', () => {
133
+ it('returns empty object when no behavior block provided', () => {
134
+ const result = parseBehaviorConfig(undefined);
135
+ // All fields should be absent (caller falls back to defaults from thresholds.ts)
136
+ assert.strictEqual(Object.keys(result).length, 0);
137
+ });
138
+ it('passes through valid values within allowed ranges', () => {
139
+ const result = parseBehaviorConfig({
140
+ staleDaysStandard: 14,
141
+ staleDaysPreferences: 60,
142
+ maxStaleInBriefing: 3,
143
+ maxDedupSuggestions: 5,
144
+ maxConflictPairs: 2,
145
+ });
146
+ assert.strictEqual(result.staleDaysStandard, 14);
147
+ assert.strictEqual(result.staleDaysPreferences, 60);
148
+ assert.strictEqual(result.maxStaleInBriefing, 3);
149
+ assert.strictEqual(result.maxDedupSuggestions, 5);
150
+ assert.strictEqual(result.maxConflictPairs, 2);
151
+ });
152
+ it('falls back to default for out-of-range staleDaysStandard', () => {
153
+ const tooLow = parseBehaviorConfig({ staleDaysStandard: 0 });
154
+ assert.strictEqual(tooLow.staleDaysStandard, 30, 'Should use default 30 for value below minimum (1)');
155
+ const tooHigh = parseBehaviorConfig({ staleDaysStandard: 500 });
156
+ assert.strictEqual(tooHigh.staleDaysStandard, 30, 'Should use default 30 for value above maximum (365)');
157
+ });
158
+ it('falls back to default for out-of-range staleDaysPreferences', () => {
159
+ const tooHigh = parseBehaviorConfig({ staleDaysPreferences: 800 });
160
+ assert.strictEqual(tooHigh.staleDaysPreferences, 90, 'Should use default 90 for value above maximum (730)');
161
+ });
162
+ it('falls back to default for out-of-range maxStaleInBriefing', () => {
163
+ const tooHigh = parseBehaviorConfig({ maxStaleInBriefing: 50 });
164
+ assert.strictEqual(tooHigh.maxStaleInBriefing, 5, 'Should use default 5 for value above maximum (20)');
165
+ });
166
+ it('falls back to default for out-of-range maxConflictPairs', () => {
167
+ const tooHigh = parseBehaviorConfig({ maxConflictPairs: 10 });
168
+ assert.strictEqual(tooHigh.maxConflictPairs, 2, 'Should use default 2 for value above maximum (5)');
169
+ });
170
+ it('rounds fractional values to integers', () => {
171
+ const result = parseBehaviorConfig({ staleDaysStandard: 14.7 });
172
+ assert.strictEqual(result.staleDaysStandard, 15, 'Should round to nearest integer');
173
+ });
174
+ it('handles partial behavior config — omitted fields do not appear', () => {
175
+ const result = parseBehaviorConfig({ staleDaysStandard: 14 });
176
+ assert.strictEqual(result.staleDaysStandard, 14);
177
+ // Omitted fields should not be set (callers use thresholds.ts defaults)
178
+ assert.strictEqual(result.staleDaysPreferences, 90, 'Non-omitted field uses default');
179
+ });
180
+ it('writes a stderr warning for unknown behavior config keys (typo detection)', () => {
181
+ const stderrWrites = [];
182
+ const origWrite = process.stderr.write.bind(process.stderr);
183
+ // Capture stderr
184
+ process.stderr.write = ((chunk, ...rest) => {
185
+ if (typeof chunk === 'string')
186
+ stderrWrites.push(chunk);
187
+ return origWrite(chunk, ...rest);
188
+ });
189
+ try {
190
+ // "staleDaysStanderd" is a typo of "staleDaysStandard"
191
+ parseBehaviorConfig({ staleDaysStandard: 14, staleDaysStanderd: 7 });
192
+ }
193
+ finally {
194
+ process.stderr.write = origWrite;
195
+ }
196
+ const warnings = stderrWrites.filter(s => s.includes('Unknown behavior config key'));
197
+ assert.ok(warnings.length > 0, 'Should warn about unknown key "staleDaysStanderd"');
198
+ assert.ok(warnings[0].includes('staleDaysStanderd'), 'Warning should name the offending key');
199
+ });
200
+ it('does not warn for valid behavior config keys', () => {
201
+ const stderrWrites = [];
202
+ const origWrite = process.stderr.write.bind(process.stderr);
203
+ process.stderr.write = ((chunk, ...rest) => {
204
+ if (typeof chunk === 'string')
205
+ stderrWrites.push(chunk);
206
+ return origWrite(chunk, ...rest);
207
+ });
208
+ try {
209
+ parseBehaviorConfig({ staleDaysStandard: 14, maxStaleInBriefing: 3 });
210
+ }
211
+ finally {
212
+ process.stderr.write = origWrite;
213
+ }
214
+ const unknownWarnings = stderrWrites.filter(s => s.includes('Unknown behavior config key'));
215
+ assert.strictEqual(unknownWarnings.length, 0, 'Should not warn for valid keys');
216
+ });
217
+ });
218
+ describe('config structure', () => {
219
+ it('all configs have required fields', () => {
220
+ // Use whatever config is currently active
221
+ const { configs } = getLobeConfigs();
222
+ for (const [name, config] of configs) {
223
+ assert.ok(config.repoRoot, `${name}: repoRoot should be set`);
224
+ assert.ok(config.memoryPath, `${name}: memoryPath should be set`);
225
+ assert.ok(config.storageBudgetBytes > 0, `${name}: storageBudgetBytes should be positive`);
226
+ }
227
+ });
228
+ it('origin is always a valid discriminated union', () => {
229
+ const { origin } = getLobeConfigs();
230
+ assert.ok(['file', 'env', 'default'].includes(origin.source), `Origin source should be file, env, or default: ${origin.source}`);
231
+ if (origin.source === 'file') {
232
+ assert.ok('path' in origin && typeof origin.path === 'string', 'File origin should have a path');
233
+ }
234
+ });
235
+ });
236
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,203 @@
1
+ // Tests for crash-journal.ts — crash report lifecycle: build, write, read, clear, format.
2
+ // Uses real disk I/O with isolated temp directories — no mocks.
3
+ import { describe, it, afterEach } from 'node:test';
4
+ import assert from 'node:assert';
5
+ import { promises as fs } from 'fs';
6
+ import path from 'path';
7
+ import os from 'os';
8
+ import { buildCrashReport, writeCrashReport, readLatestCrash, readCrashHistory, clearLatestCrash, formatCrashReport, formatCrashSummary, markServerStarted, } from '../crash-journal.js';
9
+ // The crash journal writes to ~/.memory-mcp/crashes/ — we need to isolate tests.
10
+ // We'll monkey-patch the module's internal CRASH_DIR by writing to a temp dir
11
+ // and reading back. Since the module uses hardcoded paths, we test the public
12
+ // API behaviors: buildCrashReport (pure), format functions (pure), and
13
+ // read/write lifecycle (I/O — uses real paths, so we clean up carefully).
14
+ describe('buildCrashReport', () => {
15
+ it('builds a report from an Error', () => {
16
+ markServerStarted();
17
+ const error = new Error('Test failure');
18
+ const context = {
19
+ phase: 'running',
20
+ lastToolCall: 'memory_store',
21
+ configSource: 'file',
22
+ lobeCount: 2,
23
+ };
24
+ const report = buildCrashReport(error, 'uncaught-exception', context);
25
+ assert.strictEqual(report.error, 'Test failure');
26
+ assert.ok(report.stack?.includes('Test failure'), 'Should include stack trace');
27
+ assert.strictEqual(report.type, 'uncaught-exception');
28
+ assert.strictEqual(report.context.phase, 'running');
29
+ assert.strictEqual(report.context.lastToolCall, 'memory_store');
30
+ assert.ok(report.timestamp, 'Should have ISO timestamp');
31
+ assert.ok(report.pid > 0, 'Should have process ID');
32
+ assert.ok(Array.isArray(report.recovery), 'Should have recovery steps');
33
+ assert.ok(report.recovery.length > 0, 'Should have at least one recovery step');
34
+ });
35
+ it('builds a report from a non-Error value', () => {
36
+ markServerStarted();
37
+ const report = buildCrashReport('string error', 'unknown', { phase: 'startup' });
38
+ assert.strictEqual(report.error, 'string error');
39
+ assert.strictEqual(report.stack, undefined, 'Non-Error has no stack');
40
+ assert.strictEqual(report.type, 'unknown');
41
+ });
42
+ it('includes server uptime', () => {
43
+ markServerStarted();
44
+ const report = buildCrashReport(new Error('test'), 'unknown', { phase: 'running' });
45
+ assert.ok(report.serverUptime >= 0, 'Uptime should be non-negative');
46
+ });
47
+ it('generates recovery steps for startup-failure', () => {
48
+ const report = buildCrashReport(new Error('Cannot read memory-config.json'), 'startup-failure', { phase: 'startup', configSource: 'file' });
49
+ assert.ok(report.recovery.some(s => s.includes('memory-config.json')), 'Should mention config file in recovery');
50
+ });
51
+ it('generates recovery steps for lobe-init-failure', () => {
52
+ const report = buildCrashReport(new Error('ENOENT: no such directory'), 'lobe-init-failure', { phase: 'startup', activeLobe: 'my-repo' });
53
+ assert.ok(report.recovery.some(s => s.includes('my-repo')), 'Should mention the failed lobe');
54
+ });
55
+ it('generates recovery steps for transport-error', () => {
56
+ const report = buildCrashReport(new Error('pipe broken'), 'transport-error', { phase: 'running' });
57
+ assert.ok(report.recovery.some(s => s.includes('toggle') || s.includes('Toggle')), 'Should suggest toggling MCP');
58
+ });
59
+ it('detects disk-full errors in recovery', () => {
60
+ const report = buildCrashReport(new Error('ENOSPC: no space left on device'), 'uncaught-exception', { phase: 'running' });
61
+ assert.ok(report.recovery.some(s => s.toLowerCase().includes('disk') || s.toLowerCase().includes('space')), 'Should mention disk space');
62
+ });
63
+ it('detects permission errors in recovery', () => {
64
+ const report = buildCrashReport(new Error('EACCES: permission denied'), 'uncaught-exception', { phase: 'running' });
65
+ assert.ok(report.recovery.some(s => s.toLowerCase().includes('permission')), 'Should mention permissions');
66
+ });
67
+ });
68
+ describe('formatCrashReport', () => {
69
+ const sampleReport = {
70
+ timestamp: '2026-01-15T10:30:00.000Z',
71
+ pid: 12345,
72
+ error: 'ENOENT: file not found',
73
+ stack: 'Error: ENOENT\n at readFile (node:fs)\n at Store.init (store.ts:42)',
74
+ type: 'startup-failure',
75
+ context: {
76
+ phase: 'startup',
77
+ lastToolCall: 'memory_bootstrap',
78
+ activeLobe: 'zillow',
79
+ configSource: 'file',
80
+ lobeCount: 3,
81
+ },
82
+ recovery: ['Check file permissions', 'Toggle MCP to restart'],
83
+ serverUptime: 0,
84
+ };
85
+ it('includes all key fields', () => {
86
+ const formatted = formatCrashReport(sampleReport);
87
+ assert.ok(formatted.includes('2026-01-15'), 'Should include timestamp');
88
+ assert.ok(formatted.includes('startup-failure'), 'Should include crash type');
89
+ assert.ok(formatted.includes('startup'), 'Should include phase');
90
+ assert.ok(formatted.includes('ENOENT'), 'Should include error message');
91
+ assert.ok(formatted.includes('memory_bootstrap'), 'Should include last tool call');
92
+ assert.ok(formatted.includes('zillow'), 'Should include affected lobe');
93
+ });
94
+ it('includes recovery steps', () => {
95
+ const formatted = formatCrashReport(sampleReport);
96
+ assert.ok(formatted.includes('Check file permissions'));
97
+ assert.ok(formatted.includes('Toggle MCP to restart'));
98
+ });
99
+ it('includes truncated stack trace', () => {
100
+ const formatted = formatCrashReport(sampleReport);
101
+ assert.ok(formatted.includes('Stack Trace'), 'Should have stack trace section');
102
+ assert.ok(formatted.includes('ENOENT'), 'Should include stack content');
103
+ });
104
+ it('omits stack trace section when absent', () => {
105
+ const noStack = { ...sampleReport, stack: undefined };
106
+ const formatted = formatCrashReport(noStack);
107
+ assert.ok(!formatted.includes('Stack Trace'), 'Should not have stack trace section');
108
+ });
109
+ });
110
+ describe('formatCrashSummary', () => {
111
+ it('formats a short summary with age', () => {
112
+ const report = {
113
+ timestamp: new Date(Date.now() - 5 * 60 * 1000).toISOString(), // 5 minutes ago
114
+ pid: 1,
115
+ error: 'Something broke unexpectedly during startup',
116
+ type: 'startup-failure',
117
+ context: { phase: 'startup' },
118
+ recovery: [],
119
+ serverUptime: 10,
120
+ };
121
+ const summary = formatCrashSummary(report);
122
+ assert.ok(summary.includes('5m ago') || summary.includes('4m ago'), 'Should show age in minutes');
123
+ assert.ok(summary.includes('startup-failure'), 'Should include type');
124
+ assert.ok(summary.includes('Something broke'), 'Should include error text');
125
+ });
126
+ it('truncates long error messages', () => {
127
+ const report = {
128
+ timestamp: new Date().toISOString(),
129
+ pid: 1,
130
+ error: 'A'.repeat(200),
131
+ type: 'unknown',
132
+ context: { phase: 'running' },
133
+ recovery: [],
134
+ serverUptime: 0,
135
+ };
136
+ const summary = formatCrashSummary(report);
137
+ assert.ok(summary.length < 250, 'Summary should be concise');
138
+ });
139
+ });
140
+ describe('crash report write/read lifecycle', () => {
141
+ // These tests use the real crash directory (~/.memory-mcp/crashes/)
142
+ // We write unique reports and clean them up after.
143
+ const testReports = [];
144
+ afterEach(async () => {
145
+ // Clean up test crash files
146
+ for (const report of testReports) {
147
+ const filename = `crash-${report.timestamp.replace(/[:.]/g, '-')}.json`;
148
+ const filepath = path.join(os.homedir(), '.memory-mcp', 'crashes', filename);
149
+ await fs.unlink(filepath).catch(() => { });
150
+ }
151
+ testReports.length = 0;
152
+ // Always clear LATEST.json to not interfere with real server
153
+ await clearLatestCrash();
154
+ });
155
+ it('writes and reads back a crash report', async () => {
156
+ markServerStarted();
157
+ const report = buildCrashReport(new Error('e2e test crash'), 'uncaught-exception', { phase: 'running' });
158
+ testReports.push(report);
159
+ const filepath = await writeCrashReport(report);
160
+ assert.ok(filepath.endsWith('.json'), 'Should write a .json file');
161
+ // Verify the file exists and is parseable
162
+ const content = await fs.readFile(filepath, 'utf-8');
163
+ const parsed = JSON.parse(content);
164
+ assert.strictEqual(parsed.error, 'e2e test crash');
165
+ assert.strictEqual(parsed.type, 'uncaught-exception');
166
+ });
167
+ it('readLatestCrash returns the most recent crash', async () => {
168
+ markServerStarted();
169
+ const report = buildCrashReport(new Error('latest test crash'), 'startup-failure', { phase: 'startup' });
170
+ testReports.push(report);
171
+ await writeCrashReport(report);
172
+ const latest = await readLatestCrash();
173
+ assert.ok(latest, 'Should find a latest crash');
174
+ assert.strictEqual(latest.error, 'latest test crash');
175
+ });
176
+ it('clearLatestCrash removes the latest indicator', async () => {
177
+ markServerStarted();
178
+ const report = buildCrashReport(new Error('to clear'), 'unknown', { phase: 'running' });
179
+ testReports.push(report);
180
+ await writeCrashReport(report);
181
+ await clearLatestCrash();
182
+ const latest = await readLatestCrash();
183
+ assert.strictEqual(latest, null, 'Should be null after clear');
184
+ });
185
+ it('readCrashHistory returns reports in reverse chronological order', async () => {
186
+ markServerStarted();
187
+ const report1 = buildCrashReport(new Error('crash-1'), 'unknown', { phase: 'running' });
188
+ // Small delay to ensure different timestamps
189
+ await new Promise(r => setTimeout(r, 10));
190
+ const report2 = buildCrashReport(new Error('crash-2'), 'unknown', { phase: 'running' });
191
+ testReports.push(report1, report2);
192
+ await writeCrashReport(report1);
193
+ await writeCrashReport(report2);
194
+ const history = await readCrashHistory(10);
195
+ assert.ok(history.length >= 2, 'Should have at least 2 reports');
196
+ // Most recent should be first
197
+ const idx1 = history.findIndex(r => r.error === 'crash-1');
198
+ const idx2 = history.findIndex(r => r.error === 'crash-2');
199
+ if (idx1 >= 0 && idx2 >= 0) {
200
+ assert.ok(idx2 < idx1, 'crash-2 (newer) should come before crash-1');
201
+ }
202
+ });
203
+ });
@@ -0,0 +1 @@
1
+ export {};