@@ -7,7 +7,7 @@ import * as toml from '@iarna/toml'
77import * as path from 'path'
88import * as fs from 'fs'
99import type { CLIContext , CLIProcess , ExecResult } from './cli.js'
10- import type { BrowserContext } from './browser.js '
10+ import type { Page } from '@playwright/test '
1111
1212// ---------------------------------------------------------------------------
1313// CLI helpers — thin wrappers around cli.exec()
@@ -190,208 +190,84 @@ export async function configLink(
190190}
191191
192192// ---------------------------------------------------------------------------
193- // Browser helpers — app-specific dashboard automation
193+ // Dev dashboard browser actions — find and delete apps
194194// ---------------------------------------------------------------------------
195195
196- /** Find apps matching a name pattern on the dashboard. Call navigateToDashboard first. */
197- export async function findAppsOnDashboard (
198- ctx : BrowserContext & {
199- namePattern : string
200- } ,
201- ) : Promise < { name : string ; url : string } [ ] > {
202- const appCards = await ctx . browserPage . locator ( 'a[href*="/apps/"]' ) . all ( )
203- const apps : { name : string ; url : string } [ ] = [ ]
204-
205- for ( const card of appCards ) {
206- const href = await card . getAttribute ( 'href' )
207- const text = await card . textContent ( )
208- if ( ! href || ! text || ! href . match ( / \/ a p p s \/ \d + / ) ) continue
209-
210- const name = text . split ( / \d + \s + i n s t a l l / i) [ 0 ] ?. trim ( ) ?? text . split ( '\n' ) [ 0 ] ?. trim ( ) ?? text . trim ( )
211- if ( ! name || name . length > 200 ) continue
212- if ( ! name . includes ( ctx . namePattern ) ) continue
213-
214- const url = href . startsWith ( 'http' ) ? href : `https://dev.shopify.com${ href } `
215- apps . push ( { name, url} )
216- }
217-
218- return apps
219- }
220-
221- /** Uninstall an app from all stores it's installed on. Returns true if fully uninstalled. */
222- export async function uninstallApp (
223- ctx : BrowserContext & {
224- appUrl : string
225- appName : string
226- orgId ?: string
227- } ,
228- ) : Promise < boolean > {
229- const { browserPage, appUrl, appName} = ctx
230- const orgId = ctx . orgId ?? ( process . env . E2E_ORG_ID ?? '' ) . trim ( )
231-
232- await browserPage . goto ( `${ appUrl } /installs` , { waitUntil : 'domcontentloaded' } )
233- await browserPage . waitForTimeout ( BROWSER_TIMEOUT . medium )
234-
235- const rows = await browserPage . locator ( 'table tbody tr' ) . all ( )
236- const storeNames : string [ ] = [ ]
237- for ( const row of rows ) {
238- const firstCell = row . locator ( 'td' ) . first ( )
239- const text = ( await firstCell . textContent ( ) ) ?. trim ( )
240- if ( text && ! text . toLowerCase ( ) . includes ( 'no installed' ) ) storeNames . push ( text )
241- }
242-
243- if ( storeNames . length === 0 ) return true
244-
245- let allUninstalled = true
246- for ( const storeName of storeNames ) {
247- try {
248- // Navigate to store admin via the dev dashboard dropdown
249- const dashboardUrl = orgId
250- ? `https://dev.shopify.com/dashboard/${ orgId } /apps`
251- : 'https://dev.shopify.com/dashboard'
252- let navigated = false
253- for ( let attempt = 1 ; attempt <= 3 ; attempt ++ ) {
254- await browserPage . goto ( dashboardUrl , { waitUntil : 'domcontentloaded' } )
255- await browserPage . waitForTimeout ( BROWSER_TIMEOUT . medium )
256-
257- const pageText = ( await browserPage . textContent ( 'body' ) ) ?? ''
258- if ( pageText . includes ( '500' ) || pageText . includes ( 'Internal Server Error' ) ) continue
259-
260- const orgButton = browserPage . locator ( 'header button' ) . last ( )
261- if ( ! ( await orgButton . isVisible ( { timeout : BROWSER_TIMEOUT . medium } ) . catch ( ( ) => false ) ) ) continue
262- await orgButton . click ( )
263- await browserPage . waitForTimeout ( BROWSER_TIMEOUT . short )
264-
265- const storeLink = browserPage . locator ( 'a, button' ) . filter ( { hasText : storeName } ) . first ( )
266- if ( ! ( await storeLink . isVisible ( { timeout : BROWSER_TIMEOUT . medium } ) . catch ( ( ) => false ) ) ) continue
267- await storeLink . click ( )
268- await browserPage . waitForTimeout ( BROWSER_TIMEOUT . medium )
269- navigated = true
270- break
271- }
272-
273- if ( ! navigated ) {
274- allUninstalled = false
275- continue
276- }
196+ /** Search dev dashboard for an app by name. Returns the app URL or null. */
197+ export async function findAppOnDevDashboard ( page : Page , appName : string , orgId ?: string ) : Promise < string | null > {
198+ const org = orgId ?? ( process . env . E2E_ORG_ID ?? '' ) . trim ( )
199+ const email = process . env . E2E_ACCOUNT_EMAIL
277200
278- // Navigate to store's apps settings page
279- const storeAdminUrl = browserPage . url ( )
280- await browserPage . goto ( `${ storeAdminUrl . replace ( / \/ $ / , '' ) } /settings/apps` , { waitUntil : 'domcontentloaded' } )
281- await browserPage . waitForTimeout ( BROWSER_TIMEOUT . long )
201+ await navigateToDashboard ( { browserPage : page , email, orgId : org } )
282202
283- // Dismiss any Dev Console dialog
284- const cancelButton = browserPage . locator ( 'button:has-text("Cancel")' )
285- if ( await cancelButton . isVisible ( { timeout : BROWSER_TIMEOUT . medium } ) . catch ( ( ) => false ) ) {
286- await cancelButton . click ( )
287- await browserPage . waitForTimeout ( BROWSER_TIMEOUT . short )
288- }
289-
290- // Find the app in the installed list (plain span, not Dev Console's Polaris text)
291- const appSpan = browserPage . locator ( `span:has-text("${ appName } "):not([class*="Polaris"])` ) . first ( )
292- if ( ! ( await appSpan . isVisible ( { timeout : BROWSER_TIMEOUT . medium } ) . catch ( ( ) => false ) ) ) {
293- allUninstalled = false
294- continue
295- }
296-
297- // Click the ⋯ menu button next to the app name
298- const menuButton = appSpan . locator ( 'xpath=./following::button[1]' )
299- await menuButton . click ( )
300- await browserPage . waitForTimeout ( BROWSER_TIMEOUT . short )
203+ // Scan current page + pagination for the app
301204
302- // Click "Uninstall" in the dropdown menu
303- const uninstallOption = browserPage . locator ( 'text=Uninstall' ) . last ( )
304- if ( ! ( await uninstallOption . isVisible ( { timeout : BROWSER_TIMEOUT . medium } ) . catch ( ( ) => false ) ) ) {
305- allUninstalled = false
306- continue
205+ while ( true ) {
206+ const allLinks = await page . locator ( 'a[href*="/apps/"]' ) . all ( )
207+ for ( const link of allLinks ) {
208+ const text = ( await link . textContent ( ) ) ?? ''
209+ if ( text . includes ( appName ) ) {
210+ const href = await link . getAttribute ( 'href' )
211+ if ( href ) return href . startsWith ( 'http' ) ? href : `https://dev.shopify.com${ href } `
307212 }
308- await uninstallOption . click ( )
309- await browserPage . waitForTimeout ( BROWSER_TIMEOUT . medium )
310-
311- // Handle confirmation dialog
312- const confirmButton = browserPage . locator ( 'button:has-text("Uninstall"), button:has-text("Confirm")' ) . last ( )
313- if ( await confirmButton . isVisible ( { timeout : BROWSER_TIMEOUT . medium } ) . catch ( ( ) => false ) ) {
314- await confirmButton . click ( )
315- await browserPage . waitForTimeout ( BROWSER_TIMEOUT . medium )
316- }
317- // eslint-disable-next-line no-catch-all/no-catch-all
318- } catch ( _err ) {
319- allUninstalled = false
320213 }
214+
215+ // Check for next page
216+ const nextLink = page . locator ( 'a[href*="next_cursor"]' ) . first ( )
217+ if ( ! ( await nextLink . isVisible ( { timeout : BROWSER_TIMEOUT . medium } ) . catch ( ( ) => false ) ) ) break
218+ const nextHref = await nextLink . getAttribute ( 'href' )
219+ if ( ! nextHref ) break
220+ const nextUrl = nextHref . startsWith ( 'http' ) ? nextHref : `https://dev.shopify.com${ nextHref } `
221+ await page . goto ( nextUrl , { waitUntil : 'domcontentloaded' } )
222+ await page . waitForTimeout ( BROWSER_TIMEOUT . medium )
321223 }
322224
323- return allUninstalled
225+ return null
324226}
325227
326- /** Delete an app from the partner dashboard. Should be uninstalled first. */
327- export async function deleteApp (
328- ctx : BrowserContext & {
329- appUrl : string
330- } ,
331- ) : Promise < void > {
332- const { browserPage, appUrl} = ctx
333-
334- await browserPage . goto ( `${ appUrl } /settings` , { waitUntil : 'domcontentloaded' } )
335- await browserPage . waitForTimeout ( BROWSER_TIMEOUT . medium )
228+ /** Delete an app from its dev dashboard settings page. Returns true if deleted, false if not. */
229+ 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 )
336233
337- // Retry if delete button is disabled (uninstall propagation delay )
338- const deleteButton = browserPage . locator ( 'button:has-text("Delete app")' ) . first ( )
234+ // Step 2: Wait for "Delete app" button to be enabled, then click (retry step 1+2 on failure )
235+ const deleteAppBtn = page . locator ( 'button:has-text("Delete app")' ) . first ( )
339236 for ( let attempt = 1 ; attempt <= 5 ; attempt ++ ) {
340- await deleteButton . scrollIntoViewIfNeeded ( )
341- const isDisabled = await deleteButton . getAttribute ( 'disabled' )
237+ const isDisabled = await deleteAppBtn . getAttribute ( 'disabled' ) . catch ( ( ) => 'true' )
342238 if ( ! isDisabled ) break
343- await browserPage . waitForTimeout ( BROWSER_TIMEOUT . long )
344- await browserPage . reload ( { waitUntil : 'domcontentloaded' } )
345- await browserPage . waitForTimeout ( BROWSER_TIMEOUT . medium )
239+ await page . waitForTimeout ( BROWSER_TIMEOUT . long )
240+ await page . reload ( { waitUntil : 'domcontentloaded' } )
241+ await page . waitForTimeout ( BROWSER_TIMEOUT . medium )
346242 }
347243
348- await deleteButton . click ( { timeout : BROWSER_TIMEOUT . long } )
349- await browserPage . waitForTimeout ( BROWSER_TIMEOUT . medium )
350-
351- // Handle confirmation dialog — may need to type "DELETE"
352- const confirmInput = browserPage . locator ( 'input[type="text"]' ) . last ( )
353- if ( await confirmInput . isVisible ( { timeout : BROWSER_TIMEOUT . medium } ) . catch ( ( ) => false ) ) {
354- await confirmInput . fill ( 'DELETE' )
355- await browserPage . waitForTimeout ( BROWSER_TIMEOUT . short )
244+ await deleteAppBtn . click ( { timeout : BROWSER_TIMEOUT . long } )
245+ await page . waitForTimeout ( BROWSER_TIMEOUT . medium )
246+
247+ // Step 3: Click confirm "Delete app" in the modal (retry step 2+3 if not visible)
248+ // The dev dashboard modal has a submit button with class "critical" inside a form
249+ const confirmAppBtn = page . locator ( 'button.critical[type="submit"]' )
250+ for ( let attempt = 1 ; attempt <= 3 ; attempt ++ ) {
251+ if ( await confirmAppBtn . isVisible ( { timeout : BROWSER_TIMEOUT . medium } ) . catch ( ( ) => false ) ) break
252+ if ( attempt === 3 ) break
253+ // Retry: re-click the delete button to reopen modal
254+ await page . keyboard . press ( 'Escape' )
255+ await page . waitForTimeout ( BROWSER_TIMEOUT . short )
256+ await deleteAppBtn . click ( { timeout : BROWSER_TIMEOUT . long } )
257+ await page . waitForTimeout ( BROWSER_TIMEOUT . medium )
356258 }
357259
358- const confirmButton = browserPage . locator ( 'button:has-text("Delete app")' ) . last ( )
359- await confirmButton . click ( )
360- await browserPage . waitForTimeout ( BROWSER_TIMEOUT . medium )
361- }
260+ const urlBefore = page . url ( )
261+ await confirmAppBtn . click ( { timeout : BROWSER_TIMEOUT . long } )
362262
363- /** Best-effort teardown: find app on dashboard by name, uninstall from all stores, delete. */
364- export async function teardownApp (
365- ctx : BrowserContext & {
366- appName : string
367- email ?: string
368- orgId ?: string
369- } ,
370- ) : Promise < void > {
263+ // Wait for page to navigate away after deletion
371264 try {
372- await navigateToDashboard ( ctx )
373- const apps = await findAppsOnDashboard ( { browserPage : ctx . browserPage , namePattern : ctx . appName } )
374- for ( const app of apps ) {
375- try {
376- await uninstallApp ( { browserPage : ctx . browserPage , appUrl : app . url , appName : app . name , orgId : ctx . orgId } )
377- await deleteApp ( { browserPage : ctx . browserPage , appUrl : app . url } )
378- // eslint-disable-next-line no-catch-all/no-catch-all
379- } catch ( err ) {
380- // Best-effort per app — continue teardown of remaining apps
381- if ( process . env . DEBUG === '1' ) {
382- const msg = err instanceof Error ? err . message : String ( err )
383- process . stderr . write ( `[e2e] Teardown failed for app ${ app . name } : ${ msg } \n` )
384- }
385- }
386- }
265+ await page . waitForURL ( ( url ) => url . toString ( ) !== urlBefore , { timeout : BROWSER_TIMEOUT . max } )
387266 // eslint-disable-next-line no-catch-all/no-catch-all
388- } catch ( err ) {
389- // Best-effort — don't fail the test if teardown fails
390- if ( process . env . DEBUG === '1' ) {
391- const msg = err instanceof Error ? err . message : String ( err )
392- process . stderr . write ( `[e2e] Teardown failed for ${ ctx . appName } : ${ msg } \n` )
393- }
267+ } catch ( _err ) {
268+ await page . waitForTimeout ( BROWSER_TIMEOUT . medium )
394269 }
270+ return page . url ( ) !== urlBefore
395271}
396272
397273// ---------------------------------------------------------------------------
0 commit comments