Skip to content

Commit 7e992d2

Browse files
committed
E2E: cleanup utility
1 parent 8be6206 commit 7e992d2

9 files changed

Lines changed: 329 additions & 27 deletions

package.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"shopify:run": "node packages/cli/bin/dev.js",
3030
"shopify": "nx build cli && node packages/cli/bin/dev.js",
3131
"test:e2e": "nx run-many --target=build --projects=cli,create-app --skip-nx-cache && pnpm --filter e2e exec playwright test",
32+
"test:e2e-cleanup": "npx tsx packages/e2e/scripts/cleanup.ts",
3233
"test:regenerate-snapshots": "packages/e2e/scripts/regenerate-snapshots.sh",
3334
"test": "pnpm vitest run",
3435
"type-check:affected": "nx affected --target=type-check",
@@ -145,9 +146,13 @@
145146
"unresolved": "error"
146147
},
147148
"ignoreBinaries": [
148-
"playwright"
149+
"playwright",
150+
"tsx"
151+
],
152+
"ignoreDependencies": [
153+
"dotenv",
154+
"@playwright/test"
149155
],
150-
"ignoreDependencies": [],
151156
"ignoreWorkspaces": [
152157
"packages/eslint-plugin-cli",
153158
"packages/e2e"

packages/e2e/scripts/cleanup.ts

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
#!/usr/bin/env npx tsx
2+
/* eslint-disable no-console, no-restricted-imports, no-await-in-loop */
3+
4+
/**
5+
* E2E Cleanup Utility
6+
*
7+
* Finds and deletes leftover E2E test apps from the Dev Dashboard.
8+
* Apps are matched by the "E2E-" prefix in their name.
9+
*
10+
* Usage:
11+
* npx tsx packages/e2e/scripts/cleanup.ts # Full cleanup: uninstall + delete
12+
* npx tsx packages/e2e/scripts/cleanup.ts --list # List matching apps without action
13+
* npx tsx packages/e2e/scripts/cleanup.ts --uninstall # Uninstall from all stores only (no delete)
14+
* npx tsx packages/e2e/scripts/cleanup.ts --delete # Delete only (skip uninstall — delete only apps with 0 installs)
15+
* npx tsx packages/e2e/scripts/cleanup.ts --headed # Show browser window
16+
* npx tsx packages/e2e/scripts/cleanup.ts --pattern X # Match apps containing "X" (default: "E2E-")
17+
*
18+
* Environment variables (loaded from packages/e2e/.env):
19+
* E2E_ACCOUNT_EMAIL — Shopify account email for login
20+
* E2E_ACCOUNT_PASSWORD — Shopify account password
21+
* E2E_ORG_ID — Organization ID to scan for apps
22+
*
23+
* This module also exports `cleanupAllApps()` for use as a Playwright globalTeardown
24+
* or from other scripts/tests.
25+
*/
26+
27+
import {config} from 'dotenv'
28+
import * as path from 'path'
29+
import {fileURLToPath} from 'url'
30+
import {chromium} from '@playwright/test'
31+
import {navigateToDashboard} from '../setup/browser.js'
32+
import {findAppsOnDashboard, uninstallApp, deleteApp} from '../setup/app.js'
33+
import type {Page} from '@playwright/test'
34+
35+
// Load .env from packages/e2e/ (not cwd, which may be the repo root)
36+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
37+
config({path: path.resolve(__dirname, '../.env')})
38+
39+
// ---------------------------------------------------------------------------
40+
// Core cleanup logic — reusable from tests, teardown, or CLI
41+
// ---------------------------------------------------------------------------
42+
43+
export type CleanupMode = 'full' | 'list' | 'uninstall' | 'delete'
44+
45+
const MODE_LABELS: Record<CleanupMode, string> = {
46+
full: 'Uninstall + Delete',
47+
list: 'List only',
48+
uninstall: 'Uninstall only',
49+
delete: 'Delete only',
50+
}
51+
52+
export interface CleanupOptions {
53+
/** Cleanup mode (default: "full" — uninstall + delete) */
54+
mode?: CleanupMode
55+
/** App name pattern to match (default: "E2E-") */
56+
pattern?: string
57+
/** Show browser window */
58+
headed?: boolean
59+
/** Organization ID (default: from E2E_ORG_ID env) */
60+
orgId?: string
61+
/** Max retries per app on failure (default: 2) */
62+
retries?: number
63+
}
64+
65+
/**
66+
* Find and delete all E2E test apps matching a pattern.
67+
* Handles browser login, dashboard navigation, uninstall, and deletion.
68+
*/
69+
export async function cleanupAllApps(opts: CleanupOptions = {}): Promise<void> {
70+
const mode = opts.mode ?? 'full'
71+
const pattern = opts.pattern ?? 'E2E-'
72+
const orgId = opts.orgId ?? (process.env.E2E_ORG_ID ?? '').trim()
73+
const maxRetries = opts.retries ?? 2
74+
const email = process.env.E2E_ACCOUNT_EMAIL
75+
const password = process.env.E2E_ACCOUNT_PASSWORD
76+
77+
// Banner
78+
console.log('')
79+
console.log(`[cleanup] Mode: ${MODE_LABELS[mode]}`)
80+
console.log(`[cleanup] Org: ${orgId || '(not set)'}`)
81+
console.log(`[cleanup] Pattern: "${pattern}"`)
82+
console.log('')
83+
84+
if (!email || !password) {
85+
console.error('[cleanup] E2E_ACCOUNT_EMAIL and E2E_ACCOUNT_PASSWORD are required')
86+
process.exitCode = 1
87+
return
88+
}
89+
90+
if (!orgId) {
91+
console.error('[cleanup] E2E_ORG_ID is required')
92+
process.exitCode = 1
93+
return
94+
}
95+
96+
const browser = await chromium.launch({headless: !opts.headed})
97+
const context = await browser.newContext({
98+
extraHTTPHeaders: {
99+
'X-Shopify-Loadtest-Bf8d22e7-120e-4b5b-906c-39ca9d5499a9': 'true',
100+
},
101+
})
102+
context.setDefaultTimeout(15_000)
103+
context.setDefaultNavigationTimeout(15_000)
104+
const page = await context.newPage()
105+
106+
try {
107+
// Step 1: Log into Shopify directly in the browser
108+
console.log('[cleanup] Logging in...')
109+
await browserLogin(page, email, password)
110+
111+
// Step 2: Navigate to dashboard
112+
console.log('[cleanup] Navigating to dashboard...')
113+
await navigateToDashboard({browserPage: page, email, orgId})
114+
115+
// Step 3: Find matching apps
116+
const apps = await findAppsOnDashboard({browserPage: page, namePattern: pattern})
117+
console.log(`[cleanup] Found ${apps.length} app(s)`)
118+
console.log('')
119+
120+
if (apps.length === 0) return
121+
122+
for (let i = 0; i < apps.length; i++) {
123+
console.log(` ${i + 1}. ${apps[i]!.name}`)
124+
}
125+
console.log('')
126+
127+
if (mode === 'list') return
128+
129+
// Step 4: Process each app with retries
130+
let succeeded = 0
131+
let skipped = 0
132+
let failed = 0
133+
134+
for (let i = 0; i < apps.length; i++) {
135+
const app = apps[i]!
136+
const tag = `[cleanup] [${i + 1}/${apps.length}]`
137+
let ok = false
138+
let wasSkipped = false
139+
140+
console.log(`${tag} ${app.name}`)
141+
142+
for (let attempt = 1; attempt <= maxRetries + 1; attempt++) {
143+
try {
144+
if (attempt > 1) {
145+
console.log(` Retry ${attempt - 1}/${maxRetries}...`)
146+
await navigateToDashboard({browserPage: page, email, orgId})
147+
}
148+
149+
if (mode === 'full' || mode === 'uninstall') {
150+
const noInstalls = await checkNoInstalls(page, app.url)
151+
if (noInstalls) {
152+
console.log(' Not installed')
153+
} else {
154+
console.log(' Uninstalling...')
155+
const allUninstalled = await uninstallApp({browserPage: page, appUrl: app.url, appName: app.name, orgId})
156+
if (!allUninstalled) {
157+
throw new Error('Uninstall incomplete — some stores may remain')
158+
}
159+
console.log(' Uninstalled')
160+
}
161+
}
162+
163+
if (mode === 'full' || mode === 'delete') {
164+
if (mode === 'delete') {
165+
const noInstalls = await checkNoInstalls(page, app.url)
166+
if (!noInstalls) {
167+
console.log(' Delete skipped (still installed)')
168+
wasSkipped = true
169+
skipped++
170+
break
171+
}
172+
}
173+
console.log(' Deleting...')
174+
await deleteApp({browserPage: page, appUrl: app.url})
175+
console.log(' Deleted')
176+
}
177+
178+
ok = true
179+
break
180+
} catch (err) {
181+
const msg = err instanceof Error ? err.message : String(err)
182+
if (attempt <= maxRetries) {
183+
console.warn(` Attempt ${attempt} failed: ${msg}`)
184+
await page.waitForTimeout(3000)
185+
} else {
186+
console.warn(` Failed: ${msg}`)
187+
}
188+
}
189+
}
190+
191+
if (ok) succeeded++
192+
else if (!wasSkipped) failed++
193+
console.log('')
194+
}
195+
196+
// Summary
197+
const parts = [`${succeeded} succeeded`]
198+
if (skipped > 0) parts.push(`${skipped} skipped`)
199+
if (failed > 0) parts.push(`${failed} failed`)
200+
console.log('')
201+
console.log(`[cleanup] Complete: ${parts.join(', ')}`)
202+
} finally {
203+
await browser.close()
204+
}
205+
}
206+
207+
// ---------------------------------------------------------------------------
208+
// Browser-only login — go to accounts.shopify.com directly
209+
// ---------------------------------------------------------------------------
210+
211+
async function browserLogin(page: Page, email: string, password: string): Promise<void> {
212+
await page.goto('https://accounts.shopify.com/lookup', {waitUntil: 'domcontentloaded'})
213+
214+
// Fill email
215+
await page.waitForSelector('input[name="account[email]"], input[type="email"]', {timeout: 30_000})
216+
await page.locator('input[name="account[email]"], input[type="email"]').first().fill(email)
217+
await page.locator('button[type="submit"]').first().click()
218+
219+
// Fill password
220+
await page.waitForSelector('input[name="account[password]"], input[type="password"]', {timeout: 30_000})
221+
await page.locator('input[name="account[password]"], input[type="password"]').first().fill(password)
222+
await page.locator('button[type="submit"]').first().click()
223+
224+
// Wait for login to complete
225+
await page.waitForTimeout(3000)
226+
227+
console.log('[cleanup] Logged in successfully.')
228+
}
229+
230+
// ---------------------------------------------------------------------------
231+
// Helpers
232+
// ---------------------------------------------------------------------------
233+
234+
/** Check if an app has no store installs (by visiting its installs page). */
235+
async function checkNoInstalls(page: Page, appUrl: string): Promise<boolean> {
236+
await page.goto(`${appUrl}/installs`, {waitUntil: 'domcontentloaded'})
237+
await page.waitForTimeout(3000)
238+
const rows = await page.locator('table tbody tr').all()
239+
for (const row of rows) {
240+
const text = (await row.locator('td').first().textContent())?.trim()
241+
if (text && !text.toLowerCase().includes('no installed')) return false
242+
}
243+
return true
244+
}
245+
246+
// ---------------------------------------------------------------------------
247+
// CLI entry point
248+
// ---------------------------------------------------------------------------
249+
250+
async function main() {
251+
const args = process.argv.slice(2)
252+
const headed = args.includes('--headed')
253+
const patternIdx = args.indexOf('--pattern')
254+
const pattern = patternIdx !== -1 ? args[patternIdx + 1] : undefined
255+
256+
let mode: CleanupMode = 'full'
257+
if (args.includes('--list')) mode = 'list'
258+
else if (args.includes('--uninstall')) mode = 'uninstall'
259+
else if (args.includes('--delete')) mode = 'delete'
260+
261+
await cleanupAllApps({mode, pattern, headed})
262+
}
263+
264+
// Run if executed directly (not imported)
265+
const isDirectRun = process.argv[1]?.includes('cleanup')
266+
if (isDirectRun) {
267+
main().catch((err) => {
268+
console.error('[cleanup] Fatal error:', err)
269+
process.exitCode = 1
270+
})
271+
}

packages/e2e/tests/app-deploy.spec.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,10 @@ test.describe('App deploy', () => {
3939
expect(listResult.exitCode, `versions list failed:\n${listOutput}`).toBe(0)
4040
expect(listOutput).toContain(versionTag)
4141
} finally {
42-
fs.rmSync(parentDir, {recursive: true, force: true})
43-
await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId})
42+
if (!process.env.E2E_SKIP_CLEANUP) {
43+
fs.rmSync(parentDir, {recursive: true, force: true})
44+
await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId})
45+
}
4446
}
4547
})
4648
})

