diff --git a/src/commands/apps/liveupdates/upload.ts b/src/commands/apps/liveupdates/upload.ts index 487366d..febbd2a 100644 --- a/src/commands/apps/liveupdates/upload.ts +++ b/src/commands/apps/liveupdates/upload.ts @@ -19,6 +19,7 @@ import { isReadable, getFilesInDirectoryAndSubdirectories, isDirectory, + readFileFromDirectory, } from '@/utils/file.js'; import { createHash } from '@/utils/hash.js'; import { generateManifestJson } from '@/utils/manifest.js'; @@ -442,7 +443,7 @@ const uploadFiles = async (options: { fileIndex++; consola.start(`Uploading file (${fileIndex}/${files.length})...`); - const buffer = await createBufferFromPath(file.path); + const buffer = await readFileFromDirectory(file.path); await uploadFile({ appId, diff --git a/src/utils/file.test.ts b/src/utils/file.test.ts new file mode 100644 index 0000000..74b7798 --- /dev/null +++ b/src/utils/file.test.ts @@ -0,0 +1,65 @@ +import fs from 'fs'; +import os from 'os'; +import pathModule from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { UserError } from './error.js'; +import { readFileFromDirectory } from './file.js'; + +const { mockCreateBufferFromPath } = vi.hoisted(() => ({ mockCreateBufferFromPath: vi.fn() })); + +vi.mock('./buffer.js', async (importOriginal) => { + const actual = await importOriginal(); + mockCreateBufferFromPath.mockImplementation(actual.createBufferFromPath); + return { ...actual, createBufferFromPath: mockCreateBufferFromPath }; +}); + +const createErrorWithCode = (code: string): Error => Object.assign(new Error(code), { code }); + +describe('readFileFromDirectory', () => { + let directory: string; + + beforeEach(() => { + directory = fs.mkdtempSync(pathModule.join(os.tmpdir(), 'file-')); + }); + + afterEach(() => { + fs.rmSync(directory, { force: true, recursive: true }); + }); + + it('should read the file', async () => { + const path = pathModule.join(directory, 'index.html'); + fs.writeFileSync(path, ''); + + const buffer = await readFileFromDirectory(path); + + expect(buffer.toString()).toBe(''); + }); + + it('should throw a user error if the file no longer exists', async () => { + const path = pathModule.join(directory, 'missing.png'); + + const promise = readFileFromDirectory(path); + + await expect(promise).rejects.toThrow(UserError); + await expect(promise).rejects.toThrow(`The file could not be read: ${path}. Make sure that no other process`); + }); + + it('should throw a user error if the file is not readable', async () => { + const path = pathModule.join(directory, 'index.html'); + mockCreateBufferFromPath.mockRejectedValueOnce(createErrorWithCode('EACCES')); + + const promise = readFileFromDirectory(path); + + await expect(promise).rejects.toThrow(UserError); + await expect(promise).rejects.toThrow( + `The file could not be read: ${path}. Make sure that you have permission to read the file.`, + ); + }); + + it('should rethrow errors that are not related to reading the file', async () => { + const error = createErrorWithCode('EISDIR'); + mockCreateBufferFromPath.mockRejectedValueOnce(error); + + await expect(readFileFromDirectory(pathModule.join(directory, 'assets'))).rejects.toBe(error); + }); +}); diff --git a/src/utils/file.ts b/src/utils/file.ts index 45317ae..a50d4c5 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -1,6 +1,19 @@ import fs from 'fs'; import mime from 'mime'; import pathModule from 'path'; +import { createBufferFromPath } from './buffer.js'; +import { getCodeFromUnknownError, UserError } from './error.js'; + +const concurrentModificationHint = + 'Make sure that no other process (e.g. a build or file sync client) modifies the folder while the command is running.'; +const permissionHint = 'Make sure that you have permission to read the file.'; + +const unreadableFileErrorHints: Record = { + EACCES: permissionHint, + EBUSY: concurrentModificationHint, + ENOENT: concurrentModificationHint, + EPERM: permissionHint, +}; export const getFilesInDirectoryAndSubdirectories = async ( path: string, @@ -40,6 +53,23 @@ export const getFilesInDirectoryAndSubdirectories = async ( return files; }; +/** + * Reads a file that was found by `getFilesInDirectoryAndSubdirectories`. + * Such files can vanish, get locked or become inaccessible in the meantime (e.g. by a running build or a file sync client). + */ +export const readFileFromDirectory = async (path: string): Promise => { + try { + return await createBufferFromPath(path); + } catch (error) { + const code = getCodeFromUnknownError(error); + const hint = code ? unreadableFileErrorHints[code] : undefined; + if (hint) { + throw new UserError(`The file could not be read: ${path}. ${hint}`); + } + throw error; + } +}; + export const directoryContainsSymlinks = async (path: string): Promise => { const dirEntries = await fs.promises.readdir(path, { withFileTypes: true, recursive: true }).catch(() => []); return dirEntries.some((dirEntry) => dirEntry.isSymbolicLink()); diff --git a/src/utils/manifest.ts b/src/utils/manifest.ts index e3dfcba..ff84939 100644 --- a/src/utils/manifest.ts +++ b/src/utils/manifest.ts @@ -1,6 +1,5 @@ import { MANIFEST_JSON_FILE_NAME } from '@/config/index.js'; -import { createBufferFromPath } from './buffer.js'; -import { getFilesInDirectoryAndSubdirectories, writeFile } from './file.js'; +import { getFilesInDirectoryAndSubdirectories, readFileFromDirectory, writeFile } from './file.js'; import { createHash } from './hash.js'; const ignoreFiles = ['.DS_Store', MANIFEST_JSON_FILE_NAME]; @@ -11,7 +10,7 @@ export const generateManifestJson = async (path: string) => { const files = await getFilesInDirectoryAndSubdirectories(path); // Iterate over each file for (const [index, file] of files.entries()) { - const fileBuffer = await createBufferFromPath(file.path); + const fileBuffer = await readFileFromDirectory(file.path); const checksum = await createHash(fileBuffer); const sizeInBytes = fileBuffer.byteLength; // Skip ignored files