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
12 changes: 7 additions & 5 deletions .github/workflows/tests-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -289,28 +289,30 @@ jobs:
- name: Install Playwright Chromium
run: pnpm exec playwright install chromium
working-directory: packages/e2e
- name: Prime cleanup browser auth state
- name: Build CLI for cleanup auth
run: pnpm nx run cli:build --skip-nx-cache
- name: Prime cleanup auth state
env:
E2E_ACCOUNT_EMAIL: ${{ secrets.E2E_ACCOUNT_EMAIL }}
E2E_ACCOUNT_PASSWORD: ${{ secrets.E2E_ACCOUNT_PASSWORD }}
E2E_ORG_ID: ${{ secrets.E2E_ORG_ID }}
run: pnpm --filter e2e exec tsx scripts/prime-browser-auth.ts
- name: Cleanup current-run E2E stores
- name: Cleanup current-run E2E apps
env:
E2E_ACCOUNT_EMAIL: ${{ secrets.E2E_ACCOUNT_EMAIL }}
E2E_ACCOUNT_PASSWORD: ${{ secrets.E2E_ACCOUNT_PASSWORD }}
E2E_ORG_ID: ${{ secrets.E2E_ORG_ID }}
run: |
RUN_TOKEN=$(node -e "process.stdout.write(BigInt(process.env.GITHUB_RUN_ID).toString(36))")
pnpm --filter e2e exec tsx scripts/cleanup-stores.ts --pattern "r${RUN_TOKEN}a${GITHUB_RUN_ATTEMPT}"
- name: Cleanup current-run E2E apps
pnpm --filter e2e exec tsx scripts/cleanup-apps.ts --pattern "r${RUN_TOKEN}a${GITHUB_RUN_ATTEMPT}"
- name: Cleanup current-run E2E stores
env:
E2E_ACCOUNT_EMAIL: ${{ secrets.E2E_ACCOUNT_EMAIL }}
E2E_ACCOUNT_PASSWORD: ${{ secrets.E2E_ACCOUNT_PASSWORD }}
E2E_ORG_ID: ${{ secrets.E2E_ORG_ID }}
run: |
RUN_TOKEN=$(node -e "process.stdout.write(BigInt(process.env.GITHUB_RUN_ID).toString(36))")
pnpm --filter e2e exec tsx scripts/cleanup-apps.ts --pattern "r${RUN_TOKEN}a${GITHUB_RUN_ATTEMPT}"
pnpm --filter e2e exec tsx scripts/cleanup-stores.ts --pattern "r${RUN_TOKEN}a${GITHUB_RUN_ATTEMPT}"

type-diff:
if: github.event.pull_request.head.repo.full_name == github.repository
Expand Down
139 changes: 118 additions & 21 deletions packages/e2e/scripts/cleanup-stores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ import {BROWSER_TIMEOUT} from '../setup/constants.js'
import {deleteStore, dismissDevConsole, isStoreAppsEmpty} from '../setup/store.js'
import {refreshIfPageError, trackMainFrameStatus} from '../setup/browser.js'
import {completeLogin} from '../helpers/browser-login.js'
import {
ListAppDevStores,
type ListAppDevStoresQuery,
} from '../../app/dist/cli/api/graphql/business-platform-organizations/generated/list_app_dev_stores.js'
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 type {Page} from '@playwright/test'

// Load .env from packages/e2e/ (not cwd) only if not already configured
Expand Down Expand Up @@ -96,7 +103,9 @@ export async function cleanupStores(opts: CleanupStoresOptions = {}): Promise<vo
console.log('')

if (!storageStatePath && (!email || !password)) {
throw new Error('E2E_ACCOUNT_EMAIL and E2E_ACCOUNT_PASSWORD are required when no browser storage state is available')
throw new Error(
'E2E_ACCOUNT_EMAIL and E2E_ACCOUNT_PASSWORD are required when no browser storage state is available',
)
}
if (!orgId) {
throw new Error('E2E_ORG_ID is required')
Expand Down Expand Up @@ -126,24 +135,9 @@ export async function cleanupStores(opts: CleanupStoresOptions = {}): Promise<vo
console.log('[cleanup-stores] Logged in successfully.')
}

