pi-var 1.0.1

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 (64) hide show
  1. package/.github/FUNDING.yml +4 -0
  2. package/.prettierrc +7 -0
  3. package/README.md +240 -0
  4. package/commitlint.config.cjs +3 -0
  5. package/extensions/pi-var/__tests__/integration/smoke.test.ts +308 -0
  6. package/extensions/pi-var/__tests__/unit/command.test.ts +322 -0
  7. package/extensions/pi-var/__tests__/unit/cow-detection.test.ts +340 -0
  8. package/extensions/pi-var/__tests__/unit/cow-git-merge.test.ts +404 -0
  9. package/extensions/pi-var/__tests__/unit/edr.test.ts +119 -0
  10. package/extensions/pi-var/__tests__/unit/environment.test.ts +369 -0
  11. package/extensions/pi-var/__tests__/unit/file-redirect.test.ts +295 -0
  12. package/extensions/pi-var/__tests__/unit/store.test.ts +489 -0
  13. package/extensions/pi-var/__tests__/unit/tools.test.ts +133 -0
  14. package/extensions/pi-var/__tests__/unit/variations.test.ts +352 -0
  15. package/extensions/pi-var/index.ts +111 -0
  16. package/extensions/pi-var/src/config.ts +30 -0
  17. package/extensions/pi-var/src/cow/clone.ts +46 -0
  18. package/extensions/pi-var/src/cow/detection.ts +215 -0
  19. package/extensions/pi-var/src/cow/index.ts +6 -0
  20. package/extensions/pi-var/src/edr/constants.ts +199 -0
  21. package/extensions/pi-var/src/edr/crowdstrike.ts +62 -0
  22. package/extensions/pi-var/src/edr/detection.ts +97 -0
  23. package/extensions/pi-var/src/edr/index.ts +6 -0
  24. package/extensions/pi-var/src/environment/copy-env.ts +79 -0
  25. package/extensions/pi-var/src/environment/detect.ts +91 -0
  26. package/extensions/pi-var/src/environment/index.ts +54 -0
  27. package/extensions/pi-var/src/environment/marker.ts +42 -0
  28. package/extensions/pi-var/src/environment/symlink.ts +82 -0
  29. package/extensions/pi-var/src/gitignore/manager.ts +77 -0
  30. package/extensions/pi-var/src/state/index.ts +7 -0
  31. package/extensions/pi-var/src/state/keys.ts +14 -0
  32. package/extensions/pi-var/src/state/store.ts +110 -0
  33. package/extensions/pi-var/src/state/types.ts +70 -0
  34. package/extensions/pi-var/src/tools/command/clean.ts +87 -0
  35. package/extensions/pi-var/src/tools/command/index.ts +95 -0
  36. package/extensions/pi-var/src/tools/command/status.ts +58 -0
  37. package/extensions/pi-var/src/tools/command/stop.ts +37 -0
  38. package/extensions/pi-var/src/tools/file-redirect.ts +126 -0
  39. package/extensions/pi-var/src/tools/redirect/bash.ts +75 -0
  40. package/extensions/pi-var/src/tools/redirect/edit.ts +60 -0
  41. package/extensions/pi-var/src/tools/redirect/index.ts +64 -0
  42. package/extensions/pi-var/src/tools/redirect/read.ts +61 -0
  43. package/extensions/pi-var/src/tools/redirect/write.ts +60 -0
  44. package/extensions/pi-var/src/tools/variation/create.ts +108 -0
  45. package/extensions/pi-var/src/tools/variation/merge.ts +80 -0
  46. package/extensions/pi-var/src/types/index.ts +139 -0
  47. package/extensions/pi-var/src/utils/names.ts +120 -0
  48. package/extensions/pi-var/src/variation/create-copy.ts +35 -0
  49. package/extensions/pi-var/src/variation/create-cow.ts +81 -0
  50. package/extensions/pi-var/src/variation/create-worktree.ts +31 -0
  51. package/extensions/pi-var/src/variation/create.ts +127 -0
  52. package/extensions/pi-var/src/variation/index.ts +9 -0
  53. package/extensions/pi-var/src/variation/merge/copy.ts +151 -0
  54. package/extensions/pi-var/src/variation/merge/git.ts +176 -0
  55. package/extensions/pi-var/src/variation/merge/index.ts +36 -0
  56. package/extensions/pi-var/src/variation/merge/rsync.ts +74 -0
  57. package/extensions/pi-var/src/variation/merge/strategy.ts +62 -0
  58. package/extensions/pi-var/src/variation/paths.ts +26 -0
  59. package/extensions/pi-var/src/variation/remove.ts +79 -0
  60. package/npm-shrinkwrap.json +6908 -0
  61. package/package.json +63 -0
  62. package/skills/variations/SKILL.md +182 -0
  63. package/tsconfig.json +25 -0
  64. package/vitest.config.ts +19 -0
