Skip to content

Commit c3d9bc6

Browse files
authored
Merge pull request #8989 from Shopify/theme-profile-redirect-error
Fix theme profile for URLs with a query string and report redirects
2 parents 1c7a199 + e85a56f commit c3d9bc6

8 files changed

Lines changed: 246 additions & 17 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/theme': patch
3+
---
4+
5+
Fix `theme profile` failing with "Bad response: 301" when `--url` includes a query string, and show an actionable error when the page redirects.

‎docs-shopify.dev/generated/generated_docs_data_v2.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9966,7 +9966,7 @@
99669966
"syntaxKind": "PropertySignature",
99679967
"name": "--url <value>",
99689968
"value": "string",
9969-
"description": "The url to be used as context",
9969+
"description": "The path of the page to profile, with an optional query string. For a full URL, only the path and query string are used.",
99709970
"isOptional": true,
99719971
"environmentValue": "SHOPIFY_FLAG_URL"
99729972
},
@@ -10016,7 +10016,7 @@
1001610016
"environmentValue": "SHOPIFY_FLAG_THEME_ID"
1001710017
}
1001810018
],
10019-
"value": "export interface themeprofile {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias <value>'?: string\n\n /**\n * The environment to apply to the current command.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '-e, --environment <value>'?: string\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * Password generated from the Theme Access app or an Admin API token.\n * @environment SHOPIFY_CLI_THEME_TOKEN\n */\n '--password <value>'?: string\n\n /**\n * The path where you want to run the command. Defaults to the current working directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path <value>'?: string\n\n /**\n * Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, https://example.myshopify.com).\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store <value>'?: string\n\n /**\n * The password for storefronts with password protection.\n * @environment SHOPIFY_FLAG_STORE_PASSWORD\n */\n '--store-password <value>'?: string\n\n /**\n * Theme ID or name of the remote theme.\n * @environment SHOPIFY_FLAG_THEME_ID\n */\n '-t, --theme <value>'?: string\n\n /**\n * The url to be used as context\n * @environment SHOPIFY_FLAG_URL\n */\n '--url <value>'?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
10019+
"value": "export interface themeprofile {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias <value>'?: string\n\n /**\n * The environment to apply to the current command.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '-e, --environment <value>'?: string\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * Password generated from the Theme Access app or an Admin API token.\n * @environment SHOPIFY_CLI_THEME_TOKEN\n */\n '--password <value>'?: string\n\n /**\n * The path where you want to run the command. Defaults to the current working directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path <value>'?: string\n\n /**\n * Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, https://example.myshopify.com).\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store <value>'?: string\n\n /**\n * The password for storefronts with password protection.\n * @environment SHOPIFY_FLAG_STORE_PASSWORD\n */\n '--store-password <value>'?: string\n\n /**\n * Theme ID or name of the remote theme.\n * @environment SHOPIFY_FLAG_THEME_ID\n */\n '-t, --theme <value>'?: string\n\n /**\n * The path of the page to profile, with an optional query string. For a full URL, only the path and query string are used.\n * @environment SHOPIFY_FLAG_URL\n */\n '--url <value>'?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
1002010020
}
1002110021
},
1002210022
"themepublish": {

‎packages/cli/README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17689,7 +17689,8 @@ FLAGS
1768917689
[env: SHOPIFY_FLAG_STORE_PASSWORD]
1769017690

1769117691
--url=<value>
17692-
[default: /] The url to be used as context
17692+
[default: /] The path of the page to profile, with an optional query string. For a full URL, only the path and query
17693+
string are used.
1769317694
[env: SHOPIFY_FLAG_URL]
1769417695

1769517696
--verbose

‎packages/cli/oclif.manifest.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12349,7 +12349,7 @@
1234912349
},
1235012350
"url": {
1235112351
"default": "/",
12352-
"description": "The url to be used as context",
12352+
"description": "The path of the page to profile, with an optional query string. For a full URL, only the path and query string are used.",
1235312353
"env": "SHOPIFY_FLAG_URL",
1235412354
"hasDynamicHelp": false,
1235512355
"multiple": false,

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ export default class Profile extends ThemeCommand {
3737
env: 'SHOPIFY_FLAG_THEME_ID',
3838
}),
3939
url: Flags.string({
40-
description: 'The url to be used as context',
40+
description:
41+
'The path of the page to profile, with an optional query string. For a full URL, only the path and query string are used.',
4142
env: 'SHOPIFY_FLAG_URL',
4243
default: '/',
4344
}),

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

