Skip to content

Commit b2bed2c

Browse files
Warn when opening a theme preview returns false
1 parent 92abe8f commit b2bed2c

8 files changed

Lines changed: 203 additions & 37 deletions

File tree

‎packages/cli/oclif.manifest.json‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12184,7 +12184,12 @@
1218412184
"hiddenAliases": [
1218512185
],
1218612186
"id": "theme:preview",
12187-
"multiEnvironmentsFlags": null,
12187+
"multiEnvironmentsFlags": [
12188+
"store",
12189+
"password",
12190+
"theme",
12191+
"overrides"
12192+
],
1218812193
"pluginAlias": "@shopify/cli",
1218912194
"pluginName": "@shopify/cli",
1219012195
"pluginType": "core",

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

Lines changed: 151 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,20 @@ import {findOrSelectTheme} from '../../utilities/theme-selector.js'
55
import {ensureThemeStore} from '../../utilities/theme-store.js'
66
import {openURL} from '@shopify/cli-kit/node/system'
77
import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events'
8-
import {renderSuccess} from '@shopify/cli-kit/node/ui'
8+
import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui'
99
import {buildTheme} from '@shopify/cli-kit/node/themes/factories'
1010
import {recordEvent} from '@shopify/cli-kit/node/analytics'
1111
import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session'
1212
import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output'
1313
import {Config} from '@oclif/core'
14+
import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs'
15+
import {joinPath} from '@shopify/cli-kit/node/path'
1416
import {describe, vi, expect, test, beforeEach} from 'vitest'
1517

