Skip to content

Commit c5523ca

Browse files
Preserve remote theme deletions when local cleanup fails
1 parent cbe23d0 commit c5523ca

3 files changed

Lines changed: 73 additions & 1 deletion

File tree

‎packages/theme/src/cli/commands/theme/delete.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Delete from './delete.js'
22
import {themeDeleteJsonOutputSchema} from '../../services/delete/types.js'
33
import {findThemes} from '../../utilities/theme-selector.js'
4+
import * as localStorage from '../../services/local-storage.js'
45
import {Config} from '@oclif/core'
56
import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session'
67
import {themeDelete} from '@shopify/cli-kit/node/themes/api'
@@ -10,6 +11,7 @@ import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs'
1011
import {joinPath} from '@shopify/cli-kit/node/path'
1112
import {outputWarn} from '@shopify/cli-kit/node/output'
1213
import {addSensitiveMetadata} from '@shopify/cli-kit/node/metadata'
14+
import {AbortError} from '@shopify/cli-kit/node/error'
1315
import {describe, expect, test, vi} from 'vitest'
1416

1517
vi.mock('@shopify/cli-kit/node/session')
@@ -106,6 +108,30 @@ describe('theme delete JSON output', () => {
106108
})
107109
})
108110

111+
test('keeps successful JSON output when development theme cleanup fails', async () => {
112+
vi.mocked(findThemes).mockResolvedValue([{...theme, role: 'development'}])
113+
const cleanup = vi.spyOn(localStorage, 'removeDevelopmentTheme').mockImplementation(() => {
114+
throw new AbortError('Local storage is not writable')
115+
})
116+
const exitCode = process.exitCode
117+
try {
118+
await withCapturedStandardStreams(async ({stdout, stderr}) => {
119+
await run(['--store', store, '--theme', '1', '--force', '--json'])
120+
121+
expect(JSON.parse(stdout())).toMatchObject({status: 'success', changed: true, themes: [{id: '1'}]})
122+
expect(JSON.parse(stderr())).toMatchObject({
123+
type: 'diagnostic',
124+
level: 'warning',
125+
message: expect.stringContaining('Local storage is not writable'),
126+
})
127+
expect(process.exitCode).toBe(exitCode)
128+
})
129+
} finally {
130+
cleanup.mockRestore()
131+
restoreExitCode(exitCode)
132+
}
133+
})
134+
109135
test.each([false, true])('preserves partial deletions and rejects total failure (partial: %s)', async (partial) => {
110136
const originalExitCode = process.exitCode
111137
vi.mocked(findThemes).mockResolvedValue(partial ? [theme, {...theme, id: 2}] : [theme])

‎packages/theme/src/cli/services/delete.test.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
import {themesDelete as executeDelete} from './delete.js'
22
import {renderThemeDeleteResult} from './delete/result.js'
3+
import {removeDevelopmentTheme} from './local-storage.js'
34
import {findOrSelectTheme, findThemes} from '../utilities/theme-selector.js'
45
import {themeDelete} from '@shopify/cli-kit/node/themes/api'
56
import {Theme} from '@shopify/cli-kit/node/themes/types'
67
import {test, describe, expect, vi} from 'vitest'
78
import {renderConfirmationPrompt, renderSuccess} from '@shopify/cli-kit/node/ui'
9+
import {AbortError} from '@shopify/cli-kit/node/error'
10+
import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output'
811

912
vi.mock('@shopify/cli-kit/node/ui')
1013
vi.mock('@shopify/cli-kit/node/themes/api')
14+
vi.mock('./local-storage.js')
1115
vi.mock('../utilities/theme-selector.js')
1216
vi.mock('../utilities/development-theme-manager.js', () => {
1317
const DevelopmentThemeManager = vi.fn()
@@ -167,6 +171,36 @@ test('does not prompt again for multiple environments', async () => {
167171
expect(themeDelete).toHaveBeenCalledWith(1, session)
168172
})
169173

174+
test.each([false, true])('preserves remote deletions when local cleanup fails (multiple: %s)', async (multiple) => {
175+
const output = mockAndCaptureOutput()
176+
const developmentTheme = {...theme1, role: 'development'}
177+
const themes = multiple ? [developmentTheme, theme2] : [developmentTheme]
178+
vi.mocked(findThemes).mockResolvedValue(themes)
179+
vi.mocked(removeDevelopmentTheme).mockImplementation(() => {
180+
throw new AbortError('Local storage is not writable')
181+
})
182+
183+
const result = await executeDelete(session, {...options, themes: ['1', '2'], force: true})
184+
185+
expect(result).toEqual({
186+
status: 'success',
187+
themes: themes.map((theme) => ({...theme, shop: session.storeFqdn})),
188+
})
189+
expect(output.warn()).toContain(
190+
'Theme #1 was deleted, but its local development theme reference could not be removed',
191+
)
192+
expect(output.warn()).toContain('Local storage is not writable')
193+
})
194+
195+
test('does not remove local state when remote deletion fails', async () => {
196+
vi.mocked(findThemes).mockResolvedValue([{...theme1, role: 'development'}])
197+
vi.mocked(themeDelete).mockRejectedValue(new Error('Deletion failed'))
198+
199+
await expect(executeDelete(session, {...options, themes: ['1'], force: true})).rejects.toThrow('Deletion failed')
200+
201+
expect(removeDevelopmentTheme).not.toHaveBeenCalled()
202+
})
203+
170204
test('preserves the environment label in terminal output', () => {
171205
renderThemeDeleteResult({status: 'success', themes: [{...theme1, shop: session.storeFqdn}]}, 'text', {
172206
store: session.storeFqdn,

‎packages/theme/src/cli/services/delete.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {pluralize} from '@shopify/cli-kit/common/string'
1515
import {Theme} from '@shopify/cli-kit/node/themes/types'
1616
import {isDevelopmentTheme} from '@shopify/cli-kit/node/themes/utils'
1717
import {AbortError} from '@shopify/cli-kit/node/error'
18+
import {outputWarn} from '@shopify/cli-kit/node/output'
1819

1920
interface DeleteOptions {
2021
selectTheme: boolean
@@ -44,11 +45,22 @@ export async function themesDelete(
4445
const deletions = await Promise.allSettled(
4546
themes.map(async (theme) => {
4647
await themeDelete(theme.id, adminSession)
47-
if (isDevelopmentTheme(theme)) removeDevelopmentTheme()
4848
return {...theme, shop: store}
4949
}),
5050
)
5151
const deletedThemes = deletions.flatMap((deletion) => (deletion.status === 'fulfilled' ? [deletion.value] : []))
52+
for (const theme of deletedThemes) {
53+
if (!isDevelopmentTheme(theme)) continue
54+
try {
55+
removeDevelopmentTheme()
56+
// eslint-disable-next-line no-catch-all/no-catch-all -- Local cleanup cannot undo a completed remote deletion.
57+
} catch (error) {
58+
const message = error instanceof Error ? error.message : String(error)
59+
outputWarn(
60+
`Theme #${theme.id} was deleted, but its local development theme reference could not be removed: ${message}`,
61+
)
62+
}
63+
}
5264
const errors = deletions.flatMap((deletion, index) => {
5365
if (deletion.status === 'fulfilled') return []
5466
const error: unknown = deletion.reason

0 commit comments

Comments
 (0)