Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,9 @@ packages/*/docs/

packaging/dist

# E2E test temp directories and artifacts
.e2e-tmp

# Shadowenv generates user-specific files that shouldn't be committed
.shadowenv.d/

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,8 @@
"@graphql-typed-document-node/core"
],
"ignoreWorkspaces": [
"packages/eslint-plugin-cli"
"packages/eslint-plugin-cli",
"packages/e2e"
],
"paths": {
"@shopify/eslint-plugin-cli/configs": [
Expand Down
19 changes: 19 additions & 0 deletions packages/e2e/.env.example
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=
Copy link
Contributor Author

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.


# 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=
6 changes: 6 additions & 0 deletions packages/e2e/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
node_modules/
test-results/
playwright-report/
dist/
.env
.env.local
242 changes: 242 additions & 0 deletions packages/e2e/fixtures/cli-process.ts
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
})

Choose a reason for hiding this comment

The 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 let exitResolve: ((code: number) => void) | undefined; waitForExit assigns exitResolve = (code) => { ... resolve(code) } overwriting any prior waiter; onExit calls only the last assigned exitResolve.


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}
Loading
Loading