Skip to content

Commit 42157aa

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

9 files changed

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

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: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,11 @@ 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+
// E2E_SKIP_CLEANUP=1 skips cleanup for debugging. Run `pnpm test:e2e-cleanup` afterward.
47+
if (!process.env.E2E_SKIP_CLEANUP) {
48+
fs.rmSync(parentDir, {recursive: true, force: true})
49+
await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId})
50+
}
4851
}
4952
})
5053
})

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

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,11 @@ 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+
// E2E_SKIP_CLEANUP=1 skips cleanup for debugging. Run `pnpm test:e2e-cleanup` afterward.
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+
}
4346
}
4447
})
4548

@@ -63,8 +66,11 @@ test.describe('App scaffold', () => {
6366
expect(fs.existsSync(initResult.appDir)).toBe(true)
6467
expect(fs.existsSync(path.join(initResult.appDir, 'shopify.app.toml'))).toBe(true)
6568
} finally {
66-
fs.rmSync(parentDir, {recursive: true, force: true})
67-
await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId})
69+
// E2E_SKIP_CLEANUP=1 skips cleanup for debugging. Run `pnpm test:e2e-cleanup` afterward.
70+
if (!process.env.E2E_SKIP_CLEANUP) {
71+
fs.rmSync(parentDir, {recursive: true, force: true})
72+
await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId})
73+
}
6874
}
6975
})
7076

@@ -105,8 +111,11 @@ test.describe('App scaffold', () => {
105111
const buildResult = await buildApp({cli, appDir})
106112
expect(buildResult.exitCode, `build failed:\nstderr: ${buildResult.stderr}`).toBe(0)
107113
} finally {
108-
fs.rmSync(parentDir, {recursive: true, force: true})
109-
await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId})
114+
// E2E_SKIP_CLEANUP=1 skips cleanup for debugging. Run `pnpm test:e2e-cleanup` afterward.
115+
if (!process.env.E2E_SKIP_CLEANUP) {
116+
fs.rmSync(parentDir, {recursive: true, force: true})
117+
await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId})
118+
}
110119
}
111120
})
112121
})

0 commit comments

Comments
 (0)