diff --git a/package.json b/package.json index acd7a09aa7f..538d421af1b 100644 --- a/package.json +++ b/package.json @@ -262,7 +262,6 @@ ], "project": "**/*.{ts,tsx}!", "ignoreBinaries": [ - "bundle", "open" ], "vite": { diff --git a/packages/app/src/cli/commands/app/import-extensions.ts b/packages/app/src/cli/commands/app/import-extensions.ts index 100e470b364..e2b05631cf1 100644 --- a/packages/app/src/cli/commands/app/import-extensions.ts +++ b/packages/app/src/cli/commands/app/import-extensions.ts @@ -93,6 +93,7 @@ export default class ImportExtensions extends AppCommand { renderFatalError(new AbortError('Invalid migration choice')) process.exit(1) } + await importExtensions({ ...appContext, extensionTypes: migrationChoice.extensionTypes, diff --git a/packages/app/src/cli/services/dev/app-events/app-event-watcher.test.ts b/packages/app/src/cli/services/dev/app-events/app-event-watcher.test.ts index 7c8a416615b..64cf2932187 100644 --- a/packages/app/src/cli/services/dev/app-events/app-event-watcher.test.ts +++ b/packages/app/src/cli/services/dev/app-events/app-event-watcher.test.ts @@ -275,7 +275,15 @@ describe('app-event-watcher when receiving a file event', () => { path: expect.anything(), }) - expect(emitSpy).toHaveBeenCalledWith('ready', app) + const initialEvents = app.realExtensions.map((eve) => ({ + type: EventType.Updated, + extension: eve, + buildResult: {status: 'ok', handle: eve.handle}, + })) + expect(emitSpy).toHaveBeenCalledWith('ready', { + app, + extensionEvents: expect.arrayContaining(initialEvents), + }) if (needsAppReload) { expect(loadApp).toHaveBeenCalledWith({ diff --git a/packages/app/src/cli/services/dev/app-events/app-event-watcher.ts b/packages/app/src/cli/services/dev/app-events/app-event-watcher.ts index 2f3df7fd128..d2f4c16ac5c 100644 --- a/packages/app/src/cli/services/dev/app-events/app-event-watcher.ts +++ b/packages/app/src/cli/services/dev/app-events/app-event-watcher.ts @@ -95,6 +95,7 @@ export class AppEventWatcher extends EventEmitter { private readonly esbuildManager: ESBuildContextManager private started = false private ready = false + private initialEvents: ExtensionEvent[] = [] constructor( app: AppLinkedInterface, @@ -133,7 +134,8 @@ export class AppEventWatcher extends EventEmitter { await this.esbuildManager.createContexts(this.app.realExtensions.filter((ext) => ext.isESBuildExtension)) // Initial build of all extensions - await this.buildExtensions(this.app.realExtensions.map((ext) => ({type: EventType.Updated, extension: ext}))) + this.initialEvents = this.app.realExtensions.map((ext) => ({type: EventType.Updated, extension: ext})) + await this.buildExtensions(this.initialEvents) // Start the file system watcher await startFileWatcher(this.app, this.options, (events) => { @@ -161,7 +163,7 @@ export class AppEventWatcher extends EventEmitter { }) this.ready = true - this.emit('ready', this.app) + this.emit('ready', {app: this.app, extensionEvents: this.initialEvents}) } /** @@ -183,9 +185,10 @@ export class AppEventWatcher extends EventEmitter { * @param listener - The listener function to add * @returns The AppEventWatcher instance */ - onStart(listener: (app: AppLinkedInterface) => Promise | void) { + onStart(listener: (appEvent: AppEvent) => Promise | void) { if (this.ready) { - listener(this.app)?.catch(() => {}) + const event: AppEvent = {app: this.app, extensionEvents: this.initialEvents, startTime: [0, 0], path: ''} + listener(event)?.catch(() => {}) } else { // eslint-disable-next-line @typescript-eslint/no-misused-promises this.once('ready', listener) diff --git a/packages/app/src/cli/services/dev/extension.test.ts b/packages/app/src/cli/services/dev/extension.test.ts index 1972d7ca55a..bf8f449810b 100644 --- a/packages/app/src/cli/services/dev/extension.test.ts +++ b/packages/app/src/cli/services/dev/extension.test.ts @@ -1,10 +1,11 @@ import * as store from './extension/payload/store.js' import * as server from './extension/server.js' import * as websocket from './extension/websocket.js' -import * as bundler from './extension/bundler.js' import {devUIExtensions, ExtensionDevOptions} from './extension.js' import {ExtensionsEndpointPayload} from './extension/payload/models.js' import {WebsocketConnection} from './extension/websocket/models.js' +import {AppEventWatcher} from './app-events/app-event-watcher.js' +import {testAppLinked} from '../../models/app/app.test-data.js' import {describe, test, vi, expect} from 'vitest' import {Server} from 'http' @@ -12,6 +13,7 @@ describe('devUIExtensions()', () => { const serverCloseSpy = vi.fn() const websocketCloseSpy = vi.fn() const bundlerCloseSpy = vi.fn() + const app = testAppLinked() const options = { mock: 'options', @@ -20,6 +22,7 @@ describe('devUIExtensions()', () => { stdout: process.stdout, stderr: process.stderr, checkoutCartUrl: 'mock/path/from/extensions', + appWatcher: new AppEventWatcher(app, 'url', 'path'), } as unknown as ExtensionDevOptions function spyOnEverything() { @@ -37,9 +40,6 @@ describe('devUIExtensions()', () => { vi.spyOn(websocket, 'setupWebsocketConnection').mockReturnValue({ close: websocketCloseSpy, } as unknown as WebsocketConnection) - vi.spyOn(bundler, 'setupBundlerAndFileWatcher').mockResolvedValue({ - close: bundlerCloseSpy, - }) } test('initializes the payload store', async () => { @@ -85,20 +85,6 @@ describe('devUIExtensions()', () => { }) }) - test('initializes the bundler and file watcher', async () => { - // GIVEN - spyOnEverything() - - // WHEN - await devUIExtensions(options) - - // THEN - expect(bundler.setupBundlerAndFileWatcher).toHaveBeenCalledWith({ - devOptions: options, - payloadStore: {mock: 'payload-store'}, - }) - }) - test('closes the http server, websocket and bundler when the process aborts', async () => { // GIVEN spyOnEverything() @@ -114,7 +100,6 @@ describe('devUIExtensions()', () => { abortEventCallback() - expect(bundlerCloseSpy).toHaveBeenCalledOnce() expect(websocketCloseSpy).toHaveBeenCalledOnce() expect(serverCloseSpy).toHaveBeenCalledOnce() }) diff --git a/packages/app/src/cli/services/dev/extension.ts b/packages/app/src/cli/services/dev/extension.ts index 7b785653a72..957eccee106 100644 --- a/packages/app/src/cli/services/dev/extension.ts +++ b/packages/app/src/cli/services/dev/extension.ts @@ -1,7 +1,7 @@ import {setupWebsocketConnection} from './extension/websocket.js' -import {setupBundlerAndFileWatcher} from './extension/bundler.js' import {setupHTTPServer} from './extension/server.js' import {ExtensionsPayloadStore, getExtensionsPayloadStoreRawPayload} from './extension/payload/store.js' +import {AppEvent, AppEventWatcher} from './app-events/app-event-watcher.js' import {ExtensionInstance} from '../../models/extensions/extension-instance.js' import {AbortSignal} from '@shopify/cli-kit/node/abort' import {outputDebug} from '@shopify/cli-kit/node/output' @@ -101,6 +101,11 @@ export interface ExtensionDevOptions { * This is exposed in the JSON payload for clients connecting to the Dev Server */ manifestVersion: string + + /** + * The app watcher that emits events when the app is updated + */ + appWatcher: AppEventWatcher } export async function devUIExtensions(options: ExtensionDevOptions): Promise { @@ -108,7 +113,8 @@ export async function devUIExtensions(options: ExtensionDevOptions): Promise { + for (const event of extensionEvents) { + const status = event.buildResult?.status === 'ok' ? 'success' : 'error' + // eslint-disable-next-line no-await-in-loop + await payloadStore.updateExtension(event.extension, options, bundlePath, {status}) + } + } + + options.appWatcher.onEvent(eventHandler).onStart(eventHandler) options.signal.addEventListener('abort', () => { outputDebug('Closing the UI extensions dev server...') - fileWatcher.close() websocketConnection.close() httpServer.close() }) diff --git a/packages/app/src/cli/services/dev/extension/bundler.test.ts b/packages/app/src/cli/services/dev/extension/bundler.test.ts index 551e37f9905..d093af6cc36 100644 --- a/packages/app/src/cli/services/dev/extension/bundler.test.ts +++ b/packages/app/src/cli/services/dev/extension/bundler.test.ts @@ -1,16 +1,5 @@ -import { - FileWatcherOptions, - SetupExtensionWatcherOptions, - setupBundlerAndFileWatcher, - setupExtensionWatcher, -} from './bundler.js' -import * as bundle from '../../extensions/bundle.js' -import { - testUIExtension, - testFunctionExtension, - testApp, - testAppConfigExtensions, -} from '../../../models/app/app.test-data.js' +import {SetupExtensionWatcherOptions, setupExtensionWatcher} from './bundler.js' +import {testFunctionExtension, testApp, testAppConfigExtensions} from '../../../models/app/app.test-data.js' import {reloadExtensionConfig} from '../update-extension.js' import {FunctionConfigType} from '../../../models/extensions/specifications/function.js' import * as extensionBuild from '../../../services/build/extension.js' @@ -18,7 +7,6 @@ import {ExtensionInstance} from '../../../models/extensions/extension-instance.j import {BaseConfigType} from '../../../models/extensions/schemas.js' import {beforeEach, describe, expect, test, vi} from 'vitest' import chokidar from 'chokidar' -import {BuildResult} from 'esbuild' import {AbortController, AbortSignal} from '@shopify/cli-kit/node/abort' import {outputDebug, outputWarn} from '@shopify/cli-kit/node/output' import {flushPromises} from '@shopify/cli-kit/node/promises' @@ -32,53 +20,6 @@ vi.mock('../update-extension.js') vi.mock('../../../services/build/extension.js') vi.mock('../update-extension.js') -async function testBundlerAndFileWatcher() { - const extension1 = await testUIExtension({ - devUUID: '1', - directory: 'directory/path/1', - configuration: { - handle: 'my-handle', - name: 'test-ui-extension', - type: 'product_subscription', - metafields: [], - }, - }) - - const extension2 = await testUIExtension({ - devUUID: '2', - directory: 'directory/path/2', - configuration: { - handle: 'my-handle-2', - name: 'test-ui-extension', - type: 'product_subscription', - metafields: [], - }, - }) - - const fileWatcherOptions = { - devOptions: { - extensions: [extension1, extension2], - appDotEnvFile: { - variables: { - SOME_KEY: 'SOME_VALUE', - }, - }, - url: 'mock/url', - stderr: { - mockStdErr: 'STD_ERR', - }, - stdout: { - mockStdOut: 'STD_OUT', - }, - }, - payloadStore: { - updateExtension: vi.fn(() => Promise.resolve(undefined)), - }, - } as unknown as FileWatcherOptions - await setupBundlerAndFileWatcher(fileWatcherOptions) - return fileWatcherOptions -} - function functionConfiguration(): FunctionConfigType { return { name: 'foo', @@ -90,153 +31,6 @@ function functionConfiguration(): FunctionConfigType { } } -describe('setupBundlerAndFileWatcher()', () => { - test("call 'bundleExtension' for each extension", async () => { - vi.spyOn(bundle, 'bundleExtension').mockResolvedValue(undefined) - vi.spyOn(chokidar, 'watch').mockReturnValue({ - on: vi.fn() as any, - } as any) - - await testBundlerAndFileWatcher() - - expect(bundle.bundleExtension).toHaveBeenCalledWith( - expect.objectContaining({ - minify: false, - outputPath: 'directory/path/1/dist/my-handle.js', - stdin: { - contents: "import './src/index.js';", - resolveDir: 'directory/path/1', - loader: 'tsx', - }, - environment: 'development', - env: { - SOME_KEY: 'SOME_VALUE', - APP_URL: 'mock/url', - }, - stderr: { - mockStdErr: 'STD_ERR', - }, - stdout: { - mockStdOut: 'STD_OUT', - }, - }), - ) - - expect(bundle.bundleExtension).toHaveBeenCalledWith( - expect.objectContaining({ - minify: false, - outputPath: 'directory/path/2/dist/my-handle-2.js', - stdin: { - contents: "import './src/index.js';", - resolveDir: 'directory/path/2', - loader: 'tsx', - }, - environment: 'development', - env: { - SOME_KEY: 'SOME_VALUE', - APP_URL: 'mock/url', - }, - stderr: { - mockStdErr: 'STD_ERR', - }, - stdout: { - mockStdOut: 'STD_OUT', - }, - }), - ) - }) - - test("Call 'updateExtension' with status success when no error occurs", async () => { - // GIVEN - vi.spyOn(bundle, 'bundleExtension').mockResolvedValue(undefined) - vi.spyOn(chokidar, 'watch').mockReturnValue({ - on: vi.fn() as any, - } as any) - const fileWatcherOptions = await testBundlerAndFileWatcher() - - // WHEN - const bundleExtensionFn = bundle.bundleExtension as any - bundleExtensionFn.mock.calls[0][0].watch() - - // THEN - expect(fileWatcherOptions.payloadStore.updateExtension).toHaveBeenCalledWith( - fileWatcherOptions.devOptions.extensions[0], - fileWatcherOptions.devOptions, - {status: 'success'}, - ) - }) - - test("Call 'updateExtension' with status error when an error occurs", async () => { - // GIVEN - vi.spyOn(bundle, 'bundleExtension').mockResolvedValue(undefined) - vi.spyOn(chokidar, 'watch').mockReturnValue({ - on: vi.fn() as any, - } as any) - const fileWatcherOptions = await testBundlerAndFileWatcher() - - // WHEN - const buildFailure = { - errors: ['error'] as any, - warnings: [], - outputFiles: [], - metafile: {} as any, - mangleCache: {}, - } as BuildResult - const bundleExtensionFn = bundle.bundleExtension as any - bundleExtensionFn.mock.calls[0][0].watch(buildFailure) - - // THEN - expect(fileWatcherOptions.payloadStore.updateExtension).toHaveBeenCalledWith( - fileWatcherOptions.devOptions.extensions[0], - fileWatcherOptions.devOptions, - {status: 'error'}, - ) - }) - - test('Watches the locales directory for change events', async () => { - // GIVEN - const chokidarOnSpy = vi.fn() as any - vi.spyOn(bundle, 'bundleExtension').mockResolvedValue(undefined) - vi.spyOn(chokidar, 'watch').mockReturnValue({ - on: chokidarOnSpy, - } as any) - - // WHEN - await testBundlerAndFileWatcher() - - // THEN - expect(chokidar.watch).toHaveBeenCalledWith('directory/path/1/locales/**.json') - expect(chokidar.watch).toHaveBeenCalledWith('directory/path/2/locales/**.json') - expect(chokidarOnSpy).toHaveBeenCalledTimes(2) - expect(chokidarOnSpy).toHaveBeenCalledWith('change', expect.any(Function)) - }) - - test('Updates the extension when a locale changes', async () => { - const chokidarOnSpy = vi.fn() as any - - // GIVEN - vi.spyOn(bundle, 'bundleExtension').mockResolvedValue(undefined) - vi.spyOn(chokidar, 'watch').mockReturnValue({ - on: chokidarOnSpy, - } as any) - - // WHEN - const fileWatcherOptions = await testBundlerAndFileWatcher() - chokidarOnSpy.mock.calls[0][1]() - chokidarOnSpy.mock.calls[1][1]() - - // THEN - expect(fileWatcherOptions.payloadStore.updateExtension).toHaveBeenCalledWith( - fileWatcherOptions.devOptions.extensions[0], - fileWatcherOptions.devOptions, - ) - expect(fileWatcherOptions.payloadStore.updateExtension).toHaveBeenCalledWith( - fileWatcherOptions.devOptions.extensions[1], - fileWatcherOptions.devOptions, - ) - }) -}) - describe('setupExtensionWatcher', () => { beforeEach(() => { const config = {type: 'type', name: 'name', path: 'path', metafields: []} diff --git a/packages/app/src/cli/services/dev/extension/bundler.ts b/packages/app/src/cli/services/dev/extension/bundler.ts index 89e05619148..e5ecdbb1062 100644 --- a/packages/app/src/cli/services/dev/extension/bundler.ts +++ b/packages/app/src/cli/services/dev/extension/bundler.ts @@ -1,13 +1,8 @@ -import {ExtensionsPayloadStore} from './payload/store.js' -import {ExtensionDevOptions} from '../extension.js' -import {bundleExtension} from '../../extensions/bundle.js' - import {AppInterface} from '../../../models/app/app.js' import {reloadExtensionConfig} from '../update-extension.js' import {ExtensionInstance} from '../../../models/extensions/extension-instance.js' import {ExtensionBuildOptions} from '../../build/extension.js' import {AbortController, AbortSignal} from '@shopify/cli-kit/node/abort' -import {joinPath} from '@shopify/cli-kit/node/path' import {outputDebug, outputWarn} from '@shopify/cli-kit/node/output' import {FSWatcher} from 'chokidar' import micromatch from 'micromatch' @@ -15,99 +10,6 @@ import {deepCompare} from '@shopify/cli-kit/common/object' import {Writable} from 'stream' import {AsyncResource} from 'async_hooks' -export interface FileWatcherOptions { - devOptions: ExtensionDevOptions - payloadStore: ExtensionsPayloadStore -} - -export async function setupBundlerAndFileWatcher(options: FileWatcherOptions) { - const {default: chokidar} = await import('chokidar') - const abortController = new AbortController() - - const bundlers: Promise[] = [] - - const extensions = options.devOptions.extensions.filter((ext) => ext.isESBuildExtension) - - // eslint-disable-next-line @typescript-eslint/no-misused-promises - extensions.forEach(async (extension) => { - bundlers.push( - bundleExtension({ - minify: false, - outputPath: extension.outputPath, - environment: 'development', - env: { - ...(options.devOptions.appDotEnvFile?.variables ?? {}), - APP_URL: options.devOptions.url, - }, - stdin: { - contents: extension.getBundleExtensionStdinContent(), - resolveDir: extension.directory, - loader: 'tsx', - }, - stderr: options.devOptions.stderr, - stdout: options.devOptions.stdout, - watchSignal: abortController.signal, - watch: async (result) => { - const error = (result?.errors?.length ?? 0) > 0 - outputDebug( - `The Javascript bundle of the UI extension with ID ${extension.devUUID} has ${ - error ? 'an error' : 'changed' - }`, - error ? options.devOptions.stderr : options.devOptions.stdout, - ) - - try { - await options.payloadStore.updateExtension(extension, options.devOptions, { - status: error ? 'error' : 'success', - }) - // eslint-disable-next-line no-catch-all/no-catch-all - } catch { - // ESBuild handles error output - } - }, - sourceMaps: true, - }), - ) - - const localeWatcher = chokidar - .watch(joinPath(extension.directory, 'locales', '**.json')) - .on('change', (_event, path) => { - outputDebug(`Locale file at path ${path} changed`, options.devOptions.stdout) - options.payloadStore - .updateExtension(extension, options.devOptions) - .then((_closed) => { - outputDebug(`Notified extension ${extension.devUUID} about the locale change.`, options.devOptions.stdout) - }) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .catch((_: any) => {}) - }) - - abortController.signal.addEventListener('abort', () => { - outputDebug(`Closing locale file watching for extension with ID ${extension.devUUID}`, options.devOptions.stdout) - localeWatcher - .close() - .then(() => { - outputDebug(`Locale file watching closed for extension with ${extension.devUUID}`, options.devOptions.stdout) - }) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .catch((error: any) => { - outputDebug( - `Locale file watching failed to close for extension with ${extension.devUUID}: ${error.message}`, - options.devOptions.stderr, - ) - }) - }) - }) - - await Promise.all(bundlers) - - return { - close: () => { - abortController.abort() - }, - } -} - export interface SetupExtensionWatcherOptions { extension: ExtensionInstance app: AppInterface diff --git a/packages/app/src/cli/services/dev/extension/payload.test.ts b/packages/app/src/cli/services/dev/extension/payload.test.ts index a748362ef8a..285759d9b21 100644 --- a/packages/app/src/cli/services/dev/extension/payload.test.ts +++ b/packages/app/src/cli/services/dev/extension/payload.test.ts @@ -45,7 +45,7 @@ describe('getUIExtensionPayload', () => { devUUID: 'devUUID', }) - const options: ExtensionDevOptions = { + const options: Omit = { signal, stdout, stderr, @@ -69,7 +69,7 @@ describe('getUIExtensionPayload', () => { } // When - const got = await getUIExtensionPayload(uiExtension, { + const got = await getUIExtensionPayload(uiExtension, 'mock-bundle-path', { ...options, currentDevelopmentPayload: development, }) @@ -128,7 +128,7 @@ describe('getUIExtensionPayload', () => { const development: Partial = {} // When - const got = await getUIExtensionPayload(uiExtension, { + const got = await getUIExtensionPayload(uiExtension, 'mock-bundle-path', { ...options, currentDevelopmentPayload: development, }) @@ -195,7 +195,7 @@ describe('getUIExtensionPayload', () => { const development: Partial = {} // When - const got = await getUIExtensionPayload(uiExtension, { + const got = await getUIExtensionPayload(uiExtension, 'mock-bundle-path', { ...options, currentDevelopmentPayload: development, url: 'http://tunnel-url.com', @@ -276,7 +276,7 @@ describe('getUIExtensionPayload', () => { const development: Partial = {} // When - const got = await getUIExtensionPayload(uiExtension, { + const got = await getUIExtensionPayload(uiExtension, 'mock-bundle-path', { ...options, currentDevelopmentPayload: development, url: 'http://tunnel-url.com', diff --git a/packages/app/src/cli/services/dev/extension/payload.ts b/packages/app/src/cli/services/dev/extension/payload.ts index 13344169c31..5cbd10f9155 100644 --- a/packages/app/src/cli/services/dev/extension/payload.ts +++ b/packages/app/src/cli/services/dev/extension/payload.ts @@ -8,16 +8,18 @@ import {ExtensionInstance} from '../../../models/extensions/extension-instance.j import {fileLastUpdatedTimestamp} from '@shopify/cli-kit/node/fs' import {useConcurrentOutputContext} from '@shopify/cli-kit/node/ui/components' -export type GetUIExtensionPayloadOptions = ExtensionDevOptions & { +export type GetUIExtensionPayloadOptions = Omit & { currentDevelopmentPayload?: Partial currentLocalizationPayload?: UIExtensionPayload['localization'] } export async function getUIExtensionPayload( extension: ExtensionInstance, + bundlePath: string, options: GetUIExtensionPayloadOptions, ): Promise { return useConcurrentOutputContext({outputPrefix: extension.outputPrefix}, async () => { + const extensionOutputPath = extension.getOutputPathForDirectory(bundlePath) const url = `${options.url}/extensions/${extension.devUUID}` const {localization, status: localizationStatus} = await getLocalization(extension, options) @@ -27,7 +29,7 @@ export async function getUIExtensionPayload( main: { name: 'main', url: `${url}/assets/${extension.outputFileName}`, - lastUpdated: (await fileLastUpdatedTimestamp(extension.outputPath)) ?? 0, + lastUpdated: (await fileLastUpdatedTimestamp(extensionOutputPath)) ?? 0, }, }, capabilities: { diff --git a/packages/app/src/cli/services/dev/extension/payload/store.test.ts b/packages/app/src/cli/services/dev/extension/payload/store.test.ts index 8ac6e56a393..cd9a5229da8 100644 --- a/packages/app/src/cli/services/dev/extension/payload/store.test.ts +++ b/packages/app/src/cli/services/dev/extension/payload/store.test.ts @@ -27,7 +27,7 @@ describe('getExtensionsPayloadStoreRawPayload()', () => { } as unknown as ExtensionsPayloadStoreOptions // When - const rawPayload = await getExtensionsPayloadStoreRawPayload(options) + const rawPayload = await getExtensionsPayloadStoreRawPayload(options, 'mock-bundle-path') // Then expect(rawPayload).toMatchObject({ @@ -252,10 +252,10 @@ describe('ExtensionsPayloadStore()', () => { const updatedExtension = {devUUID: '123', updated: 'extension'} as unknown as ExtensionInstance // When - await extensionsPayloadStore.updateExtension(updatedExtension, mockOptions, {hidden: true}) + await extensionsPayloadStore.updateExtension(updatedExtension, mockOptions, 'mock-bundle-path', {hidden: true}) // Then - expect(payload.getUIExtensionPayload).toHaveBeenCalledWith(updatedExtension, { + expect(payload.getUIExtensionPayload).toHaveBeenCalledWith(updatedExtension, 'mock-bundle-path', { ...mockOptions, currentDevelopmentPayload: {hidden: true}, }) @@ -279,10 +279,10 @@ describe('ExtensionsPayloadStore()', () => { const updatedExtension = {devUUID: '123', updated: 'extension'} as unknown as ExtensionInstance // When - await extensionsPayloadStore.updateExtension(updatedExtension, mockOptions) + await extensionsPayloadStore.updateExtension(updatedExtension, mockOptions, 'mock-bundle-path') // Then - expect(payload.getUIExtensionPayload).toHaveBeenCalledWith(updatedExtension, { + expect(payload.getUIExtensionPayload).toHaveBeenCalledWith(updatedExtension, 'mock-bundle-path', { ...mockOptions, currentDevelopmentPayload: { status: 'success', @@ -310,10 +310,10 @@ describe('ExtensionsPayloadStore()', () => { const updatedExtension = {devUUID: '123', updated: 'extension'} as unknown as ExtensionInstance // When - await extensionsPayloadStore.updateExtension(updatedExtension, mockOptions) + await extensionsPayloadStore.updateExtension(updatedExtension, mockOptions, 'mock-bundle-path') // Then - expect(payload.getUIExtensionPayload).toHaveBeenCalledWith(updatedExtension, { + expect(payload.getUIExtensionPayload).toHaveBeenCalledWith(updatedExtension, 'mock-bundle-path', { ...mockOptions, currentDevelopmentPayload: { status: 'success', @@ -338,7 +338,7 @@ describe('ExtensionsPayloadStore()', () => { extensionsPayloadStore.on(ExtensionsPayloadStoreEvent.Update, onUpdateSpy) // When - await extensionsPayloadStore.updateExtension(updatedExtension, mockOptions) + await extensionsPayloadStore.updateExtension(updatedExtension, mockOptions, 'mock-bundle-path') // Then expect(onUpdateSpy).toHaveBeenCalledWith(['123']) @@ -358,7 +358,7 @@ describe('ExtensionsPayloadStore()', () => { extensionsPayloadStore.on(ExtensionsPayloadStoreEvent.Update, onUpdateSpy) // When - await extensionsPayloadStore.updateExtension(updatedExtension, mockOptions) + await extensionsPayloadStore.updateExtension(updatedExtension, mockOptions, 'mock-bundle-path') // Then expect(initialRawPayload).toStrictEqual(extensionsPayloadStore.getRawPayload()) diff --git a/packages/app/src/cli/services/dev/extension/payload/store.ts b/packages/app/src/cli/services/dev/extension/payload/store.ts index f813ac7a406..7969839c91c 100644 --- a/packages/app/src/cli/services/dev/extension/payload/store.ts +++ b/packages/app/src/cli/services/dev/extension/payload/store.ts @@ -16,7 +16,8 @@ export enum ExtensionsPayloadStoreEvent { } export async function getExtensionsPayloadStoreRawPayload( - options: ExtensionsPayloadStoreOptions, + options: Omit, + bundlePath: string, ): Promise { return { app: { @@ -37,7 +38,7 @@ export async function getExtensionsPayloadStoreRawPayload( url: new URL('/extensions/dev-console', options.url).toString(), }, store: options.storeFqdn, - extensions: await Promise.all(options.extensions.map((extension) => getUIExtensionPayload(extension, options))), + extensions: await Promise.all(options.extensions.map((ext) => getUIExtensionPayload(ext, bundlePath, options))), } } @@ -130,7 +131,8 @@ export class ExtensionsPayloadStore extends EventEmitter { async updateExtension( extension: ExtensionInstance, - options: ExtensionDevOptions, + options: Omit, + bundlePath: string, development?: Partial, ) { const payloadExtensions = this.rawPayload.extensions @@ -144,9 +146,9 @@ export class ExtensionsPayloadStore extends EventEmitter { return } - payloadExtensions[index] = await getUIExtensionPayload(extension, { + payloadExtensions[index] = await getUIExtensionPayload(extension, bundlePath, { ...this.options, - currentDevelopmentPayload: development || {status: payloadExtensions[index]?.development.status}, + currentDevelopmentPayload: development ?? {status: payloadExtensions[index]?.development.status}, currentLocalizationPayload: payloadExtensions[index]?.localization, }) diff --git a/packages/app/src/cli/services/dev/extension/server/middlewares.test.ts b/packages/app/src/cli/services/dev/extension/server/middlewares.test.ts index 9eaa096f754..97ef8e82bc2 100644 --- a/packages/app/src/cli/services/dev/extension/server/middlewares.test.ts +++ b/packages/app/src/cli/services/dev/extension/server/middlewares.test.ts @@ -16,7 +16,7 @@ import {testUIExtension} from '../../../../models/app/app.test-data.js' import {describe, expect, vi, test} from 'vitest' import {inTemporaryDirectory, mkdir, touchFile, writeFile} from '@shopify/cli-kit/node/fs' import * as h3 from 'h3' -import {joinPath} from '@shopify/cli-kit/node/path' +import {dirname, joinPath} from '@shopify/cli-kit/node/path' vi.mock('h3', async () => { const actual: any = await vi.importActual('h3') @@ -234,23 +234,20 @@ describe('getExtensionAssetMiddleware()', () => { test('returns the file for that asset path', async () => { await inTemporaryDirectory(async (tmpDir: string) => { const response = getMockResponse() - const devUUID = '123abc' const fileName = 'test-ui-extension.js' - const outputPath = joinPath(tmpDir, devUUID, fileName) + const extension = await testUIExtension({}) + const outputPath = extension.getOutputPathForDirectory(tmpDir) const options = { devOptions: { - extensions: [ - { - devUUID, - outputPath, - outputFileName: fileName, - }, - ], + extensions: [extension], + appWatcher: { + buildOutputPath: tmpDir, + }, }, payloadStore: {}, } as unknown as GetExtensionsMiddlewareOptions - await mkdir(joinPath(tmpDir, devUUID)) + await mkdir(dirname(outputPath)) await touchFile(outputPath) await writeFile(outputPath, `content from ${fileName}`) @@ -258,7 +255,7 @@ describe('getExtensionAssetMiddleware()', () => { getMockRequest({ context: { params: { - extensionId: devUUID, + extensionId: extension.devUUID, assetPath: fileName, }, }, @@ -420,6 +417,9 @@ describe('getExtensionPayloadMiddleware()', () => { }), ], manifestVersion: '3', + appWatcher: { + buildOutputPath: 'mock-build-output-path', + }, }, payloadStore: {}, } as unknown as GetExtensionsMiddlewareOptions diff --git a/packages/app/src/cli/services/dev/extension/server/middlewares.ts b/packages/app/src/cli/services/dev/extension/server/middlewares.ts index 6eee1d5f8f8..823510d5fbb 100644 --- a/packages/app/src/cli/services/dev/extension/server/middlewares.ts +++ b/packages/app/src/cli/services/dev/extension/server/middlewares.ts @@ -86,7 +86,10 @@ export function getExtensionAssetMiddleware({devOptions}: GetExtensionsMiddlewar }) } - const buildDirectory = extension.outputPath.replace(extension.outputFileName, '') + const bundlePath = devOptions.appWatcher.buildOutputPath + const extensionOutputPath = extension.getOutputPathForDirectory(bundlePath) + + const buildDirectory = extensionOutputPath.replace(extension.outputFileName, '') return fileServerMiddleware(request, response, next, { filePath: joinPath(buildDirectory, assetPath), @@ -183,6 +186,7 @@ export function getExtensionPayloadMiddleware({devOptions}: GetExtensionsMiddlew return } } + const bundlePath = devOptions.appWatcher.buildOutputPath response.setHeader('content-type', 'application/json') response.end( @@ -201,7 +205,7 @@ export function getExtensionPayloadMiddleware({devOptions}: GetExtensionsMiddlew url: new URL('/extensions/dev-console', devOptions.url).toString(), }, store: devOptions.storeFqdn, - extension: await getUIExtensionPayload(extension, devOptions), + extension: await getUIExtensionPayload(extension, bundlePath, devOptions), }), ) } diff --git a/packages/app/src/cli/services/dev/processes/dev-session.ts b/packages/app/src/cli/services/dev/processes/dev-session.ts index d8b1e1f0902..0e2c70d65a1 100644 --- a/packages/app/src/cli/services/dev/processes/dev-session.ts +++ b/packages/app/src/cli/services/dev/processes/dev-session.ts @@ -89,6 +89,10 @@ export const pushUpdatesForDevSession: DevProcessFunction = a return } + // If there are any errors build errors, don't update the dev session + const anyError = event.extensionEvents.some((eve) => eve.buildResult?.status === 'error') + if (anyError) return + // Cancel any ongoing bundle and upload process bundleControllers.forEach((controller) => controller.abort()) // Remove aborted controllers from array: @@ -111,9 +115,9 @@ export const pushUpdatesForDevSession: DevProcessFunction = a outputDebug(`✅ Event handled [Network: ${endNetworkTime}ms -- Total: ${endTime}ms]`, processOptions.stdout) }, refreshToken) }) - .onStart(async (app) => { + .onStart(async (event) => { await performActionWithRetryAfterRecovery(async () => { - const result = await bundleExtensionsAndUpload({...processOptions, app}) + const result = await bundleExtensionsAndUpload({...processOptions, app: event.app}) await handleDevSessionResult(result, processOptions) }, refreshToken) }) diff --git a/packages/app/src/cli/services/dev/processes/previewable-extension.ts b/packages/app/src/cli/services/dev/processes/previewable-extension.ts index d8a647b4d0d..a2cfeec5504 100644 --- a/packages/app/src/cli/services/dev/processes/previewable-extension.ts +++ b/packages/app/src/cli/services/dev/processes/previewable-extension.ts @@ -2,6 +2,7 @@ import {BaseProcess, DevProcessFunction} from './types.js' import {devUIExtensions} from '../extension.js' import {ExtensionInstance} from '../../../models/extensions/extension-instance.js' import {buildCartURLIfNeeded} from '../extension/utilities.js' +import {AppEventWatcher} from '../app-events/app-event-watcher.js' import {DotEnvFile} from '@shopify/cli-kit/node/dot-env' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' @@ -22,6 +23,7 @@ interface PreviewableExtensionOptions { appId?: string grantedScopes: string[] previewableExtensions: ExtensionInstance[] + appWatcher: AppEventWatcher } export interface PreviewableExtensionProcess extends BaseProcess { @@ -44,6 +46,7 @@ export const launchPreviewableExtensionProcess: DevProcessFunction { await devUIExtensions({ @@ -64,6 +67,7 @@ export const launchPreviewableExtensionProcess: DevProcessFunction