|
| 1 | +import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' |
| 2 | +import { link } from 'node:fs/promises' |
| 3 | +import { tmpdir } from 'node:os' |
| 4 | +import path from 'node:path' |
| 5 | +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' |
| 6 | +import linkOrCopyFile from './link-or-copy.js' |
| 7 | + |
| 8 | +vi.mock('node:fs/promises', async importOriginal => { |
| 9 | + const actual = await importOriginal<typeof import('node:fs/promises')>() |
| 10 | + return { ...actual, link: vi.fn(actual.link) } |
| 11 | +}) |
| 12 | + |
| 13 | +describe('Link or copy', () => { |
| 14 | + let dir: string |
| 15 | + let source: string |
| 16 | + let destination: string |
| 17 | + |
| 18 | + beforeEach(() => { |
| 19 | + dir = mkdtempSync(path.join(tmpdir(), 'onepacerr-link-')) |
| 20 | + source = path.join(dir, 'source.mkv') |
| 21 | + destination = path.join(dir, 'destination.mkv') |
| 22 | + writeFileSync(source, 'payload') |
| 23 | + vi.mocked(link).mockClear() |
| 24 | + }) |
| 25 | + |
| 26 | + afterEach(() => { |
| 27 | + rmSync(dir, { recursive: true, force: true }) |
| 28 | + }) |
| 29 | + |
| 30 | + it('Should hardlink when source and destination share a filesystem', async () => { |
| 31 | + await linkOrCopyFile(source, destination) |
| 32 | + |
| 33 | + expect(statSync(destination).nlink).toBe(2) |
| 34 | + expect(statSync(source).ino).toBe(statSync(destination).ino) |
| 35 | + }) |
| 36 | + |
| 37 | + it('Should replace an existing destination', async () => { |
| 38 | + writeFileSync(destination, 'stale') |
| 39 | + |
| 40 | + await linkOrCopyFile(source, destination) |
| 41 | + |
| 42 | + expect(readFileSync(destination, 'utf8')).toBe('payload') |
| 43 | + expect(statSync(destination).nlink).toBe(2) |
| 44 | + }) |
| 45 | + |
| 46 | + let COPY_FALLBACK_CODES = ['EXDEV', 'EPERM', 'ENOSYS', 'ENOTSUP', 'EMLINK'] |
| 47 | + |
| 48 | + it.each(COPY_FALLBACK_CODES)( |
| 49 | + 'Should copy when the filesystems differ (%s)', |
| 50 | + async code => { |
| 51 | + vi.mocked(link).mockRejectedValueOnce( |
| 52 | + Object.assign(new Error('link not possible'), { code }), |
| 53 | + ) |
| 54 | + |
| 55 | + await linkOrCopyFile(source, destination) |
| 56 | + |
| 57 | + expect(readFileSync(destination, 'utf8')).toBe('payload') |
| 58 | + expect(statSync(destination).nlink).toBe(1) |
| 59 | + }, |
| 60 | + ) |
| 61 | + |
| 62 | + it('Should rethrow errors that are not a hardlink limitation', async () => { |
| 63 | + vi.mocked(link).mockRejectedValueOnce( |
| 64 | + Object.assign(new Error('no space left on device'), { code: 'ENOSPC' }), |
| 65 | + ) |
| 66 | + |
| 67 | + await expect(linkOrCopyFile(source, destination)).rejects.toThrow() |
| 68 | + }) |
| 69 | + |
| 70 | + it('Should leave an existing destination untouched when linking fails with a non-fallback error', async () => { |
| 71 | + writeFileSync(destination, 'original') |
| 72 | + vi.mocked(link).mockRejectedValueOnce( |
| 73 | + Object.assign(new Error('no space left on device'), { code: 'ENOSPC' }), |
| 74 | + ) |
| 75 | + |
| 76 | + await expect(linkOrCopyFile(source, destination)).rejects.toThrow() |
| 77 | + |
| 78 | + expect(readFileSync(destination, 'utf8')).toBe('original') |
| 79 | + }) |
| 80 | + |
| 81 | + it('Should not corrupt the destination when two concurrent calls target it', async () => { |
| 82 | + let sourceA = source |
| 83 | + let sourceB = path.join(dir, 'source-b.mkv') |
| 84 | + writeFileSync(sourceB, 'payload-b') |
| 85 | + |
| 86 | + await Promise.all([ |
| 87 | + linkOrCopyFile(sourceA, destination), |
| 88 | + linkOrCopyFile(sourceB, destination), |
| 89 | + ]) |
| 90 | + |
| 91 | + let destinationContent = readFileSync(destination, 'utf8') |
| 92 | + let destinationIno = statSync(destination).ino |
| 93 | + expect(statSync(destination).nlink).toBe(2) |
| 94 | + if (destinationContent === 'payload') { |
| 95 | + expect(destinationIno).toBe(statSync(sourceA).ino) |
| 96 | + } else { |
| 97 | + expect(destinationContent).toBe('payload-b') |
| 98 | + expect(destinationIno).toBe(statSync(sourceB).ino) |
| 99 | + } |
| 100 | + |
| 101 | + let leftoverTempFiles = readdirSync(dir).filter(name => |
| 102 | + name.includes('.onepacerr-tmp'), |
| 103 | + ) |
| 104 | + expect(leftoverTempFiles).toEqual([]) |
| 105 | + }) |
| 106 | +}) |
0 commit comments