import { expect, test, describe, beforeAll, afterAll, beforeEach, vi, } from 'vitest' import { writeFile, mkdir, rm, symlink, readFile, access } from 'fs/promises' import fs from 'node:fs' import { Readable } from 'node:stream' import { pipeline } from 'node:stream/promises' import { join } from 'path' import { tmpdir } from 'os' import { tarFileStream } from '../../../src/template/utils' import * as tar from 'tar' import { ReadEntry } from 'tar' describe('tarFileStream', () => { const testDir = join(__dirname, 'tar-test-folder') beforeAll(async () => { await rm(testDir, { recursive: true, force: true }) await mkdir(testDir, { recursive: true }) }) afterAll(async () => { await rm(testDir, { recursive: true, force: true }) }) beforeEach(async () => { await rm(testDir, { recursive: true, force: true }) await mkdir(testDir, { recursive: true }) }) /** * Wait until a path no longer exists. Cleanup runs asynchronously from the * stream's `close` handler, so it may not complete in the same tick. */ async function waitUntilGone(target: string): Promise { for (let i = 0; i < 100; i++) { try { await access(target) } catch { return } await new Promise((resolve) => setTimeout(resolve, 10)) } throw new Error(`path was not removed: ${target}`) } /** * Pipe an archive stream into tar's extractor and collect its entries. */ async function extractTarStream( stream: Readable ): Promise<{ extractDir: string; members: ReadEntry[] }> { const extractDir = join( tmpdir(), `tar-extract-${Date.now()}-${Math.random()}` ) await mkdir(extractDir, { recursive: true }) const members: ReadEntry[] = [] await pipeline( stream, tar.extract({ cwd: extractDir, gzip: true, onentry: (entry: ReadEntry) => { members.push(entry) }, }) ) return { extractDir, members } } /** * Extract archive contents into a map of path -> file contents. */ async function extractTarContents( stream: Readable ): Promise> { const { extractDir, members } = await extractTarStream(stream) const contents = new Map() for (const member of members) { if (member.type === 'File') { try { contents.set( member.path, await readFile(join(extractDir, member.path)) ) } catch { // File might not exist } } else if (member.type === 'Directory') { contents.set(member.path, null) } else if (member.type === 'SymbolicLink') { contents.set(member.path, Buffer.from(member.linkpath || '')) } } await rm(extractDir, { recursive: true, force: true }) return contents } /** * Extract archive members into a map of path -> entry. */ async function getTarMembers( stream: Readable ): Promise> { const { extractDir, members } = await extractTarStream(stream) const map = new Map() for (const member of members) { map.set(member.path, member) } await rm(extractDir, { recursive: true, force: true }) return map } test('should create tar with simple files', async () => { await writeFile(join(testDir, 'file1.txt'), 'content1') await writeFile(join(testDir, 'file2.txt'), 'content2') const { stream, size } = await tarFileStream( '*.txt', testDir, [], false, true ) expect(size).toBeGreaterThan(0) const contents = await extractTarContents(stream) expect(contents.size).toBe(2) expect(contents.get('file1.txt')?.toString()).toBe('content1') expect(contents.get('file2.txt')?.toString()).toBe('content2') }) test('should create an uncompressed tar when gzip is disabled', async () => { await writeFile(join(testDir, 'file1.txt'), 'content1') await writeFile(join(testDir, 'file2.txt'), 'content2') const { stream } = await tarFileStream('*.txt', testDir, [], false, false) // Buffer the archive so we can both assert it is not gzipped and extract it const chunks: Buffer[] = [] for await (const chunk of stream as unknown as AsyncIterable) { chunks.push(Buffer.from(chunk)) } const archive = Buffer.concat(chunks) // gzip streams start with the magic bytes 0x1f 0x8b — an uncompressed tar must not expect(archive[0]).not.toBe(0x1f) expect(archive.subarray(0, 2).equals(Buffer.from([0x1f, 0x8b]))).toBe(false) // And it should still extract to the original files const tarFile = join(tmpdir(), `tar-uncompressed-${Date.now()}.tar`) await writeFile(tarFile, archive) const extractDir = join(tmpdir(), `tar-uncompressed-extract-${Date.now()}`) await mkdir(extractDir, { recursive: true }) await tar.extract({ file: tarFile, cwd: extractDir }) expect((await readFile(join(extractDir, 'file1.txt'))).toString()).toBe( 'content1' ) expect((await readFile(join(extractDir, 'file2.txt'))).toString()).toBe( 'content2' ) await rm(tarFile, { force: true }) await rm(extractDir, { recursive: true, force: true }) }) test('should respect ignore patterns', async () => { await writeFile(join(testDir, 'file1.txt'), 'content1') await writeFile(join(testDir, 'file2.txt'), 'content2') await writeFile(join(testDir, 'temp.txt'), 'temp content') await writeFile(join(testDir, 'backup.txt'), 'backup content') const { stream } = await tarFileStream( '*.txt', testDir, ['temp*', 'backup*'], false, true ) const contents = await extractTarContents(stream) expect(contents.size).toBe(2) expect(contents.get('file1.txt')?.toString()).toBe('content1') expect(contents.get('file2.txt')?.toString()).toBe('content2') expect(contents.has('temp.txt')).toBe(false) expect(contents.has('backup.txt')).toBe(false) }) test('should handle nested files', async () => { const nestedDir = join(testDir, 'src', 'components') await mkdir(nestedDir, { recursive: true }) await writeFile(join(testDir, 'src', 'index.ts'), 'index content') await writeFile(join(nestedDir, 'Button.tsx'), 'button content') const { stream } = await tarFileStream('src', testDir, [], false, true) const contents = await extractTarContents(stream) const paths = Array.from(contents.keys()) expect(paths.some((p) => p.includes('src'))).toBe(true) expect(paths.some((p) => p.includes('index.ts'))).toBe(true) expect(paths.some((p) => p.includes('Button.tsx'))).toBe(true) }) test('should resolve symlinks when enabled', async () => { await writeFile(join(testDir, 'original.txt'), 'original content') try { await symlink('original.txt', join(testDir, 'link.txt')) } catch (error: any) { // Skip test if symlinks are not supported on this platform if (error.code === 'ENOSYS' || error.code === 'EPERM') { return } throw error } // Test with resolveSymlinks=true const { stream } = await tarFileStream('*.txt', testDir, [], true, true) const contents = await extractTarContents(stream) expect(contents.get('original.txt')?.toString()).toBe('original content') // Symlink should be resolved (contain actual content, not link) expect(contents.get('link.txt')?.toString()).toBe('original content') }) test('should preserve symlinks when disabled', async () => { await writeFile(join(testDir, 'original.txt'), 'original content') try { await symlink('original.txt', join(testDir, 'link.txt')) } catch (error: any) { // Skip test if symlinks are not supported on this platform if (error.code === 'ENOSYS' || error.code === 'EPERM') { return } throw error } // Test with resolveSymlinks=false const { stream } = await tarFileStream('*.txt', testDir, [], false, true) const members = await getTarMembers(stream) expect(members.get('original.txt')?.type).toBe('File') const link = members.get('link.txt')! expect(link.type).toBe('SymbolicLink') expect(link.linkpath).toBe('original.txt') }) test('removes the spooled archive once the stream is consumed', async () => { await writeFile(join(testDir, 'file1.txt'), 'content1') // Capture the temp dir the helper creates so we can assert it is gone. let tmpDir: string | undefined const mkdtemp = fs.promises.mkdtemp.bind(fs.promises) const spy = vi .spyOn(fs.promises, 'mkdtemp') .mockImplementation(async (prefix: any, ...rest: any[]) => { const dir = await mkdtemp(prefix, ...rest) tmpDir = dir return dir }) try { const { stream } = await tarFileStream('*.txt', testDir, [], false) expect(tmpDir).toBeDefined() await access(tmpDir!) // Drain the stream to completion; the `close` handler then removes the // spooled archive. stream.resume() await waitUntilGone(tmpDir!) } finally { spy.mockRestore() } }) test('cleans up the spooled archive if it is never consumed', async () => { await writeFile(join(testDir, 'file1.txt'), 'content1') let tmpDir: string | undefined const mkdtemp = fs.promises.mkdtemp.bind(fs.promises) const spy = vi .spyOn(fs.promises, 'mkdtemp') .mockImplementation(async (prefix: any, ...rest: any[]) => { const dir = await mkdtemp(prefix, ...rest) tmpDir = dir return dir }) try { const { stream } = await tarFileStream('*.txt', testDir, [], false) await access(tmpDir!) // Destroying without reading still fires `close` and removes the file. stream.destroy() await waitUntilGone(tmpDir!) } finally { spy.mockRestore() } }) })