-
Notifications
You must be signed in to change notification settings - Fork 247
E2E: one-time global auth before test start #7272
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
e3c0788
097b81a
2e10289
191ec88
54c055d
0e57009
cb219bf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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,47 @@ 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 | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. do you need any of this at all if you run global auth once before the tests run, then don't call login() or any auth commands in any of the tests?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes! This copying step is needed for parallel worker isolation. To be clear: this separation is for CLI command isolation, not for browser or login needs. It’s correct that browser auth works fine with a single shared Global auth creates one set of CLI tokens in The CLI writes to these local stores during commands:
The critical one is Another original reason to keep |
||||||||||||||
| 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') | ||||||||||||||
|
|
||||||||||||||
| if ( | ||||||||||||||
| !fs.existsSync(authConfigDir) || | ||||||||||||||
| !fs.existsSync(authDataDir) || | ||||||||||||||
| !fs.existsSync(authStateDir) || | ||||||||||||||
| !fs.existsSync(authCacheDir) | ||||||||||||||
| ) { | ||||||||||||||
| throw new Error('Global auth dirs missing — global setup may not have completed successfully') | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| 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'], { | ||||||||||||||
|
|
||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,187 @@ | ||
| /** | ||
| * Playwright globalSetup — authenticates once before any workers start. | ||
| * | ||
| * Uses a stable `global-auth/` dir for session caching across runs. | ||
| * On subsequent runs, validates the cached browser session before | ||
| * re-authenticating. Workers copy the session files into their own | ||
| * isolated XDG dirs via E2E_AUTH_* env vars. | ||
| */ | ||
|
|
||
| /* eslint-disable no-restricted-imports */ | ||
| import {directories, executables, globalLog} from './env.js' | ||
| import {CLI_TIMEOUT, BROWSER_TIMEOUT} from './constants.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 {chromium, type Page} from '@playwright/test' | ||
| import * as path from 'path' | ||
| import * as fs from 'fs' | ||
|
|
||
| function isAccountsShopifyUrl(rawUrl: string): boolean { | ||
| try { | ||
| return new URL(rawUrl).hostname === 'accounts.shopify.com' | ||
| // eslint-disable-next-line no-catch-all/no-catch-all | ||
| } catch { | ||
| return false | ||
| } | ||
| } | ||
|
|
||
| export default async function globalSetup() { | ||
| const email = process.env.E2E_ACCOUNT_EMAIL | ||
| const password = process.env.E2E_ACCOUNT_PASSWORD | ||
|
|
||
| if (!email || !password) return | ||
|
|
||
| const debug = process.env.DEBUG === '1' | ||
| globalLog('auth', 'global setup starting') | ||
|
|
||
| // Use a stable auth dir (reused across runs for session caching) | ||
| const tmpBase = process.env.E2E_TEMP_DIR ?? path.join(directories.root, '.e2e-tmp') | ||
| fs.mkdirSync(tmpBase, {recursive: true}) | ||
| const authDir = path.join(tmpBase, 'global-auth') | ||
| const storageStatePath = path.join(authDir, 'browser-storage-state.json') | ||
|
|
||
| const xdgEnv = { | ||
| 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'), | ||
| } | ||
|
|
||
| const processEnv: NodeJS.ProcessEnv = { | ||
| ...process.env, | ||
| ...xdgEnv, | ||
| SHOPIFY_RUN_AS_USER: '0', | ||
| NODE_OPTIONS: '', | ||
| CI: '1', | ||
| SHOPIFY_CLI_1P_DEV: undefined, | ||
| SHOPIFY_FLAG_CLIENT_ID: undefined, | ||
| } | ||
|
|
||
| // Check if cached session from a previous run is still valid | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we don't need a cache only for local runs. It will be used very sporadically, and I think it's better to keep the same logic as CI. And code will be simpler as well. |
||
| if (fs.existsSync(storageStatePath)) { | ||
| const browser = await chromium.launch({headless: true}) | ||
| try { | ||
| const context = await browser.newContext({storageState: storageStatePath}) | ||
| const page = await context.newPage() | ||
| await page.goto('https://admin.shopify.com/', {waitUntil: 'domcontentloaded', timeout: BROWSER_TIMEOUT.long}) | ||
| if (!isAccountsShopifyUrl(page.url())) { | ||
| globalLog('auth', 'reusing cached session') | ||
| setAuthEnvVars(xdgEnv, storageStatePath) | ||
| return | ||
| } | ||
| // eslint-disable-next-line no-catch-all/no-catch-all | ||
| } catch (_error) { | ||
| // Browser check failed — fall through to re-authenticate | ||
| } finally { | ||
| await browser.close().catch(() => {}) | ||
| } | ||
| globalLog('auth', 'cached session expired, re-authenticating') | ||
| } else { | ||
| globalLog('auth', 'no cached session found') | ||
| } | ||
|
|
||
| // Create fresh XDG dirs | ||
| for (const dir of Object.values(xdgEnv)) { | ||
| fs.mkdirSync(dir, {recursive: true}) | ||
| } | ||
|
|
||
| // Clear any existing session | ||
| await execa('node', [executables.cli, 'auth', 'logout'], { | ||
| env: processEnv, | ||
| reject: false, | ||
| }) | ||
|
|
||
| // Spawn auth login via PTY | ||
| const nodePty = await import('node-pty') | ||
| const spawnEnv: {[key: string]: string} = {} | ||
| for (const [key, value] of Object.entries(processEnv)) { | ||
| 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 | ||
| if (debug) process.stdout.write(data) | ||
| }) | ||
|
|
||
| try { | ||
| 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}) | ||
| try { | ||
| 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) | ||
|
|
||
| // 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) { | ||
| await visitAndHandleAccountPicker(page, 'https://admin.shopify.com/', email) | ||
| await visitAndHandleAccountPicker(page, `https://dev.shopify.com/dashboard/${orgId}/apps`, email) | ||
| globalLog('auth', 'browser sessions established for admin + dev dashboard') | ||
| } | ||
|
|
||
| // Save browser cookies/storage so workers can reuse the session | ||
| await context.storageState({path: storageStatePath}) | ||
| } finally { | ||
| await browser.close() | ||
| } | ||
| } finally { | ||
| try { | ||
| ptyProcess.kill() | ||
| // eslint-disable-next-line no-catch-all/no-catch-all | ||
| } catch (_error) { | ||
| // Process may already be dead | ||
| } | ||
| } | ||
|
|
||
| setAuthEnvVars(xdgEnv, storageStatePath) | ||
| globalLog('auth', `global setup done, config at ${xdgEnv.XDG_CONFIG_HOME}`) | ||
| } | ||
|
|
||
| /** Navigate to a URL and dismiss the account picker if it appears. */ | ||
| async function visitAndHandleAccountPicker(page: Page, url: string, email: string) { | ||
| await page.goto(url, {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) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| function setAuthEnvVars(xdgEnv: Record<string, string>, storageStatePath: string): void { | ||
| 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 | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.