@@ -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+ */
182208export 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+ */
229333export 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+ // 15s: generous budget for click auto-wait; between BROWSER_TIMEOUT.long (5s) and .max (60s).
358+ await deleteBtn . click ( { timeout : 15_000 } )
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