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
37 changes: 27 additions & 10 deletions packages/e2e/scripts/cleanup-stores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@ 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'
import {deleteStore, dismissDevConsole, isStoreAppsEmpty} from '../setup/store.js'
import {BROWSER_TIMEOUT, CLI_TIMEOUT} from '../setup/constants.js'
import {deleteDevStoreWithCli, dismissDevConsole, isStoreAppsEmpty} from '../setup/store.js'
import {executables} from '../setup/env.js'
import {refreshIfPageError, trackMainFrameStatus} from '../setup/browser.js'
import {completeLogin} from '../helpers/browser-login.js'
import {addLoadtestHeader} from '../helpers/loadtest-header.js'
Expand All @@ -37,6 +38,8 @@ import {
import {businessPlatformOrganizationsRequestDoc} from '../../cli-kit/dist/public/node/api/business-platform.js'
import {ensureAuthenticatedBusinessPlatform} from '../../cli-kit/dist/public/node/session.js'
import {extractHost} from '../../cli-kit/dist/public/common/url.js'
import {execa} from 'execa'
import type {CLIProcess} from '../setup/cli.js'
import type {Page} from '@playwright/test'

// Load .env from packages/e2e/ (not cwd) only if not already configured
Expand Down Expand Up @@ -95,6 +98,22 @@ function existingStorageStatePath(candidate?: string): string | undefined {
)
}

const cleanupCli: Pick<CLIProcess, 'exec'> = {
async exec(args, opts = {}) {
const result = await execa('node', [executables.cli, ...args], {
cwd: opts.cwd,
env: {...process.env, ...opts.env},
timeout: opts.timeout ?? CLI_TIMEOUT.store,
reject: false,
})
return {
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
exitCode: result.exitCode ?? 1,
}
},
}

