diff --git a/.changeset/fix-ui-extension-dev-bundle-path.md b/.changeset/fix-ui-extension-dev-bundle-path.md new file mode 100644 index 00000000000..f7d3c3a7728 --- /dev/null +++ b/.changeset/fix-ui-extension-dev-bundle-path.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +Fix `shopify app dev` draft uploads for generic UI extensions so serialized scripts use the generated bundle manifest path. diff --git a/packages/app/src/cli/services/dev/update-extension.test.ts b/packages/app/src/cli/services/dev/update-extension.test.ts index 67304d3b8b7..3a5b7d3eb0e 100644 --- a/packages/app/src/cli/services/dev/update-extension.test.ts +++ b/packages/app/src/cli/services/dev/update-extension.test.ts @@ -75,6 +75,158 @@ describe('updateExtensionDraft()', () => { }) }) + test('uses manifest main path for generic UI extension serialized script', async () => { + const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient() + await inTemporaryDirectory(async (tmpDir) => { + const target = 'purchase.checkout.block.render' + const configuration = { + name: 'test-ui-extension', + type: 'ui_extension', + handle, + uid: 'uid1', + extension_points: [ + { + target, + module: 'src/index.js', + build_manifest: { + assets: { + main: { + module: 'src/index.js', + filepath: `${handle}.js`, + }, + }, + }, + }, + ], + } as any + + const mockExtension = await testUIExtension({ + devUUID: '1', + configuration, + directory: tmpDir, + uid: 'uid1', + }) + + await mkdir(joinPath(tmpDir, 'uid1', 'dist')) + await writeFile( + joinPath(tmpDir, 'uid1', 'manifest.json'), + JSON.stringify({[target]: {main: `dist/${handle}.js`}}), + ) + await writeFile(joinPath(tmpDir, 'uid1', 'dist', `${handle}.js`), 'manifest content') + await writeFile(mockExtension.getOutputPathForDirectory(tmpDir), 'fallback content') + + await updateExtensionDraft({ + extension: mockExtension, + developerPlatformClient, + apiKey, + registrationId, + stdout, + stderr, + appConfiguration: placeholderAppConfiguration, + bundlePath: tmpDir, + }) + + const updateCall = vi.mocked(developerPlatformClient.updateExtension).mock.calls[0]![0] + const config = JSON.parse(updateCall.config) + expect(config.serialized_script).toBe(Buffer.from('manifest content').toString('base64')) + }) + }) + + test('falls back to output path when generic UI extension manifest is missing', async () => { + const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient() + await inTemporaryDirectory(async (tmpDir) => { + const configuration = { + name: 'test-ui-extension', + type: 'ui_extension', + handle, + uid: 'uid1', + extension_points: [ + { + target: 'purchase.checkout.block.render', + module: 'src/index.js', + build_manifest: { + assets: { + main: { + module: 'src/index.js', + filepath: `${handle}.js`, + }, + }, + }, + }, + ], + } as any + + const mockExtension = await testUIExtension({ + devUUID: '1', + configuration, + directory: tmpDir, + uid: 'uid1', + }) + + await mkdir(joinPath(tmpDir, 'uid1')) + await writeFile(mockExtension.getOutputPathForDirectory(tmpDir), 'fallback content') + + await updateExtensionDraft({ + extension: mockExtension, + developerPlatformClient, + apiKey, + registrationId, + stdout, + stderr, + appConfiguration: placeholderAppConfiguration, + bundlePath: tmpDir, + }) + + const updateCall = vi.mocked(developerPlatformClient.updateExtension).mock.calls[0]![0] + const config = JSON.parse(updateCall.config) + expect(config.serialized_script).toBe(Buffer.from('fallback content').toString('base64')) + }) + }) + + test('continues to use output path for checkout UI extension serialized script', async () => { + const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient() + await inTemporaryDirectory(async (tmpDir) => { + const target = 'purchase.checkout.block.render' + const configuration = { + name: 'test-checkout-extension', + type: 'checkout_ui_extension', + handle, + uid: 'uid1', + extension_points: [target], + } as any + + const mockExtension = await testUIExtension({ + devUUID: '1', + configuration, + directory: tmpDir, + uid: 'uid1', + }) + + await mkdir(joinPath(tmpDir, 'uid1', 'dist')) + await writeFile( + joinPath(tmpDir, 'uid1', 'manifest.json'), + JSON.stringify({[target]: {main: `dist/${handle}-from-manifest.js`}}), + ) + await writeFile(joinPath(tmpDir, 'uid1', 'dist', `${handle}-from-manifest.js`), 'manifest content') + await writeFile(mockExtension.getOutputPathForDirectory(tmpDir), 'checkout content') + + await updateExtensionDraft({ + extension: mockExtension, + developerPlatformClient, + apiKey, + registrationId, + stdout, + stderr, + appConfiguration: placeholderAppConfiguration, + bundlePath: tmpDir, + }) + + const updateCall = vi.mocked(developerPlatformClient.updateExtension).mock.calls[0]![0] + const config = JSON.parse(updateCall.config) + expect(config.serialized_script).toBe(Buffer.from('checkout content').toString('base64')) + }) + }) + test('updates draft successfully with context for extension with target', async () => { const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient() const mockExtension = await testPaymentExtensions() diff --git a/packages/app/src/cli/services/dev/update-extension.ts b/packages/app/src/cli/services/dev/update-extension.ts index 7252018f7b5..66165b1e4a8 100644 --- a/packages/app/src/cli/services/dev/update-extension.ts +++ b/packages/app/src/cli/services/dev/update-extension.ts @@ -8,6 +8,7 @@ import {DeveloperPlatformClient} from '../../utilities/developer-platform-client import {themeExtensionConfig} from '../deploy/theme-extension-config.js' import {readFile} from '@shopify/cli-kit/node/fs' import {outputInfo} from '@shopify/cli-kit/node/output' +import {dirname, isAbsolutePath, isSubpath, joinPath, resolvePath} from '@shopify/cli-kit/node/path' import {Writable} from 'stream' interface UpdateExtensionDraftOptions { @@ -21,6 +22,84 @@ interface UpdateExtensionDraftOptions { bundlePath: string } +async function getSerializedScriptOutputPath(extension: ExtensionInstance, bundlePath: string) { + const fallbackOutputPath = extension.getOutputPathForDirectory(bundlePath) + if (extension.type !== 'ui_extension') return fallbackOutputPath + + const buildDirectory = dirname(fallbackOutputPath) + const manifestMainPath = await getManifestMainPath(extension, buildDirectory) + + const manifestOutputPath = manifestMainPath + ? getSafeManifestOutputPath(buildDirectory, manifestMainPath) + : undefined + + return manifestOutputPath ?? fallbackOutputPath +} + +async function getManifestMainPath(extension: ExtensionInstance, buildDirectory: string): Promise { + const manifest = await readBundleManifest(buildDirectory) + if (!manifest) return undefined + + const singleTarget = getSingleConfiguredTarget(extension.configuration as Record) + if (singleTarget) { + const targetMainPath = getManifestEntryMainPath(manifest[singleTarget]) + if (targetMainPath) return targetMainPath + } + + const mainPaths = Object.keys(manifest) + .sort() + .map((target) => getManifestEntryMainPath(manifest[target])) + .filter((mainPath): mainPath is string => mainPath !== undefined) + + return mainPaths[0] +} + +async function readBundleManifest(buildDirectory: string): Promise | undefined> { + try { + const content = await readFile(joinPath(buildDirectory, 'manifest.json')) + const parsedManifest = JSON.parse(content) + return isRecord(parsedManifest) ? parsedManifest : undefined + // eslint-disable-next-line no-catch-all/no-catch-all + } catch { + return undefined + } +} + +function getSingleConfiguredTarget(configuration: Record) { + const targets = [...getTargets(configuration.extension_points), ...getTargets(configuration.targeting)] + const uniqueTargets = [...new Set(targets)] + + return uniqueTargets.length === 1 ? uniqueTargets[0] : undefined +} + +function getTargets(targeting: unknown) { + if (!Array.isArray(targeting)) return [] + + return targeting.flatMap((target) => { + if (!isRecord(target) || typeof target.target !== 'string') return [] + return target.target + }) +} + +function getManifestEntryMainPath(entry: unknown) { + if (!isRecord(entry) || typeof entry.main !== 'string' || entry.main.length === 0) return undefined + return entry.main +} + +function getSafeManifestOutputPath(buildDirectory: string, manifestMainPath: string) { + if (isAbsolutePath(manifestMainPath)) return undefined + + const outputPath = joinPath(buildDirectory, manifestMainPath) + const resolvedBuildDirectory = resolvePath(buildDirectory) + const resolvedOutputPath = resolvePath(outputPath) + + return isSubpath(resolvedBuildDirectory, resolvedOutputPath) ? outputPath : undefined +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + export async function updateExtensionDraft({ extension, developerPlatformClient, @@ -32,8 +111,8 @@ export async function updateExtensionDraft({ bundlePath, }: UpdateExtensionDraftOptions) { let encodedFile: string | undefined - const outputPath = extension.getOutputPathForDirectory(bundlePath) if (extension.features.includes('esbuild')) { + const outputPath = await getSerializedScriptOutputPath(extension, bundlePath) const content = await readFile(outputPath) if (!content) return encodedFile = Buffer.from(content).toString('base64')