mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-03 10:17:00 +02:00
297 lines
9.2 KiB
TypeScript
297 lines
9.2 KiB
TypeScript
import type { Dirent, Stats } from 'node:fs';
|
|
import fs from 'node:fs/promises';
|
|
import { mock } from 'vitest-mock-extended';
|
|
|
|
import {
|
|
folderExists,
|
|
copyFolder,
|
|
delayAtLeast,
|
|
writeFileSafe,
|
|
ensureFolder,
|
|
renameFilesInDirectory,
|
|
renameDirectory,
|
|
createSymlink,
|
|
} from './filesystem';
|
|
|
|
vi.mock('node:fs/promises');
|
|
|
|
const mockFs = vi.mocked(fs);
|
|
|
|
describe('file system utils', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('folderExists', () => {
|
|
it('should return true for directory', async () => {
|
|
mockFs.stat.mockResolvedValue(mock<Stats>({ isDirectory: () => true }));
|
|
|
|
const result = await folderExists('/test/dir');
|
|
|
|
expect(result).toBe(true);
|
|
expect(mockFs.stat).toHaveBeenCalledWith('/test/dir');
|
|
});
|
|
|
|
it('should return false for file', async () => {
|
|
mockFs.stat.mockResolvedValue(mock<Stats>({ isDirectory: () => false }));
|
|
|
|
const result = await folderExists('/test/file.txt');
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('should return false when path does not exist', async () => {
|
|
mockFs.stat.mockRejectedValue(new Error('ENOENT'));
|
|
|
|
const result = await folderExists('/nonexistent');
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('copyFolder', () => {
|
|
it('should copy folder recursively', async () => {
|
|
const rootDirContent = [
|
|
mock<Dirent>({ name: 'root.txt', isDirectory: () => false }),
|
|
mock<Dirent>({ name: 'subdir', isDirectory: () => true }),
|
|
];
|
|
|
|
const subDirContent = [mock<Dirent>({ name: 'nested.txt', isDirectory: () => false })];
|
|
// @ts-expect-error ts does not select correct readdir overload
|
|
mockFs.readdir.mockResolvedValueOnce(rootDirContent).mockResolvedValueOnce(subDirContent);
|
|
mockFs.mkdir.mockResolvedValue(undefined);
|
|
mockFs.copyFile.mockResolvedValue();
|
|
|
|
await copyFolder({ source: '/src', destination: '/dest' });
|
|
|
|
expect(mockFs.mkdir).toHaveBeenCalledWith('/dest', { recursive: true });
|
|
expect(mockFs.copyFile).toHaveBeenCalledWith('/src/root.txt', '/dest/root.txt');
|
|
expect(mockFs.copyFile).toHaveBeenCalledWith(
|
|
'/src/subdir/nested.txt',
|
|
'/dest/subdir/nested.txt',
|
|
);
|
|
expect(mockFs.mkdir).toHaveBeenCalledWith('/dest/subdir', { recursive: true });
|
|
});
|
|
|
|
it('should ignore specified files', async () => {
|
|
const dirs = [
|
|
mock<Dirent>({ name: 'keep.txt', isDirectory: () => false }),
|
|
mock<Dirent>({ name: 'ignore.txt', isDirectory: () => false }),
|
|
];
|
|
// @ts-expect-error ts does not select correct readdir overload
|
|
mockFs.readdir.mockResolvedValueOnce(dirs);
|
|
mockFs.mkdir.mockResolvedValue(undefined);
|
|
mockFs.copyFile.mockResolvedValue();
|
|
|
|
await copyFolder({
|
|
source: '/src',
|
|
destination: '/dest',
|
|
ignore: ['ignore.txt'],
|
|
});
|
|
|
|
expect(mockFs.copyFile).toHaveBeenCalledWith('/src/keep.txt', '/dest/keep.txt');
|
|
expect(mockFs.copyFile).not.toHaveBeenCalledWith('/src/ignore.txt', '/dest/ignore.txt');
|
|
});
|
|
});
|
|
|
|
describe('delayAtLeast', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('should resolve after minimum delay', async () => {
|
|
const promise = Promise.resolve('result');
|
|
const resultPromise = delayAtLeast(promise, 100);
|
|
|
|
vi.advanceTimersByTime(90);
|
|
vi.runAllTicks();
|
|
|
|
let resolved = false;
|
|
void resultPromise.then(() => {
|
|
resolved = true;
|
|
});
|
|
vi.runAllTicks();
|
|
|
|
expect(resolved).toBe(false);
|
|
|
|
vi.advanceTimersByTime(10);
|
|
vi.runAllTicks();
|
|
|
|
const result = await resultPromise;
|
|
expect(result).toBe('result');
|
|
});
|
|
|
|
it('should not add delay if promise takes longer', async () => {
|
|
const promise = new Promise((resolve) => {
|
|
setTimeout(() => resolve('slow'), 200);
|
|
});
|
|
|
|
const resultPromise = delayAtLeast(promise, 100);
|
|
|
|
vi.advanceTimersByTime(100);
|
|
vi.runAllTicks();
|
|
|
|
let resolved = false;
|
|
void resultPromise.then(() => {
|
|
resolved = true;
|
|
});
|
|
vi.runAllTicks();
|
|
|
|
expect(resolved).toBe(false);
|
|
|
|
vi.advanceTimersByTime(100);
|
|
vi.runAllTicks();
|
|
|
|
const result = await resultPromise;
|
|
expect(result).toBe('slow');
|
|
});
|
|
});
|
|
|
|
describe('writeFileSafe', () => {
|
|
it('should create directory and write file', async () => {
|
|
mockFs.mkdir.mockResolvedValue(undefined);
|
|
mockFs.writeFile.mockResolvedValue();
|
|
|
|
await writeFileSafe('/path/to/file.txt', 'content');
|
|
|
|
expect(mockFs.mkdir).toHaveBeenCalledWith('/path/to', { recursive: true });
|
|
expect(mockFs.writeFile).toHaveBeenCalledWith('/path/to/file.txt', 'content');
|
|
});
|
|
|
|
it('should handle Uint8Array content', async () => {
|
|
mockFs.mkdir.mockResolvedValue(undefined);
|
|
mockFs.writeFile.mockResolvedValue();
|
|
const buffer = new Uint8Array([1, 2, 3]);
|
|
|
|
await writeFileSafe('/file.bin', buffer);
|
|
|
|
expect(mockFs.writeFile).toHaveBeenCalledWith('/file.bin', buffer);
|
|
});
|
|
});
|
|
|
|
describe('ensureFolder', () => {
|
|
it('should create directory recursively', async () => {
|
|
mockFs.mkdir.mockResolvedValue(undefined);
|
|
|
|
await ensureFolder('/deep/nested/dir');
|
|
|
|
expect(mockFs.mkdir).toHaveBeenCalledWith('/deep/nested/dir', { recursive: true });
|
|
});
|
|
});
|
|
|
|
describe('renameFilesInDirectory', () => {
|
|
it('should rename files with old name', async () => {
|
|
// @ts-expect-error ts does not select correct readdir overload
|
|
mockFs.readdir.mockResolvedValue(['oldName.svg', 'keep.txt', 'oldNameFile.js']);
|
|
mockFs.rename.mockResolvedValue();
|
|
|
|
await renameFilesInDirectory('/dir', 'oldName', 'newName');
|
|
|
|
expect(mockFs.rename).toHaveBeenCalledWith('/dir/oldName.svg', '/dir/newName.svg');
|
|
expect(mockFs.rename).toHaveBeenCalledWith('/dir/oldNameFile.js', '/dir/newNameFile.js');
|
|
expect(mockFs.rename).not.toHaveBeenCalledWith('/dir/keep.txt', expect.any(String));
|
|
});
|
|
|
|
it('should rename files with camelCase variants', async () => {
|
|
// @ts-expect-error ts does not select correct readdir overload
|
|
mockFs.readdir.mockResolvedValue(['myOldName.svg', 'MyOldName.node.js']);
|
|
mockFs.rename.mockResolvedValue();
|
|
|
|
await renameFilesInDirectory('/dir', 'MyOldName', 'MyNewName');
|
|
|
|
expect(mockFs.rename).toHaveBeenCalledTimes(2);
|
|
expect(mockFs.rename).toHaveBeenCalledWith('/dir/myOldName.svg', '/dir/myNewName.svg');
|
|
expect(mockFs.rename).toHaveBeenCalledWith(
|
|
'/dir/MyOldName.node.js',
|
|
'/dir/MyNewName.node.js',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('renameDirectory', () => {
|
|
it('should rename directory and return new path', async () => {
|
|
mockFs.rename.mockResolvedValue();
|
|
|
|
const result = await renameDirectory('/parent/oldDir', 'newDir');
|
|
|
|
expect(mockFs.rename).toHaveBeenCalledWith('/parent/oldDir', '/parent/newDir');
|
|
expect(result).toBe('/parent/newDir');
|
|
});
|
|
|
|
it('should handle root directory', async () => {
|
|
mockFs.rename.mockResolvedValue();
|
|
|
|
const result = await renameDirectory('/oldDir', 'newDir');
|
|
|
|
expect(result).toBe('/newDir');
|
|
});
|
|
});
|
|
|
|
describe('createSymlink', () => {
|
|
it('should create parent directory and symlink', async () => {
|
|
mockFs.mkdir.mockResolvedValue(undefined);
|
|
const enoentError = Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
|
mockFs.lstat.mockRejectedValue(enoentError);
|
|
mockFs.stat.mockResolvedValue(mock<Stats>({ isDirectory: () => true }));
|
|
mockFs.symlink.mockResolvedValue();
|
|
|
|
await createSymlink('/source/path', '/link/path');
|
|
|
|
expect(mockFs.mkdir).toHaveBeenCalledWith('/link', { recursive: true });
|
|
expect(mockFs.symlink).toHaveBeenCalledWith('/source/path', '/link/path', 'dir');
|
|
});
|
|
|
|
it('should remove existing symlink before creating new one', async () => {
|
|
mockFs.mkdir.mockResolvedValue(undefined);
|
|
mockFs.lstat.mockResolvedValue(mock<Stats>({ isSymbolicLink: () => true }));
|
|
mockFs.unlink.mockResolvedValue();
|
|
mockFs.stat.mockResolvedValue(mock<Stats>({ isDirectory: () => false }));
|
|
mockFs.symlink.mockResolvedValue();
|
|
|
|
await createSymlink('/source/file', '/link/file');
|
|
|
|
expect(mockFs.unlink).toHaveBeenCalledWith('/link/file');
|
|
expect(mockFs.symlink).toHaveBeenCalledWith('/source/file', '/link/file', 'file');
|
|
});
|
|
|
|
it('should remove existing directory before creating symlink', async () => {
|
|
mockFs.mkdir.mockResolvedValue(undefined);
|
|
mockFs.lstat.mockResolvedValue(
|
|
mock<Stats>({ isSymbolicLink: () => false, isDirectory: () => true }),
|
|
);
|
|
mockFs.rm.mockResolvedValue();
|
|
mockFs.stat.mockResolvedValue(mock<Stats>({ isDirectory: () => true }));
|
|
mockFs.symlink.mockResolvedValue();
|
|
|
|
await createSymlink('/source', '/link');
|
|
|
|
expect(mockFs.rm).toHaveBeenCalledWith('/link', { recursive: true, force: true });
|
|
expect(mockFs.symlink).toHaveBeenCalledWith('/source', '/link', 'dir');
|
|
});
|
|
|
|
it('should fallback to junction on Windows when type detection fails', async () => {
|
|
mockFs.mkdir.mockResolvedValue(undefined);
|
|
const enoentError = Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
|
mockFs.lstat.mockRejectedValue(enoentError);
|
|
mockFs.stat.mockRejectedValue(new Error('Access denied'));
|
|
mockFs.symlink.mockResolvedValue();
|
|
|
|
await createSymlink('/source', '/link');
|
|
|
|
expect(mockFs.symlink).toHaveBeenCalledWith('/source', '/link', 'junction');
|
|
});
|
|
|
|
it('should throw error if lstat fails with non-ENOENT error', async () => {
|
|
mockFs.mkdir.mockResolvedValue(undefined);
|
|
const error = new Error('Permission denied');
|
|
mockFs.lstat.mockRejectedValue(error);
|
|
|
|
await expect(createSymlink('/source', '/link')).rejects.toThrow('Permission denied');
|
|
});
|
|
});
|
|
});
|