From 8113eeb621ec0cd3a8cf1400aa8e26eb0a88dd11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Fri, 14 Aug 2026 16:05:18 +0200 Subject: [PATCH] Prevent leaked E2E resources Assisted-By: devx/215513a9-13fa-4e74-bb8f-79faee6e4f09 --- .github/workflows/e2e-orphan-sweep.yml | 79 +++++++++++++++++++ .github/workflows/tests-pr.yml | 14 +++- packages/e2e/playwright.config.ts | 2 + packages/e2e/scripts/cleanup-apps.ts | 73 +++++++++++------ packages/e2e/scripts/cleanup-stores.ts | 79 ++++++++++++++----- packages/e2e/setup/resource-ownership.ts | 62 +++++++++++++++ packages/e2e/tests/resource-ownership.spec.ts | 79 +++++++++++++++++++ 7 files changed, 343 insertions(+), 45 deletions(-) create mode 100644 .github/workflows/e2e-orphan-sweep.yml create mode 100644 packages/e2e/setup/resource-ownership.ts create mode 100644 packages/e2e/tests/resource-ownership.spec.ts diff --git a/.github/workflows/e2e-orphan-sweep.yml b/.github/workflows/e2e-orphan-sweep.yml new file mode 100644 index 00000000000..e68bfe2f82c --- /dev/null +++ b/.github/workflows/e2e-orphan-sweep.yml @@ -0,0 +1,79 @@ +name: E2E orphan sweep + +on: + schedule: + - cron: '17 7 * * *' + workflow_dispatch: + +concurrency: + group: shopify-cli-e2e-orphan-sweep + cancel-in-progress: false + +permissions: + contents: read + +env: + DEBUG: '1' + SHOPIFY_CLI_ENV: development + SHOPIFY_CONFIG: debug + PNPM_VERSION: '10.11.1' + BUNDLE_WITHOUT: 'test:development' + GH_TOKEN: ${{ secrets.SHOPIFY_GH_READ_CONTENT_TOKEN }} + GH_TOKEN_SHOP: ${{ secrets.SHOP_GH_READ_CONTENT_TOKEN }} + PLAYWRIGHT_NODE_VERSION: '24.1.0' + ORPHAN_MINIMUM_AGE_HOURS: '24' + +jobs: + sweep: + name: 'Remove abandoned E2E resources' + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + - name: Setup deps + uses: ./.github/actions/setup-cli-deps + with: + node-version: ${{ env.PLAYWRIGHT_NODE_VERSION }} + - name: Install Playwright Chromium + run: pnpm exec playwright install chromium + working-directory: packages/e2e + - name: Build CLI for cleanup auth + run: pnpm nx run cli:build + - name: Prime cleanup auth state + continue-on-error: true + env: + E2E_ACCOUNT_EMAIL: ${{ secrets.E2E_ACCOUNT_EMAIL }} + E2E_ACCOUNT_PASSWORD: ${{ secrets.E2E_ACCOUNT_PASSWORD }} + E2E_ORG_ID: ${{ secrets.E2E_ORG_ID }} + E2E_LOADTEST_HEADER: ${{ secrets.E2E_LOADTEST_HEADER }} + run: pnpm --filter e2e exec tsx scripts/prime-browser-auth.ts + - name: Remove abandoned E2E apps + continue-on-error: true + env: + E2E_ACCOUNT_EMAIL: ${{ secrets.E2E_ACCOUNT_EMAIL }} + E2E_ACCOUNT_PASSWORD: ${{ secrets.E2E_ACCOUNT_PASSWORD }} + E2E_ORG_ID: ${{ secrets.E2E_ORG_ID }} + E2E_LOADTEST_HEADER: ${{ secrets.E2E_LOADTEST_HEADER }} + run: pnpm --filter e2e exec tsx scripts/cleanup-apps.ts --older-than-hours "${ORPHAN_MINIMUM_AGE_HOURS}" + - name: Remove abandoned E2E stores + continue-on-error: true + env: + E2E_ACCOUNT_EMAIL: ${{ secrets.E2E_ACCOUNT_EMAIL }} + E2E_ACCOUNT_PASSWORD: ${{ secrets.E2E_ACCOUNT_PASSWORD }} + E2E_ORG_ID: ${{ secrets.E2E_ORG_ID }} + E2E_LOADTEST_HEADER: ${{ secrets.E2E_LOADTEST_HEADER }} + run: pnpm --filter e2e exec tsx scripts/cleanup-stores.ts --older-than-hours "${ORPHAN_MINIMUM_AGE_HOURS}" + - name: Verify abandoned E2E resources were removed + if: ${{ always() }} + env: + E2E_ACCOUNT_EMAIL: ${{ secrets.E2E_ACCOUNT_EMAIL }} + E2E_ACCOUNT_PASSWORD: ${{ secrets.E2E_ACCOUNT_PASSWORD }} + E2E_ORG_ID: ${{ secrets.E2E_ORG_ID }} + E2E_LOADTEST_HEADER: ${{ secrets.E2E_LOADTEST_HEADER }} + run: | + FAILED=0 + pnpm --filter e2e exec tsx scripts/cleanup-apps.ts --list --fail-if-found --older-than-hours "${ORPHAN_MINIMUM_AGE_HOURS}" || FAILED=1 + pnpm --filter e2e exec tsx scripts/cleanup-stores.ts --list --fail-if-found --older-than-hours "${ORPHAN_MINIMUM_AGE_HOURS}" || FAILED=1 + exit "${FAILED}" diff --git a/.github/workflows/tests-pr.yml b/.github/workflows/tests-pr.yml index 9a2d043e9c4..e7443565b4b 100644 --- a/.github/workflows/tests-pr.yml +++ b/.github/workflows/tests-pr.yml @@ -310,7 +310,6 @@ jobs: if: ${{ always() && needs.e2e-tests.result != 'skipped' }} runs-on: ubuntu-latest timeout-minutes: 20 - continue-on-error: true steps: - uses: actions/checkout@v6 with: @@ -356,6 +355,19 @@ jobs: 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}" + - name: Verify current-run E2E cleanup + if: ${{ always() }} + env: + E2E_ACCOUNT_EMAIL: ${{ secrets.E2E_ACCOUNT_EMAIL }} + E2E_ACCOUNT_PASSWORD: ${{ secrets.E2E_ACCOUNT_PASSWORD }} + E2E_ORG_ID: ${{ secrets.E2E_ORG_ID }} + E2E_LOADTEST_HEADER: ${{ secrets.E2E_LOADTEST_HEADER }} + run: | + RUN_TOKEN=$(node -e "process.stdout.write(BigInt(process.env.GITHUB_RUN_ID).toString(36))") + FAILED=0 + pnpm --filter e2e exec tsx scripts/cleanup-apps.ts --list --fail-if-found --pattern "r${RUN_TOKEN}" || FAILED=1 + pnpm --filter e2e exec tsx scripts/cleanup-stores.ts --list --fail-if-found --pattern "r${RUN_TOKEN}" || FAILED=1 + exit "${FAILED}" type-diff: if: github.event.pull_request.head.repo.full_name == github.repository diff --git a/packages/e2e/playwright.config.ts b/packages/e2e/playwright.config.ts index c4e6e148c8f..fe7b8c40d7d 100644 --- a/packages/e2e/playwright.config.ts +++ b/packages/e2e/playwright.config.ts @@ -31,6 +31,7 @@ export default defineConfig({ 'tests/smoke-pty.spec.ts', 'tests/fixture-toml.spec.ts', 'tests/auth-diagnostics.spec.ts', + 'tests/resource-ownership.spec.ts', ], }, { @@ -45,6 +46,7 @@ export default defineConfig({ 'tests/smoke-pty.spec.ts', 'tests/fixture-toml.spec.ts', 'tests/auth-diagnostics.spec.ts', + 'tests/resource-ownership.spec.ts', ], dependencies: ['remote-auth'], }, diff --git a/packages/e2e/scripts/cleanup-apps.ts b/packages/e2e/scripts/cleanup-apps.ts index 586f684119d..e281f229f38 100644 --- a/packages/e2e/scripts/cleanup-apps.ts +++ b/packages/e2e/scripts/cleanup-apps.ts @@ -13,6 +13,8 @@ * pnpm --filter e2e exec tsx scripts/cleanup-apps.ts --delete # Delete only (skip uninstall — delete only apps with 0 installs) * pnpm --filter e2e exec tsx scripts/cleanup-apps.ts --headed # Show browser window * pnpm --filter e2e exec tsx scripts/cleanup-apps.ts --pattern X # Match apps containing "X" (default: "E2E-") + * pnpm --filter e2e exec tsx scripts/cleanup-apps.ts --older-than-hours 24 + * pnpm --filter e2e exec tsx scripts/cleanup-apps.ts --list --fail-if-found * * Environment variables (loaded from packages/e2e/.env): * E2E_ACCOUNT_EMAIL — Shopify account email for login @@ -29,6 +31,7 @@ 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 {matchesOwnedE2EResource} from '../setup/resource-ownership.js' import {uninstallAppFromStore} from '../setup/store.js' import {completeLogin} from '../helpers/browser-login.js' import {addLoadtestHeader} from '../helpers/loadtest-header.js' @@ -69,6 +72,10 @@ export interface CleanupOptions { orgId?: string /** Playwright browser storage state path (default: E2E_BROWSER_STATE_PATH or global-auth path) */ storageStatePath?: string + /** Match only resources older than this age */ + olderThanHours?: number + /** Fail list mode when matching resources remain */ + failIfFound?: boolean } interface DashboardApp { @@ -124,11 +131,14 @@ export async function cleanupAllApps(opts: CleanupOptions = {}): Promise { const password = process.env.E2E_ACCOUNT_PASSWORD const storageStatePath = existingStorageStatePath(opts.storageStatePath) + if (opts.failIfFound && mode !== 'list') throw new Error('failIfFound requires list mode') + // Banner console.log('') console.log(`[cleanup-apps] Mode: ${MODE_LABELS[mode]}`) console.log(`[cleanup-apps] Org: ${orgId || '(not set)'}`) console.log(`[cleanup-apps] Pattern: "${pattern}"`) + if (opts.olderThanHours !== undefined) console.log(`[cleanup-apps] Minimum age: ${opts.olderThanHours} hour(s)`) console.log('') if (!storageStatePath && (!email || !password)) { @@ -182,7 +192,15 @@ export async function cleanupAllApps(opts: CleanupOptions = {}): Promise { // cleanup work before loading the full app list, because the dashboard can // return transient 5xx responses after many pages. const stats: CleanupStats = {found: 0, succeeded: 0, skipped: 0, failed: 0} - await cleanupAppsPageByPage({page, mode, pattern, email, orgId, stats}) + await cleanupAppsPageByPage({ + page, + mode, + pattern, + olderThanHours: opts.olderThanHours, + email, + orgId, + stats, + }) // Summary const parts = [`${stats.found} found`, `${stats.succeeded} succeeded`] @@ -191,6 +209,9 @@ export async function cleanupAllApps(opts: CleanupOptions = {}): Promise { console.log('') const totalElapsed = ((Date.now() - totalStart) / 1000).toFixed(1) console.log(`[cleanup-apps] Complete: ${parts.join(', ')} (${totalElapsed}s total)`) + if (opts.failIfFound && stats.found > 0) { + throw new Error(`[cleanup-apps] Verification failed: ${stats.found} owned app(s) remain`) + } if (stats.failed > 0) process.exitCode = 1 } finally { await browser.close() @@ -205,11 +226,12 @@ async function cleanupAppsPageByPage(opts: { page: Page mode: CleanupMode pattern: string + olderThanHours?: number email: string orgId: string stats: CleanupStats }): Promise { - const {page, mode, pattern, email, orgId, stats} = opts + const {page, mode, pattern, olderThanHours, email, orgId, stats} = opts let totalSeen = 0 let pageNumber = 1 const handledAppUrls = new Set() @@ -221,7 +243,7 @@ async function cleanupAppsPageByPage(opts: { await recoverFromAppsPageError(page) await waitForAppsIndex(page) const nextUrl = await nextAppsPageUrl(page) - const {seen, matches} = await findAppsOnCurrentDashboardPage(page, pattern, handledAppUrls) + const {seen, matches} = await findAppsOnCurrentDashboardPage(page, {pattern, olderThanHours}, handledAppUrls) const matchOffset = stats.found totalSeen += seen stats.found += matches.length @@ -447,7 +469,7 @@ function compactPageText(text: string): string { async function findAppsOnCurrentDashboardPage( page: Page, - namePattern: string, + filter: {pattern: string; olderThanHours?: number}, excludeAppUrls: Set = new Set(), ): Promise<{seen: number; matches: DashboardApp[]}> { const matches: DashboardApp[] = [] @@ -461,7 +483,7 @@ async function findAppsOnCurrentDashboardPage( seen++ - const name = extractDashboardAppName(text, namePattern) + const name = extractDashboardAppName(text, filter) if (!name || name.length > 200) continue const installs = extractDashboardInstallCount(text, name) @@ -474,22 +496,16 @@ async function findAppsOnCurrentDashboardPage( return {seen, matches} } -function extractDashboardAppName(cardText: string, namePattern: string): string | undefined { - const dateStampedName = cardText.match(new RegExp(`${escapeRegExp(namePattern)}\\S*?\\d{13}`))?.[0] - if (dateStampedName) return dateStampedName - +function extractDashboardAppName( + cardText: string, + filter: {pattern: string; olderThanHours?: number}, +): string | undefined { const lines = cardText .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) - const matchingLine = lines.find((line) => line.includes(namePattern)) - if (matchingLine) return stripInstallCount(matchingLine) - - const patternIndex = cardText.indexOf(namePattern) - if (patternIndex === -1) return undefined - - const fromPattern = cardText.slice(patternIndex) - return stripInstallCount(fromPattern) + const candidates = [...lines.map(stripInstallCount), ...(cardText.match(/E2E-[a-z0-9-]+/gi) ?? [])] + return candidates.find((candidate) => matchesOwnedE2EResource('app', candidate, filter)) } function extractDashboardInstallCount(cardText: string, appName: string): number { @@ -507,15 +523,9 @@ function stripInstallCount(text: string): string { const installCount = text.match(/\d+\s+installs?/i) if (!installCount || installCount.index === undefined) return text.trim() - // Date-stamped names are recovered earlier via extractDashboardAppName's 13-digit - // anchor; this only trims the trailing "N installs" off the non-date-stamped fallback. return text.slice(0, installCount.index).trim() } -function escapeRegExp(text: string): string { - return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') -} - async function nextAppsPageUrl(page: Page): Promise { const nextLink = page.locator('a[href*="next_cursor"]').first() if (!(await nextLink.isVisible({timeout: BROWSER_TIMEOUT.medium}).catch(() => false))) return undefined @@ -636,7 +646,22 @@ async function main() { else if (args.includes('--uninstall')) mode = 'uninstall' else if (args.includes('--delete')) mode = 'delete' - await cleanupAllApps({mode, pattern, headed}) + const olderThanHours = positiveNumberOption(args, '--older-than-hours') + const failIfFound = args.includes('--fail-if-found') + + await cleanupAllApps({mode, pattern, headed, olderThanHours, failIfFound}) +} + +function positiveNumberOption(args: string[], option: string): number | undefined { + const optionIndex = args.indexOf(option) + if (optionIndex === -1) return undefined + + const rawValue = args[optionIndex + 1] + const value = Number(rawValue) + if (!rawValue || rawValue.startsWith('--') || !Number.isFinite(value) || value <= 0) { + throw new Error(`${option} requires a number greater than zero`) + } + return value } // Run if executed directly (not imported) diff --git a/packages/e2e/scripts/cleanup-stores.ts b/packages/e2e/scripts/cleanup-stores.ts index 61108caca04..dbd3536f178 100644 --- a/packages/e2e/scripts/cleanup-stores.ts +++ b/packages/e2e/scripts/cleanup-stores.ts @@ -12,6 +12,8 @@ * pnpm --filter e2e exec tsx scripts/cleanup-stores.ts --delete # Delete only stores with 0 apps installed * pnpm --filter e2e exec tsx scripts/cleanup-stores.ts --headed # Show browser window * pnpm --filter e2e exec tsx scripts/cleanup-stores.ts --pattern X # Match stores containing "X" (default: "e2e-w") + * pnpm --filter e2e exec tsx scripts/cleanup-stores.ts --older-than-hours 24 + * pnpm --filter e2e exec tsx scripts/cleanup-stores.ts --list --fail-if-found * * Environment variables (loaded from packages/e2e/.env): * E2E_ACCOUNT_EMAIL — Shopify account email for login @@ -28,6 +30,7 @@ import {chromium} from '@playwright/test' import {BROWSER_TIMEOUT, CLI_TIMEOUT} from '../setup/constants.js' import {deleteDevStoreWithCli, dismissDevConsole, isStoreAppsEmpty} from '../setup/store.js' import {executables} from '../setup/env.js' +import {matchesOwnedE2EResource} from '../setup/resource-ownership.js' import {refreshIfPageError, trackMainFrameStatus} from '../setup/browser.js' import {completeLogin} from '../helpers/browser-login.js' import {addLoadtestHeader} from '../helpers/loadtest-header.js' @@ -76,6 +79,10 @@ export interface CleanupStoresOptions { orgId?: string /** Playwright browser storage state path (default: E2E_BROWSER_STATE_PATH or global-auth path) */ storageStatePath?: string + /** Match only resources older than this age */ + olderThanHours?: number + /** Fail list mode when matching resources remain */ + failIfFound?: boolean } function isAccountsShopifyUrl(rawUrl: string): boolean { @@ -122,10 +129,13 @@ export async function cleanupStores(opts: CleanupStoresOptions = {}): Promise { try { - return await findStoresWithBusinessPlatformApi(opts.pattern, opts.orgId) + return await findStoresWithBusinessPlatformApi(opts) // 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}`, + `[cleanup-stores] API discovery failed, falling back to Dev Dashboard UI: ${ + err instanceof Error ? err.message : err + }`, ) } @@ -312,15 +338,15 @@ async function findStores(page: Page, opts: FindStoresOptions): Promise { +async function findStoresWithBusinessPlatformApi(opts: FindStoresOptions): Promise { 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}, + organizationId: opts.orgId, + variables: {searchTerm: opts.pattern}, unauthorizedHandler: { type: 'token_refresh', handler: async () => ({token: await ensureAuthenticatedBusinessPlatform([], {noPrompt: true})}), @@ -330,15 +356,13 @@ async function findStoresWithBusinessPlatformApi(namePattern: string, orgId: str 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.`, - ) + throw new Error(`API discovery has more pages for pattern "${opts.pattern}"`) } const seen = new Set() const stores: StoreInfo[] = [] for (const edge of accessibleShops.edges) { - const store = toStoreInfo(edge.node, namePattern) + const store = toStoreInfo(edge.node, opts) if (!store || seen.has(store.fqdn)) continue seen.add(store.fqdn) stores.push(store) @@ -351,7 +375,7 @@ type AppDevStoreNode = NonNullable< NonNullable['accessibleShops']>['edges'][number]['node'] > -function toStoreInfo(node: AppDevStoreNode, namePattern: string): StoreInfo | undefined { +function toStoreInfo(node: AppDevStoreNode, filter: FindStoresOptions): StoreInfo | undefined { const fqdn = normalizeStoreFqdn(node.primaryDomain) ?? normalizeStoreFqdn(node.url) ?? @@ -359,10 +383,10 @@ function toStoreInfo(node: AppDevStoreNode, namePattern: string): StoreInfo | un 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 + const name = fqdn.replace('.myshopify.com', '') + if (!matchesOwnedE2EResource('store', name, filter)) return undefined - return {name: fqdn.replace('.myshopify.com', ''), fqdn, appCount: 0} + return {name, fqdn, appCount: 0} } function normalizeStoreFqdn(rawValue?: string | null): string | undefined { @@ -445,7 +469,7 @@ async function findStoresOnDashboard(page: Page, opts: FindStoresOptions): Promi while (match) { const slug = match[1]! const fqdn = `${slug}.myshopify.com` - if (!seen.has(fqdn) && slug.toLowerCase().includes(namePattern.toLowerCase())) { + if (!seen.has(fqdn) && matchesOwnedE2EResource('store', slug, opts)) { seen.add(fqdn) stores.push({name: slug, fqdn, appCount: 0}) } @@ -596,7 +620,22 @@ async function main() { if (args.includes('--list')) mode = 'list' else if (args.includes('--delete')) mode = 'delete' - await cleanupStores({mode, pattern, headed}) + const olderThanHours = positiveNumberOption(args, '--older-than-hours') + const failIfFound = args.includes('--fail-if-found') + + await cleanupStores({mode, pattern, headed, olderThanHours, failIfFound}) +} + +function positiveNumberOption(args: string[], option: string): number | undefined { + const optionIndex = args.indexOf(option) + if (optionIndex === -1) return undefined + + const rawValue = args[optionIndex + 1] + const value = Number(rawValue) + if (!rawValue || rawValue.startsWith('--') || !Number.isFinite(value) || value <= 0) { + throw new Error(`${option} requires a number greater than zero`) + } + return value } const isDirectRun = process.argv[1] === fileURLToPath(import.meta.url) diff --git a/packages/e2e/setup/resource-ownership.ts b/packages/e2e/setup/resource-ownership.ts new file mode 100644 index 00000000000..73129761fdd --- /dev/null +++ b/packages/e2e/setup/resource-ownership.ts @@ -0,0 +1,62 @@ +export type E2EResourceType = 'app' | 'store' + +export interface E2EResourceFilter { + pattern: string + olderThanHours?: number + now?: number +} + +interface E2EResourceOwnership { + createdAt: number +} + +const CURRENT_RUN_SEGMENT = '(r[0-9a-z]+a[1-9][0-9]*|local)' +const BASE36_TIMESTAMP = '([0-9a-z]+)' +const CURRENT_APP_PREFIX = '(?:dep1|dep2|dev|scaf|exto|extg|hrel|hcrt|hdel|mcfg|mdef|tdep|tdev)' +const CURRENT_APP_NAME = new RegExp(`^E2E-${CURRENT_APP_PREFIX}-${CURRENT_RUN_SEGMENT}-${BASE36_TIMESTAMP}$`, 'i') +const CURRENT_STORE_NAME = new RegExp(`^e2e-w[0-9]+-${CURRENT_RUN_SEGMENT}-${BASE36_TIMESTAMP}$`, 'i') +const LEGACY_APP_NAME = + /^E2E-(?:deploy1|deploy2|dev|scaffold|ext-only|ext-gen|hot-reload|hot-create|hot-delete|multi-cfg|mcfg-def|toml-deploy|toml-dev)-(\d{13})$/i +const LEGACY_STORE_NAME = /^e2e-w[0-9]+-(\d{13})$/i +const MINIMUM_E2E_TIMESTAMP = Date.UTC(2020, 0, 1) + +export function matchesOwnedE2EResource( + resourceType: E2EResourceType, + resourceName: string, + filter: E2EResourceFilter, +): boolean { + if (filter.olderThanHours !== undefined && (!Number.isFinite(filter.olderThanHours) || filter.olderThanHours <= 0)) { + throw new Error('olderThanHours must be greater than zero') + } + + const ownership = parseE2EResourceOwnership(resourceType, resourceName) + if (!ownership || !resourceName.toLowerCase().includes(filter.pattern.toLowerCase())) return false + if (filter.olderThanHours === undefined) return true + + const oldestAllowedCreationTime = (filter.now ?? Date.now()) - filter.olderThanHours * 60 * 60 * 1000 + return ownership.createdAt <= oldestAllowedCreationTime +} + +function parseE2EResourceOwnership( + resourceType: E2EResourceType, + resourceName: string, +): E2EResourceOwnership | undefined { + const currentMatch = resourceName.match(resourceType === 'app' ? CURRENT_APP_NAME : CURRENT_STORE_NAME) + if (currentMatch?.[1] && currentMatch[2]) { + const createdAt = parseTimestamp(currentMatch[2]) + if (createdAt !== undefined) return {createdAt} + } + + const legacyMatch = resourceName.match(resourceType === 'app' ? LEGACY_APP_NAME : LEGACY_STORE_NAME) + if (legacyMatch?.[1]) { + const createdAt = parseTimestamp(legacyMatch[1]) + if (createdAt !== undefined) return {createdAt} + } + + return undefined +} + +function parseTimestamp(timestampSegment: string): number | undefined { + const timestamp = /^\d{13}$/.test(timestampSegment) ? Number(timestampSegment) : parseInt(timestampSegment, 36) + return Number.isSafeInteger(timestamp) && timestamp >= MINIMUM_E2E_TIMESTAMP ? timestamp : undefined +} diff --git a/packages/e2e/tests/resource-ownership.spec.ts b/packages/e2e/tests/resource-ownership.spec.ts new file mode 100644 index 00000000000..0e2593da8ef --- /dev/null +++ b/packages/e2e/tests/resource-ownership.spec.ts @@ -0,0 +1,79 @@ +import {matchesOwnedE2EResource} from '../setup/resource-ownership.js' +import {e2eRunSegment} from '../setup/env.js' +import {expect, test} from '@playwright/test' + +const now = Date.UTC(2026, 7, 14, 12) +const oldTimestamp = now - 25 * 60 * 60 * 1000 +const recentTimestamp = now - 2 * 60 * 60 * 1000 + +test.describe('E2E resource ownership', () => { + test('includes the GitHub run and attempt in generated resource names', () => { + const previousRunId = process.env.GITHUB_RUN_ID + const previousRunAttempt = process.env.GITHUB_RUN_ATTEMPT + process.env.GITHUB_RUN_ID = '123456789' + process.env.GITHUB_RUN_ATTEMPT = '4' + + try { + expect(e2eRunSegment()).toBe(`r${BigInt(123456789).toString(36)}a4`) + } finally { + if (previousRunId === undefined) delete process.env.GITHUB_RUN_ID + else process.env.GITHUB_RUN_ID = previousRunId + if (previousRunAttempt === undefined) delete process.env.GITHUB_RUN_ATTEMPT + else process.env.GITHUB_RUN_ATTEMPT = previousRunAttempt + } + }) + + test('matches current app and store names for one run attempt', () => { + const filter = {pattern: 'rabc123a2'} + + expect(matchesOwnedE2EResource('app', `E2E-dep1-rabc123a2-${oldTimestamp.toString(36)}`, filter)).toBe(true) + expect(matchesOwnedE2EResource('store', `e2e-w4-rabc123a2-${oldTimestamp.toString(36)}`, filter)).toBe(true) + }) + + test('rejects resources that only contain the requested pattern', () => { + const filter = {pattern: 'rabc123'} + + expect(matchesOwnedE2EResource('app', 'Customer app rabc123', filter)).toBe(false) + expect(matchesOwnedE2EResource('store', 'merchant-rabc123-store', filter)).toBe(false) + expect(matchesOwnedE2EResource('app', `E2E-customer-${oldTimestamp}`, {pattern: 'E2E-'})).toBe(false) + expect( + matchesOwnedE2EResource('app', `E2E-customer-rabc123a1-${oldTimestamp.toString(36)}`, { + pattern: 'E2E-', + }), + ).toBe(false) + }) + + test('matches only resources older than the configured minimum age', () => { + const filter = {pattern: 'E2E-', olderThanHours: 24, now} + + expect(matchesOwnedE2EResource('app', `E2E-dep1-rabc123a1-${oldTimestamp.toString(36)}`, filter)).toBe(true) + expect(matchesOwnedE2EResource('app', `E2E-dep1-rabc123a1-${recentTimestamp.toString(36)}`, filter)).toBe(false) + }) + + test('recognizes the previous decimal timestamp naming scheme', () => { + expect( + matchesOwnedE2EResource('app', `E2E-deploy1-${oldTimestamp}`, { + pattern: 'E2E-', + olderThanHours: 24, + now, + }), + ).toBe(true) + expect( + matchesOwnedE2EResource('store', `e2e-w2-${oldTimestamp}`, { + pattern: 'e2e-w', + olderThanHours: 24, + now, + }), + ).toBe(true) + }) + + test('rejects unsafe age limits', () => { + expect(() => + matchesOwnedE2EResource('app', `E2E-dep1-rabc123a1-${oldTimestamp.toString(36)}`, { + pattern: 'E2E-', + olderThanHours: 0, + now, + }), + ).toThrow('olderThanHours must be greater than zero') + }) +})