Skip to content

Commit b83a2b2

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

9 files changed

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

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,11 @@ 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+
// E2E_SKIP_CLEANUP=1 skips cleanup for debugging. Run `pnpm test:e2e-cleanup` afterward.
43+
if (!process.env.E2E_SKIP_CLEANUP) {
44+
fs.rmSync(parentDir, {recursive: true, force: true})
45+
await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId})
46+
}
4447
}
4548
})
4649
})

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
})

0 commit comments

Comments
 (0)