Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/e2e/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
40 changes: 30 additions & 10 deletions packages/e2e/setup/auth.ts
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
*/
Expand All @@ -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'], {
Expand Down
2 changes: 2 additions & 0 deletions packages/e2e/setup/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions packages/e2e/setup/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ export function requireEnv(env: E2EEnv, ...keys: (keyof Pick<E2EEnv, 'storeFqdn'
}
}

/** Log a message during global setup (before workers start). Only prints when DEBUG=1. */
export function globalLog(tag: string, msg: string): void {
if (process.env.DEBUG === '1') {
process.stdout.write(`[e2e][${tag}] ${msg}\n`)
}
}

/**
* Worker-scoped fixture providing environment configuration.
* Env vars are optional — tests that need them should call requireEnv().
Expand Down
156 changes: 156 additions & 0 deletions packages/e2e/setup/global-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/**
* Playwright globalSetup — authenticates once before any workers start.
*
* Performs CLI `auth login` with a dedicated temp dir, then stores the
* path in E2E_AUTH_CONFIG_DIR so each worker can copy the session files
* into its own isolated XDG dirs.
*/

/* eslint-disable no-restricted-imports */
import {createIsolatedEnv, 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} 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')

// Create a temp dir for the auth session
const tmpBase = process.env.E2E_TEMP_DIR ?? path.join(directories.root, '.e2e-tmp')
fs.mkdirSync(tmpBase, {recursive: true})
const {xdgEnv} = createIsolatedEnv(tmpBase)

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,
}

// 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)
})

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}`)
}
Loading