diff --git a/src/utils/deploy/upload-source-zip.ts b/src/utils/deploy/upload-source-zip.ts index ed0ca64f86e..f15ee838e5a 100644 --- a/src/utils/deploy/upload-source-zip.ts +++ b/src/utils/deploy/upload-source-zip.ts @@ -1,18 +1,15 @@ -import { execFile } from 'child_process' -import { readFile, mkdir } from 'fs/promises' -import { join, dirname } from 'path' -import { promisify } from 'util' -import type { PathLike } from 'fs' -import { platform } from 'os' +import { mkdir, readFile } from 'node:fs/promises' +import { join, dirname } from 'node:path' +import type { PathLike } from 'node:fs' +import { platform } from 'node:os' +import execa, { ExecaError } from 'execa' import fetch from 'node-fetch' import { log, warn } from '../command-helpers.js' import { temporaryDirectory } from '../temporary-file.js' import type { DeployEvent } from './status-cb.js' -const execFileAsync = promisify(execFile) - interface UploadSourceZipOptions { sourceDir: string uploadUrl: string @@ -76,10 +73,20 @@ const createSourceZip = async ({ const excludeArgs = DEFAULT_IGNORE_PATTERNS.flatMap((pattern) => ['-x', pattern]) // Use system zip command to create the archive - await execFileAsync('zip', ['-r', zipPath, '.', ...excludeArgs], { - cwd: sourceDir, - maxBuffer: 1024 * 1024 * 100, // 100MB buffer - }) + try { + await execa('zip', ['-r', '-q', zipPath, '.', ...excludeArgs], { + all: true, + cwd: sourceDir, + stdio: ['ignore', 'pipe', 'pipe'], + }) + } catch (_baseErr) { + let message = 'zip command failed' + if (_baseErr instanceof Error && 'command' in _baseErr) { + const baseErr = _baseErr as ExecaError + message = `${baseErr.shortMessage}\n\n${baseErr.stdout}` + } + throw new Error(message, { cause: _baseErr }) + } return zipPath } @@ -161,7 +168,7 @@ export const uploadSourceZip = async ({ // Clean up temporary zip file if (zipPath) { try { - await import('fs/promises').then((fs) => fs.unlink(zipPath as unknown as PathLike)) + await (await import('node:fs/promises')).unlink(zipPath as unknown as PathLike) } catch { // Ignore cleanup errors } diff --git a/tests/unit/utils/deploy/upload-source-zip.test.ts b/tests/unit/utils/deploy/upload-source-zip.test.ts index 201becc5afb..1bb93c588ee 100644 --- a/tests/unit/utils/deploy/upload-source-zip.test.ts +++ b/tests/unit/utils/deploy/upload-source-zip.test.ts @@ -1,15 +1,16 @@ -import { describe, expect, test, vi, beforeEach } from 'vitest' -import { join } from 'path' +import { join } from 'node:path' + +import type { ExecaReturnValue } from 'execa' import type { Response } from 'node-fetch' -import type { ChildProcess } from 'child_process' +import { describe, expect, test, vi, beforeEach } from 'vitest' // Mock all dependencies at the top level vi.mock('node-fetch', () => ({ default: vi.fn(), })) -vi.mock('child_process', () => ({ - execFile: vi.fn(), +vi.mock('execa', () => ({ + default: vi.fn(), })) vi.mock('fs/promises', () => ({ @@ -47,7 +48,7 @@ describe('uploadSourceZip', () => { // Setup mocks using vi.mocked() const mockFetch = await import('node-fetch') - const mockChildProcess = await import('child_process') + const mockExeca = await import('execa') const mockFs = await import('fs/promises') const mockCommandHelpers = await import('../../../../src/utils/command-helpers.js') const mockTempFile = await import('../../../../src/utils/temporary-file.js') @@ -58,11 +59,9 @@ describe('uploadSourceZip', () => { statusText: 'OK', } as unknown as Response) - vi.mocked(mockChildProcess.execFile).mockImplementation((_command, _args, _options, callback) => { - if (callback) { - callback(null, '', '') - } - return {} as ChildProcess + // @ts-expect-error(ndhoule): getting the type on this fairly challenging + vi.mocked(mockExeca.default).mockImplementation((..._args) => { + return Promise.resolve({} as ExecaReturnValue) }) vi.mocked(mockFs.readFile).mockResolvedValue(Buffer.from('mock zip content')) @@ -78,14 +77,10 @@ describe('uploadSourceZip', () => { statusCb: mockStatusCb, }) - expect(mockChildProcess.execFile).toHaveBeenCalledWith( + expect(mockExeca.default).toHaveBeenCalledWith( 'zip', - expect.arrayContaining(['-r', expect.stringMatching(/test-source\.zip$/), '.']), - expect.objectContaining({ - cwd: '/test/source', - maxBuffer: 104857600, - }), - expect.any(Function), + expect.arrayContaining(['-r', '-q', expect.stringMatching(/test-source\.zip$/), '.']), + expect.objectContaining({ cwd: '/test/source' }), ) expect(mockFetch.default).toHaveBeenCalledWith( @@ -116,7 +111,7 @@ describe('uploadSourceZip', () => { const { uploadSourceZip } = await import('../../../../src/utils/deploy/upload-source-zip.js') const mockFetch = await import('node-fetch') - const mockChildProcess = await import('child_process') + const mockExeca = await import('execa') const mockFs = await import('fs/promises') const mockCommandHelpers = await import('../../../../src/utils/command-helpers.js') const mockTempFile = await import('../../../../src/utils/temporary-file.js') @@ -127,11 +122,9 @@ describe('uploadSourceZip', () => { statusText: 'Forbidden', } as unknown as Response) - vi.mocked(mockChildProcess.execFile).mockImplementation((_command, _args, _options, callback) => { - if (callback) { - callback(null, '', '') - } - return {} as ChildProcess + // @ts-expect-error(ndhoule): getting the type on this fairly challenging + vi.mocked(mockExeca.default).mockImplementation((..._args) => { + return Promise.resolve({} as ExecaReturnValue) }) vi.mocked(mockFs.readFile).mockResolvedValue(Buffer.from('mock zip content')) @@ -168,7 +161,7 @@ describe('uploadSourceZip', () => { const { uploadSourceZip } = await import('../../../../src/utils/deploy/upload-source-zip.js') const mockFetch = await import('node-fetch') - const mockChildProcess = await import('child_process') + const mockExeca = await import('execa') const mockFs = await import('fs/promises') const mockCommandHelpers = await import('../../../../src/utils/command-helpers.js') const mockTempFile = await import('../../../../src/utils/temporary-file.js') @@ -179,11 +172,9 @@ describe('uploadSourceZip', () => { statusText: 'OK', } as unknown as Response) - vi.mocked(mockChildProcess.execFile).mockImplementation((_command, _args, _options, callback) => { - if (callback) { - callback(null, '', '') - } - return {} as ChildProcess + // @ts-expect-error(ndhoule): getting the type on this fairly challenging + vi.mocked(mockExeca.default).mockImplementation((..._args) => { + return Promise.resolve({} as ExecaReturnValue) }) vi.mocked(mockFs.readFile).mockResolvedValue(Buffer.from('mock zip content')) @@ -199,14 +190,10 @@ describe('uploadSourceZip', () => { statusCb: mockStatusCb, }) - expect(mockChildProcess.execFile).toHaveBeenCalledWith( + expect(mockExeca.default).toHaveBeenCalledWith( 'zip', expect.arrayContaining(['-x', 'node_modules*', '-x', '.git*', '-x', '.netlify*', '-x', '.env']), - expect.objectContaining({ - cwd: '/test/source', - maxBuffer: 104857600, - }), - expect.any(Function), + expect.objectContaining({ cwd: '/test/source' }), ) }) @@ -245,16 +232,14 @@ describe('uploadSourceZip', () => { const { uploadSourceZip } = await import('../../../../src/utils/deploy/upload-source-zip.js') - const mockChildProcess = await import('child_process') + const mockExeca = await import('execa') const mockCommandHelpers = await import('../../../../src/utils/command-helpers.js') const mockTempFile = await import('../../../../src/utils/temporary-file.js') // Mock execFile to simulate failure - vi.mocked(mockChildProcess.execFile).mockImplementation((_command, _args, _options, callback) => { - if (callback) { - callback(new Error('zip command failed'), '', 'zip: error creating archive') - } - return {} as import('child_process').ChildProcess + // @ts-expect-error(ndhoule): getting the type on this fairly challenging + vi.mocked(mockExeca.default).mockImplementation((..._args) => { + return Promise.reject(new Error('zip command failed')) }) vi.mocked(mockCommandHelpers.warn).mockImplementation(() => {}) @@ -290,17 +275,15 @@ describe('uploadSourceZip', () => { const { uploadSourceZip } = await import('../../../../src/utils/deploy/upload-source-zip.js') const mockFetch = await import('node-fetch') - const mockChildProcess = await import('child_process') + const mockExeca = await import('execa') const mockFs = await import('fs/promises') const mockCommandHelpers = await import('../../../../src/utils/command-helpers.js') const mockTempFile = await import('../../../../src/utils/temporary-file.js') // Mock successful zip creation but failed upload - vi.mocked(mockChildProcess.execFile).mockImplementation((_command, _args, _options, callback) => { - if (callback) { - callback(null, '', '') - } - return {} as import('child_process').ChildProcess + // @ts-expect-error(ndhoule): getting the type on this fairly challenging + vi.mocked(mockExeca.default).mockImplementation((..._args) => { + return Promise.resolve({} as ExecaReturnValue) }) vi.mocked(mockFs.readFile).mockResolvedValue(Buffer.from('mock zip content')) @@ -337,7 +320,7 @@ describe('uploadSourceZip', () => { const { uploadSourceZip } = await import('../../../../src/utils/deploy/upload-source-zip.js') const mockFetch = await import('node-fetch') - const mockChildProcess = await import('child_process') + const mockExeca = await import('execa') const mockFs = await import('fs/promises') const mockCommandHelpers = await import('../../../../src/utils/command-helpers.js') const mockTempFile = await import('../../../../src/utils/temporary-file.js') @@ -349,11 +332,9 @@ describe('uploadSourceZip', () => { json: vi.fn().mockResolvedValue({ url: 'https://test-source-zip-url.com' }), } as unknown as import('node-fetch').Response) - vi.mocked(mockChildProcess.execFile).mockImplementation((_command, _args, _options, callback) => { - if (callback) { - callback(null, '', '') - } - return {} as import('child_process').ChildProcess + // @ts-expect-error(ndhoule): getting the type on this fairly challenging + vi.mocked(mockExeca.default).mockImplementation((..._args) => { + return {} as ExecaReturnValue }) vi.mocked(mockFs.readFile).mockResolvedValue(Buffer.from('mock zip content')) @@ -379,7 +360,7 @@ describe('uploadSourceZip', () => { const { uploadSourceZip } = await import('../../../../src/utils/deploy/upload-source-zip.js') const mockFetch = await import('node-fetch') - const mockChildProcess = await import('child_process') + const mockExeca = await import('execa') const mockFs = await import('fs/promises') const mockCommandHelpers = await import('../../../../src/utils/command-helpers.js') const mockTempFile = await import('../../../../src/utils/temporary-file.js') @@ -390,11 +371,9 @@ describe('uploadSourceZip', () => { statusText: 'OK', } as unknown as Response) - vi.mocked(mockChildProcess.execFile).mockImplementation((_command, _args, _options, callback) => { - if (callback) { - callback(null, '', '') - } - return {} as ChildProcess + // @ts-expect-error(ndhoule): getting the type on this fairly challenging + vi.mocked(mockExeca.default).mockImplementation((..._args) => { + return {} as ExecaReturnValue }) vi.mocked(mockFs.readFile).mockResolvedValue(Buffer.from('mock zip content')) @@ -416,18 +395,15 @@ describe('uploadSourceZip', () => { expect(mockFs.mkdir).toHaveBeenCalledWith(join('/tmp/test-temp-dir', 'workspace-snapshots'), { recursive: true }) // Should still call zip command with the full path - expect(mockChildProcess.execFile).toHaveBeenCalledWith( + expect(mockExeca.default).toHaveBeenCalledWith( 'zip', expect.arrayContaining([ '-r', + '-q', join('/tmp/test-temp-dir', 'workspace-snapshots', 'source-abc123-def456.zip'), '.', ]), - expect.objectContaining({ - cwd: '/test/source', - maxBuffer: 104857600, - }), - expect.any(Function), + expect.objectContaining({ cwd: '/test/source' }), ) }) })