File size: 16,180 Bytes
6a2bc3b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
/**
 * Scenario: VSCode-owned file baselines for new, migrated, and forked sessions.
 * Responsibilities: capture originals, show changes, keep/undo, persist legacy tombstones, and reject unsafe paths.
 * Wiring: real temporary workspace/global-storage/legacy files; no stubbed collaborators.
 * Run: pnpm --filter kimi-code test -- baseline.manager.test.ts
 */
import { existsSync, writeFileSync } from 'node:fs';
import { chmod, mkdir, mkdtemp, readFile, rm, symlink, unlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import {
  BaselineManager,
  type BaselineSession,
} from '../src/managers/baseline.manager';

let root: string;
let workDir: string;
let storageRoot: string;
let manager: BaselineManager;

beforeEach(async () => {
  root = await mkdtemp(join(tmpdir(), 'vscode-baseline-'));
  workDir = join(root, 'workspace');
  storageRoot = join(root, 'global-storage');
  await mkdir(workDir, { recursive: true });
  manager = new BaselineManager(storageRoot);
});

afterEach(async () => {
  await rm(root, { recursive: true, force: true });
});

describe('file baselines (capture, compare, keep, and undo)', () => {
  it('keeps the first original when overlapping captures race', async () => {
    const session = createSession();
    const filePath = join(workDir, 'src', 'app.ts');
    await mkdir(join(workDir, 'src'), { recursive: true });
    writeFileSync(filePath, 'original\n', 'utf-8');

    const firstCapture = manager.capture(session, filePath);
    writeFileSync(filePath, 'after first edit\n', 'utf-8');
    const secondCapture = manager.capture(session, filePath);
    await Promise.all([firstCapture, secondCapture]);

    expect(await manager.getContent(session, filePath)).toBe('original\n');
  });

  it('returns the captured original after the manager is recreated', async () => {
    const session = createSession();
    const filePath = join(workDir, 'app.ts');
    await writeFile(filePath, 'persisted original\n', 'utf-8');
    await manager.capture(session, filePath);

    const reloadedManager = new BaselineManager(storageRoot);

    await expect(reloadedManager.getContent(session, filePath)).resolves.toBe(
      'persisted original\n',
    );
  });

  it('isolates the same session id between different Kimi homes', async () => {
    const session = createSession();
    const filePath = join(workDir, 'app.ts');
    const firstHome = new BaselineManager(storageRoot, join(root, 'home-a'));
    const secondHome = new BaselineManager(storageRoot, join(root, 'home-b'));
    await writeFile(filePath, 'home A original\n', 'utf-8');
    await firstHome.capture(session, filePath);
    await writeFile(filePath, 'home B original\n', 'utf-8');
    await secondHome.capture(session, filePath);

    await expect(firstHome.getContent(session, filePath)).resolves.toBe('home A original\n');
    await expect(secondHome.getContent(session, filePath)).resolves.toBe('home B original\n');
  });

  it('reports a modified file when current content differs from its original', async () => {
    const session = createSession();
    const filePath = join(workDir, 'app.ts');
    await writeFile(filePath, 'before\n', 'utf-8');
    await manager.capture(session, filePath);
    await writeFile(filePath, 'after\n', 'utf-8');

    await expect(manager.getChanges(session)).resolves.toEqual([
      { path: 'app.ts', status: 'Modified', additions: 1, deletions: 1 },
    ]);
  });

  it('reports an added file when it did not exist at capture time', async () => {
    const session = createSession();
    const filePath = join(workDir, 'new.ts');
    await manager.capture(session, filePath);
    await writeFile(filePath, 'new content\n', 'utf-8');

    await expect(manager.getChanges(session)).resolves.toEqual([
      { path: 'new.ts', status: 'Added', additions: 2, deletions: 0 },
    ]);
  });

  it('reports a deleted file when an existing original is removed', async () => {
    const session = createSession();
    const filePath = join(workDir, 'old.ts');
    await writeFile(filePath, 'line one\nline two', 'utf-8');
    await manager.capture(session, filePath);
    await unlink(filePath);

    await expect(manager.getChanges(session)).resolves.toEqual([
      { path: 'old.ts', status: 'Deleted', additions: 0, deletions: 2 },
    ]);
  });

  it('removes a newly created file when undo restores its missing original', async () => {
    const session = createSession();
    const filePath = join(workDir, 'new.ts');
    await manager.capture(session, filePath);
    await writeFile(filePath, 'created\n', 'utf-8');

    await manager.undo(session, filePath);

    expect(existsSync(filePath)).toBe(false);
  });

  it('restores an existing empty file without deleting it when undo runs', async () => {
    const session = createSession();
    const filePath = join(workDir, 'empty.ts');
    await writeFile(filePath, '', 'utf-8');
    await manager.capture(session, filePath);
    await writeFile(filePath, 'changed', 'utf-8');

    await manager.undo(session, filePath);

    expect(existsSync(filePath)).toBe(true);
    await expect(readFile(filePath, 'utf-8')).resolves.toBe('');
  });

  it('preserves CRLF bytes when undo restores the original file', async () => {
    const session = createSession();
    const filePath = join(workDir, 'windows.txt');
    await writeFile(filePath, 'first\r\nsecond\r\n', 'utf-8');
    await manager.capture(session, filePath);
    await writeFile(filePath, 'changed\n', 'utf-8');

    await manager.undo(session, filePath);

    await expect(readFile(filePath, 'utf-8')).resolves.toBe('first\r\nsecond\r\n');
  });

  it('captures a new original after keep accepts the previous changes', async () => {
    const session = createSession();
    const filePath = join(workDir, 'app.ts');
    await writeFile(filePath, 'first\n', 'utf-8');
    await manager.capture(session, filePath);
    await writeFile(filePath, 'accepted\n', 'utf-8');
    await manager.keep(session, filePath);

    await manager.capture(session, filePath);
    await writeFile(filePath, 'next edit\n', 'utf-8');

    expect(await manager.getContent(session, filePath)).toBe('accepted\n');
  });

  it('restores every tracked original when undo all runs', async () => {
    const session = createSession();
    await writeFile(join(workDir, 'one.ts'), 'one before', 'utf-8');
    await writeFile(join(workDir, 'two.ts'), 'two before', 'utf-8');
    await manager.capture(session, 'one.ts');
    await manager.capture(session, 'two.ts');
    await writeFile(join(workDir, 'one.ts'), 'one after', 'utf-8');
    await writeFile(join(workDir, 'two.ts'), 'two after', 'utf-8');

    await manager.undoAll(session);

    await expect(readFile(join(workDir, 'one.ts'), 'utf-8')).resolves.toBe('one before');
    await expect(readFile(join(workDir, 'two.ts'), 'utf-8')).resolves.toBe('two before');
  });

  it('refuses undo all when a tracked directory now links outside the workspace', async () => {
    const session = createSession();
    const trackedDir = join(workDir, 'src');
    const outsideDir = join(root, 'outside');
    await mkdir(trackedDir);
    await mkdir(outsideDir);
    await writeFile(join(trackedDir, 'app.ts'), 'original');
    await manager.capture(session, 'src/app.ts');
    await rm(trackedDir, { recursive: true });
    await writeFile(join(outsideDir, 'app.ts'), 'outside');
    await symlink(outsideDir, trackedDir, process.platform === 'win32' ? 'junction' : 'dir');

    await expect(manager.undoAll(session)).rejects.toThrow('outside the session workspace');
    await expect(readFile(join(outsideDir, 'app.ts'), 'utf-8')).resolves.toBe('outside');
  });

  it('removes every visible change when keep all runs', async () => {
    const session = createSession();
    await writeFile(join(workDir, 'one.ts'), 'one before', 'utf-8');
    await writeFile(join(workDir, 'two.ts'), 'two before', 'utf-8');
    await manager.capture(session, 'one.ts');
    await manager.capture(session, 'two.ts');
    await writeFile(join(workDir, 'one.ts'), 'one after', 'utf-8');
    await writeFile(join(workDir, 'two.ts'), 'two after', 'utf-8');

    await manager.keepAll(session);

    await expect(manager.getChanges(session)).resolves.toEqual([]);
  });
});

describe('legacy baselines (fallback, tombstones, and fork isolation)', () => {
  it('returns the legacy original when no local baseline exists', async () => {
    const { session } = await createLegacySession('legacy original\n');
    await writeFile(join(workDir, 'app.ts'), 'current content\n', 'utf-8');

    await expect(manager.getContent(session, 'app.ts')).resolves.toBe('legacy original\n');
  });

  it('reports a legacy file when current content differs from its original', async () => {
    const { session } = await createLegacySession('legacy original\n');
    await writeFile(join(workDir, 'app.ts'), 'current content\n', 'utf-8');

    await expect(manager.getChanges(session)).resolves.toEqual([
      { path: 'app.ts', status: 'Modified', additions: 1, deletions: 1 },
    ]);
  });

  it('restores a legacy original without mutating its snapshot when undo runs', async () => {
    const { session, baselinePath } = await createLegacySession('legacy original\n');
    await writeFile(join(workDir, 'app.ts'), 'current content\n', 'utf-8');

    await manager.undo(session, 'app.ts');

    await expect(readFile(join(workDir, 'app.ts'), 'utf-8')).resolves.toBe('legacy original\n');
    await expect(readFile(baselinePath, 'utf-8')).resolves.toBe('legacy original\n');
  });

  it('persists a keep tombstone for legacy content without deleting the old snapshot', async () => {
    const { session, baselinePath } = await createLegacySession('legacy original\n');
    await writeFile(join(workDir, 'app.ts'), 'accepted content\n', 'utf-8');
    await manager.keep(session, 'app.ts');

    const reloadedManager = new BaselineManager(storageRoot);

    await expect(reloadedManager.getChanges(session)).resolves.toEqual([]);
    await expect(readFile(baselinePath, 'utf-8')).resolves.toBe('legacy original\n');
  });

  it('uses a materialized local original before the fork target legacy fallback', async () => {
    const source = createSession('ses-source');
    const filePath = join(workDir, 'app.ts');
    await writeFile(filePath, 'local original\n', 'utf-8');
    await manager.capture(source, filePath);
    const { session: target } = await createLegacySession('legacy original\n', 'ses-target');

    await manager.materializeToFork(source, target);

    await expect(manager.getContent(target, 'app.ts')).resolves.toBe('local original\n');
  });

  it('keeps a fork baseline readable when the legacy source is later deleted', async () => {
    const { session: source, legacySessionDir } = await createLegacySession(
      'legacy original\n',
      'ses-source',
    );
    const target = createSession('ses-target', source.metadata);
    await manager.materializeToFork(source, target);
    await rm(legacySessionDir, { recursive: true, force: true });

    await expect(manager.getContent(target, 'app.ts')).resolves.toBe('legacy original\n');
  });

  it('keeps the source baseline visible when the fork accepts its own copy', async () => {
    const { session: source } = await createLegacySession('legacy original\n', 'ses-source');
    const target = createSession('ses-target', source.metadata);
    await manager.materializeToFork(source, target);

    await manager.keep(target, 'app.ts');

    await expect(manager.getContent(source, 'app.ts')).resolves.toBe('legacy original\n');
  });

  it('preserves accepted legacy paths when a fork is materialized', async () => {
    const { session: source } = await createLegacySession('legacy original\n', 'ses-source');
    const target = createSession('ses-target', source.metadata);
    await manager.keep(source, 'app.ts');

    await manager.materializeToFork(source, target);

    await expect(manager.getContent(target, 'app.ts')).rejects.toThrow('No baseline exists');
  });
});

describe('baseline boundaries (errors, cleanup, and platform paths)', () => {
  it.skipIf(process.platform === 'win32')(
    'rejects an unreadable original without recording an empty baseline',
    async () => {
      const session = createSession();
      const filePath = join(workDir, 'unreadable.ts');
      await writeFile(filePath, 'original', 'utf-8');
      await chmod(filePath, 0o000);

      try {
        await expect(manager.capture(session, filePath)).rejects.toThrow(
          'Unable to capture original file',
        );
        await expect(manager.getContent(session, filePath)).rejects.toThrow(
          'No baseline exists',
        );
      } finally {
        await chmod(filePath, 0o600);
      }
    },
  );

  it('rejects a directory path without recording an empty baseline', async () => {
    const session = createSession();
    const directoryPath = join(workDir, 'not-a-file');
    await mkdir(directoryPath);

    await expect(manager.capture(session, directoryPath)).rejects.toThrow(
      'is not a regular file',
    );
    await expect(manager.getContent(session, directoryPath)).rejects.toThrow(
      'No baseline exists',
    );
  });

  it('throws a clear error when requested baseline content does not exist', async () => {
    await expect(manager.getContent(createSession(), 'missing.ts')).rejects.toThrow(
      'No baseline exists for "missing.ts"',
    );
  });

  it('rejects a relative path that escapes the workspace', async () => {
    await expect(manager.capture(createSession(), '../outside.ts')).rejects.toThrow(
      'is outside workspace',
    );
  });

  it('normalizes Windows drive case for an in-workspace baseline', async () => {
    const session: BaselineSession = { id: 'ses-windows', workDir: 'C:\\Workspace' };
    await manager.capture(session, 'C:\\Workspace\\src\\new.ts');

    await expect(
      manager.getContent(session, 'c:\\WORKSPACE\\SRC\\NEW.ts'),
    ).resolves.toBe('');
  });

  it('rejects a Windows path on another drive', async () => {
    const session: BaselineSession = { id: 'ses-windows', workDir: 'C:\\Workspace' };

    await expect(manager.capture(session, 'D:\\Workspace\\file.ts')).rejects.toThrow(
      'is outside workspace',
    );
  });

  it('normalizes case differences in an in-workspace UNC path', async () => {
    const session: BaselineSession = {
      id: 'ses-unc',
      workDir: '\\\\Server\\Share\\Workspace',
    };
    await manager.capture(session, '\\\\server\\share\\workspace\\src\\new.ts');

    await expect(manager.getContent(session, 'src\\new.ts')).resolves.toBe('');
  });

  it('rejects a UNC path from another share', async () => {
    const session: BaselineSession = {
      id: 'ses-unc',
      workDir: '\\\\Server\\Share\\Workspace',
    };

    await expect(
      manager.capture(session, '\\\\Server\\OtherShare\\Workspace\\file.ts'),
    ).rejects.toThrow('is outside workspace');
  });

  it('removes persisted originals when the session baseline is deleted', async () => {
    const session = createSession();
    const filePath = join(workDir, 'app.ts');
    await writeFile(filePath, 'original', 'utf-8');
    await manager.capture(session, filePath);

    await manager.deleteSession(session.id);

    await expect(manager.getContent(session, filePath)).rejects.toThrow('No baseline exists');
  });
});

function createSession(
  id = 'ses-local',
  metadata?: Readonly<Record<string, unknown>>,
): BaselineSession {
  return { id, workDir, metadata };
}

async function createLegacySession(
  content: string,
  id = 'ses-legacy',
): Promise<{
  session: BaselineSession;
  legacySessionDir: string;
  baselinePath: string;
}> {
  const legacySessionDir = join(root, `legacy-${id}`);
  const baselinePath = join(legacySessionDir, 'baseline', 'app.ts');
  await mkdir(join(legacySessionDir, 'baseline'), { recursive: true });
  await writeFile(baselinePath, content, 'utf-8');
  return {
    session: createSession(id, { kimi_cli_source_path: legacySessionDir }),
    legacySessionDir,
    baselinePath,
  };
}