Skip to content

Commit b6e3825

Browse files
authored
feat: migrate from Promises to Streams (#15)
* feat: migrate from Promises to Streams
1 parent 79d307f commit b6e3825

4 files changed

Lines changed: 268 additions & 98 deletions

File tree

src/__tests__/cli.spec.ts

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
import { readdir, rmdirSync, writeFileSync } from 'node:fs';
1+
import { readdir, rmSync, writeFileSync } from 'node:fs';
22
import { afterAll, afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
33

44
import { createDir } from '../index';
55

66
async function cleanupFolders() {
77
try {
8-
rmdirSync('input2', { recursive: true });
9-
rmdirSync('output2', { recursive: true });
8+
rmSync('input2', { recursive: true, force: true });
9+
rmSync('output2', { recursive: true, force: true });
1010
} catch (e) { }
1111
}
1212

@@ -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: 119 additions & 11 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 { 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');
@@ -41,10 +63,7 @@ describe('copyfiles', () => {
4163
writeFileSync('input/b.txt', 'b');
4264
writeFileSync('input/c.js', 'c');
4365
copyfiles(['input/*.txt', 'output'], {}, (err) => {
44-
console.error(err, 'copyfiles');
4566
readdir('output/input', async (err, files) => {
46-
// console.error(err, 'readdir');
47-
// 'correct number of things'
4867
expect(files).toEqual(['a.txt', 'b.txt']);
4968
done();
5069
});
@@ -58,7 +77,6 @@ describe('copyfiles', () => {
5877
writeFileSync('input/b.txt', 'b');
5978
writeFileSync('input/c.js', 'c');
6079
copyfiles(['input/*.txt', 'output'], {}, (err) => {
61-
console.error(err, 'copyfiles');
6280
readdir('output/input', (err, files) => {
6381
expect(files).toEqual(['a.txt', 'b.txt']);
6482
// 'correct mode'
@@ -168,10 +186,11 @@ describe('copyfiles', () => {
168186
mkdirSync('input/origin');
169187
mkdirSync('input/origin/inner');
170188
writeFileSync('input/origin/inner/a.txt', 'a');
189+
writeFileSync('input/origin/inner/b.txt', 'b');
171190
symlinkSync('origin', 'input/dest');
172191
copyfiles(['input/**/*.txt', 'output'], { up: 1, follow: true }, (err) => {
173192
const files = globSync('output/**/*.txt');
174-
expect(files).toEqual(['output/dest/inner/a.txt', 'output/origin/inner/a.txt']);
193+
expect(new Set(files)).toEqual(new Set(['output/a.txt', 'output/b.txt']));
175194
});
176195
}
177196
});
@@ -184,7 +203,9 @@ describe('copyfiles', () => {
184203
copyfiles(['input/**/*.txt', 'output'], { flat: true, verbose: true }, (err) => {
185204
readdir('output', (err, files) => {
186205
expect(files).toEqual(['a.txt', 'b.txt']);
187-
expect(logSpy).toHaveBeenCalledWith('glob found', ['input/b.txt', 'input/other/a.txt']);
206+
const globCall = logSpy.mock.calls.find(call => call[0] === 'glob found');
207+
expect(globCall).toBeTruthy();
208+
expect(new Set(globCall![1])).toEqual(new Set(['input/b.txt', 'input/other/a.txt']));
188209
expect(logSpy).toHaveBeenCalledWith('copy:', { from: 'input/other/a.txt', to: 'output/a.txt' });
189210
expect(logSpy).toHaveBeenCalledWith('copy:', { from: 'input/b.txt', to: 'output/b.txt' });
190211
expect(logSpy).toHaveBeenCalledWith('Files copied: 2');
@@ -193,6 +214,93 @@ describe('copyfiles', () => {
193214
});
194215
}));
195216

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

0 commit comments

Comments
 (0)