From cef7de93585862b0ba1bf9bb2f460b09133a1a9d Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Thu, 11 Jun 2026 12:49:43 +0200 Subject: [PATCH 1/2] Fix e2e cleanup --- packages/e2e/scripts/cleanup-apps.ts | 373 +++++++++++++++++---------- packages/e2e/setup/store.ts | 101 +++++++- 2 files changed, 331 insertions(+), 143 deletions(-) diff --git a/packages/e2e/scripts/cleanup-apps.ts b/packages/e2e/scripts/cleanup-apps.ts index 7d6552a7217..620452a3d7e 100644 --- a/packages/e2e/scripts/cleanup-apps.ts +++ b/packages/e2e/scripts/cleanup-apps.ts @@ -61,6 +61,19 @@ export interface CleanupOptions { orgId?: string } +interface DashboardApp { + name: string + url: string + installs: number +} + +interface CleanupStats { + found: number + succeeded: number + skipped: number + failed: number +} + /** * Find and delete all E2E test apps matching a pattern. * Handles browser login, dashboard navigation, uninstall, and deletion. @@ -116,177 +129,257 @@ export async function cleanupAllApps(opts: CleanupOptions = {}): Promise { } console.log('[cleanup-apps] Dashboard loaded.') - // Step 3: Find matching apps - console.log('[cleanup-apps] Finding matching apps...') - const apps = await findAppsOnDashboard(page, pattern) - console.log(`[cleanup-apps] Found ${apps.length} app(s) matching pattern "${pattern}"`) - console.log('') - - if (apps.length === 0) return + // Step 3: Process matching apps page by page. This intentionally does useful + // cleanup work before loading the full app list, because the dashboard can + // return transient 5xx responses after many pages. + const stats: CleanupStats = {found: 0, succeeded: 0, skipped: 0, failed: 0} + await cleanupAppsPageByPage({page, mode, pattern, email, orgId, stats}) - 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' : ''})`) - } + // Summary + const parts = [`${stats.found} found`, `${stats.succeeded} succeeded`] + if (stats.skipped > 0) parts.push(`${stats.skipped} skipped`) + if (stats.failed > 0) parts.push(`${stats.failed} failed`) console.log('') + const totalElapsed = ((Date.now() - totalStart) / 1000).toFixed(1) + console.log(`[cleanup-apps] Complete: ${parts.join(', ')} (${totalElapsed}s total)`) + if (stats.failed > 0) process.exitCode = 1 + } finally { + await browser.close() + } +} + +// --------------------------------------------------------------------------- +// Dashboard browser helpers — bulk discovery and cleanup +// --------------------------------------------------------------------------- - if (mode === 'list') return +async function cleanupAppsPageByPage(opts: { + page: Page + mode: CleanupMode + pattern: string + email: string + orgId: string + stats: CleanupStats +}): Promise { + const {page, mode, pattern, email, orgId, stats} = opts + let totalSeen = 0 + let pageNumber = 1 + const handledAppUrls = new Set() - // Step 4: Process each app with retries - let succeeded = 0 - let skipped = 0 - let failed = 0 + console.log('[cleanup-apps] Finding matching apps...') - for (let i = 0; i < apps.length; i++) { - const app = apps[i]! - const tag = `[cleanup-apps] [${i + 1}/${apps.length}]` - const appStart = Date.now() - let uninstalled = false - let wasSkipped = false + // eslint-disable-next-line no-constant-condition + while (true) { + await recoverFromAppsPageError(page) + const nextUrl = await nextAppsPageUrl(page) + const {seen, matches} = await findAppsOnCurrentDashboardPage(page, pattern, handledAppUrls) + const matchOffset = stats.found + totalSeen += seen + stats.found += matches.length + + console.log(`[cleanup-apps] page ${pageNumber}: loaded ${totalSeen} apps, found ${matches.length} match(es)`) + + for (const [index, app] of matches.entries()) { + const installLabel = app.installs === 1 ? 'install' : 'installs' + console.log(` ${matchOffset + index + 1}. ${app.name} (${app.installs} ${installLabel})`) + } + if (matches.length > 0) console.log('') - console.log(`${tag} ${app.name}`) + if (mode !== 'list') { + for (const app of matches) { + await cleanupApp({page, mode, app, email, orgId, stats}) + handledAppUrls.add(app.url) + } + } - for (let attempt = 1; attempt <= 3; attempt++) { - try { - if (attempt > 1) { - console.log(` (${attempt}/3) retrying...`) - await navigateToDashboard({browserPage: page, email, orgId}) - } + if (mode !== 'list' && matches.length > 0) { + // Re-paginate from page 1 after any mutation. This is intentionally O(pages²) + // for an org with matches spread across many pages: re-scanning from the top + // avoids stale next_cursor links that point past now-deleted apps. Don't + // "optimize" this into carrying the cursor across mutations. + await navigateToDashboard({browserPage: page, email, orgId}) + totalSeen = 0 + pageNumber = 1 + continue + } - 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) - if (!allUninstalled) { - throw new Error('Uninstall incomplete — some stores may remain') - } - console.log(' Uninstalled') - } - } + if (!nextUrl) break + await page.goto(nextUrl, {waitUntil: 'domcontentloaded'}) + await page.waitForTimeout(BROWSER_TIMEOUT.medium) + pageNumber++ + } +} - if (mode === 'full' || mode === 'delete') { - if (mode === 'delete' && app.installs > 0) { - console.log(' Delete skipped (still installed)') - wasSkipped = true - skipped++ - break - } - console.log(' Deleting...') - const deleted = await deleteAppFromDevDashboard(page, app.url) - if (!deleted) throw new Error('App deletion could not be verified') - console.log(' Deleted') - } +async function cleanupApp(opts: { + page: Page + mode: CleanupMode + app: DashboardApp + email: string + orgId: string + stats: CleanupStats +}): Promise { + const {page, mode, app, email, orgId, stats} = opts + const tag = `[cleanup-apps] [${stats.succeeded + stats.skipped + stats.failed + 1}/${stats.found}]` + const appStart = Date.now() + let uninstalled = false + let wasSkipped = false + + console.log(`${tag} ${app.name}`) + + for (let attempt = 1; attempt <= 3; attempt++) { + try { + if (attempt > 1) { + console.log(` (${attempt}/3) retrying...`) + await navigateToDashboard({browserPage: page, email, orgId}) + } - uninstalled = true - break - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - // Fail fast if the app still has installs — retries won't help - if (msg === 'STILL_HAS_INSTALLS') { - console.log(' Delete skipped (still has installs — dashboard count may be stale)') + if (mode === 'full' || mode === 'uninstall') { + if (app.installs === 0) { + if (mode === 'uninstall') { + console.log(' Not installed (skipped)') wasSkipped = true - skipped++ + stats.skipped++ break } - if (attempt < 3) { - console.warn(` (${attempt}/3) failed: ${msg}`) - await page.waitForTimeout(BROWSER_TIMEOUT.medium) - } else { - console.warn(` Failed: ${msg}`) + console.log(' Not installed') + } else { + console.log(' Uninstalling...') + const allUninstalled = await uninstallApp(page, app.url, app.name) + if (!allUninstalled) { + throw new Error('Uninstall incomplete — some stores may remain') } + console.log(' Uninstalled') } } - if (uninstalled) succeeded++ - else if (!wasSkipped) failed++ - const appElapsed = ((Date.now() - appStart) / 1000).toFixed(1) - console.log(` (${appElapsed}s)`) - console.log('') - } + if (mode === 'full' || mode === 'delete') { + if (mode === 'delete' && app.installs > 0) { + console.log(' Delete skipped (still installed)') + wasSkipped = true + stats.skipped++ + break + } + console.log(' Deleting...') + const deleted = await deleteAppFromDevDashboard(page, app.url) + if (!deleted) throw new Error('App deletion could not be verified') + console.log(' Deleted') + } - // Summary - const parts = [`${succeeded} succeeded`] - if (skipped > 0) parts.push(`${skipped} skipped`) - if (failed > 0) parts.push(`${failed} failed`) - console.log('') - const totalElapsed = ((Date.now() - totalStart) / 1000).toFixed(1) - console.log(`[cleanup-apps] Complete: ${parts.join(', ')} (${totalElapsed}s total)`) - if (failed > 0) process.exitCode = 1 - } finally { - await browser.close() + uninstalled = true + break + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + // Fail fast if the app still has installs — retries won't help + if (msg === 'STILL_HAS_INSTALLS') { + console.log(' Delete skipped (still has installs — dashboard count may be stale)') + wasSkipped = true + stats.skipped++ + break + } + if (attempt < 3) { + console.warn(` (${attempt}/3) failed: ${msg}`) + await page.waitForTimeout(BROWSER_TIMEOUT.medium) + } else { + console.warn(` Failed: ${msg}`) + } + } } + + if (uninstalled) stats.succeeded++ + else if (!wasSkipped) stats.failed++ + const appElapsed = ((Date.now() - appStart) / 1000).toFixed(1) + console.log(` (${appElapsed}s)`) + console.log('') } -// --------------------------------------------------------------------------- -// Dashboard browser helpers — bulk discovery and cleanup -// --------------------------------------------------------------------------- +async function recoverFromAppsPageError(page: Page): Promise { + for (let attempt = 1; attempt <= 3; attempt++) { + if (!(await refreshIfPageError(page))) return + if (attempt === 3) throw new Error('Apps page returned server error after 3 attempts') + console.log(`[cleanup-apps] ...server error on apps page (${attempt}/3), retrying`) + } +} -/** Find apps matching a name pattern on the dashboard. Handles pagination. */ -async function findAppsOnDashboard( +async function findAppsOnCurrentDashboardPage( page: Page, namePattern: string, -): Promise<{name: string; url: string; installs: number}[]> { - const apps: {name: string; url: string; installs: number}[] = [] - let totalSeen = 0 + excludeAppUrls: Set = new Set(), +): Promise<{seen: number; matches: DashboardApp[]}> { + const matches: DashboardApp[] = [] + let seen = 0 + const appCards = await page.locator('a[href*="/apps/"]').all() - // eslint-disable-next-line no-constant-condition - while (true) { - // Recover from transient 500/502 before parsing the page - for (let attempt = 1; attempt <= 3; attempt++) { - if (!(await refreshIfPageError(page))) break - if (attempt === 3) throw new Error('Apps page returned server error after 3 attempts') - console.log(`[cleanup-apps] ...server error on apps page (${attempt}/3), retrying`) - } + 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 appCards = await page.locator('a[href*="/apps/"]').all() + seen++ - 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 = extractDashboardAppName(text, namePattern) + if (!name || name.length > 200) continue - totalSeen++ + const installs = extractDashboardInstallCount(text, name) - 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 url = href.startsWith('http') ? href : `https://dev.shopify.com${href}` + if (excludeAppUrls.has(url)) continue + matches.push({name, url, installs}) + } - const installMatch = text.match(/(\d+)\s+install/i) - const installs = installMatch ? parseInt(installMatch[1]!, 10) : 0 + return {seen, matches} +} - const url = href.startsWith('http') ? href : `https://dev.shopify.com${href}` - apps.push({name, url, installs}) - } +function extractDashboardAppName(cardText: string, namePattern: string): string | undefined { + const dateStampedName = cardText.match(new RegExp(`${escapeRegExp(namePattern)}\\S*?\\d{13}`))?.[0] + if (dateStampedName) return dateStampedName - console.log(`[cleanup-apps] ...loaded ${totalSeen} apps`) + const lines = cardText + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + const matchingLine = lines.find((line) => line.includes(namePattern)) + if (matchingLine) return stripInstallCount(matchingLine) - // 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: 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) - } + const patternIndex = cardText.indexOf(namePattern) + if (patternIndex === -1) return undefined - return apps + const fromPattern = cardText.slice(patternIndex) + return stripInstallCount(fromPattern) +} + +function extractDashboardInstallCount(cardText: string, appName: string): number { + const appNameIndex = cardText.indexOf(appName) + const textAfterAppName = appNameIndex === -1 ? cardText : cardText.slice(appNameIndex + appName.length) + const installMatch = textAfterAppName.match(/(\d+)\s+installs?/i) + if (installMatch?.[1]) return parseInt(installMatch[1], 10) + + const allInstallMatches = [...cardText.matchAll(/(\d+)\s+installs?/gi)] + const lastInstallCount = allInstallMatches.at(-1)?.[1] + return lastInstallCount ? parseInt(lastInstallCount, 10) : 0 +} + +function stripInstallCount(text: string): string { + const installCount = text.match(/\d+\s+installs?/i) + if (!installCount || installCount.index === undefined) return text.trim() + + // Date-stamped names are recovered earlier via extractDashboardAppName's 13-digit + // anchor; this only trims the trailing "N installs" off the non-date-stamped fallback. + return text.slice(0, installCount.index).trim() +} + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +async function nextAppsPageUrl(page: Page): Promise { + const nextLink = page.locator('a[href*="next_cursor"]').first() + if (!(await nextLink.isVisible({timeout: BROWSER_TIMEOUT.medium}).catch(() => false))) return undefined + const nextHref = await nextLink.getAttribute('href') + if (!nextHref) return undefined + return nextHref.startsWith('http') ? nextHref : `https://dev.shopify.com${nextHref}` } /** Uninstall an app from all stores via the admin UI menu. Returns true if fully uninstalled. */ -async function uninstallApp( - page: Page, - appUrl: string, - appName: string, -): Promise { +async function uninstallApp(page: Page, appUrl: string, appName: string): Promise { // Collect store slugs from the installs page (with pagination) const storeSlugs: string[] = [] await page.goto(`${appUrl}/installs`, {waitUntil: 'domcontentloaded'}) @@ -322,9 +415,9 @@ async function uninstallApp( // Check for next page 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) + const isNextDisabled = await nextBtn + .evaluate((el) => el.getAttribute('aria-disabled') === 'true' || el.hasAttribute('disabled')) + .catch(() => true) if (isNextDisabled) break await nextBtn.click() @@ -361,9 +454,9 @@ async function uninstallApp( 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) + const isNextDisabled = await nextBtn + .evaluate((el) => el.getAttribute('aria-disabled') === 'true' || el.hasAttribute('disabled')) + .catch(() => true) if (isNextDisabled) break await nextBtn.click() diff --git a/packages/e2e/setup/store.ts b/packages/e2e/setup/store.ts index 6b5f033de5a..c80e47f54d0 100644 --- a/packages/e2e/setup/store.ts +++ b/packages/e2e/setup/store.ts @@ -5,7 +5,7 @@ import {BROWSER_TIMEOUT} from './constants.js' import {createLogger, e2eSection} from './env.js' import * as fs from 'fs' import type {BrowserContext} from './browser.js' -import type {Page} from '@playwright/test' +import type {Locator, Page} from '@playwright/test' const log = createLogger('browser') @@ -164,9 +164,35 @@ export async function uninstallAppFromStore(page: Page, storeSlug: string, appNa await page.waitForTimeout(BROWSER_TIMEOUT.medium) // Step 4: Confirm the uninstall in the modal (if one appears). - const confirmBtn = page.locator('button:has-text("Uninstall"), button:has-text("Confirm")').last() + const modal = page.locator('.Polaris-Modal-Dialog__Modal').last() + const confirmBtn = modal.locator('button:has-text("Uninstall"), button:has-text("Confirm")').last() if (await isVisibleWithin(confirmBtn, BROWSER_TIMEOUT.medium)) { - await confirmBtn.click() + await selectUninstallReason(page, modal) + await fillUninstallFeedback(modal) + await dismissOpenPopover(page, modal) + + let confirmEnabled = false + for (let i = 1; i <= 3; i++) { + if (await confirmBtn.isEnabled().catch(() => false)) { + confirmEnabled = true + break + } + await selectUninstallReason(page, modal) + await fillUninstallFeedback(modal) + await dismissOpenPopover(page, modal) + await page.waitForTimeout(BROWSER_TIMEOUT.short) + } + + // If still disabled, the force-click below no-ops; flag it so a stuck confirm + // is distinguishable from a real uninstall in the logs (verified in step 5). + if (!confirmEnabled) { + // eslint-disable-next-line no-console + console.warn(` Uninstall confirm button never enabled for ${appName} — force-clicking anyway`) + } + + // Force a DOM click to bypass Playwright actionability (the button can read as + // disabled mid-transition). + await confirmBtn.evaluate((button) => button.click()) await page.waitForTimeout(BROWSER_TIMEOUT.medium) } @@ -183,6 +209,75 @@ export async function uninstallAppFromStore(page: Page, storeSlug: string, appNa return !stillVisible } +async function selectUninstallReason(page: Page, modal: Locator): Promise { + const nativeSelect = modal.locator('select').first() + if (await isVisibleWithin(nativeSelect, BROWSER_TIMEOUT.short)) { + await nativeSelect.selectOption({index: 1}).catch(() => {}) + return + } + + const reasonTrigger = modal + .locator('button, [role="combobox"]') + .filter({hasText: /Select all that apply|Reason for uninstalling/i}) + .first() + if (!(await isVisibleWithin(reasonTrigger, BROWSER_TIMEOUT.short))) return + + await reasonTrigger.click() + await page.waitForTimeout(BROWSER_TIMEOUT.short) + + const preferredReason = page + .locator('[role="option"], [role="menuitemcheckbox"], [role="checkbox"], label') + .filter({hasText: /Other|No longer need|Don't need|Do not need|Not using|Testing/i}) + .first() + if (await isVisibleWithin(preferredReason, BROWSER_TIMEOUT.short)) { + await preferredReason.click() + return + } + + const options = await page.locator('[role="option"], [role="menuitemcheckbox"], [role="checkbox"]').all() + for (const option of options) { + if (await isVisibleWithin(option, BROWSER_TIMEOUT.short)) { + await option.click() + return + } + } +} + +async function fillUninstallFeedback(modal: Locator): Promise { + const feedback = modal.locator('textarea').first() + if (await isVisibleWithin(feedback, BROWSER_TIMEOUT.short)) { + await feedback.fill('Automated E2E cleanup.') + } +} + +async function dismissOpenPopover(page: Page, modal: Locator): Promise { + const feedback = modal.locator('textarea').first() + if (await isVisibleWithin(feedback, BROWSER_TIMEOUT.short)) { + await feedback.click({force: true}).catch(() => {}) + } else { + await modal + .locator('h1, h2, h3') + .first() + .click({force: true}) + .catch(() => {}) + } + await page.waitForTimeout(BROWSER_TIMEOUT.short) + + if ( + await page + .locator('[data-portal-id^="popover-"]') + .isVisible({timeout: BROWSER_TIMEOUT.short}) + .catch(() => false) + ) { + await modal + .locator('textarea, h1, h2, h3') + .first() + .click({force: true}) + .catch(() => {}) + await page.waitForTimeout(BROWSER_TIMEOUT.short) + } +} + /** Check if the current page shows the empty state (zero apps installed). Caller must navigate first. */ export async function isStoreAppsEmpty(page: Page): Promise { // "Add apps to your store" empty state is the definitive zero-apps signal From e8732aa103c6f5d461a1af6d390cf2c9e0d361ae Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Thu, 11 Jun 2026 13:48:16 +0200 Subject: [PATCH 2/2] Update e2e snapshots --- packages/e2e/data/snapshots/commands.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/e2e/data/snapshots/commands.txt b/packages/e2e/data/snapshots/commands.txt index 9e9afb6c946..4a121e660fe 100644 --- a/packages/e2e/data/snapshots/commands.txt +++ b/packages/e2e/data/snapshots/commands.txt @@ -94,7 +94,8 @@ ├─ search ├─ store │ ├─ auth -│ └─ execute +│ ├─ execute +│ └─ info ├─ theme │ ├─ check │ ├─ console