From 4fed81a58328a7fdf8e2efe2527881a1c3ac06e4 Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Fri, 27 Feb 2026 13:18:45 -0700 Subject: [PATCH 1/2] cleanup script for org --- packages/e2e/scripts/cleanup-test-apps.ts | 264 ++++++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 packages/e2e/scripts/cleanup-test-apps.ts diff --git a/packages/e2e/scripts/cleanup-test-apps.ts b/packages/e2e/scripts/cleanup-test-apps.ts new file mode 100644 index 00000000000..0e2a80c39e6 --- /dev/null +++ b/packages/e2e/scripts/cleanup-test-apps.ts @@ -0,0 +1,264 @@ +/** + * Deletes all test apps from the dev dashboard via browser automation. + * Run: npx tsx packages/e2e/scripts/cleanup-test-apps.ts + * + * Pass --dry-run to list apps without deleting. + * Pass --filter to only delete apps matching the pattern. + */ + +import * as fs from 'fs' +import * as path from 'path' +import {fileURLToPath} from 'url' +import {execa} from 'execa' +import {chromium, type Page} from '@playwright/test' +import stripAnsiModule from 'strip-ansi' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const rootDir = path.resolve(__dirname, '../../..') +const cliPath = path.join(rootDir, 'packages/cli/bin/run.js') + +const dryRun = process.argv.includes('--dry-run') +const filterIdx = process.argv.indexOf('--filter') +const filterPattern = filterIdx >= 0 ? process.argv[filterIdx + 1] : undefined +const headed = process.argv.includes('--headed') || !process.env.CI + +// Load .env +const envPath = path.join(__dirname, '../.env') +if (fs.existsSync(envPath)) { + for (const line of fs.readFileSync(envPath, 'utf-8').split('\n')) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + const eqIdx = trimmed.indexOf('=') + if (eqIdx === -1) continue + const key = trimmed.slice(0, eqIdx).trim() + const value = trimmed.slice(eqIdx + 1).trim() + if (!process.env[key]) process.env[key] = value + } +} + +const email = process.env.E2E_ACCOUNT_EMAIL +const password = process.env.E2E_ACCOUNT_PASSWORD +if (!email || !password) { + console.error('E2E_ACCOUNT_EMAIL and E2E_ACCOUNT_PASSWORD must be set') + process.exit(1) +} + +const baseEnv: {[key: string]: string} = { + ...(process.env as {[key: string]: string}), + NODE_OPTIONS: '', + SHOPIFY_RUN_AS_USER: '0', +} +delete baseEnv.SHOPIFY_CLI_PARTNERS_TOKEN + +async function main() { + // Step 1: OAuth login to get a browser session + console.log('--- Logging out ---') + await execa('node', [cliPath, 'auth', 'logout'], {env: baseEnv, reject: false}) + + console.log('--- Logging in via OAuth ---') + const nodePty = await import('node-pty') + const spawnEnv = {...baseEnv, CI: '', BROWSER: 'none'} + const pty = nodePty.spawn('node', [cliPath, 'auth', 'login'], { + name: 'xterm-color', + cols: 120, + rows: 30, + env: spawnEnv, + }) + + let output = '' + pty.onData((data: string) => { + output += data + }) + + await waitForText(() => output, 'Press any key to open the login page', 30_000) + pty.write(' ') + await waitForText(() => output, 'start the auth process', 10_000) + + const stripped = stripAnsiModule(output) + const urlMatch = stripped.match(/https:\/\/accounts\.shopify\.com\S+/) + if (!urlMatch) throw new Error(`No login URL found:\n${stripped}`) + + // Launch browser - we'll reuse this session for dashboard navigation + const browser = await chromium.launch({headless: !headed}) + const context = await browser.newContext({ + extraHTTPHeaders: { + 'X-Shopify-Loadtest-Bf8d22e7-120e-4b5b-906c-39ca9d5499a9': 'true', + }, + }) + const page = await context.newPage() + + // Complete OAuth login + await page.goto(urlMatch[0]) + await page.waitForSelector('input[name="account[email]"], input[type="email"]', {timeout: 60_000}) + await page.locator('input[name="account[email]"], input[type="email"]').first().fill(email) + await page.locator('button[type="submit"]').first().click() + await page.waitForSelector('input[name="account[password]"], input[type="password"]', {timeout: 60_000}) + await page.locator('input[name="account[password]"], input[type="password"]').first().fill(password!) + await page.locator('button[type="submit"]').first().click() + + // Wait for confirmation page and handle it + await page.waitForTimeout(3000) + try { + const confirmBtn = page.locator('button[type="submit"]').first() + if (await confirmBtn.isVisible({timeout: 5000})) { + await confirmBtn.click() + } + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (_err) { + // No confirmation page + } + + await waitForText(() => output, 'Logged in', 60_000) + console.log('Logged in successfully!') + try { + pty.kill() + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (_err) { + // already dead + } + + // Step 2: Navigate to dev dashboard + console.log('\n--- Navigating to dev dashboard ---') + await page.goto('https://dev.shopify.com/dashboard', {waitUntil: 'domcontentloaded'}) + await page.waitForTimeout(3000) + + // Handle account picker if shown + const accountButton = page.locator(`text=${email}`).first() + if (await accountButton.isVisible({timeout: 5000}).catch(() => false)) { + console.log('Account picker detected, selecting account...') + await accountButton.click() + await page.waitForTimeout(3000) + } + + // May need to handle org selection or retry on error + await page.waitForTimeout(2000) + + // Check for 500 error and retry + const pageText = await page.textContent('body') ?? '' + if (pageText.includes('500') || pageText.includes('Internal Server Error')) { + console.log('Got 500 error, retrying...') + await page.reload({waitUntil: 'domcontentloaded'}) + await page.waitForTimeout(3000) + } + + // Check for org selection page + const orgLink = page.locator('a, button').filter({hasText: /core-build|cli-e2e/i}).first() + if (await orgLink.isVisible({timeout: 3000}).catch(() => false)) { + console.log('Org selection detected, clicking...') + await orgLink.click() + await page.waitForTimeout(3000) + } + + await page.screenshot({path: '/tmp/e2e-dashboard.png'}) + console.log(`Dashboard URL: ${page.url()}`) + console.log('Dashboard screenshot saved to /tmp/e2e-dashboard.png') + + // Step 3: Find all app cards on the dashboard + // Each app is a clickable card/row with the app name visible + const appCards = await page.locator('a[href*="/apps/"]').all() + console.log(`Found ${appCards.length} app links on dashboard`) + + // Collect app names and URLs + 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+/)) { + // Extract just the app name (first line of text, before "installs") + const name = text.split(/\d+ install/)[0]?.trim() ?? text.trim() + if (!name) continue + if (filterPattern && !name.toLowerCase().includes(filterPattern.toLowerCase())) continue + const url = href.startsWith('http') ? href : `https://dev.shopify.com${href}` + apps.push({name, url}) + } + } + + if (apps.length === 0) { + console.log('No apps found to delete.') + await browser.close() + return + } + + console.log(`\nApps to delete (${apps.length}):`) + for (const app of apps) { + console.log(` - ${app.name}`) + } + + if (dryRun) { + console.log('\n--dry-run: not deleting anything.') + await browser.close() + return + } + + // Step 4: Delete each app + let deleted = 0 + for (const app of apps) { + console.log(`\nDeleting "${app.name}"...`) + try { + await deleteApp(page, app.url, app.name) + deleted++ + console.log(` Deleted "${app.name}"`) + } catch (err) { + console.error(` Failed to delete "${app.name}":`, err) + await page.screenshot({path: `/tmp/e2e-delete-error-${deleted}.png`}) + } + } + + console.log(`\n--- Done: deleted ${deleted}/${apps.length} apps ---`) + await browser.close() +} + +async function deleteApp(page: Page, appUrl: string, _appName: string): Promise { + // Navigate to the app page + await page.goto(appUrl, {waitUntil: 'domcontentloaded'}) + await page.waitForTimeout(3000) + + // Click "Settings" in the sidebar nav (last matches the desktop nav, first is mobile) + await page.locator('a[aria-label="Settings"]').last().click({force: true}) + await page.waitForTimeout(3000) + + // Take screenshot for debugging + await page.screenshot({path: '/tmp/e2e-settings-page.png'}) + + // Look for delete button + const deleteButton = page.locator('button:has-text("Delete app")').first() + await deleteButton.scrollIntoViewIfNeeded() + await deleteButton.click() + await page.waitForTimeout(2000) + + // Take screenshot of confirmation dialog + await page.screenshot({path: '/tmp/e2e-delete-confirm.png'}) + + // Handle confirmation dialog - may need to type app name or click confirm + const confirmInput = page.locator('input[type="text"]').last() + if (await confirmInput.isVisible({timeout: 3000}).catch(() => false)) { + await confirmInput.fill('DELETE') + await page.waitForTimeout(500) + } + + // Click the final delete/confirm button in the dialog + const confirmButton = page.locator('button:has-text("Delete app")').last() + await confirmButton.click() + await page.waitForTimeout(3000) +} + +function waitForText(getOutput: () => string, text: string, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const interval = setInterval(() => { + if (stripAnsiModule(getOutput()).includes(text) || getOutput().includes(text)) { + clearInterval(interval) + clearTimeout(timer) + resolve() + } + }, 200) + const timer = setTimeout(() => { + clearInterval(interval) + reject(new Error(`Timed out waiting for: "${text}"`)) + }, timeoutMs) + }) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) From 989e5eaa0b755d55786af5ad2c1a7e75c6d8f85d Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Wed, 11 Mar 2026 13:58:22 -0600 Subject: [PATCH 2/2] Address PR review feedback on cleanup script Use shared helpers (completeLogin, waitForText, stripAnsi) instead of inline duplicates, use os.tmpdir() for cross-platform paths, wrap browser in try/finally, handle quoted .env values, validate --filter arg, fix error screenshot naming, and remove unused _appName param. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/e2e/scripts/cleanup-test-apps.ts | 217 ++++++++++------------ 1 file changed, 98 insertions(+), 119 deletions(-) diff --git a/packages/e2e/scripts/cleanup-test-apps.ts b/packages/e2e/scripts/cleanup-test-apps.ts index 0e2a80c39e6..4785c73e4b3 100644 --- a/packages/e2e/scripts/cleanup-test-apps.ts +++ b/packages/e2e/scripts/cleanup-test-apps.ts @@ -7,11 +7,14 @@ */ import * as fs from 'fs' +import * as os from 'os' import * as path from 'path' import {fileURLToPath} from 'url' import {execa} from 'execa' import {chromium, type Page} from '@playwright/test' -import stripAnsiModule from 'strip-ansi' +import {completeLogin} from '../helpers/browser-login.js' +import {stripAnsi} from '../helpers/strip-ansi.js' +import {waitForText} from '../helpers/wait-for-text.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const rootDir = path.resolve(__dirname, '../../..') @@ -19,6 +22,10 @@ const cliPath = path.join(rootDir, 'packages/cli/bin/run.js') const dryRun = process.argv.includes('--dry-run') const filterIdx = process.argv.indexOf('--filter') +if (filterIdx >= 0 && !process.argv[filterIdx + 1]) { + console.error('--filter requires a value') + process.exit(1) +} const filterPattern = filterIdx >= 0 ? process.argv[filterIdx + 1] : undefined const headed = process.argv.includes('--headed') || !process.env.CI @@ -31,7 +38,11 @@ if (fs.existsSync(envPath)) { const eqIdx = trimmed.indexOf('=') if (eqIdx === -1) continue const key = trimmed.slice(0, eqIdx).trim() - const value = trimmed.slice(eqIdx + 1).trim() + let value = trimmed.slice(eqIdx + 1).trim() + // Remove surrounding quotes if present + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1) + } if (!process.env[key]) process.env[key] = value } } @@ -74,7 +85,7 @@ async function main() { pty.write(' ') await waitForText(() => output, 'start the auth process', 10_000) - const stripped = stripAnsiModule(output) + const stripped = stripAnsi(output) const urlMatch = stripped.match(/https:\/\/accounts\.shopify\.com\S+/) if (!urlMatch) throw new Error(`No login URL found:\n${stripped}`) @@ -87,26 +98,8 @@ async function main() { }) const page = await context.newPage() - // Complete OAuth login - await page.goto(urlMatch[0]) - await page.waitForSelector('input[name="account[email]"], input[type="email"]', {timeout: 60_000}) - await page.locator('input[name="account[email]"], input[type="email"]').first().fill(email) - await page.locator('button[type="submit"]').first().click() - await page.waitForSelector('input[name="account[password]"], input[type="password"]', {timeout: 60_000}) - await page.locator('input[name="account[password]"], input[type="password"]').first().fill(password!) - await page.locator('button[type="submit"]').first().click() - - // Wait for confirmation page and handle it - await page.waitForTimeout(3000) - try { - const confirmBtn = page.locator('button[type="submit"]').first() - if (await confirmBtn.isVisible({timeout: 5000})) { - await confirmBtn.click() - } - // eslint-disable-next-line no-catch-all/no-catch-all - } catch (_err) { - // No confirmation page - } + // Complete OAuth login using shared helper + await completeLogin(page, urlMatch[0], email!, password!) await waitForText(() => output, 'Logged in', 60_000) console.log('Logged in successfully!') @@ -117,98 +110,100 @@ async function main() { // already dead } - // Step 2: Navigate to dev dashboard - console.log('\n--- Navigating to dev dashboard ---') - await page.goto('https://dev.shopify.com/dashboard', {waitUntil: 'domcontentloaded'}) - await page.waitForTimeout(3000) - - // Handle account picker if shown - const accountButton = page.locator(`text=${email}`).first() - if (await accountButton.isVisible({timeout: 5000}).catch(() => false)) { - console.log('Account picker detected, selecting account...') - await accountButton.click() + try { + // Step 2: Navigate to dev dashboard + console.log('\n--- Navigating to dev dashboard ---') + await page.goto('https://dev.shopify.com/dashboard', {waitUntil: 'domcontentloaded'}) await page.waitForTimeout(3000) - } - // May need to handle org selection or retry on error - await page.waitForTimeout(2000) + // Handle account picker if shown + const accountButton = page.locator(`text=${email}`).first() + if (await accountButton.isVisible({timeout: 5000}).catch(() => false)) { + console.log('Account picker detected, selecting account...') + await accountButton.click() + await page.waitForTimeout(3000) + } - // Check for 500 error and retry - const pageText = await page.textContent('body') ?? '' - if (pageText.includes('500') || pageText.includes('Internal Server Error')) { - console.log('Got 500 error, retrying...') - await page.reload({waitUntil: 'domcontentloaded'}) - await page.waitForTimeout(3000) - } + // May need to handle org selection or retry on error + await page.waitForTimeout(2000) - // Check for org selection page - const orgLink = page.locator('a, button').filter({hasText: /core-build|cli-e2e/i}).first() - if (await orgLink.isVisible({timeout: 3000}).catch(() => false)) { - console.log('Org selection detected, clicking...') - await orgLink.click() - await page.waitForTimeout(3000) - } + // Check for 500 error and retry + const pageText = await page.textContent('body') ?? '' + if (pageText.includes('500') || pageText.includes('Internal Server Error')) { + console.log('Got 500 error, retrying...') + await page.reload({waitUntil: 'domcontentloaded'}) + await page.waitForTimeout(3000) + } - await page.screenshot({path: '/tmp/e2e-dashboard.png'}) - console.log(`Dashboard URL: ${page.url()}`) - console.log('Dashboard screenshot saved to /tmp/e2e-dashboard.png') - - // Step 3: Find all app cards on the dashboard - // Each app is a clickable card/row with the app name visible - const appCards = await page.locator('a[href*="/apps/"]').all() - console.log(`Found ${appCards.length} app links on dashboard`) - - // Collect app names and URLs - 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+/)) { - // Extract just the app name (first line of text, before "installs") - const name = text.split(/\d+ install/)[0]?.trim() ?? text.trim() - if (!name) continue - if (filterPattern && !name.toLowerCase().includes(filterPattern.toLowerCase())) continue - const url = href.startsWith('http') ? href : `https://dev.shopify.com${href}` - apps.push({name, url}) + // Check for org selection page + const orgLink = page.locator('a, button').filter({hasText: /core-build|cli-e2e/i}).first() + if (await orgLink.isVisible({timeout: 3000}).catch(() => false)) { + console.log('Org selection detected, clicking...') + await orgLink.click() + await page.waitForTimeout(3000) } - } - if (apps.length === 0) { - console.log('No apps found to delete.') - await browser.close() - return - } + const dashboardScreenshot = path.join(os.tmpdir(), 'e2e-dashboard.png') + await page.screenshot({path: dashboardScreenshot}) + console.log(`Dashboard URL: ${page.url()}`) + console.log(`Dashboard screenshot saved to ${dashboardScreenshot}`) + + // Step 3: Find all app cards on the dashboard + // Each app is a clickable card/row with the app name visible + const appCards = await page.locator('a[href*="/apps/"]').all() + console.log(`Found ${appCards.length} app links on dashboard`) + + // Collect app names and URLs + 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+/)) { + // Extract just the app name (first line of text, before "installs") + const name = text.split(/\d+ install/)[0]?.trim() || text.split('\n')[0]?.trim() || text.trim() + if (!name || name.length > 200) continue + if (filterPattern && !name.toLowerCase().includes(filterPattern.toLowerCase())) continue + const url = href.startsWith('http') ? href : `https://dev.shopify.com${href}` + apps.push({name, url}) + } + } - console.log(`\nApps to delete (${apps.length}):`) - for (const app of apps) { - console.log(` - ${app.name}`) - } + if (apps.length === 0) { + console.log('No apps found to delete.') + return + } - if (dryRun) { - console.log('\n--dry-run: not deleting anything.') - await browser.close() - return - } + console.log(`\nApps to delete (${apps.length}):`) + for (const app of apps) { + console.log(` - ${app.name}`) + } - // Step 4: Delete each app - let deleted = 0 - for (const app of apps) { - console.log(`\nDeleting "${app.name}"...`) - try { - await deleteApp(page, app.url, app.name) - deleted++ - console.log(` Deleted "${app.name}"`) - } catch (err) { - console.error(` Failed to delete "${app.name}":`, err) - await page.screenshot({path: `/tmp/e2e-delete-error-${deleted}.png`}) + if (dryRun) { + console.log('\n--dry-run: not deleting anything.') + return } - } - console.log(`\n--- Done: deleted ${deleted}/${apps.length} apps ---`) - await browser.close() + // Step 4: Delete each app + let deleted = 0 + for (const [index, app] of apps.entries()) { + console.log(`\nDeleting "${app.name}"...`) + try { + await deleteApp(page, app.url) + deleted++ + console.log(` Deleted "${app.name}"`) + } catch (err) { + console.error(` Failed to delete "${app.name}":`, err) + await page.screenshot({path: path.join(os.tmpdir(), `e2e-delete-error-${index}.png`)}) + } + } + + console.log(`\n--- Done: deleted ${deleted}/${apps.length} apps ---`) + } finally { + await browser.close() + } } -async function deleteApp(page: Page, appUrl: string, _appName: string): Promise { +async function deleteApp(page: Page, appUrl: string): Promise { // Navigate to the app page await page.goto(appUrl, {waitUntil: 'domcontentloaded'}) await page.waitForTimeout(3000) @@ -218,7 +213,7 @@ async function deleteApp(page: Page, appUrl: string, _appName: string): Promise< await page.waitForTimeout(3000) // Take screenshot for debugging - await page.screenshot({path: '/tmp/e2e-settings-page.png'}) + await page.screenshot({path: path.join(os.tmpdir(), 'e2e-settings-page.png')}) // Look for delete button const deleteButton = page.locator('button:has-text("Delete app")').first() @@ -227,7 +222,7 @@ async function deleteApp(page: Page, appUrl: string, _appName: string): Promise< await page.waitForTimeout(2000) // Take screenshot of confirmation dialog - await page.screenshot({path: '/tmp/e2e-delete-confirm.png'}) + await page.screenshot({path: path.join(os.tmpdir(), 'e2e-delete-confirm.png')}) // Handle confirmation dialog - may need to type app name or click confirm const confirmInput = page.locator('input[type="text"]').last() @@ -242,22 +237,6 @@ async function deleteApp(page: Page, appUrl: string, _appName: string): Promise< await page.waitForTimeout(3000) } -function waitForText(getOutput: () => string, text: string, timeoutMs: number): Promise { - return new Promise((resolve, reject) => { - const interval = setInterval(() => { - if (stripAnsiModule(getOutput()).includes(text) || getOutput().includes(text)) { - clearInterval(interval) - clearTimeout(timer) - resolve() - } - }, 200) - const timer = setTimeout(() => { - clearInterval(interval) - reject(new Error(`Timed out waiting for: "${text}"`)) - }, timeoutMs) - }) -} - main().catch((err) => { console.error(err) process.exit(1)