@@ -0,0 +1,369 @@
1
+ /**
2
+ * Unit tests for environment utilities
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
6
+ import { promises as fs } from 'fs';
7
+ import path from 'path';
8
+ import os from 'os';
9
+ import crypto from 'crypto';
10
+ import {
11
+ setupVariationEnvironment,
12
+ detectVariationContext,
13
+ detectVariationContextAsync,
14
+ copyEnvFiles,
15
+ symlinkHeavyDirs,
16
+ createVariationMarker,
17
+ removeVariationMarker,
18
+ DEFAULT_CONFIG,
19
+ } from '../../src/environment/index';
20
+
21
+ // Helper to create a temporary test directory
22
+ async function createTempDir(prefix: string): Promise<string> {
23
+ const tmpDir = os.tmpdir();
24
+ const randomId = crypto.randomBytes(4).toString('hex');
25
+ const dir = path.join(tmpDir, `${prefix}-${randomId}`);
26
+ await fs.mkdir(dir, { recursive: true });
27
+ return dir;
28
+ }
29
+
30
+ // Helper to cleanup a directory
31
+ async function cleanupDir(dir: string): Promise<void> {
32
+ try {
33
+ await fs.rm(dir, { recursive: true, force: true });
34
+ } catch {
35
+ // Ignore cleanup errors
36
+ }
37
+ }
38
+
39
+ describe('DEFAULT_CONFIG', () => {
40
+ it('should have copy patterns', () => {
41
+ expect(DEFAULT_CONFIG.copy).toContain('.env');
42
+ expect(DEFAULT_CONFIG.copy).toContain('.env.*');
43
+ expect(DEFAULT_CONFIG.copy).toContain('.envrc');
44
+ expect(DEFAULT_CONFIG.copy).toContain('.npmrc');
45
+ expect(DEFAULT_CONFIG.copy).toContain('.tool-versions');
46
+ });
47
+
48
+ it('should have symlink patterns', () => {
49
+ expect(DEFAULT_CONFIG.symlink).toContain('node_modules');
50
+ expect(DEFAULT_CONFIG.symlink).toContain('.next');
51
+ expect(DEFAULT_CONFIG.symlink).toContain('.nuxt');
52
+ expect(DEFAULT_CONFIG.symlink).toContain('target');
53
+ expect(DEFAULT_CONFIG.symlink).toContain('.venv');
54
+ });
55
+ });
56
+
57
+ describe('detectVariationContext', () => {
58
+ it('should return not in variation for regular directory', () => {
59
+ const context = detectVariationContext('/home/user/project');
60
+
61
+ expect(context.inVariation).toBe(false);
62
+ expect(context.variationId).toBeNull();
63
+ expect(context.variationName).toBeNull();
64
+ expect(context.variationPath).toBeNull();
65
+ expect(context.sourcePath).toBe('/home/user/project');
66
+ });
67
+
68
+ it('should detect variation from .pi/variations path', () => {
69
+ const cwd = '/home/user/project/.pi/variations/myproject-abc123/test-var';
70
+ const context = detectVariationContext(cwd);
71
+
72
+ expect(context.inVariation).toBe(true);
73
+ expect(context.variationName).toBe('test-var');
74
+ expect(context.variationPath).toBe(
75
+ '/home/user/project/.pi/variations/myproject-abc123/test-var'
76
+ );
77
+ expect(context.sourcePath).toBe('/home/user/project');
78
+ expect(context.variationId).toBeDefined();
79
+ });
80
+
81
+ it('should handle paths with backslashes (Windows)', () => {
82
+ const cwd = '\\Users\\user\\project\\.pi\\variations\\myproject-abc123\\test-var';
83
+ const context = detectVariationContext(cwd);
84
+
85
+ expect(context.inVariation).toBe(true);
86
+ expect(context.variationName).toBe('test-var');
87
+ });
88
+
89
+ it('should normalize relative paths', () => {
90
+ const context = detectVariationContext('.');
91
+
92
+ expect(context.sourcePath).toBe(process.cwd());
93
+ });
94
+ });
95
+
96
+ describe('copyEnvFiles', () => {
97
+ let sourceDir: string;
98
+ let variationDir: string;
99
+
100
+ beforeEach(async () => {
101
+ sourceDir = await createTempDir('source');
102
+ variationDir = await createTempDir('variation');
103
+ });
104
+
105
+ afterEach(async () => {
106
+ await cleanupDir(sourceDir);
107
+ await cleanupDir(variationDir);
108
+ });
109
+
110
+ it('should copy .env file', async () => {
111
+ await fs.writeFile(path.join(sourceDir, '.env'), 'KEY=value');
112
+
113
+ await copyEnvFiles(sourceDir, variationDir, ['.env']);
114
+
115
+ const content = await fs.readFile(path.join(variationDir, '.env'), 'utf-8');
116
+ expect(content).toBe('KEY=value');
117
+ });
118
+
119
+ it('should copy files matching glob pattern', async () => {
120
+ await fs.writeFile(path.join(sourceDir, '.env'), 'KEY=base');
121
+ await fs.writeFile(path.join(sourceDir, '.env.local'), 'KEY=local');
122
+ await fs.writeFile(path.join(sourceDir, '.env.production'), 'KEY=prod');
123
+
124
+ await copyEnvFiles(sourceDir, variationDir, ['.env', '.env.*']);
125
+
126
+ const base = await fs.readFile(path.join(variationDir, '.env'), 'utf-8');
127
+ const local = await fs.readFile(path.join(variationDir, '.env.local'), 'utf-8');
128
+ const prod = await fs.readFile(path.join(variationDir, '.env.production'), 'utf-8');
129
+
130
+ expect(base).toBe('KEY=base');
131
+ expect(local).toBe('KEY=local');
132
+ expect(prod).toBe('KEY=prod');
133
+ });
134
+
135
+ it('should skip missing files', async () => {
136
+ // Don't create any files in source
137
+
138
+ await expect(copyEnvFiles(sourceDir, variationDir, ['.env', '.envrc'])).resolves.not.toThrow();
139
+
140
+ // Variation should be empty
141
+ const entries = await fs.readdir(variationDir);
142
+ expect(entries).toHaveLength(0);
143
+ });
144
+
145
+ it('should handle nested directories', async () => {
146
+ await fs.mkdir(path.join(sourceDir, 'config'), { recursive: true });
147
+ await fs.writeFile(path.join(sourceDir, 'config', '.env'), 'NESTED=value');
148
+
149
+ // Only top-level patterns are supported currently
150
+ await copyEnvFiles(sourceDir, variationDir, ['.env']);
151
+
152
+ // File in subdirectory won't be copied with simple pattern
153
+ const entries = await fs.readdir(variationDir);
154
+ expect(entries).not.toContain('config');
155
+ });
156
+ });
157
+
158
+ describe('symlinkHeavyDirs', () => {
159
+ let sourceDir: string;
160
+ let variationDir: string;
161
+
162
+ beforeEach(async () => {
163
+ sourceDir = await createTempDir('source');
164
+ variationDir = await createTempDir('variation');
165
+ });
166
+
167
+ afterEach(async () => {
168
+ await cleanupDir(sourceDir);
169
+ await cleanupDir(variationDir);
170
+ });
171
+
172
+ it('should create symlinks for existing directories', async () => {
173
+ // Create source directories
174
+ const nodeModulesSrc = path.join(sourceDir, 'node_modules');
175
+ await fs.mkdir(nodeModulesSrc, { recursive: true });
176
+ await fs.writeFile(path.join(nodeModulesSrc, 'package.json'), '{}');
177
+
178
+ const nextSrc = path.join(sourceDir, '.next');
179
+ await fs.mkdir(nextSrc, { recursive: true });
180
+
181
+ await symlinkHeavyDirs(sourceDir, variationDir, ['node_modules', '.next']);
182
+
183
+ // Check symlinks were created
184
+ const nodeModulesDest = path.join(variationDir, 'node_modules');
185
+ const nextDest = path.join(variationDir, '.next');
186
+
187
+ const nmStat = await fs.lstat(nodeModulesDest);
188
+ const nextStat = await fs.lstat(nextDest);
189
+
190
+ expect(nmStat.isSymbolicLink()).toBe(true);
191
+ expect(nextStat.isSymbolicLink()).toBe(true);
192
+
193
+ // Verify we can read through symlink
194
+ const pkg = await fs.readFile(path.join(nodeModulesDest, 'package.json'), 'utf-8');
195
+ expect(pkg).toBe('{}');
196
+ });
197
+
198
+ it('should skip non-existent directories', async () => {
199
+ // Don't create node_modules in source
200
+
201
+ await expect(
202
+ symlinkHeavyDirs(sourceDir, variationDir, ['node_modules'])
203
+ ).resolves.not.toThrow();
204
+
205
+ // Variation should be empty
206
+ const entries = await fs.readdir(variationDir);
207
+ expect(entries).toHaveLength(0);
208
+ });
209
+
210
+ it('should replace existing directories with symlinks', async () => {
211
+ // Create source directory
212
+ const nodeModulesSrc = path.join(sourceDir, 'node_modules');
213
+ await fs.mkdir(nodeModulesSrc, { recursive: true });
214
+ await fs.writeFile(path.join(nodeModulesSrc, 'original.json'), '{}');
215
+
216
+ // Create a directory (not symlink) in variation
217
+ const nodeModulesDest = path.join(variationDir, 'node_modules');
218
+ await fs.mkdir(nodeModulesDest, { recursive: true });
219
+ await fs.writeFile(path.join(nodeModulesDest, 'copied.json'), '{}');
220
+
221
+ await symlinkHeavyDirs(sourceDir, variationDir, ['node_modules']);
222
+
223
+ // Should now be a symlink pointing to source
224
+ const stat = await fs.lstat(nodeModulesDest);
225
+ expect(stat.isSymbolicLink()).toBe(true);
226
+
227
+ // Content should come from source
228
+ const content = await fs.readFile(path.join(nodeModulesDest, 'original.json'), 'utf-8');
229
+ expect(content).toBe('{}');
230
+ });
231
+ });
232
+
233
+ describe('createVariationMarker / removeVariationMarker', () => {
234
+ let variationDir: string;
235
+
236
+ beforeEach(async () => {
237
+ variationDir = await createTempDir('variation');
238
+ });
239
+
240
+ afterEach(async () => {
241
+ await cleanupDir(variationDir);
242
+ });
243
+
244
+ it('should create marker file with variation info', async () => {
245
+ const info = {
246
+ id: 'var-test-123',
247
+ name: 'test-variation',
248
+ sourcePath: '/home/user/project',
249
+ type: 'cow',
250
+ };
251
+
252
+ await createVariationMarker(variationDir, info);
253
+
254
+ const markerPath = path.join(variationDir, '.pi-variation');
255
+ const content = await fs.readFile(markerPath, 'utf-8');
256
+ const parsed = JSON.parse(content);
257
+
258
+ expect(parsed.id).toBe(info.id);
259
+ expect(parsed.variationName).toBe(info.name);
260
+ expect(parsed.sourcePath).toBe(info.sourcePath);
261
+ expect(parsed.type).toBe(info.type);
262
+ expect(parsed.createdAt).toBeDefined();
263
+ });
264
+
265
+ it('should remove marker file', async () => {
266
+ await createVariationMarker(variationDir, {
267
+ id: 'var-test',
268
+ name: 'test',
269
+ sourcePath: '/source',
270
+ type: 'copy',
271
+ });
272
+
273
+ const markerPath = path.join(variationDir, '.pi-variation');
274
+ await fs.access(markerPath); // Should exist
275
+
276
+ await removeVariationMarker(variationDir);
277
+
278
+ await expect(fs.access(markerPath)).rejects.toThrow();
279
+ });
280
+
281
+ it('should not throw when removing non-existent marker', async () => {
282
+ await expect(removeVariationMarker(variationDir)).resolves.not.toThrow();
283
+ });
284
+ });
285
+
286
+ describe('detectVariationContextAsync with marker file', () => {
287
+ let variationDir: string;
288
+
289
+ beforeEach(async () => {
290
+ variationDir = await createTempDir('variation');
291
+ });
292
+
293
+ afterEach(async () => {
294
+ await cleanupDir(variationDir);
295
+ });
296
+
297
+ it('should detect variation from marker file', async () => {
298
+ await createVariationMarker(variationDir, {
299
+ id: 'var-marker-123',
300
+ name: 'marker-test',
301
+ sourcePath: '/home/user/source',
302
+ type: 'worktree',
303
+ });
304
+
305
+ const context = await detectVariationContextAsync(variationDir);
306
+
307
+ expect(context.inVariation).toBe(true);
308
+ expect(context.variationId).toBe('var-marker-123');
309
+ expect(context.variationName).toBe('marker-test');
310
+ expect(context.sourcePath).toBe('/home/user/source');
311
+ expect(context.variationPath).toBe(variationDir);
312
+ });
313
+ });
314
+
315
+ describe('setupVariationEnvironment', () => {
316
+ let sourceDir: string;
317
+ let variationDir: string;
318
+
319
+ beforeEach(async () => {
320
+ sourceDir = await createTempDir('source');
321
+ variationDir = await createTempDir('variation');
322
+
323
+ // Create typical project structure in source
324
+ await fs.writeFile(path.join(sourceDir, '.env'), 'API_KEY=secret');
325
+ await fs.writeFile(path.join(sourceDir, '.env.local'), 'LOCAL=true');
326
+ await fs.mkdir(path.join(sourceDir, 'node_modules'), { recursive: true });
327
+ await fs.writeFile(path.join(sourceDir, 'node_modules', 'package.json'), '{"name": "test"}');
328
+ await fs.mkdir(path.join(sourceDir, '.next'), { recursive: true });
329
+ });
330
+
331
+ afterEach(async () => {
332
+ await cleanupDir(sourceDir);
333
+ await cleanupDir(variationDir);
334
+ });
335
+
336
+ it('should setup complete environment', async () => {
337
+ await setupVariationEnvironment(sourceDir, variationDir, DEFAULT_CONFIG);
338
+
339
+ // Check env files were copied
340
+ const env = await fs.readFile(path.join(variationDir, '.env'), 'utf-8');
341
+ const envLocal = await fs.readFile(path.join(variationDir, '.env.local'), 'utf-8');
342
+ expect(env).toBe('API_KEY=secret');
343
+ expect(envLocal).toBe('LOCAL=true');
344
+
345
+ // Check symlinks were created
346
+ const nmStat = await fs.lstat(path.join(variationDir, 'node_modules'));
347
+ const nextStat = await fs.lstat(path.join(variationDir, '.next'));
348
+ expect(nmStat.isSymbolicLink()).toBe(true);
349
+ expect(nextStat.isSymbolicLink()).toBe(true);
350
+ });
351
+
352
+ it('should respect custom config', async () => {
353
+ const customConfig = {
354
+ copy: ['.env'],
355
+ symlink: ['node_modules'],
356
+ };
357
+
358
+ await setupVariationEnvironment(sourceDir, variationDir, customConfig);
359
+
360
+ // Only .env copied
361
+ await fs.access(path.join(variationDir, '.env'));
362
+ await expect(fs.access(path.join(variationDir, '.env.local'))).rejects.toThrow();
363
+
364
+ // Only node_modules symlinked
365
+ const nmStat = await fs.lstat(path.join(variationDir, 'node_modules'));
366
+ expect(nmStat.isSymbolicLink()).toBe(true);
367
+ await expect(fs.lstat(path.join(variationDir, '.next'))).rejects.toThrow();
368
+ });
369
+ });
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Unit tests for file-redirect module
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach } from 'vitest';
6
+ import path from 'node:path';
7
+ import type { VarState, VarRuntime, Variation } from '../../src/types/index';
8
+ import {
9
+ resolveVariationPath,
10
+ createRedirectedReadOps,
11
+ createRedirectedWriteOps,
12
+ createRedirectedEditOps,
13
+ } from '../../src/tools/file-redirect';
14
+
15
+ // Mock runtime factory
16
+ function createMockRuntime(overrides: Partial<VarRuntime> = {}): VarRuntime {
17
+ return {
18
+ state: {
19
+ activeVariationId: null,
20
+ variations: [],
21
+ sessionId: 'test-session',
22
+ },
23
+
24
+ lastPersisted: Date.now(),
25
+ ...overrides,
26
+ };
27
+ }
28
+
29
+ // Mock variation factory
30
+ function createMockVariation(overrides: Partial<Variation> = {}): Variation {
31
+ return {
32
+ id: 'var-123',
33
+ name: 'test-variation',
34
+ path: '/tmp/variations/test-var',
35
+ sourcePath: '/home/user/project',
36
+ type: 'cow',
37
+ createdAt: new Date().toISOString(),
38
+ lastAccessed: new Date().toISOString(),
39
+ ...overrides,
40
+ };
41
+ }
42
+
43
+ describe('resolveVariationPath', () => {
44
+ const cwd = '/home/user/project';
45
+ let state: VarState;
46
+
47
+ beforeEach(() => {
48
+ state = {
49
+ activeVariationId: null,
50
+ variations: [],
51
+ sessionId: 'test-session',
52
+ };
53
+ });
54
+
55
+ describe('when no variation is active', () => {
56
+ it('returns relative paths unchanged', () => {
57
+ const inputPath = 'src/components/Button';
58
+ const result = resolveVariationPath(inputPath, cwd, state);
59
+ expect(result).toBe(inputPath);
60
+ });
61
+
62
+ it('returns absolute paths unchanged', () => {
63
+ const inputPath = '/home/user/project/src/Button';
64
+ const result = resolveVariationPath(inputPath, cwd, state);
65
+ expect(result).toBe(inputPath);
66
+ });
67
+ });
68
+
69
+ describe('when a variation is active', () => {
70
+ beforeEach(() => {
71
+ const variation = createMockVariation();
72
+ state.activeVariationId = variation.id;
73
+ state.variations = [variation];
74
+ });
75
+
76
+ describe('relative paths', () => {
77
+ it('resolves relative paths against variation path', () => {
78
+ const inputPath = 'src/components/Button';
79
+ const result = resolveVariationPath(inputPath, cwd, state);
80
+ expect(result).toBe('/tmp/variations/test-var/src/components/Button');
81
+ });
82
+
83
+ it('handles dot-relative paths', () => {
84
+ const inputPath = './README.md';
85
+ const result = resolveVariationPath(inputPath, cwd, state);
86
+ // path.normalize will clean up ./
87
+ expect(result).toBe('/tmp/variations/test-var/README.md');
88
+ });
89
+
90
+ it('handles parent-relative paths', () => {
91
+ const inputPath = '../shared/utils';
92
+ const result = resolveVariationPath(inputPath, cwd, state);
93
+ // path.normalize will resolve ../
94
+ expect(result).toBe('/tmp/variations/shared/utils');
95
+ });
96
+ });
97
+
98
+ describe('absolute paths in source directory', () => {
99
+ it('redirects source file paths to variation', () => {
100
+ const inputPath = '/home/user/project/src/Button';
101
+ const result = resolveVariationPath(inputPath, cwd, state);
102
+ expect(result).toBe('/tmp/variations/test-var/src/Button');
103
+ });
104
+
105
+ it('redirects source root files', () => {
106
+ const inputPath = '/home/user/project/package.json';
107
+ const result = resolveVariationPath(inputPath, cwd, state);
108
+ expect(result).toBe('/tmp/variations/test-var/package.json');
109
+ });
110
+
111
+ it('handles paths in subdirectories', () => {
112
+ const inputPath = '/home/user/project/src/components/nested/DeepComponent';
113
+ const result = resolveVariationPath(inputPath, cwd, state);
114
+ expect(result).toBe('/tmp/variations/test-var/src/components/nested/DeepComponent');
115
+ });
116
+ });
117
+
118
+ describe('absolute paths already in variation', () => {
119
+ it('keeps variation paths as-is', () => {
120
+ const inputPath = '/tmp/variations/test-var/src/Button';
121
+ const result = resolveVariationPath(inputPath, cwd, state);
122
+ expect(result).toBe(inputPath);
123
+ });
124
+
125
+ it('keeps variation root files as-is', () => {
126
+ const inputPath = '/tmp/variations/test-var/package.json';
127
+ const result = resolveVariationPath(inputPath, cwd, state);
128
+ expect(result).toBe(inputPath);
129
+ });
130
+ });
131
+
132
+ describe('external paths (outside source and variation)', () => {
133
+ it('keeps external paths unchanged', () => {
134
+ const inputPath = '/etc/config/nginx.conf';
135
+ const result = resolveVariationPath(inputPath, cwd, state);
136
+ expect(result).toBe(inputPath);
137
+ });
138
+
139
+ it('keeps system paths unchanged', () => {
140
+ const inputPath = '/usr/local/bin/script.sh';
141
+ const result = resolveVariationPath(inputPath, cwd, state);
142
+ expect(result).toBe(inputPath);
143
+ });
144
+
145
+ it('keeps other user directories unchanged', () => {
146
+ const inputPath = '/home/otheruser/documents/file.txt';
147
+ const result = resolveVariationPath(inputPath, cwd, state);
148
+ expect(result).toBe(inputPath);
149
+ });
150
+ });
151
+
152
+ describe('edge cases', () => {
153
+ it('handles paths with trailing slashes', () => {
154
+ const inputPath = '/home/user/project/src/';
155
+ const result = resolveVariationPath(inputPath, cwd, state);
156
+ // path.join normalizes trailing slashes
157
+ expect(result).toBe('/tmp/variations/test-var/src');
158
+ });
159
+
160
+ it('handles paths with spaces', () => {
161
+ const inputPath = '/home/user/project/src/my file';
162
+ const result = resolveVariationPath(inputPath, cwd, state);
163
+ expect(result).toBe('/tmp/variations/test-var/src/my file');
164
+ });
165
+
166
+ it('handles paths with special characters', () => {
167
+ const inputPath = '/home/user/project/src/file[name]';
168
+ const result = resolveVariationPath(inputPath, cwd, state);
169
+ expect(result).toBe('/tmp/variations/test-var/src/file[name]');
170
+ });
171
+
172
+ it('handles empty string paths', () => {
173
+ const inputPath = '';
174
+ const result = resolveVariationPath(inputPath, cwd, state);
175
+ // path.join(variation.path, '') returns variation.path (normalized)
176
+ expect(result).toBe('/tmp/variations/test-var');
177
+ });
178
+ });
179
+ });
180
+
181
+ describe('when active variation is not found', () => {
182
+ it('returns path unchanged if variation id references non-existent variation', () => {
183
+ state.activeVariationId = 'non-existent-id';
184
+ state.variations = []; // Empty variations array
185
+
186
+ const inputPath = 'src/Button';
187
+ const result = resolveVariationPath(inputPath, cwd, state);
188
+ expect(result).toBe(inputPath);
189
+ });
190
+ });
191
+ });
192
+
193
+ describe('createRedirectedReadOps', () => {
194
+ const cwd = '/home/user/project';
195
+
196
+ it('creates read operations object with required methods', () => {
197
+ const runtime = createMockRuntime();
198
+ const ops = createRedirectedReadOps(cwd, runtime);
199
+
200
+ expect(ops).toHaveProperty('readFile');
201
+ expect(ops).toHaveProperty('access');
202
+ expect(ops).toHaveProperty('detectImageMimeType');
203
+ expect(typeof ops.readFile).toBe('function');
204
+ expect(typeof ops.access).toBe('function');
205
+ expect(typeof ops.detectImageMimeType).toBe('function');
206
+ });
207
+
208
+ it('detectImageMimeType returns correct MIME types', async () => {
209
+ const runtime = createMockRuntime();
210
+ const ops = createRedirectedReadOps(cwd, runtime);
211
+
212
+ // These will fail with ENOENT but we can still test the MIME type detection logic
213
+ // by checking the method exists and returns the expected values based on extension
214
+
215
+ // Since we can't easily mock fs.readFile without more setup, we verify
216
+ // the operations object structure is correct
217
+ expect(ops.detectImageMimeType).toBeDefined();
218
+ });
219
+ });
220
+
221
+ describe('createRedirectedWriteOps', () => {
222
+ const cwd = '/home/user/project';
223
+
224
+ it('creates write operations object with required methods', () => {
225
+ const runtime = createMockRuntime();
226
+ const ops = createRedirectedWriteOps(cwd, runtime);
227
+
228
+ expect(ops).toHaveProperty('writeFile');
229
+ expect(ops).toHaveProperty('mkdir');
230
+ expect(typeof ops.writeFile).toBe('function');
231
+ expect(typeof ops.mkdir).toBe('function');
232
+ });
233
+ });
234
+
235
+ describe('createRedirectedEditOps', () => {
236
+ const cwd = '/home/user/project';
237
+
238
+ it('creates edit operations object with required methods', () => {
239
+ const runtime = createMockRuntime();
240
+ const ops = createRedirectedEditOps(cwd, runtime);
241
+
242
+ expect(ops).toHaveProperty('readFile');
243
+ expect(ops).toHaveProperty('access');
244
+ expect(ops).toHaveProperty('writeFile');
245
+ expect(typeof ops.readFile).toBe('function');
246
+ expect(typeof ops.access).toBe('function');
247
+ expect(typeof ops.writeFile).toBe('function');
248
+ });
249
+
250
+ it('uses redirected read and write operations internally', async () => {
251
+ // This test verifies that editOps combines read and write ops correctly
252
+ const runtime = createMockRuntime();
253
+ const editOps = createRedirectedEditOps(cwd, runtime);
254
+
255
+ // The edit operations should have the same methods as read and write combined
256
+ expect(Object.keys(editOps)).toEqual(
257
+ expect.arrayContaining(['readFile', 'access', 'writeFile'])
258
+ );
259
+ });
260
+ });
261
+
262
+ describe('integration: path resolution with operations', () => {
263
+ const cwd = '/home/user/project';
264
+ const variation = createMockVariation();
265
+
266
+ it('readOps resolves paths using variation when active', async () => {
267
+ const runtime = createMockRuntime({
268
+ state: {
269
+ activeVariationId: variation.id,
270
+ variations: [variation],
271
+ sessionId: 'test-session',
272
+ },
273
+ });
274
+
275
+ // We can't actually read without mocking fs, but we can verify
276
+ // the operations are created with the correct runtime context
277
+ const readOps = createRedirectedReadOps(cwd, runtime);
278
+ expect(readOps).toBeDefined();
279
+ expect(typeof readOps.readFile).toBe('function');
280
+ });
281
+
282
+ it('writeOps resolves paths using variation when active', async () => {
283
+ const runtime = createMockRuntime({
284
+ state: {
285
+ activeVariationId: variation.id,
286
+ variations: [variation],
287
+ sessionId: 'test-session',
288
+ },
289
+ });
290
+
291
+ const writeOps = createRedirectedWriteOps(cwd, runtime);
292
+ expect(writeOps).toBeDefined();
293
+ expect(typeof writeOps.writeFile).toBe('function');
294
+ });
295
+ });