返回 CodeWhale
pet-recorder.test.mjs
根目录 / pet / tests / pet-recorder.test.mjs
1 import test from 'node:test';
2 import assert from 'node:assert/strict';
3 import { mkdtemp, readFile, writeFile, stat, readdir, rename, symlink, utimes } from 'node:fs/promises';
4 import { join } from 'node:path';
5 import { tmpdir } from 'node:os';
6 import { createPetRecorder } from '../scripts/lib/pet-recorder.mjs';
7 import { compilePetTelemetry, encodePetJSONL, decodePetJSONL, PetLiveTape } from '../dist/core/pet-telemetry.js';
8 import { spawnRecorder } from './helpers/recorder-process.mjs';
9
10 const empty = compilePetTelemetry([])[0];
11 const destination = async () => join(await mkdtemp(join(tmpdir(), 'pet-segment-')), 'pet-state');
12 const part = (path, i) => `${path}.segment-${String(i).padStart(6, '0')}.jsonl`;
13
14 test('continuous recorder crosses a day, archives every bucket once, and the live reader resumes after each replacement', async () => {
15 const path = await destination(), writer = await createPetRecorder(path, { maxBuckets: 2 });
16 const live = new PetLiveTape(), inputs = [];
17 try {
18 for (let i = 0; i < 7; i++) {
19 const state = { ...empty, sequence: 215_999 + i, simTimeMs: (215_999 + i) * 400, activity: i / 10 };
20 inputs.push(state); await writer.append(state);
21 const accepted = live.readTail(await readFile(path, 'utf8'));
22 if (i % 2 === 0) assert.equal(accepted, undefined, 'New segment establishes a baseline');
23 else assert.equal(accepted.activity, state.activity, 'Next append is a new observation');
24 }
25 } finally { await writer.close(); }
26 const paths = [part(path, 1), part(path, 2), part(path, 3), path], replay = [];
27 for (const file of paths) {
28 const rows = decodePetJSONL(await readFile(file, 'utf8'));
29 assert.ok(rows.length <= 2); assert.equal(rows[0].sequence, 0);
30 replay.push(...rows);
31 if (process.platform !== 'win32') assert.equal((await stat(file)).mode & 0o777, 0o600);
32 }
33 assert.deepEqual(replay.map(b => b.activity), inputs.map(b => b.activity));
34 await assert.rejects(createPetRecorder(path), { code: 'EEXIST' });
35 await assert.rejects(writer.append(empty), /closed/);
36 });
37
38 test('segment byte limit counts UTF-8 data and rejects an oversized bucket before changing saved history', async () => {
39 const path = await destination(), state = { ...empty, agentIds: ['鯨'.repeat(20)] };
40 const bytes = Buffer.byteLength(encodePetJSONL([state, { ...state, sequence: 1, simTimeMs: 400 }]));
41 const writer = await createPetRecorder(path, { maxBytes: bytes });
42 try {
43 for (let i = 0; i < 3; i++) await writer.append(state);
44 const saved = await readFile(path);
45 await assert.rejects(writer.append({ ...empty, agentIds: ['鯨'.repeat(1000)] }), /byte limit/);
46 assert.deepEqual(await readFile(path), saved);
47 } finally { await writer.close(); }
48 assert.equal(decodePetJSONL(await readFile(part(path, 1), 'utf8')).length, 2);
49 assert.equal(decodePetJSONL(await readFile(path, 'utf8')).length, 1);
50 assert.equal((await stat(part(path, 1))).size, bytes);
51 });
52
53 test('an archive collision preserves both existing files and removes only the new unpublished temporary file', async () => {
54 const path = await destination(), writer = await createPetRecorder(path, { maxBuckets: 1 });
55 await writer.append(empty);
56 await writeFile(part(path, 1), 'existing archive');
57 const before = await readFile(path);
58 try { await assert.rejects(writer.append({ ...empty, channel: 'code' }), { code: 'EEXIST' }); }
59 finally { await writer.close(); }
60 assert.deepEqual(await readFile(path), before);
61 assert.equal(await readFile(part(path, 1), 'utf8'), 'existing archive');
62 assert.deepEqual((await readdir(join(path, '..'))).sort(), ['pet-state', 'pet-state.segment-000001.jsonl', 'pet-state.writer-lock']);
63 });
64
65 test('ordinary appends refuse an externally replaced live path without overwriting either recording', async () => {
66 const path = await destination(), writer = await createPetRecorder(path, { maxBuckets: 2 });
67 await writer.append(empty);
68 const before = await readFile(path);
69 await rename(path, `${path}.held`); await writeFile(path, 'external replacement');
70 try { await assert.rejects(writer.append(empty), /replaced externally/); }
71 finally { await writer.close(); }
72 assert.equal(await readFile(path, 'utf8'), 'external replacement');
73 assert.deepEqual(await readFile(`${path}.held`), before);
74 assert.deepEqual((await readdir(join(path, '..'))).sort(), ['pet-state', 'pet-state.held', 'pet-state.writer-lock']);
75 });
76
77 test('the actual watch CLI keeps recording at one pathname through several rotations and exits cleanly', { timeout: 15_000 }, async t => {
78 const { once } = await import('node:events');
79 const { setTimeout: delay } = await import('node:timers/promises');
80 const path = await destination(), input = `${path}.source.json`;
81 await writeFile(input, JSON.stringify({ schemaVersion: 1, id: 'old', traceId: 'fixture', name: 'bash', category: 'code', startTime: 0, endTime: 1, attributes: {} }));
82 const child = spawnRecorder([`--input=${input}`, `--output=${path}`, '--watch', '--segment-buckets=2']);
83 let log = ''; child.stdout.on('data', b => log += b); child.stderr.on('data', b => log += b);
84 const exited = once(child, 'exit');
85 t.after(() => { if (child.exitCode === null) child.kill('SIGTERM'); });
86 const deadline = Date.now() + 10_000;
87 while (Date.now() < deadline && child.exitCode === null) {
88 try { if (decodePetJSONL(await readFile(path, 'utf8')).length >= 1 && (await stat(part(path, 3))).size) break; } catch { /* Wait for the next complete segment. */ }
89 await delay(25);
90 }
91 child.stopRecorder(); const [code] = await exited; assert.equal(code, 0, log);
92 for (let i = 1; i <= 3; i++) assert.equal(decodePetJSONL(await readFile(part(path, i), 'utf8')).length, 2);
93 assert.ok(decodePetJSONL(await readFile(path, 'utf8')).length >= 1);
94 assert.match(log, /Archived pet recording:/);
95 });
96
97 test('a post-publication error preserves correct row accounting and allows the next distinct append', async () => {
98 const path = await destination(); let reports = 0;
99 const writer = await createPetRecorder(path, { maxBuckets: 1, report: () => { if (++reports === 1) throw new Error('report boom'); } });
100 try {
101 await writer.append(empty);
102 await assert.rejects(writer.append({ ...empty, channel: 'code' }), /report boom/);
103 assert.equal(decodePetJSONL(await readFile(path, 'utf8'))[0].channel, 'code', 'The new row was already durably published');
104 await writer.append({ ...empty, channel: 'human' });
105 assert.equal(decodePetJSONL(await readFile(part(path, 1), 'utf8'))[0].channel, 'other');
106 assert.equal(decodePetJSONL(await readFile(part(path, 2), 'utf8'))[0].channel, 'code');
107 assert.equal(decodePetJSONL(await readFile(path, 'utf8'))[0].channel, 'human');
108 } finally { await writer.close(); }
109 });
110
111
112 test('resume preserves previous bytes, continues archive numbering, and the follower accepts only fresh appends', async () => {
113 const path = await destination(), before = encodePetJSONL([empty, { ...empty, sequence: 1, simTimeMs: 400, channel: 'human', waiting: true }]);
114 await writeFile(path, before);
115 await writeFile(part(path, 9), 'earlier archive retained');
116 const live = new PetLiveTape(); live.readTail(before);
117 const writer = await createPetRecorder(path, { resume: true, maxBuckets: 2 });
118 try {
119 await writer.append(empty);
120 assert.equal(live.readTail(await readFile(path, 'utf8')), undefined, 'Restart establishes a baseline without replaying the old request');
121 assert.equal(await readFile(part(path, 10), 'utf8'), before);
122 await writer.append({ ...empty, channel: 'code', observed: 1 });
123 assert.equal(live.readTail(await readFile(path, 'utf8')).channel, 'code');
124 await writer.append(empty);
125 assert.equal(decodePetJSONL(await readFile(part(path, 11), 'utf8')).length, 2);
126 assert.equal(await readFile(part(path, 9), 'utf8'), 'earlier archive retained');
127 } finally { await writer.close(); }
128 const again = await createPetRecorder(path, { resume: true });
129 try { await again.append(empty); } finally { await again.close(); }
130 assert.equal(decodePetJSONL(await readFile(part(path, 12), 'utf8')).length, 1);
131 });
132
133 test('a resumed recorder rejects malformed, incomplete, and non-file sources without changing them', async () => {
134 for (const text of ['not a pet tape\n', encodePetJSONL([empty]).trimEnd(), encodePetJSONL([{ ...empty, sequence: 2, simTimeMs: 800 }])]) {
135 const path = await destination(); await writeFile(path, text);
136 await assert.rejects(createPetRecorder(path, { resume: true }));
137 assert.equal(await readFile(path, 'utf8'), text);
138 assert.deepEqual((await readdir(join(path, '..'))).sort(), ['pet-state', 'pet-state.writer-lock']);
139 // Failed validation must release the lock, too.
140 await writeFile(path, encodePetJSONL([empty]));
141 const writer = await createPetRecorder(path, { resume: true }); await writer.close();
142 }
143 const path = await destination();
144 const { mkdir } = await import('node:fs/promises');
145 await mkdir(path);
146 await assert.rejects(createPetRecorder(path, { resume: true }));
147 if (process.platform !== 'win32') {
148 const linked = await destination(), target = `${linked}.original`;
149 await writeFile(target, encodePetJSONL([empty])); await symlink(target, linked);
150 await assert.rejects(createPetRecorder(linked, { resume: true }), /regular file/);
151 assert.equal(await readFile(target, 'utf8'), encodePetJSONL([empty]));
152 }
153 });
154
155 test('the recorder rejects another writer, a replaced writer lock, and same-size external edits', async () => {
156 const path = await destination(), writer = await createPetRecorder(path);
157 try {
158 await writer.append(empty);
159 await assert.rejects(createPetRecorder(path, { resume: true }), /Another pet recorder/);
160 await writer.append(empty); // Closing the rejected contender must not release this writer's lock.
161 await assert.rejects(createPetRecorder(path, { resume: true }), /Another pet recorder/);
162 const original = await readFile(path, 'utf8'), rewritten = original.replace('"channel":"other"', '"channel":"human"');
163 assert.equal(Buffer.byteLength(rewritten), Buffer.byteLength(original));
164 await writeFile(path, rewritten);
165 await utimes(path, new Date(0), new Date(0));
166 await assert.rejects(writer.append(empty), /changed or replaced externally/);
167 assert.equal(await readFile(path, 'utf8'), rewritten);
168 } finally { await writer.close(); }
169 const other = await destination(), active = await createPetRecorder(other);
170 try {
171 await active.append(empty);
172 try { await rename(`${other}.writer-lock`, `${other}.old-lock`); }
173 catch (error) {
174 // Some Windows filesystems deny renaming the held SQLite lock outright.
175 // In that case the original writer must remain exclusive and usable.
176 if (process.platform !== 'win32' || !['EPERM', 'EBUSY', 'EACCES'].includes(error.code)) throw error;
177 await assert.rejects(createPetRecorder(other, { resume: true }), /Another pet recorder/);
178 await active.append(empty);
179 return;
180 }
181 await writeFile(`${other}.writer-lock`, '');
182 await assert.rejects(active.append(empty), /lock was replaced/);
183 assert.equal(decodePetJSONL(await readFile(other, 'utf8')).length, 1);
184 } finally { await active.close(); }
185 });
186
187 test('the actual CLI resumes after process death at the same path without reclaiming a live writer or deleting history', { timeout: 20_000 }, async t => {
188 const { once } = await import('node:events');
189 const { setTimeout: delay } = await import('node:timers/promises');
190 const path = await destination(), input = `${path}.source.json`;
191 await writeFile(input, JSON.stringify({ schemaVersion: 1, id: 'old', traceId: 'fixture', name: 'bash', category: 'code', startTime: 0, endTime: 1, attributes: {} }));
192 const args = [`--input=${input}`, `--output=${path}`, '--watch'];
193 const launch = extra => {
194 // Exercise the Windows IPC harness on every platform; it must not keep
195 // a correctly rejected startup alive after the CLI sets its exit code.
196 const child = spawnRecorder([...args, ...extra], process.env, true);
197 child.log = ''; child.stdout.on('data', b => child.log += b); child.stderr.on('data', b => child.log += b);
198 child.exited = once(child, 'exit');
199 t.after(() => { if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); });
200 return child;
201 };
202 const waitFor = async predicate => {
203 const deadline = Date.now() + 7000;
204 while (Date.now() < deadline) { try { if (await predicate()) return; } catch { /* Atomic publication or the next tick is pending. */ } await delay(25); }
205 assert.fail('Recorder did not reach the requested state');
206 };
207 const first = launch([]);
208 await waitFor(async () => decodePetJSONL(await readFile(path, 'utf8')).length >= 2);
209 const rejected = launch(['--resume']);
210 assert.equal((await rejected.exited)[0], 1); assert.match(rejected.log, /Another pet recorder/);
211 first.kill('SIGKILL'); await first.exited;
212 const previous = await readFile(path, 'utf8');
213 const second = launch(['--resume']);
214 // The archive link can exist before replacement and handler installation.
215 // Wait for CLI readiness as well as the new tape before requesting shutdown.
216 await waitFor(async () => second.log.includes('Recording local live pet states.') && (await stat(part(path, 1))).size && decodePetJSONL(await readFile(path, 'utf8')).length >= 2);
217 second.stopRecorder(); assert.equal((await second.exited)[0], 0, second.log);
218 assert.equal(await readFile(part(path, 1), 'utf8'), previous);
219 const buckets = decodePetJSONL(await readFile(path, 'utf8'));
220 assert.equal(buckets[0].observed, 0); assert.equal(buckets[0].waiting, false); assert.equal(buckets[0].errors, 0);
221 assert.ok(buckets[0].onsets.every(n => n === 0));
222 const third = launch(['--resume']);
223 await waitFor(async () => third.log.includes('Recording local live pet states.') && (await stat(part(path, 2))).size && decodePetJSONL(await readFile(path, 'utf8')).length >= 2);
224 third.stopRecorder(); assert.equal((await third.exited)[0], 0, third.log);
225 assert.equal(decodePetJSONL(await readFile(part(path, 2), 'utf8')).length, buckets.length);
226 });
227
227 lines Plain Text