16-
vi.mock('@shopify/cli-kit/node/ui')
18+
vi.mock('@shopify/cli-kit/node/ui', async (importOriginal) => {
19+
const actual = await importOriginal<typeof import('@shopify/cli-kit/node/ui')>()
20+
return {...actual, renderSuccess: vi.fn(), renderWarning: vi.fn(actual.renderWarning)}
21+
})
1722
vi.mock('@shopify/cli-kit/node/system', async (importOriginal) => ({
1823
...(await importOriginal<typeof import('@shopify/cli-kit/node/system')>()),
1924
openURL: vi.fn(),
@@ -53,6 +58,36 @@ describe('Preview', () => {
5358
vi.mocked(devWithOverrideFile).mockResolvedValue(result)
5459
})
5560

61+
test.each(['default', 'staging'].flatMap((environment) => [false, true].map((json) => ({environment, json}))))(
62+
'uses the remembered store for the $environment environment with json=$json',
63+
async ({environment, json}) => {
64+
const environments = await import('@shopify/cli-kit/node/environments')
65+
const actual = await vi.importActual<typeof environments>('@shopify/cli-kit/node/environments')
66+
vi.mocked(environments.loadEnvironment).mockImplementation(actual.loadEnvironment)
67+
vi.mocked(environments.environmentFilePath).mockImplementation(actual.environmentFilePath)
68+
await inTemporaryDirectory(async (directory) => {
69+
await writeFile(
70+
joinPath(directory, 'shopify.theme.toml'),
71+
`[environments.${environment}]\ntheme = "2"\noverrides = "/path/to/overrides.json"\n`,
72+
)
73+
const config = new Config({root: __dirname})
74+
await config.load()
75+
const argv = [
76+
'--path',
77+
directory,
78+
...(environment === 'default' ? [] : ['--environment', environment]),
79+
...(json ? ['--json'] : []),
80+
]
81+
await withCapturedStandardStreams(async () => {
82+
await runWithCommandEventsForCommand(argv, () => new Preview(argv, config).run())
83+
})
84+
expect(ensureThemeStore).toHaveBeenCalledWith(expect.objectContaining({store: undefined}))
85+
expect(ensureAuthenticatedThemes).toHaveBeenCalledWith(adminSession.storeFqdn, undefined)
86+
expect(devWithOverrideFile).toHaveBeenCalledWith(expect.objectContaining({adminSession, themeId: '2'}))
87+
})
88+
},
89+
)
90+
5691
test('calls devWithOverrideFile with minimum options passed into the command', async () => {
5792
const expectedTheme = buildTheme({id: 5, name: 'Expected Theme', role: 'unpublished'})!
5893
vi.mocked(findOrSelectTheme).mockResolvedValue(expectedTheme)
@@ -204,14 +239,51 @@ describe('Preview', () => {
204239
.split('\n')
205240
.map((line) => JSON.parse(line))
206241
expect(events).toMatchObject([
207-
{type: 'diagnostic', level: 'warning', message: `Failed to open theme preview.\n${error.stack}`},
242+
{type: 'diagnostic', level: 'warning', message: `Failed to open theme preview.\n\n${error.stack}`},
208243
])
209244
expect(JSON.parse(stdout())).toEqual({
210245
status: 'success',
211246
preview: {id: result.preview_identifier, url: result.url},
212247
})
213248
})
214249
})
250+
251+
test.each([false, true])('reports the browser open result in JSON diagnostics (opened: %s)', async (opened) => {
252+
vi.mocked(openURL).mockResolvedValue(opened)
253+
254+
await withCapturedStandardStreams(async ({stdout, stderr}) => {
255+
await runWithCommandEventsForCommand(['--json'], () =>
256+
run(['--overrides=/path/to/overrides.json', '--theme=2', '--json', '--open']),
257+
)
258+
259+
expect(JSON.parse(stdout())).toEqual({
260+
status: 'success',
261+
preview: {id: result.preview_identifier, url: result.url},
262+
})
263+
if (opened) {
264+
expect(stderr()).toBe('')
265+
} else {
266+
expect(JSON.parse(stderr())).toMatchObject({
267+
type: 'diagnostic',
268+
level: 'warning',
269+
message: expect.stringContaining(`Open this URL manually: ${result.url}`),
270+
})
271+
}
272+
})
273+
expect(openURL).toHaveBeenCalledWith(result.url)
274+
})
275+
276+
test('warns in text mode when the browser could not be opened', async () => {
277+
vi.mocked(openURL).mockResolvedValue(false)
278+
279+
await run(['--overrides=/path/to/overrides.json', '--theme=2', '--open'])
280+
281+
expect(renderWarning).toHaveBeenCalledWith({
282+
headline: 'Failed to open theme preview.',
283+
body: expect.stringContaining(`Open this URL manually: ${result.url}`),
284+
})
285+
expect(renderSuccess).toHaveBeenCalledOnce()
286+
})
215287
})
216288

217289
test('returns one explicit environment result', async () => {
@@ -249,5 +321,81 @@ test.each([['--theme=2'], ['--overrides=/path/to/overrides.json']])(
249321
'Specify both --theme and --overrides, either as flags or in an environment.',
250322
)
251323
expect(devWithOverrideFile).not.toHaveBeenCalled()
324+
expect(ensureAuthenticatedThemes).not.toHaveBeenCalled()
325+
},
326+
)
327+
328+
test.each(['none', 'validation', 'store', 'execution'])(
329+
'retains every preview environment with %s failures',
330+
async (failure) => {
331+
const {loadEnvironment} = await import('@shopify/cli-kit/node/environments')
332+
vi.mocked(loadEnvironment).mockImplementation(async (environment) => ({
333+
...(failure === 'store' && environment === 'staging' ? {} : {store: `${environment}.myshopify.com`}),
334+
password: 'token',
335+
theme: '2',
336+
...(failure === 'validation' && environment === 'staging' ? {} : {overrides: '/path/to/overrides.json'}),
337+
}))
338+
vi.mocked(ensureThemeStore).mockImplementation(({store}) => store!)
339+
vi.mocked(ensureAuthenticatedThemes).mockImplementation(async (storeFqdn) => ({storeFqdn, token: 'token'}))
340+
vi.mocked(findOrSelectTheme).mockResolvedValue(namedTheme)
341+
vi.mocked(devWithOverrideFile).mockImplementation(async ({adminSession}) => {
342+
if (failure === 'execution' && adminSession.storeFqdn === 'staging.myshopify.com') {
343+
throw new Error('Preview failed')
344+
}
345+
return result
346+
})
347+
const config = new Config({root: __dirname})
348+
await config.load()
349+
const argv = ['--json', '-e', 'staging', '-e', 'production']
350+
const missingFlag = failure === 'store' ? 'store' : 'overrides'
351+
const previousExitCode = process.exitCode
352+
process.exitCode = 0
353+
try {
354+
await withCapturedStandardStreams(async ({stdout}) => {
355+
await runWithCommandEventsForCommand(argv, () => new Preview(argv, config).run())
356+
expect(JSON.parse(stdout())).toEqual({
357+
environments: ['staging', 'production'].map((environment) => ({
358+
environment,
359+
...(environment === 'staging' && failure !== 'none'
360+
? {
361+
error: expect.objectContaining({
362+
message: expect.stringContaining(failure === 'execution' ? 'Preview failed' : missingFlag),
363+
}),
364+
}
365+
: {result: {status: 'success', preview: {id: result.preview_identifier, url: result.url}}}),
366+
})),
367+
})
368+
})
369+
expect(ensureAuthenticatedThemes).toHaveBeenCalledTimes(['validation', 'store'].includes(failure) ? 1 : 2)
370+
expect(devWithOverrideFile).toHaveBeenCalledTimes(['validation', 'store'].includes(failure) ? 1 : 2)
371+
expect(process.exitCode).toBe(failure === 'none' ? 0 : 1)
372+
} finally {
373+
process.exitCode = previousExitCode
374+
}
375+
},
376+
)
377+
378+
test.each(['theme', 'overrides'])(
379+
'validates missing %s in a JSON environment before authentication',
380+
async (missing) => {
381+
const {loadEnvironment} = await import('@shopify/cli-kit/node/environments')
382+
vi.mocked(loadEnvironment).mockResolvedValue({
383+
store: adminSession.storeFqdn,
384+
...(missing === 'theme' ? {overrides: '/path/to/overrides.json'} : {theme: '2'}),
385+
})
386+
const previousExitCode = process.exitCode
387+
try {
388+
await withCapturedStandardStreams(async ({stdout}) => {
389+
await runWithCommandEventsForCommand(['--json'], () => run(['--environment=staging', '--json']))
390+
expect(JSON.parse(stdout())).toMatchObject({
391+
environments: [{environment: 'staging', error: {message: expect.stringContaining(missing)}}],
392+
})
393+
})
394+
expect(process.exitCode).toBe(1)
395+
expect(ensureAuthenticatedThemes).not.toHaveBeenCalled()
396+
expect(devWithOverrideFile).not.toHaveBeenCalled()
397+
} finally {
398+
process.exitCode = previousExitCode
399+
}
252400
},
253401
)

‎packages/theme/src/cli/commands/theme/preview.ts‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,23 +58,23 @@ export default class Preview extends ThemeCommand {
5858
}),
5959
}
6060