export async function cleanupStores(opts: CleanupStoresOptions = {}): Promise<void> {
const mode = opts.mode ?? 'full'
const pattern = opts.pattern ?? 'e2e-w'
Expand Down Expand Up @@ -219,21 +238,19 @@ export async function cleanupStores(opts: CleanupStoresOptions = {}): Promise<vo

if (safeToDelete) {
console.log(' Deleting store...')
let deleted = false
let deletionRequested = false
for (let attempt = 1; attempt <= 3; attempt++) {
try {
if (await deleteStore(page, storeSlug)) {
deleted = true
break
}
console.log(` (${attempt}/3) deletion failed`)
const deletionConfirmed = await deleteDevStoreWithCli({cli: cleanupCli, storeFqdn: store.fqdn, orgId})
console.log(deletionConfirmed ? ' Deletion confirmed by CLI' : ' Deletion requested with CLI')
deletionRequested = true
break
// eslint-disable-next-line no-catch-all/no-catch-all
} catch (err) {
console.log(` (${attempt}/3) deletion failed: ${err instanceof Error ? err.message : err}`)
}
}
if (deleted) {
console.log(' Deleted')
if (deletionRequested) {
succeeded++
} else {
console.warn(' Failed after 3 attempts')
Expand Down
2 changes: 2 additions & 0 deletions packages/e2e/setup/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export const CLI_TIMEOUT = {
medium: 3 * 60_000,
/** 5 min — slow commands (create app, scaffold) */
long: 5 * 60_000,
/** 6 min — store commands that poll for provisioning or deletion */
store: 6 * 60_000,
} as const

/** Browser interaction timeouts */
Expand Down
216 changes: 64 additions & 152 deletions packages/e2e/setup/store.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
/* eslint-disable no-await-in-loop */
import {appTestFixture} from './app.js'
import {isVisibleWithin} from './browser.js'
import {BROWSER_TIMEOUT} from './constants.js'
import {createLogger, e2eRunSegment, e2eSection} from './env.js'
import * as fs from 'fs'
import type {BrowserContext} from './browser.js'
import {BROWSER_TIMEOUT, CLI_TIMEOUT} from './constants.js'
import {createLogger, e2eRunSegment, e2eSection, requireEnv} from './env.js'
import type {CLIProcess, ExecResult} from './cli.js'
import type {Locator, Page} from '@playwright/test'

const log = createLogger('browser')
const log = createLogger('cli')

// ---------------------------------------------------------------------------
// Dev store provisioning — create new stores via browser automation
// Dev store provisioning
// ---------------------------------------------------------------------------

/** Generate a unique store name for a worker. */
Expand All @@ -23,104 +22,74 @@ 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<string> {
const {browserPage} = ctx
const orgId = ctx.orgId ?? (process.env.E2E_ORG_ID ?? '').trim()
interface StoreCommandJson {
store?: {
domain?: unknown
deletionRequested?: unknown
deletionConfirmed?: unknown
}
}

/** Create a development store with the CLI and return its FQDN. */
export async function createDevStoreWithCli(
ctx: WorkerCtx & {cli: CLIProcess; storeName: string; orgId: string},
): Promise<string> {
e2eSection(ctx, `Setup: store ${ctx.storeName}`)
log.log(ctx, 'store creating')
log.log(ctx, 'creating store')

// 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 result = await ctx.cli.exec(
['store', 'create', 'dev', '--name', ctx.storeName, '--organization-id', ctx.orgId, '--plan', 'basic', '--json'],
{timeout: CLI_TIMEOUT.store},
)
assertCommandSucceeded('create development store', result)

const storageStatePath = process.env.E2E_BROWSER_STATE_PATH
if (storageStatePath) {
const state = JSON.parse(fs.readFileSync(storageStatePath, 'utf8'))
await browserPage.context().addCookies(state.cookies)
}
const storeFqdn = parseStoreDomain(result, 'create development store')
log.log(ctx, `store created ${storeFqdn}`)
return storeFqdn
}

await browserPage.goto(`https://admin.shopify.com/store-create/organization/${orgId}`, {
waitUntil: 'domcontentloaded',
})
await browserPage.waitForTimeout(BROWSER_TIMEOUT.medium)
/** Request development store deletion with the CLI and report whether the CLI confirmed it. */
export async function deleteDevStoreWithCli(options: {
cli: Pick<CLIProcess, 'exec'>
storeFqdn: string
orgId: string
}): Promise<boolean> {
const result = await options.cli.exec(
['store', 'delete', '--store', options.storeFqdn, '--organization-id', options.orgId, '--force', '--json'],
{timeout: CLI_TIMEOUT.store},
)
assertCommandSucceeded('delete development store', result)

if (browserPage.url().includes('accounts.shopify.com') && email) {
const accountButton = browserPage.locator(`text=${email}`).first()
if (await isVisibleWithin(accountButton, BROWSER_TIMEOUT.long)) {
await accountButton.click()
await browserPage.waitForTimeout(BROWSER_TIMEOUT.medium)
}
}
const output = parseJsonOutput(result, 'delete development store')
if (output.store?.deletionRequested !== true) {
throw new Error(`CLI did not confirm the store deletion request:\n${result.stdout}`)
}
return output.store.deletionConfirmed === true
}

// 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 isVisibleWithin(nameInput, BROWSER_TIMEOUT.max)) 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)
function assertCommandSucceeded(action: string, result: ExecResult): void {
if (result.exitCode !== 0) {
throw new Error(
`Failed to ${action} (exit code ${result.exitCode}):\nstdout: ${result.stdout}\nstderr: ${result.stderr}`,
)
}
}

// 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()}`)
function parseStoreDomain(result: ExecResult, action: string): string {
const output = parseJsonOutput(result, action)
const domain = output.store?.domain
if (typeof domain !== 'string' || !domain.endsWith('.myshopify.com')) {
throw new Error(`CLI returned an invalid store domain:\n${result.stdout}`)
}
return domain
}

const storeFqdn = `${slugMatch[1]}.myshopify.com`
log.log(ctx, `store created ${storeFqdn}`)
return storeFqdn
function parseJsonOutput(result: ExecResult, action: string): StoreCommandJson {
try {
return JSON.parse(result.stdout) as StoreCommandJson
} catch {
throw new Error(`CLI returned invalid JSON while trying to ${action}:\n${result.stdout}`)
}
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -290,64 +259,6 @@ export async function isStoreAppsEmpty(page: Page): Promise<boolean> {
return menuButtons.length === 0
}

/**
* Delete a store via the admin /settings/plan/cancel page. Returns true if deleted.
*
* Gate: store must have zero apps installed.
* Caller should verify via `isStoreAppsEmpty` and skip this call if apps remain,
* otherwise step 4 will exhaust its micro-retry (Delete button never enables) and throw.
*
* Single attempt — caller owns the retry loop.
*/
export async function deleteStore(page: Page, storeSlug: string): Promise<boolean> {
// Step 1: Navigate to /settings/plan/cancel (auto-opens the "Review before deleting store" modal).
const cancelUrl = `https://admin.shopify.com/store/${storeSlug}/settings/plan/cancel`
await page.goto(cancelUrl, {waitUntil: 'domcontentloaded'})

// Step 2: Race — modal renders (normal) vs. redirect to /access_account (already deleted).
// The redirect can fire post-DOMContentLoaded, so a URL check right after goto is too early.
const modal = page.locator('.Polaris-Modal-Dialog__Modal:has-text("Review before deleting store")')
const checkbox = modal.locator('input[type="checkbox"]')
try {
await Promise.race([
checkbox.waitFor({state: 'visible', timeout: BROWSER_TIMEOUT.max}),
page.waitForURL(/access_account/, {timeout: BROWSER_TIMEOUT.max}),
])
// eslint-disable-next-line no-catch-all/no-catch-all
} catch {
// Both branches timed out — fall through so outer retry can decide.
}
if (page.url().includes('access_account')) return true

// Step 3: Check the confirmation checkbox (enables the Delete button).
await checkbox.check()

// Step 4: Wait for the Delete button to enable, then click.
// Micro-retry for flaky checkbox state — different concern from caller's retry loop.
const confirmButton = modal.locator('button:has-text("Delete store")')
for (let i = 1; i <= 3; i++) {
if (await confirmButton.isEnabled().catch(() => false)) break
if (i === 3) throw new Error('Confirm button still disabled')
await checkbox.check()
await page.waitForTimeout(BROWSER_TIMEOUT.short)
}
await confirmButton.click()

// Step 5: Wait for the delete POST to finish before reloading.
try {
await page.waitForLoadState('networkidle', {timeout: BROWSER_TIMEOUT.max})
// eslint-disable-next-line no-catch-all/no-catch-all
} catch {
// networkidle can miss on busy pages — fall through to reload anyway.
}

// Step 6: Reload /settings/plan/cancel to confirm deletion.
// Success → redirect to /access_account.
// Failure → still on /settings/plan/cancel
await page.reload({waitUntil: 'domcontentloaded'})
return page.url().includes('access_account')
}

// ---------------------------------------------------------------------------
// Fixture — per-test dev store for tests that need `app dev`
// ---------------------------------------------------------------------------
Expand All @@ -364,7 +275,8 @@ export async function deleteStore(page: Page, storeSlug: string): Promise<boolea
* Tests that don't (scaffold, deploy, commands, smoke) stay on appTestFixture.
*/
export const storeTestFixture = appTestFixture.extend<{storeFqdn: string}>({
storeFqdn: async ({browserPage, env}, use) => {
storeFqdn: async ({cli, env}, use) => {
requireEnv(env, 'orgId')
const wi = env.workerIndex

// Unique ports per worker to avoid EADDRINUSE when running in parallel
Expand All @@ -373,7 +285,7 @@ export const storeTestFixture = appTestFixture.extend<{storeFqdn: string}>({
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})
const fqdn = await createDevStoreWithCli({cli, workerIndex: wi, storeName, orgId: env.orgId})

env.processEnv.SHOPIFY_FLAG_STORE = fqdn // eslint-disable-line require-atomic-updates

Expand Down
Loading
Loading