Skip to content

Commit 3ad93ef

Browse files
authored
Merge pull request #6907 from Shopify/02-27-add_cleanup
Add cleanup script for e2e test org
2 parents 1b7229b + 989e5ea commit 3ad93ef

1 file changed

Lines changed: 243 additions & 0 deletions

File tree

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
/**
2+
* Deletes all test apps from the dev dashboard via browser automation.
3+
* Run: npx tsx packages/e2e/scripts/cleanup-test-apps.ts
4+
*
5+
* Pass --dry-run to list apps without deleting.
6+
* Pass --filter <pattern> to only delete apps matching the pattern.
7+
*/
8+
9+
import * as fs from 'fs'
10+
import * as os from 'os'
11+
import * as path from 'path'
12+
import {fileURLToPath} from 'url'
13+
import {execa} from 'execa'
14+
import {chromium, type Page} from '@playwright/test'
15+
import {completeLogin} from '../helpers/browser-login.js'
16+
import {stripAnsi} from '../helpers/strip-ansi.js'
17+
import {waitForText} from '../helpers/wait-for-text.js'
18+
19+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
20+
const rootDir = path.resolve(__dirname, '../../..')
21+
const cliPath = path.join(rootDir, 'packages/cli/bin/run.js')
22+
23+
const dryRun = process.argv.includes('--dry-run')
24+
const filterIdx = process.argv.indexOf('--filter')
25+
if (filterIdx >= 0 && !process.argv[filterIdx + 1]) {
26+
console.error('--filter requires a value')
27+
process.exit(1)
28+
}
29+
const filterPattern = filterIdx >= 0 ? process.argv[filterIdx + 1] : undefined
30+
const headed = process.argv.includes('--headed') || !process.env.CI
31+
32+
// Load .env
33+
const envPath = path.join(__dirname, '../.env')
34+
if (fs.existsSync(envPath)) {
35+
for (const line of fs.readFileSync(envPath, 'utf-8').split('\n')) {
36+
const trimmed = line.trim()
37+
if (!trimmed || trimmed.startsWith('#')) continue
38+
const eqIdx = trimmed.indexOf('=')
39+
if (eqIdx === -1) continue
40+
const key = trimmed.slice(0, eqIdx).trim()
41+
let value = trimmed.slice(eqIdx + 1).trim()
42+
// Remove surrounding quotes if present
43+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
44+
value = value.slice(1, -1)
45+
}
46+
if (!process.env[key]) process.env[key] = value
47+
}
48+
}
49+
50+
const email = process.env.E2E_ACCOUNT_EMAIL
51+
const password = process.env.E2E_ACCOUNT_PASSWORD
52+
if (!email || !password) {
53+
console.error('E2E_ACCOUNT_EMAIL and E2E_ACCOUNT_PASSWORD must be set')
54+
process.exit(1)
55+
}
56+
57+
const baseEnv: {[key: string]: string} = {
58+
...(process.env as {[key: string]: string}),
59+
NODE_OPTIONS: '',
60+
SHOPIFY_RUN_AS_USER: '0',
61+
}
62+
delete baseEnv.SHOPIFY_CLI_PARTNERS_TOKEN
63+
64+
async function main() {
65+
// Step 1: OAuth login to get a browser session
66+
console.log('--- Logging out ---')
67+
await execa('node', [cliPath, 'auth', 'logout'], {env: baseEnv, reject: false})
68+
69+
console.log('--- Logging in via OAuth ---')
70+
const nodePty = await import('node-pty')
71+
const spawnEnv = {...baseEnv, CI: '', BROWSER: 'none'}
72+
const pty = nodePty.spawn('node', [cliPath, 'auth', 'login'], {
73+
name: 'xterm-color',
74+
cols: 120,
75+
rows: 30,
76+
env: spawnEnv,
77+
})
78+
79+
let output = ''
80+
pty.onData((data: string) => {
81+
output += data
82+
})
83+
84+
await waitForText(() => output, 'Press any key to open the login page', 30_000)
85+
pty.write(' ')
86+
await waitForText(() => output, 'start the auth process', 10_000)
87+
88+
const stripped = stripAnsi(output)
89+
const urlMatch = stripped.match(/https:\/\/accounts\.shopify\.com\S+/)
90+
if (!urlMatch) throw new Error(`No login URL found:\n${stripped}`)
91+
92+
// Launch browser - we'll reuse this session for dashboard navigation
93+
const browser = await chromium.launch({headless: !headed})
94+
const context = await browser.newContext({
95+
extraHTTPHeaders: {
96+
'X-Shopify-Loadtest-Bf8d22e7-120e-4b5b-906c-39ca9d5499a9': 'true',
97+
},
98+
})
99+
const page = await context.newPage()
100+
101+
// Complete OAuth login using shared helper
102+
await completeLogin(page, urlMatch[0], email!, password!)
103+
104+
await waitForText(() => output, 'Logged in', 60_000)
105+
console.log('Logged in successfully!')
106+
try {
107+
pty.kill()
108+
// eslint-disable-next-line no-catch-all/no-catch-all
109+
} catch (_err) {
110+
// already dead
111+
}
112+
113+
try {
114+
// Step 2: Navigate to dev dashboard
115+
console.log('\n--- Navigating to dev dashboard ---')
116+
await page.goto('https://dev.shopify.com/dashboard', {waitUntil: 'domcontentloaded'})
117+
await page.waitForTimeout(3000)
118+
119+
// Handle account picker if shown
120+
const accountButton = page.locator(`text=${email}`).first()
121+
if (await accountButton.isVisible({timeout: 5000}).catch(() => false)) {
122+
console.log('Account picker detected, selecting account...')
123+
await accountButton.click()
124+
await page.waitForTimeout(3000)
125+
}
126+
127+
// May need to handle org selection or retry on error
128+
await page.waitForTimeout(2000)
129+
130+
// Check for 500 error and retry
131+
const pageText = await page.textContent('body') ?? ''
132+
if (pageText.includes('500') || pageText.includes('Internal Server Error')) {
133+
console.log('Got 500 error, retrying...')
134+
await page.reload({waitUntil: 'domcontentloaded'})
135+
await page.waitForTimeout(3000)
136+
}
137+
138+
// Check for org selection page
139+
const orgLink = page.locator('a, button').filter({hasText: /core-build|cli-e2e/i}).first()
140+
if (await orgLink.isVisible({timeout: 3000}).catch(() => false)) {
141+
console.log('Org selection detected, clicking...')
142+
await orgLink.click()
143+
await page.waitForTimeout(3000)
144+
}
145+
146+
const dashboardScreenshot = path.join(os.tmpdir(), 'e2e-dashboard.png')
147+
await page.screenshot({path: dashboardScreenshot})
148+
console.log(`Dashboard URL: ${page.url()}`)
149+
console.log(`Dashboard screenshot saved to ${dashboardScreenshot}`)
150+
151+
// Step 3: Find all app cards on the dashboard
152+
// Each app is a clickable card/row with the app name visible
153+
const appCards = await page.locator('a[href*="/apps/"]').all()
154+
console.log(`Found ${appCards.length} app links on dashboard`)
155+
156+
// Collect app names and URLs
157+
const apps: {name: string; url: string}[] = []
158+
for (const card of appCards) {
159+
const href = await card.getAttribute('href')
160+
const text = await card.textContent()
161+
if (href && text && href.match(/\/apps\/\d+/)) {
162+
// Extract just the app name (first line of text, before "installs")
163+
const name = text.split(/\d+ install/)[0]?.trim() || text.split('\n')[0]?.trim() || text.trim()
164+
if (!name || name.length > 200) continue
165+
if (filterPattern && !name.toLowerCase().includes(filterPattern.toLowerCase())) continue
166+
const url = href.startsWith('http') ? href : `https://dev.shopify.com${href}`
167+
apps.push({name, url})
168+
}
169+
}
170+
171+
if (apps.length === 0) {
172+
console.log('No apps found to delete.')
173+
return
174+
}
175+
176+
console.log(`\nApps to delete (${apps.length}):`)
177+
for (const app of apps) {
178+
console.log(` - ${app.name}`)
179+
}
180+
181+
if (dryRun) {
182+
console.log('\n--dry-run: not deleting anything.')
183+
return
184+
}
185+
186+
// Step 4: Delete each app
187+
let deleted = 0
188+
for (const [index, app] of apps.entries()) {
189+
console.log(`\nDeleting "${app.name}"...`)
190+
try {
191+
await deleteApp(page, app.url)
192+
deleted++
193+
console.log(` Deleted "${app.name}"`)
194+
} catch (err) {
195+
console.error(` Failed to delete "${app.name}":`, err)
196+
await page.screenshot({path: path.join(os.tmpdir(), `e2e-delete-error-${index}.png`)})
197+
}
198+
}
199+
200+
console.log(`\n--- Done: deleted ${deleted}/${apps.length} apps ---`)
201+
} finally {
202+
await browser.close()
203+
}
204+
}
205+
206+
async function deleteApp(page: Page, appUrl: string): Promise<void> {
207+
// Navigate to the app page
208+
await page.goto(appUrl, {waitUntil: 'domcontentloaded'})
209+
await page.waitForTimeout(3000)
210+
211+
// Click "Settings" in the sidebar nav (last matches the desktop nav, first is mobile)
212+
await page.locator('a[aria-label="Settings"]').last().click({force: true})
213+
await page.waitForTimeout(3000)
214+
215+
// Take screenshot for debugging
216+
await page.screenshot({path: path.join(os.tmpdir(), 'e2e-settings-page.png')})
217+
218+
// Look for delete button
219+
const deleteButton = page.locator('button:has-text("Delete app")').first()
220+
await deleteButton.scrollIntoViewIfNeeded()
221+
await deleteButton.click()
222+
await page.waitForTimeout(2000)
223+
224+
// Take screenshot of confirmation dialog
225+
await page.screenshot({path: path.join(os.tmpdir(), 'e2e-delete-confirm.png')})
226+
227+
// Handle confirmation dialog - may need to type app name or click confirm
228+
const confirmInput = page.locator('input[type="text"]').last()
229+
if (await confirmInput.isVisible({timeout: 3000}).catch(() => false)) {
230+
await confirmInput.fill('DELETE')
231+
await page.waitForTimeout(500)
232+
}
233+
234+
// Click the final delete/confirm button in the dialog
235+
const confirmButton = page.locator('button:has-text("Delete app")').last()
236+
await confirmButton.click()
237+
await page.waitForTimeout(3000)
238+
}
239+
240+
main().catch((err) => {
241+
console.error(err)
242+
process.exit(1)
243+
})

0 commit comments

Comments
 (0)