Skip to content

Commit dbcae8d

Browse files
committed
E2E: per-test store isolation with robust teardown
1 parent e673884 commit dbcae8d

File tree

13 files changed

+779
-269
lines changed

13 files changed

+779
-269
lines changed

packages/e2e/setup/app.ts

Lines changed: 83 additions & 186 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
/* eslint-disable no-restricted-imports, no-await-in-loop */
22
import {authFixture} from './auth.js'
3-
import {navigateToDashboard} from './browser.js'
3+
import {navigateToDashboard, refreshIfPageError} from './browser.js'
44
import {CLI_TIMEOUT, BROWSER_TIMEOUT} from './constants.js'
55
import {updateTomlValues} from '@shopify/toml-patch'
66
import * as toml from '@iarna/toml'
77
import * as path from 'path'
88
import * as fs from 'fs'
99
import 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,105 @@ 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(/\/apps\/\d+/)) continue
209-
210-
const name = text.split(/\d+\s+install/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-
}
277-
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)
282-
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)
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
200+
201+
await navigateToDashboard({browserPage: page, email, orgId: org})
202+
203+
// Scan current page + pagination for the app
204+
while (true) {
205+
const allLinks = await page.locator('a[href*="/apps/"]').all()
206+
for (const link of allLinks) {
207+
const text = (await link.textContent()) ?? ''
208+
if (text.includes(appName)) {
209+
const href = await link.getAttribute('href')
210+
if (href) return href.startsWith('http') ? href : `https://dev.shopify.com${href}`
288211
}
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)
301-
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
307-
}
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
320212
}
213+
214+
// Check for next page
215+
const nextLink = page.locator('a[href*="next_cursor"]').first()
216+
if (!(await nextLink.isVisible({timeout: BROWSER_TIMEOUT.medium}).catch(() => false))) break
217+
const nextHref = await nextLink.getAttribute('href')
218+
if (!nextHref) break
219+
const nextUrl = nextHref.startsWith('http') ? nextHref : `https://dev.shopify.com${nextHref}`
220+
await page.goto(nextUrl, {waitUntil: 'domcontentloaded'})
221+
await page.waitForTimeout(BROWSER_TIMEOUT.medium)
222+
await refreshIfPageError(page)
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)
336-
337-
// Retry if delete button is disabled (uninstall propagation delay)
338-
const deleteButton = browserPage.locator('button:has-text("Delete app")').first()
339-
for (let attempt = 1; attempt <= 5; attempt++) {
340-
await deleteButton.scrollIntoViewIfNeeded()
341-
const isDisabled = await deleteButton.getAttribute('disabled')
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)
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')
342240
if (!isDisabled) break
343-
await browserPage.waitForTimeout(BROWSER_TIMEOUT.long)
344-
await browserPage.reload({waitUntil: 'domcontentloaded'})
345-
await browserPage.waitForTimeout(BROWSER_TIMEOUT.medium)
241+
await page.reload({waitUntil: 'domcontentloaded'})
242+
await page.waitForTimeout(BROWSER_TIMEOUT.medium)
346243
}
347244

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)
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"]')
256+
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)
356268
}
357269

358-
const confirmButton = browserPage.locator('button:has-text("Delete app")').last()
359-
await confirmButton.click()
360-
await browserPage.waitForTimeout(BROWSER_TIMEOUT.medium)
361-
}
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
362276

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> {
277+
// Wait for page to navigate away after deletion
371278
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-
}
279+
await page.waitForURL((url) => url.toString() !== urlBefore, {timeout: BROWSER_TIMEOUT.max})
387280
// 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`)
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)
285+
const bodyText = (await page.textContent('body')) ?? ''
286+
if (bodyText.includes('404: Not Found')) return true
287+
return false
393288
}
289+
await page.waitForTimeout(BROWSER_TIMEOUT.medium)
394290
}
291+
return page.url() !== urlBefore
395292
}
396293

397294
// ---------------------------------------------------------------------------

packages/e2e/setup/browser.ts

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,22 @@ export const browserFixture = cliFixture.extend<{}, {browserPage: Page}>({
4848
})
4949

5050
// ---------------------------------------------------------------------------
51-
// Browser helpers — generic dashboard navigation
51+
// Browser helpers
5252
// ---------------------------------------------------------------------------
53+
54+
/**
55+
* Check if the current page shows a server error (500, 502). If so, refresh and return true.
56+
* Call this in retry loops when a selector isn't found — the page might be an error page.
57+
*/
58+
export async function refreshIfPageError(page: Page): Promise<boolean> {
59+
const pageText = (await page.textContent('body')) ?? ''
60+
if (!pageText.includes('Internal Server Error') && !pageText.includes('502 Bad Gateway')) return false
61+
// if (process.env.DEBUG === '1') process.stdout.write(' page refreshing...\n')
62+
await page.reload({waitUntil: 'domcontentloaded'})
63+
await page.waitForTimeout(BROWSER_TIMEOUT.medium)
64+
return true
65+
}
66+
5367
/** Navigate to the dev dashboard for the configured org. */
5468
export async function navigateToDashboard(
5569
ctx: BrowserContext & {
@@ -63,6 +77,9 @@ export async function navigateToDashboard(
6377
await browserPage.goto(dashboardUrl, {waitUntil: 'domcontentloaded'})
6478
await browserPage.waitForTimeout(BROWSER_TIMEOUT.medium)
6579

80+
// Retry on server errors
81+
await refreshIfPageError(browserPage)
82+
6683
// Handle account picker (skip if email not provided)
6784
if (ctx.email) {
6885
const accountButton = browserPage.locator(`text=${ctx.email}`).first()
@@ -71,12 +88,4 @@ export async function navigateToDashboard(
7188
await browserPage.waitForTimeout(BROWSER_TIMEOUT.medium)
7289
}
7390
}
74-
75-
// Retry on 500 errors
76-
for (let attempt = 1; attempt <= 3; attempt++) {
77-
const pageText = (await browserPage.textContent('body')) ?? '' // eslint-disable-line no-await-in-loop
78-
if (!pageText.includes('500: Internal Server Error') && !pageText.includes('Internal Server Error')) break
79-
await browserPage.waitForTimeout(BROWSER_TIMEOUT.medium) // eslint-disable-line no-await-in-loop
80-
await browserPage.reload({waitUntil: 'domcontentloaded'}) // eslint-disable-line no-await-in-loop
81-
}
8291
}

0 commit comments

Comments
 (0)