diff --git a/packages/e2e/fixtures/app-scaffold.ts b/packages/e2e/fixtures/app-scaffold.ts new file mode 100644 index 0000000000..4b4206ffb0 --- /dev/null +++ b/packages/e2e/fixtures/app-scaffold.ts @@ -0,0 +1,276 @@ +/* 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 = '' + 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/fixtures/env.ts b/packages/e2e/fixtures/env.ts index 36e8d8e6c7..ea22c69ceb 100644 --- a/packages/e2e/fixtures/env.ts +++ b/packages/e2e/fixtures/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 diff --git a/packages/e2e/package.json b/packages/e2e/package.json index ff126b8d52..8b9d5de63e 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/project.json b/packages/e2e/project.json index d8ec4c24fe..79730c73d8 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" } }, diff --git a/packages/e2e/scripts/create-test-apps.ts b/packages/e2e/scripts/create-test-apps.ts new file mode 100644 index 0000000000..e55964a99e --- /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/tests/app-deploy.spec.ts b/packages/e2e/tests/app-deploy.spec.ts new file mode 100644 index 0000000000..1c024f1cf3 --- /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 0000000000..c542dece63 --- /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 0000000000..79f9eeb8ae --- /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) + }) +})