-
Notifications
You must be signed in to change notification settings - Fork 228
Add new section for e2e testing #6899
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| # Required: Client ID of the primary test app (must be in the genghis account's org) | ||
| # CI secret: E2E_CLIENT_ID | ||
| SHOPIFY_FLAG_CLIENT_ID= | ||
|
|
||
| # Required: Genghis account email for browser-based OAuth login | ||
| # CI secret: E2E_ACCOUNT_EMAIL | ||
| E2E_ACCOUNT_EMAIL= | ||
|
|
||
| # Required: Genghis account password for browser-based OAuth login | ||
| # CI secret: E2E_ACCOUNT_PASSWORD | ||
| E2E_ACCOUNT_PASSWORD= | ||
|
|
||
| # Required: Dev store FQDN for dev server / deploy tests (e.g. my-store.myshopify.com) | ||
| # CI secret: E2E_STORE_FQDN | ||
| E2E_STORE_FQDN= | ||
|
|
||
| # Optional: Client ID of a secondary app for config link tests | ||
| # CI secret: E2E_SECONDARY_CLIENT_ID | ||
| E2E_SECONDARY_CLIENT_ID= | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| node_modules/ | ||
| test-results/ | ||
| playwright-report/ | ||
| dist/ | ||
| .env | ||
| .env.local |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,242 @@ | ||
| /* eslint-disable no-console */ | ||
| import {envFixture, executables} from './env.js' | ||
| import {stripAnsi} from '../helpers/strip-ansi.js' | ||
| import {execa, type Options as ExecaOptions} from 'execa' | ||
| import type {E2EEnv} from './env.js' | ||
| import type * as pty from 'node-pty' | ||
|
|
||
| export interface ExecResult { | ||
| stdout: string | ||
| stderr: string | ||
| exitCode: number | ||
| } | ||
|
|
||
| export interface SpawnedProcess { | ||
| /** Wait for a string to appear in the PTY output */ | ||
| waitForOutput(text: string, timeoutMs?: number): Promise<void> | ||
| /** Send a single key to the PTY */ | ||
| sendKey(key: string): void | ||
| /** Send a line of text followed by Enter */ | ||
| sendLine(line: string): void | ||
| /** Wait for the process to exit */ | ||
| waitForExit(timeoutMs?: number): Promise<number> | ||
| /** Kill the process */ | ||
| kill(): void | ||
| /** Get all output captured so far (ANSI stripped) */ | ||
| getOutput(): string | ||
| /** The underlying node-pty process */ | ||
| readonly ptyProcess: pty.IPty | ||
| } | ||
|
|
||
| export interface CLIProcess { | ||
| /** Execute a CLI command non-interactively via execa */ | ||
| exec(args: string[], opts?: {cwd?: string; env?: NodeJS.ProcessEnv; timeout?: number}): Promise<ExecResult> | ||
| /** Execute the create-app binary non-interactively via execa */ | ||
| execCreateApp(args: string[], opts?: {cwd?: string; env?: NodeJS.ProcessEnv; timeout?: number}): Promise<ExecResult> | ||
| /** Spawn an interactive CLI command via node-pty */ | ||
| spawn(args: string[], opts?: {cwd?: string; env?: NodeJS.ProcessEnv}): Promise<SpawnedProcess> | ||
| } | ||
|
|
||
| /** | ||
| * Test-scoped fixture providing CLI process management. | ||
| * Tracks all spawned processes and kills them in teardown. | ||
| */ | ||
| export const cliFixture = envFixture.extend<{cli: CLIProcess}>({ | ||
| cli: async ({env}, use) => { | ||
| const spawnedProcesses: SpawnedProcess[] = [] | ||
|
|
||
| const cli: CLIProcess = { | ||
| async exec(args, opts = {}) { | ||
| // 3 min default | ||
| const timeout = opts.timeout ?? 3 * 60 * 1000 | ||
| const execaOpts: ExecaOptions = { | ||
| cwd: opts.cwd, | ||
| env: {...env.processEnv, ...opts.env}, | ||
| timeout, | ||
| reject: false, | ||
| } | ||
|
|
||
| if (process.env.DEBUG === '1') { | ||
| console.log(`[e2e] exec: node ${executables.cli} ${args.join(' ')}`) | ||
| } | ||
|
|
||
| const result = await execa('node', [executables.cli, ...args], execaOpts) | ||
|
|
||
| return { | ||
| stdout: result.stdout ?? '', | ||
| stderr: result.stderr ?? '', | ||
| exitCode: result.exitCode ?? 1, | ||
| } | ||
| }, | ||
|
|
||
| async execCreateApp(args, opts = {}) { | ||
| // 5 min default for scaffolding | ||
| const timeout = opts.timeout ?? 5 * 60 * 1000 | ||
| const execaOpts: ExecaOptions = { | ||
| cwd: opts.cwd, | ||
| env: {...env.processEnv, ...opts.env}, | ||
| timeout, | ||
| reject: false, | ||
| } | ||
|
|
||
| if (process.env.DEBUG === '1') { | ||
| console.log(`[e2e] exec: node ${executables.createApp} ${args.join(' ')}`) | ||
| } | ||
|
|
||
| const result = await execa('node', [executables.createApp, ...args], execaOpts) | ||
|
|
||
| return { | ||
| stdout: result.stdout ?? '', | ||
| stderr: result.stderr ?? '', | ||
| exitCode: result.exitCode ?? 1, | ||
| } | ||
| }, | ||
|
|
||
| async spawn(args, opts = {}) { | ||
| // Dynamic import to avoid requiring node-pty for Phase 1 tests | ||
| const nodePty = await import('node-pty') | ||
|
|
||
| const spawnEnv: {[key: string]: string} = {} | ||
| for (const [key, value] of Object.entries({...env.processEnv, ...opts.env})) { | ||
| if (value !== undefined) { | ||
| spawnEnv[key] = value | ||
| } | ||
| } | ||
|
|
||
| if (process.env.DEBUG === '1') { | ||
| console.log(`[e2e] spawn: node ${executables.cli} ${args.join(' ')}`) | ||
| } | ||
|
|
||
| const ptyProcess = nodePty.spawn('node', [executables.cli, ...args], { | ||
| name: 'xterm-color', | ||
| cols: 120, | ||
| rows: 30, | ||
| cwd: opts.cwd, | ||
| env: spawnEnv, | ||
| }) | ||
|
|
||
| let output = '' | ||
| const outputWaiters: {text: string; resolve: () => void; reject: (err: Error) => void}[] = [] | ||
|
|
||
| ptyProcess.onData((data: string) => { | ||
| output += data | ||
| if (process.env.DEBUG === '1') { | ||
| process.stdout.write(data) | ||
| } | ||
|
|
||
| // Check if any waiters are satisfied (check both raw and stripped output) | ||
| const stripped = stripAnsi(output) | ||
| for (let idx = outputWaiters.length - 1; idx >= 0; idx--) { | ||
| const waiter = outputWaiters[idx] | ||
| if (waiter && (stripped.includes(waiter.text) || output.includes(waiter.text))) { | ||
| waiter.resolve() | ||
| outputWaiters.splice(idx, 1) | ||
| } | ||
| } | ||
| }) | ||
|
|
||
| let exitCode: number | undefined | ||
| let exitResolve: ((code: number) => void) | undefined | ||
|
|
||
| ptyProcess.onExit(({exitCode: code}) => { | ||
| exitCode = code | ||
| if (exitResolve) { | ||
| exitResolve(code) | ||
| } | ||
| // Reject any remaining output waiters | ||
| for (const waiter of outputWaiters) { | ||
| waiter.reject(new Error(`Process exited (code ${code}) while waiting for output: "${waiter.text}"`)) | ||
| } | ||
| outputWaiters.length = 0 | ||
| }) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. waitForExit() only supports a single waiter; subsequent calls can deadlock The implementation stores only one exitResolve callback. If waitForExit() is called twice before the process exits, the second call overwrites exitResolve, leaving the first promise unresolved forever. Evidence: single-slot |
||
|
|
||
| const spawned: SpawnedProcess = { | ||
| ptyProcess, | ||
|
|
||
| waitForOutput(text: string, timeoutMs = 3 * 60 * 1000) { | ||
| // Check if already in output (raw or stripped) | ||
| if (stripAnsi(output).includes(text) || output.includes(text)) { | ||
| return Promise.resolve() | ||
| } | ||
|
|
||
| return new Promise<void>((resolve, reject) => { | ||
| const timer = setTimeout(() => { | ||
| const waiterIdx = outputWaiters.findIndex((waiter) => waiter.text === text) | ||
| if (waiterIdx >= 0) outputWaiters.splice(waiterIdx, 1) | ||
| reject( | ||
| new Error( | ||
| `Timed out after ${timeoutMs}ms waiting for output: "${text}"\n\nCaptured output:\n${stripAnsi( | ||
| output, | ||
| )}`, | ||
| ), | ||
| ) | ||
| }, timeoutMs) | ||
|
|
||
| outputWaiters.push({ | ||
| text, | ||
| resolve: () => { | ||
| clearTimeout(timer) | ||
| resolve() | ||
| }, | ||
| reject: (err) => { | ||
| clearTimeout(timer) | ||
| reject(err) | ||
| }, | ||
| }) | ||
| }) | ||
| }, | ||
|
|
||
| sendKey(key: string) { | ||
| ptyProcess.write(key) | ||
| }, | ||
|
|
||
| sendLine(line: string) { | ||
| ptyProcess.write(`${line}\r`) | ||
| }, | ||
|
|
||
| waitForExit(timeoutMs = 60 * 1000) { | ||
| if (exitCode !== undefined) { | ||
| return Promise.resolve(exitCode) | ||
| } | ||
|
|
||
| return new Promise<number>((resolve, reject) => { | ||
| const timer = setTimeout(() => { | ||
| reject(new Error(`Timed out after ${timeoutMs}ms waiting for process exit`)) | ||
| }, timeoutMs) | ||
|
|
||
| exitResolve = (code) => { | ||
| clearTimeout(timer) | ||
| resolve(code) | ||
| } | ||
| }) | ||
| }, | ||
|
|
||
| kill() { | ||
| try { | ||
| ptyProcess.kill() | ||
| // eslint-disable-next-line no-catch-all/no-catch-all | ||
| } catch (_error) { | ||
| // Process may already be dead | ||
| } | ||
| }, | ||
|
|
||
| getOutput() { | ||
| return stripAnsi(output) | ||
| }, | ||
| } | ||
|
|
||
| spawnedProcesses.push(spawned) | ||
| return spawned | ||
| }, | ||
| } | ||
|
|
||
| await use(cli) | ||
|
|
||
| // Teardown: kill all spawned processes | ||
| for (const proc of spawnedProcesses) { | ||
| proc.kill() | ||
| } | ||
| }, | ||
| }) | ||
|
|
||
| export {type E2EEnv} | ||
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.
For initial E2E tests, we won't create new apps but instead use existing ones. We should 100% evolve this, but we need a starting point to monitor and observe stability of this job.