Skip to content

Commit e3c0788

Browse files
committed
E2E: one-time global auth before test start
1 parent 9e41617 commit e3c0788

5 files changed

Lines changed: 205 additions & 10 deletions

File tree

packages/e2e/playwright.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ config()
88
const isCI = Boolean(process.env.CI)
99

1010
export default defineConfig({
11+
globalSetup: './setup/global-auth.ts',
1112
testDir: './tests',
1213
fullyParallel: false,
1314
forbidOnly: isCI,

packages/e2e/setup/auth.ts

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,39 @@
1-
import {CLI_TIMEOUT, BROWSER_TIMEOUT} from './constants.js'
1+
/* eslint-disable no-restricted-imports */
22
import {browserFixture} from './browser.js'
3-
import {executables} from './env.js'
3+
import {CLI_TIMEOUT, BROWSER_TIMEOUT} from './constants.js'
4+
import {globalLog, executables} from './env.js'
45
import {stripAnsi} from '../helpers/strip-ansi.js'
56
import {waitForText} from '../helpers/wait-for-text.js'
67
import {completeLogin} from '../helpers/browser-login.js'
78
import {execa} from 'execa'
9+
import * as fs from 'fs'
10+
import * as path from 'path'
11+
12+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
13+
const log = {log: (_ctx: any, msg: string) => globalLog('auth', msg)}
814

915
/**
10-
* Worker-scoped fixture that performs OAuth login using the shared browser page.
16+
* Copy directory contents recursively.
17+
*/
18+
function copyDirSync(src: string, dest: string): void {
19+
fs.mkdirSync(dest, {recursive: true})
20+
for (const entry of fs.readdirSync(src, {withFileTypes: true})) {
21+
const srcPath = path.join(src, entry.name)
22+
const destPath = path.join(dest, entry.name)
23+
if (entry.isDirectory()) {
24+
copyDirSync(srcPath, destPath)
25+
} else {
26+
fs.copyFileSync(srcPath, destPath)
27+
}
28+
}
29+
}
30+
31+
/**
32+
* Worker-scoped fixture that provides an authenticated CLI session.
1133
*
12-
* Extends browserFixture — the browser is already running when auth starts.
13-
* After login, the CLI session is stored in XDG dirs and the browser page
14-
* remains available for other browser-based actions (dashboard navigation, etc.).
34+
* If globalSetup already ran auth (E2E_AUTH_CONFIG_DIR is set), copies the
35+
* pre-authenticated session files into this worker's isolated XDG dirs.
36+
* Otherwise falls back to running auth login directly (single-worker mode).
1537
*
1638
* Fixture chain: envFixture → cliFixture → browserFixture → authFixture
1739
*/
@@ -26,22 +48,38 @@ export const authFixture = browserFixture.extend<{}, {authLogin: void}>({
2648
return
2749
}
2850

29-
process.stdout.write('[e2e] Authenticating automatically — no action required.\n')
51+
const authConfigDir = process.env.E2E_AUTH_CONFIG_DIR
52+
const authDataDir = process.env.E2E_AUTH_DATA_DIR
53+
const authStateDir = process.env.E2E_AUTH_STATE_DIR
54+
const authCacheDir = process.env.E2E_AUTH_CACHE_DIR
55+
56+
if (authConfigDir && authDataDir && authStateDir && authCacheDir) {
57+
// Copy pre-authenticated session from global setup
58+
log.log(env, 'copying session from global setup')
59+
60+
copyDirSync(authConfigDir, env.processEnv.XDG_CONFIG_HOME!)
61+
copyDirSync(authDataDir, env.processEnv.XDG_DATA_HOME!)
62+
copyDirSync(authStateDir, env.processEnv.XDG_STATE_HOME!)
63+
copyDirSync(authCacheDir, env.processEnv.XDG_CACHE_HOME!)
64+
65+
await use()
66+
return
67+
}
68+
69+
// Fallback: run auth login directly (single-worker / no global setup)
70+
log.log(env, ' authenticating automatically')
3071

31-
// Clear any existing session
3272
await execa('node', [executables.cli, 'auth', 'logout'], {
3373
env: env.processEnv,
3474
reject: false,
3575
})
3676

37-
// Spawn auth login via PTY (must not have CI=1)
3877
const nodePty = await import('node-pty')
3978
const spawnEnv: {[key: string]: string} = {}
4079
for (const [key, value] of Object.entries(env.processEnv)) {
4180
if (value !== undefined) spawnEnv[key] = value
4281
}
4382
spawnEnv.CI = ''
44-
// Print login URL directly instead of opening system browser
4583
spawnEnv.CODESPACES = 'true'
4684

4785
const ptyProcess = nodePty.spawn('node', [executables.cli, 'auth', 'login'], {

packages/e2e/setup/browser.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,12 @@ export const browserFixture = cliFixture.extend<{}, {browserPage: Page}>({
2828
// eslint-disable-next-line no-empty-pattern
2929
async ({}, use) => {
3030
const browser = await chromium.launch({headless: !process.env.E2E_HEADED})
31+
const storageStatePath = process.env.E2E_BROWSER_STATE_PATH
3132
const context = await browser.newContext({
3233
extraHTTPHeaders: {
3334
'X-Shopify-Loadtest-Bf8d22e7-120e-4b5b-906c-39ca9d5499a9': 'true',
3435
},
36+
...(storageStatePath ? {storageState: storageStatePath} : {}),
3537
})
3638
context.setDefaultTimeout(BROWSER_TIMEOUT.max)
3739
context.setDefaultNavigationTimeout(BROWSER_TIMEOUT.max)

packages/e2e/setup/env.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,13 @@ export function requireEnv(env: E2EEnv, ...keys: (keyof Pick<E2EEnv, 'storeFqdn'
7272
}
7373
}
7474

75+
/** Log a message during global setup (before workers start). Only prints when DEBUG=1. */
76+
export function globalLog(tag: string, msg: string): void {
77+
if (process.env.DEBUG === '1') {
78+
process.stdout.write(`[e2e][${tag}] ${msg}\n`)
79+
}
80+
}
81+
7582
/**
7683
* Worker-scoped fixture providing environment configuration.
7784
* Env vars are optional — tests that need them should call requireEnv().

packages/e2e/setup/global-auth.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/**
2+
* Playwright globalSetup — authenticates once before any workers start.
3+
*
4+
* Performs CLI `auth login` with a dedicated temp dir, then stores the
5+
* path in E2E_AUTH_CONFIG_DIR so each worker can copy the session files
6+
* into its own isolated XDG dirs.
7+
*/
8+
9+
/* eslint-disable no-restricted-imports, @shopify/cli/no-process-cwd */
10+
import {createIsolatedEnv, executables, globalLog} from './env.js'
11+
import {CLI_TIMEOUT, BROWSER_TIMEOUT} from './constants.js'
12+
import {stripAnsi} from '../helpers/strip-ansi.js'
13+
import {waitForText} from '../helpers/wait-for-text.js'
14+
import {completeLogin} from '../helpers/browser-login.js'
15+
import {execa} from 'execa'
16+
import {chromium} from '@playwright/test'
17+
import * as path from 'path'
18+
import * as fs from 'fs'
19+
20+
export default async function globalSetup() {
21+
const email = process.env.E2E_ACCOUNT_EMAIL
22+
const password = process.env.E2E_ACCOUNT_PASSWORD
23+
24+
if (!email || !password) return
25+
26+
const debug = process.env.DEBUG === '1'
27+
globalLog('auth', 'global setup starting')
28+
29+
// Create a temp dir for the auth session
30+
const tmpBase = process.env.E2E_TEMP_DIR ?? path.join(process.cwd(), '.e2e-tmp')
31+
fs.mkdirSync(tmpBase, {recursive: true})
32+
const {xdgEnv} = createIsolatedEnv(tmpBase)
33+
34+
const processEnv: NodeJS.ProcessEnv = {
35+
...process.env,
36+
...xdgEnv,
37+
SHOPIFY_RUN_AS_USER: '0',
38+
NODE_OPTIONS: '',
39+
CI: '1',
40+
SHOPIFY_CLI_1P_DEV: undefined,
41+
SHOPIFY_FLAG_CLIENT_ID: undefined,
42+
}
43+
44+
// Clear any existing session
45+
await execa('node', [executables.cli, 'auth', 'logout'], {
46+
env: processEnv,
47+
reject: false,
48+
})
49+
50+
// Spawn auth login via PTY
51+
const nodePty = await import('node-pty')
52+
const spawnEnv: {[key: string]: string} = {}
53+
for (const [key, value] of Object.entries(processEnv)) {
54+
if (value !== undefined) spawnEnv[key] = value
55+
}
56+
spawnEnv.CI = ''
57+
spawnEnv.CODESPACES = 'true'
58+
59+
const ptyProcess = nodePty.spawn('node', [executables.cli, 'auth', 'login'], {
60+
name: 'xterm-color',
61+
cols: 120,
62+
rows: 30,
63+
env: spawnEnv,
64+
})
65+
66+
let output = ''
67+
ptyProcess.onData((data: string) => {
68+
output += data
69+
if (debug) process.stdout.write(data)
70+
})
71+
72+
await waitForText(() => output, 'Open this link to start the auth process', CLI_TIMEOUT.short)
73+
74+
const stripped = stripAnsi(output)
75+
const urlMatch = stripped.match(/https:\/\/accounts\.shopify\.com\S+/)
76+
if (!urlMatch) {
77+
throw new Error(`[e2e] global-auth: could not find login URL in output:\n${stripped}`)
78+
}
79+
80+
// Complete login in a headless browser
81+
const browser = await chromium.launch({headless: !process.env.E2E_HEADED})
82+
const context = await browser.newContext({
83+
extraHTTPHeaders: {
84+
'X-Shopify-Loadtest-Bf8d22e7-120e-4b5b-906c-39ca9d5499a9': 'true',
85+
},
86+
})
87+
const page = await context.newPage()
88+
89+
await completeLogin(page, urlMatch[0], email, password)
90+
91+
await waitForText(() => output, 'Logged in', BROWSER_TIMEOUT.max)
92+
try {
93+
ptyProcess.kill()
94+
// eslint-disable-next-line no-catch-all/no-catch-all
95+
} catch (_error) {
96+
// Process may already be dead
97+
}
98+
99+
// Visit admin.shopify.com and dev.shopify.com to establish session cookies
100+
// (completeLogin only authenticates on accounts.shopify.com)
101+
const orgId = (process.env.E2E_ORG_ID ?? '').trim()
102+
if (orgId) {
103+
// Establish admin.shopify.com cookies
104+
await page.goto('https://admin.shopify.com/', {waitUntil: 'domcontentloaded'})
105+
await page.waitForTimeout(BROWSER_TIMEOUT.medium)
106+
107+
// Handle account picker if shown
108+
if (page.url().includes('accounts.shopify.com')) {
109+
const accountButton = page.locator(`text=${email}`).first()
110+
if (await accountButton.isVisible({timeout: BROWSER_TIMEOUT.long}).catch(() => false)) {
111+
await accountButton.click()
112+
await page.waitForTimeout(BROWSER_TIMEOUT.medium)
113+
}
114+
}
115+
116+
// Establish dev.shopify.com cookies
117+
await page.goto(`https://dev.shopify.com/dashboard/${orgId}/apps`, {waitUntil: 'domcontentloaded'})
118+
await page.waitForTimeout(BROWSER_TIMEOUT.medium)
119+
120+
if (page.url().includes('accounts.shopify.com')) {
121+
const accountButton = page.locator(`text=${email}`).first()
122+
if (await accountButton.isVisible({timeout: BROWSER_TIMEOUT.long}).catch(() => false)) {
123+
await accountButton.click()
124+
await page.waitForTimeout(BROWSER_TIMEOUT.medium)
125+
}
126+
}
127+
128+
globalLog('auth', 'browser sessions established for admin + dev dashboard')
129+
}
130+
131+
// Save browser cookies/storage so workers can reuse the session
132+
// Now includes cookies for both accounts.shopify.com AND admin.shopify.com
133+
const storageStatePath = path.join(tmpBase, 'browser-storage-state.json')
134+
await context.storageState({path: storageStatePath})
135+
await browser.close()
136+
137+
// Store paths so workers can copy CLI auth + load browser state
138+
/* eslint-disable require-atomic-updates */
139+
process.env.E2E_AUTH_CONFIG_DIR = xdgEnv.XDG_CONFIG_HOME
140+
process.env.E2E_AUTH_DATA_DIR = xdgEnv.XDG_DATA_HOME
141+
process.env.E2E_AUTH_STATE_DIR = xdgEnv.XDG_STATE_HOME
142+
process.env.E2E_AUTH_CACHE_DIR = xdgEnv.XDG_CACHE_HOME
143+
process.env.E2E_BROWSER_STATE_PATH = storageStatePath
144+
/* eslint-enable require-atomic-updates */
145+
146+
globalLog('auth', `global setup done, config at ${xdgEnv.XDG_CONFIG_HOME}`)
147+
}

0 commit comments

Comments
 (0)