-
Notifications
You must be signed in to change notification settings - Fork 228
Add tests for dev, deploy, app init #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
Open
ryancbahan
wants to merge
3
commits into
e2e-testing
Choose a base branch
from
02-26-add-e2e-infra
base: e2e-testing
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+643
−3
Open
Changes from all commits
Commits
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,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<ExecResult> | ||
| /** Generate an extension in the app */ | ||
| generateExtension(opts: ExtensionOptions): Promise<ExecResult> | ||
| /** Build the app */ | ||
| build(): Promise<ExecResult> | ||
| /** Get app info as JSON */ | ||
| appInfo(): Promise<AppInfoResult> | ||
| } | ||
|
|
||
| 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<AppInfoResult> { | ||
| 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<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)}`, | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| 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
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.
Login failure error includes page HTML (risk of credential/token leakage in logs)
On login failure, the thrown error includes the first 2000 characters of
page.content()(full HTML). Auth pages can include dynamic data, anti-bot metadata, error details, and potentially identifiers/tokens. In CI, thrown errors are typically logged and retained; this increases the risk of leaking sensitive auth/session information.