Skip to content

Commit d7f5c02

Browse files
committed
feat: migrate from Promises to Streams
1 parent 894d29e commit d7f5c02

4 files changed

Lines changed: 258 additions & 92 deletions

File tree

src/__tests__/cli.spec.ts

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -39,21 +39,31 @@ describe('copyfiles', () => {
3939
'**/*.ps.txt'
4040
]);
4141

42+
// Mock process.exit so it doesn't kill the test runner
43+
// @ts-ignore
44+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null | undefined) => {
45+
// Do nothing
46+
});
47+
4248
import('../cli')
43-
.then((cli: any) => {
44-
console.log(cli);
45-
})
4649
.then(() => {
47-
readdir('output2/input2', (err, files) => {
48-
expect(files).toEqual(['a.txt', 'b.txt']);
49-
done();
50-
});
50+
// Wait a tick to ensure file writes are complete
51+
setTimeout(() => {
52+
readdir('output2/input2', (err, files) => {
53+
expect(files).toEqual(['a.txt', 'b.txt']);
54+
exitSpy.mockRestore();
55+
done();
56+
});
57+
}, 100); // 100ms delay to allow async file writes
5158
})
5259
.catch(e => {
53-
readdir('output2/input2', (err, files) => {
54-
expect(files).toEqual(['a.txt', 'b.txt']);
55-
done();
56-
});
60+
setTimeout(() => {
61+
readdir('output2/input2', (err, files) => {
62+
expect(files).toEqual(['a.txt', 'b.txt']);
63+
exitSpy.mockRestore();
64+
done();
65+
});
66+
}, 100);
5767
});
58-
}));
59-
});
68+
}))
69+
})

src/__tests__/index.spec.ts

