diff --git a/package.json b/package.json index 1257493edd8..317b9a7a293 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "shopify:run": "node packages/cli/bin/dev.js", "shopify": "nx build cli && node packages/cli/bin/dev.js", "test:e2e": "nx run-many --target=build --projects=cli,create-app --skip-nx-cache && pnpm --filter e2e exec playwright test", + "test:e2e-cleanup": "npx tsx packages/e2e/scripts/cleanup.ts", "test:regenerate-snapshots": "packages/e2e/scripts/regenerate-snapshots.sh", "test": "pnpm vitest run", "type-check:affected": "nx affected --target=type-check", @@ -143,9 +144,13 @@ "unresolved": "error" }, "ignoreBinaries": [ - "playwright" + "playwright", + "tsx" + ], + "ignoreDependencies": [ + "dotenv", + "@playwright/test" ], - "ignoreDependencies": [], "ignoreWorkspaces": [ "packages/eslint-plugin-cli", "packages/e2e" diff --git a/packages/e2e/scripts/cleanup.ts b/packages/e2e/scripts/cleanup.ts new file mode 100644 index 00000000000..f6b1fa72d7c --- /dev/null +++ b/packages/e2e/scripts/cleanup.ts @@ -0,0 +1,408 @@ +/* eslint-disable no-console, no-restricted-imports, no-await-in-loop */ + +/** + * E2E Cleanup Utility + * + * Finds and deletes leftover E2E test apps from the Dev Dashboard. + * Apps are matched by the "E2E-" prefix in their name. + * + * Usage: + * npx tsx packages/e2e/scripts/cleanup.ts # Full cleanup: uninstall + delete + * npx tsx packages/e2e/scripts/cleanup.ts --list # List matching apps without action + * npx tsx packages/e2e/scripts/cleanup.ts --uninstall # Uninstall from all stores only (no delete) + * npx tsx packages/e2e/scripts/cleanup.ts --delete # Delete only (skip uninstall — delete only apps with 0 installs) + * npx tsx packages/e2e/scripts/cleanup.ts --headed # Show browser window + * npx tsx packages/e2e/scripts/cleanup.ts --pattern X # Match apps containing "X" (default: "E2E-") + * + * Environment variables (loaded from packages/e2e/.env): + * E2E_ACCOUNT_EMAIL — Shopify account email for login + * E2E_ACCOUNT_PASSWORD — Shopify account password + * E2E_ORG_ID — Organization ID to scan for apps + * + * This module also exports `cleanupAllApps()` for use as a Playwright globalTeardown + * or from other scripts/tests. + */ + +import {config} from 'dotenv' +import * as path from 'path' +import {fileURLToPath} from 'url' +import {chromium} from '@playwright/test' +import {navigateToDashboard} from '../setup/browser.js' +import {completeLogin} from '../helpers/browser-login.js' +import type {Page} from '@playwright/test' + +// Load .env from packages/e2e/ (not cwd) only if not already configured +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +if (!process.env.E2E_ACCOUNT_EMAIL) { + config({path: path.resolve(__dirname, '../.env')}) +} + +// --------------------------------------------------------------------------- +// Core cleanup logic — reusable from tests, teardown, or CLI +// --------------------------------------------------------------------------- + +export type CleanupMode = 'full' | 'list' | 'uninstall' | 'delete' + +const MODE_LABELS: Record = { + full: 'Uninstall + Delete', + list: 'List only', + uninstall: 'Uninstall only', + delete: 'Delete only', +} + +export interface CleanupOptions { + /** Cleanup mode (default: "full" — uninstall + delete) */ + mode?: CleanupMode + /** App name pattern to match (default: "E2E-") */ + pattern?: string + /** Show browser window */ + headed?: boolean + /** Organization ID (default: from E2E_ORG_ID env) */ + orgId?: string + /** Max retries per app on failure (default: 2) */ + retries?: number +} + +/** + * Find and delete all E2E test apps matching a pattern. + * Handles browser login, dashboard navigation, uninstall, and deletion. + */ +export async function cleanupAllApps(opts: CleanupOptions = {}): Promise { + const mode = opts.mode ?? 'full' + const pattern = opts.pattern ?? 'E2E-' + const orgId = opts.orgId ?? (process.env.E2E_ORG_ID ?? '').trim() + const maxRetries = opts.retries ?? 2 + const email = process.env.E2E_ACCOUNT_EMAIL + const password = process.env.E2E_ACCOUNT_PASSWORD + + // Banner + console.log('') + console.log(`[cleanup] Mode: ${MODE_LABELS[mode]}`) + console.log(`[cleanup] Org: ${orgId || '(not set)'}`) + console.log(`[cleanup] Pattern: "${pattern}"`) + console.log('') + + if (!email || !password) { + throw new Error('E2E_ACCOUNT_EMAIL and E2E_ACCOUNT_PASSWORD are required') + } + + if (!orgId) { + throw new Error('E2E_ORG_ID is required') + } + + const browser = await chromium.launch({headless: !opts.headed}) + const context = await browser.newContext({ + extraHTTPHeaders: { + 'X-Shopify-Loadtest-Bf8d22e7-120e-4b5b-906c-39ca9d5499a9': 'true', + }, + }) + context.setDefaultTimeout(30_000) + context.setDefaultNavigationTimeout(30_000) + const page = await context.newPage() + + try { + // Step 1: Log into Shopify directly in the browser + console.log('[cleanup] Logging in...') + await completeLogin(page, 'https://accounts.shopify.com/lookup', email, password) + console.log('[cleanup] Logged in successfully.') + + // Step 2: Navigate to dashboard + console.log('[cleanup] Navigating to dashboard...') + await navigateToDashboard({browserPage: page, email, orgId}) + + // Step 3: Find matching apps + const apps = await findAppsOnDashboard(page, pattern) + console.log(`[cleanup] Found ${apps.length} app(s)`) + console.log('') + + if (apps.length === 0) return + + for (let i = 0; i < apps.length; i++) { + const app = apps[i]! + console.log(` ${i + 1}. ${app.name} (${app.installs} install${app.installs !== 1 ? 's' : ''})`) + } + console.log('') + + if (mode === 'list') return + + // Step 4: Process each app with retries + let succeeded = 0 + let skipped = 0 + let failed = 0 + + for (let i = 0; i < apps.length; i++) { + const app = apps[i]! + const tag = `[cleanup] [${i + 1}/${apps.length}]` + let ok = false + let wasSkipped = false + + console.log(`${tag} ${app.name}`) + + for (let attempt = 1; attempt <= maxRetries + 1; attempt++) { + try { + if (attempt > 1) { + console.log(` Retry ${attempt - 1}/${maxRetries}...`) + await navigateToDashboard({browserPage: page, email, orgId}) + } + + if (mode === 'full' || mode === 'uninstall') { + if (app.installs === 0) { + if (mode === 'uninstall') { + console.log(' Not installed (skipped)') + wasSkipped = true + skipped++ + break + } + console.log(' Not installed') + } else { + console.log(' Uninstalling...') + const allUninstalled = await uninstallApp(page, app.url, app.name, orgId) + if (!allUninstalled) { + throw new Error('Uninstall incomplete — some stores may remain') + } + console.log(' Uninstalled') + } + } + + if (mode === 'full' || mode === 'delete') { + if (mode === 'delete' && app.installs > 0) { + console.log(' Delete skipped (still installed)') + wasSkipped = true + skipped++ + break + } + console.log(' Deleting...') + await deleteApp(page, app.url) + console.log(' Deleted') + } + + ok = true + break + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + if (attempt <= maxRetries) { + console.warn(` Attempt ${attempt} failed: ${msg}`) + await page.waitForTimeout(3000) + } else { + console.warn(` Failed: ${msg}`) + } + } + } + + if (ok) succeeded++ + else if (!wasSkipped) failed++ + console.log('') + } + + // Summary + const parts = [`${succeeded} succeeded`] + if (skipped > 0) parts.push(`${skipped} skipped`) + if (failed > 0) parts.push(`${failed} failed`) + console.log('') + console.log(`[cleanup] Complete: ${parts.join(', ')}`) + } finally { + await browser.close() + } +} + +// --------------------------------------------------------------------------- +// Dashboard browser helpers — bulk discovery and cleanup +// --------------------------------------------------------------------------- + +/** Find apps matching a name pattern on the dashboard. Handles pagination. */ +async function findAppsOnDashboard( + page: Page, + namePattern: string, +): Promise<{name: string; url: string; installs: number}[]> { + const apps: {name: string; url: string; installs: number}[] = [] + + // eslint-disable-next-line no-constant-condition + while (true) { + const appCards = await page.locator('a[href*="/apps/"]').all() + + for (const card of appCards) { + const href = await card.getAttribute('href') + const text = await card.textContent() + if (!href || !text || !href.match(/\/apps\/\d+/)) continue + + const name = text.split(/\d+\s+install/i)[0]?.trim() ?? text.split('\n')[0]?.trim() ?? text.trim() + if (!name || name.length > 200) continue + if (!name.includes(namePattern)) continue + + const installMatch = text.match(/(\d+)\s+install/i) + const installs = installMatch ? parseInt(installMatch[1]!, 10) : 0 + + const url = href.startsWith('http') ? href : `https://dev.shopify.com${href}` + apps.push({name, url, installs}) + } + + // Check for next page — navigate via href since the button click may not work + const nextLink = page.locator('a[href*="next_cursor"]').first() + if (!(await nextLink.isVisible({timeout: 2000}).catch(() => false))) break + const nextHref = await nextLink.getAttribute('href') + if (!nextHref) break + const nextUrl = nextHref.startsWith('http') ? nextHref : `https://dev.shopify.com${nextHref}` + await page.goto(nextUrl, {waitUntil: 'domcontentloaded'}) + await page.waitForTimeout(3000) + } + + return apps +} + +/** Uninstall an app from all stores via the store switcher dropdown. Returns true if fully uninstalled. */ +async function uninstallApp( + page: Page, + appUrl: string, + appName: string, + orgId: string, +): Promise { + await page.goto(`${appUrl}/installs`, {waitUntil: 'domcontentloaded'}) + await page.waitForTimeout(3000) + + const rows = await page.locator('table tbody tr').all() + const storeNames: string[] = [] + for (const row of rows) { + const firstCell = row.locator('td').first() + const text = (await firstCell.textContent())?.trim() + if (text && !text.toLowerCase().includes('no installed')) storeNames.push(text) + } + + if (storeNames.length === 0) return true + + let allUninstalled = true + for (const storeName of storeNames) { + try { + // Click the store switcher dropdown to navigate to the store admin + let navigated = false + for (let attempt = 1; attempt <= 3; attempt++) { + const orgButton = page.locator('button[popovertarget="store-switcher-popover"]').first() + if (!(await orgButton.isVisible({timeout: 5000}).catch(() => false))) continue + await orgButton.click() + await page.waitForTimeout(1000) + + const storeLink = page.locator('a, button').filter({hasText: storeName}).first() + if (!(await storeLink.isVisible({timeout: 5000}).catch(() => false))) continue + await storeLink.click() + await page.waitForTimeout(3000) + navigated = true + break + } + + if (!navigated) { + allUninstalled = false + continue + } + + // Navigate to store's apps settings page + const storeAdminUrl = page.url() + await page.goto(`${storeAdminUrl.replace(/\/$/, '')}/settings/apps`, {waitUntil: 'domcontentloaded'}) + await page.waitForTimeout(5000) + + // Dismiss any Dev Console dialog + const cancelButton = page.locator('button:has-text("Cancel")') + if (await cancelButton.isVisible({timeout: 2000}).catch(() => false)) { + await cancelButton.click() + await page.waitForTimeout(1000) + } + + // Find the app in the installed list + const appSpan = page.locator(`span:has-text("${appName}"):not([class*="Polaris"])`).first() + if (!(await appSpan.isVisible({timeout: 5000}).catch(() => false))) { + allUninstalled = false + continue + } + + // Click the ⋯ menu button next to the app name + const menuButton = appSpan.locator('xpath=./following::button[1]') + await menuButton.click() + await page.waitForTimeout(1000) + + // Click "Uninstall" in the dropdown menu + const uninstallOption = page.locator('text=Uninstall').last() + if (!(await uninstallOption.isVisible({timeout: 3000}).catch(() => false))) { + allUninstalled = false + continue + } + await uninstallOption.click() + await page.waitForTimeout(2000) + + // Handle confirmation dialog + const confirmButton = page.locator('button:has-text("Uninstall"), button:has-text("Confirm")').last() + if (await confirmButton.isVisible({timeout: 3000}).catch(() => false)) { + await confirmButton.click() + await page.waitForTimeout(3000) + } + } catch (_err) { + allUninstalled = false + } + } + + return allUninstalled +} + +/** Delete an app from the dev dashboard settings page. */ +async function deleteApp(page: Page, appUrl: string): Promise { + await page.goto(`${appUrl}/settings`, {waitUntil: 'domcontentloaded'}) + await page.waitForTimeout(3000) + + // Retry if delete button is disabled (uninstall propagation delay) + const deleteButton = page.locator('button:has-text("Delete app")').first() + for (let attempt = 1; attempt <= 5; attempt++) { + await deleteButton.scrollIntoViewIfNeeded() + const isDisabled = await deleteButton.getAttribute('disabled') + if (!isDisabled) break + await page.waitForTimeout(5000) + await page.reload({waitUntil: 'domcontentloaded'}) + await page.waitForTimeout(3000) + } + + await deleteButton.click({timeout: 10_000}) + await page.waitForTimeout(2000) + + // Handle confirmation dialog — may need to type "DELETE" + const confirmInput = page.locator('input[type="text"]').last() + if (await confirmInput.isVisible({timeout: 3000}).catch(() => false)) { + await confirmInput.fill('DELETE') + await page.waitForTimeout(500) + } + + const confirmButton = page.locator('button:has-text("Delete app")').last() + await confirmButton.click() + await page.waitForTimeout(3000) +} + +// --------------------------------------------------------------------------- +// CLI entry point +// --------------------------------------------------------------------------- + +async function main() { + const args = process.argv.slice(2) + const headed = args.includes('--headed') + const patternIdx = args.indexOf('--pattern') + let pattern: string | undefined + if (patternIdx !== -1) { + const nextArg = args[patternIdx + 1] + if (!nextArg || nextArg.startsWith('--')) { + console.error('[cleanup] --pattern requires a value') + process.exitCode = 1 + return + } + pattern = nextArg + } + + let mode: CleanupMode = 'full' + if (args.includes('--list')) mode = 'list' + else if (args.includes('--uninstall')) mode = 'uninstall' + else if (args.includes('--delete')) mode = 'delete' + + await cleanupAllApps({mode, pattern, headed}) +} + +// Run if executed directly (not imported) +const isDirectRun = process.argv[1] === fileURLToPath(import.meta.url) +if (isDirectRun) { + main().catch((err) => { + console.error('[cleanup] Fatal error:', err) + process.exitCode = 1 + }) +} diff --git a/packages/e2e/setup/app.ts b/packages/e2e/setup/app.ts index 6d7cf4d0931..18534ffaf5c 100644 --- a/packages/e2e/setup/app.ts +++ b/packages/e2e/setup/app.ts @@ -1,6 +1,7 @@ /* eslint-disable no-restricted-imports, no-await-in-loop */ import {authFixture} from './auth.js' import {navigateToDashboard} from './browser.js' +import {completeLogin} from '../helpers/browser-login.js' import * as path from 'path' import * as fs from 'fs' import type {CLIContext, CLIProcess, ExecResult} from './cli.js' @@ -184,203 +185,150 @@ export async function configLink( } // --------------------------------------------------------------------------- -// Browser helpers — app-specific dashboard automation +// Per-test teardown — find app on dashboard, uninstall if needed, delete // --------------------------------------------------------------------------- -/** Find apps matching a name pattern on the dashboard. Call navigateToDashboard first. */ -export async function findAppsOnDashboard( - ctx: BrowserContext & { - namePattern: string - }, -): Promise<{name: string; url: string}[]> { - const appCards = await ctx.browserPage.locator('a[href*="/apps/"]').all() - const apps: {name: string; url: string}[] = [] - - for (const card of appCards) { - const href = await card.getAttribute('href') - const text = await card.textContent() - if (!href || !text || !href.match(/\/apps\/\d+/)) continue - - const name = text.split(/\d+\s+install/i)[0]?.trim() ?? text.split('\n')[0]?.trim() ?? text.trim() - if (!name || name.length > 200) continue - if (!name.includes(ctx.namePattern)) continue - - const url = href.startsWith('http') ? href : `https://dev.shopify.com${href}` - apps.push({name, url}) - } - - return apps -} - -/** Uninstall an app from all stores it's installed on. Returns true if fully uninstalled. */ -export async function uninstallApp( +/** + * Best-effort per-test teardown. + * + * Flow: + * 0. Log browser into Shopify (browser page isn't authenticated on dev.shopify.com) + * 1. Navigate to dashboard → search for app by name → get app URL + * 2. If installed → go to store admin via FQDN → uninstall + * 3. Go to app settings → delete + */ +export async function teardownApp( ctx: BrowserContext & { - appUrl: string appName: string + email?: string orgId?: string + storeFqdn?: string }, -): Promise { - const {browserPage, appUrl, appName} = ctx - const orgId = ctx.orgId ?? (process.env.E2E_ORG_ID ?? '').trim() - - await browserPage.goto(`${appUrl}/installs`, {waitUntil: 'domcontentloaded'}) - await browserPage.waitForTimeout(3000) - - const rows = await browserPage.locator('table tbody tr').all() - const storeNames: string[] = [] - for (const row of rows) { - const firstCell = row.locator('td').first() - const text = (await firstCell.textContent())?.trim() - if (text && !text.toLowerCase().includes('no installed')) storeNames.push(text) - } - - if (storeNames.length === 0) return true +): Promise { + try { + const {browserPage} = ctx + const storeFqdn = ctx.storeFqdn ?? (process.env.E2E_STORE_FQDN ?? '').trim() + const orgId = ctx.orgId ?? (process.env.E2E_ORG_ID ?? '').trim() + const email = ctx.email ?? process.env.E2E_ACCOUNT_EMAIL + const password = process.env.E2E_ACCOUNT_PASSWORD + const debug = process.env.DEBUG === '1' - let allUninstalled = true - for (const storeName of storeNames) { - try { - // Navigate to store admin via the dev dashboard dropdown - const dashboardUrl = orgId - ? `https://dev.shopify.com/dashboard/${orgId}/apps` - : 'https://dev.shopify.com/dashboard' - let navigated = false - for (let attempt = 1; attempt <= 3; attempt++) { - await browserPage.goto(dashboardUrl, {waitUntil: 'domcontentloaded'}) - await browserPage.waitForTimeout(3000) + if (debug) process.stdout.write(`[e2e] teardown: ${ctx.appName}\n`) - const pageText = (await browserPage.textContent('body')) ?? '' - if (pageText.includes('500') || pageText.includes('Internal Server Error')) continue + // Step 0: Log the browser into Shopify + if (email && password) { + try { + await completeLogin(browserPage, 'https://accounts.shopify.com/lookup', email, password) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (_err) { + // Login may fail if already logged in — continue anyway + } + } - const orgButton = browserPage.locator('header button').last() - if (!(await orgButton.isVisible({timeout: 5000}).catch(() => false))) continue - await orgButton.click() - await browserPage.waitForTimeout(1000) + // Step 1: Navigate to dashboard and search for the app + await navigateToDashboard({browserPage, email, orgId}) + const searchUrl = `${browserPage.url()}?search=${encodeURIComponent(ctx.appName)}` + await browserPage.goto(searchUrl, {waitUntil: 'domcontentloaded'}) + await browserPage.waitForTimeout(3000) - const storeLink = browserPage.locator('a, button').filter({hasText: storeName}).first() - if (!(await storeLink.isVisible({timeout: 5000}).catch(() => false))) continue - await storeLink.click() - await browserPage.waitForTimeout(3000) - navigated = true + let appHref: string | null = null + let hasInstalls = false + const allLinks = await browserPage.locator('a[href*="/apps/"]').all() + for (const link of allLinks) { + const text = (await link.textContent()) ?? '' + if (text.includes(ctx.appName)) { + appHref = await link.getAttribute('href') + // Check install count from the card text (e.g., "E2E-deploy-123 1 install • ...") + const installMatch = text.match(/(\d+)\s+install/i) + hasInstalls = installMatch ? parseInt(installMatch[1]!, 10) > 0 : false break } + } - if (!navigated) { - allUninstalled = false - continue - } - - // Navigate to store's apps settings page - const storeAdminUrl = browserPage.url() - await browserPage.goto(`${storeAdminUrl.replace(/\/$/, '')}/settings/apps`, {waitUntil: 'domcontentloaded'}) - await browserPage.waitForTimeout(5000) - - // Dismiss any Dev Console dialog - const cancelButton = browserPage.locator('button:has-text("Cancel")') - if (await cancelButton.isVisible({timeout: 2000}).catch(() => false)) { - await cancelButton.click() - await browserPage.waitForTimeout(1000) - } + if (!appHref) { + if (debug) process.stdout.write(`[e2e] teardown: "${ctx.appName}" not found on dashboard\n`) + return + } - // Find the app in the installed list (plain span, not Dev Console's Polaris text) - const appSpan = browserPage.locator(`span:has-text("${appName}"):not([class*="Polaris"])`).first() - if (!(await appSpan.isVisible({timeout: 5000}).catch(() => false))) { - allUninstalled = false - continue - } + const appUrl = appHref.startsWith('http') ? appHref : `https://dev.shopify.com${appHref}` - // Click the ⋯ menu button next to the app name - const menuButton = appSpan.locator('xpath=./following::button[1]') - await menuButton.click() - await browserPage.waitForTimeout(1000) + // Step 2: If the app is installed, uninstall via direct store admin URL + if (hasInstalls && storeFqdn) { + try { + if (debug) process.stdout.write(`[e2e] teardown: uninstalling from ${storeFqdn}\n`) + const storeSlug = storeFqdn.replace('.myshopify.com', '') + await browserPage.goto(`https://admin.shopify.com/store/${storeSlug}/settings/apps`, { + waitUntil: 'domcontentloaded', + }) + await browserPage.waitForTimeout(5000) + + // Dismiss any Dev Console dialog + const cancelBtn = browserPage.locator('button:has-text("Cancel")') + if (await cancelBtn.isVisible({timeout: 2000}).catch(() => false)) { + await cancelBtn.click() + await browserPage.waitForTimeout(1000) + } - // Click "Uninstall" in the dropdown menu - const uninstallOption = browserPage.locator('text=Uninstall').last() - if (!(await uninstallOption.isVisible({timeout: 3000}).catch(() => false))) { - allUninstalled = false - continue + const appSpan = browserPage.locator(`span:has-text("${ctx.appName}"):not([class*="Polaris"])`).first() + if (await appSpan.isVisible({timeout: 5000}).catch(() => false)) { + await appSpan.locator('xpath=./following::button[1]').click() + await browserPage.waitForTimeout(1000) + + const uninstallOpt = browserPage.locator('text=Uninstall').last() + if (await uninstallOpt.isVisible({timeout: 3000}).catch(() => false)) { + await uninstallOpt.click() + await browserPage.waitForTimeout(2000) + + const confirmBtn = browserPage.locator('button:has-text("Uninstall"), button:has-text("Confirm")').last() + if (await confirmBtn.isVisible({timeout: 3000}).catch(() => false)) { + await confirmBtn.click() + await browserPage.waitForTimeout(3000) + } + } + if (debug) process.stdout.write('[e2e] teardown: uninstalled\n') + } + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (_err) { + // Best-effort — continue to delete } - await uninstallOption.click() - await browserPage.waitForTimeout(2000) + } - // Handle confirmation dialog - const confirmButton = browserPage.locator('button:has-text("Uninstall"), button:has-text("Confirm")').last() - if (await confirmButton.isVisible({timeout: 3000}).catch(() => false)) { - await confirmButton.click() + // Step 3: Delete from app settings page + try { + await browserPage.goto(`${appUrl}/settings`, {waitUntil: 'domcontentloaded'}) + await browserPage.waitForTimeout(3000) + + const deleteButton = browserPage.locator('button:has-text("Delete app")').first() + for (let attempt = 1; attempt <= 5; attempt++) { + const isDisabled = await deleteButton.getAttribute('disabled').catch(() => 'true') + if (!isDisabled) break + await browserPage.waitForTimeout(5000) + await browserPage.reload({waitUntil: 'domcontentloaded'}) await browserPage.waitForTimeout(3000) } - // eslint-disable-next-line no-catch-all/no-catch-all - } catch (_err) { - allUninstalled = false - } - } - - return allUninstalled -} - -/** Delete an app from the partner dashboard. Should be uninstalled first. */ -export async function deleteApp( - ctx: BrowserContext & { - appUrl: string - }, -): Promise { - const {browserPage, appUrl} = ctx - - await browserPage.goto(`${appUrl}/settings`, {waitUntil: 'domcontentloaded'}) - await browserPage.waitForTimeout(3000) - - // Retry if delete button is disabled (uninstall propagation delay) - const deleteButton = browserPage.locator('button:has-text("Delete app")').first() - for (let attempt = 1; attempt <= 5; attempt++) { - await deleteButton.scrollIntoViewIfNeeded() - const isDisabled = await deleteButton.getAttribute('disabled') - if (!isDisabled) break - await browserPage.waitForTimeout(5000) - await browserPage.reload({waitUntil: 'domcontentloaded'}) - await browserPage.waitForTimeout(3000) - } - - await deleteButton.click({timeout: 10_000}) - await browserPage.waitForTimeout(2000) - // Handle confirmation dialog — may need to type "DELETE" - const confirmInput = browserPage.locator('input[type="text"]').last() - if (await confirmInput.isVisible({timeout: 3000}).catch(() => false)) { - await confirmInput.fill('DELETE') - await browserPage.waitForTimeout(500) - } + await deleteButton.click({timeout: 10_000}) + await browserPage.waitForTimeout(2000) - const confirmButton = browserPage.locator('button:has-text("Delete app")').last() - await confirmButton.click() - await browserPage.waitForTimeout(3000) -} + const confirmInput = browserPage.locator('input[type="text"]').last() + if (await confirmInput.isVisible({timeout: 3000}).catch(() => false)) { + await confirmInput.fill('DELETE') + await browserPage.waitForTimeout(500) + } -/** Best-effort teardown: find app on dashboard by name, uninstall from all stores, delete. */ -export async function teardownApp( - ctx: BrowserContext & { - appName: string - email?: string - orgId?: string - }, -): Promise { - try { - await navigateToDashboard(ctx) - const apps = await findAppsOnDashboard({browserPage: ctx.browserPage, namePattern: ctx.appName}) - for (const app of apps) { - try { - await uninstallApp({browserPage: ctx.browserPage, appUrl: app.url, appName: app.name, orgId: ctx.orgId}) - await deleteApp({browserPage: ctx.browserPage, appUrl: app.url}) - // eslint-disable-next-line no-catch-all/no-catch-all - } catch (err) { - // Best-effort per app — continue teardown of remaining apps - if (process.env.DEBUG === '1') { - const msg = err instanceof Error ? err.message : String(err) - process.stderr.write(`[e2e] Teardown failed for app ${app.name}: ${msg}\n`) - } + const confirmButton = browserPage.locator('button:has-text("Delete app")').last() + await confirmButton.click() + await browserPage.waitForTimeout(3000) + if (debug) process.stdout.write(`[e2e] teardown: deleted`) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (err) { + if (debug) { + const msg = err instanceof Error ? err.message : String(err) + process.stderr.write(`[e2e] Teardown delete failed for ${ctx.appName}: ${msg}\n`) } } // eslint-disable-next-line no-catch-all/no-catch-all } catch (err) { - // Best-effort — don't fail the test if teardown fails if (process.env.DEBUG === '1') { const msg = err instanceof Error ? err.message : String(err) process.stderr.write(`[e2e] Teardown failed for ${ctx.appName}: ${msg}\n`) diff --git a/packages/e2e/tests/app-deploy.spec.ts b/packages/e2e/tests/app-deploy.spec.ts index 4e85bd524be..68d74a9e0e5 100644 --- a/packages/e2e/tests/app-deploy.spec.ts +++ b/packages/e2e/tests/app-deploy.spec.ts @@ -39,8 +39,11 @@ test.describe('App deploy', () => { expect(listResult.exitCode, `versions list failed:\n${listOutput}`).toBe(0) expect(listOutput).toContain(versionTag) } finally { - fs.rmSync(parentDir, {recursive: true, force: true}) - await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + // E2E_SKIP_CLEANUP=1 skips cleanup for debugging. Run `pnpm test:e2e-cleanup` afterward. + if (!process.env.E2E_SKIP_CLEANUP) { + fs.rmSync(parentDir, {recursive: true, force: true}) + await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + } } }) }) diff --git a/packages/e2e/tests/app-dev-server.spec.ts b/packages/e2e/tests/app-dev-server.spec.ts index 358553b458d..a9dba4feeee 100644 --- a/packages/e2e/tests/app-dev-server.spec.ts +++ b/packages/e2e/tests/app-dev-server.spec.ts @@ -43,8 +43,11 @@ test.describe('App dev server', () => { const exitCode = await dev.waitForExit(30_000) expect(exitCode).toBe(0) } finally { - fs.rmSync(parentDir, {recursive: true, force: true}) - await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + // E2E_SKIP_CLEANUP=1 skips cleanup for debugging. Run `pnpm test:e2e-cleanup` afterward. + if (!process.env.E2E_SKIP_CLEANUP) { + fs.rmSync(parentDir, {recursive: true, force: true}) + await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + } } }) }) diff --git a/packages/e2e/tests/app-scaffold.spec.ts b/packages/e2e/tests/app-scaffold.spec.ts index 5155fd55510..411fe2c5e04 100644 --- a/packages/e2e/tests/app-scaffold.spec.ts +++ b/packages/e2e/tests/app-scaffold.spec.ts @@ -38,8 +38,11 @@ test.describe('App scaffold', () => { const buildResult = await buildApp({cli, appDir}) expect(buildResult.exitCode, `build failed:\nstderr: ${buildResult.stderr}`).toBe(0) } finally { - fs.rmSync(parentDir, {recursive: true, force: true}) - await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + // E2E_SKIP_CLEANUP=1 skips cleanup for debugging. Run `pnpm test:e2e-cleanup` afterward. + if (!process.env.E2E_SKIP_CLEANUP) { + fs.rmSync(parentDir, {recursive: true, force: true}) + await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + } } }) @@ -63,8 +66,11 @@ test.describe('App scaffold', () => { expect(fs.existsSync(initResult.appDir)).toBe(true) expect(fs.existsSync(path.join(initResult.appDir, 'shopify.app.toml'))).toBe(true) } finally { - fs.rmSync(parentDir, {recursive: true, force: true}) - await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + // E2E_SKIP_CLEANUP=1 skips cleanup for debugging. Run `pnpm test:e2e-cleanup` afterward. + if (!process.env.E2E_SKIP_CLEANUP) { + fs.rmSync(parentDir, {recursive: true, force: true}) + await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + } } }) @@ -105,8 +111,11 @@ test.describe('App scaffold', () => { const buildResult = await buildApp({cli, appDir}) expect(buildResult.exitCode, `build failed:\nstderr: ${buildResult.stderr}`).toBe(0) } finally { - fs.rmSync(parentDir, {recursive: true, force: true}) - await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + // E2E_SKIP_CLEANUP=1 skips cleanup for debugging. Run `pnpm test:e2e-cleanup` afterward. + if (!process.env.E2E_SKIP_CLEANUP) { + fs.rmSync(parentDir, {recursive: true, force: true}) + await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + } } }) }) diff --git a/packages/e2e/tests/dev-hot-reload.spec.ts b/packages/e2e/tests/dev-hot-reload.spec.ts index 33c7b6e4118..f3e04ef9534 100644 --- a/packages/e2e/tests/dev-hot-reload.spec.ts +++ b/packages/e2e/tests/dev-hot-reload.spec.ts @@ -84,8 +84,11 @@ test.describe('Dev hot reload', () => { proc.kill() } } finally { - fs.rmSync(parentDir, {recursive: true, force: true}) - await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + // E2E_SKIP_CLEANUP=1 skips cleanup for debugging. Run `pnpm test:e2e-cleanup` afterward. + if (!process.env.E2E_SKIP_CLEANUP) { + fs.rmSync(parentDir, {recursive: true, force: true}) + await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + } } }) @@ -127,8 +130,11 @@ test.describe('Dev hot reload', () => { proc.kill() } } finally { - fs.rmSync(parentDir, {recursive: true, force: true}) - await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + // E2E_SKIP_CLEANUP=1 skips cleanup for debugging. Run `pnpm test:e2e-cleanup` afterward. + if (!process.env.E2E_SKIP_CLEANUP) { + fs.rmSync(parentDir, {recursive: true, force: true}) + await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + } } }) @@ -176,8 +182,11 @@ test.describe('Dev hot reload', () => { proc.kill() } } finally { - fs.rmSync(parentDir, {recursive: true, force: true}) - await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + // E2E_SKIP_CLEANUP=1 skips cleanup for debugging. Run `pnpm test:e2e-cleanup` afterward. + if (!process.env.E2E_SKIP_CLEANUP) { + fs.rmSync(parentDir, {recursive: true, force: true}) + await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + } } }) }) diff --git a/packages/e2e/tests/multi-config-dev.spec.ts b/packages/e2e/tests/multi-config-dev.spec.ts index 4bb00985f12..5b4a68c990c 100644 --- a/packages/e2e/tests/multi-config-dev.spec.ts +++ b/packages/e2e/tests/multi-config-dev.spec.ts @@ -80,8 +80,11 @@ include_config_on_deploy = true proc.kill() } } finally { - fs.rmSync(parentDir, {recursive: true, force: true}) - await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + // E2E_SKIP_CLEANUP=1 skips cleanup for debugging. Run `pnpm test:e2e-cleanup` afterward. + if (!process.env.E2E_SKIP_CLEANUP) { + fs.rmSync(parentDir, {recursive: true, force: true}) + await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + } } }) @@ -142,8 +145,11 @@ api_version = "2025-01" proc.kill() } } finally { - fs.rmSync(parentDir, {recursive: true, force: true}) - await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + // E2E_SKIP_CLEANUP=1 skips cleanup for debugging. Run `pnpm test:e2e-cleanup` afterward. + if (!process.env.E2E_SKIP_CLEANUP) { + fs.rmSync(parentDir, {recursive: true, force: true}) + await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + } } }) }) diff --git a/packages/e2e/tests/toml-config-invalid.spec.ts b/packages/e2e/tests/toml-config-invalid.spec.ts index df455be7cc2..5f930d08db5 100644 --- a/packages/e2e/tests/toml-config-invalid.spec.ts +++ b/packages/e2e/tests/toml-config-invalid.spec.ts @@ -35,7 +35,10 @@ test.describe('TOML config invalid', () => { expect(result.exitCode, `expected deploy to fail for ${label}, but it succeeded:\n${output}`).not.toBe(0) expect(output.toLowerCase(), `expected error output for ${label}:\n${output}`).toMatch(/error|invalid|failed/) } finally { - fs.rmSync(appDir, {recursive: true, force: true}) + // E2E_SKIP_CLEANUP=1 skips cleanup for debugging. Run `pnpm test:e2e-cleanup` afterward. + if (!process.env.E2E_SKIP_CLEANUP) { + fs.rmSync(appDir, {recursive: true, force: true}) + } } }) } diff --git a/packages/e2e/tests/toml-config.spec.ts b/packages/e2e/tests/toml-config.spec.ts index 7d478aa9d0b..318b7a9872e 100644 --- a/packages/e2e/tests/toml-config.spec.ts +++ b/packages/e2e/tests/toml-config.spec.ts @@ -32,8 +32,11 @@ test.describe('TOML config regression', () => { const output = result.stdout + result.stderr expect(result.exitCode, `deploy failed:\n${output}`).toBe(0) } finally { - fs.rmSync(parentDir, {recursive: true, force: true}) - await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + // E2E_SKIP_CLEANUP=1 skips cleanup for debugging. Run `pnpm test:e2e-cleanup` afterward. + if (!process.env.E2E_SKIP_CLEANUP) { + fs.rmSync(parentDir, {recursive: true, force: true}) + await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + } } }) @@ -66,8 +69,11 @@ test.describe('TOML config regression', () => { proc.kill() } } finally { - fs.rmSync(parentDir, {recursive: true, force: true}) - await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + // E2E_SKIP_CLEANUP=1 skips cleanup for debugging. Run `pnpm test:e2e-cleanup` afterward. + if (!process.env.E2E_SKIP_CLEANUP) { + fs.rmSync(parentDir, {recursive: true, force: true}) + await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + } } }) })