Skip to content

Commit df6b6a0

Browse files
committed
cleanup script for org
1 parent 09a894e commit df6b6a0

File tree

1 file changed

+264
-0
lines changed

1 file changed

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

0 commit comments

Comments
 (0)