-
Notifications
You must be signed in to change notification settings - Fork 230
Add e2e tests for app init, deploy, and dev server #6900
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
3b958bd
Add OAuth login, app scaffold, deploy, and dev server e2e tests
ryancbahan 75e9e71
ignore linting for e2e scripts dir
ryancbahan dcc6daa
ignore scripts dir
ryancbahan 3bc98fd
prevent spawning system browser for playwright
ryancbahan f14eb35
Restructure e2e: fixtures/ → setup/, extract shared helpers
ryancbahan 498d3de
Remove leftover app-scaffold.ts after rename to app.ts
ryancbahan File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<void> { | ||
| 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)}`, | ||
| ) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<void> { | ||
| 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) | ||
| }) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,199 @@ | ||
| /** | ||
| * 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' | ||
| import {completeLogin} from '../helpers/browser-login.js' | ||
|
|
||
| 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<string, string> = { | ||
| ...process.env as Record<string, string>, | ||
| 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<string> { | ||
| 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<number>((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 completeLogin(page, urlMatch[0], email!, password!) | ||
|
|
||
| await waitForText(() => output, 'Logged in', 60_000) | ||
| try { pty.kill() } catch {} | ||
| await browser.close() | ||
| } | ||
|
|
||
| function waitForText(getOutput: () => string, text: string, timeoutMs: number): Promise<void> { | ||
| 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<void> { | ||
| return new Promise((r) => setTimeout(r, ms)) | ||
| } | ||
|
|
||
| main().catch((err) => { | ||
| console.error(err) | ||
| process.exit(1) | ||
| }) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Non-headless browser launched by default (can hang CI / break local automation)
oauthLogin()unconditionally launches Chromium with{headless: false}. In headless CI environments (or developer machines without a display server), this can hang or fail to launch, blocking app provisioning. Since this script is intended for automation (“Creates test apps… via interactive PTY”), defaulting to headless is safer, with an opt-in for headed runs.