From e18e3acadbc8760deef2fe595e0191ebe335f243 Mon Sep 17 00:00:00 2001 From: CR29-22-2805 <266851988+CR29-22-2805@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:46:35 -0500 Subject: [PATCH] Preserve duplicate WebView asset paths --- packages/cli/src/util/AssetUploader.test.ts | 80 +++++++++++++++++++++ packages/cli/src/util/AssetUploader.ts | 24 ++++++- 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/util/AssetUploader.test.ts b/packages/cli/src/util/AssetUploader.test.ts index 7c49eab0..754c8383 100644 --- a/packages/cli/src/util/AssetUploader.test.ts +++ b/packages/cli/src/util/AssetUploader.test.ts @@ -9,6 +9,26 @@ import { import { JSDOM } from 'jsdom'; import { afterEach, describe, expect, it, vi } from 'vitest'; +const appClient = vi.hoisted(() => ({ + CheckIfMediaExists: vi.fn(), + UploadNewMedia: vi.fn(), +})); + +vi.mock('./clientGenerators.js', () => ({ + createAppClient: () => appClient, +})); + +vi.mock('@oclif/core', () => ({ + ux: { + action: { + start: vi.fn(), + stop: vi.fn(), + }, + error: vi.fn(), + info: vi.fn(), + }, +})); + import { AssetUploader, queryAssets, transformHTML } from './AssetUploader.js'; import type { DevvitCommand } from './commands/DevvitCommand.js'; @@ -165,6 +185,66 @@ describe('assertAssetCanBeAnIcon', () => { } }); +describe('syncAssets()', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'devvit-assets-test-')); + fs.writeFileSync(path.join(tmpDir, 'header-logo.svg'), ''); + fs.writeFileSync(path.join(tmpDir, 'footer-logo.svg'), ''); + + appClient.CheckIfMediaExists.mockImplementation( + async ({ signatures }: { signatures: { filePath: string }[] }) => ({ + statuses: signatures.map((signature, index) => ({ + ...signature, + isNew: true, + uploadUrl: `https://uploads.example.com/assets/${index}?signature=test`, + uploadHeaders: {}, + })), + }) + ); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + }) + ); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); + + it('maps duplicate WebView asset paths to the same uploaded URL', async () => { + const cmd = { + project: { + root: tmpDir, + mediaDir: undefined, + clientDir: '.', + appConfig: undefined, + }, + error: vi.fn((message: string) => { + throw new Error(message); + }), + log: vi.fn(), + warn: vi.fn(), + }; + const assetUploader = new AssetUploader(cmd as unknown as DevvitCommand, 'some-slug', { + verbose: false, + }); + + const result = await assetUploader.syncAssets(); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(result.webViewAssetMap).toEqual({ + 'footer-logo.svg': 'https://uploads.example.com/assets/0', + 'header-logo.svg': 'https://uploads.example.com/assets/0', + }); + }); +}); + describe('queryAssets()', () => { let tmpDir: string; diff --git a/packages/cli/src/util/AssetUploader.ts b/packages/cli/src/util/AssetUploader.ts index 58e77b04..ab85f85c 100644 --- a/packages/cli/src/util/AssetUploader.ts +++ b/packages/cli/src/util/AssetUploader.ts @@ -54,6 +54,10 @@ type MediaSignatureWithContentsAndUploadInfo = MediaSignatureWithContents & { uploadHeaders: { [key: string]: string }; }; +function getAssetSignature(asset: Pick): string { + return `${asset.hash}:${asset.size}`; +} + type SyncAssetsResult = { assetMap: AssetMap | undefined; webViewAssetMap: AssetMap | undefined; @@ -457,6 +461,7 @@ export class AssetUploader { ux.action.start( `Uploading new WebView assets, ${assetsRemaining} remaining (${prettyPrintSize(sizeRemaining)})` ); + const uploadedUrlsBySignature = new Map(); await mapAsyncWithMaxConcurrency( newAssets, @@ -487,12 +492,24 @@ export class AssetUploader { } const uploadUrl = new URL(newAsset.uploadUrl); - assetMap[newAsset.filePath] = uploadUrl.origin + uploadUrl.pathname; + const publicUploadUrl = uploadUrl.origin + uploadUrl.pathname; + assetMap[newAsset.filePath] = publicUploadUrl; + uploadedUrlsBySignature.set(getAssetSignature(newAsset), publicUploadUrl); updateUploadMsg(newAsset.size); }, PARALLEL_UPLOADS ); + for (const duplicateAsset of duplicateAssets) { + const uploadedUrl = uploadedUrlsBySignature.get(getAssetSignature(duplicateAsset)); + if (!uploadedUrl) { + throw new Error( + `Could not find the uploaded WebView asset matching duplicate ${duplicateAsset.filePath}.` + ); + } + assetMap[duplicateAsset.filePath] = uploadedUrl; + } + ux.action.stop(`${newAssets.length} new WebView assets uploaded.`); ux.action.start(`Finishing upload`); @@ -536,6 +553,7 @@ export class AssetUploader { const newAssets: (MediaSignatureWithContents | MediaSignatureWithContentsAndUploadInfo)[] = []; const duplicateAssets: MediaSignatureWithContents[] = []; + const newAssetSignatures = new Set(); const existingAssets: AssetMap = {}; statuses.forEach((status) => { const asset = assetsByFilePath[status.filePath]; @@ -547,9 +565,11 @@ export class AssetUploader { if (status.isNew) { // The user may have the same asset in multiple places, but we need to // only upload it once - if (newAssets.find((a) => a.hash === asset.hash && a.size === asset.size)) { + const signature = getAssetSignature(asset); + if (newAssetSignatures.has(signature)) { duplicateAssets.push(asset); } else { + newAssetSignatures.add(signature); if (!areWebviewAssets) { newAssets.push(asset); return;