Skip to content

Commit c24724f

Browse files
Fix theme profile for URLs with a query string and report redirects
Split the --url into a path and a query before rendering. The renderer appends its own query, so a --url with a query string produced a malformed URL that dropped _fd=0, and stores with a custom primary domain answered with a 301. Raise an AbortError with the redirect destination as the next step when the profiled page redirects, with a storefront password hint for /password. Raise an AbortError for other non-200 responses too, so they are reported as expected errors instead of crashes.
1 parent 5505e58 commit c24724f

3 files changed

Lines changed: 167 additions & 7 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.

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

Lines changed: 110 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,20 +112,127 @@ 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('suggests the redirect destination when the page redirects', async () => {
155+
// Given
156+
vi.mocked(render).mockResolvedValue(
157+
new Response(null, {
158+
status: 301,
159+
headers: {Location: 'https://custom-domain.com/de/pages/x?_fd=0&pb=0&view=full'},
160+
}),
161+
)
162+
163+
// When
164+
const result = profile(mockAdminSession, themeId, urlPath)
165+
166+
// Then
167+
await expect(result).rejects.toThrow(
168+
new AbortError(
169+
`The page ${urlPath} redirects (301) instead of rendering, so it can't be profiled.`,
170+
'It redirects to /de/pages/x?view=full. Run the command again with --url "/de/pages/x?view=full".',
171+
),
172+
)
173+
})
174+
175+
test('suggests the storefront password when the page redirects to the password page', async () => {
176+
// Given
177+
vi.mocked(render).mockResolvedValue(new Response(null, {status: 302, headers: {Location: '/password'}}))
178+
179+
// When
180+
const result = profile(mockAdminSession, themeId, '/')
181+
182+
// Then
183+
await expect(result).rejects.toThrow(
184+
new AbortError(
185+
"The page / redirects (302) instead of rendering, so it can't be profiled.",
186+
"It redirects to the storefront password page, so the storefront password wasn't accepted. Run the command again with the correct --store-password.",
187+
),
188+
)
189+
})
190+
191+
test('reports a redirect without a location as an expected error', async () => {
192+
// Given
193+
vi.mocked(render).mockResolvedValue(new Response(null, {status: 301}))
194+
195+
// When
196+
const result = profile(mockAdminSession, themeId, urlPath)
197+
198+
// Then
199+
await expect(result).rejects.toThrow(
200+
new AbortError(`The page ${urlPath} redirects (301) instead of rendering, so it can't be profiled.`),
201+
)
202+
})
203+
204+
test('reports a non-200 response as an expected error', async () => {
116205
// Given
117206
vi.mocked(render).mockResolvedValue(
118207
new Response(JSON.stringify({error: 'Some error message'}), {
119208
status: 404,
120-
headers: new Headers({'content-type': 'application/json'}),
209+
headers: new Headers({'content-type': 'application/json', 'x-request-id': 'request-id'}),
121210
}),
122211
)
123212

124213
// When
125214
const result = profile(mockAdminSession, themeId, urlPath)
126215

127216
// Then
128-
await expect(result).rejects.toThrow('Bad response: 404: {"error":"Some error message"}')
217+
await expect(result).rejects.toThrow(
218+
new AbortError(
219+
`Failed to profile ${urlPath}. The storefront responded with 404 (x-request-id: request-id): {"error":"Some error message"}`,
220+
),
221+
)
222+
})
223+
224+
test('does not include a long non-JSON response body in the error', async () => {
225+
// Given
226+
const htmlPage = `<html><body>${'Server error. '.repeat(20)}</body></html>`
227+
vi.mocked(render).mockResolvedValue(new Response(htmlPage, {status: 500}))
228+
229+
// When
230+
const result = profile(mockAdminSession, themeId, urlPath)
231+
232+
// Then
233+
await expect(result).rejects.toThrow(
234+
new AbortError(`Failed to profile ${urlPath}. The storefront responded with 500`),
235+
)
129236
})
130237

131238
test('throws error when a password is used', async () => {

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

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ import {isStorefrontPasswordProtected} from '../utilities/theme-environment/stor
66
import {AdminSession} from '@shopify/cli-kit/node/session'
77
import {AbortError} from '@shopify/cli-kit/node/error'
88

9+
/** Query parameters that the storefront renderer always sets itself. */
10+
const RENDERER_QUERY_PARAMS = ['_fd', 'pb']
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,
@@ -25,22 +31,64 @@ export async function profile(
2531
}
2632

2733
const session = await fetchDevServerSession(themeId, adminSession, themeAccessPassword, storePassword)
34+
const target = storefrontTarget(url)
2835
const response = await render(session, {
2936
method: 'GET',
30-
path: url,
31-
query: [],
37+
path: target.pathname,
38+
query: [...target.searchParams],
3239
themeId,
3340
headers: {
3441
Accept: 'application/vnd.speedscope+json',
3542
},
3643
})
3744

45+
if (response.status >= 300 && response.status < 400) {
46+
throw new AbortError(
47+
`The page ${url} redirects (${response.status}) instead of rendering, so it can't be profiled.`,
48+
redirectTryMessage(response.headers.get('Location')),
49+
)
50+
}
51+
3852
if (response.status !== 200) {
39-
const body = await response.text()
40-
throw new Error(`Bad response: ${response.status}: ${body}`)
53+
const details = await responseErrorDetails(response)
54+
throw new AbortError(`Failed to profile ${url}. The storefront responded with ${response.status}${details}`)
4155
}
4256

4357
const profileJson = await response.text()
4458

4559
return {result: themeProfileResultSchema.parse(JSON.parse(profileJson)), source: profileJson}
4660
}
61+
62+
/**
63+
* Parses a `--url` or `Location` value into a store-relative path and query.
64+
* The renderer adds its own query, so the query has to be passed separately from the path.
65+
*/
66+
function storefrontTarget(url: string) {
67+
const target = new URL(url, 'https://storefront.invalid')
68+
RENDERER_QUERY_PARAMS.forEach((param) => target.searchParams.delete(param))
69+
return target
70+
}
71+
72+
function redirectTryMessage(location: string | null) {
73+
if (!location) return null
74+
75+
const redirectTarget = storefrontTarget(location)
76+
if (redirectTarget.pathname === '/password') {
77+
return "It redirects to the storefront password page, so the storefront password wasn't accepted. Run the command again with the correct --store-password."
78+
}
79+
80+
const suggestedUrl = `${redirectTarget.pathname}${redirectTarget.search}`
81+
return `It redirects to ${suggestedUrl}. Run the command again with --url "${suggestedUrl}".`
82+
}
83+
84+
async function responseErrorDetails(response: Response) {
85+
const requestId = response.headers.get('x-request-id')
86+
const requestIdDetail = requestId ? ` (x-request-id: ${requestId})` : ''
87+
88+
const body = (await response.text()).trim()
89+
const isJsonBody = response.headers.get('Content-Type')?.includes('application/json') ?? false
90+
const showBody = body.length > 0 && (isJsonBody || body.length <= MAX_ERROR_BODY_LENGTH)
91+
const bodyDetail = showBody ? `: ${body}` : ''
92+
93+
return `${requestIdDetail}${bodyDetail}`
94+
}

0 commit comments

Comments
 (0)