diff --git a/packages/e2e/playwright.config.ts b/packages/e2e/playwright.config.ts index a46b602c7d..2c33df99bb 100644 --- a/packages/e2e/playwright.config.ts +++ b/packages/e2e/playwright.config.ts @@ -8,6 +8,7 @@ config() const isCI = Boolean(process.env.CI) export default defineConfig({ + globalSetup: './setup/global-auth.ts', testDir: './tests', fullyParallel: false, forbidOnly: isCI, diff --git a/packages/e2e/setup/auth.ts b/packages/e2e/setup/auth.ts index 9a8bb1054b..21bbe65151 100644 --- a/packages/e2e/setup/auth.ts +++ b/packages/e2e/setup/auth.ts @@ -1,17 +1,21 @@ -import {CLI_TIMEOUT, BROWSER_TIMEOUT} from './constants.js' import {browserFixture} from './browser.js' -import {executables} from './env.js' +import {CLI_TIMEOUT, BROWSER_TIMEOUT} from './constants.js' +import {globalLog, executables} from './env.js' import {stripAnsi} from '../helpers/strip-ansi.js' import {waitForText} from '../helpers/wait-for-text.js' import {completeLogin} from '../helpers/browser-login.js' import {execa} from 'execa' +import * as fs from 'fs' + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const log = {log: (_ctx: any, msg: string) => globalLog('auth', msg)} /** - * Worker-scoped fixture that performs OAuth login using the shared browser page. + * Worker-scoped fixture that provides an authenticated CLI session. * - * Extends browserFixture — the browser is already running when auth starts. - * After login, the CLI session is stored in XDG dirs and the browser page - * remains available for other browser-based actions (dashboard navigation, etc.). + * If globalSetup already ran auth (E2E_AUTH_CONFIG_DIR is set), copies the + * pre-authenticated session files into this worker's isolated XDG dirs. + * Otherwise falls back to running auth login directly (single-worker mode). * * Fixture chain: envFixture → cliFixture → browserFixture → authFixture */ @@ -26,22 +30,38 @@ export const authFixture = browserFixture.extend<{}, {authLogin: void}>({ return } - process.stdout.write('[e2e] Authenticating automatically — no action required.\n') + const authConfigDir = process.env.E2E_AUTH_CONFIG_DIR + const authDataDir = process.env.E2E_AUTH_DATA_DIR + const authStateDir = process.env.E2E_AUTH_STATE_DIR + const authCacheDir = process.env.E2E_AUTH_CACHE_DIR + + if (authConfigDir && authDataDir && authStateDir && authCacheDir) { + // Copy pre-authenticated session from global setup + log.log(env, 'copying session from global setup') + + fs.cpSync(authConfigDir, env.processEnv.XDG_CONFIG_HOME!, {recursive: true}) + fs.cpSync(authDataDir, env.processEnv.XDG_DATA_HOME!, {recursive: true}) + fs.cpSync(authStateDir, env.processEnv.XDG_STATE_HOME!, {recursive: true}) + fs.cpSync(authCacheDir, env.processEnv.XDG_CACHE_HOME!, {recursive: true}) + + await use() + return + } + + // Fallback: run auth login directly (single-worker / no global setup) + log.log(env, 'authenticating automatically') - // Clear any existing session await execa('node', [executables.cli, 'auth', 'logout'], { env: env.processEnv, reject: false, }) - // Spawn auth login via PTY (must not have CI=1) const nodePty = await import('node-pty') const spawnEnv: {[key: string]: string} = {} for (const [key, value] of Object.entries(env.processEnv)) { if (value !== undefined) spawnEnv[key] = value } spawnEnv.CI = '' - // Print login URL directly instead of opening system browser spawnEnv.CODESPACES = 'true' const ptyProcess = nodePty.spawn('node', [executables.cli, 'auth', 'login'], { diff --git a/packages/e2e/setup/browser.ts b/packages/e2e/setup/browser.ts index ff3fa20dc7..e0270fc711 100644 --- a/packages/e2e/setup/browser.ts +++ b/packages/e2e/setup/browser.ts @@ -28,10 +28,12 @@ export const browserFixture = cliFixture.extend<{}, {browserPage: Page}>({ // eslint-disable-next-line no-empty-pattern async ({}, use) => { const browser = await chromium.launch({headless: !process.env.E2E_HEADED}) + const storageStatePath = process.env.E2E_BROWSER_STATE_PATH const context = await browser.newContext({ extraHTTPHeaders: { 'X-Shopify-Loadtest-Bf8d22e7-120e-4b5b-906c-39ca9d5499a9': 'true', }, + ...(storageStatePath ? {storageState: storageStatePath} : {}), }) context.setDefaultTimeout(BROWSER_TIMEOUT.max) context.setDefaultNavigationTimeout(BROWSER_TIMEOUT.max) diff --git a/packages/e2e/setup/env.ts b/packages/e2e/setup/env.ts index a6a4a2c9ac..0d862a8299 100644 --- a/packages/e2e/setup/env.ts +++ b/packages/e2e/setup/env.ts @@ -72,6 +72,13 @@ export function requireEnv(env: E2EEnv, ...keys: (keyof Pick { + output += data + if (debug) process.stdout.write(data) + }) + + await waitForText(() => output, 'Open this 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(`[e2e] global-auth: could not find login URL in output:\n${stripped}`) + } + + // Complete login in a headless browser + const browser = await chromium.launch({headless: !process.env.E2E_HEADED}) + const context = await browser.newContext({ + extraHTTPHeaders: { + 'X-Shopify-Loadtest-Bf8d22e7-120e-4b5b-906c-39ca9d5499a9': 'true', + }, + }) + const page = await context.newPage() + + await completeLogin(page, urlMatch[0], email, password) + + await waitForText(() => output, 'Logged in', BROWSER_TIMEOUT.max) + try { + ptyProcess.kill() + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (_error) { + // Process may already be dead + } + + // Visit admin.shopify.com and dev.shopify.com to establish session cookies + // (completeLogin only authenticates on accounts.shopify.com) + const orgId = (process.env.E2E_ORG_ID ?? '').trim() + if (orgId) { + // Establish admin.shopify.com cookies + await page.goto('https://admin.shopify.com/', {waitUntil: 'domcontentloaded'}) + await page.waitForTimeout(BROWSER_TIMEOUT.medium) + + // Handle account picker if shown + if (isAccountsShopifyUrl(page.url())) { + const accountButton = page.locator(`text=${email}`).first() + if (await accountButton.isVisible({timeout: BROWSER_TIMEOUT.long}).catch(() => false)) { + await accountButton.click() + await page.waitForTimeout(BROWSER_TIMEOUT.medium) + } + } + + // Establish dev.shopify.com cookies + await page.goto(`https://dev.shopify.com/dashboard/${orgId}/apps`, {waitUntil: 'domcontentloaded'}) + await page.waitForTimeout(BROWSER_TIMEOUT.medium) + + if (isAccountsShopifyUrl(page.url())) { + const accountButton = page.locator(`text=${email}`).first() + if (await accountButton.isVisible({timeout: BROWSER_TIMEOUT.long}).catch(() => false)) { + await accountButton.click() + await page.waitForTimeout(BROWSER_TIMEOUT.medium) + } + } + + globalLog('auth', 'browser sessions established for admin + dev dashboard') + } + + // Save browser cookies/storage so workers can reuse the session + // Now includes cookies for both accounts.shopify.com AND admin.shopify.com + const storageStatePath = path.join(tmpBase, 'browser-storage-state.json') + await context.storageState({path: storageStatePath}) + await browser.close() + + // Store paths so workers can copy CLI auth + load browser state + /* eslint-disable require-atomic-updates */ + process.env.E2E_AUTH_CONFIG_DIR = xdgEnv.XDG_CONFIG_HOME + process.env.E2E_AUTH_DATA_DIR = xdgEnv.XDG_DATA_HOME + process.env.E2E_AUTH_STATE_DIR = xdgEnv.XDG_STATE_HOME + process.env.E2E_AUTH_CACHE_DIR = xdgEnv.XDG_CACHE_HOME + process.env.E2E_BROWSER_STATE_PATH = storageStatePath + /* eslint-enable require-atomic-updates */ + + globalLog('auth', `global setup done, config at ${xdgEnv.XDG_CONFIG_HOME}`) +}