diff --git a/packages/e2e/scripts/cleanup-stores.ts b/packages/e2e/scripts/cleanup-stores.ts new file mode 100644 index 0000000000..a4e36d3228 --- /dev/null +++ b/packages/e2e/scripts/cleanup-stores.ts @@ -0,0 +1,499 @@ +/* eslint-disable no-console, no-restricted-imports, no-await-in-loop */ + +/** + * E2E Store Cleanup Utility + * + * Finds and deletes leftover E2E dev stores from the Dev Dashboard. + * Stores are matched by the "e2e-w" prefix in their name (default). + * + * Usage: + * npx tsx packages/e2e/scripts/cleanup-stores.ts # Full: uninstall apps + delete stores + * npx tsx packages/e2e/scripts/cleanup-stores.ts --list # List stores with app counts + * npx tsx packages/e2e/scripts/cleanup-stores.ts --delete # Delete only stores with 0 apps installed + * npx tsx packages/e2e/scripts/cleanup-stores.ts --headed # Show browser window + * npx tsx packages/e2e/scripts/cleanup-stores.ts --pattern X # Match stores containing "X" (default: "e2e-w") + * + * 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 stores + */ + +import {config} from 'dotenv' +import * as path from 'path' +import {fileURLToPath} from 'url' +import {chromium} from '@playwright/test' +import {BROWSER_TIMEOUT} from '../setup/constants.js' +import {dismissDevConsole, isStoreAppsEmpty} from '../setup/store.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 || !process.env.E2E_ACCOUNT_PASSWORD || !process.env.E2E_ORG_ID) { + config({path: path.resolve(__dirname, '../.env')}) +} + +// --------------------------------------------------------------------------- +// Core cleanup logic +// --------------------------------------------------------------------------- + +export type CleanupStoresMode = 'full' | 'list' | 'delete' + +const MODE_LABELS: Record = { + full: 'Uninstall apps + Delete stores', + list: 'List only', + delete: 'Delete empty stores only', +} + +export interface CleanupStoresOptions { + /** Cleanup mode (default: "full") */ + mode?: CleanupStoresMode + /** Store name pattern to match (default: "e2e-w") */ + pattern?: string + /** Show browser window */ + headed?: boolean + /** Organization ID (default: from E2E_ORG_ID env) */ + orgId?: string +} + +export async function cleanupStores(opts: CleanupStoresOptions = {}): Promise { + const mode = opts.mode ?? 'full' + const pattern = opts.pattern ?? 'e2e-w' + const orgId = opts.orgId ?? (process.env.E2E_ORG_ID ?? '').trim() + const email = process.env.E2E_ACCOUNT_EMAIL + const password = process.env.E2E_ACCOUNT_PASSWORD + + console.log('') + console.log(`[cleanup-stores] Mode: ${MODE_LABELS[mode]}`) + console.log(`[cleanup-stores] Org: ${orgId || '(not set)'}`) + console.log(`[cleanup-stores] 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(BROWSER_TIMEOUT.max) + context.setDefaultNavigationTimeout(BROWSER_TIMEOUT.max) + const page = await context.newPage() + + const totalStart = Date.now() + + try { + // Step 1: Log in + console.log('[cleanup-stores] Logging in...') + await completeLogin(page, 'https://accounts.shopify.com/lookup', email, password) + console.log('[cleanup-stores] Logged in successfully.') + + // Step 2: Navigate to stores page and find matching stores + console.log('[cleanup-stores] Navigating to stores page...') + await page.goto(`https://dev.shopify.com/dashboard/${orgId}/stores`, {waitUntil: 'domcontentloaded'}) + await page.waitForTimeout(BROWSER_TIMEOUT.medium) + + // Handle account picker + const accountButton = page.locator(`text=${email}`).first() + if (await accountButton.isVisible({timeout: BROWSER_TIMEOUT.long}).catch(() => false)) { + await accountButton.click() + await page.waitForTimeout(BROWSER_TIMEOUT.medium) + } + + const stores = await findStoresOnDashboard(page, pattern) + console.log(`[cleanup-stores] Found ${stores.length} store(s)`) + console.log('') + + if (stores.length === 0) return + + if (mode === 'list') { + // List mode: count apps for each store, then print summary + for (const store of stores) { + store.appCount = await countInstalledApps(page, store.fqdn) + } + for (let i = 0; i < stores.length; i++) { + const store = stores[i]! + console.log(` ${i + 1}. ${store.name} (${store.appCount} app${store.appCount !== 1 ? 's' : ''} installed)`) + } + console.log('') + return + } + + // Step 3: Process each store in a single visit (count + uninstall + delete) + let succeeded = 0 + let skipped = 0 + let failed = 0 + + for (let i = 0; i < stores.length; i++) { + const store = stores[i]! + const tag = `[cleanup-stores] [${i + 1}/${stores.length}]` + const storeStart = Date.now() + + console.log(`${tag} ${store.name}`) + + try { + const storeSlug = store.fqdn.replace('.myshopify.com', '') + + // Navigate to apps settings page once + await page.goto(`https://admin.shopify.com/store/${storeSlug}/settings/apps`, { + waitUntil: 'domcontentloaded', + }) + await page.waitForTimeout(BROWSER_TIMEOUT.long) + await dismissDevConsole(page) + + // Wait for page to settle: either the empty state or at least one app menu button + const emptyState = page.locator('text=Add apps to your store') + const firstMenuBtn = page.locator('.Polaris-Layout__Section button[aria-label="More actions"]').first() + await Promise.race([ + emptyState.waitFor({state: 'visible', timeout: BROWSER_TIMEOUT.max}).catch(() => {}), + firstMenuBtn.waitFor({state: 'visible', timeout: BROWSER_TIMEOUT.max}).catch(() => {}), + ]) + + // Detect installed apps — only trust the empty state as proof of zero apps. + // If the empty state isn't visible, assume apps are present (safer than relying on menu button selectors). + const confirmedEmpty = await isStoreAppsEmpty(page) + const hasApps = !confirmedEmpty + if (confirmedEmpty) { + console.log(' No apps installed (empty state confirmed)') + } else { + const appMenuButtons = await page.locator('.Polaris-Layout__Section button[aria-label="More actions"]').all() + console.log(` ${appMenuButtons.length || '?'} app(s) installed`) + } + + // In delete mode, skip stores that have apps installed + if (mode === 'delete' && hasApps) { + console.log(` Skipped (still has apps)`) + skipped++ + } else { + let canDelete = true + + // In full mode, uninstall all apps (handles pagination) + if (hasApps) { + console.log(` Uninstalling apps...`) + await uninstallAllAppsOnPage(page) + + // Verify all apps were uninstalled before deleting — require empty state confirmation. + // uninstallAllAppsOnPage already reloads the page after the last uninstall, + // so we're already on /settings/apps with a fresh DOM. + const verifyEmpty = await isStoreAppsEmpty(page) + if (!verifyEmpty) { + console.warn(' Apps may still be installed (empty state not confirmed) — skipping delete') + skipped++ + canDelete = false + } else { + console.log(' Apps uninstalled (empty state confirmed)') + } + } + + if (canDelete) { + console.log(' Deleting store...') + await deleteStore(page, storeSlug) + console.log(' Deleted') + succeeded++ + } + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + console.warn(` Failed: ${msg}`) + failed++ + } + + const storeElapsed = ((Date.now() - storeStart) / 1000).toFixed(1) + console.log(` (${storeElapsed}s)`) + console.log('') + } + + // Summary + const parts = [`${succeeded} succeeded`] + if (skipped > 0) parts.push(`${skipped} skipped`) + if (failed > 0) parts.push(`${failed} failed`) + const totalElapsed = ((Date.now() - totalStart) / 1000).toFixed(1) + console.log('') + console.log(`[cleanup-stores] Complete: ${parts.join(', ')} (${totalElapsed}s total)`) + if (failed > 0) process.exitCode = 1 + } finally { + await browser.close() + } +} + +// --------------------------------------------------------------------------- +// Browser helpers +// --------------------------------------------------------------------------- + +interface StoreInfo { + name: string + fqdn: string + appCount: number +} + +/** Find stores matching a name pattern on the stores page. Handles pagination. */ +async function findStoresOnDashboard(page: Page, namePattern: string): Promise { + const seen = new Set() + const stores: StoreInfo[] = [] + + // eslint-disable-next-line no-constant-condition + while (true) { + // Scroll to bottom to ensure all rows are rendered (lazy-loaded tables) + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)) + await page.waitForTimeout(BROWSER_TIMEOUT.medium) + + // Use full HTML (not textContent) — FQDNs may be in hrefs/attributes, not visible text + const bodyHtml = await page.content() + const fqdnRegex = /([\w-]+)\.myshopify\.com/g + let match = fqdnRegex.exec(bodyHtml) + + while (match) { + const slug = match[1]! + const fqdn = `${slug}.myshopify.com` + + if (!seen.has(fqdn) && slug.toLowerCase().includes(namePattern.toLowerCase())) { + seen.add(fqdn) + stores.push({name: slug, fqdn, appCount: 0}) + } + + match = fqdnRegex.exec(bodyHtml) + } + + // Check for next page + const nextLink = page.locator('a[href*="next_cursor"]').first() + if (!(await nextLink.isVisible({timeout: BROWSER_TIMEOUT.medium}).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(BROWSER_TIMEOUT.medium) + } + + return stores +} + +/** Count installed apps on a store (used by --list mode only). Handles pagination. */ +async function countInstalledApps(page: Page, storeFqdn: string): Promise { + const storeSlug = storeFqdn.replace('.myshopify.com', '') + await page.goto(`https://admin.shopify.com/store/${storeSlug}/settings/apps`, { + waitUntil: 'domcontentloaded', + }) + await page.waitForTimeout(BROWSER_TIMEOUT.long) + await dismissDevConsole(page) + + // Wait for page to settle: either the empty state or at least one app menu button should appear + const emptyState = page.locator('text=Add apps to your store') + const firstMenuBtn = page.locator('.Polaris-Layout__Section button[aria-label="More actions"]').first() + await Promise.race([ + emptyState.waitFor({state: 'visible', timeout: BROWSER_TIMEOUT.max}).catch(() => {}), + firstMenuBtn.waitFor({state: 'visible', timeout: BROWSER_TIMEOUT.max}).catch(() => {}), + ]) + + // Check empty state after page has settled + if (await isStoreAppsEmpty(page)) return 0 + + let total = 0 + // eslint-disable-next-line no-constant-condition + while (true) { + const appMenuButtons = await page.locator('.Polaris-Layout__Section button[aria-label="More actions"]').all() + total += appMenuButtons.length + + const nextBtn = page.locator('button#nextURL') + if (!(await nextBtn.isVisible({timeout: BROWSER_TIMEOUT.short}).catch(() => false))) break + const isNextDisabled = await nextBtn.evaluate( + (el) => el.getAttribute('aria-disabled') === 'true' || el.hasAttribute('disabled'), + ).catch(() => true) + if (isNextDisabled) break + + await nextBtn.click() + await page.waitForTimeout(BROWSER_TIMEOUT.long) + await dismissDevConsole(page) + } + + return total +} + +/** + * Uninstall all apps on the currently loaded apps settings page. + * Caller must have already navigated to /settings/apps and dismissed Dev Console. + */ +async function uninstallAllAppsOnPage(page: Page): Promise { + // Uninstall apps one at a time using the ⋯ "More actions" menu buttons. + // The admin paginates installed apps, so after clearing the current page + // we check for a "Next" button and continue on subsequent pages. + // eslint-disable-next-line no-constant-condition + while (true) { + // Uninstall all apps visible on the current page + let consecutiveSkips = 0 + // eslint-disable-next-line no-constant-condition + while (true) { + const menuBtn = page.locator('.Polaris-Layout__Section button[aria-label="More actions"]').nth(consecutiveSkips) + if (!(await menuBtn.isVisible({timeout: BROWSER_TIMEOUT.medium}).catch(() => false))) break + + // Get the app name from the list item container + const appName = await menuBtn.evaluate((el) => { + const row = el.closest('div[role="listitem"]') + if (!row) return 'unknown' + // The app name is in a inside the clickable link + const link = row.querySelector('a span') + return link?.textContent?.trim() || 'unknown' + }).catch(() => 'unknown') + + await menuBtn.click() + await page.waitForTimeout(BROWSER_TIMEOUT.short) + + const uninstallOpt = page.locator('text=Uninstall').last() + if (!(await uninstallOpt.isVisible({timeout: BROWSER_TIMEOUT.medium}).catch(() => false))) { + // Close the menu and skip this app — try the next one in the list + await page.keyboard.press('Escape') + await page.waitForTimeout(BROWSER_TIMEOUT.short) + consecutiveSkips++ + continue + } + await uninstallOpt.click() + await page.waitForTimeout(BROWSER_TIMEOUT.medium) + + const confirmBtn = page.locator('button:has-text("Uninstall"), button:has-text("Confirm")').last() + if (await confirmBtn.isVisible({timeout: BROWSER_TIMEOUT.medium}).catch(() => false)) { + await confirmBtn.click() + await page.waitForTimeout(BROWSER_TIMEOUT.medium) + consecutiveSkips = 0 + console.log(` Uninstalled ${appName}`) + } else { + // Confirm never appeared — skip this app to avoid infinite loop + console.log(` Uninstall confirm not found for ${appName}, skipping`) + consecutiveSkips++ + } + + // Reload to refresh the list + await page.reload({waitUntil: 'domcontentloaded'}) + await page.waitForTimeout(BROWSER_TIMEOUT.long) + await dismissDevConsole(page) + } + + // Check for pagination — if there's a next page, navigate to it + const nextBtn = page.locator('button#nextURL') + if (!(await nextBtn.isVisible({timeout: BROWSER_TIMEOUT.short}).catch(() => false))) break + const isNextDisabled = await nextBtn.evaluate( + (el) => el.getAttribute('aria-disabled') === 'true' || el.hasAttribute('disabled'), + ).catch(() => true) + if (isNextDisabled) break + + await nextBtn.click() + await page.waitForTimeout(BROWSER_TIMEOUT.long) + await dismissDevConsole(page) + } +} + +/** + * Delete a store via the admin settings plan page. + * Caller must ensure all apps are already uninstalled. + * Retries the full flow if the page redirects to store home instead of access_account. + */ +async function deleteStore(page: Page, storeSlug: string): Promise { + const planUrl = `https://admin.shopify.com/store/${storeSlug}/settings/plan` + + for (let attempt = 1; attempt <= 3; attempt++) { + try { + // Step 1: Navigate to plan page (wait for full hydration before clicking) + await page.goto(planUrl, {waitUntil: 'load'}) + await page.waitForTimeout(2 * BROWSER_TIMEOUT.long) + + // If redirected to access_account, store is already deleted + if (page.url().includes('access_account')) return + + // Step 2: Click delete button → wait for modal (retry click if modal doesn't appear) + const deleteButton = page.locator('s-internal-button[tone="critical"]').locator('button') + // Scope to the delete modal by title to avoid matching stale/hidden modals + const modal = page.locator('.Polaris-Modal-Dialog__Modal:has-text("Review before deleting store")') + const checkbox = modal.locator('input[type="checkbox"]') + + let modalOpened = false + for (let clickAttempt = 1; clickAttempt <= 3; clickAttempt++) { + await deleteButton.click({timeout: BROWSER_TIMEOUT.long}) + await page.waitForTimeout(BROWSER_TIMEOUT.medium) + + if (await checkbox.isVisible({timeout: BROWSER_TIMEOUT.long}).catch(() => false)) { + modalOpened = true + break + } + // Modal didn't open — first click often refreshes the page. + // Don't re-navigate; the page already reloaded. Just wait for hydration and retry. + await page.waitForTimeout(BROWSER_TIMEOUT.long) + } + if (!modalOpened) throw new Error('Delete modal did not appear after 3 clicks') + await checkbox.check({force: true}) + await page.waitForTimeout(BROWSER_TIMEOUT.short) + + // Step 3: Click confirm + const confirmButton = modal.locator('button:has-text("Delete store")') + for (let i = 1; i <= 3; i++) { + const isDisabled = await confirmButton.evaluate( + (el) => el.getAttribute('aria-disabled') === 'true' || el.hasAttribute('disabled'), + ).catch(() => true) + if (!isDisabled) break + if (i === 3) throw new Error('Confirm button still disabled') + await checkbox.check({force: true}) + await page.waitForTimeout(BROWSER_TIMEOUT.short) + } + + await confirmButton.click({force: true}) + + // Step 4: Verify — wait for either access_account (success) or store home (failure) + try { + await page.waitForURL( + (url) => url.toString().includes('access_account') || url.pathname.match(/^\/store\/[^/]+\/?$/) !== null, + {timeout: BROWSER_TIMEOUT.max}, + ) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (_err) { + // Timeout — neither redirect happened + } + + if (page.url().includes('access_account')) return // Success + + // Landed on store home — deletion failed, will retry + throw new Error(`Store deletion not confirmed (url: ${page.url()})`) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (err) { + if (attempt === 3) throw err + // Retry — next iteration will re-navigate + } + } +} + +// --------------------------------------------------------------------------- +// 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-stores] --pattern requires a value') + process.exitCode = 1 + return + } + pattern = nextArg + } + + let mode: CleanupStoresMode = 'full' + if (args.includes('--list')) mode = 'list' + else if (args.includes('--delete')) mode = 'delete' + + await cleanupStores({mode, pattern, headed}) +} + +const isDirectRun = process.argv[1] === fileURLToPath(import.meta.url) +if (isDirectRun) { + main().catch((err) => { + console.error('[cleanup-stores] Fatal error:', err) + process.exitCode = 1 + }) +}