packages/e2e/tests/app-dev-server.spec.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,10 @@ test.describe('App dev server', () => {
4343
const exitCode = await dev.waitForExit(30_000)
4444
expect(exitCode).toBe(0)
4545
} finally {
46-
fs.rmSync(parentDir, {recursive: true, force: true})
47-
await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId})
46+
if (!process.env.E2E_SKIP_CLEANUP) {
47+
fs.rmSync(parentDir, {recursive: true, force: true})
48+
await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId})
49+
}
4850
}
4951
})
5052
})

packages/e2e/tests/app-scaffold.spec.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,10 @@ test.describe('App scaffold', () => {
3838
const buildResult = await buildApp({cli, appDir})
3939
expect(buildResult.exitCode, `build failed:\nstderr: ${buildResult.stderr}`).toBe(0)
4040
} finally {
41-
fs.rmSync(parentDir, {recursive: true, force: true})
42-
await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId})
41+
if (!process.env.E2E_SKIP_CLEANUP) {
42+
fs.rmSync(parentDir, {recursive: true, force: true})
43+
await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId})
44+
}
4345
}
4446
})
4547

@@ -63,8 +65,10 @@ test.describe('App scaffold', () => {
6365
expect(fs.existsSync(initResult.appDir)).toBe(true)
6466
expect(fs.existsSync(path.join(initResult.appDir, 'shopify.app.toml'))).toBe(true)
6567
} finally {
66-
fs.rmSync(parentDir, {recursive: true, force: true})
67-
await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId})
68+
if (!process.env.E2E_SKIP_CLEANUP) {
69+
fs.rmSync(parentDir, {recursive: true, force: true})
70+
await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId})
71+
}
6872
}
6973
})
7074