Lines changed: 156 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,20 +112,173 @@ describe('profile', () => {
112112
await expect(result).rejects.toThrow('Network error')
113113
})
114114

115-
test('throws error when response status is not 200', async () => {
115+
test('passes the query string of the url separately from the path', async () => {
116+
// When
117+
await profile(mockAdminSession, themeId, '/search?q=kw&page=2#results')
118+
119+
// Then
120+
expect(render).toHaveBeenCalledWith(
121+
expect.anything(),
122+
expect.objectContaining({
123+
path: '/search',
124+
query: [
125+
['q', 'kw'],
126+
['page', '2'],
127+
],
128+
}),
129+
)
130+
})
131+
132+
test('does not let the url override the storefront renderer query params', async () => {
133+
// When
134+
await profile(mockAdminSession, themeId, '/collections/all?_fd=1&pb=1&page=2')
135+
136+
// Then
137+
expect(render).toHaveBeenCalledWith(
138+
expect.anything(),
139+
expect.objectContaining({path: '/collections/all', query: [['page', '2']]}),
140+
)
141+
})
142+
143+
test('uses only the path and query of an absolute url', async () => {
144+
// When
145+
await profile(mockAdminSession, themeId, 'https://custom-domain.com/products/test?variant=1')
146+
147+
// Then
148+
expect(render).toHaveBeenCalledWith(
149+
expect.anything(),
150+
expect.objectContaining({path: '/products/test', query: [['variant', '1']]}),
151+
)
152+
})
153+
154+
test.each(['//products/test', 'https://'])('reports %s as an invalid url', async (url) => {
155+
// When
156+
const result = profile(mockAdminSession, themeId, url)
157+
158+
// Then
159+
await expect(result).rejects.toThrow(
160+
new AbortError(
161+
`${url} isn't a valid storefront URL.`,
162+
'Pass the path of a storefront page to --url, for example /products/classic-leather-jacket.',
163+
),
164+
)
165+
expect(render).not.toHaveBeenCalled()
166+
})
167+
168+
test('suggests the redirect destination when the page redirects within the storefront', async () => {
169+
// Given
170+
vi.mocked(render).mockResolvedValue(new Response(null, {status: 301, headers: {Location: '/pages/new?_fd=0&pb=0'}}))
171+
172+
// When
173+
const result = profile(mockAdminSession, themeId, urlPath)
174+
175+
// Then
176+
await expect(result).rejects.toThrow(
177+
new AbortError(
178+
`The page ${urlPath} redirects (301) instead of rendering, so it can't be profiled.`,
179+
'It redirects to /pages/new. Run the command again with --url "/pages/new".',
180+
),
181+
)
182+
})
183+
184+
test('shows the full redirect destination when the page redirects to another host', async () => {
185+
// Given
186+
vi.mocked(render).mockResolvedValue(
187+
new Response(null, {
188+
status: 301,
189+
headers: {Location: 'https://custom-domain.com/de/pages/x?_fd=0&pb=0&view=full'},
190+
}),
191+
)
192+
193+
// When
194+
const result = profile(mockAdminSession, themeId, urlPath)
195+
196+
// Then
197+
await expect(result).rejects.toThrow(
198+
new AbortError(
199+
`The page ${urlPath} redirects (301) instead of rendering, so it can't be profiled.`,
200+
'It redirects to https://custom-domain.com/de/pages/x?view=full. If that page is on your storefront, run the command again with --url "/de/pages/x?view=full".',
201+
),
202+
)
203+
})
204+
205+
test('does not suggest the same url when the page redirects to the same path', async () => {
206+
// Given
207+
vi.mocked(render).mockResolvedValue(
208+
new Response(null, {status: 301, headers: {Location: `https://custom-domain.com${urlPath}?_fd=0&pb=0`}}),
209+
)
210+
211+
// When
212+
const result = profile(mockAdminSession, themeId, urlPath)
213+
214+
// Then
215+
await expect(result).rejects.toThrow(
216+
new AbortError(
217+
`The page ${urlPath} redirects (301) instead of rendering, so it can't be profiled.`,
218+
`It redirects to https://custom-domain.com${urlPath}.`,
219+
),
220+
)
221+
})
222+
223+
test('suggests the storefront password when the page redirects to the password page', async () => {
224+
// Given
225+
vi.mocked(render).mockResolvedValue(new Response(null, {status: 302, headers: {Location: '/password'}}))
226+
227+
// When
228+
const result = profile(mockAdminSession, themeId, '/')
229+
230+
// Then
231+
await expect(result).rejects.toThrow(
232+
new AbortError(
233+
"The page / redirects (302) instead of rendering, so it can't be profiled.",
234+
'It redirects to the storefront password page. Check the storefront password, and pass it with --store-password.',
235+
),
236+
)
237+
})
238+
239+
test('reports a redirect without a location as an expected error', async () => {
240+
// Given
241+
vi.mocked(render).mockResolvedValue(new Response(null, {status: 301}))
242+
243+
// When
244+
const result = profile(mockAdminSession, themeId, urlPath)
245+
246+
// Then
247+
await expect(result).rejects.toThrow(
248+
new AbortError(`The page ${urlPath} redirects (301) instead of rendering, so it can't be profiled.`),
249+
)
250+
})
251+
252+
test('reports a non-200 response as an expected error', async () => {
116253
// Given
117254
vi.mocked(render).mockResolvedValue(
118255
new Response(JSON.stringify({error: 'Some error message'}), {
119256
status: 404,
120-
headers: new Headers({'content-type': 'application/json'}),
257+
headers: new Headers({'content-type': 'application/json', 'x-request-id': 'request-id'}),
121258
}),
122259
)
123260

124261
// When
125262
const result = profile(mockAdminSession, themeId, urlPath)
126263

127264
// Then
128-
await expect(result).rejects.toThrow('Bad response: 404: {"error":"Some error message"}')
265+
await expect(result).rejects.toThrow(
266+
new AbortError(
267+
`Can't profile ${urlPath}. The storefront responded with 404.\nRequest ID: request-id\n{"error":"Some error message"}`,
268+
),
269+
)
270+
})
271+
272+
test('does not include a long non-JSON response body in the error', async () => {
273+
// Given
274+
const htmlPage = `<html><body>${'Server error. '.repeat(20)}</body></html>`
275+
vi.mocked(render).mockResolvedValue(new Response(htmlPage, {status: 500}))
276+
277+
// When
278+
const result = profile(mockAdminSession, themeId, urlPath)
279+
280+
// Then
281+
await expect(result).rejects.toThrow(new AbortError(`Can't profile ${urlPath}. The storefront responded with 500.`))
129282
})
130283

131284
test('throws error when a password is used', async () => {
Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,32 @@
11
import {themeProfileResultSchema} from './profile/types.js'
2-
import {render} from '../utilities/theme-environment/storefront-renderer.js'
2+
import {render, STOREFRONT_RENDERER_QUERY_PARAMS} from '../utilities/theme-environment/storefront-renderer.js'
33
import {fetchDevServerSession} from '../utilities/theme-environment/dev-server-session.js'
44
import {ensureValidPassword} from '../utilities/theme-environment/storefront-password-prompt.js'
55
import {isStorefrontPasswordProtected} from '../utilities/theme-environment/storefront-session.js'
66
import {AdminSession} from '@shopify/cli-kit/node/session'
77
import {AbortError} from '@shopify/cli-kit/node/error'
88

9+
/** Origin used to parse store-relative URLs. It's never requested. */
10+
const PLACEHOLDER_ORIGIN = 'https://storefront.invalid'
11+
12+
/** Longest non-JSON response body included in an error message, so full HTML pages are never shown. */
13+
const MAX_ERROR_BODY_LENGTH = 200
14+
915
export async function profile(
1016
adminSession: AdminSession,
1117
themeId: string,
1218
url: string,
1319
themeAccessPassword?: string,
1420
storefrontPassword?: string,
1521
) {
22+
// A leading `//` would be parsed as a host, so the rest of the path would be profiled instead.
23+
if (url.startsWith('//') || !URL.canParse(url, PLACEHOLDER_ORIGIN)) {
24+
throw new AbortError(
25+
`${url} isn't a valid storefront URL.`,
26+
'Pass the path of a storefront page to --url, for example /products/classic-leather-jacket.',
27+
)
28+
}
29+
1630
const storePassword = (await isStorefrontPasswordProtected(adminSession))
1731
? await ensureValidPassword(storefrontPassword, adminSession.storeFqdn)
1832
: undefined
@@ -25,22 +39,77 @@ export async function profile(
2539
}
2640

2741
const session = await fetchDevServerSession(themeId, adminSession, themeAccessPassword, storePassword)
42+
const target = storefrontTarget(url)
2843
const response = await render(session, {
2944
method: 'GET',
30-
path: url,
31-
query: [],
45+
path: target.pathname,
46+
query: [...target.searchParams],
3247
themeId,
3348
headers: {
3449
Accept: 'application/vnd.speedscope+json',
3550
},
3651
})
3752

53+
if (response.status >= 300 && response.status < 400) {
54+
throw new AbortError(
55+
`The page ${url} redirects (${response.status}) instead of rendering, so it can't be profiled.`,
56+
redirectTryMessage(target, response.headers.get('Location')),
57+
)
58+
}
59+
3860
if (response.status !== 200) {
39-
const body = await response.text()
40-
throw new Error(`Bad response: ${response.status}: ${body}`)
61+
const details = await responseErrorDetails(response)
62+
throw new AbortError(`Can't profile ${url}. The storefront responded with ${response.status}.${details}`)
4163
}
4264

4365
const profileJson = await response.text()
4466

4567
return {result: themeProfileResultSchema.parse(JSON.parse(profileJson)), source: profileJson}
4668
}
69+
70+
/**
71+
* Parses a `--url` or `Location` value into a URL without the renderer's own query params.
72+
* The renderer adds its own query, so the query has to be passed separately from the path.
73+
*/
74+
function storefrontTarget(url: string) {
75+
const target = new URL(url, PLACEHOLDER_ORIGIN)
76+
Object.keys(STOREFRONT_RENDERER_QUERY_PARAMS).forEach((param) => target.searchParams.delete(param))
77+
return target
78+
}
79+
80+
function pathWithQuery(target: URL) {
81+
return `${target.pathname}${target.search}`
82+
}
83+
84+
function redirectTryMessage(requestedTarget: URL, location: string | null) {
85+
if (!location || !URL.canParse(location, PLACEHOLDER_ORIGIN)) return null
86+
87+
const redirectTarget = storefrontTarget(location)
88+
if (redirectTarget.pathname === '/password') {
89+
return 'It redirects to the storefront password page. Check the storefront password, and pass it with --store-password.'
90+
}
91+
92+
const suggestedUrl = pathWithQuery(redirectTarget)
93+
const isStoreRelative = redirectTarget.origin === PLACEHOLDER_ORIGIN
94+
const destination = isStoreRelative ? suggestedUrl : redirectTarget.href
95+
96+
// Suggesting the same --url again would make scripts and agents retry the same redirect.
97+
if (suggestedUrl === pathWithQuery(requestedTarget)) return `It redirects to ${destination}.`
98+
99+
// An absolute destination can be another host, such as customer accounts, where the path doesn't exist on the store.
100+
return isStoreRelative
101+
? `It redirects to ${destination}. Run the command again with --url "${suggestedUrl}".`
102+
: `It redirects to ${destination}. If that page is on your storefront, run the command again with --url "${suggestedUrl}".`
103+
}
104+
105+
async function responseErrorDetails(response: Response) {
106+
const requestId = response.headers.get('x-request-id')
107+
const requestIdDetail = requestId ? `\nRequest ID: ${requestId}` : ''
108+
109+
const body = (await response.text()).trim()
110+
const isJsonBody = response.headers.get('Content-Type')?.includes('application/json') ?? false
111+
const showBody = body.length > 0 && (isJsonBody || body.length <= MAX_ERROR_BODY_LENGTH)
112+
const bodyDetail = showBody ? `\n${body}` : ''
113+
114+
return `${requestIdDetail}${bodyDetail}`
115+
}

‎packages/theme/src/cli/utilities/theme-environment/storefront-renderer.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import {AdminSession} from '@shopify/cli-kit/node/session'
77
import {getThemeKitAccessDomain} from '@shopify/cli-kit/node/context/local'
88
import {recordError} from '@shopify/cli-kit/node/analytics'
99

10+
/** Query params that the renderer sets on every storefront request. */
11+
export const STOREFRONT_RENDERER_QUERY_PARAMS = {_fd: '0', pb: '0'}
12+
1013
export async function render(session: DevServerSession, context: DevServerRenderContext): Promise<Response> {
1114
const url = buildStorefrontUrl(session, context)
1215
const headers = await buildHeaders(session, context)
@@ -119,10 +122,7 @@ export function buildCookies(session: DevServerSession, ctx: Pick<DevServerRende
119122
function buildStorefrontUrl(session: DevServerSession, {path, sectionId, appBlockId, query}: DevServerRenderContext) {
120123
const baseUrl = buildBaseStorefrontUrl(session)
121124
const url = `${baseUrl}${path}`
122-
const params = new URLSearchParams({
123-
_fd: '0',
124-
pb: '0',
125-
})
125+
const params = new URLSearchParams(STOREFRONT_RENDERER_QUERY_PARAMS)
126126

127127
for (const [key, value] of query) {
128128
params.append(key, value)

0 commit comments

Comments
 (0)