Skip to content
Open
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
8 changes: 7 additions & 1 deletion packages/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ Concurrent runs can use the same stores because each test creates a separate app

## Browser use

Shared authentication and app deletion use the browser. Dev tests do not check store emptiness. App uninstall uses the Admin API. The legacy bulk cleanup scripts still use browser discovery, uninstall, and store-emptiness checks before store deletion.
Shared authentication uses Chromium once per shard. The E2E test cases do not launch a browser after authentication. App lookup and deletion use authenticated HTTP requests to the Dev Dashboard with CSRF protection, exact name and organization checks, and deletion confirmation. These HTTP routes still depend on the dashboard's HTML contract.

Dev tests do not check store emptiness. They clean only their own app and preview; app uninstall uses the Admin API. The legacy bulk cleanup scripts still use browser discovery, uninstall, and store-emptiness checks before store deletion.

Malformed TOML is checked by the local project without authentication. Schema validation still needs remote specifications.

## CI setup

Expand Down Expand Up @@ -52,6 +56,8 @@ pnpm --filter e2e exec playwright test tests/store-lifecycle.spec.ts --project r

Remote app cleanup failures produce warnings with the app name, store, failed step, and recovery command. Each later cleanup step is still attempted. These warnings do not fail an otherwise successful test or block the store from subsequent tests. Failure to stop a CLI process remains an error because the process can hold ports and keep making requests.

After a successful uninstall, app deletion retries the dashboard's remaining-installs redirect for up to 15 seconds. The dashboard installation count can lag behind the uninstall. Other deletion errors fail immediately, and deletion still requires a confirmed missing app.

Recover leftover apps with `cleanup-apps.ts --pattern <run-attempt-shard>` or uninstall them in the store admin. Use the exact run marker from the failed run's app names and confirm that the run has stopped. Store reuse does not wait for this recovery.