@@ -105,8 +109,10 @@ test.describe('App scaffold', () => {
105109
const buildResult = await buildApp({cli, appDir})
106110
expect(buildResult.exitCode, `build failed:\nstderr: ${buildResult.stderr}`).toBe(0)
107111
} finally {
108-
fs.rmSync(parentDir, {recursive: true, force: true})
109-
await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId})
112+
if (!process.env.E2E_SKIP_CLEANUP) {
113+
fs.rmSync(parentDir, {recursive: true, force: true})
114+
await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId})
115+
}
110116
}
111117
})
112118
})

packages/e2e/tests/dev-hot-reload.spec.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,10 @@ test.describe('Dev hot reload', () => {
8484
proc.kill()
8585
}
8686
} finally {
87-
fs.rmSync(parentDir, {recursive: true, force: true})
88-
await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId})
87+
if (!process.env.E2E_SKIP_CLEANUP) {
88+
fs.rmSync(parentDir, {recursive: true, force: true})
89+
await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId})
90+
}
8991
}
9092
})
9193

@@ -127,8 +129,10 @@ test.describe('Dev hot reload', () => {
127129
proc.kill()
128130
}
129131
} finally {
130-
fs.rmSync(parentDir, {recursive: true, force: true})
131-
await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId})
132+
if (!process.env.E2E_SKIP_CLEANUP) {
133+
fs.rmSync(parentDir, {recursive: true, force: true})
134+
await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId})
135+
}
132136
}
133137
})
134138

@@ -176,8 +180,10 @@ test.describe('Dev hot reload', () => {
176180
proc.kill()
177181
}
178182
} finally {
179-
fs.rmSync(parentDir, {recursive: true, force: true})
180-
await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId})
183+
if (!process.env.E2E_SKIP_CLEANUP) {
184+
fs.rmSync(parentDir, {recursive: true, force: true})
185+
await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId})
186+
}
181187
}
182188
})
183189
})

0 commit comments

Comments
 (0)