Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 40 additions & 6 deletions packages/e2e/scripts/cleanup-apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

import {config} from 'dotenv'
import * as path from 'path'
import * as fs from 'fs'
import {fileURLToPath} from 'url'
import {chromium} from '@playwright/test'
import {BROWSER_TIMEOUT} from '../setup/constants.js'
Expand Down Expand Up @@ -59,6 +60,8 @@ export interface CleanupOptions {
headed?: boolean
/** Organization ID (default: from E2E_ORG_ID env) */
orgId?: string
/** Playwright browser storage state path (default: E2E_BROWSER_STATE_PATH or global-auth path) */
storageStatePath?: string
}

interface DashboardApp {
Expand All @@ -79,6 +82,26 @@ const EMPTY_APPS_PATTERN =
/(no apps matched your search|don't have any apps|do not have any apps|haven't created any apps)/i
const DASHBOARD_ERROR_PATTERN = /(unprocessable entity|request can't be processed|server error|something went wrong)/i

function isAccountsShopifyUrl(rawUrl: string): boolean {
try {
return new URL(rawUrl).hostname === 'accounts.shopify.com'
// eslint-disable-next-line no-catch-all/no-catch-all
} catch {
return false
}
}

function defaultStorageStatePath(): string {
const tmpBase = process.env.E2E_TEMP_DIR ?? path.resolve(__dirname, '../../../.e2e-tmp')
return path.join(tmpBase, 'global-auth', 'browser-storage-state.json')
}

function existingStorageStatePath(candidate?: string): string | undefined {
return [candidate, process.env.E2E_BROWSER_STATE_PATH, defaultStorageStatePath()].find(
(storageStatePath): storageStatePath is string => Boolean(storageStatePath && fs.existsSync(storageStatePath)),
)
}

/**
* Find and delete all E2E test apps matching a pattern.
* Handles browser login, dashboard navigation, uninstall, and deletion.
Expand All @@ -89,6 +112,7 @@ export async function cleanupAllApps(opts: CleanupOptions = {}): Promise<void> {
const orgId = opts.orgId ?? (process.env.E2E_ORG_ID ?? '').trim()
const email = process.env.E2E_ACCOUNT_EMAIL
const password = process.env.E2E_ACCOUNT_PASSWORD
const storageStatePath = existingStorageStatePath(opts.storageStatePath)

// Banner
console.log('')
Expand All @@ -97,8 +121,8 @@ export async function cleanupAllApps(opts: CleanupOptions = {}): Promise<void> {
console.log(`[cleanup-apps] Pattern: "${pattern}"`)
console.log('')

if (!email || !password) {
throw new Error('E2E_ACCOUNT_EMAIL and E2E_ACCOUNT_PASSWORD are required')
if (!storageStatePath && (!email || !password)) {
throw new Error('E2E_ACCOUNT_EMAIL and E2E_ACCOUNT_PASSWORD are required when no browser storage state is available')
}

if (!orgId) {
Expand All @@ -110,6 +134,7 @@ export async function cleanupAllApps(opts: CleanupOptions = {}): Promise<void> {
extraHTTPHeaders: {
'X-Shopify-Loadtest-Bf8d22e7-120e-4b5b-906c-39ca9d5499a9': 'true',
},
...(storageStatePath ? {storageState: storageStatePath} : {}),
})
context.setDefaultTimeout(BROWSER_TIMEOUT.max)
context.setDefaultNavigationTimeout(BROWSER_TIMEOUT.max)
Expand All @@ -118,15 +143,24 @@ export async function cleanupAllApps(opts: CleanupOptions = {}): Promise<void> {
const totalStart = Date.now()

try {
// Step 1: Log into Shopify directly in the browser
console.log('[cleanup-apps] Logging in...')
await completeLogin(page, 'https://accounts.shopify.com/lookup', email, password)
console.log('[cleanup-apps] Logged in successfully.')
// Step 1: Reuse Playwright's global auth storage when available; otherwise log in directly.
if (storageStatePath) {
console.log('[cleanup-apps] Reusing browser storage state.')
} else if (email && password) {
console.log('[cleanup-apps] Logging in...')
await completeLogin(page, 'https://accounts.shopify.com/lookup', email, password)
console.log('[cleanup-apps] Logged in successfully.')
}

// Step 2: Navigate to dashboard (retry on 500/502).
// navigateToDashboard already refreshes once on error; this loop is extra resilience.
console.log('[cleanup-apps] Navigating to dashboard...')
await navigateToDashboard({browserPage: page, email, orgId, searchTerm: pattern})
if (isAccountsShopifyUrl(page.url()) && email && password) {
console.log('[cleanup-apps] Browser storage state was not accepted; logging in...')
await completeLogin(page, page.url(), email, password)
await navigateToDashboard({browserPage: page, email, orgId, searchTerm: pattern})
}
for (let attempt = 1; attempt <= 3; attempt++) {
if (!(await refreshIfPageError(page))) break
if (attempt === 3) throw new Error('Dashboard returned server error after 3 attempts, aborting cleanup')
Expand Down
113 changes: 113 additions & 0 deletions packages/e2e/scripts/prime-browser-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/* eslint-disable no-console, no-restricted-imports */

/**
* Prime Playwright browser storage state for standalone E2E maintenance scripts.
*
* Playwright global setup creates this state before test workers start, but
* standalone GitHub Actions jobs need a small auth-only entrypoint so follow-up
* cleanup jobs can reuse browser cookies without each cleanup operation going
* through Shopify Accounts again.
*/

import {config} from 'dotenv'
import * as fs from 'fs'
import * as path from 'path'
import {fileURLToPath} from 'url'
import {chromium} from '@playwright/test'
import {BROWSER_TIMEOUT} from '../setup/constants.js'
import {isVisibleWithin} from '../setup/browser.js'
import {completeLogin} from '../helpers/browser-login.js'
import type {Page} from '@playwright/test'

const __dirname = path.dirname(fileURLToPath(import.meta.url))

if (!process.env.E2E_ACCOUNT_EMAIL || !process.env.E2E_ACCOUNT_PASSWORD || !process.env.E2E_ORG_ID) {
config({path: path.resolve(__dirname, '../.env')})
}

interface PrimeBrowserAuthOptions {
/** Playwright browser storage state path (default: E2E_BROWSER_STATE_PATH or global-auth path) */
storageStatePath?: string
/** Show browser window */
headed?: boolean
/** Organization ID (default: from E2E_ORG_ID env) */
orgId?: string
}

const LOADTEST_HEADER = 'X-Shopify-Loadtest-Bf8d22e7-120e-4b5b-906c-39ca9d5499a9'

function isAccountsShopifyUrl(rawUrl: string): boolean {
try {
return new URL(rawUrl).hostname === 'accounts.shopify.com'
// eslint-disable-next-line no-catch-all/no-catch-all
} catch {
return false
}
}

function defaultStorageStatePath(): string {
const tmpBase = process.env.E2E_TEMP_DIR ?? path.resolve(__dirname, '../../../.e2e-tmp')
return path.join(tmpBase, 'global-auth', 'browser-storage-state.json')
}

export async function primeBrowserAuthStorage(opts: PrimeBrowserAuthOptions = {}): Promise<string> {
const email = process.env.E2E_ACCOUNT_EMAIL
const password = process.env.E2E_ACCOUNT_PASSWORD
const orgId = opts.orgId ?? (process.env.E2E_ORG_ID ?? '').trim()
const storageStatePath = opts.storageStatePath ?? process.env.E2E_BROWSER_STATE_PATH ?? defaultStorageStatePath()

if (!email || !password) {
throw new Error('E2E_ACCOUNT_EMAIL and E2E_ACCOUNT_PASSWORD are required')
}

if (!orgId) {
throw new Error('E2E_ORG_ID is required')
}

fs.mkdirSync(path.dirname(storageStatePath), {recursive: true})

const browser = await chromium.launch({headless: !opts.headed})
try {
const context = await browser.newContext({
extraHTTPHeaders: {
[LOADTEST_HEADER]: 'true',
},
})
context.setDefaultTimeout(BROWSER_TIMEOUT.max)
context.setDefaultNavigationTimeout(BROWSER_TIMEOUT.max)
const page = await context.newPage()

console.log('[prime-browser-auth] Logging in...')
await completeLogin(page, 'https://accounts.shopify.com/lookup', email, password)

await visitAndHandleAccountPicker(page, 'https://admin.shopify.com/', email)
await visitAndHandleAccountPicker(page, `https://dev.shopify.com/dashboard/${orgId}/apps`, email)

await context.storageState({path: storageStatePath})
console.log(`[prime-browser-auth] Browser storage state saved to ${storageStatePath}`)
return storageStatePath
} finally {
await browser.close()
}
}

/** Navigate to a URL and dismiss the account picker if it appears. */
async function visitAndHandleAccountPicker(page: Page, url: string, email: string) {
await page.goto(url, {waitUntil: 'domcontentloaded'})
await page.waitForTimeout(BROWSER_TIMEOUT.medium)
if (isAccountsShopifyUrl(page.url())) {
const accountButton = page.locator(`text=${email}`).first()
if (await isVisibleWithin(accountButton, BROWSER_TIMEOUT.long)) {
await accountButton.click()
await page.waitForTimeout(BROWSER_TIMEOUT.medium)
}
}
}

const isDirectRun = process.argv[1] === fileURLToPath(import.meta.url)
if (isDirectRun) {
primeBrowserAuthStorage({headed: process.argv.includes('--headed')}).catch((err) => {
console.error('[prime-browser-auth] Fatal error:', err)
process.exitCode = 1
})
}
Loading