Skip to content

Commit 9966ba6

Browse files
committed
Run environments acting on the same store sequentially
Previously, if multiple environments were acting on the same store during multi environment command calls there was a chance that commands would behave strangely due to concurrent process interleaving For example: pushing a dev theme to the same store twice should check if there is already a dev theme to push to and only if not, create a new one. However, during concurrent execution, two new themes were being created since both environments local storage READs occurred before either's WRITEs This commit groups environments with unique store URLs and runs those groups sequentially, preventing this behaviour
1 parent 7f54a36 commit 9966ba6

File tree

3 files changed

+103
-38
lines changed

3 files changed

+103
-38
lines changed

.changeset/many-ties-check.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/theme': minor
3+
---
4+
5+
Allow commands run in multiple environments to act on the same store URL

packages/theme/src/cli/utilities/theme-command.test.ts

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,9 @@ describe('ThemeCommand', () => {
135135

136136
test('multiple environments provided - uses renderConcurrent for parallel execution', async () => {
137137
// Given
138-
const environmentConfig = {store: 'store.myshopify.com'}
139-
vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig)
138+
vi.mocked(loadEnvironment)
139+
.mockResolvedValueOnce({store: 'store1.myshopify.com', development: true})
140+
.mockResolvedValueOnce({store: 'store2.myshopify.com', theme: 'staging'})
140141
vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(mockSession)
141142

142143
vi.mocked(renderConcurrent).mockResolvedValue(undefined)
@@ -150,7 +151,6 @@ describe('ThemeCommand', () => {
150151
// Then
151152
expect(loadEnvironment).toHaveBeenCalledWith('development', 'shopify.theme.toml', {from: undefined, silent: true})
152153
expect(loadEnvironment).toHaveBeenCalledWith('staging', 'shopify.theme.toml', {from: undefined, silent: true})
153-
expect(ensureAuthenticatedThemes).toHaveBeenCalledTimes(2)
154154

155155
expect(renderConcurrent).toHaveBeenCalledOnce()
156156
expect(renderConcurrent).toHaveBeenCalledWith(
@@ -166,10 +166,40 @@ describe('ThemeCommand', () => {
166166
})
167167

168168
describe('multi environment', () => {
169+
test('commands that act on the same store are run in groups to prevent conflicts', async () => {
170+
// Given
171+
vi.mocked(loadEnvironment)
172+
.mockResolvedValueOnce({store: 'store1.myshopify.com', theme: 'wow a theme'})
173+
.mockResolvedValueOnce({store: 'store1.myshopify.com', development: true})
174+
.mockResolvedValueOnce({store: 'store2.myshopify.com', theme: 'another theme'})
175+
176+
vi.mocked(renderConfirmationPrompt).mockResolvedValue(true)
177+
vi.mocked(renderConcurrent).mockResolvedValue(undefined)
178+
179+
await CommandConfig.load()
180+
const command = new TestThemeCommandWithUnionFlags(
181+
['--environment', 'store1-theme', '--environment', 'store1-development', '--environment', 'store2-theme'],
182+
CommandConfig,
183+
)
184+
185+
// When
186+
await command.run()
187+
188+
// Then
189+
const runGroupOneProcesses = vi.mocked(renderConcurrent).mock.calls[0]?.[0]?.processes
190+
expect(runGroupOneProcesses).toHaveLength(2)
191+
expect(runGroupOneProcesses?.map((process) => process.prefix)).toEqual(['store1-theme', 'store2-theme'])
192+
193+
const runGroupTwoProcesses = vi.mocked(renderConcurrent).mock.calls[1]?.[0]?.processes
194+
expect(runGroupTwoProcesses).toHaveLength(1)
195+
expect(runGroupTwoProcesses?.map((process) => process.prefix)).toEqual(['store1-development'])
196+
})
197+
169198
test('commands with --force flag should not prompt for confirmation', async () => {
170199
// Given
171-
const environmentConfig = {store: 'store.myshopify.com'}
172-
vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig)
200+
vi.mocked(loadEnvironment)
201+
.mockResolvedValueOnce({store: 'store1.myshopify.com', development: true})
202+
.mockResolvedValueOnce({store: 'store2.myshopify.com', theme: 'staging'})
173203
vi.mocked(renderConfirmationPrompt).mockResolvedValue(true)
174204
vi.mocked(renderConcurrent).mockResolvedValue(undefined)
175205

@@ -198,8 +228,9 @@ describe('ThemeCommand', () => {
198228

199229
test('commands that do not allow --force flag should not prompt for confirmation', async () => {
200230
// Given
201-
const environmentConfig = {store: 'store.myshopify.com'}
202-
vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig)
231+
vi.mocked(loadEnvironment)
232+
.mockResolvedValueOnce({store: 'store1.myshopify.com', development: true})
233+
.mockResolvedValueOnce({store: 'store2.myshopify.com', theme: 'staging'})
203234
vi.mocked(renderConfirmationPrompt).mockResolvedValue(true)
204235
vi.mocked(renderConcurrent).mockResolvedValue(undefined)
205236

@@ -216,8 +247,9 @@ describe('ThemeCommand', () => {
216247

217248
test('commands without --force flag that allow it should prompt for confirmation', async () => {
218249
// Given
219-
const environmentConfig = {store: 'store.myshopify.com'}
220-
vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig)
250+
vi.mocked(loadEnvironment)
251+
.mockResolvedValueOnce({store: 'store1.myshopify.com', development: true})
252+
.mockResolvedValueOnce({store: 'store2.myshopify.com', theme: 'staging'})
221253
vi.mocked(renderConfirmationPrompt).mockResolvedValue(true)
222254
vi.mocked(renderConcurrent).mockResolvedValue(undefined)
223255

@@ -244,8 +276,9 @@ describe('ThemeCommand', () => {
244276

245277
test('should not execute command if confirmation is cancelled', async () => {
246278
// Given
247-
const environmentConfig = {store: 'store.myshopify.com'}
248-
vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig)
279+
vi.mocked(loadEnvironment)
280+
.mockResolvedValueOnce({store: 'store1.myshopify.com', development: true})
281+
.mockResolvedValueOnce({store: 'store2.myshopify.com', theme: 'staging'})
249282
vi.mocked(renderConfirmationPrompt).mockResolvedValue(false)
250283
vi.mocked(renderConcurrent).mockResolvedValue(undefined)
251284

@@ -340,8 +373,10 @@ describe('ThemeCommand', () => {
340373

341374
test('commands error gracefully and continue with other environments', async () => {
342375
// Given
343-
vi.mocked(loadEnvironment).mockResolvedValue({store: 'store.myshopify.com'})
344-
376+
vi.mocked(loadEnvironment)
377+
.mockResolvedValueOnce({store: 'store1.myshopify.com', development: true})
378+
.mockResolvedValueOnce({store: 'store2.myshopify.com', theme: 'staging'})
379+
.mockResolvedValueOnce({store: 'store3.myshopify.com', live: true})
345380
vi.mocked(renderConfirmationPrompt).mockResolvedValue(true)
346381
vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => {
347382
for (const process of processes) {

packages/theme/src/cli/utilities/theme-command.ts

Lines changed: 50 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -225,33 +225,58 @@ export default abstract class ThemeCommand extends Command {
225225
private async runConcurrent(validEnvironments: {environment: EnvironmentName; flags: FlagValues}[]) {
226226
const abortController = new AbortController()
227227

228-
await renderConcurrent({
229-
processes: validEnvironments.map(({environment, flags}) => ({
230-
prefix: environment,
231-
action: async (stdout: Writable, stderr: Writable, _signal) => {
232-
try {
233-
const store = flags.store as string
234-
await useThemeStoreContext(store, async () => {
235-
const session = await this.createSession(flags)
236-
237-
const commandName = this.constructor.name.toLowerCase()
238-
recordEvent(`theme-command:${commandName}:multi-env:authenticated`)
239-
240-
await this.command(flags, session, true, {stdout, stderr})
241-
})
242-
243-
// eslint-disable-next-line no-catch-all/no-catch-all
244-
} catch (error) {
245-
if (error instanceof Error) {
246-
error.message = `Environment ${environment} failed: \n\n${error.message}`
247-
renderError({body: [error.message]})
228+
const stores = validEnvironments.map((env) => env.flags.store as string)
229+
const uniqueStores = new Set(stores)
230+
const runGroups =
231+
stores.length === uniqueStores.size ? [validEnvironments] : this.createSequentialGroups(validEnvironments)
232+
233+
for (const runGroup of runGroups) {
234+
// eslint-disable-next-line no-await-in-loop
235+
await renderConcurrent({
236+
processes: runGroup.map(({environment, flags}) => ({
237+
prefix: environment,
238+
action: async (stdout: Writable, stderr: Writable, _signal) => {
239+
try {
240+
const store = flags.store as string
241+
await useThemeStoreContext(store, async () => {
242+
const session = await this.createSession(flags)
243+
244+
const commandName = this.constructor.name.toLowerCase()
245+
recordEvent(`theme-command:${commandName}:multi-env:authenticated`)
246+
247+
await this.command(flags, session, true, {stdout, stderr})
248+
})
249+
250+
// eslint-disable-next-line no-catch-all/no-catch-all
251+
} catch (error) {
252+
if (error instanceof Error) {
253+
error.message = `Environment ${environment} failed: \n\n${error.message}`
254+
renderError({body: [error.message]})
255+
}
248256
}
249-
}
250-
},
251-
})),
252-
abortSignal: abortController.signal,
253-
showTimestamps: true,
257+
},
258+
})),
259+
abortSignal: abortController.signal,
260+
showTimestamps: true,
261+
})
262+
}
263+
}
264+
265+
/**
266+
* Create groups of environments with unique flags.store values to run sequentially
267+
* to prevent conflicts between environments acting on the same store
268+
* @param environments - The environments to group
269+
* @returns The environment groups
270+
*/
271+
private createSequentialGroups(environments: {environment: EnvironmentName; flags: FlagValues}[]) {
272+
const groups: {environment: EnvironmentName; flags: FlagValues}[][] = []
273+
274+
environments.forEach((environment) => {
275+
const groupWithoutStore = groups.find((arr) => !arr.some((env) => env.flags.store === environment.flags.store))
276+
groupWithoutStore ? groupWithoutStore.push(environment) : groups.push([environment])
254277
})
278+
279+
return groups
255280
}
256281

257282
/**

0 commit comments

Comments
 (0)