From 3b958bda866bf3cd853c1fddb9e1ed81db3571c8 Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Fri, 27 Feb 2026 12:20:33 -0700 Subject: [PATCH 1/6] Add OAuth login, app scaffold, deploy, and dev server e2e tests Browser-automated OAuth login via Playwright + genghis account with Cloudflare bypass header for CI compatibility. Tests: - App scaffold: init (react-router + extension-only) and build - App deploy: deploy with version tag + versions list verification - App dev server: start, ready detection, quit with 'q' key - Extension generation (skipped pending BP API auth fix) Includes create-test-apps.ts script for provisioning test apps. --- packages/e2e/scripts/create-test-apps.ts | 210 ++++++++++++++++ packages/e2e/setup/app-scaffold.ts | 277 ++++++++++++++++++++++ packages/e2e/tests/app-deploy.spec.ts | 46 ++++ packages/e2e/tests/app-dev-server.spec.ts | 34 +++ packages/e2e/tests/app-scaffold.spec.ts | 72 ++++++ 5 files changed, 639 insertions(+) create mode 100644 packages/e2e/scripts/create-test-apps.ts create mode 100644 packages/e2e/setup/app-scaffold.ts create mode 100644 packages/e2e/tests/app-deploy.spec.ts create mode 100644 packages/e2e/tests/app-dev-server.spec.ts create mode 100644 packages/e2e/tests/app-scaffold.spec.ts diff --git a/packages/e2e/scripts/create-test-apps.ts b/packages/e2e/scripts/create-test-apps.ts new file mode 100644 index 00000000000..e55964a99e5 --- /dev/null +++ b/packages/e2e/scripts/create-test-apps.ts @@ -0,0 +1,210 @@ +/** + * Creates test apps in the authenticated org and prints their client IDs. + * Run: npx tsx packages/e2e/scripts/create-test-apps.ts + */ + +import * as fs from 'fs' +import * as path from 'path' +import * as os from 'os' +import {fileURLToPath} from 'url' +import {execa} from 'execa' +import {chromium} from '@playwright/test' +import stripAnsiModule from 'strip-ansi' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const rootDir = path.resolve(__dirname, '../../..') +const cliPath = path.join(rootDir, 'packages/cli/bin/run.js') +const createAppPath = path.join(rootDir, 'packages/create-app/bin/run.js') + +// Load .env +const envPath = path.join(__dirname, '../.env') +if (fs.existsSync(envPath)) { + for (const line of fs.readFileSync(envPath, 'utf-8').split('\n')) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + const eqIdx = trimmed.indexOf('=') + if (eqIdx === -1) continue + const key = trimmed.slice(0, eqIdx).trim() + const value = trimmed.slice(eqIdx + 1).trim() + if (!process.env[key]) process.env[key] = value + } +} + +const email = process.env.E2E_ACCOUNT_EMAIL +const password = process.env.E2E_ACCOUNT_PASSWORD +if (!email || !password) { + console.error('E2E_ACCOUNT_EMAIL and E2E_ACCOUNT_PASSWORD must be set') + process.exit(1) +} + +const baseEnv: Record = { + ...process.env as Record, + NODE_OPTIONS: '', + SHOPIFY_RUN_AS_USER: '0', + FORCE_COLOR: '0', +} +delete baseEnv.SHOPIFY_CLI_PARTNERS_TOKEN +delete baseEnv.SHOPIFY_FLAG_CLIENT_ID +delete baseEnv.CI + +async function main() { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'e2e-create-apps-')) + console.log(`Working directory: ${tmpDir}`) + + // Step 1: OAuth login + console.log('\n--- Logging out ---') + await execa('node', [cliPath, 'auth', 'logout'], {env: baseEnv, reject: false}) + + console.log('\n--- Logging in via OAuth ---') + await oauthLogin() + console.log('Logged in successfully!') + + // Step 2: Create primary app via PTY (needs interactive prompts) + console.log('\n--- Creating primary test app ---') + const primaryClientId = await createAppInteractive(tmpDir, 'cli-e2e-primary') + console.log(`Primary app client ID: ${primaryClientId}`) + + // Step 3: Create secondary app + console.log('\n--- Creating secondary test app ---') + const secondaryClientId = await createAppInteractive(tmpDir, 'cli-e2e-secondary') + console.log(`Secondary app client ID: ${secondaryClientId}`) + + // Print summary + console.log('\n========================================') + console.log('Add these to your packages/e2e/.env:') + console.log('========================================') + console.log(`SHOPIFY_FLAG_CLIENT_ID=${primaryClientId}`) + console.log(`E2E_SECONDARY_CLIENT_ID=${secondaryClientId}`) + console.log('========================================') + + fs.rmSync(tmpDir, {recursive: true, force: true}) +} + +async function createAppInteractive(tmpDir: string, appName: string): Promise { + const appDir = path.join(tmpDir, appName) + fs.mkdirSync(appDir) + + const nodePty = await import('node-pty') + const pty = nodePty.spawn('node', [ + createAppPath, + '--name', appName, + '--path', appDir, + '--template', 'none', + '--package-manager', 'npm', + '--local', + ], { + name: 'xterm-color', cols: 120, rows: 30, env: baseEnv, + }) + + let output = '' + pty.onData((data: string) => { + output += data + process.stdout.write(data) + }) + + // Answer each interactive prompt as it appears + const prompts = [ + 'Which organization', + 'Create this project as a new app', + 'App name', + ] + for (const prompt of prompts) { + try { + await waitForText(() => output, prompt, 60_000) + await sleep(500) + pty.write('\r') + } catch { + // Prompt may not appear (e.g. single org skips selection) + if (stripAnsiModule(output).includes('is ready for you to build')) break + } + } + + // Wait for completion + await waitForText(() => output, 'is ready for you to build', 120_000) + + const exitCode = await new Promise((resolve) => { + pty.onExit(({exitCode}) => resolve(exitCode)) + }) + if (exitCode !== 0) throw new Error(`app init exited with code ${exitCode}`) + + // Find the app dir and extract client_id + const entries = fs.readdirSync(appDir, {withFileTypes: true}) + const created = entries.find( + (e) => e.isDirectory() && fs.existsSync(path.join(appDir, e.name, 'shopify.app.toml')), + ) + if (!created) throw new Error(`No app directory found in ${appDir}`) + + const tomlPath = path.join(appDir, created.name, 'shopify.app.toml') + const toml = fs.readFileSync(tomlPath, 'utf-8') + const match = toml.match(/client_id\s*=\s*"([^"]+)"/) + if (!match) throw new Error(`No client_id in ${tomlPath}`) + + return match[1] +} + +async function oauthLogin() { + const nodePty = await import('node-pty') + const spawnEnv = {...baseEnv, BROWSER: 'none'} + const pty = nodePty.spawn('node', [cliPath, 'auth', 'login'], { + name: 'xterm-color', cols: 120, rows: 30, env: spawnEnv, + }) + + let output = '' + pty.onData((data: string) => { output += data }) + + await waitForText(() => output, 'Press any key to open the login page', 30_000) + pty.write(' ') + await waitForText(() => output, 'start the auth process', 10_000) + + const stripped = stripAnsiModule(output) + const urlMatch = stripped.match(/https:\/\/accounts\.shopify\.com\S+/) + if (!urlMatch) throw new Error(`No login URL found:\n${stripped}`) + + const browser = await chromium.launch({headless: false}) + const context = await browser.newContext({ + extraHTTPHeaders: {'X-Shopify-Loadtest-Bf8d22e7-120e-4b5b-906c-39ca9d5499a9': 'true'}, + }) + const page = await context.newPage() + await page.goto(urlMatch[0]) + + await page.waitForSelector('input[name="account[email]"], input[type="email"]', {timeout: 60_000}) + await page.locator('input[name="account[email]"], input[type="email"]').first().fill(email!) + await page.locator('button[type="submit"]').first().click() + await page.waitForSelector('input[name="account[password]"], input[type="password"]', {timeout: 60_000}) + await page.locator('input[name="account[password]"], input[type="password"]').first().fill(password!) + await page.locator('button[type="submit"]').first().click() + await page.waitForTimeout(3000) + try { + const btn = page.locator('button[type="submit"]').first() + if (await btn.isVisible({timeout: 5000})) await btn.click() + } catch {} + + await waitForText(() => output, 'Logged in', 60_000) + try { pty.kill() } catch {} + await browser.close() +} + +function waitForText(getOutput: () => string, text: string, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const interval = setInterval(() => { + if (stripAnsiModule(getOutput()).includes(text) || getOutput().includes(text)) { + clearInterval(interval) + clearTimeout(timer) + resolve() + } + }, 200) + const timer = setTimeout(() => { + clearInterval(interval) + reject(new Error(`Timed out waiting for: "${text}"\nOutput:\n${stripAnsiModule(getOutput())}`)) + }, timeoutMs) + }) +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/packages/e2e/setup/app-scaffold.ts b/packages/e2e/setup/app-scaffold.ts new file mode 100644 index 00000000000..89651fc0811 --- /dev/null +++ b/packages/e2e/setup/app-scaffold.ts @@ -0,0 +1,277 @@ +/* eslint-disable no-restricted-imports */ +import {cliFixture} from './cli-process.js' +import {executables} from './env.js' +import {stripAnsi} from '../helpers/strip-ansi.js' +import {chromium, type Browser, type Page} from '@playwright/test' +import {execa} from 'execa' +import * as path from 'path' +import * as fs from 'fs' +import type {ExecResult} from './cli-process.js' + +export interface AppScaffold { + /** The directory where the app was created */ + appDir: string + /** Create a new app from a template */ + init(opts: AppInitOptions): Promise + /** Generate an extension in the app */ + generateExtension(opts: ExtensionOptions): Promise + /** Build the app */ + build(): Promise + /** Get app info as JSON */ + appInfo(): Promise +} + +export interface AppInitOptions { + name?: string + template?: 'reactRouter' | 'remix' | 'none' + flavor?: 'javascript' | 'typescript' + packageManager?: 'npm' | 'yarn' | 'pnpm' | 'bun' +} + +export interface ExtensionOptions { + name: string + template: string + flavor?: string +} + +export interface AppInfoResult { + packageManager: string + allExtensions: { + configuration: {name: string; type: string; handle?: string} + directory: string + outputPath: string + entrySourceFilePath: string + }[] +} + +/** + * Worker-scoped fixture that performs OAuth login via browser automation. + * Runs once per worker, stores the session in shared XDG dirs. + */ +// eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-invalid-void-type +const withAuth = cliFixture.extend<{}, {authLogin: void}>({ + authLogin: [ + async ({env}, use) => { + const email = process.env.E2E_ACCOUNT_EMAIL + const password = process.env.E2E_ACCOUNT_PASSWORD + + if (!email || !password) { + await use() + return + } + + // 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 = '' + spawnEnv.BROWSER = 'none' + + 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 (process.env.DEBUG === '1') process.stdout.write(data) + }) + + await waitForText(() => output, 'Press any key to open the login page', 30_000) + ptyProcess.write(' ') + await waitForText(() => output, 'start the auth process', 10_000) + + const stripped = stripAnsi(output) + const urlMatch = stripped.match(/https:\/\/accounts\.shopify\.com\S+/) + if (!urlMatch) { + throw new Error(`Could not find login URL in output:\n${stripped}`) + } + + let browser: Browser | undefined + try { + 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) + } finally { + await browser?.close() + } + + await waitForText(() => output, 'Logged in', 60_000) + try { + ptyProcess.kill() + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (_error) { + // Process may already be dead + } + + // Remove the partners token so CLI uses the OAuth session + // instead of the token (which can't auth against Business Platform API) + delete env.processEnv.SHOPIFY_CLI_PARTNERS_TOKEN + + await use() + }, + {scope: 'worker'}, + ], +}) + +/** + * Test-scoped fixture that creates a fresh app in a temp directory. + * Depends on authLogin (worker-scoped) for OAuth session. + */ +export const appScaffoldFixture = withAuth.extend<{appScaffold: AppScaffold}>({ + appScaffold: async ({cli, env, authLogin: _authLogin}, use) => { + const appTmpDir = fs.mkdtempSync(path.join(env.tempDir, 'app-')) + let appDir = '' + + const scaffold: AppScaffold = { + get appDir() { + if (!appDir) throw new Error('App has not been initialized yet. Call init() first.') + return appDir + }, + + async init(opts: AppInitOptions) { + const name = opts.name ?? 'e2e-test-app' + const template = opts.template ?? 'reactRouter' + const packageManager = opts.packageManager ?? 'npm' + + const args = [ + '--name', + name, + '--path', + appTmpDir, + '--package-manager', + packageManager, + '--local', + '--template', + template, + ] + if (opts.flavor) args.push('--flavor', opts.flavor) + + const result = await cli.execCreateApp(args, { + env: {FORCE_COLOR: '0'}, + timeout: 5 * 60 * 1000, + }) + + const allOutput = `${result.stdout}\n${result.stderr}` + const match = allOutput.match(/([\w-]+) is ready for you to build!/) + + if (match?.[1]) { + appDir = path.join(appTmpDir, match[1]) + } else { + const entries = fs.readdirSync(appTmpDir, {withFileTypes: true}) + const appEntry = entries.find( + (entry) => entry.isDirectory() && fs.existsSync(path.join(appTmpDir, entry.name, 'shopify.app.toml')), + ) + if (appEntry) { + appDir = path.join(appTmpDir, appEntry.name) + } else { + throw new Error( + `Could not find created app directory in ${appTmpDir}.\n` + + `Exit code: ${result.exitCode}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`, + ) + } + } + + const npmrcPath = path.join(appDir, '.npmrc') + if (!fs.existsSync(npmrcPath)) fs.writeFileSync(npmrcPath, '') + fs.appendFileSync(npmrcPath, 'frozen-lockfile=false\n') + + return result + }, + + async generateExtension(opts: ExtensionOptions) { + const args = [ + 'app', + 'generate', + 'extension', + '--name', + opts.name, + '--path', + appDir, + '--template', + opts.template, + ] + if (opts.flavor) args.push('--flavor', opts.flavor) + return cli.exec(args, {timeout: 5 * 60 * 1000}) + }, + + async build() { + return cli.exec(['app', 'build', '--path', appDir], {timeout: 5 * 60 * 1000}) + }, + + async appInfo(): Promise { + const result = await cli.exec(['app', 'info', '--path', appDir, '--json']) + return JSON.parse(result.stdout) + }, + } + + await use(scaffold) + fs.rmSync(appTmpDir, {recursive: true, force: true}) + }, +}) + +async function completeLogin(page: Page, loginUrl: string, email: string, password: string): Promise { + await page.goto(loginUrl) + + try { + // Fill in email + await page.waitForSelector('input[name="account[email]"], input[type="email"]', {timeout: 60_000}) + await page.locator('input[name="account[email]"], input[type="email"]').first().fill(email) + await page.locator('button[type="submit"]').first().click() + + // Fill in password + await page.waitForSelector('input[name="account[password]"], input[type="password"]', {timeout: 60_000}) + await page.locator('input[name="account[password]"], input[type="password"]').first().fill(password) + await page.locator('button[type="submit"]').first().click() + + // Handle any confirmation/approval page + await page.waitForTimeout(3000) + try { + const btn = page.locator('button[type="submit"]').first() + if (await btn.isVisible({timeout: 5000})) await btn.click() + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (_error) { + // No confirmation page — expected + } + } catch (error) { + const pageContent = await page.content().catch(() => '(failed to get content)') + const pageUrl = page.url() + throw new Error( + `Login failed at ${pageUrl}\n` + + `Original error: ${error}\n` + + `Page HTML (first 2000 chars): ${pageContent.slice(0, 2000)}`, + ) + } +} + +function waitForText(getOutput: () => string, text: string, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const interval = setInterval(() => { + if (stripAnsi(getOutput()).includes(text)) { + clearInterval(interval) + clearTimeout(timer) + resolve() + } + }, 200) + const timer = setTimeout(() => { + clearInterval(interval) + reject(new Error(`Timed out after ${timeoutMs}ms waiting for: "${text}"\n\nOutput:\n${stripAnsi(getOutput())}`)) + }, timeoutMs) + }) +} diff --git a/packages/e2e/tests/app-deploy.spec.ts b/packages/e2e/tests/app-deploy.spec.ts new file mode 100644 index 00000000000..1c024f1cf3e --- /dev/null +++ b/packages/e2e/tests/app-deploy.spec.ts @@ -0,0 +1,46 @@ +import {appScaffoldFixture as test} from '../fixtures/app-scaffold.js' +import {requireEnv} from '../fixtures/env.js' +import {expect} from '@playwright/test' + +test.describe('App deploy', () => { + test('deploy and verify version exists', async ({appScaffold, cli, env}) => { + requireEnv(env, 'clientId') + + // Step 1: Create an extension-only app (no scopes needed for deploy) + const initResult = await appScaffold.init({ + template: 'none', + packageManager: 'npm', + }) + expect(initResult.exitCode).toBe(0) + + // Step 2: Deploy with a tagged version + const versionTag = `e2e-v-${Date.now()}` + const deployResult = await cli.exec( + [ + 'app', + 'deploy', + '--path', + appScaffold.appDir, + '--force', + '--version', + versionTag, + '--message', + 'E2E test deployment', + ], + {timeout: 5 * 60 * 1000}, + ) + const deployOutput = deployResult.stdout + deployResult.stderr + expect(deployResult.exitCode, `deploy failed:\n${deployOutput}`).toBe(0) + + // Step 3: Verify the version exists via versions list + const listResult = await cli.exec(['app', 'versions', 'list', '--path', appScaffold.appDir, '--json'], { + timeout: 60 * 1000, + }) + const listOutput = listResult.stdout + listResult.stderr + expect(listResult.exitCode, `versions list failed:\n${listOutput}`).toBe(0) + + // Check that our version tag appears in the output + const allOutput = listResult.stdout + listResult.stderr + expect(allOutput).toContain(versionTag) + }) +}) diff --git a/packages/e2e/tests/app-dev-server.spec.ts b/packages/e2e/tests/app-dev-server.spec.ts new file mode 100644 index 00000000000..c542dece636 --- /dev/null +++ b/packages/e2e/tests/app-dev-server.spec.ts @@ -0,0 +1,34 @@ +import {appScaffoldFixture as test} from '../fixtures/app-scaffold.js' +import {requireEnv} from '../fixtures/env.js' +import {expect} from '@playwright/test' + +test.describe('App dev server', () => { + test('dev starts, shows ready message, and quits with q', async ({appScaffold, cli, env}) => { + requireEnv(env, 'clientId', 'storeFqdn') + + // Step 1: Create an extension-only app (no scopes needed) + const initResult = await appScaffold.init({ + template: 'none', + packageManager: 'npm', + }) + expect(initResult.exitCode).toBe(0) + + // Step 2: Start dev server via PTY + // Unset CI so keyboard shortcuts are enabled in the Dev UI + const dev = await cli.spawn(['app', 'dev', '--path', appScaffold.appDir], {env: {CI: ''}}) + + // Step 3: Wait for the ready message + await dev.waitForOutput('Ready, watching for changes in your app', 3 * 60 * 1000) + + // Step 4: Verify keyboard shortcuts are shown (indicates TTY mode is working) + const output = dev.getOutput() + expect(output).toContain('q') + + // Step 5: Press q to quit + dev.sendKey('q') + + // Step 6: Wait for clean exit + const exitCode = await dev.waitForExit(30_000) + expect(exitCode).toBe(0) + }) +}) diff --git a/packages/e2e/tests/app-scaffold.spec.ts b/packages/e2e/tests/app-scaffold.spec.ts new file mode 100644 index 00000000000..79f9eeb8aef --- /dev/null +++ b/packages/e2e/tests/app-scaffold.spec.ts @@ -0,0 +1,72 @@ +/* eslint-disable no-restricted-imports */ +import {appScaffoldFixture as test} from '../fixtures/app-scaffold.js' +import {requireEnv} from '../fixtures/env.js' +import {expect} from '@playwright/test' +import * as fs from 'fs' +import * as path from 'path' + +test.describe('App scaffold', () => { + test('init creates a react-router app and builds', async ({appScaffold, env}) => { + requireEnv(env, 'clientId') + + // Step 1: Create a new app from the react-router template + const initResult = await appScaffold.init({ + template: 'reactRouter', + flavor: 'javascript', + packageManager: 'npm', + }) + expect(initResult.exitCode).toBe(0) + // Ink writes to stderr + const initOutput = initResult.stdout + initResult.stderr + expect(initOutput).toContain('is ready for you to build!') + + // Step 2: Verify the app directory was created with expected files + expect(fs.existsSync(appScaffold.appDir)).toBe(true) + expect(fs.existsSync(path.join(appScaffold.appDir, 'shopify.app.toml'))).toBe(true) + expect(fs.existsSync(path.join(appScaffold.appDir, 'package.json'))).toBe(true) + + // Step 3: Build the app + const buildResult = await appScaffold.build() + expect(buildResult.exitCode, `build failed:\nstderr: ${buildResult.stderr}`).toBe(0) + }) + + test('init creates an extension-only app', async ({appScaffold, env}) => { + requireEnv(env, 'clientId') + + const initResult = await appScaffold.init({ + name: 'e2e-ext-only', + template: 'none', + packageManager: 'npm', + }) + expect(initResult.exitCode).toBe(0) + expect(fs.existsSync(appScaffold.appDir)).toBe(true) + expect(fs.existsSync(path.join(appScaffold.appDir, 'shopify.app.toml'))).toBe(true) + }) + + // Extension generation hits businessPlatformOrganizationsRequest which returns 401 + // even with a valid OAuth session. The Business Platform Organizations API token + // exchange needs investigation. OAuth login works, but this specific API rejects it. + test.skip('generate extensions and build', async ({appScaffold, env}) => { + requireEnv(env, 'clientId') + + await appScaffold.init({ + template: 'reactRouter', + flavor: 'javascript', + packageManager: 'npm', + }) + + const extensionConfigs = [ + {name: 'test-product-sub', template: 'product_subscription_ui', flavor: 'react'}, + {name: 'test-theme-ext', template: 'theme_app_extension'}, + ] + + for (const ext of extensionConfigs) { + // eslint-disable-next-line no-await-in-loop + const result = await appScaffold.generateExtension(ext) + expect(result.exitCode, `generate "${ext.name}" failed:\nstderr: ${result.stderr}`).toBe(0) + } + + const buildResult = await appScaffold.build() + expect(buildResult.exitCode, `build failed:\nstderr: ${buildResult.stderr}`).toBe(0) + }) +}) From 75e9e7199b1f7c204d2e22548bc9a7cf528c85f2 Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Fri, 27 Feb 2026 14:07:14 -0700 Subject: [PATCH 2/6] ignore linting for e2e scripts dir --- packages/e2e/package.json | 3 +++ packages/e2e/setup/app-scaffold.ts | 1 - packages/e2e/setup/env.ts | 1 - 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/e2e/package.json b/packages/e2e/package.json index ff126b8d52f..8b9d5de63e8 100644 --- a/packages/e2e/package.json +++ b/packages/e2e/package.json @@ -14,6 +14,9 @@ "extends": [ "../../.eslintrc.cjs" ], + "ignorePatterns": [ + "scripts/" + ], "rules": { "no-console": "off", "import/extensions": [ diff --git a/packages/e2e/setup/app-scaffold.ts b/packages/e2e/setup/app-scaffold.ts index 89651fc0811..4b4206ffb0a 100644 --- a/packages/e2e/setup/app-scaffold.ts +++ b/packages/e2e/setup/app-scaffold.ts @@ -48,7 +48,6 @@ export interface AppInfoResult { * Worker-scoped fixture that performs OAuth login via browser automation. * Runs once per worker, stores the session in shared XDG dirs. */ -// eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-invalid-void-type const withAuth = cliFixture.extend<{}, {authLogin: void}>({ authLogin: [ async ({env}, use) => { diff --git a/packages/e2e/setup/env.ts b/packages/e2e/setup/env.ts index 36e8d8e6c77..ea22c69ceb6 100644 --- a/packages/e2e/setup/env.ts +++ b/packages/e2e/setup/env.ts @@ -85,7 +85,6 @@ export function requireEnv( * Worker-scoped fixture providing auth tokens and environment configuration. * Auth tokens are optional — tests that need them should call requireEnv(). */ -// eslint-disable-next-line @typescript-eslint/no-empty-object-type export const envFixture = base.extend<{}, {env: E2EEnv}>({ env: [ // eslint-disable-next-line no-empty-pattern From dcc6daa47c54067ae944b02844f6505dca02f146 Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Fri, 27 Feb 2026 14:15:19 -0700 Subject: [PATCH 3/6] ignore scripts dir --- packages/e2e/project.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/e2e/project.json b/packages/e2e/project.json index d8ec4c24fe6..79730c73d8a 100644 --- a/packages/e2e/project.json +++ b/packages/e2e/project.json @@ -17,14 +17,14 @@ "lint": { "executor": "nx:run-commands", "options": { - "command": "pnpm eslint \"**/*.ts\"", + "command": "pnpm eslint \"fixtures/**/*.ts\" \"helpers/**/*.ts\" \"tests/**/*.ts\" \"*.ts\"", "cwd": "packages/e2e" } }, "lint:fix": { "executor": "nx:run-commands", "options": { - "command": "pnpm eslint '**/*.ts' --fix", + "command": "pnpm eslint 'fixtures/**/*.ts' 'helpers/**/*.ts' 'tests/**/*.ts' '*.ts' --fix", "cwd": "packages/e2e" } }, From 3bc98fdb2be11bc835ace66f768e711a5e105bc5 Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Thu, 5 Mar 2026 10:01:47 -0700 Subject: [PATCH 4/6] prevent spawning system browser for playwright --- packages/e2e/setup/app-scaffold.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/e2e/setup/app-scaffold.ts b/packages/e2e/setup/app-scaffold.ts index 4b4206ffb0a..4d05b1a5b89 100644 --- a/packages/e2e/setup/app-scaffold.ts +++ b/packages/e2e/setup/app-scaffold.ts @@ -72,7 +72,9 @@ const withAuth = cliFixture.extend<{}, {authLogin: void}>({ if (value !== undefined) spawnEnv[key] = value } spawnEnv.CI = '' - spawnEnv.BROWSER = 'none' + // Pretend we're in a cloud environment so the CLI prints the login URL + // directly instead of opening a system browser (BROWSER=none doesn't work on macOS) + spawnEnv.CODESPACES = 'true' const ptyProcess = nodePty.spawn('node', [executables.cli, 'auth', 'login'], { name: 'xterm-color', @@ -87,9 +89,7 @@ const withAuth = cliFixture.extend<{}, {authLogin: void}>({ if (process.env.DEBUG === '1') process.stdout.write(data) }) - await waitForText(() => output, 'Press any key to open the login page', 30_000) - ptyProcess.write(' ') - await waitForText(() => output, 'start the auth process', 10_000) + await waitForText(() => output, 'Open this link to start the auth process', 30_000) const stripped = stripAnsi(output) const urlMatch = stripped.match(/https:\/\/accounts\.shopify\.com\S+/) From f14eb3577c2862b1257915ba9f3c28e52ad43009 Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Thu, 5 Mar 2026 11:12:38 -0700 Subject: [PATCH 5/6] =?UTF-8?q?Restructure=20e2e:=20fixtures/=20=E2=86=92?= =?UTF-8?q?=20setup/,=20extract=20shared=20helpers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename fixtures/ to setup/ (Playwright fixture definitions only) - Rename cli-process.ts → cli.ts, app-scaffold.ts → app.ts - Extract auth into setup/auth.ts (was inlined in app-scaffold.ts) - Extract completeLogin into helpers/browser-login.ts (shared by setup + scripts) - Extract waitForText into helpers/wait-for-text.ts - Update all test and script imports Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/e2e/helpers/browser-login.ts | 38 ++++++ packages/e2e/helpers/wait-for-text.ts | 20 ++++ packages/e2e/project.json | 4 +- packages/e2e/scripts/create-test-apps.ts | 15 +-- packages/e2e/setup/app.ts | 137 ++++++++++++++++++++++ packages/e2e/setup/auth.ts | 93 +++++++++++++++ packages/e2e/tests/app-deploy.spec.ts | 4 +- packages/e2e/tests/app-dev-server.spec.ts | 4 +- packages/e2e/tests/app-scaffold.spec.ts | 4 +- 9 files changed, 298 insertions(+), 21 deletions(-) create mode 100644 packages/e2e/helpers/browser-login.ts create mode 100644 packages/e2e/helpers/wait-for-text.ts create mode 100644 packages/e2e/setup/app.ts create mode 100644 packages/e2e/setup/auth.ts diff --git a/packages/e2e/helpers/browser-login.ts b/packages/e2e/helpers/browser-login.ts new file mode 100644 index 00000000000..ba637b6d382 --- /dev/null +++ b/packages/e2e/helpers/browser-login.ts @@ -0,0 +1,38 @@ +import type {Page} from '@playwright/test' + +/** + * Completes the Shopify OAuth login flow on a Playwright page. + */ +export async function completeLogin(page: Page, loginUrl: string, email: string, password: string): Promise { + await page.goto(loginUrl) + + try { + // Fill in email + await page.waitForSelector('input[name="account[email]"], input[type="email"]', {timeout: 60_000}) + await page.locator('input[name="account[email]"], input[type="email"]').first().fill(email) + await page.locator('button[type="submit"]').first().click() + + // Fill in password + await page.waitForSelector('input[name="account[password]"], input[type="password"]', {timeout: 60_000}) + await page.locator('input[name="account[password]"], input[type="password"]').first().fill(password) + await page.locator('button[type="submit"]').first().click() + + // Handle any confirmation/approval page + await page.waitForTimeout(3000) + try { + const btn = page.locator('button[type="submit"]').first() + if (await btn.isVisible({timeout: 5000})) await btn.click() + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (_error) { + // No confirmation page — expected + } + } catch (error) { + const pageContent = await page.content().catch(() => '(failed to get content)') + const pageUrl = page.url() + throw new Error( + `Login failed at ${pageUrl}\n` + + `Original error: ${error}\n` + + `Page HTML (first 2000 chars): ${pageContent.slice(0, 2000)}`, + ) + } +} diff --git a/packages/e2e/helpers/wait-for-text.ts b/packages/e2e/helpers/wait-for-text.ts new file mode 100644 index 00000000000..dfa8838d5ec --- /dev/null +++ b/packages/e2e/helpers/wait-for-text.ts @@ -0,0 +1,20 @@ +import {stripAnsi} from './strip-ansi.js' + +/** + * Polls output for a text match, resolving when found or rejecting on timeout. + */ +export function waitForText(getOutput: () => string, text: string, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const interval = setInterval(() => { + if (stripAnsi(getOutput()).includes(text)) { + clearInterval(interval) + clearTimeout(timer) + resolve() + } + }, 200) + const timer = setTimeout(() => { + clearInterval(interval) + reject(new Error(`Timed out after ${timeoutMs}ms waiting for: "${text}"\n\nOutput:\n${stripAnsi(getOutput())}`)) + }, timeoutMs) + }) +} diff --git a/packages/e2e/project.json b/packages/e2e/project.json index 79730c73d8a..cc050bd29ac 100644 --- a/packages/e2e/project.json +++ b/packages/e2e/project.json @@ -17,14 +17,14 @@ "lint": { "executor": "nx:run-commands", "options": { - "command": "pnpm eslint \"fixtures/**/*.ts\" \"helpers/**/*.ts\" \"tests/**/*.ts\" \"*.ts\"", + "command": "pnpm eslint \"setup/**/*.ts\" \"helpers/**/*.ts\" \"tests/**/*.ts\" \"*.ts\"", "cwd": "packages/e2e" } }, "lint:fix": { "executor": "nx:run-commands", "options": { - "command": "pnpm eslint 'fixtures/**/*.ts' 'helpers/**/*.ts' 'tests/**/*.ts' '*.ts' --fix", + "command": "pnpm eslint 'setup/**/*.ts' 'helpers/**/*.ts' 'tests/**/*.ts' '*.ts' --fix", "cwd": "packages/e2e" } }, diff --git a/packages/e2e/scripts/create-test-apps.ts b/packages/e2e/scripts/create-test-apps.ts index e55964a99e5..89bbe732a6c 100644 --- a/packages/e2e/scripts/create-test-apps.ts +++ b/packages/e2e/scripts/create-test-apps.ts @@ -10,6 +10,7 @@ import {fileURLToPath} from 'url' import {execa} from 'execa' import {chromium} from '@playwright/test' import stripAnsiModule from 'strip-ansi' +import {completeLogin} from '../helpers/browser-login.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const rootDir = path.resolve(__dirname, '../../..') @@ -165,19 +166,7 @@ async function oauthLogin() { extraHTTPHeaders: {'X-Shopify-Loadtest-Bf8d22e7-120e-4b5b-906c-39ca9d5499a9': 'true'}, }) const page = await context.newPage() - await page.goto(urlMatch[0]) - - await page.waitForSelector('input[name="account[email]"], input[type="email"]', {timeout: 60_000}) - await page.locator('input[name="account[email]"], input[type="email"]').first().fill(email!) - await page.locator('button[type="submit"]').first().click() - await page.waitForSelector('input[name="account[password]"], input[type="password"]', {timeout: 60_000}) - await page.locator('input[name="account[password]"], input[type="password"]').first().fill(password!) - await page.locator('button[type="submit"]').first().click() - await page.waitForTimeout(3000) - try { - const btn = page.locator('button[type="submit"]').first() - if (await btn.isVisible({timeout: 5000})) await btn.click() - } catch {} + await completeLogin(page, urlMatch[0], email!, password!) await waitForText(() => output, 'Logged in', 60_000) try { pty.kill() } catch {} diff --git a/packages/e2e/setup/app.ts b/packages/e2e/setup/app.ts new file mode 100644 index 00000000000..dbd3da4b995 --- /dev/null +++ b/packages/e2e/setup/app.ts @@ -0,0 +1,137 @@ +/* eslint-disable no-restricted-imports */ +import {authFixture} from './auth.js' +import * as path from 'path' +import * as fs from 'fs' +import type {ExecResult} from './cli.js' + +export interface AppScaffold { + /** The directory where the app was created */ + appDir: string + /** Create a new app from a template */ + init(opts: AppInitOptions): Promise + /** Generate an extension in the app */ + generateExtension(opts: ExtensionOptions): Promise + /** Build the app */ + build(): Promise + /** Get app info as JSON */ + appInfo(): Promise +} + +export interface AppInitOptions { + name?: string + template?: 'reactRouter' | 'remix' | 'none' + flavor?: 'javascript' | 'typescript' + packageManager?: 'npm' | 'yarn' | 'pnpm' | 'bun' +} + +export interface ExtensionOptions { + name: string + template: string + flavor?: string +} + +export interface AppInfoResult { + packageManager: string + allExtensions: { + configuration: {name: string; type: string; handle?: string} + directory: string + outputPath: string + entrySourceFilePath: string + }[] +} + +/** + * Test-scoped fixture that creates a fresh app in a temp directory. + * Depends on authLogin (worker-scoped) for OAuth session. + */ +export const appScaffoldFixture = authFixture.extend<{appScaffold: AppScaffold}>({ + appScaffold: async ({cli, env, authLogin: _authLogin}, use) => { + const appTmpDir = fs.mkdtempSync(path.join(env.tempDir, 'app-')) + let appDir = '' + + const scaffold: AppScaffold = { + get appDir() { + if (!appDir) throw new Error('App has not been initialized yet. Call init() first.') + return appDir + }, + + async init(opts: AppInitOptions) { + const name = opts.name ?? 'e2e-test-app' + const template = opts.template ?? 'reactRouter' + const packageManager = opts.packageManager ?? 'npm' + + const args = [ + '--name', + name, + '--path', + appTmpDir, + '--package-manager', + packageManager, + '--local', + '--template', + template, + ] + if (opts.flavor) args.push('--flavor', opts.flavor) + + const result = await cli.execCreateApp(args, { + env: {FORCE_COLOR: '0'}, + timeout: 5 * 60 * 1000, + }) + + const allOutput = `${result.stdout}\n${result.stderr}` + const match = allOutput.match(/([\w-]+) is ready for you to build!/) + + if (match?.[1]) { + appDir = path.join(appTmpDir, match[1]) + } else { + const entries = fs.readdirSync(appTmpDir, {withFileTypes: true}) + const appEntry = entries.find( + (entry) => entry.isDirectory() && fs.existsSync(path.join(appTmpDir, entry.name, 'shopify.app.toml')), + ) + if (appEntry) { + appDir = path.join(appTmpDir, appEntry.name) + } else { + throw new Error( + `Could not find created app directory in ${appTmpDir}.\n` + + `Exit code: ${result.exitCode}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`, + ) + } + } + + const npmrcPath = path.join(appDir, '.npmrc') + if (!fs.existsSync(npmrcPath)) fs.writeFileSync(npmrcPath, '') + fs.appendFileSync(npmrcPath, 'frozen-lockfile=false\n') + + return result + }, + + async generateExtension(opts: ExtensionOptions) { + const args = [ + 'app', + 'generate', + 'extension', + '--name', + opts.name, + '--path', + appDir, + '--template', + opts.template, + ] + if (opts.flavor) args.push('--flavor', opts.flavor) + return cli.exec(args, {timeout: 5 * 60 * 1000}) + }, + + async build() { + return cli.exec(['app', 'build', '--path', appDir], {timeout: 5 * 60 * 1000}) + }, + + async appInfo(): Promise { + const result = await cli.exec(['app', 'info', '--path', appDir, '--json']) + return JSON.parse(result.stdout) + }, + } + + await use(scaffold) + fs.rmSync(appTmpDir, {recursive: true, force: true}) + }, +}) diff --git a/packages/e2e/setup/auth.ts b/packages/e2e/setup/auth.ts new file mode 100644 index 00000000000..5a37752bef3 --- /dev/null +++ b/packages/e2e/setup/auth.ts @@ -0,0 +1,93 @@ +/* eslint-disable no-restricted-imports */ +import {cliFixture} from './cli.js' +import {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 {chromium, type Browser} from '@playwright/test' +import {execa} from 'execa' + +/** + * Worker-scoped fixture that performs OAuth login via browser automation. + * Runs once per worker, stores the session in shared XDG dirs. + */ +export const authFixture = cliFixture.extend<{}, {authLogin: void}>({ + authLogin: [ + async ({env}, use) => { + const email = process.env.E2E_ACCOUNT_EMAIL + const password = process.env.E2E_ACCOUNT_PASSWORD + + if (!email || !password) { + await use() + return + } + + // 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 = '' + // Pretend we're in a cloud environment so the CLI prints the login URL + // directly instead of opening a system browser (BROWSER=none doesn't work on macOS) + 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 (process.env.DEBUG === '1') process.stdout.write(data) + }) + + await waitForText(() => output, 'Open this link to start the auth process', 30_000) + + const stripped = stripAnsi(output) + const urlMatch = stripped.match(/https:\/\/accounts\.shopify\.com\S+/) + if (!urlMatch) { + throw new Error(`Could not find login URL in output:\n${stripped}`) + } + + let browser: Browser | undefined + try { + 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) + } finally { + await browser?.close() + } + + await waitForText(() => output, 'Logged in', 60_000) + try { + ptyProcess.kill() + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (_error) { + // Process may already be dead + } + + // Remove the partners token so CLI uses the OAuth session + // instead of the token (which can't auth against Business Platform API) + delete env.processEnv.SHOPIFY_CLI_PARTNERS_TOKEN + + await use() + }, + {scope: 'worker'}, + ], +}) diff --git a/packages/e2e/tests/app-deploy.spec.ts b/packages/e2e/tests/app-deploy.spec.ts index 1c024f1cf3e..e2d9953da60 100644 --- a/packages/e2e/tests/app-deploy.spec.ts +++ b/packages/e2e/tests/app-deploy.spec.ts @@ -1,5 +1,5 @@ -import {appScaffoldFixture as test} from '../fixtures/app-scaffold.js' -import {requireEnv} from '../fixtures/env.js' +import {appScaffoldFixture as test} from '../setup/app.js' +import {requireEnv} from '../setup/env.js' import {expect} from '@playwright/test' test.describe('App deploy', () => { diff --git a/packages/e2e/tests/app-dev-server.spec.ts b/packages/e2e/tests/app-dev-server.spec.ts index c542dece636..38832a30dea 100644 --- a/packages/e2e/tests/app-dev-server.spec.ts +++ b/packages/e2e/tests/app-dev-server.spec.ts @@ -1,5 +1,5 @@ -import {appScaffoldFixture as test} from '../fixtures/app-scaffold.js' -import {requireEnv} from '../fixtures/env.js' +import {appScaffoldFixture as test} from '../setup/app.js' +import {requireEnv} from '../setup/env.js' import {expect} from '@playwright/test' test.describe('App dev server', () => { diff --git a/packages/e2e/tests/app-scaffold.spec.ts b/packages/e2e/tests/app-scaffold.spec.ts index 79f9eeb8aef..c7d6ee42783 100644 --- a/packages/e2e/tests/app-scaffold.spec.ts +++ b/packages/e2e/tests/app-scaffold.spec.ts @@ -1,6 +1,6 @@ /* eslint-disable no-restricted-imports */ -import {appScaffoldFixture as test} from '../fixtures/app-scaffold.js' -import {requireEnv} from '../fixtures/env.js' +import {appScaffoldFixture as test} from '../setup/app.js' +import {requireEnv} from '../setup/env.js' import {expect} from '@playwright/test' import * as fs from 'fs' import * as path from 'path' From 498d3de27b7c0a38046ba1d1f9cc382c1d9d1df2 Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Thu, 5 Mar 2026 11:23:02 -0700 Subject: [PATCH 6/6] Remove leftover app-scaffold.ts after rename to app.ts Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/e2e/setup/app-scaffold.ts | 276 ----------------------------- 1 file changed, 276 deletions(-) delete mode 100644 packages/e2e/setup/app-scaffold.ts diff --git a/packages/e2e/setup/app-scaffold.ts b/packages/e2e/setup/app-scaffold.ts deleted file mode 100644 index 4d05b1a5b89..00000000000 --- a/packages/e2e/setup/app-scaffold.ts +++ /dev/null @@ -1,276 +0,0 @@ -/* eslint-disable no-restricted-imports */ -import {cliFixture} from './cli-process.js' -import {executables} from './env.js' -import {stripAnsi} from '../helpers/strip-ansi.js' -import {chromium, type Browser, type Page} from '@playwright/test' -import {execa} from 'execa' -import * as path from 'path' -import * as fs from 'fs' -import type {ExecResult} from './cli-process.js' - -export interface AppScaffold { - /** The directory where the app was created */ - appDir: string - /** Create a new app from a template */ - init(opts: AppInitOptions): Promise - /** Generate an extension in the app */ - generateExtension(opts: ExtensionOptions): Promise - /** Build the app */ - build(): Promise - /** Get app info as JSON */ - appInfo(): Promise -} - -export interface AppInitOptions { - name?: string - template?: 'reactRouter' | 'remix' | 'none' - flavor?: 'javascript' | 'typescript' - packageManager?: 'npm' | 'yarn' | 'pnpm' | 'bun' -} - -export interface ExtensionOptions { - name: string - template: string - flavor?: string -} - -export interface AppInfoResult { - packageManager: string - allExtensions: { - configuration: {name: string; type: string; handle?: string} - directory: string - outputPath: string - entrySourceFilePath: string - }[] -} - -/** - * Worker-scoped fixture that performs OAuth login via browser automation. - * Runs once per worker, stores the session in shared XDG dirs. - */ -const withAuth = cliFixture.extend<{}, {authLogin: void}>({ - authLogin: [ - async ({env}, use) => { - const email = process.env.E2E_ACCOUNT_EMAIL - const password = process.env.E2E_ACCOUNT_PASSWORD - - if (!email || !password) { - await use() - return - } - - // 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 = '' - // Pretend we're in a cloud environment so the CLI prints the login URL - // directly instead of opening a system browser (BROWSER=none doesn't work on macOS) - 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 (process.env.DEBUG === '1') process.stdout.write(data) - }) - - await waitForText(() => output, 'Open this link to start the auth process', 30_000) - - const stripped = stripAnsi(output) - const urlMatch = stripped.match(/https:\/\/accounts\.shopify\.com\S+/) - if (!urlMatch) { - throw new Error(`Could not find login URL in output:\n${stripped}`) - } - - let browser: Browser | undefined - try { - 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) - } finally { - await browser?.close() - } - - await waitForText(() => output, 'Logged in', 60_000) - try { - ptyProcess.kill() - // eslint-disable-next-line no-catch-all/no-catch-all - } catch (_error) { - // Process may already be dead - } - - // Remove the partners token so CLI uses the OAuth session - // instead of the token (which can't auth against Business Platform API) - delete env.processEnv.SHOPIFY_CLI_PARTNERS_TOKEN - - await use() - }, - {scope: 'worker'}, - ], -}) - -/** - * Test-scoped fixture that creates a fresh app in a temp directory. - * Depends on authLogin (worker-scoped) for OAuth session. - */ -export const appScaffoldFixture = withAuth.extend<{appScaffold: AppScaffold}>({ - appScaffold: async ({cli, env, authLogin: _authLogin}, use) => { - const appTmpDir = fs.mkdtempSync(path.join(env.tempDir, 'app-')) - let appDir = '' - - const scaffold: AppScaffold = { - get appDir() { - if (!appDir) throw new Error('App has not been initialized yet. Call init() first.') - return appDir - }, - - async init(opts: AppInitOptions) { - const name = opts.name ?? 'e2e-test-app' - const template = opts.template ?? 'reactRouter' - const packageManager = opts.packageManager ?? 'npm' - - const args = [ - '--name', - name, - '--path', - appTmpDir, - '--package-manager', - packageManager, - '--local', - '--template', - template, - ] - if (opts.flavor) args.push('--flavor', opts.flavor) - - const result = await cli.execCreateApp(args, { - env: {FORCE_COLOR: '0'}, - timeout: 5 * 60 * 1000, - }) - - const allOutput = `${result.stdout}\n${result.stderr}` - const match = allOutput.match(/([\w-]+) is ready for you to build!/) - - if (match?.[1]) { - appDir = path.join(appTmpDir, match[1]) - } else { - const entries = fs.readdirSync(appTmpDir, {withFileTypes: true}) - const appEntry = entries.find( - (entry) => entry.isDirectory() && fs.existsSync(path.join(appTmpDir, entry.name, 'shopify.app.toml')), - ) - if (appEntry) { - appDir = path.join(appTmpDir, appEntry.name) - } else { - throw new Error( - `Could not find created app directory in ${appTmpDir}.\n` + - `Exit code: ${result.exitCode}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`, - ) - } - } - - const npmrcPath = path.join(appDir, '.npmrc') - if (!fs.existsSync(npmrcPath)) fs.writeFileSync(npmrcPath, '') - fs.appendFileSync(npmrcPath, 'frozen-lockfile=false\n') - - return result - }, - - async generateExtension(opts: ExtensionOptions) { - const args = [ - 'app', - 'generate', - 'extension', - '--name', - opts.name, - '--path', - appDir, - '--template', - opts.template, - ] - if (opts.flavor) args.push('--flavor', opts.flavor) - return cli.exec(args, {timeout: 5 * 60 * 1000}) - }, - - async build() { - return cli.exec(['app', 'build', '--path', appDir], {timeout: 5 * 60 * 1000}) - }, - - async appInfo(): Promise { - const result = await cli.exec(['app', 'info', '--path', appDir, '--json']) - return JSON.parse(result.stdout) - }, - } - - await use(scaffold) - fs.rmSync(appTmpDir, {recursive: true, force: true}) - }, -}) - -async function completeLogin(page: Page, loginUrl: string, email: string, password: string): Promise { - await page.goto(loginUrl) - - try { - // Fill in email - await page.waitForSelector('input[name="account[email]"], input[type="email"]', {timeout: 60_000}) - await page.locator('input[name="account[email]"], input[type="email"]').first().fill(email) - await page.locator('button[type="submit"]').first().click() - - // Fill in password - await page.waitForSelector('input[name="account[password]"], input[type="password"]', {timeout: 60_000}) - await page.locator('input[name="account[password]"], input[type="password"]').first().fill(password) - await page.locator('button[type="submit"]').first().click() - - // Handle any confirmation/approval page - await page.waitForTimeout(3000) - try { - const btn = page.locator('button[type="submit"]').first() - if (await btn.isVisible({timeout: 5000})) await btn.click() - // eslint-disable-next-line no-catch-all/no-catch-all - } catch (_error) { - // No confirmation page — expected - } - } catch (error) { - const pageContent = await page.content().catch(() => '(failed to get content)') - const pageUrl = page.url() - throw new Error( - `Login failed at ${pageUrl}\n` + - `Original error: ${error}\n` + - `Page HTML (first 2000 chars): ${pageContent.slice(0, 2000)}`, - ) - } -} - -function waitForText(getOutput: () => string, text: string, timeoutMs: number): Promise { - return new Promise((resolve, reject) => { - const interval = setInterval(() => { - if (stripAnsi(getOutput()).includes(text)) { - clearInterval(interval) - clearTimeout(timer) - resolve() - } - }, 200) - const timer = setTimeout(() => { - clearInterval(interval) - reject(new Error(`Timed out after ${timeoutMs}ms waiting for: "${text}"\n\nOutput:\n${stripAnsi(getOutput())}`)) - }, timeoutMs) - }) -}