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
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import {generateManifestFile} from './include-assets/generate-manifest.js'
import {generateManifestFile, resolveOutputDir} from './include-assets/generate-manifest.js'
import {copyByPattern} from './include-assets/copy-by-pattern.js'
import {copySourceEntry} from './include-assets/copy-source-entry.js'
import {copyConfigKeyEntry} from './include-assets/copy-config-key-entry.js'
import {joinPath, dirname, extname, sanitizeRelativePath} from '@shopify/cli-kit/node/path'
import {joinPath, sanitizeRelativePath} from '@shopify/cli-kit/node/path'
import {z} from 'zod'
import type {LifecycleStep, BuildContext} from '../client-steps.js'

Expand Down Expand Up @@ -123,9 +123,7 @@ export async function executeIncludeAssetsStep(
): Promise<{filesCopied: number}> {
const config = IncludeAssetsConfigSchema.parse(step.config)
const {extension, options} = context
// When outputPath is a file (e.g. index.js, index.wasm), the output directory is its
// parent. When outputPath has no extension, it IS the output directory.
const outputDir = extname(extension.outputPath) ? dirname(extension.outputPath) : extension.outputPath
const outputDir = resolveOutputDir(extension.outputPath)

const aggregatedPathMap = new Map<string, string | string[]>()
// Track basenames written across all configKey entries in this build to detect
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ import {fileExists, mkdir, readFile, writeFile} from '@shopify/cli-kit/node/fs'
import {outputDebug} from '@shopify/cli-kit/node/output'
import type {BuildContext} from '../../client-steps.js'

/**
* Resolves the output directory from an extension's outputPath.
* When outputPath is a file (has extension), uses dirname. Otherwise uses outputPath directly.
*/
export function resolveOutputDir(outputPath: string): string {
return extname(outputPath) ? dirname(outputPath) : outputPath
}

interface ConfigKeyManifestEntry {
anchor?: string | undefined
groupBy?: string | undefined
Expand Down Expand Up @@ -121,12 +129,7 @@ export async function createOrUpdateManifestFile(
context: BuildContext,
entries: {[key: string]: unknown},
): Promise<void> {
const outputPath = context.extension.outputPath
/**
* Resolves the output directory from an extension's outputPath.
* When outputPath is a file (has extension), uses dirname. Otherwise uses outputPath directly.
*/
const outputDir = extname(outputPath) ? dirname(outputPath) : outputPath
const outputDir = resolveOutputDir(context.extension.outputPath)

const manifestPath = joinPath(outputDir, 'manifest.json')

Expand Down
1 change: 1 addition & 0 deletions packages/app/src/cli/services/dev/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ describe('devUIExtensions()', () => {
expect(store.ExtensionsPayloadStore).toHaveBeenCalledWith(
{mock: 'payload'},
{...options, websocketURL: 'wss://mock.url/extensions'},
expect.any(Map),
)
})

Expand Down
5 changes: 3 additions & 2 deletions packages/app/src/cli/services/dev/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,9 @@ export async function devUIExtensions(options: ExtensionDevOptions): Promise<voi
// affecting the original `options` object and we only need to care about `payloadOptions` in this function.

const bundlePath = payloadOptions.appWatcher.buildOutputPath
const payloadStoreRawPayload = await getExtensionsPayloadStoreRawPayload(payloadOptions, bundlePath)
const payloadStore = new ExtensionsPayloadStore(payloadStoreRawPayload, payloadOptions)
const assetResolvers = new Map()
const payloadStoreRawPayload = await getExtensionsPayloadStoreRawPayload(payloadOptions, bundlePath, assetResolvers)
const payloadStore = new ExtensionsPayloadStore(payloadStoreRawPayload, payloadOptions, assetResolvers)
let extensions = payloadOptions.extensions.filter((ext) => ext.isPreviewable)

const getExtensions = () => {
Expand Down
217 changes: 208 additions & 9 deletions packages/app/src/cli/services/dev/extension/payload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,12 +192,12 @@ describe('getUIExtensionPayload', () => {
assets: {
tools: {
name: 'tools',
url: 'http://tunnel-url.com/extensions/devUUID/assets/tools.json',
url: 'http://tunnel-url.com/extensions/devUUID/assets/CUSTOM_EXTENSION_POINT/tools.json',
lastUpdated: expect.any(Number),
},
instructions: {
name: 'instructions',
url: 'http://tunnel-url.com/extensions/devUUID/assets/instructions.md',
url: 'http://tunnel-url.com/extensions/devUUID/assets/CUSTOM_EXTENSION_POINT/instructions.md',
lastUpdated: expect.any(Number),
},
},
Expand Down Expand Up @@ -241,12 +241,12 @@ describe('getUIExtensionPayload', () => {
assets: {
main: {
name: 'main',
url: 'http://tunnel-url.com/extensions/devUUID/assets/test-ui-extension.js',
url: 'http://tunnel-url.com/extensions/devUUID/assets/CUSTOM_EXTENSION_POINT/main.js',
lastUpdated: expect.any(Number),
},
should_render: {
name: 'should_render',
url: 'http://tunnel-url.com/extensions/devUUID/assets/test-ui-extension-conditions.js',
url: 'http://tunnel-url.com/extensions/devUUID/assets/CUSTOM_EXTENSION_POINT/should_render.js',
lastUpdated: expect.any(Number),
},
},
Expand Down Expand Up @@ -295,12 +295,12 @@ describe('getUIExtensionPayload', () => {
assets: {
main: {
name: 'main',
url: 'http://tunnel-url.com/extensions/devUUID/assets/dist/test-ui-extension.js',
url: 'http://tunnel-url.com/extensions/devUUID/assets/CUSTOM_EXTENSION_POINT/main.js',
lastUpdated: expect.any(Number),
},
should_render: {
name: 'should_render',
url: 'http://tunnel-url.com/extensions/devUUID/assets/dist/test-ui-extension-conditions.js',
url: 'http://tunnel-url.com/extensions/devUUID/assets/CUSTOM_EXTENSION_POINT/should_render.js',
lastUpdated: expect.any(Number),
},
},
Expand All @@ -309,6 +309,205 @@ describe('getUIExtensionPayload', () => {
})
})

test('emits a distinct URL per extension point even when built assets share a filepath', async () => {
await inTemporaryDirectory(async (tmpDir) => {
const uiExtension = await testUIExtension({
directory: tmpDir,
configuration: {
name: 'test-ui-extension',
type: 'ui_extension',
extension_points: [
{target: 'TARGET_A', module: './src/ExtensionPointA.js'},
{target: 'TARGET_B', module: './src/ExtensionPointB.js'},
],
},
devUUID: 'devUUID',
})

await setupBuildOutput(
uiExtension,
tmpDir,
{
TARGET_A: {main: 'dist/main.js'},
TARGET_B: {main: 'dist/main.js'},
},
{},
)

const resolver = new Map<string, string>()
const got = await getUIExtensionPayload(
uiExtension,
tmpDir,
{
...createMockOptions(tmpDir, [uiExtension]),
currentDevelopmentPayload: {hidden: true, status: 'success'},
},
resolver,
)

expect(got.extensionPoints).toMatchObject([
{
target: 'TARGET_A',
assets: {
main: {name: 'main', url: 'http://tunnel-url.com/extensions/devUUID/assets/TARGET_A/main.js'},
},
},
{
target: 'TARGET_B',
assets: {
main: {name: 'main', url: 'http://tunnel-url.com/extensions/devUUID/assets/TARGET_B/main.js'},
},
},
])
expect(resolver.get('TARGET_A/main.js')).toBe('dist/main.js')
expect(resolver.get('TARGET_B/main.js')).toBe('dist/main.js')
})
})

test('emits a directory-prefix URL and per-file resolver entries when the config points at a folder', async () => {
await inTemporaryDirectory(async (tmpDir) => {
const uiExtension = await testUIExtension({
directory: tmpDir,
configuration: {
name: 'test-ui-extension',
type: 'ui_extension',
extension_points: [
{
target: 'CUSTOM_EXTENSION_POINT',
module: './src/ExtensionPointA.js',
assets: './assets',
},
],
},
devUUID: 'devUUID',
})

await setupBuildOutput(
uiExtension,
tmpDir,
{CUSTOM_EXTENSION_POINT: {assets: ['foo.json', 'subdir/bar.png']}},
{'foo.json': '{}', 'subdir/bar.png': 'stub'},
)

const extensionOutputPath = uiExtension.getOutputPathForDirectory(tmpDir)
const buildDirectory = extname(extensionOutputPath) ? dirname(extensionOutputPath) : extensionOutputPath
await writeFile(joinPath(buildDirectory, 'foo.json'), '{}')
await mkdir(joinPath(buildDirectory, 'subdir'))
await writeFile(joinPath(buildDirectory, 'subdir/bar.png'), 'stub')

const resolver = new Map<string, string>()
const got = await getUIExtensionPayload(
uiExtension,
tmpDir,
{
...createMockOptions(tmpDir, [uiExtension]),
currentDevelopmentPayload: {hidden: true, status: 'success'},
},
resolver,
)

expect(got.extensionPoints).toMatchObject([
{
target: 'CUSTOM_EXTENSION_POINT',
assets: {
assets: {
name: 'assets',
url: 'http://tunnel-url.com/extensions/devUUID/assets/CUSTOM_EXTENSION_POINT/assets/',
lastUpdated: expect.any(Number),
},
},
},
])
expect(resolver.get('CUSTOM_EXTENSION_POINT/assets/foo.json')).toBe('foo.json')
expect(resolver.get('CUSTOM_EXTENSION_POINT/assets/subdir/bar.png')).toBe('subdir/bar.png')
})
})

test('emits distinct directory URLs per extension point when two targets share the same assets folder', async () => {
await inTemporaryDirectory(async (tmpDir) => {
const uiExtension = await testUIExtension({
directory: tmpDir,
configuration: {
name: 'test-ui-extension',
type: 'ui_extension',
extension_points: [
{target: 'TARGET_A', module: './src/ExtensionPointA.js', assets: './assets'},
{target: 'TARGET_B', module: './src/ExtensionPointB.js', assets: './assets'},
],
},
devUUID: 'devUUID',
})

await setupBuildOutput(
uiExtension,
tmpDir,
{
TARGET_A: {assets: ['foo.json']},
TARGET_B: {assets: ['foo.json']},
},
{'foo.json': '{}'},
)
const extensionOutputPath = uiExtension.getOutputPathForDirectory(tmpDir)
const buildDirectory = extname(extensionOutputPath) ? dirname(extensionOutputPath) : extensionOutputPath
await writeFile(joinPath(buildDirectory, 'foo.json'), '{}')

const resolver = new Map<string, string>()
const got = await getUIExtensionPayload(
uiExtension,
tmpDir,
{
...createMockOptions(tmpDir, [uiExtension]),
currentDevelopmentPayload: {hidden: true, status: 'success'},
},
resolver,
)

expect(got.extensionPoints).toMatchObject([
{
target: 'TARGET_A',
assets: {assets: {url: 'http://tunnel-url.com/extensions/devUUID/assets/TARGET_A/assets/'}},
},
{
target: 'TARGET_B',
assets: {assets: {url: 'http://tunnel-url.com/extensions/devUUID/assets/TARGET_B/assets/'}},
},
])
// Both targets' resolver entries point at the same output-relative file.
expect(resolver.get('TARGET_A/assets/foo.json')).toBe('foo.json')
expect(resolver.get('TARGET_B/assets/foo.json')).toBe('foo.json')
})
})

test('clears stale resolver entries on each payload regeneration', async () => {
await inTemporaryDirectory(async (tmpDir) => {
const uiExtension = await testUIExtension({
directory: tmpDir,
configuration: {
name: 'test-ui-extension',
type: 'ui_extension',
extension_points: [{target: 'CUSTOM_EXTENSION_POINT', module: './src/ExtensionPointA.js'}],
},
devUUID: 'devUUID',
})

await setupBuildOutput(uiExtension, tmpDir, {CUSTOM_EXTENSION_POINT: {main: 'dist/main.js'}}, {})

const resolver = new Map<string, string>([['STALE_TARGET/main.js', 'stale.js']])
await getUIExtensionPayload(
uiExtension,
tmpDir,
{
...createMockOptions(tmpDir, [uiExtension]),
currentDevelopmentPayload: {hidden: true, status: 'success'},
},
resolver,
)

expect(resolver.has('STALE_TARGET/main.js')).toBe(false)
expect(resolver.get('CUSTOM_EXTENSION_POINT/main.js')).toBe('dist/main.js')
})
})

test('maps intents from manifest.json to asset payloads', async () => {
await inTemporaryDirectory(async (tmpDir) => {
const uiExtension = await testUIExtension({
Expand Down Expand Up @@ -351,7 +550,7 @@ describe('getUIExtensionPayload', () => {
action: 'create',
schema: {
name: 'schema',
url: 'http://tunnel-url.com/extensions/devUUID/assets/intents/create-schema.json',
url: 'http://tunnel-url.com/extensions/devUUID/assets/CUSTOM_EXTENSION_POINT/intents/0/schema.json',
lastUpdated: expect.any(Number),
},
},
Expand All @@ -360,7 +559,7 @@ describe('getUIExtensionPayload', () => {
action: 'update',
schema: {
name: 'schema',
url: 'http://tunnel-url.com/extensions/devUUID/assets/intents/update-schema.json',
url: 'http://tunnel-url.com/extensions/devUUID/assets/CUSTOM_EXTENSION_POINT/intents/1/schema.json',
lastUpdated: expect.any(Number),
},
},
Expand Down Expand Up @@ -461,7 +660,7 @@ describe('getUIExtensionPayload', () => {
assets: {
tools: {
name: 'tools',
url: 'http://tunnel-url.com/extensions/devUUID/assets/tools.json',
url: 'http://tunnel-url.com/extensions/devUUID/assets/admin.app.intent.link/tools.json',
lastUpdated: expect.any(Number),
},
},
Expand Down
Loading
Loading