Lines changed: 112 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,44 @@
1-
import { mkdirSync, readdir, rmdirSync, symlinkSync, writeFileSync } from 'node:fs';
1+
import { existsSync, mkdirSync, readdir, rmSync, symlinkSync, writeFileSync } from 'node:fs';
22
import { globSync } from 'tinyglobby';
33
import { afterAll, afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
44

55
import { copyfiles, createDir } from '../index';
66

7+
let shouldMockReadError = false;
8+
const error = new Error('Mock read error');
9+
10+
vi.mock('node:fs', async () => {
11+
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
12+
return {
13+
...actual,
14+
createReadStream: (...args: any[]) => {
15+
if (shouldMockReadError) {
16+
const { Readable } = require('node:stream');
17+
const stream = new Readable({ read() { } });
18+
setImmediate(() => stream.emit('error', error));
19+
return stream;
20+
}
21+
// fallback to real implementation
22+
return (actual.createReadStream as any)(...args);
23+
}
24+
};
25+
});
26+
727
async function cleanupFolders() {
828
try {
9-
rmdirSync('input', { recursive: true });
10-
rmdirSync('output', { recursive: true });
29+
rmSync('input', { recursive: true, force: true });
30+
rmSync('output', { recursive: true, force: true });
1131
} catch (e) { }
1232
}
1333

1434
describe('copyfiles', () => {
1535
afterEach(async () => {
16-
cleanupFolders();
36+
await cleanupFolders();
1737
});
1838

19-
afterAll(() => cleanupFolders());
39+
afterAll(async () => {
40+
await cleanupFolders();
41+
});
2042

2143
beforeEach(() => {
2244
createDir('input/other');
@@ -43,8 +65,6 @@ describe('copyfiles', () => {
4365
copyfiles(['input/*.txt', 'output'], {}, (err) => {
4466
console.error(err, 'copyfiles');
4567
readdir('output/input', async (err, files) => {
46-
// console.error(err, 'readdir');
47-
// 'correct number of things'
4868
expect(files).toEqual(['a.txt', 'b.txt']);
4969
done();
5070
});
@@ -184,7 +204,9 @@ describe('copyfiles', () => {
184204
copyfiles(['input/**/*.txt', 'output'], { flat: true, verbose: true }, (err) => {
185205
readdir('output', (err, files) => {
186206
expect(files).toEqual(['a.txt', 'b.txt']);
187-
expect(logSpy).toHaveBeenCalledWith('glob found', ['input/b.txt', 'input/other/a.txt']);
207+
const globCall = logSpy.mock.calls.find(call => call[0] === 'glob found');
208+
expect(globCall).toBeTruthy();
209+
expect(new Set(globCall![1])).toEqual(new Set(['input/b.txt', 'input/other/a.txt']));
188210
expect(logSpy).toHaveBeenCalledWith('copy:', { from: 'input/other/a.txt', to: 'output/a.txt' });
189211
expect(logSpy).toHaveBeenCalledWith('copy:', { from: 'input/b.txt', to: 'output/b.txt' });
190212
expect(logSpy).toHaveBeenCalledWith('Files copied: 2');
@@ -193,6 +215,88 @@ describe('copyfiles', () => {
193215
});
194216
}));
195217

218+
test('createDir does not throw if dir exists', () => {
219+
createDir('input');
220+
expect(() => createDir('input')).not.toThrow();
221+
});
222+
223+
test('throws when inFile or outDir are missing (no callback)', () => {
224+
expect(() => copyfiles(['input/**/*.txt'], {})).toThrow(
225+
'Please make sure to provide both <inFile> and <outDirectory>, i.e.: "copyfiles <inFile> <outDirectory>"'
226+
);
227+
});
228+
229+
test('callback called when no files to copy', () => new Promise((done: any) => {
230+
copyfiles(['input/doesnotexist/*.txt', 'output'], {}, (err) => {
231+
expect(err).toBeUndefined();
232+
done();
233+
});
234+
}));
235+
236+
test('copyFileStream handles read error', () => new Promise((done: any) => {
237+
writeFileSync('input/bad.txt', 'bad'); // <-- Ensure the file exists!
238+
shouldMockReadError = true;
239+
copyfiles(['input/bad.txt', 'output'], {}, (err) => {
240+
expect(err).toBeInstanceOf(Error);
241+
expect(err?.message).toBe('Mock read error');
242+
shouldMockReadError = false;
243+
done();
244+
});
245+
}));
246+
247+
test('throws when flat & up used together', () => {
248+
expect(() => copyfiles(['input/**/*.txt', 'output'], { flat: true, up: 1 })).toThrow(
249+
'Cannot use --flat in conjunction with --up option.'
250+
);
251+
});
252+
253+
test('calls callback with error when nothing copied and options.error is set', () => new Promise((done: any) => {
254+
copyfiles(['input/doesnotexist/*.txt', 'output'], { error: true }, (err) => {
255+
expect(err).toBeInstanceOf(Error);
256+
expect(err?.message).toBe('nothing copied');
257+
done();
258+
});
259+
}));
260+
261+
test('logs and calls callback when nothing copied and verbose/stat is set', () => new Promise((done: any) => {
262+
const logSpy = vi.spyOn(global.console, 'log').mockReturnValue();
263+
const timeSpy = vi.spyOn(global.console, 'timeEnd').mockReturnValue();
264+
copyfiles(['input/doesnotexist/*.txt', 'output'], { verbose: true }, (err) => {
265+
expect(logSpy).toHaveBeenCalledWith('Files copied: 0');
266+
expect(timeSpy).toHaveBeenCalled();
267+
expect(err).toBeUndefined();
268+
logSpy.mockRestore();
269+
timeSpy.mockRestore();
270+
done();
271+
});
272+
}));
273+
274+
test('throws when flat & up used together (with callback)', () => new Promise((done: any) => {
275+
copyfiles(['input/**/*.txt', 'output'], { flat: true, up: 1 }, (err) => {
276+
expect(err).toBeInstanceOf(Error);
277+
expect(err?.message).toBe('Cannot use --flat in conjunction with --up option.');
278+
done();
279+
});
280+
}));
281+
282+
test('throws when nothing copied and options.error is set (no callback)', () => {
283+
expect(() => {
284+
copyfiles(['input/doesnotexist/*.txt', 'output'], { error: true });
285+
}).toThrow('nothing copied');
286+
});
287+
288+
test('sets followSymbolicLinks when options.follow is true', () => new Promise((done: any) => {
289+
if (process.platform === 'win32') return done(); // skip on Windows (symlink perms)
290+
mkdirSync('input/real', { recursive: true });
291+
writeFileSync('input/real/a.txt', 'test');
292+
symlinkSync('real', 'input/link');
293+
copyfiles(['input/link/*.txt', 'output'], { follow: true }, (err) => {
294+
expect(err).toBeUndefined();
295+
expect(existsSync('output/link/a.txt')).toBe(true);
296+
done();
297+
});
298+
}));
299+
196300
test('verbose up', () => new Promise((done: any) => {
197301
const logSpy = vi.spyOn(global.console, 'log').mockReturnValue();
198302
writeFileSync('input/other/a.txt', 'a');

0 commit comments

Comments
 (0)