Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-ui-extension-dev-bundle-path.md
Original file line number Diff line number Diff line change
@@ -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.
152 changes: 152 additions & 0 deletions packages/app/src/cli/services/dev/update-extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
81 changes: 80 additions & 1 deletion packages/app/src/cli/services/dev/update-extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<string | undefined> {
const manifest = await readBundleManifest(buildDirectory)
if (!manifest) return undefined

const singleTarget = getSingleConfiguredTarget(extension.configuration as Record<string, unknown>)
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<Record<string, unknown> | 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<string, unknown>) {
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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}

export async function updateExtensionDraft({
extension,
developerPlatformClient,
Expand All @@ -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')
Expand Down
Loading