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/web-build-on-deploy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@shopify/app": patch
---

Run web build commands during `shopify app deploy` for hosted apps. Previously, the `build` command defined in `web.toml` was only executed by `shopify app build` — deploy skipped it entirely, requiring a manual build step first.
151 changes: 150 additions & 1 deletion packages/app/src/cli/services/deploy/bundle.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
21 changes: 17 additions & 4 deletions packages/app/src/cli/services/deploy/bundle.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -30,9 +31,21 @@ export async function bundleAndBuildExtensions(options: BundleOptions) {
await installJavy(options.app)
}

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) => {
await buildWeb('build', {web, stdout, stderr, signal})
},
}))

await renderConcurrent({
processes: options.app.allExtensions.map((extension) => {
return {
processes: [
...webBuildProcesses,
...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
Expand All @@ -55,8 +68,8 @@ export async function bundleAndBuildExtensions(options: BundleOptions) {
)
}
},
}
}),
})),
],
showTimestamps: false,
})

Expand Down
Loading