// Step 2: Navigate to stores page and find matching stores
console.log('[cleanup-stores] Navigating to stores page...')
await page.goto(`https://dev.shopify.com/dashboard/${orgId}/stores`, {waitUntil: 'domcontentloaded'})
if (isAccountsShopifyUrl(page.url()) && email && password) {
console.log('[cleanup-stores] Browser storage state was not accepted; logging in...')
await completeLogin(page, page.url(), email, password)
await page.goto(`https://dev.shopify.com/dashboard/${orgId}/stores`, {waitUntil: 'domcontentloaded'})
}
await page.waitForTimeout(BROWSER_TIMEOUT.medium)

// Handle account picker
const accountButton = email ? page.locator(`text=${email}`).first() : undefined
if (accountButton && (await accountButton.isVisible({timeout: BROWSER_TIMEOUT.long}).catch(() => false))) {
await accountButton.click()
await page.waitForTimeout(BROWSER_TIMEOUT.medium)
}

const stores = await findStoresOnDashboard(page, pattern)
// Step 2: Find matching stores. Prefer Business Platform API discovery because the Dev Dashboard
// stores page is virtualized/lazy-loaded and its rendered HTML does not always include myshopify domains.
const stores = await findStores(page, {pattern, orgId, email, password})
console.log(`[cleanup-stores] Found ${stores.length} store(s) matching pattern "${pattern}"`)
console.log('')

Expand Down Expand Up @@ -266,7 +260,7 @@ export async function cleanupStores(opts: CleanupStoresOptions = {}): Promise<vo
}

// ---------------------------------------------------------------------------
// Browser helpers
// Discovery and browser helpers
// ---------------------------------------------------------------------------

