diff --git a/lib/s3UploadFile.js b/lib/s3UploadFile.js index cbb306d4..b9753abf 100644 --- a/lib/s3UploadFile.js +++ b/lib/s3UploadFile.js @@ -1,6 +1,10 @@ import fs from 'fs'; +import path from 'path'; import mime from 'mime'; +const uploadError = status => + Object.assign(new Error(`Upload failed with status ${status}`), { statusCode: status }); + const uploadFile = async (fileName, s3Url) => { const stats = fs.statSync(fileName); const fileBuffer = fs.readFileSync(fileName); @@ -16,7 +20,7 @@ const uploadFile = async (fileName, s3Url) => { }); if (!response.ok) { - throw new Error(`Upload failed with status ${response.status}`); + throw uploadError(response.status); } return s3Url; @@ -36,7 +40,9 @@ const uploadFileFormData = async (filePath, data) => { formData.append('Content-Type', contentType); } - const fileName = filePath.split('/').pop(); + // S3 expands this into the ${filename} placeholder in the presigned key, so it + // must be the basename even when the caller passes an OS-native path. + const fileName = path.basename(filePath); formData.append('file', new File([fileBuffer], fileName, { type: contentType })); const response = await fetch(data.url, { @@ -45,10 +51,10 @@ const uploadFileFormData = async (filePath, data) => { }); if (!response.ok) { - throw new Error(`Upload failed with status ${response.status}`); + throw uploadError(response.status); } return true; }; -export { uploadFile, uploadFileFormData }; +export { uploadError, uploadFile, uploadFileFormData }; diff --git a/lib/watch.js b/lib/watch.js index 4bc13183..d0ab9599 100644 --- a/lib/watch.js +++ b/lib/watch.js @@ -2,7 +2,6 @@ import fs from 'fs'; import path from 'path'; import chokidar from 'chokidar'; import async from 'async'; -import cloneDeep from 'lodash.clonedeep'; import debounce from 'lodash.debounce'; import ServerError from './ServerError.js'; @@ -28,6 +27,13 @@ class AlreadyLoggedError extends Error { } } +// Report a sync failure without killing the process, then throw so callers know +// it was already logged — watch mode's queue continues, sync -f exits non-zero. +const failSync = async (logMessage, errorMessage = logMessage) => { + await logger.Error(logMessage, { exit: false, notify: false }); + throw new AlreadyLoggedError(errorMessage); +}; + const filePathUnixified = filePath => filePath .replace(/\\/g, '/') @@ -115,14 +121,12 @@ const pushFile = async (gateway, syncedFilePath) => { const body = e.response.body; const error = body.error || (body.errors && body.errors.join(', ')); if (error) { - await logger.Error(`[Sync] Failed to sync: ${filePath}\n${error}`, { exit: false, notify: false }); - throw new AlreadyLoggedError(error); + await failSync(`[Sync] Failed to sync: ${filePath}\n${error}`, error); } } // Network connection errors should not kill sync — it may be a transient failure if (e.name === 'RequestError') { - await logger.Error(`[Sync] Failed to sync: ${filePath}`, { exit: false, notify: false }); - throw new AlreadyLoggedError(e.message); + await failSync(`[Sync] Failed to sync: ${filePath}`, e.message); } // For HTTP status code errors, use the centralized handler await ServerError.handler(e); @@ -146,14 +150,12 @@ const deleteFile = async (gateway, syncedFilePath) => { const body = e.response.body; const error = body.error || (body.errors && body.errors.join(', ')); if (error) { - await logger.Error(`[Sync] Failed to delete: ${filePath}\n${error}`, { exit: false, notify: false }); - throw new AlreadyLoggedError(error); + await failSync(`[Sync] Failed to delete: ${filePath}\n${error}`, error); } } // Network connection errors should not kill sync — it may be a transient failure if (e.name === 'RequestError') { - await logger.Error(`[Sync] Failed to delete: ${filePath}`, { exit: false, notify: false }); - throw new AlreadyLoggedError(e.message); + await failSync(`[Sync] Failed to delete: ${filePath}`, e.message); } await ServerError.handler(e); } @@ -168,12 +170,33 @@ const pushFileDirectAssets = async (gateway, syncedFilePath) => { } }; +// Register the assets uploaded since the last flush. Building the manifest reads +// each file from disk, so it can fail just like the request itself; either way the +// batch is put back so the next flush retries it — the assets are already +// uploaded, they are just not registered yet. +const sendManifestBatch = async gateway => { + if (manifestFilesToAdd.length === 0) return; + + const batch = manifestFilesToAdd; + manifestFilesToAdd = []; + try { + const manifest = manifestGenerateForAssets(batch); + logger.Debug(manifest); + await gateway.sendManifest(manifest); + } catch (e) { + manifestFilesToAdd.push(...batch); + throw e; + } +}; + const manifestSend = debounce( gateway => { - const manifest = manifestGenerateForAssets(manifestFilesToAdd.slice()); - logger.Debug(manifest); - gateway.sendManifest(manifest); - manifestFilesToAdd = []; + // Fires from a debounce timer, outside any request's try/catch — an error here + // would be unhandled and kill the process. sendManifestBatch is async, so a + // throw while building the manifest arrives as a rejection too. + sendManifestBatch(gateway).catch(e => + logger.Error(`[Sync] Failed to update assets manifest: ${e.message || e}`, { exit: false, notify: false }) + ); }, 1000, { maxWait: 1000 * 10 } @@ -181,29 +204,57 @@ const manifestSend = debounce( const manifestAddAsset = path => manifestFilesToAdd.push(path); +const assetUploadData = normalizedPath => { + const fileSubdir = normalizedPath.startsWith('app/assets') + ? path.dirname(normalizedPath).replace('app/assets', '') + : '/' + path.dirname(normalizedPath).replace('/public/assets', ''); + const key = directUploadData.fields.key.replace('assets/${filename}', `assets${fileSubdir}/\${filename}`); + const data = { ...directUploadData, fields: { ...directUploadData.fields, key } }; + logger.Debug(data); + return data; +}; + +const uploadAsset = async (gateway, filePath, normalizedPath) => { + const authorizationUsed = directUploadData; + try { + await uploadFileFormData(filePath, assetUploadData(normalizedPath)); + } catch (e) { + // The presigned upload authorization is fetched once at sync start and + // expires server-side; from then on every asset upload gets a 403 until + // it is refreshed. Refresh and retry once instead of failing. + if (e.statusCode !== 403) throw e; + logger.Debug('[Sync] Asset upload authorization expired, refreshing it and retrying...'); + // A concurrent upload may have refreshed it already while this one was in + // flight — then just retry with what it fetched. + if (directUploadData === authorizationUsed) await refreshDirectUploadData(gateway); + await uploadFileFormData(filePath, assetUploadData(normalizedPath)); + } +}; + const sendAsset = async (gateway, filePath) => { + const normalizedPath = filePath.replace(/\\/g, '/'); try { - const data = cloneDeep(directUploadData); - const normalizedPath = filePath.replace(/\\/g, '/'); - const fileSubdir = normalizedPath.startsWith('app/assets') - ? path.dirname(normalizedPath).replace('app/assets', '') - : '/' + path.dirname(normalizedPath).replace('/public/assets', ''); - const key = data.fields.key.replace('assets/${filename}', `assets${fileSubdir}/\${filename}`); - data.fields.key = key; - logger.Debug(data); - await uploadFileFormData(filePath, data); + await uploadAsset(gateway, filePath, normalizedPath); manifestAddAsset(filePath); manifestSend(gateway); logger.Success(`[Sync] Synced asset: ${normalizedPath}`); } catch (e) { logger.Debug(e.message); logger.Debug(e.stack); - if (ServerError.isNetworkError(e)) { - await logger.Error(`[Sync] Failed to sync: ${filePath}`); + // Network connection errors should not kill sync — it may be a transient failure + if (e.name === 'RequestError') { + await failSync(`[Sync] Failed to sync: ${normalizedPath}`, e.message); + } + // Refreshing the upload authorization talks to the API, so its failures are + // HTTP status code errors — the centralized handler explains those (a 401 + // tells the user to refresh their token). + if (e.name === 'StatusCodeError') { await ServerError.handler(e); - } else { - await logger.Error(`[Sync] Failed to sync ${filePath}: ${e.message || e}`); } + const message = e.message || String(e); + // Asset upload failures must never kill watch mode — log and let the queue + // continue; the throw lets single-file mode (sync -f) exit non-zero. + await failSync(`[Sync] Failed to sync ${normalizedPath}: ${message}`, message); } }; @@ -214,6 +265,16 @@ const fetchDirectUploadData = async gateway => { directUploadData = data; }; +// The queue uploads several assets concurrently, so when the authorization +// expires they all get 403 at once — share one in-flight refresh between them. +let directUploadDataRefresh = null; +const refreshDirectUploadData = gateway => { + directUploadDataRefresh ||= fetchDirectUploadData(gateway).finally(() => { + directUploadDataRefresh = null; + }); + return directUploadDataRefresh; +}; + const start = async (env, directAssetsUpload, liveReload) => { const program = { email: env.MARKETPLACE_EMAIL, @@ -330,12 +391,10 @@ const sendFile = async (gateway, filePath) => { await pushFileDirectAssets(gateway, filePath); // If it was an asset file, we need to flush the manifest immediately - // since we're not in watch mode with debouncing - if (isAssetsPath(filePath) && manifestFilesToAdd.length > 0) { - const manifest = manifestGenerateForAssets(manifestFilesToAdd.slice()); - logger.Debug(manifest); - await gateway.sendManifest(manifest); - manifestFilesToAdd = []; + // since we're not in watch mode with debouncing. Unlike the debounced flush this + // one lets the error through, so a single-file sync exits non-zero. + if (isAssetsPath(filePath)) { + await sendManifestBatch(gateway); } }; diff --git a/package-lock.json b/package-lock.json index 79cb2f26..676997de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,7 +34,6 @@ "inquirer": "^13.3.0", "is-stream": "^4.0.1", "livereload": "^0.10.3", - "lodash.clonedeep": "^4.5.0", "lodash.compact": "^3.0.1", "lodash.debounce": "^4.0.8", "lodash.flatten": "^4.4.0", @@ -5463,12 +5462,6 @@ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, - "node_modules/lodash.clonedeep": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", - "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", - "license": "MIT" - }, "node_modules/lodash.compact": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/lodash.compact/-/lodash.compact-3.0.1.tgz", diff --git a/package.json b/package.json index 7897e028..36228dc9 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,6 @@ "inquirer": "^13.3.0", "is-stream": "^4.0.1", "livereload": "^0.10.3", - "lodash.clonedeep": "^4.5.0", "lodash.compact": "^3.0.1", "lodash.debounce": "^4.0.8", "lodash.flatten": "^4.4.0", diff --git a/test/unit/s3UploadFile.test.js b/test/unit/s3UploadFile.test.js index e2fa4823..cffa3233 100644 --- a/test/unit/s3UploadFile.test.js +++ b/test/unit/s3UploadFile.test.js @@ -4,6 +4,7 @@ */ import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; import fs from 'fs'; +import path from 'path'; import mime from 'mime'; // Mock fs module @@ -114,7 +115,11 @@ describe('s3UploadFile', () => { status: 403 }); - await expect(uploadFile(fileName, s3Url)).rejects.toThrow('Upload failed with status 403'); + // statusCode lets callers (sendAsset) detect an expired upload authorization and retry + await expect(uploadFile(fileName, s3Url)).rejects.toMatchObject({ + message: 'Upload failed with status 403', + statusCode: 403 + }); }); test('throws error when upload fails with 500 server error', async () => { @@ -260,8 +265,10 @@ describe('s3UploadFile', () => { expect(result).toBe(true); }); - test('correctly extracts filename from Unix path', async () => { - const filePath = '/home/user/projects/app/assets/images/logo.png'; + // S3 expands the uploaded file's name into the ${filename} placeholder in the + // presigned key, so anything but the basename puts the object at the wrong key + // while the upload still reports success. + const uploadedFileName = async filePath => { const data = { url: 'https://s3.amazonaws.com/bucket', fields: { key: 'assets/${filename}' } @@ -273,26 +280,21 @@ describe('s3UploadFile', () => { await uploadFileFormData(filePath, data); - // Verify FormData was created (we can't easily inspect FormData contents) - expect(global.fetch).toHaveBeenCalled(); - const fetchCall = global.fetch.mock.calls[0]; - expect(fetchCall[1].body).toBeInstanceOf(FormData); - }); - - test('correctly extracts filename from Windows path', async () => { - const filePath = 'C:\\Users\\user\\projects\\app\\assets\\images\\logo.png'; - const data = { - url: 'https://s3.amazonaws.com/bucket', - fields: { key: 'assets/${filename}' } - }; + const body = global.fetch.mock.calls[0][1].body; + expect(body).toBeInstanceOf(FormData); + return body.get('file').name; + }; - fs.readFileSync.mockReturnValue(Buffer.from('image')); - mime.getType.mockReturnValue('image/png'); - global.fetch.mockResolvedValue({ ok: true, status: 200 }); + test('correctly extracts filename from Unix path', async () => { + expect(await uploadedFileName('/home/user/projects/app/assets/images/logo.png')).toBe('logo.png'); + }); - await uploadFileFormData(filePath, data); + test('correctly extracts filename from a native OS path', async () => { + // Built with path.join so it uses backslashes on Windows, where a path split + // on '/' alone would yield the whole path as the filename. + const filePath = path.join('modules', 'community', 'public', 'assets', 'images', 'logo.png'); - expect(global.fetch).toHaveBeenCalled(); + expect(await uploadedFileName(filePath)).toBe('logo.png'); }); test('handles large file near 50MB limit with FormData', async () => { @@ -328,7 +330,10 @@ describe('s3UploadFile', () => { status: 403 }); - await expect(uploadFileFormData(filePath, data)).rejects.toThrow('Upload failed with status 403'); + await expect(uploadFileFormData(filePath, data)).rejects.toMatchObject({ + message: 'Upload failed with status 403', + statusCode: 403 + }); }); test('throws error when FormData upload fails with network error', async () => { @@ -584,7 +589,10 @@ describe('s3UploadFile', () => { status: 403 }); - await expect(uploadFile(fileName, s3Url)).rejects.toThrow('Upload failed with status 403'); + await expect(uploadFile(fileName, s3Url)).rejects.toMatchObject({ + message: 'Upload failed with status 403', + statusCode: 403 + }); }); test('uploadFileFormData handles invalid presigned data', async () => { diff --git a/test/unit/watch.test.js b/test/unit/watch.test.js index 41a84328..471dcf1c 100644 --- a/test/unit/watch.test.js +++ b/test/unit/watch.test.js @@ -8,7 +8,7 @@ * These tests lock in the correct behaviour: a 422 response must log a * human-readable error message and throw so the queue can continue. */ -import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; // --- module mocks (hoisted by vitest before any import) ------------------ @@ -58,7 +58,12 @@ vi.mock('#lib/directories.js', () => ({ })); vi.mock('#lib/watch-files-extensions.js', () => ({ default: ['liquid', 'yml'] })); vi.mock('#lib/assets/manifest.js', () => ({ manifestGenerateForAssets: vi.fn() })); -vi.mock('#lib/s3UploadFile.js', () => ({ uploadFileFormData: vi.fn() })); +// uploadError stays real so the tests build upload failures in exactly the shape +// the 403 retry keys on. +vi.mock('#lib/s3UploadFile.js', async () => ({ + ...(await vi.importActual('#lib/s3UploadFile.js')), + uploadFileFormData: vi.fn() +})); vi.mock('#lib/presignUrl.js', () => ({ presignDirectory: vi.fn() })); vi.mock('#lib/shouldBeSynced.js', () => ({ default: vi.fn() })); vi.mock('#lib/settings.js', () => ({ loadSettingsFileForModule: vi.fn().mockReturnValue({}) })); @@ -70,7 +75,10 @@ import fs from 'fs'; import logger from '#lib/logger.js'; import ServerError from '#lib/ServerError.js'; import Gateway from '#lib/proxy.js'; -import { pushFile, deleteFile, start, watchIgnored, handleWatcherError } from '#lib/watch.js'; +import { uploadError, uploadFileFormData } from '#lib/s3UploadFile.js'; +import { presignDirectory } from '#lib/presignUrl.js'; +import { manifestGenerateForAssets } from '#lib/assets/manifest.js'; +import { pushFile, deleteFile, sendFile, start, watchIgnored, handleWatcherError } from '#lib/watch.js'; // --- test helpers --------------------------------------------------------- @@ -270,6 +278,194 @@ describe('deleteFile', () => { }); }); +// --- asset sync tests (sendAsset via sendFile) ----------------------------- + +// Regression tests for the 403 crash: the presigned upload authorization is +// fetched once at sync start and expires server-side. sendAsset used to call +// logger.Error without { exit: false } on any upload failure, so an expired +// authorization killed the whole watch process with "Upload failed with +// status 403". Now a 403 refreshes the authorization and retries once, and no +// asset upload failure ever exits the process. +describe('asset sync', () => { + const assetPath = 'app/assets/style/main.css'; + + let gateway; + + beforeEach(() => { + // sendAsset schedules the debounced manifest flush on success; fake timers keep + // that 1s timer from firing during a later test. + vi.useFakeTimers(); + vi.clearAllMocks(); + manifestGenerateForAssets.mockReturnValue({ files: {} }); + presignDirectory.mockResolvedValue({ + url: 'https://s3.example.com/bucket', + fields: { key: 'assets/${filename}' } + }); + gateway = { + getInstance: vi.fn().mockResolvedValue({ id: 'inst-1' }), + sendManifest: vi.fn().mockResolvedValue({}) + }; + }); + + afterEach(async () => { + // Let the pending flush run instead of cancelling it: the debounce is module + // level, and cancelling its timer behind its back leaves it thinking one is + // still scheduled, so it would never fire again in any later test. + await vi.runOnlyPendingTimersAsync(); + vi.useRealTimers(); + }); + + test('uploads the asset and flushes the manifest on success', async () => { + uploadFileFormData.mockResolvedValue(true); + + await sendFile(gateway, assetPath); + + expect(uploadFileFormData).toHaveBeenCalledTimes(1); + expect(logger.Success).toHaveBeenCalledWith(`[Sync] Synced asset: ${assetPath}`); + expect(gateway.sendManifest).toHaveBeenCalled(); + expect(logger.Error).not.toHaveBeenCalled(); + }); + + test('refreshes the upload authorization and retries once on 403', async () => { + uploadFileFormData.mockRejectedValueOnce(uploadError(403)).mockResolvedValueOnce(true); + + await sendFile(gateway, assetPath); + + // Initial fetch at sendFile start + one refresh after the 403. + expect(presignDirectory).toHaveBeenCalledTimes(2); + expect(uploadFileFormData).toHaveBeenCalledTimes(2); + expect(logger.Success).toHaveBeenCalledWith(`[Sync] Synced asset: ${assetPath}`); + // The refresh is silent — debug-only trace, no user-facing warning or error. + expect(logger.Warn).not.toHaveBeenCalled(); + expect(logger.Error).not.toHaveBeenCalled(); + }); + + test('logs without exiting and throws when the 403 persists after a refresh', async () => { + uploadFileFormData.mockRejectedValue(uploadError(403)); + + await expect(sendFile(gateway, assetPath)).rejects.toMatchObject({ alreadyLogged: true }); + + // Exactly one retry — no infinite refresh loop. + expect(uploadFileFormData).toHaveBeenCalledTimes(2); + expect(logger.Error).toHaveBeenCalledWith( + `[Sync] Failed to sync ${assetPath}: Upload failed with status 403`, + { exit: false, notify: false } + ); + }); + + test('logs without exiting and throws on non-403 upload failures, with no retry', async () => { + uploadFileFormData.mockRejectedValue(uploadError(500)); + + await expect(sendFile(gateway, assetPath)).rejects.toMatchObject({ alreadyLogged: true }); + + expect(uploadFileFormData).toHaveBeenCalledTimes(1); + expect(presignDirectory).toHaveBeenCalledTimes(1); + expect(logger.Error).toHaveBeenCalledWith( + `[Sync] Failed to sync ${assetPath}: Upload failed with status 500`, + { exit: false, notify: false } + ); + }); + + test('normalizes Windows backslash paths in messages and throws with exit disabled', async () => { + uploadFileFormData.mockRejectedValue(uploadError(403)); + const windowsPath = 'modules\\community\\public\\assets\\style\\notification.css'; + + await expect(sendFile(gateway, windowsPath)).rejects.toMatchObject({ alreadyLogged: true }); + + expect(logger.Error).toHaveBeenCalledWith( + '[Sync] Failed to sync modules/community/public/assets/style/notification.css: Upload failed with status 403', + { exit: false, notify: false } + ); + }); + + test('sends an API error from the authorization refresh through the centralized handler', async () => { + // The refresh talks to the API, so its failures need the handler's guidance — + // a 401 has to tell the user to refresh their token, not just print the status. + const unauthorized = Object.assign(new Error('Request failed with status 401'), { + name: 'StatusCodeError', + statusCode: 401 + }); + uploadFileFormData.mockRejectedValue(uploadError(403)); + // The initial fetch succeeds; the refresh the 403 triggers is what fails. + gateway.getInstance.mockResolvedValueOnce({ id: 'inst-1' }).mockRejectedValue(unauthorized); + + await expect(sendFile(gateway, assetPath)).rejects.toMatchObject({ alreadyLogged: true }); + + expect(ServerError.handler).toHaveBeenCalledWith(unauthorized); + }); + + test('derives the S3 key per asset without mutating the shared authorization', async () => { + // presignDirectory hands back the same object every call, so a key written back + // into it would surface as a wrong key on the second upload. + uploadFileFormData.mockResolvedValue(true); + + await sendFile(gateway, assetPath); + + expect(uploadFileFormData).toHaveBeenLastCalledWith(assetPath, { + url: 'https://s3.example.com/bucket', + fields: { key: 'assets/style/${filename}' } + }); + + await sendFile(gateway, 'modules/community/public/assets/images/logo.png'); + + expect(uploadFileFormData).toHaveBeenLastCalledWith('modules/community/public/assets/images/logo.png', { + url: 'https://s3.example.com/bucket', + fields: { key: 'assets/modules/community/images/${filename}' } + }); + }); + + test('keeps the batch for the next flush when registering the assets fails', async () => { + uploadFileFormData.mockResolvedValue(true); + gateway.sendManifest.mockRejectedValueOnce(new Error('502 Bad Gateway')); + + // The asset itself uploaded — only registering it failed. + await expect(sendFile(gateway, assetPath)).rejects.toThrow('502 Bad Gateway'); + + gateway.sendManifest.mockResolvedValue({}); + await sendFile(gateway, 'app/assets/style/other.css'); + + // The asset that was dropped is registered along with the new one, and was not + // re-uploaded to get there. + expect(manifestGenerateForAssets).toHaveBeenLastCalledWith([assetPath, 'app/assets/style/other.css']); + expect(uploadFileFormData).toHaveBeenCalledTimes(2); + }); + + test('keeps the batch when the manifest cannot be built', async () => { + // Building the manifest stats every file, so an asset deleted right after its + // upload makes the build fail rather than the request. + uploadFileFormData.mockResolvedValue(true); + manifestGenerateForAssets.mockImplementationOnce(() => { + throw Object.assign(new Error(`ENOENT: no such file or directory, stat '${assetPath}'`), { code: 'ENOENT' }); + }); + + await expect(sendFile(gateway, assetPath)).rejects.toThrow('ENOENT'); + expect(gateway.sendManifest).not.toHaveBeenCalled(); + + await sendFile(gateway, assetPath); + + expect(gateway.sendManifest).toHaveBeenCalledTimes(1); + }); + + test('logs instead of throwing when the debounced flush fails', async () => { + // This flush fires from a timer, where a rejection would go unhandled and kill + // the process — the crash this whole path exists to prevent. + uploadFileFormData.mockResolvedValue(true); + gateway.sendManifest.mockRejectedValue(new Error('502 Bad Gateway')); + + await expect(sendFile(gateway, assetPath)).rejects.toThrow('502 Bad Gateway'); + await vi.advanceTimersByTimeAsync(1000); + + expect(logger.Error).toHaveBeenCalledWith('[Sync] Failed to update assets manifest: 502 Bad Gateway', { + exit: false, + notify: false + }); + + // Drain the retained batch so it does not leak into the next test. + gateway.sendManifest.mockResolvedValue({}); + await sendFile(gateway, assetPath); + }); +}); + // --- start() tests -------------------------------------------------------- describe('start', () => {