runwork 0.2.0 → 0.2.5

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,377 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { execFileSync } from 'child_process';
3
+ import { mkdtempSync, writeFileSync, rmSync, existsSync, mkdirSync, symlinkSync, } from 'fs';
4
+ import { join } from 'path';
5
+ import { tmpdir } from 'os';
6
+ import { generateManifest, saveManifest, loadManifest, detectUserEdits, } from '../../template/manifest.js';
7
+ const tempDirs = [];
8
+ function makeTempDir(prefix) {
9
+ const dir = mkdtempSync(join(tmpdir(), `runwork-manifest-test-${prefix}-`));
10
+ tempDirs.push(dir);
11
+ return dir;
12
+ }
13
+ function initGitRepo(dir) {
14
+ execFileSync('git', ['init', dir]);
15
+ execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir });
16
+ execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir });
17
+ }
18
+ function commitFile(repoDir, filename, content, message) {
19
+ const fullPath = join(repoDir, filename);
20
+ const dir = fullPath.substring(0, fullPath.lastIndexOf('/'));
21
+ if (dir !== repoDir && !existsSync(dir)) {
22
+ mkdirSync(dir, { recursive: true });
23
+ }
24
+ writeFileSync(fullPath, content);
25
+ execFileSync('git', ['add', filename], { cwd: repoDir });
26
+ execFileSync('git', ['commit', '-m', message], { cwd: repoDir });
27
+ }
28
+ afterEach(() => {
29
+ for (const dir of tempDirs) {
30
+ try {
31
+ rmSync(dir, { recursive: true, force: true });
32
+ }
33
+ catch { /* best effort */ }
34
+ }
35
+ tempDirs.length = 0;
36
+ });
37
+ describe('generateManifest()', () => {
38
+ it('generates manifest for a simple directory', async () => {
39
+ const dir = makeTempDir('simple');
40
+ writeFileSync(join(dir, 'a.txt'), 'hello');
41
+ writeFileSync(join(dir, 'b.txt'), 'world');
42
+ const manifest = await generateManifest(dir);
43
+ expect(manifest.version).toBe(1);
44
+ expect(Object.keys(manifest.files)).toHaveLength(2);
45
+ expect(manifest.files['a.txt']).toMatch(/^sha256:/);
46
+ expect(manifest.files['b.txt']).toMatch(/^sha256:/);
47
+ });
48
+ it('skips .git, node_modules, and .runwork directories', async () => {
49
+ const dir = makeTempDir('skip');
50
+ writeFileSync(join(dir, 'a.txt'), 'hello');
51
+ mkdirSync(join(dir, '.git'));
52
+ writeFileSync(join(dir, '.git', 'config'), 'git config');
53
+ mkdirSync(join(dir, 'node_modules'));
54
+ writeFileSync(join(dir, 'node_modules', 'pkg.js'), 'module');
55
+ mkdirSync(join(dir, '.runwork'));
56
+ writeFileSync(join(dir, '.runwork', 'manifest.json'), '{}');
57
+ const manifest = await generateManifest(dir);
58
+ expect(Object.keys(manifest.files)).toHaveLength(1);
59
+ expect(manifest.files['a.txt']).toBeDefined();
60
+ });
61
+ it('skips .dev.vars and .env files', async () => {
62
+ const dir = makeTempDir('skipfiles');
63
+ writeFileSync(join(dir, 'a.txt'), 'hello');
64
+ writeFileSync(join(dir, '.dev.vars'), 'SECRET=123');
65
+ writeFileSync(join(dir, '.env'), 'OTHER=456');
66
+ const manifest = await generateManifest(dir);
67
+ expect(Object.keys(manifest.files)).toHaveLength(1);
68
+ expect(manifest.files['a.txt']).toBeDefined();
69
+ });
70
+ it('handles files with spaces in names', async () => {
71
+ const dir = makeTempDir('spaces');
72
+ writeFileSync(join(dir, 'hello world.txt'), 'content');
73
+ writeFileSync(join(dir, 'normal.txt'), 'content');
74
+ const manifest = await generateManifest(dir);
75
+ expect(Object.keys(manifest.files)).toHaveLength(2);
76
+ expect(manifest.files['hello world.txt']).toBeDefined();
77
+ });
78
+ it('handles nested directories', async () => {
79
+ const dir = makeTempDir('nested');
80
+ mkdirSync(join(dir, 'src', 'components'), { recursive: true });
81
+ writeFileSync(join(dir, 'src', 'index.ts'), 'export {}');
82
+ writeFileSync(join(dir, 'src', 'components', 'App.tsx'), '<App/>');
83
+ const manifest = await generateManifest(dir);
84
+ expect(manifest.files['src/index.ts']).toBeDefined();
85
+ expect(manifest.files['src/components/App.tsx']).toBeDefined();
86
+ });
87
+ });
88
+ describe('walkDir() symlink edge cases', () => {
89
+ it('BUG PROBE: symlink cycle causes infinite recursion in walkDir/generateManifest', async () => {
90
+ // walkDir uses readdirSync with withFileTypes: true.
91
+ // Dirent.isDirectory() follows symlinks, so a symlink pointing
92
+ // to a parent directory will cause infinite recursion.
93
+ const dir = makeTempDir('symlink-cycle');
94
+ writeFileSync(join(dir, 'a.txt'), 'hello');
95
+ mkdirSync(join(dir, 'subdir'));
96
+ writeFileSync(join(dir, 'subdir', 'b.txt'), 'world');
97
+ // Create a symlink cycle: subdir/link -> parent dir
98
+ symlinkSync(dir, join(dir, 'subdir', 'link'));
99
+ // This should not hang or crash, but walkDir follows symlinks
100
+ // via isDirectory() which returns true for symlink-to-directory.
101
+ // Expected: either it handles symlinks gracefully, or it crashes
102
+ // with max call stack exceeded.
103
+ let threw = false;
104
+ let errorMessage = '';
105
+ try {
106
+ // Set a timeout to detect infinite loops - if this takes too long, it's a bug
107
+ await generateManifest(dir);
108
+ }
109
+ catch (error) {
110
+ threw = true;
111
+ errorMessage = error instanceof Error ? error.message : String(error);
112
+ }
113
+ // If we get here without hanging, check if it threw
114
+ // The bug is that walkDir WILL follow the symlink and recurse infinitely
115
+ // until it hits ENAMETOOLONG or max call stack.
116
+ // We expect this to either throw or produce duplicate entries.
117
+ // If it doesn't throw, it somehow handled it (unlikely).
118
+ if (threw) {
119
+ // Bug confirmed: walkDir crashes on symlink cycles
120
+ expect(errorMessage).toBeTruthy();
121
+ }
122
+ else {
123
+ // If it somehow succeeds, check that it didn't include
124
+ // infinitely nested paths
125
+ expect(true).toBe(true); // pass - it handled it somehow
126
+ }
127
+ });
128
+ it('symlinked directories are silently skipped by walkDir (not followed)', async () => {
129
+ // Node.js readdirSync withFileTypes: Dirent.isDirectory() returns false for symlinks.
130
+ // So walkDir silently skips symlinked directories entirely.
131
+ // This is safe (no infinite recursion) but means symlinked content is invisible.
132
+ const dir = makeTempDir('symlink-dir');
133
+ const targetDir = makeTempDir('symlink-target');
134
+ writeFileSync(join(targetDir, 'external.txt'), 'external content');
135
+ writeFileSync(join(dir, 'local.txt'), 'local');
136
+ symlinkSync(targetDir, join(dir, 'linked'));
137
+ const manifest = await generateManifest(dir);
138
+ const keys = Object.keys(manifest.files);
139
+ const hasLinkedFile = keys.some(k => k.includes('linked'));
140
+ // Symlinked directories are NOT followed -- content is invisible
141
+ expect(hasLinkedFile).toBe(false);
142
+ expect(keys).toEqual(['local.txt']);
143
+ });
144
+ });
145
+ describe('saveManifest() and loadManifest()', () => {
146
+ it('creates .runwork directory if it does not exist', async () => {
147
+ const dir = makeTempDir('norunwork');
148
+ const manifest = { version: 1, files: { 'a.txt': 'sha256:abc' } };
149
+ expect(existsSync(join(dir, '.runwork'))).toBe(false);
150
+ await saveManifest(dir, manifest);
151
+ expect(existsSync(join(dir, '.runwork', 'template-manifest.json'))).toBe(true);
152
+ const loaded = await loadManifest(dir);
153
+ expect(loaded).toEqual(manifest);
154
+ });
155
+ it('returns null when .runwork directory does not exist', async () => {
156
+ const dir = makeTempDir('nomanifest');
157
+ const result = await loadManifest(dir);
158
+ expect(result).toBeNull();
159
+ });
160
+ it('returns null for corrupted manifest file', async () => {
161
+ const dir = makeTempDir('corrupt');
162
+ mkdirSync(join(dir, '.runwork'));
163
+ writeFileSync(join(dir, '.runwork', 'template-manifest.json'), 'not json{{{');
164
+ const result = await loadManifest(dir);
165
+ expect(result).toBeNull();
166
+ });
167
+ });
168
+ describe('detectUserEdits() edge cases', () => {
169
+ it('returns empty array when git diff and ls-files return nothing', async () => {
170
+ // Edge case: empty strings from git commands.
171
+ // "".split('\n') returns [''] (array with one empty string).
172
+ // The code should handle this gracefully.
173
+ const dir = makeTempDir('clean');
174
+ initGitRepo(dir);
175
+ const content = 'hello';
176
+ writeFileSync(join(dir, 'a.txt'), content);
177
+ execFileSync('git', ['add', 'a.txt'], { cwd: dir });
178
+ execFileSync('git', ['commit', '-m', 'init'], { cwd: dir });
179
+ // Use correct hash so manifest scan doesn't flag it as modified
180
+ const { createHash } = await import('crypto');
181
+ const hash = 'sha256:' + createHash('sha256').update(Buffer.from(content)).digest('hex');
182
+ const manifest = {
183
+ version: 1,
184
+ files: { 'a.txt': hash },
185
+ };
186
+ const edits = await detectUserEdits(dir, manifest);
187
+ expect(edits).toEqual([]);
188
+ });
189
+ it('BUG PROBE: files with spaces in names from git diff output', async () => {
190
+ // git diff --name-only returns filenames with spaces as-is.
191
+ // The code splits by \n and processes each line.
192
+ // This should work unless there's quoting involved.
193
+ const dir = makeTempDir('spaces');
194
+ initGitRepo(dir);
195
+ writeFileSync(join(dir, 'hello world.txt'), 'original');
196
+ execFileSync('git', ['add', 'hello world.txt'], { cwd: dir });
197
+ execFileSync('git', ['commit', '-m', 'init'], { cwd: dir });
198
+ // Modify the file with spaces
199
+ writeFileSync(join(dir, 'hello world.txt'), 'modified');
200
+ const manifest = { version: 1, files: {} };
201
+ const edits = await detectUserEdits(dir, manifest);
202
+ // Should contain the file with spaces
203
+ expect(edits).toContain('hello world.txt');
204
+ });
205
+ it('returns correct unicode filenames (not octal-escaped)', async () => {
206
+ const dir = makeTempDir('unicode');
207
+ initGitRepo(dir);
208
+ const filename = 'caf\u00e9.txt';
209
+ writeFileSync(join(dir, filename), 'original');
210
+ execFileSync('git', ['add', filename], { cwd: dir });
211
+ execFileSync('git', ['commit', '-m', 'init'], { cwd: dir });
212
+ writeFileSync(join(dir, filename), 'modified');
213
+ const manifest = { version: 1, files: {} };
214
+ const edits = await detectUserEdits(dir, manifest);
215
+ expect(edits).toContain(filename);
216
+ // Should NOT contain octal-escaped garbage
217
+ expect(edits.some(e => e.includes('\\303'))).toBe(false);
218
+ });
219
+ it('BUG PROBE: file deleted by user that was in manifest (untracked section)', async () => {
220
+ // If a user deletes a file that was in the manifest, it won't
221
+ // appear in git ls-files --others (because it doesn't exist on disk).
222
+ // It also won't appear in git diff --name-only HEAD because it was
223
+ // never committed. So detectUserEdits will miss it entirely.
224
+ // This is arguably correct behavior (delete = no edit to detect),
225
+ // but worth documenting.
226
+ const dir = makeTempDir('deleted');
227
+ initGitRepo(dir);
228
+ writeFileSync(join(dir, 'committed.txt'), 'hello');
229
+ execFileSync('git', ['add', '.'], { cwd: dir });
230
+ execFileSync('git', ['commit', '-m', 'init'], { cwd: dir });
231
+ // Template manifest includes both a committed file and a template file
232
+ const manifest = {
233
+ version: 1,
234
+ files: {
235
+ 'committed.txt': 'sha256:abc',
236
+ 'template-only.txt': 'sha256:def',
237
+ },
238
+ };
239
+ // template-only.txt never existed on disk (or was deleted)
240
+ // detectUserEdits should not crash
241
+ const edits = await detectUserEdits(dir, manifest);
242
+ // No crash is the main assertion
243
+ expect(Array.isArray(edits)).toBe(true);
244
+ });
245
+ it('detects untracked files that are not in the manifest', async () => {
246
+ const dir = makeTempDir('newfile');
247
+ initGitRepo(dir);
248
+ writeFileSync(join(dir, 'a.txt'), 'hello');
249
+ execFileSync('git', ['add', '.'], { cwd: dir });
250
+ execFileSync('git', ['commit', '-m', 'init'], { cwd: dir });
251
+ // User creates a new file
252
+ writeFileSync(join(dir, 'user-created.txt'), 'my new file');
253
+ const manifest = {
254
+ version: 1,
255
+ files: { 'a.txt': 'sha256:abc' },
256
+ };
257
+ const edits = await detectUserEdits(dir, manifest);
258
+ expect(edits).toContain('user-created.txt');
259
+ });
260
+ it('skips untracked file that matches template manifest hash (pristine template file)', async () => {
261
+ const dir = makeTempDir('pristine');
262
+ initGitRepo(dir);
263
+ writeFileSync(join(dir, 'committed.txt'), 'hello');
264
+ execFileSync('git', ['add', '.'], { cwd: dir });
265
+ execFileSync('git', ['commit', '-m', 'init'], { cwd: dir });
266
+ // Create an untracked file that matches the template hash exactly
267
+ const content = 'template content';
268
+ writeFileSync(join(dir, 'template-file.txt'), content);
269
+ // Generate the correct hash for this content
270
+ const { createHash } = await import('crypto');
271
+ const hash = 'sha256:' + createHash('sha256').update(Buffer.from(content)).digest('hex');
272
+ const manifest = {
273
+ version: 1,
274
+ files: {
275
+ 'committed.txt': 'sha256:abc',
276
+ 'template-file.txt': hash,
277
+ },
278
+ };
279
+ const edits = await detectUserEdits(dir, manifest);
280
+ // Pristine template file should NOT be in edits
281
+ expect(edits).not.toContain('template-file.txt');
282
+ });
283
+ it('BUG PROBE: detects edit when untracked file has different content than manifest hash', async () => {
284
+ const dir = makeTempDir('modified-template');
285
+ initGitRepo(dir);
286
+ writeFileSync(join(dir, 'committed.txt'), 'hello');
287
+ execFileSync('git', ['add', '.'], { cwd: dir });
288
+ execFileSync('git', ['commit', '-m', 'init'], { cwd: dir });
289
+ // Create an untracked file with content that differs from manifest
290
+ writeFileSync(join(dir, 'template-file.txt'), 'user modified this');
291
+ const manifest = {
292
+ version: 1,
293
+ files: {
294
+ 'committed.txt': 'sha256:abc',
295
+ 'template-file.txt': 'sha256:different_hash_from_template',
296
+ },
297
+ };
298
+ const edits = await detectUserEdits(dir, manifest);
299
+ // Modified template file SHOULD be in edits
300
+ expect(edits).toContain('template-file.txt');
301
+ });
302
+ it('does not duplicate files that appear in both git diff and git diff --cached', async () => {
303
+ const dir = makeTempDir('dedup');
304
+ initGitRepo(dir);
305
+ writeFileSync(join(dir, 'a.txt'), 'original');
306
+ execFileSync('git', ['add', '.'], { cwd: dir });
307
+ execFileSync('git', ['commit', '-m', 'init'], { cwd: dir });
308
+ // Stage a change AND have unstaged changes to same file
309
+ writeFileSync(join(dir, 'a.txt'), 'staged change');
310
+ execFileSync('git', ['add', 'a.txt'], { cwd: dir });
311
+ writeFileSync(join(dir, 'a.txt'), 'unstaged further change');
312
+ const manifest = { version: 1, files: {} };
313
+ const edits = await detectUserEdits(dir, manifest);
314
+ // File should appear only once even though it's in both diff outputs
315
+ const count = edits.filter(f => f === 'a.txt').length;
316
+ expect(count).toBe(1);
317
+ });
318
+ it('BUG PROBE: git diff --name-only HEAD with no commits yet', async () => {
319
+ // When repo has no commits, git rev-parse HEAD fails.
320
+ // The code wraps this in try/catch, so it should skip the git diff section.
321
+ // But what about git ls-files --others?
322
+ const dir = makeTempDir('nocommits');
323
+ initGitRepo(dir);
324
+ // Create a file but don't commit
325
+ writeFileSync(join(dir, 'new-file.txt'), 'hello');
326
+ const manifest = { version: 1, files: {} };
327
+ const edits = await detectUserEdits(dir, manifest);
328
+ // Should detect the untracked file via ls-files --others
329
+ expect(edits).toContain('new-file.txt');
330
+ });
331
+ it('detects modified gitignored files that are in the template manifest', async () => {
332
+ // Real-world scenario: template ships with a config file, user adds it
333
+ // to .gitignore (e.g., to keep local secrets), then modifies it.
334
+ // detectUserEdits should still detect it by scanning manifest files on disk.
335
+ const dir = makeTempDir('gitignore');
336
+ initGitRepo(dir);
337
+ writeFileSync(join(dir, 'a.txt'), 'hello');
338
+ writeFileSync(join(dir, '.gitignore'), 'ignored.txt\n');
339
+ execFileSync('git', ['add', '.'], { cwd: dir });
340
+ execFileSync('git', ['commit', '-m', 'init'], { cwd: dir });
341
+ // Create a file that's gitignored but was in the template manifest
342
+ writeFileSync(join(dir, 'ignored.txt'), 'user modified this ignored file');
343
+ const manifest = {
344
+ version: 1,
345
+ files: {
346
+ 'a.txt': 'sha256:abc',
347
+ 'ignored.txt': 'sha256:original_template_hash',
348
+ },
349
+ };
350
+ const edits = await detectUserEdits(dir, manifest);
351
+ // Fixed: gitignored file IS detected because we scan manifest files on disk
352
+ expect(edits).toContain('ignored.txt');
353
+ });
354
+ it('does not flag gitignored file if it matches template manifest hash', async () => {
355
+ // If the gitignored file has NOT been modified (hash matches manifest),
356
+ // it should NOT appear in edits.
357
+ const dir = makeTempDir('gitignore-pristine');
358
+ initGitRepo(dir);
359
+ writeFileSync(join(dir, 'a.txt'), 'hello');
360
+ writeFileSync(join(dir, '.gitignore'), 'config.txt\n');
361
+ execFileSync('git', ['add', '.'], { cwd: dir });
362
+ execFileSync('git', ['commit', '-m', 'init'], { cwd: dir });
363
+ const content = 'pristine template content';
364
+ writeFileSync(join(dir, 'config.txt'), content);
365
+ const { createHash } = await import('crypto');
366
+ const hash = 'sha256:' + createHash('sha256').update(Buffer.from(content)).digest('hex');
367
+ const manifest = {
368
+ version: 1,
369
+ files: {
370
+ 'a.txt': 'sha256:abc',
371
+ 'config.txt': hash,
372
+ },
373
+ };
374
+ const edits = await detectUserEdits(dir, manifest);
375
+ expect(edits).not.toContain('config.txt');
376
+ });
377
+ });
@@ -0,0 +1 @@
1
+ export {};