Skip to content

Commit 9ef5dba

Browse files
committed
E2E: automatic teardown polish
1 parent 71cf082 commit 9ef5dba

10 files changed

Lines changed: 307 additions & 211 deletions

packages/e2e/setup/app.ts

Lines changed: 179 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -27,20 +27,16 @@ export async function createApp(ctx: {
2727
const template = ctx.template ?? 'reactRouter'
2828
const packageManager =
2929
ctx.packageManager ?? (process.env.E2E_PACKAGE_MANAGER as 'npm' | 'yarn' | 'pnpm' | 'bun') ?? 'pnpm'
30-
31-
const args = [
32-
'--name',
33-
name,
34-
'--path',
35-
parentDir,
36-
'--package-manager',
37-
packageManager,
38-
'--local',
39-
'--template',
40-
template,
41-
]
30+
// reactRouter/remix both require a --flavor or they'll hang on the language
31+
// prompt in non-interactive runs. Default to javascript when template needs
32+
// it. For `--template none` (extension-only) flavor is ignored.
33+
const flavor = ctx.flavor ?? (template === 'none' ? undefined : 'javascript')
34+
35+
const args = ['--template', template]
36+
if (flavor) args.push('--flavor', flavor)
37+
args.push('--name', name, '--package-manager', packageManager, '--local')
4238
if (ctx.orgId) args.push('--organization-id', ctx.orgId)
43-
if (ctx.flavor) args.push('--flavor', ctx.flavor)
39+
args.push('--path', parentDir)
4440

4541
const result = await cli.execCreateApp(args, {
4642
env: {FORCE_COLOR: '0'},
@@ -116,8 +112,9 @@ export async function generateExtension(
116112
flavor?: string
117113
},
118114
): Promise<ExecResult> {
119-
const args = ['app', 'generate', 'extension', '--name', ctx.name, '--path', ctx.appDir, '--template', ctx.template]
115+
const args = ['app', 'generate', 'extension', '--template', ctx.template]
120116
if (ctx.flavor) args.push('--flavor', ctx.flavor)
117+
args.push('--name', ctx.name, '--path', ctx.appDir)
121118
return ctx.cli.exec(args, {timeout: CLI_TIMEOUT.long})
122119
}
123120

@@ -134,12 +131,13 @@ export async function deployApp(
134131
noBuild?: boolean
135132
},
136133
): Promise<ExecResult> {
137-
const args = ['app', 'deploy', '--path', ctx.appDir]
138-
if (ctx.force ?? true) args.push('--force')
139-
if (ctx.noBuild) args.push('--no-build')
134+
const args = ['app', 'deploy']
140135
if (ctx.version) args.push('--version', ctx.version)
141136
if (ctx.message) args.push('--message', ctx.message)
142137
if (ctx.config) args.push('--config', ctx.config)
138+
if (ctx.force ?? true) args.push('--force')
139+
if (ctx.noBuild) args.push('--no-build')
140+
args.push('--path', ctx.appDir)
143141
return ctx.cli.exec(args, {timeout: CLI_TIMEOUT.long})
144142
}
145143

@@ -152,7 +150,7 @@ export async function appInfo(ctx: CLIContext): Promise<{
152150
entrySourceFilePath: string
153151
}[]
154152
}> {
155-
const result = await ctx.cli.exec(['app', 'info', '--path', ctx.appDir, '--json'])
153+
const result = await ctx.cli.exec(['app', 'info', '--json', '--path', ctx.appDir])
156154
if (result.exitCode !== 0) {
157155
throw new Error(`app info failed (exit ${result.exitCode}):\nstdout: ${result.stdout}\nstderr: ${result.stderr}`)
158156
}
@@ -168,25 +166,124 @@ export async function functionRun(
168166
inputPath: string
169167
},
170168
): Promise<ExecResult> {
171-
return ctx.cli.exec(['app', 'function', 'run', '--path', ctx.appDir, '--input', ctx.inputPath], {
169+
return ctx.cli.exec(['app', 'function', 'run', '--input', ctx.inputPath, '--path', ctx.appDir], {
172170
timeout: CLI_TIMEOUT.short,
173171
})
174172
}
175173

176-
export async function versionsList(ctx: CLIContext): Promise<ExecResult> {
177-
return ctx.cli.exec(['app', 'versions', 'list', '--path', ctx.appDir, '--json'], {
178-
timeout: CLI_TIMEOUT.short,
179-
})
174+
export async function versionsList(
175+
ctx: CLIContext & {
176+
config?: string
177+
},
178+
): Promise<ExecResult> {
179+
const args = ['app', 'versions', 'list', '--json']
180+
if (ctx.config) args.push('--config', ctx.config)
181+
args.push('--path', ctx.appDir)
182+
return ctx.cli.exec(args, {timeout: CLI_TIMEOUT.short})
180183
}
181184

185+
/**
186+
* Run `app config link` to create a brand-new app on Shopify interactively.
187+
* Answers the prompts:
188+
* "Which organization is this work for?" → filter by orgId → Enter
189+
* "Create this project as a new app on Shopify?" → Yes (default)
190+
* "App name" → appName
191+
* "Configuration file name" → skipped via `--config` flag
192+
*
193+
* Env overrides (via PTY spawn):
194+
* CI=undefined — drop the key so prompts render.
195+
* Fixture default is CI=1; Ink's `is-in-ci`
196+
* treats `'CI' in env` as CI even when ''.
197+
* In CI mode Ink suppresses prompt frames
198+
* (only emitted on unmount), so waitForOutput
199+
* hangs until the process is killed.
200+
* SHOPIFY_CLI_NEVER_USE_PARTNERS_API=1 — skip Partners client in fetchOrganizations.
201+
* Without this, fetchOrganizations iterates
202+
* AppManagement AND Partners sequentially.
203+
* Partners requires SHOPIFY_CLI_PARTNERS_TOKEN
204+
* (not set in OAuth-auth'd tests) and hangs
205+
* for minutes trying to authenticate. The e2e
206+
* test org (161686155) lives in AppManagement.
207+
*/
182208
export async function configLink(
183209
ctx: CLIContext & {
184-
clientId: string
210+
appName: string
211+
orgId: string
212+
configName?: string
185213
},
186214
): Promise<ExecResult> {
187-
return ctx.cli.exec(['app', 'config', 'link', '--path', ctx.appDir, '--client-id', ctx.clientId], {
188-
timeout: CLI_TIMEOUT.medium,
215+
const args = ['app', 'config', 'link']
216+
// Pass configName as --config flag. link.ts → loadConfigurationFileName skips
217+
// the "Configuration file name" prompt when options.configName is set, which
218+
// also side-steps a painful interactive quirk: that prompt uses
219+
// `initialAnswer = remoteApp.title`, so any text we write would be appended
220+
// to the app name rather than replacing it.
221+
if (ctx.configName) args.push('--config', ctx.configName)
222+
args.push('--path', ctx.appDir)
223+
224+
const proc = await ctx.cli.spawn(args, {
225+
env: {
226+
CI: undefined,
227+
SHOPIFY_CLI_NEVER_USE_PARTNERS_API: '1',
228+
},
189229
})
230+
231+
// Short sleep so Ink's useInput hooks attach before we start writing.
232+
// Without this, an Enter press arrives mid-mount and a subsequent render can
233+
// flip the prompt state unexpectedly (e.g. turning a select into search mode).
234+
const settle = (ms = 150) => new Promise<void>((resolve) => setTimeout(resolve, ms))
235+
236+
try {
237+
// The first prompt is either the multi-org selector or — when the account
238+
// has only one org, or none of the orgs have existing apps — we jump
239+
// straight to `createAsNewAppPrompt`. Race both.
240+
const firstPrompt = await Promise.race([
241+
proc.waitForOutput('Which organization', CLI_TIMEOUT.medium).then(() => 'org' as const),
242+
proc.waitForOutput('Create this project as a new app', CLI_TIMEOUT.medium).then(() => 'create' as const),
243+
proc.waitForOutput('App name', CLI_TIMEOUT.medium).then(() => 'appName' as const),
244+
])
245+
246+
if (firstPrompt === 'org') {
247+
// Type the orgId to filter the autocomplete prompt to exactly one match.
248+
// selectOrganizationPrompt's label includes `(${org.id})` when duplicate
249+
// org names exist (which is true for the e2e test account), so substring
250+
// matching on the numeric ID is unique. Avoids relying on MRU ordering.
251+
await settle()
252+
proc.ptyProcess.write(ctx.orgId)
253+
await settle()
254+
proc.sendKey('\r')
255+
// After org selection the CLI fetches apps for the chosen org. If
256+
// the org has existing apps → "Create this project" prompt. If it has
257+
// zero apps → selectOrCreateApp skips straight to appNamePrompt.
258+
const next = await Promise.race([
259+
proc.waitForOutput('Create this project as a new app', CLI_TIMEOUT.medium).then(() => 'create' as const),
260+
proc.waitForOutput('App name', CLI_TIMEOUT.medium).then(() => 'appName' as const),
261+
])
262+
if (next === 'create') {
263+
await settle()
264+
proc.sendKey('\r')
265+
}
266+
} else if (firstPrompt === 'create') {
267+
await settle()
268+
proc.sendKey('\r')
269+
}
270+
271+
// Wait for "App name" text prompt and submit the desired name.
272+
// Important: Ink parses each PTY data event as ONE keypress. If we write
273+
// "name\r" in one call, parseKeypress sees the whole string and treats
274+
// it as text (not Enter), so the prompt never submits. We must write the
275+
// text, wait for it to be consumed, then write \r separately.
276+
await proc.waitForOutput('App name', CLI_TIMEOUT.medium)
277+
await settle()
278+
proc.ptyProcess.write(ctx.appName)
279+
await settle()
280+
proc.sendKey('\r')
281+
282+
const exitCode = await proc.waitForExit(CLI_TIMEOUT.long)
283+
return {exitCode, stdout: proc.getOutput(), stderr: ''}
284+
} finally {
285+
proc.kill()
286+
}
190287
}
191288

192289
// ---------------------------------------------------------------------------
@@ -225,70 +322,69 @@ export async function findAppOnDevDashboard(page: Page, appName: string, orgId?:
225322
return null
226323
}
227324

228-
/** Delete an app from its dev dashboard settings page. Returns true if deleted, false if not. */
325+
/**
326+
* Delete an app from its dev dashboard settings page. Returns true if deleted.
327+
* Mirrors the resilience patterns from scripts/cleanup-apps.ts:deleteApp:
328+
* - Outer 3-attempt retry loop around the whole flow (500/502 recovery)
329+
* - scrollIntoViewIfNeeded on the "Delete app" button
330+
* - Conditional "type DELETE" confirmation (some orgs/modals require it)
331+
* - Verify via settings-page reload → expect 404
332+
*/
229333
export async function deleteAppFromDevDashboard(page: Page, appUrl: string): Promise<boolean> {
230-
// Step 1: Navigate to settings page
231-
await page.goto(`${appUrl}/settings`, {waitUntil: 'domcontentloaded'})
232-
await page.waitForTimeout(BROWSER_TIMEOUT.medium)
233-
await refreshIfPageError(page)
234-
235-
// Step 2: Wait for "Delete app" button to be enabled, then click (retry with error check)
236-
const deleteAppBtn = page.locator('button:has-text("Delete app")').first()
237-
for (let attempt = 1; attempt <= 3; attempt++) {
238-
if (await refreshIfPageError(page)) continue
239-
const isDisabled = await deleteAppBtn.getAttribute('disabled').catch(() => 'true')
240-
if (!isDisabled) break
241-
await page.reload({waitUntil: 'domcontentloaded'})
242-
await page.waitForTimeout(BROWSER_TIMEOUT.medium)
243-
}
244-
245-
// Click the delete button — if it's not found, the page didn't load properly
246-
const deleteClicked = await deleteAppBtn
247-
.click({timeout: BROWSER_TIMEOUT.long})
248-
.then(() => true)
249-
.catch(() => false)
250-
if (!deleteClicked) return false
251-
await page.waitForTimeout(BROWSER_TIMEOUT.medium)
252-
253-
// Step 3: Click confirm "Delete" in the modal (retry step 2+3 if not visible)
254-
// The dev dashboard modal has a submit button with class "critical" inside a form
255-
const confirmAppBtn = page.locator('button.critical[type="submit"]')
256334
for (let attempt = 1; attempt <= 3; attempt++) {
257-
if (await confirmAppBtn.isVisible({timeout: BROWSER_TIMEOUT.medium}).catch(() => false)) break
258-
if (attempt === 3) return false
259-
// Retry: re-click the delete button to reopen modal
260-
await page.keyboard.press('Escape')
261-
await page.waitForTimeout(BROWSER_TIMEOUT.short)
262-
const retryClicked = await deleteAppBtn
263-
.click({timeout: BROWSER_TIMEOUT.long})
264-
.then(() => true)
265-
.catch(() => false)
266-
if (!retryClicked) return false
267-
await page.waitForTimeout(BROWSER_TIMEOUT.medium)
268-
}
269-
270-
const urlBefore = page.url()
271-
const confirmClicked = await confirmAppBtn
272-
.click({timeout: BROWSER_TIMEOUT.long})
273-
.then(() => true)
274-
.catch(() => false)
275-
if (!confirmClicked) return false
335+
try {
336+
await page.goto(`${appUrl}/settings`, {waitUntil: 'domcontentloaded'})
337+
await page.waitForTimeout(BROWSER_TIMEOUT.medium)
276338

277-
// Wait for page to navigate away after deletion
278-
try {
279-
await page.waitForURL((url) => url.toString() !== urlBefore, {timeout: BROWSER_TIMEOUT.max})
280-
// eslint-disable-next-line no-catch-all/no-catch-all
281-
} catch (_err) {
282-
// URL didn't change — check if page error occurred during redirect
283-
if (await refreshIfPageError(page)) {
284-
// After refresh, 404 means the app was deleted (settings page no longer exists)
339+
// 404 before we even click = app already gone. 500/502 = throw to retry.
285340
const bodyText = (await page.textContent('body')) ?? ''
286341
if (bodyText.includes('404: Not Found')) return true
287-
return false
342+
if (bodyText.includes('500: Internal Server Error') || bodyText.includes('502 Bad Gateway')) {
343+
throw new Error('Server error loading app settings page')
344+
}
345+
await refreshIfPageError(page)
346+
347+
// If uninstall succeeded, the button enables within ~1-2s. Reload once
348+
// as a fallback for propagation lag; click()'s auto-wait handles the
349+
// rest at ~100ms granularity. Button can be below the fold.
350+
const deleteBtn = page.locator('button:has-text("Delete app")').first()
351+
await deleteBtn.scrollIntoViewIfNeeded()
352+
if (!(await deleteBtn.isEnabled())) {
353+
await page.reload({waitUntil: 'domcontentloaded'})
354+
await page.waitForTimeout(BROWSER_TIMEOUT.medium)
355+
await deleteBtn.scrollIntoViewIfNeeded()
356+
}
357+
// 10s: generous budget for click auto-wait
358+
await deleteBtn.click({timeout: 2 * BROWSER_TIMEOUT.long})
359+
await page.waitForTimeout(BROWSER_TIMEOUT.medium)
360+
361+
// Some confirmation modals require typing "DELETE". Fill if present.
362+
const confirmInput = page.locator('input[type="text"]').last()
363+
if (await confirmInput.isVisible({timeout: BROWSER_TIMEOUT.medium}).catch(() => false)) {
364+
await confirmInput.fill('DELETE')
365+
await page.waitForTimeout(BROWSER_TIMEOUT.short)
366+
}
367+
368+
// Confirm button is the second "Delete app" button on the page (inside modal).
369+
const confirmBtn = page.locator('button:has-text("Delete app")').last()
370+
await confirmBtn.click({timeout: BROWSER_TIMEOUT.long})
371+
await page.waitForTimeout(BROWSER_TIMEOUT.medium)
372+
373+
// Verify by reloading settings — 404 means deleted.
374+
await page.goto(`${appUrl}/settings`, {waitUntil: 'domcontentloaded'})
375+
await page.waitForTimeout(BROWSER_TIMEOUT.short)
376+
const afterText = (await page.textContent('body')) ?? ''
377+
if (afterText.includes('404: Not Found')) return true
378+
if (afterText.includes('500: Internal Server Error') || afterText.includes('502 Bad Gateway')) {
379+
throw new Error('Server error verifying app deletion')
380+
}
381+
// eslint-disable-next-line no-catch-all/no-catch-all
382+
} catch (_err) {
383+
if (attempt === 3) return false
384+
await page.waitForTimeout(BROWSER_TIMEOUT.medium)
288385
}
289-
await page.waitForTimeout(BROWSER_TIMEOUT.medium)
290386
}
291-
return page.url() !== urlBefore
387+
return false
292388
}
293389

294390
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)