interface StoreInfo {
Expand All @@ -275,13 +269,116 @@ interface StoreInfo {
appCount: number
}

interface FindStoresOptions {
pattern: string
orgId: string
email?: string
password?: string
}

async function findStores(page: Page, opts: FindStoresOptions): Promise<StoreInfo[]> {
try {
return await findStoresWithBusinessPlatformApi(opts.pattern, opts.orgId)
// eslint-disable-next-line no-catch-all/no-catch-all
} catch (err) {
console.warn(
`[cleanup-stores] API discovery failed, falling back to Dev Dashboard UI: ${err instanceof Error ? err.message : err}`,
)
}

return findStoresOnDashboard(page, opts)
}

/** Find app development stores matching a name pattern using Business Platform GraphQL. */
async function findStoresWithBusinessPlatformApi(namePattern: string, orgId: string): Promise<StoreInfo[]> {
console.log('[cleanup-stores] Discovering stores via Business Platform API...')

const token = await ensureAuthenticatedBusinessPlatform([], {noPrompt: true})
const result = await businessPlatformOrganizationsRequestDoc({
query: ListAppDevStores,
token,
organizationId: orgId,
variables: {searchTerm: namePattern},
unauthorizedHandler: {
type: 'token_refresh',
handler: async () => ({token: await ensureAuthenticatedBusinessPlatform([], {noPrompt: true})}),
},
})

const accessibleShops = result.organization?.accessibleShops
if (!accessibleShops) return []
if (accessibleShops.pageInfo.hasNextPage) {
console.warn(
`[cleanup-stores] API discovery has more pages for pattern "${namePattern}"; use a narrower pattern if matches are missing.`,
)
}

const seen = new Set<string>()
const stores: StoreInfo[] = []
for (const edge of accessibleShops.edges) {
const store = toStoreInfo(edge.node, namePattern)
if (!store || seen.has(store.fqdn)) continue
seen.add(store.fqdn)
stores.push(store)
}

return stores
}

type AppDevStoreNode = NonNullable<
NonNullable<NonNullable<ListAppDevStoresQuery['organization']>['accessibleShops']>['edges'][number]['node']
>

function toStoreInfo(node: AppDevStoreNode, namePattern: string): StoreInfo | undefined {
const fqdn =
normalizeStoreFqdn(node.primaryDomain) ??
normalizeStoreFqdn(node.url) ??
normalizeStoreFqdn(node.shortName) ??
normalizeStoreFqdn(node.name)
if (!fqdn) return undefined

const searchable = [node.name, node.shortName, node.primaryDomain, node.url, fqdn].filter(Boolean).join(' ')
if (!searchable.toLowerCase().includes(namePattern.toLowerCase())) return undefined

return {name: fqdn.replace('.myshopify.com', ''), fqdn, appCount: 0}
}

function normalizeStoreFqdn(rawValue?: string | null): string | undefined {
if (!rawValue) return undefined

const host = extractHost(rawValue) ?? rawValue.replace(/^https?:\/\//, '').split('/')[0]
const normalizedHost = host?.trim().toLowerCase()
if (!normalizedHost) return undefined
if (normalizedHost.endsWith('.myshopify.com')) return normalizedHost
if (/^[a-z0-9][a-z0-9-]*$/.test(normalizedHost)) return `${normalizedHost}.myshopify.com`
return undefined
}

/**
* Find stores matching a name pattern on the stores page (dev dashboard).
*
* The stores page lazy-loads rows as you scroll — each scroll-to-bottom triggers another batch to render.
* Keep scrolling until the row count has been stable for several consecutive passes, then scrape all FQDNs from the final HTML.
*/
async function findStoresOnDashboard(page: Page, namePattern: string): Promise<StoreInfo[]> {
async function findStoresOnDashboard(page: Page, opts: FindStoresOptions): Promise<StoreInfo[]> {
const {pattern: namePattern, orgId, email, password} = opts

console.log('[cleanup-stores] Navigating to stores page...')
await page.goto(`https://dev.shopify.com/dashboard/${orgId}/stores`, {waitUntil: 'domcontentloaded'})
if (isAccountsShopifyUrl(page.url()) && email && password) {
console.log('[cleanup-stores] Browser storage state was not accepted; logging in...')
await completeLogin(page, page.url(), email, password)
await page.goto(`https://dev.shopify.com/dashboard/${orgId}/stores`, {waitUntil: 'domcontentloaded'})
}
await page.waitForTimeout(BROWSER_TIMEOUT.medium)

// Handle account picker
const accountButton = email ? page.locator(`text=${email}`).first() : undefined
if (accountButton && (await accountButton.isVisible({timeout: BROWSER_TIMEOUT.long}).catch(() => false))) {
await accountButton.click()
await page.waitForTimeout(BROWSER_TIMEOUT.medium)
}

// Recover from transient 500/502 before parsing.
await refreshIfPageError(page)

Expand Down
104 changes: 97 additions & 7 deletions packages/e2e/scripts/prime-browser-auth.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
/* eslint-disable no-console, no-restricted-imports */

/**
* Prime Playwright browser storage state for standalone E2E maintenance scripts.
* Prime CLI auth and 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
* cleanup jobs can reuse Business Platform tokens and browser cookies without each cleanup operation going
* through Shopify Accounts again.
*/

Expand All @@ -14,9 +14,13 @@ 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 {BROWSER_TIMEOUT, CLI_TIMEOUT} from '../setup/constants.js'
import {executables} from '../setup/env.js'
import {isVisibleWithin} from '../setup/browser.js'
import {completeLogin} from '../helpers/browser-login.js'
import {stripAnsi} from '../helpers/strip-ansi.js'
import {waitForText} from '../helpers/wait-for-text.js'
import {execa} from 'execa'
import type {Page} from '@playwright/test'

const __dirname = path.dirname(fileURLToPath(import.meta.url))
Expand Down Expand Up @@ -45,9 +49,38 @@ function isAccountsShopifyUrl(rawUrl: string): boolean {
}
}

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

function defaultStorageStatePath(): string {
return path.join(defaultAuthDir(), 'browser-storage-state.json')
}

function cleanupAuthEnv(storageStatePath: string): NodeJS.ProcessEnv {
const authDir = defaultAuthDir()
return {
...process.env,
XDG_DATA_HOME: path.join(authDir, 'XDG_DATA_HOME'),
XDG_CONFIG_HOME: path.join(authDir, 'XDG_CONFIG_HOME'),
XDG_STATE_HOME: path.join(authDir, 'XDG_STATE_HOME'),
XDG_CACHE_HOME: path.join(authDir, 'XDG_CACHE_HOME'),
E2E_BROWSER_STATE_PATH: storageStatePath,
SHOPIFY_RUN_AS_USER: '0',
SHOPIFY_CLI_NO_ANALYTICS: '1',
NODE_OPTIONS: '',
CI: '1',
SHOPIFY_FLAG_CLIENT_ID: undefined,
}
}

function persistAuthEnvForLaterSteps(env: NodeJS.ProcessEnv) {
const githubEnv = process.env.GITHUB_ENV
if (!githubEnv) return

const keys = ['XDG_DATA_HOME', 'XDG_CONFIG_HOME', 'XDG_STATE_HOME', 'E2E_BROWSER_STATE_PATH']
fs.appendFileSync(githubEnv, keys.map((key) => `${key}=${env[key] ?? ''}`).join('\n') + '\n')
}

export async function primeBrowserAuthStorage(opts: PrimeBrowserAuthOptions = {}): Promise<string> {
Expand All @@ -64,7 +97,17 @@ export async function primeBrowserAuthStorage(opts: PrimeBrowserAuthOptions = {}
throw new Error('E2E_ORG_ID is required')
}

fs.mkdirSync(path.dirname(storageStatePath), {recursive: true})
const authEnv = cleanupAuthEnv(storageStatePath)
for (const dir of [
path.dirname(storageStatePath),
authEnv.XDG_DATA_HOME,
authEnv.XDG_CONFIG_HOME,
authEnv.XDG_STATE_HOME,
authEnv.XDG_CACHE_HOME,
]) {
if (dir) fs.mkdirSync(dir, {recursive: true})
}
persistAuthEnvForLaterSteps(authEnv)

const browser = await chromium.launch({headless: !opts.headed})
try {
Expand All @@ -78,7 +121,7 @@ export async function primeBrowserAuthStorage(opts: PrimeBrowserAuthOptions = {}
const page = await context.newPage()

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

await attemptVisitAndHandleAccountPicker(page, 'https://admin.shopify.com/', email, 'admin')
await attemptVisitAndHandleAccountPicker(
Expand All @@ -96,6 +139,53 @@ export async function primeBrowserAuthStorage(opts: PrimeBrowserAuthOptions = {}
}
}

async function primeCliAuth(page: Page, email: string, password: string, env: NodeJS.ProcessEnv) {
await execa('node', [executables.cli, 'auth', 'logout'], {
env,
reject: false,
})

const nodePty = await import('node-pty')
const spawnEnv: {[key: string]: string} = {}
for (const [key, value] of Object.entries(env)) {
if (value !== undefined) spawnEnv[key] = value
}
spawnEnv.CI = ''
spawnEnv.CODESPACES = 'true'

const ptyProcess = nodePty.spawn('node', [executables.cli, 'auth', 'login'], {
name: 'xterm-color',
cols: 120,
rows: 30,
env: spawnEnv,
})

let output = ''
ptyProcess.onData((data: string) => {
output += data
})

try {
await waitForText(() => output, 'link to start the auth process', CLI_TIMEOUT.short)

const stripped = stripAnsi(output)
const urlMatch = stripped.match(/https:\/\/accounts\.shopify\.com\S+/)
if (!urlMatch) {
throw new Error('[prime-browser-auth] could not find login URL in Shopify auth output')
}

await completeLogin(page, urlMatch[0], email, password)
await waitForText(() => output, 'Logged in', BROWSER_TIMEOUT.max)
} finally {
try {
ptyProcess.kill()
// eslint-disable-next-line no-catch-all/no-catch-all
} catch (_error) {
// Process may already be dead.
}
}
}

async function attemptVisitAndHandleAccountPicker(page: Page, url: string, email: string, label: string) {
try {
await visitAndHandleAccountPicker(page, url, email)
Expand Down
Loading