`E2E_SKIP_TEARDOWN=1` deliberately leaves test apps installed. Use the cleanup script to remove them after debugging; their presence does not block store reuse.
Expand Down
1 change: 1 addition & 0 deletions packages/e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"dotenv": "16.6.1",
"execa": "^7.2.0",
"node-pty": "^1.0.0",
"parse5": "7.3.0",
"strip-ansi": "^7.2.0",
"tsx": "^4.23.1"
},
Expand Down
4 changes: 4 additions & 0 deletions packages/e2e/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export default defineConfig({
'tests/fixture-toml.spec.ts',
'tests/auth-diagnostics.spec.ts',
'tests/persistent-stores.spec.ts',
'tests/dashboard.spec.ts',
'tests/toml-syntax.spec.ts',
],
},
{
Expand All @@ -47,6 +49,8 @@ export default defineConfig({
'tests/fixture-toml.spec.ts',
'tests/auth-diagnostics.spec.ts',
'tests/persistent-stores.spec.ts',
'tests/dashboard.spec.ts',
'tests/toml-syntax.spec.ts',
],
dependencies: ['remote-auth'],
},
Expand Down
16 changes: 10 additions & 6 deletions packages/e2e/scripts/cleanup-apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {fileURLToPath} from 'url'
import {chromium} from '@playwright/test'
import {BROWSER_TIMEOUT} from '../setup/constants.js'
import {getLastPageStatus, navigateToDashboard, refreshIfPageError, trackMainFrameStatus} from '../setup/browser.js'
import {deleteAppFromDevDashboard} from '../setup/app.js'
import {createDashboardRequest, DevDashboardClient} from '../setup/dashboard.js'
import {uninstallAppFromStore} from '../setup/store.js'
import {completeLogin} from '../helpers/browser-login.js'
import {addLoadtestHeader} from '../helpers/loadtest-header.js'
Expand Down Expand Up @@ -205,7 +205,7 @@ async function cleanupAppsPageByPage(opts: {
page: Page
mode: CleanupMode
pattern: string
email: string
email?: string
orgId: string
stats: CleanupStats
}): Promise<void> {
Expand Down Expand Up @@ -274,7 +274,7 @@ async function cleanupAppsInParallel(opts: {
dashboardPage: Page
mode: CleanupMode
apps: DashboardApp[]
email: string
email?: string
orgId: string
matchOffset: number
foundCount: number
Expand Down Expand Up @@ -319,7 +319,7 @@ async function cleanupApp(opts: {
page: Page
mode: CleanupMode
app: DashboardApp
email: string
email?: string
orgId: string
workerNumber: number
appNumber: number
Expand Down Expand Up @@ -365,8 +365,12 @@ async function cleanupApp(opts: {
break
}
console.log(`${tag}: Deleting...`)
const deleted = await deleteAppFromDevDashboard(page, app.url)
if (!deleted) throw new Error('App deletion could not be verified')
const http = await createDashboardRequest(await page.context().storageState())
try {
await new DevDashboardClient(http, orgId).deleteApp({name: app.name, url: app.url})
} finally {
await http.dispose()
}
console.log(`${tag}: Deleted`)
}

Expand Down
103 changes: 4 additions & 99 deletions packages/e2e/setup/app.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
/* eslint-disable no-restricted-imports, no-await-in-loop */
import {authFixture} from './auth.js'
import {getLastPageStatus, isVisibleWithin, navigateToDashboard, refreshIfPageError} from './browser.js'
import {CLI_TIMEOUT, BROWSER_TIMEOUT} from './constants.js'
/* eslint-disable no-restricted-imports */
import {dashboardFixture} from './dashboard.js'
import {CLI_TIMEOUT} from './constants.js'
import * as toml from '@iarna/toml'
import * as path from 'path'
import * as fs from 'fs'
import type {CLIContext, CLIProcess, ExecResult} from './cli.js'
import type {Page} from '@playwright/test'

/**
* Race the given promise builders. When the winner resolves, losers are
Expand Down Expand Up @@ -331,104 +329,11 @@ export async function configLink(
}
}

// ---------------------------------------------------------------------------
// Dev dashboard browser actions — find and delete apps
// ---------------------------------------------------------------------------

/** Search dev dashboard for an app by name. Returns the app URL or null. */
export async function findAppOnDevDashboard(page: Page, appName: string, orgId?: string): Promise<string | null> {
const org = orgId ?? (process.env.E2E_ORG_ID ?? '').trim()
const email = process.env.E2E_ACCOUNT_EMAIL

await navigateToDashboard({browserPage: page, email, orgId: org, searchTerm: appName})

// Scan current page + pagination for the app
while (true) {
const allLinks = await page.locator('a[href*="/apps/"]').all()
for (const link of allLinks) {
const text = (await link.textContent()) ?? ''
if (text.includes(appName)) {
const href = await link.getAttribute('href')
if (href) return href.startsWith('http') ? href : `https://dev.shopify.com${href}`
}
}

// Check for next page
const nextLink = page.locator('a[href*="next_cursor"]').first()
if (!(await isVisibleWithin(nextLink, BROWSER_TIMEOUT.medium))) break
const nextHref = await nextLink.getAttribute('href')
if (!nextHref) break
const nextUrl = nextHref.startsWith('http') ? nextHref : `https://dev.shopify.com${nextHref}`
await page.goto(nextUrl, {waitUntil: 'domcontentloaded'})
await page.waitForTimeout(BROWSER_TIMEOUT.medium)
await refreshIfPageError(page)
}

return null
}

/**
* Delete an app from its dev dashboard settings page. Returns true if deleted.
*
* Single attempt — caller owns the retry loop.
*
* Fail-fast on STILL_HAS_INSTALLS: the Delete button stays disabled while
* installs exist, so we throw to let the caller skip instead of spinning.
*/
export async function deleteAppFromDevDashboard(page: Page, appUrl: string): Promise<boolean> {
// Step 1: Navigate to the app's settings page. 404 → already deleted. 5xx → throw for retry.
await page.goto(`${appUrl}/settings`, {waitUntil: 'domcontentloaded'})
await page.waitForTimeout(BROWSER_TIMEOUT.medium)
const gotoStatus = getLastPageStatus(page)
if (gotoStatus === 404) return true
if (gotoStatus !== undefined && gotoStatus >= 500) {
throw new Error(`Server error loading app settings page (status ${gotoStatus})`)
}

// Step 2: Click "Delete app" to open the confirmation modal.
// Button can be below the fold, and takes ~1-2s to enable after uninstall (one reload covers propagation lag).
// If it stays disabled after reload, installs remain — fail fast for caller.
const deleteBtn = page.locator('button:has-text("Delete app")').first()
await deleteBtn.scrollIntoViewIfNeeded({timeout: BROWSER_TIMEOUT.long})
if (!(await deleteBtn.isEnabled())) {
await page.reload({waitUntil: 'domcontentloaded'})
await page.waitForTimeout(BROWSER_TIMEOUT.medium)
await deleteBtn.scrollIntoViewIfNeeded({timeout: BROWSER_TIMEOUT.long})
if (!(await deleteBtn.isEnabled())) throw new Error('STILL_HAS_INSTALLS')
}
await deleteBtn.click({timeout: 2 * BROWSER_TIMEOUT.long})
await page.waitForTimeout(BROWSER_TIMEOUT.medium)

// Step 3: Some confirmation modals require typing "DELETE". Fill if the input is present.
const confirmInput = page.locator('input[type="text"]').last()
if (await isVisibleWithin(confirmInput, BROWSER_TIMEOUT.medium)) {
await confirmInput.fill('DELETE')
await page.waitForTimeout(BROWSER_TIMEOUT.short)
}

// Step 4: Click the confirm button (second "Delete app" button on the page, inside the modal).
const confirmBtn = page.locator('button:has-text("Delete app")').last()
await confirmBtn.click({timeout: BROWSER_TIMEOUT.long})
await page.waitForTimeout(BROWSER_TIMEOUT.medium)

// Step 5: Reload the settings page to confirm deletion.
// Success → 404.
// Failure → same settings page.
await page.goto(`${appUrl}/settings`, {waitUntil: 'domcontentloaded'})
await page.waitForTimeout(BROWSER_TIMEOUT.short)
const afterStatus = getLastPageStatus(page)
if (afterStatus === 404) return true
if (afterStatus !== undefined && afterStatus >= 500) {
throw new Error(`Server error verifying app deletion (status ${afterStatus})`)
}
return false
}

// ---------------------------------------------------------------------------
// Fixture — ensures auth runs before tests. Tests use helper functions directly.
// ---------------------------------------------------------------------------

export const appTestFixture = authFixture.extend<{authReady: void}>({
export const appTestFixture = dashboardFixture.extend<{authReady: void}>({
// Auto-trigger authLogin so the OAuth session is available for all app tests
authReady: [
async ({authLogin: _authLogin}: {authLogin: void}, use: () => Promise<void>) => {
Expand Down
6 changes: 3 additions & 3 deletions packages/e2e/setup/auth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {browserFixture} from './browser.js'
import {cliFixture} from './cli.js'
import {authStatePaths} from './auth-state.js'
import {AuthSetupError} from './auth-diagnostics.js'
import {globalLog} from './env.js'
Expand All @@ -13,9 +13,9 @@ const log = {log: (_ctx: any, msg: string) => globalLog('auth', msg)}
* Copies the pre-authenticated session from the remote auth setup into this
* worker's isolated XDG directories.
*
* Fixture chain: envFixture → cliFixture → browserFixture → authFixture
* Fixture chain: envFixture → cliFixture → authFixture
*/
export const authFixture = browserFixture.extend<{}, {authLogin: void}>({
export const authFixture = cliFixture.extend<{}, {authLogin: void}>({
authLogin: [
async ({env}, use) => {
const {xdgEnv: authXdgEnv} = authStatePaths()
Expand Down
45 changes: 2 additions & 43 deletions packages/e2e/setup/browser.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
import {cliFixture} from './cli.js'
import {authStatePaths} from './auth-state.js'
import {BROWSER_TIMEOUT} from './constants.js'
import {addLoadtestHeader} from '../helpers/loadtest-header.js'
import {chromium, type Locator, type Page} from '@playwright/test'
import * as fs from 'fs'
import type {Locator, Page} from '@playwright/test'

// ---------------------------------------------------------------------------
// Shared browser context type
Expand All @@ -20,7 +16,7 @@ export interface BrowserContext {
/**
* Records the HTTP status of the most recent main-frame document response per
* page. Populated by a `response` listener attached when the page is created
* (see fixture below). Read via `getLastPageStatus(page)`.
* (see `trackMainFrameStatus`). Read via `getLastPageStatus(page)`.
*
* A WeakMap lets the entry be garbage-collected with the page — no manual
* cleanup required.
Expand All @@ -40,43 +36,6 @@ export function getLastPageStatus(page: Page): number | undefined {
return lastMainFrameStatus.get(page)
}

// ---------------------------------------------------------------------------
// Fixture
// ---------------------------------------------------------------------------

/**
* Worker-scoped fixture providing a persistent Playwright browser page.
*
* The browser launches once per worker and stays open for the entire run.
* Downstream fixtures (auth) and tests use `browserPage` for any browser-based
* actions: OAuth login, dashboard navigation, app uninstall/deletion, etc.
*
* Fixture chain: envFixture → cliFixture → browserFixture
*/
export const browserFixture = cliFixture.extend<{}, {browserPage: Page}>({
browserPage: [
// eslint-disable-next-line no-empty-pattern
async ({}, use) => {
const browser = await chromium.launch({headless: !process.env.E2E_HEADED})
const storageStatePath = authStatePaths().storageStatePath
const hasValidStorageState = Boolean(
process.env.E2E_ACCOUNT_EMAIL && process.env.E2E_ACCOUNT_PASSWORD && fs.existsSync(storageStatePath),
)
const context = await browser.newContext({
...(hasValidStorageState ? {storageState: storageStatePath} : {}),
})
await addLoadtestHeader(context)
context.setDefaultTimeout(BROWSER_TIMEOUT.max)
context.setDefaultNavigationTimeout(BROWSER_TIMEOUT.max)
const page = await context.newPage()
trackMainFrameStatus(page)
await use(page)
await browser.close()
},
{scope: 'worker'},
],
})

// ---------------------------------------------------------------------------
// Browser helpers
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading