diff --git a/packages/app/src/cli/services/deploy/bundle.test.ts b/packages/app/src/cli/services/deploy/bundle.test.ts index cce9f433456..e30d3e091fc 100644 --- a/packages/app/src/cli/services/deploy/bundle.test.ts +++ b/packages/app/src/cli/services/deploy/bundle.test.ts @@ -1,13 +1,15 @@ import {bundleAndBuildExtensions} from './bundle.js' import {testApp, testFunctionExtension, testThemeExtensions, testUIExtension} from '../../models/app/app.test-data.js' -import {AppInterface, AppManifest} from '../../models/app/app.js' +import {AppInterface, AppManifest, WebType} from '../../models/app/app.js' import * as bundle from '../bundle.js' import * as functionBuild from '../function/build.js' +import * as webService from '../web.js' import {describe, expect, test, vi} from 'vitest' import * as file from '@shopify/cli-kit/node/fs' import {joinPath} from '@shopify/cli-kit/node/path' vi.mock('../function/build.js') +vi.mock('../web.js') describe('bundleAndBuildExtensions', () => { let app: AppInterface @@ -254,6 +256,153 @@ describe('bundleAndBuildExtensions', () => { }) }) + test('runs web build command concurrently with extensions when build command is defined', async () => { + await file.inTemporaryDirectory(async (tmpDir: string) => { + // Given + const bundlePath = joinPath(tmpDir, 'bundle.zip') + const mockBuildWeb = vi.mocked(webService.default) + + const functionExtension = await testFunctionExtension() + const extensionBuildMock = vi.fn().mockImplementation(async (options, bundleDirectory) => { + file.writeFileSync(joinPath(bundleDirectory, 'index.wasm'), '') + }) + functionExtension.buildForBundle = extensionBuildMock + + const app = testApp({ + allExtensions: [functionExtension], + directory: tmpDir, + webs: [ + { + directory: '/tmp/web', + configuration: { + roles: [WebType.Backend], + commands: {dev: 'npm run dev', build: 'npm run build'}, + }, + }, + ], + }) + + const identifiers = { + app: 'app-id', + extensions: {[functionExtension.localIdentifier]: functionExtension.localIdentifier}, + extensionIds: {}, + extensionsNonUuidManaged: {}, + } + appManifest = await app.manifest(identifiers) + + // When + await bundleAndBuildExtensions({ + app, + appManifest, + identifiers, + bundlePath, + skipBuild: false, + isDevDashboardApp: false, + }) + + // Then + expect(mockBuildWeb).toHaveBeenCalledWith('build', expect.objectContaining({web: app.webs[0]})) + }) + }) + + test('skips web build for webs without a build command defined', async () => { + await file.inTemporaryDirectory(async (tmpDir: string) => { + // Given + const bundlePath = joinPath(tmpDir, 'bundle.zip') + const mockBuildWeb = vi.mocked(webService.default) + + const functionExtension = await testFunctionExtension() + const extensionBuildMock = vi.fn().mockImplementation(async (options, bundleDirectory) => { + file.writeFileSync(joinPath(bundleDirectory, 'index.wasm'), '') + }) + functionExtension.buildForBundle = extensionBuildMock + + const app = testApp({ + allExtensions: [functionExtension], + directory: tmpDir, + webs: [ + { + directory: '/tmp/web', + configuration: { + roles: [WebType.Backend], + commands: {dev: 'npm run dev'}, + }, + }, + ], + }) + + const identifiers = { + app: 'app-id', + extensions: {[functionExtension.localIdentifier]: functionExtension.localIdentifier}, + extensionIds: {}, + extensionsNonUuidManaged: {}, + } + appManifest = await app.manifest(identifiers) + + // When + await bundleAndBuildExtensions({ + app, + appManifest, + identifiers, + bundlePath, + skipBuild: false, + isDevDashboardApp: false, + }) + + // Then + expect(mockBuildWeb).not.toHaveBeenCalled() + }) + }) + + test('skips web build command when skipBuild is true', async () => { + await file.inTemporaryDirectory(async (tmpDir: string) => { + // Given + const bundlePath = joinPath(tmpDir, 'bundle.zip') + const mockBuildWeb = vi.mocked(webService.default) + + const functionExtension = await testFunctionExtension() + const extensionCopyMock = vi.fn().mockImplementation(async (options, bundleDirectory) => { + file.writeFileSync(joinPath(bundleDirectory, 'index.wasm'), '') + }) + functionExtension.copyIntoBundle = extensionCopyMock + + const app = testApp({ + allExtensions: [functionExtension], + directory: tmpDir, + webs: [ + { + directory: '/tmp/web', + configuration: { + roles: [WebType.Backend], + commands: {dev: 'npm run dev', build: 'npm run build'}, + }, + }, + ], + }) + + const identifiers = { + app: 'app-id', + extensions: {[functionExtension.localIdentifier]: functionExtension.localIdentifier}, + extensionIds: {}, + extensionsNonUuidManaged: {}, + } + appManifest = await app.manifest(identifiers) + + // When + await bundleAndBuildExtensions({ + app, + appManifest, + identifiers, + bundlePath, + skipBuild: true, + isDevDashboardApp: false, + }) + + // Then + expect(mockBuildWeb).not.toHaveBeenCalled() + }) + }) + test('handles multiple extension types together', async () => { await file.inTemporaryDirectory(async (tmpDir: string) => { // Given diff --git a/packages/app/src/cli/services/deploy/bundle.ts b/packages/app/src/cli/services/deploy/bundle.ts index cc18cddcf00..5811ff368a9 100644 --- a/packages/app/src/cli/services/deploy/bundle.ts +++ b/packages/app/src/cli/services/deploy/bundle.ts @@ -1,6 +1,7 @@ import {AppInterface, AppManifest} from '../../models/app/app.js' import {Identifiers} from '../../models/app/identifiers.js' import {installJavy} from '../function/build.js' +import buildWeb from '../web.js' import {compressBundle, writeManifestToBundle} from '../bundle.js' import {AbortSignal} from '@shopify/cli-kit/node/abort' import {mkdir, rmdir} from '@shopify/cli-kit/node/fs' @@ -30,33 +31,45 @@ export async function bundleAndBuildExtensions(options: BundleOptions) { await installJavy(options.app) } - await renderConcurrent({ - processes: options.app.allExtensions.map((extension) => { - return { - prefix: extension.localIdentifier, - action: async (stdout: Writable, stderr: Writable, signal: AbortSignal) => { - // This outputId is the UID for AppManagement, and UUID for Partners - // Comes from the matching logic in `ensureDeployContext` - const outputId = options.isDevDashboardApp - ? undefined - : options.identifiers?.extensions[extension.localIdentifier] + const webBuildProcesses = options.skipBuild + ? [] + : options.app.webs + .filter((web) => web.configuration.commands.build) + .map((web) => ({ + prefix: ['web', ...web.configuration.roles].join('-'), + action: async (stdout: Writable, stderr: Writable, signal: AbortSignal) => { + if (options.skipBuild) return + await buildWeb('build', {web, stdout, stderr, signal}) + }, + })) + + const extensionBuildProcesses = options.app.allExtensions.map((extension) => ({ + prefix: extension.localIdentifier, + action: async (stdout: Writable, stderr: Writable, signal: AbortSignal) => { + // This outputId is the UID for AppManagement, and UUID for Partners + // Comes from the matching logic in `ensureDeployContext` + const outputId = options.isDevDashboardApp + ? undefined + : options.identifiers?.extensions[extension.localIdentifier] - if (options.skipBuild) { - await extension.copyIntoBundle( - {stderr, stdout, signal, app: options.app, environment: 'production'}, - bundleDirectory, - outputId, - ) - } else { - await extension.buildForBundle( - {stderr, stdout, signal, app: options.app, environment: 'production'}, - bundleDirectory, - outputId, - ) - } - }, + if (options.skipBuild) { + await extension.copyIntoBundle( + {stderr, stdout, signal, app: options.app, environment: 'production'}, + bundleDirectory, + outputId, + ) + } else { + await extension.buildForBundle( + {stderr, stdout, signal, app: options.app, environment: 'production'}, + bundleDirectory, + outputId, + ) } - }), + }, + })) + + await renderConcurrent({ + processes: [webBuildProcesses, extensionBuildProcesses].flat(), showTimestamps: false, })