61-
static multiEnvironmentsFlags: RequiredFlags = null
61+
static multiEnvironmentsFlags: RequiredFlags = ['store', 'password', 'theme', 'overrides']
6262

6363
async command(flags: PreviewFlags, adminSession: AdminSession, multiEnvironment = false) {
64-
if (!flags.theme || !flags.overrides) {
65-
throw new AbortError('Specify both --theme and --overrides, either as flags or in an environment.')
66-
}
6764
const theme = await findOrSelectTheme(adminSession, {filter: {theme: flags.theme}})
6865
const result = await devWithOverrideFile({
6966
adminSession,
70-
overrideJson: flags.overrides,
67+
overrideJson: flags.overrides!,
7168
themeId: theme.id.toString(),
7269
previewIdentifier: flags['preview-id'],
7370
password: flags.password,
7471
})
7572
const format = flags.json ? 'json' : 'text'
7673
if (flags.open) {
77-
await openURL(result.url).catch((error: Error) => renderThemePreviewOpenError(error, format))
74+
const opened = await openURL(result.url).catch((error: Error) => renderThemePreviewOpenError(error))
75+
if (opened === false) {
76+
renderThemePreviewOpenError(new Error(`Could not open the browser. Open this URL manually: ${result.url}`))
77+
}
7878
}
7979
if (!multiEnvironment || !flags.json) renderThemePreviewResult(result, format, Boolean(flags['preview-id']))
8080
return result
@@ -83,4 +83,16 @@ export default class Preview extends ThemeCommand {
8383
protected renderEnvironmentResults(environments: ThemeEnvironmentResult[]): void {
8484
outputResult(themePreviewJsonOutputSchema.encode({environments}))
8585
}
86+
87+
protected validateCommandFlags(flags: PreviewFlags): void {
88+
if (!flags.theme || !flags.overrides) {
89+
throw new AbortError('Specify both --theme and --overrides, either as flags or in an environment.')
90+
}
91+
}
92+
93+
protected requiredFlagsForSingleEnvironment(
94+
requiredFlags: Exclude<RequiredFlags, null>,
95+
): Exclude<RequiredFlags, null> {
96+
return super.requiredFlagsForSingleEnvironment(requiredFlags).filter((flag) => flag !== 'store')
97+
}
8698
}

‎packages/theme/src/cli/services/dev-override/codec.ts‎

Lines changed: 0 additions & 5 deletions
This file was deleted.

‎packages/theme/src/cli/services/dev-override/result.test.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,24 +25,26 @@ test.each([false, true])('preserves the success banner with updated=%s', (update
2525

2626
test('preserves the browser warning in text mode', () => {
2727
const error = new Error('Browser unavailable')
28-
renderThemePreviewOpenError(error, 'text')
28+
renderThemePreviewOpenError(error)
2929

3030
expect(renderWarning).toHaveBeenCalledWith({headline: 'Failed to open theme preview.', body: error.stack})
3131
})
3232

3333
test('sends browser failures as diagnostic events to stderr', async () => {
34+
const {renderWarning: actualRenderWarning} =
35+
await vi.importActual<typeof import('@shopify/cli-kit/node/ui')>('@shopify/cli-kit/node/ui')
36+
vi.mocked(renderWarning).mockImplementationOnce(actualRenderWarning)
3437
const error = new Error('Browser unavailable')
3538
await withCapturedStandardStreams(async ({stdout, stderr}) => {
36-
await runWithCommandEventsForCommand(['--json'], () => renderThemePreviewOpenError(error, 'json'))
39+
await runWithCommandEventsForCommand(['--json'], () => renderThemePreviewOpenError(error))
3740

3841
expect(stdout()).toBe('')
3942
expect(JSON.parse(stderr())).toMatchObject({
4043
type: 'diagnostic',
4144
level: 'warning',
42-
message: `Failed to open theme preview.\n${error.stack}`,
45+
message: `Failed to open theme preview.\n\n${error.stack}`,
4346
})
4447
})
45-
expect(renderWarning).not.toHaveBeenCalled()
4648
})
4749

4850
test('writes one JSON object to stdout outside the command event context', async () => {
Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
1-
import {type ThemePreviewResult} from './types.js'
2-
import {encodeThemePreviewResult} from './codec.js'
1+
import {themePreviewJsonOutputSchema, type ThemePreviewResult} from './types.js'
32
import {outputResult} from '@shopify/cli-kit/node/output'
43
import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui'
5-
import {emitCommandEvent} from '@shopify/cli-kit/node/command-events'
64

75
export function renderThemePreviewResult(result: ThemePreviewResult, format: 'text' | 'json', updated: boolean): void {
86
if (format === 'json') {
9-
outputResult(encodeThemePreviewResult(result))
7+
outputResult(themePreviewJsonOutputSchema.encode(result))
108
return
119
}
1210

@@ -22,12 +20,8 @@ export function renderThemePreviewResult(result: ThemePreviewResult, format: 'te
2220
})
2321
}
2422

25-
export function renderThemePreviewOpenError(error: Error, format: 'text' | 'json'): void {
23+
export function renderThemePreviewOpenError(error: Error): void {
2624
const headline = 'Failed to open theme preview.'
2725
const body = error.stack ?? error.message
28-
if (format === 'json') {
29-
emitCommandEvent({type: 'diagnostic', level: 'warning', message: `${headline}\n${body}`})
30-
} else {
31-
renderWarning({headline, body})
32-
}
26+
renderWarning({headline, body})
3327
}

packages/theme/src/cli/services/dev-override/codec.test.ts renamed to packages/theme/src/cli/services/dev-override/types.test.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,18 @@
1-
import {encodeThemePreviewResult} from './codec.js'
21
import {themePreviewJsonOutputSchema} from './types.js'
32
import {expect, test} from 'vitest'
43

54
const result = {url: 'https://abc123.shopifypreview.com', preview_identifier: 'abc123'}
65

76
test('projects the native preview response to the strict public result', () => {
8-
expect(JSON.parse(encodeThemePreviewResult(result))).toEqual({
7+
expect(JSON.parse(themePreviewJsonOutputSchema.encode(result))).toEqual({
98
status: 'success',
109
preview: {id: 'abc123', url: result.url},
1110
})
1211
})
1312

1413
test('omits internal API fields', () => {
1514
const response = {...result, internal: 'private'}
16-
expect(JSON.parse(encodeThemePreviewResult(response))).toEqual({
15+
expect(JSON.parse(themePreviewJsonOutputSchema.encode(response))).toEqual({
1716
status: 'success',
1817
preview: {id: 'abc123', url: result.url},
1918
})
@@ -29,7 +28,7 @@ test.each([
2928
})
3029

3130
test('validates the opaque preview namespace, URL and strict object fields', () => {
32-
const output = JSON.parse(encodeThemePreviewResult(result))
31+
const output = JSON.parse(themePreviewJsonOutputSchema.encode(result))
3332
expect(themePreviewJsonOutputSchema.validate(output)).toEqual(output)
3433
expect(() => themePreviewJsonOutputSchema.validate({...output, unknown: true})).toThrow()
3534
expect(() => themePreviewJsonOutputSchema.validate({...output, preview: {...output.preview, id: ''}})).toThrow()

0 commit comments

Comments
 (0)