Skip to content

Commit d6a4e19

Browse files
committed
E2E: per-test store isolation with robust teardown
1 parent 94f4908 commit d6a4e19

12 files changed

Lines changed: 736 additions & 254 deletions

packages/e2e/setup/app.ts

Lines changed: 57 additions & 181 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ 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,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(/\/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-
}
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
// ---------------------------------------------------------------------------

packages/e2e/setup/cli.ts

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* eslint-disable no-console */
22
import {CLI_TIMEOUT} from './constants.js'
3-
import {envFixture, executables} from './env.js'
3+
import {createLogger, envFixture, executables} from './env.js'
44
import {stripAnsi} from '../helpers/strip-ansi.js'
55
import {execa, type Options as ExecaOptions} from 'execa'
66
import type {E2EEnv} from './env.js'
@@ -45,6 +45,7 @@ export interface CLIProcess {
4545
export const cliFixture = envFixture.extend<{cli: CLIProcess}>({
4646
cli: async ({env}, use) => {
4747
const spawnedProcesses: SpawnedProcess[] = []
48+
const cliLog = createLogger('cli')
4849

4950
const cli: CLIProcess = {
5051
async exec(args, opts = {}) {
@@ -56,9 +57,7 @@ export const cliFixture = envFixture.extend<{cli: CLIProcess}>({
5657
reject: false,
5758
}
5859

59-
if (process.env.DEBUG === '1') {
60-
console.log(`[e2e] exec: node ${executables.cli} ${args.join(' ')}`)
61-
}
60+
cliLog.log(env, `exec: node ${executables.cli} ${args.join(' ')}`)
6261

6362
const result = await execa('node', [executables.cli, ...args], execaOpts)
6463

@@ -78,9 +77,7 @@ export const cliFixture = envFixture.extend<{cli: CLIProcess}>({
7877
reject: false,
7978
}
8079

81-
if (process.env.DEBUG === '1') {
82-
console.log(`[e2e] exec: node ${executables.createApp} ${args.join(' ')}`)
83-
}
80+
cliLog.log(env, `exec: node ${executables.createApp} ${args.join(' ')}`)
8481

8582
const result = await execa('node', [executables.createApp, ...args], execaOpts)
8683

@@ -102,9 +99,7 @@ export const cliFixture = envFixture.extend<{cli: CLIProcess}>({
10299
}
103100
}
104101

105-
if (process.env.DEBUG === '1') {
106-
console.log(`[e2e] spawn: node ${executables.cli} ${args.join(' ')}`)
107-
}
102+
cliLog.log(env, `spawn: node ${executables.cli} ${args.join(' ')}`)
108103

109104
const ptyProcess = nodePty.spawn('node', [executables.cli, ...args], {
110105
name: 'xterm-color',

0 commit comments

Comments
 (0)