diff --git a/packages/e2e/setup/app.ts b/packages/e2e/setup/app.ts index 262ac738d5..dca613ea57 100644 --- a/packages/e2e/setup/app.ts +++ b/packages/e2e/setup/app.ts @@ -7,7 +7,7 @@ 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 {BrowserContext} from './browser.js' +import type {Page} from '@playwright/test' // --------------------------------------------------------------------------- // CLI helpers — thin wrappers around cli.exec() @@ -190,208 +190,84 @@ export async function configLink( } // --------------------------------------------------------------------------- -// Browser helpers — app-specific dashboard automation +// Dev dashboard browser actions — find and delete apps // --------------------------------------------------------------------------- -/** Find apps matching a name pattern on the dashboard. Call navigateToDashboard first. */ -export async function findAppsOnDashboard( - ctx: BrowserContext & { - namePattern: string - }, -): Promise<{name: string; url: string}[]> { - const appCards = await ctx.browserPage.locator('a[href*="/apps/"]').all() - const apps: {name: string; url: string}[] = [] - - for (const card of appCards) { - const href = await card.getAttribute('href') - const text = await card.textContent() - if (!href || !text || !href.match(/\/apps\/\d+/)) continue - - const name = text.split(/\d+\s+install/i)[0]?.trim() ?? text.split('\n')[0]?.trim() ?? text.trim() - if (!name || name.length > 200) continue - if (!name.includes(ctx.namePattern)) continue - - const url = href.startsWith('http') ? href : `https://dev.shopify.com${href}` - apps.push({name, url}) - } - - return apps -} - -/** Uninstall an app from all stores it's installed on. Returns true if fully uninstalled. */ -export async function uninstallApp( - ctx: BrowserContext & { - appUrl: string - appName: string - orgId?: string - }, -): Promise { - const {browserPage, appUrl, appName} = ctx - const orgId = ctx.orgId ?? (process.env.E2E_ORG_ID ?? '').trim() - - await browserPage.goto(`${appUrl}/installs`, {waitUntil: 'domcontentloaded'}) - await browserPage.waitForTimeout(BROWSER_TIMEOUT.medium) - - const rows = await browserPage.locator('table tbody tr').all() - const storeNames: string[] = [] - for (const row of rows) { - const firstCell = row.locator('td').first() - const text = (await firstCell.textContent())?.trim() - if (text && !text.toLowerCase().includes('no installed')) storeNames.push(text) - } - - if (storeNames.length === 0) return true - - let allUninstalled = true - for (const storeName of storeNames) { - try { - // Navigate to store admin via the dev dashboard dropdown - const dashboardUrl = orgId - ? `https://dev.shopify.com/dashboard/${orgId}/apps` - : 'https://dev.shopify.com/dashboard' - let navigated = false - for (let attempt = 1; attempt <= 3; attempt++) { - await browserPage.goto(dashboardUrl, {waitUntil: 'domcontentloaded'}) - await browserPage.waitForTimeout(BROWSER_TIMEOUT.medium) - - const pageText = (await browserPage.textContent('body')) ?? '' - if (pageText.includes('500') || pageText.includes('Internal Server Error')) continue - - const orgButton = browserPage.locator('header button').last() - if (!(await orgButton.isVisible({timeout: BROWSER_TIMEOUT.medium}).catch(() => false))) continue - await orgButton.click() - await browserPage.waitForTimeout(BROWSER_TIMEOUT.short) - - const storeLink = browserPage.locator('a, button').filter({hasText: storeName}).first() - if (!(await storeLink.isVisible({timeout: BROWSER_TIMEOUT.medium}).catch(() => false))) continue - await storeLink.click() - await browserPage.waitForTimeout(BROWSER_TIMEOUT.medium) - navigated = true - break - } - - if (!navigated) { - allUninstalled = false - continue - } +/** 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 { + const org = orgId ?? (process.env.E2E_ORG_ID ?? '').trim() + const email = process.env.E2E_ACCOUNT_EMAIL - // Navigate to store's apps settings page - const storeAdminUrl = browserPage.url() - await browserPage.goto(`${storeAdminUrl.replace(/\/$/, '')}/settings/apps`, {waitUntil: 'domcontentloaded'}) - await browserPage.waitForTimeout(BROWSER_TIMEOUT.long) + await navigateToDashboard({browserPage: page, email, orgId: org}) - // Dismiss any Dev Console dialog - const cancelButton = browserPage.locator('button:has-text("Cancel")') - if (await cancelButton.isVisible({timeout: BROWSER_TIMEOUT.medium}).catch(() => false)) { - await cancelButton.click() - await browserPage.waitForTimeout(BROWSER_TIMEOUT.short) - } - - // Find the app in the installed list (plain span, not Dev Console's Polaris text) - const appSpan = browserPage.locator(`span:has-text("${appName}"):not([class*="Polaris"])`).first() - if (!(await appSpan.isVisible({timeout: BROWSER_TIMEOUT.medium}).catch(() => false))) { - allUninstalled = false - continue - } - - // Click the ⋯ menu button next to the app name - const menuButton = appSpan.locator('xpath=./following::button[1]') - await menuButton.click() - await browserPage.waitForTimeout(BROWSER_TIMEOUT.short) + // Scan current page + pagination for the app - // Click "Uninstall" in the dropdown menu - const uninstallOption = browserPage.locator('text=Uninstall').last() - if (!(await uninstallOption.isVisible({timeout: BROWSER_TIMEOUT.medium}).catch(() => false))) { - allUninstalled = false - continue + 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}` } - await uninstallOption.click() - await browserPage.waitForTimeout(BROWSER_TIMEOUT.medium) - - // Handle confirmation dialog - const confirmButton = browserPage.locator('button:has-text("Uninstall"), button:has-text("Confirm")').last() - if (await confirmButton.isVisible({timeout: BROWSER_TIMEOUT.medium}).catch(() => false)) { - await confirmButton.click() - await browserPage.waitForTimeout(BROWSER_TIMEOUT.medium) - } - // eslint-disable-next-line no-catch-all/no-catch-all - } catch (_err) { - allUninstalled = false } + + // Check for next page + const nextLink = page.locator('a[href*="next_cursor"]').first() + if (!(await nextLink.isVisible({timeout: BROWSER_TIMEOUT.medium}).catch(() => false))) 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) } - return allUninstalled + return null } -/** Delete an app from the partner dashboard. Should be uninstalled first. */ -export async function deleteApp( - ctx: BrowserContext & { - appUrl: string - }, -): Promise { - const {browserPage, appUrl} = ctx - - await browserPage.goto(`${appUrl}/settings`, {waitUntil: 'domcontentloaded'}) - await browserPage.waitForTimeout(BROWSER_TIMEOUT.medium) +/** Delete an app from its dev dashboard settings page. Returns true if deleted, false if not. */ +export async function deleteAppFromDevDashboard(page: Page, appUrl: string): Promise { + // Step 1: Navigate to settings page + await page.goto(`${appUrl}/settings`, {waitUntil: 'domcontentloaded'}) + await page.waitForTimeout(BROWSER_TIMEOUT.medium) - // Retry if delete button is disabled (uninstall propagation delay) - const deleteButton = browserPage.locator('button:has-text("Delete app")').first() + // Step 2: Wait for "Delete app" button to be enabled, then click (retry step 1+2 on failure) + const deleteAppBtn = page.locator('button:has-text("Delete app")').first() for (let attempt = 1; attempt <= 5; attempt++) { - await deleteButton.scrollIntoViewIfNeeded() - const isDisabled = await deleteButton.getAttribute('disabled') + const isDisabled = await deleteAppBtn.getAttribute('disabled').catch(() => 'true') if (!isDisabled) break - await browserPage.waitForTimeout(BROWSER_TIMEOUT.long) - await browserPage.reload({waitUntil: 'domcontentloaded'}) - await browserPage.waitForTimeout(BROWSER_TIMEOUT.medium) + await page.waitForTimeout(BROWSER_TIMEOUT.long) + await page.reload({waitUntil: 'domcontentloaded'}) + await page.waitForTimeout(BROWSER_TIMEOUT.medium) } - await deleteButton.click({timeout: BROWSER_TIMEOUT.long}) - await browserPage.waitForTimeout(BROWSER_TIMEOUT.medium) - - // Handle confirmation dialog — may need to type "DELETE" - const confirmInput = browserPage.locator('input[type="text"]').last() - if (await confirmInput.isVisible({timeout: BROWSER_TIMEOUT.medium}).catch(() => false)) { - await confirmInput.fill('DELETE') - await browserPage.waitForTimeout(BROWSER_TIMEOUT.short) + await deleteAppBtn.click({timeout: BROWSER_TIMEOUT.long}) + await page.waitForTimeout(BROWSER_TIMEOUT.medium) + + // Step 3: Click confirm "Delete app" in the modal (retry step 2+3 if not visible) + // The dev dashboard modal has a submit button with class "critical" inside a form + const confirmAppBtn = page.locator('button.critical[type="submit"]') + for (let attempt = 1; attempt <= 3; attempt++) { + if (await confirmAppBtn.isVisible({timeout: BROWSER_TIMEOUT.medium}).catch(() => false)) break + if (attempt === 3) break + // Retry: re-click the delete button to reopen modal + await page.keyboard.press('Escape') + await page.waitForTimeout(BROWSER_TIMEOUT.short) + await deleteAppBtn.click({timeout: BROWSER_TIMEOUT.long}) + await page.waitForTimeout(BROWSER_TIMEOUT.medium) } - const confirmButton = browserPage.locator('button:has-text("Delete app")').last() - await confirmButton.click() - await browserPage.waitForTimeout(BROWSER_TIMEOUT.medium) -} + const urlBefore = page.url() + await confirmAppBtn.click({timeout: BROWSER_TIMEOUT.long}) -/** Best-effort teardown: find app on dashboard by name, uninstall from all stores, delete. */ -export async function teardownApp( - ctx: BrowserContext & { - appName: string - email?: string - orgId?: string - }, -): Promise { + // Wait for page to navigate away after deletion try { - await navigateToDashboard(ctx) - const apps = await findAppsOnDashboard({browserPage: ctx.browserPage, namePattern: ctx.appName}) - for (const app of apps) { - try { - await uninstallApp({browserPage: ctx.browserPage, appUrl: app.url, appName: app.name, orgId: ctx.orgId}) - await deleteApp({browserPage: ctx.browserPage, appUrl: app.url}) - // eslint-disable-next-line no-catch-all/no-catch-all - } catch (err) { - // Best-effort per app — continue teardown of remaining apps - if (process.env.DEBUG === '1') { - const msg = err instanceof Error ? err.message : String(err) - process.stderr.write(`[e2e] Teardown failed for app ${app.name}: ${msg}\n`) - } - } - } + await page.waitForURL((url) => url.toString() !== urlBefore, {timeout: BROWSER_TIMEOUT.max}) // eslint-disable-next-line no-catch-all/no-catch-all - } catch (err) { - // Best-effort — don't fail the test if teardown fails - if (process.env.DEBUG === '1') { - const msg = err instanceof Error ? err.message : String(err) - process.stderr.write(`[e2e] Teardown failed for ${ctx.appName}: ${msg}\n`) - } + } catch (_err) { + await page.waitForTimeout(BROWSER_TIMEOUT.medium) } + return page.url() !== urlBefore } // --------------------------------------------------------------------------- diff --git a/packages/e2e/setup/cli.ts b/packages/e2e/setup/cli.ts index a781c0233f..9de2ab9357 100644 --- a/packages/e2e/setup/cli.ts +++ b/packages/e2e/setup/cli.ts @@ -1,6 +1,5 @@ -/* eslint-disable no-console */ import {CLI_TIMEOUT} from './constants.js' -import {envFixture, executables} from './env.js' +import {createLogger, envFixture, executables} from './env.js' import {stripAnsi} from '../helpers/strip-ansi.js' import {execa, type Options as ExecaOptions} from 'execa' import type {E2EEnv} from './env.js' @@ -45,6 +44,7 @@ export interface CLIProcess { export const cliFixture = envFixture.extend<{cli: CLIProcess}>({ cli: async ({env}, use) => { const spawnedProcesses: SpawnedProcess[] = [] + const cliLog = createLogger('cli') const cli: CLIProcess = { async exec(args, opts = {}) { @@ -56,9 +56,7 @@ export const cliFixture = envFixture.extend<{cli: CLIProcess}>({ reject: false, } - if (process.env.DEBUG === '1') { - console.log(`[e2e] exec: node ${executables.cli} ${args.join(' ')}`) - } + cliLog.log(env, `exec: node ${executables.cli} ${args.join(' ')}`) const result = await execa('node', [executables.cli, ...args], execaOpts) @@ -78,9 +76,7 @@ export const cliFixture = envFixture.extend<{cli: CLIProcess}>({ reject: false, } - if (process.env.DEBUG === '1') { - console.log(`[e2e] exec: node ${executables.createApp} ${args.join(' ')}`) - } + cliLog.log(env, `exec: node ${executables.createApp} ${args.join(' ')}`) const result = await execa('node', [executables.createApp, ...args], execaOpts) @@ -102,9 +98,7 @@ export const cliFixture = envFixture.extend<{cli: CLIProcess}>({ } } - if (process.env.DEBUG === '1') { - console.log(`[e2e] spawn: node ${executables.cli} ${args.join(' ')}`) - } + cliLog.log(env, `spawn: node ${executables.cli} ${args.join(' ')}`) const ptyProcess = nodePty.spawn('node', [executables.cli, ...args], { name: 'xterm-color', diff --git a/packages/e2e/setup/env.ts b/packages/e2e/setup/env.ts index 0d862a8299..34c4e288ce 100644 --- a/packages/e2e/setup/env.ts +++ b/packages/e2e/setup/env.ts @@ -16,6 +16,50 @@ export interface E2EEnv { processEnv: NodeJS.ProcessEnv /** Temporary directory root for this worker */ tempDir: string + /** Playwright worker index (0-based) for debug logging */ + workerIndex: number +} + +/** Worker context for logging */ +interface LogCtx { + workerIndex: number +} + +/** + * Create a tagged logger for a module. + * Usage: `const log = createLogger('browser')` → `[e2e][w0][browser] message` + */ +export function createLogger(tag: string) { + return { + log(ctx: LogCtx, msg: string): void { + if (process.env.DEBUG === '1') { + process.stdout.write(`[e2e][w${ctx.workerIndex}][${tag}] ${msg}\n`) + } + }, + error(ctx: LogCtx, msg: string): void { + if (process.env.DEBUG === '1') { + process.stderr.write(`[e2e][w${ctx.workerIndex}][${tag}] ${msg}\n`) + } + }, + } +} + +/** + * Log a section header: `[e2e][w0] ----- Setup: store e2e-w0-123 -----` + */ +export function e2eSection(ctx: LogCtx, msg: string): void { + if (process.env.DEBUG === '1') { + process.stdout.write(`\n[e2e][w${ctx.workerIndex}] ----- ${msg} ----- \n`) + } +} + +/** + * Log without worker context (for global setup before workers start). + */ +export function globalLog(tag: string, msg: string): void { + if (process.env.DEBUG === '1') { + process.stdout.write(`[e2e][${tag}] ${msg}\n`) + } } export const directories = { @@ -72,21 +116,22 @@ export function requireEnv(env: E2EEnv, ...keys: (keyof Pick({ +export const envFixture = base.extend<{testSection: void}, {env: E2EEnv}>({ + // Auto-log TEST section header for every test + testSection: [ + async ({env}, use, testInfo) => { + e2eSection(env, `TEST: ${testInfo.title}`) + await use() + }, + {auto: true}, + ], env: [ // eslint-disable-next-line no-empty-pattern - async ({}, use) => { + async ({}, use, workerInfo) => { const storeFqdn = process.env.E2E_STORE_FQDN ?? '' const orgId = process.env.E2E_ORG_ID ?? '' @@ -114,6 +159,7 @@ export const envFixture = base.extend<{}, {env: E2EEnv}>({ orgId, processEnv, tempDir, + workerIndex: workerInfo.parallelIndex, } await use(env) diff --git a/packages/e2e/setup/store.ts b/packages/e2e/setup/store.ts new file mode 100644 index 0000000000..64750c6244 --- /dev/null +++ b/packages/e2e/setup/store.ts @@ -0,0 +1,307 @@ +/* eslint-disable no-await-in-loop */ +import {appTestFixture} from './app.js' +import {BROWSER_TIMEOUT} from './constants.js' +import {createLogger, e2eSection} from './env.js' +import * as fs from 'fs' +import type {BrowserContext} from './browser.js' +import type {Page} from '@playwright/test' + +const log = createLogger('browser') + +// --------------------------------------------------------------------------- +// Dev store management — create and delete stores via browser automation +// --------------------------------------------------------------------------- + +/** Generate a unique store name for a worker. */ +export function generateStoreName(workerIndex: number): string { + return `e2e-w${workerIndex}-${Date.now()}` +} + +interface WorkerCtx { + workerIndex: number +} + +/** + * Create a dev store via the admin store creation form. + * Returns the store FQDN (e.g., "e2e-w0-1712345678.myshopify.com"). + */ +export async function createDevStore( + ctx: BrowserContext & + WorkerCtx & { + storeName: string + email?: string + orgId?: string + }, +): Promise { + const {browserPage} = ctx + const orgId = ctx.orgId ?? (process.env.E2E_ORG_ID ?? '').trim() + + e2eSection(ctx, `Setup: store ${ctx.storeName}`) + log.log(ctx, 'store creating') + + // Navigate directly to the store creation form on admin.shopify.com + const email = ctx.email ?? process.env.E2E_ACCOUNT_EMAIL + await browserPage.goto(`https://admin.shopify.com/store-create/organization/${orgId}`, { + waitUntil: 'domcontentloaded', + }) + await browserPage.waitForTimeout(BROWSER_TIMEOUT.medium) + + // Handle login redirect — reload storageState and retry if needed + if (browserPage.url().includes('accounts.shopify.com')) { + log.log(ctx, 'redirected to login, reloading session') + + const storageStatePath = process.env.E2E_BROWSER_STATE_PATH + if (storageStatePath) { + const state = JSON.parse(fs.readFileSync(storageStatePath, 'utf8')) + await browserPage.context().addCookies(state.cookies) + } + + await browserPage.goto(`https://admin.shopify.com/store-create/organization/${orgId}`, { + waitUntil: 'domcontentloaded', + }) + await browserPage.waitForTimeout(BROWSER_TIMEOUT.medium) + + if (browserPage.url().includes('accounts.shopify.com') && email) { + const accountButton = browserPage.locator(`text=${email}`).first() + if (await accountButton.isVisible({timeout: BROWSER_TIMEOUT.long}).catch(() => false)) { + await accountButton.click() + await browserPage.waitForTimeout(BROWSER_TIMEOUT.medium) + } + } + } + + // Wait for the store creation form to load — retry if page didn't render + const nameInput = browserPage.locator('s-internal-text-field[label="Store name"]').locator('input') + for (let attempt = 1; attempt <= 3; attempt++) { + if (await nameInput.isVisible({timeout: BROWSER_TIMEOUT.max}).catch(() => false)) break + + log.log(ctx, `store form not loaded (attempt ${attempt}/3), url=${browserPage.url()}`) + await browserPage.goto(`https://admin.shopify.com/store-create/organization/${orgId}`, { + waitUntil: 'domcontentloaded', + }) + await browserPage.waitForTimeout(BROWSER_TIMEOUT.long) + } + + // Fill store name and select plan (inputs are inside shadow DOM) + const plans = [ + 'BASIC_APP_DEVELOPMENT', + 'PROFESSIONAL_APP_DEVELOPMENT', + 'UNLIMITED_APP_DEVELOPMENT', + 'SHOPIFY_PLUS_APP_DEVELOPMENT', + ] + const plan = plans[Date.now() % plans.length]! + log.log(ctx, `store plan=${plan}`) + + // Fill store name — chained locator pierces shadow DOM (pattern from admin-web E2E) + await nameInput.click({timeout: BROWSER_TIMEOUT.max}) + await nameInput.fill('') + await nameInput.type(ctx.storeName) + await browserPage.waitForTimeout(BROWSER_TIMEOUT.short) + + // Select plan — chained locator into shadow DOM select + const planSelect = browserPage.locator('s-internal-select[label="Shopify plan"]').locator('select') + await planSelect.selectOption(plan) + await browserPage.waitForTimeout(BROWSER_TIMEOUT.short) + + // Click "Create store" + const createButton = browserPage.locator('s-internal-button[variant="primary"]').locator('button') + await createButton.click() + + // Wait for redirect to store admin (provisioning can be slow) + await browserPage.waitForURL(/admin\.shopify\.com\/store\/(?!store-create)/, {timeout: BROWSER_TIMEOUT.max}) + + // Extract store slug from URL: https://admin.shopify.com/store/{slug} + const slugMatch = browserPage.url().match(/admin\.shopify\.com\/store\/([^/]+)/) + if (!slugMatch?.[1]) { + throw new Error(`Could not extract store slug from URL: ${browserPage.url()}`) + } + + const storeFqdn = `${slugMatch[1]}.myshopify.com` + log.log(ctx, `store created ${storeFqdn}`) + return storeFqdn +} + +// --------------------------------------------------------------------------- +// Store admin browser actions — uninstall apps and delete stores +// --------------------------------------------------------------------------- + +/** + * Uninstall an app from a store's admin settings page. + * Navigates to /settings/apps, finds the app by name, uninstalls it, and verifies removal. + * Returns true if app is confirmed gone, false if still present. + */ +export async function uninstallAppFromStoreAdmin(page: Page, storeSlug: string, appName: string): Promise { + await page.goto(`https://admin.shopify.com/store/${storeSlug}/settings/apps`, { + waitUntil: 'domcontentloaded', + }) + await page.waitForTimeout(BROWSER_TIMEOUT.long) + + // Dismiss any Dev Console dialog + const cancelBtn = page.locator('button:has-text("Cancel")') + if (await cancelBtn.isVisible({timeout: BROWSER_TIMEOUT.medium}).catch(() => false)) { + await cancelBtn.click() + await page.waitForTimeout(BROWSER_TIMEOUT.short) + } + + // Check if already uninstalled + if (await isAppsPageEmpty(page)) return true + + const appSpan = page.locator(`span:has-text("${appName}"):not([class*="Polaris"])`).first() + if (!(await appSpan.isVisible({timeout: BROWSER_TIMEOUT.long}).catch(() => false))) return true + + // Click ⋯ menu → Uninstall → Confirm + await appSpan.locator('xpath=./following::button[1]').click() + await page.waitForTimeout(BROWSER_TIMEOUT.short) + + const uninstallOpt = page.locator('text=Uninstall').last() + if (!(await uninstallOpt.isVisible({timeout: BROWSER_TIMEOUT.medium}).catch(() => false))) return false + await uninstallOpt.click() + await page.waitForTimeout(BROWSER_TIMEOUT.medium) + + const confirmBtn = page.locator('button:has-text("Uninstall"), button:has-text("Confirm")').last() + if (await confirmBtn.isVisible({timeout: BROWSER_TIMEOUT.medium}).catch(() => false)) { + await confirmBtn.click() + await page.waitForTimeout(BROWSER_TIMEOUT.medium) + } + + // Verify: reload and check app is gone + await page.reload({waitUntil: 'domcontentloaded'}) + await page.waitForTimeout(BROWSER_TIMEOUT.long) + + if (await isAppsPageEmpty(page)) return true + + // App name no longer visible = success even if other apps remain + const stillVisible = await page + .locator(`span:has-text("${appName}"):not([class*="Polaris"])`) + .first() + .isVisible({timeout: BROWSER_TIMEOUT.medium}) + .catch(() => false) + return !stillVisible +} + +/** Check if the store apps page shows the empty state (zero apps installed). */ +async function isAppsPageEmpty(page: Page): Promise { + // "Add apps to your store" empty state is the definitive zero-apps signal + const emptyState = page.locator('text=Add apps to your store') + if (await emptyState.isVisible({timeout: BROWSER_TIMEOUT.medium}).catch(() => false)) return true + + // Fallback: no "More actions" menu buttons in the app list + const menuButtons = await page.locator('.Polaris-Layout__Section button[aria-label="More actions"]').all() + return menuButtons.length === 0 +} + +/** + * Delete a store from the admin settings plan page. + * Verifies no apps are installed first — refuses to delete if apps remain. + * Returns true if deleted, false if skipped. + */ +export async function deleteStoreFromAdmin(page: Page, storeSlug: string): Promise { + // Verify no apps are installed before deleting + await page.goto(`https://admin.shopify.com/store/${storeSlug}/settings/apps`, { + waitUntil: 'domcontentloaded', + }) + await page.waitForTimeout(BROWSER_TIMEOUT.long) + + const cancelBtn = page.locator('button:has-text("Cancel")') + if (await cancelBtn.isVisible({timeout: BROWSER_TIMEOUT.medium}).catch(() => false)) { + await cancelBtn.click() + await page.waitForTimeout(BROWSER_TIMEOUT.short) + } + + if (!(await isAppsPageEmpty(page))) { + // Apps still installed — refuse to delete + return false + } + + // Delete the store + // Step 1: Navigate to plan page and click delete button to open modal (retry navigation on failure) + const planUrl = `https://admin.shopify.com/store/${storeSlug}/settings/plan` + const deleteButton = page.locator('s-internal-button[tone="critical"]').locator('button') + + for (let attempt = 1; attempt <= 3; attempt++) { + try { + await page.goto(planUrl, {waitUntil: 'domcontentloaded'}) + await page.waitForTimeout(BROWSER_TIMEOUT.long) + await deleteButton.click({timeout: BROWSER_TIMEOUT.long}) + break + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (_err) { + if (attempt === 3) return false + await page.waitForTimeout(BROWSER_TIMEOUT.medium) + } + } + await page.waitForTimeout(BROWSER_TIMEOUT.medium) + + const modal = page.locator('.Polaris-Modal-Dialog__Modal') + + // Step 2: Check the confirmation checkbox (retry step 1+2 if fails) + for (let attempt = 1; attempt <= 3; attempt++) { + const checkbox = modal.locator('input[type="checkbox"]') + if (await checkbox.isVisible({timeout: BROWSER_TIMEOUT.medium}).catch(() => false)) { + await checkbox.check({force: true}) + await page.waitForTimeout(BROWSER_TIMEOUT.short) + break + } + if (attempt === 3) return false + // Retry: close modal and re-click delete + await page.keyboard.press('Escape') + await page.waitForTimeout(BROWSER_TIMEOUT.short) + await deleteButton.click({timeout: BROWSER_TIMEOUT.max}) + await page.waitForTimeout(BROWSER_TIMEOUT.medium) + } + + // Step 3: Click confirm (retry step 2+3 if button is still disabled) + const confirmButton = modal.locator('button:has-text("Delete store")') + for (let attempt = 1; attempt <= 3; attempt++) { + const isDisabled = await confirmButton + .evaluate((el) => el.getAttribute('aria-disabled') === 'true' || el.hasAttribute('disabled')) + .catch(() => true) + if (!isDisabled) break + if (attempt === 3) return false + // Retry: re-check the checkbox + const checkbox = modal.locator('input[type="checkbox"]') + await checkbox.check({force: true}) + await page.waitForTimeout(BROWSER_TIMEOUT.short) + } + + await confirmButton.click({force: true}) + await page.waitForURL(/access_account/, {timeout: BROWSER_TIMEOUT.max}) + + // Verify: "Your plan was canceled" confirms the store is deleted + const canceled = page.locator('text=Your plan was canceled') + const verified = await canceled.isVisible({timeout: BROWSER_TIMEOUT.long}).catch(() => false) + return verified || page.url().includes('access_account') +} + +// --------------------------------------------------------------------------- +// Fixture — per-test dev store for tests that need `app dev` +// --------------------------------------------------------------------------- + +/** + * Test-scoped fixture that creates a fresh dev store per test. + * + * Each test gets its own isolated store — no shared state between tests. + * Store + app cleanup is handled by teardownAll() in the test's finally block. + * + * Fixture chain: envFixture → cliFixture → browserFixture → authFixture → appTestFixture → storeTestFixture + * + * Tests that need a dev store (app dev, hot reload, multi-config dev) use this fixture. + * Tests that don't (scaffold, deploy, commands, smoke) stay on appTestFixture. + */ +export const storeTestFixture = appTestFixture.extend<{storeFqdn: string}>({ + storeFqdn: async ({browserPage, env}, use) => { + const wi = env.workerIndex + + // Unique ports per worker to avoid EADDRINUSE when running in parallel + const portBase = 3457 + wi * 10 + env.processEnv.SHOPIFY_FLAG_GRAPHIQL_PORT = String(portBase) + env.processEnv.SHOPIFY_FLAG_THEME_APP_EXTENSION_PORT = String(portBase + 2) + + const storeName = generateStoreName(wi) + const fqdn = await createDevStore({browserPage, workerIndex: wi, storeName, orgId: env.orgId}) + + env.processEnv.SHOPIFY_FLAG_STORE = fqdn // eslint-disable-line require-atomic-updates + + await use(fqdn) + }, +}) diff --git a/packages/e2e/setup/teardown.ts b/packages/e2e/setup/teardown.ts new file mode 100644 index 0000000000..48c054d532 --- /dev/null +++ b/packages/e2e/setup/teardown.ts @@ -0,0 +1,141 @@ +/* eslint-disable no-await-in-loop */ +import {findAppOnDevDashboard, deleteAppFromDevDashboard} from './app.js' +import {BROWSER_TIMEOUT} from './constants.js' +import {createLogger, e2eSection} from './env.js' +import {uninstallAppFromStoreAdmin, deleteStoreFromAdmin} from './store.js' +import type {Page} from '@playwright/test' + +const log = createLogger('browser') + +interface TeardownCtx { + browserPage: Page + appName: string + orgId?: string + workerIndex?: number + /** If set, uninstalls app from store + deletes store before deleting the app */ + storeFqdn?: string +} + +/** + * Best-effort per-test teardown with escalating retry. + * + * Each phase is independent — failure never prevents later phases. + * Escalation: retry same step → go back one step and retry both. + * + * App + store flow: + * Phase 1: uninstall app from store admin + * Phase 2: delete store (escalates to phase 1 on failure) + * Phase 3: delete app from dev dashboard (always runs) + * + * App-only flow: + * Phase 3 only + */ +export async function teardownAll(ctx: TeardownCtx): Promise { + const wCtx = {workerIndex: ctx.workerIndex ?? 0} + const page = ctx.browserPage + + // Phase 1 + 2: Store cleanup (app+store tests only) + if (ctx.storeFqdn) { + const storeSlug = ctx.storeFqdn.replace('.myshopify.com', '') + e2eSection(wCtx, `Teardown: store ${ctx.storeFqdn}`) + + // Phase 1: Uninstall app from store + let uninstalled = false + try { + log.log(wCtx, 'uninstalling app from store') + for (let attempt = 1; attempt <= 3; attempt++) { + try { + uninstalled = await uninstallAppFromStoreAdmin(page, storeSlug, ctx.appName) + if (uninstalled) { + log.log(wCtx, 'app uninstalled') + break + } + log.log(wCtx, `uninstall not verified (${attempt}/3)`) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (_err) { + log.log(wCtx, `uninstall failed (${attempt}/3)`) + } + if (attempt < 3) { + await page.goto('about:blank').catch(() => {}) + await page.waitForTimeout(BROWSER_TIMEOUT.short) + } + } + if (!uninstalled) log.error(wCtx, 'uninstall failed after 3 attempts') + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (err) { + log.error(wCtx, `uninstall phase error: ${err instanceof Error ? err.message : err}`) + } + + // Phase 2: Delete store (escalates to phase 1 on failure) + try { + log.log(wCtx, 'deleting store') + let storeDeleted = false + for (let attempt = 1; attempt <= 3; attempt++) { + try { + const deleted = await deleteStoreFromAdmin(page, storeSlug) + if (deleted) { + log.log(wCtx, 'store deleted') + storeDeleted = true + break + } + // deleteStoreFromAdmin returns false = apps still installed + log.log(wCtx, `store has apps, escalating to re-uninstall (${attempt}/3)`) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (_err) { + log.log(wCtx, `store deletion failed (${attempt}/3)`) + } + if (attempt < 3) { + // Escalate: go back to phase 1 (re-uninstall) then retry phase 2 + await page.goto('about:blank').catch(() => {}) + await page.waitForTimeout(BROWSER_TIMEOUT.short) + try { + await uninstallAppFromStoreAdmin(page, storeSlug, ctx.appName) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (_err) { + // best effort + } + } + } + if (!storeDeleted) log.error(wCtx, 'store deletion failed after 3 attempts') + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (err) { + log.error(wCtx, `store delete phase error: ${err instanceof Error ? err.message : err}`) + } + } + + // Phase 3: Delete app from dev dashboard — ALWAYS runs + try { + e2eSection(wCtx, `Teardown: app ${ctx.appName}`) + log.log(wCtx, 'deleting app') + let appDeleted = false + for (let attempt = 1; attempt <= 3; attempt++) { + try { + const appUrl = await findAppOnDevDashboard(page, ctx.appName, ctx.orgId) + if (!appUrl) { + // App not on dashboard = already deleted (possibly by a previous attempt) + log.log(wCtx, 'app deleted') + appDeleted = true + break + } + const deleted = await deleteAppFromDevDashboard(page, appUrl) + if (deleted) { + log.log(wCtx, 'app deleted') + appDeleted = true + break + } + log.log(wCtx, `app delete pending, retrying (${attempt}/3)`) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (_err) { + log.log(wCtx, `app deletion failed (${attempt}/3)`) + } + if (attempt < 3) { + await page.goto('about:blank').catch(() => {}) + await page.waitForTimeout(BROWSER_TIMEOUT.short) + } + } + if (!appDeleted) log.error(wCtx, 'app deletion failed after 3 attempts') + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (err) { + log.error(wCtx, `teardown: failed for ${ctx.appName}: ${err instanceof Error ? err.message : err}`) + } +} diff --git a/packages/e2e/tests/app-deploy.spec.ts b/packages/e2e/tests/app-deploy.spec.ts index 343d16403c..33e8864f8d 100644 --- a/packages/e2e/tests/app-deploy.spec.ts +++ b/packages/e2e/tests/app-deploy.spec.ts @@ -1,4 +1,5 @@ -import {appTestFixture as test, createApp, deployApp, versionsList, teardownApp} from '../setup/app.js' +import {appTestFixture as test, createApp, deployApp, versionsList} from '../setup/app.js' +import {teardownAll} from '../setup/teardown.js' import {TEST_TIMEOUT} from '../setup/constants.js' import {requireEnv} from '../setup/env.js' import {expect} from '@playwright/test' @@ -40,8 +41,16 @@ test.describe('App deploy', () => { expect(listResult.exitCode, `versions list failed:\n${listOutput}`).toBe(0) expect(listOutput).toContain(versionTag) } finally { - fs.rmSync(parentDir, {recursive: true, force: true}) - await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + // E2E_SKIP_CLEANUP=1 skips cleanup for debugging. Run `pnpm test:e2e-cleanup` afterward. + if (!process.env.E2E_SKIP_CLEANUP) { + fs.rmSync(parentDir, {recursive: true, force: true}) + await teardownAll({ + browserPage, + appName, + orgId: env.orgId, + workerIndex: env.workerIndex, + }) + } } }) }) diff --git a/packages/e2e/tests/app-dev-server.spec.ts b/packages/e2e/tests/app-dev-server.spec.ts index 158750c41c..da57e43e12 100644 --- a/packages/e2e/tests/app-dev-server.spec.ts +++ b/packages/e2e/tests/app-dev-server.spec.ts @@ -1,14 +1,16 @@ -import {appTestFixture as test, createApp, teardownApp} from '../setup/app.js' +import {createApp} from '../setup/app.js' +import {teardownAll} from '../setup/teardown.js' import {CLI_TIMEOUT, TEST_TIMEOUT} from '../setup/constants.js' import {requireEnv} from '../setup/env.js' +import {storeTestFixture as test} from '../setup/store.js' import {expect} from '@playwright/test' import * as fs from 'fs' import * as path from 'path' // eslint-disable-line no-restricted-imports test.describe('App dev server', () => { - test('dev starts, shows ready message, and quits with q', async ({cli, env, browserPage}) => { + test('dev starts, shows ready message, and quits with q', async ({cli, env, browserPage, storeFqdn}) => { test.setTimeout(TEST_TIMEOUT.long) - requireEnv(env, 'orgId', 'storeFqdn') + requireEnv(env, 'orgId') const parentDir = fs.mkdtempSync(path.join(env.tempDir, 'app-')) const appName = `E2E-dev-${Date.now()}` @@ -29,9 +31,10 @@ test.describe('App dev server', () => { ).toBe(0) const appDir = initResult.appDir - // Step 2: Start dev server via PTY - // Unset CI so keyboard shortcuts are enabled in the Dev UI - const dev = await cli.spawn(['app', 'dev', '--path', appDir], {env: {CI: ''}}) + // Step 2: Start dev server via PTY, targeting the worker's store + const dev = await cli.spawn(['app', 'dev', '--path', appDir], { + env: {CI: '', SHOPIFY_FLAG_STORE: storeFqdn}, + }) // Step 3: Wait for the ready message await dev.waitForOutput('Ready, watching for changes in your app', CLI_TIMEOUT.medium) @@ -47,8 +50,17 @@ test.describe('App dev server', () => { const exitCode = await dev.waitForExit(CLI_TIMEOUT.short) expect(exitCode, `dev exited with non-zero code. Output:\n${dev.getOutput()}`).toBe(0) } finally { - fs.rmSync(parentDir, {recursive: true, force: true}) - await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + // E2E_SKIP_CLEANUP=1 skips cleanup for debugging. Run `pnpm test:e2e-cleanup` afterward. + if (!process.env.E2E_SKIP_CLEANUP) { + fs.rmSync(parentDir, {recursive: true, force: true}) + await teardownAll({ + browserPage, + appName, + orgId: env.orgId, + storeFqdn, + workerIndex: env.workerIndex, + }) + } } }) }) diff --git a/packages/e2e/tests/app-scaffold.spec.ts b/packages/e2e/tests/app-scaffold.spec.ts index fa630a9be8..21588cb89b 100644 --- a/packages/e2e/tests/app-scaffold.spec.ts +++ b/packages/e2e/tests/app-scaffold.spec.ts @@ -1,5 +1,6 @@ /* eslint-disable no-restricted-imports */ -import {appTestFixture as test, createApp, buildApp, generateExtension, teardownApp} from '../setup/app.js' +import {appTestFixture as test, createApp, buildApp, generateExtension} from '../setup/app.js' +import {teardownAll} from '../setup/teardown.js' import {TEST_TIMEOUT} from '../setup/constants.js' import {requireEnv} from '../setup/env.js' import {expect} from '@playwright/test' @@ -42,8 +43,16 @@ test.describe('App scaffold', () => { const buildResult = await buildApp({cli, appDir}) expect(buildResult.exitCode, `build failed:\nstderr: ${buildResult.stderr}`).toBe(0) } finally { - fs.rmSync(parentDir, {recursive: true, force: true}) - await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + // E2E_SKIP_CLEANUP=1 skips cleanup for debugging. Run `pnpm test:e2e-cleanup` afterward. + if (!process.env.E2E_SKIP_CLEANUP) { + fs.rmSync(parentDir, {recursive: true, force: true}) + await teardownAll({ + browserPage, + appName, + orgId: env.orgId, + workerIndex: env.workerIndex, + }) + } } }) @@ -70,8 +79,16 @@ test.describe('App scaffold', () => { expect(fs.existsSync(initResult.appDir)).toBe(true) expect(fs.existsSync(path.join(initResult.appDir, 'shopify.app.toml'))).toBe(true) } finally { - fs.rmSync(parentDir, {recursive: true, force: true}) - await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + // E2E_SKIP_CLEANUP=1 skips cleanup for debugging. Run `pnpm test:e2e-cleanup` afterward. + if (!process.env.E2E_SKIP_CLEANUP) { + fs.rmSync(parentDir, {recursive: true, force: true}) + await teardownAll({ + browserPage, + appName, + orgId: env.orgId, + workerIndex: env.workerIndex, + }) + } } }) @@ -115,8 +132,16 @@ test.describe('App scaffold', () => { const buildResult = await buildApp({cli, appDir}) expect(buildResult.exitCode, `build failed:\nstderr: ${buildResult.stderr}`).toBe(0) } finally { - fs.rmSync(parentDir, {recursive: true, force: true}) - await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + // E2E_SKIP_CLEANUP=1 skips cleanup for debugging. Run `pnpm test:e2e-cleanup` afterward. + if (!process.env.E2E_SKIP_CLEANUP) { + fs.rmSync(parentDir, {recursive: true, force: true}) + await teardownAll({ + browserPage, + appName, + orgId: env.orgId, + workerIndex: env.workerIndex, + }) + } } }) }) diff --git a/packages/e2e/tests/dev-hot-reload.spec.ts b/packages/e2e/tests/dev-hot-reload.spec.ts index e53a2a0484..c3f53de191 100644 --- a/packages/e2e/tests/dev-hot-reload.spec.ts +++ b/packages/e2e/tests/dev-hot-reload.spec.ts @@ -1,8 +1,10 @@ /* eslint-disable no-console */ /* eslint-disable no-restricted-imports */ -import {appTestFixture as test, createApp, injectFixtureToml, teardownApp} from '../setup/app.js' +import {createApp, injectFixtureToml} from '../setup/app.js' +import {teardownAll} from '../setup/teardown.js' import {CLI_TIMEOUT, TEST_TIMEOUT} from '../setup/constants.js' import {requireEnv} from '../setup/env.js' +import {storeTestFixture as test} from '../setup/store.js' import {updateTomlValues} from '@shopify/toml-patch' import {expect} from '@playwright/test' import * as fs from 'fs' @@ -37,9 +39,9 @@ description = "E2E test trigger" } test.describe('Dev hot reload', () => { - test('editing app config TOML triggers reload', async ({cli, env, browserPage}) => { + test('editing app config TOML triggers reload', async ({cli, env, browserPage, storeFqdn}) => { test.setTimeout(TEST_TIMEOUT.long) - requireEnv(env, 'orgId', 'storeFqdn') + requireEnv(env, 'orgId') const parentDir = fs.mkdtempSync(path.join(env.tempDir, 'app-')) const appName = `E2E-hot-reload-${Date.now()}` @@ -52,7 +54,7 @@ test.describe('Dev hot reload', () => { injectFixtureToml(appDir, FIXTURE_TOML, appName) const proc = await cli.spawn(['app', 'dev', '--path', appDir, '--skip-dependencies-installation'], { - env: {CI: ''}, + env: {CI: '', SHOPIFY_FLAG_STORE: storeFqdn}, }) try { @@ -81,14 +83,23 @@ test.describe('Dev hot reload', () => { proc.kill() } } finally { - fs.rmSync(parentDir, {recursive: true, force: true}) - await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + // E2E_SKIP_CLEANUP=1 skips cleanup for debugging. Run `pnpm test:e2e-cleanup` afterward. + if (!process.env.E2E_SKIP_CLEANUP) { + fs.rmSync(parentDir, {recursive: true, force: true}) + await teardownAll({ + browserPage, + appName, + orgId: env.orgId, + storeFqdn, + workerIndex: env.workerIndex, + }) + } } }) - test('creating a new extension mid-dev is detected', async ({cli, env, browserPage}) => { + test('creating a new extension mid-dev is detected', async ({cli, env, browserPage, storeFqdn}) => { test.setTimeout(TEST_TIMEOUT.long) - requireEnv(env, 'orgId', 'storeFqdn') + requireEnv(env, 'orgId') const parentDir = fs.mkdtempSync(path.join(env.tempDir, 'app-')) const appName = `E2E-hot-create-${Date.now()}` @@ -101,7 +112,7 @@ test.describe('Dev hot reload', () => { injectFixtureToml(appDir, FIXTURE_TOML, appName) const proc = await cli.spawn(['app', 'dev', '--path', appDir, '--skip-dependencies-installation'], { - env: {CI: ''}, + env: {CI: '', SHOPIFY_FLAG_STORE: storeFqdn}, }) try { @@ -124,14 +135,23 @@ test.describe('Dev hot reload', () => { proc.kill() } } finally { - fs.rmSync(parentDir, {recursive: true, force: true}) - await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + // E2E_SKIP_CLEANUP=1 skips cleanup for debugging. Run `pnpm test:e2e-cleanup` afterward. + if (!process.env.E2E_SKIP_CLEANUP) { + fs.rmSync(parentDir, {recursive: true, force: true}) + await teardownAll({ + browserPage, + appName, + orgId: env.orgId, + storeFqdn, + workerIndex: env.workerIndex, + }) + } } }) - test('deleting an extension mid-dev is detected', async ({cli, env, browserPage}) => { + test('deleting an extension mid-dev is detected', async ({cli, env, browserPage, storeFqdn}) => { test.setTimeout(TEST_TIMEOUT.long) - requireEnv(env, 'orgId', 'storeFqdn') + requireEnv(env, 'orgId') const parentDir = fs.mkdtempSync(path.join(env.tempDir, 'app-')) const appName = `E2E-hot-delete-${Date.now()}` @@ -144,7 +164,7 @@ test.describe('Dev hot reload', () => { injectFixtureToml(appDir, FIXTURE_TOML, appName) const proc = await cli.spawn(['app', 'dev', '--path', appDir, '--skip-dependencies-installation'], { - env: {CI: ''}, + env: {CI: '', SHOPIFY_FLAG_STORE: storeFqdn}, }) try { @@ -173,8 +193,17 @@ test.describe('Dev hot reload', () => { proc.kill() } } finally { - fs.rmSync(parentDir, {recursive: true, force: true}) - await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + // E2E_SKIP_CLEANUP=1 skips cleanup for debugging. Run `pnpm test:e2e-cleanup` afterward. + if (!process.env.E2E_SKIP_CLEANUP) { + fs.rmSync(parentDir, {recursive: true, force: true}) + await teardownAll({ + browserPage, + appName, + orgId: env.orgId, + storeFqdn, + workerIndex: env.workerIndex, + }) + } } }) }) diff --git a/packages/e2e/tests/multi-config-dev.spec.ts b/packages/e2e/tests/multi-config-dev.spec.ts index ceecdcb52b..ec0038353f 100644 --- a/packages/e2e/tests/multi-config-dev.spec.ts +++ b/packages/e2e/tests/multi-config-dev.spec.ts @@ -1,8 +1,10 @@ /* eslint-disable no-console */ /* eslint-disable no-restricted-imports */ -import {appTestFixture as test, createApp, extractClientId, injectFixtureToml, teardownApp} from '../setup/app.js' +import {createApp, extractClientId, injectFixtureToml} from '../setup/app.js' +import {teardownAll} from '../setup/teardown.js' import {CLI_TIMEOUT, TEST_TIMEOUT} from '../setup/constants.js' import {requireEnv} from '../setup/env.js' +import {storeTestFixture as test} from '../setup/store.js' import {expect} from '@playwright/test' import * as fs from 'fs' import * as path from 'path' @@ -12,9 +14,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)) const FIXTURE_TOML = fs.readFileSync(path.join(__dirname, '../data/valid-app/shopify.app.toml'), 'utf8') test.describe('Multi-config dev', () => { - test('dev with -c flag loads the named config', async ({cli, env, browserPage}) => { + test('dev with -c flag loads the named config', async ({cli, env, browserPage, storeFqdn}) => { test.setTimeout(TEST_TIMEOUT.long) - requireEnv(env, 'orgId', 'storeFqdn') + requireEnv(env, 'orgId') const parentDir = fs.mkdtempSync(path.join(env.tempDir, 'app-')) const appName = `E2E-multi-cfg-${Date.now()}` @@ -59,7 +61,7 @@ include_config_on_deploy = true // --config and --client-id are mutually exclusive. CLIENT_ID is stripped globally in env.ts. const proc = await cli.spawn( ['app', 'dev', '--path', appDir, '-c', 'staging', '--skip-dependencies-installation'], - {env: {CI: ''}}, + {env: {CI: '', SHOPIFY_FLAG_STORE: storeFqdn}}, ) try { @@ -81,14 +83,23 @@ include_config_on_deploy = true proc.kill() } } finally { - fs.rmSync(parentDir, {recursive: true, force: true}) - await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + // E2E_SKIP_CLEANUP=1 skips cleanup for debugging. Run `pnpm test:e2e-cleanup` afterward. + if (!process.env.E2E_SKIP_CLEANUP) { + fs.rmSync(parentDir, {recursive: true, force: true}) + await teardownAll({ + browserPage, + appName, + orgId: env.orgId, + storeFqdn, + workerIndex: env.workerIndex, + }) + } } }) - test('dev without -c flag uses default config', async ({cli, env, browserPage}) => { + test('dev without -c flag uses default config', async ({cli, env, browserPage, storeFqdn}) => { test.setTimeout(TEST_TIMEOUT.long) - requireEnv(env, 'orgId', 'storeFqdn') + requireEnv(env, 'orgId') const parentDir = fs.mkdtempSync(path.join(env.tempDir, 'app-')) const appName = `E2E-mcfg-def-${Date.now()}` @@ -122,7 +133,7 @@ api_version = "2025-01" // Start dev without -c flag — should use shopify.app.toml const proc = await cli.spawn(['app', 'dev', '--path', appDir, '--skip-dependencies-installation'], { - env: {CI: ''}, + env: {CI: '', SHOPIFY_FLAG_STORE: storeFqdn}, }) try { @@ -143,8 +154,17 @@ api_version = "2025-01" proc.kill() } } finally { - fs.rmSync(parentDir, {recursive: true, force: true}) - await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + // E2E_SKIP_CLEANUP=1 skips cleanup for debugging. Run `pnpm test:e2e-cleanup` afterward. + if (!process.env.E2E_SKIP_CLEANUP) { + fs.rmSync(parentDir, {recursive: true, force: true}) + await teardownAll({ + browserPage, + appName, + orgId: env.orgId, + storeFqdn, + workerIndex: env.workerIndex, + }) + } } }) }) diff --git a/packages/e2e/tests/toml-config-invalid.spec.ts b/packages/e2e/tests/toml-config-invalid.spec.ts index a6ecd3ef51..7d8cbbf05e 100644 --- a/packages/e2e/tests/toml-config-invalid.spec.ts +++ b/packages/e2e/tests/toml-config-invalid.spec.ts @@ -36,7 +36,10 @@ test.describe('TOML config invalid', () => { expect(result.exitCode, `expected deploy to fail for ${label}, but it succeeded:\n${output}`).not.toBe(0) expect(output.toLowerCase(), `expected error output for ${label}:\n${output}`).toMatch(/error|invalid|failed/) } finally { - fs.rmSync(appDir, {recursive: true, force: true}) + // E2E_SKIP_CLEANUP=1 skips cleanup for debugging. Run `pnpm test:e2e-cleanup` afterward. + if (!process.env.E2E_SKIP_CLEANUP) { + fs.rmSync(appDir, {recursive: true, force: true}) + } } }) } diff --git a/packages/e2e/tests/toml-config.spec.ts b/packages/e2e/tests/toml-config.spec.ts index fe36f07f46..f3ff4f854c 100644 --- a/packages/e2e/tests/toml-config.spec.ts +++ b/packages/e2e/tests/toml-config.spec.ts @@ -1,8 +1,10 @@ /* eslint-disable no-console */ /* eslint-disable no-restricted-imports */ -import {appTestFixture as test, createApp, injectFixtureToml, teardownApp} from '../setup/app.js' +import {createApp, injectFixtureToml} from '../setup/app.js' +import {teardownAll} from '../setup/teardown.js' import {CLI_TIMEOUT, TEST_TIMEOUT} from '../setup/constants.js' import {requireEnv} from '../setup/env.js' +import {storeTestFixture as test} from '../setup/store.js' import {expect} from '@playwright/test' import * as fs from 'fs' import * as path from 'path' @@ -33,14 +35,22 @@ test.describe('TOML config regression', () => { const output = result.stdout + result.stderr expect(result.exitCode, `deploy failed:\n${output}`).toBe(0) } finally { - fs.rmSync(parentDir, {recursive: true, force: true}) - await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + // E2E_SKIP_CLEANUP=1 skips cleanup for debugging. Run `pnpm test:e2e-cleanup` afterward. + if (!process.env.E2E_SKIP_CLEANUP) { + fs.rmSync(parentDir, {recursive: true, force: true}) + await teardownAll({ + browserPage, + appName, + orgId: env.orgId, + workerIndex: env.workerIndex, + }) + } } }) - test('dev starts with fully populated toml', async ({cli, env, browserPage}) => { + test('dev starts with fully populated toml', async ({cli, env, browserPage, storeFqdn}) => { test.setTimeout(TEST_TIMEOUT.long) - requireEnv(env, 'orgId', 'storeFqdn') + requireEnv(env, 'orgId') const parentDir = fs.mkdtempSync(path.join(env.tempDir, 'app-')) const appName = `E2E-toml-dev-${Date.now()}` @@ -52,7 +62,7 @@ test.describe('TOML config regression', () => { injectFixtureToml(appDir, FIXTURE_TOML, appName) - const proc = await cli.spawn(['app', 'dev', '--path', appDir], {env: {CI: ''}}) + const proc = await cli.spawn(['app', 'dev', '--path', appDir], {env: {CI: '', SHOPIFY_FLAG_STORE: storeFqdn}}) try { await proc.waitForOutput('Ready, watching for changes in your app', CLI_TIMEOUT.medium) @@ -67,8 +77,17 @@ test.describe('TOML config regression', () => { proc.kill() } } finally { - fs.rmSync(parentDir, {recursive: true, force: true}) - await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + // E2E_SKIP_CLEANUP=1 skips cleanup for debugging. Run `pnpm test:e2e-cleanup` afterward. + if (!process.env.E2E_SKIP_CLEANUP) { + fs.rmSync(parentDir, {recursive: true, force: true}) + await teardownAll({ + browserPage, + appName, + orgId: env.orgId, + storeFqdn, + workerIndex: env.workerIndex, + }) + } } }) })