Skip to content
Draft
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
4 changes: 4 additions & 0 deletions packages/e2e/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules/
test-results/
playwright-report/
dist/
212 changes: 212 additions & 0 deletions packages/e2e/fixtures/cli-process.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import {envFixture, executables} from './env.js'
import type {E2EEnv} from './env.js'
import {stripAnsi} from '../helpers/strip-ansi.js'
import {execa, type ExecaChildProcess, type Options as ExecaOptions} from 'execa'
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>
/** 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 = {}) {
const timeout = opts.timeout ?? 3 * 60 * 1000 // 3 min default
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 spawn(args, opts = {}) {
// Dynamic import to avoid requiring node-pty for Phase 1 tests
const nodePty = await import('node-pty')

const spawnEnv: Record<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: Array<{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
const stripped = stripAnsi(output)
for (let i = outputWaiters.length - 1; i >= 0; i--) {
const waiter = outputWaiters[i]!
if (stripped.includes(waiter.text)) {
waiter.resolve()
outputWaiters.splice(i, 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
})

const spawned: SpawnedProcess = {
ptyProcess,

waitForOutput(text: string, timeoutMs = 3 * 60 * 1000) {
// Check if already in output
if (stripAnsi(output).includes(text)) {
return Promise.resolve()
}

return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
const idx = outputWaiters.findIndex((w) => w.text === text)
if (idx >= 0) outputWaiters.splice(idx, 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()
} catch {
// 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}
132 changes: 132 additions & 0 deletions packages/e2e/fixtures/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import {test as base} from '@playwright/test'
import * as path from 'path'
import * as fs from 'fs'
import {fileURLToPath} from 'url'

const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)

export interface E2EEnv {
/** Partners token for API auth (empty string if not set) */
partnersToken: string
/** Primary test app client ID (empty string if not set) */
clientId: string
/** Dev store FQDN (e.g. cli-e2e-test.myshopify.com) */
storeFqdn: string
/** Secondary app client ID for config link tests */
secondaryClientId: string
/** Environment variables to pass to CLI processes */
processEnv: NodeJS.ProcessEnv
/** Temporary directory root for this worker */
tempDir: string
}

export const directories = {
root: path.resolve(__dirname, '../../..'),
packages: {
cli: path.resolve(__dirname, '../../../packages/cli'),
app: path.resolve(__dirname, '../../../packages/app'),
cliKit: path.resolve(__dirname, '../../../packages/cli-kit'),
},
}

export const executables = {
cli: path.resolve(__dirname, '../../../packages/cli/bin/run.js'),
createApp: path.resolve(__dirname, '../../../packages/create-app/bin/run.js'),
}

/**
* Creates an isolated temporary directory with XDG subdirectories and .npmrc.
* Returns the temp directory path and the env vars to pass to child processes.
*/
export function createIsolatedEnv(baseDir: string): {tempDir: string; xdgEnv: Record<string, string>} {
const tempDir = fs.mkdtempSync(path.join(baseDir, 'e2e-'))

const xdgDirs = {
XDG_DATA_HOME: path.join(tempDir, 'XDG_DATA_HOME'),
XDG_CONFIG_HOME: path.join(tempDir, 'XDG_CONFIG_HOME'),
XDG_STATE_HOME: path.join(tempDir, 'XDG_STATE_HOME'),
XDG_CACHE_HOME: path.join(tempDir, 'XDG_CACHE_HOME'),
}

for (const dir of Object.values(xdgDirs)) {
fs.mkdirSync(dir, {recursive: true})
}

// Write .npmrc to ensure package resolution works in CI
fs.writeFileSync(path.join(tempDir, '.npmrc'), '//registry.npmjs.org/')

return {tempDir, xdgEnv: xdgDirs}
}

/**
* Asserts that a required environment variable is set.
* Call this at the top of tests that need auth.
*/
export function requireEnv(env: E2EEnv, ...keys: (keyof Pick<E2EEnv, 'partnersToken' | 'clientId' | 'storeFqdn' | 'secondaryClientId'>)[]): void {
for (const key of keys) {
if (!env[key]) {
const envVarNames: Record<string, string> = {
partnersToken: 'SHOPIFY_CLI_PARTNERS_TOKEN',
clientId: 'SHOPIFY_FLAG_CLIENT_ID',
storeFqdn: 'E2E_STORE_FQDN',
secondaryClientId: 'E2E_SECONDARY_CLIENT_ID',
}
throw new Error(`${envVarNames[key]} environment variable is required for this test`)
}
}
}

/**
* Worker-scoped fixture providing auth tokens and environment configuration.
* Auth tokens are optional — tests that need them should call requireEnv().
*/
export const envFixture = base.extend<{}, {env: E2EEnv}>({
env: [
async ({}, use) => {
const partnersToken = process.env.SHOPIFY_CLI_PARTNERS_TOKEN ?? ''
const clientId = process.env.SHOPIFY_FLAG_CLIENT_ID ?? ''
const storeFqdn = process.env.E2E_STORE_FQDN ?? ''
const secondaryClientId = process.env.E2E_SECONDARY_CLIENT_ID ?? ''

const tmpBase = process.env.E2E_TEMP_DIR ?? path.join(directories.root, '.e2e-tmp')
fs.mkdirSync(tmpBase, {recursive: true})

const {tempDir, xdgEnv} = createIsolatedEnv(tmpBase)

const processEnv: NodeJS.ProcessEnv = {
...process.env,
...xdgEnv,
SHOPIFY_RUN_AS_USER: '0',
NODE_OPTIONS: '',
// Prevent interactive prompts
CI: '1',
}

if (partnersToken) {
processEnv.SHOPIFY_CLI_PARTNERS_TOKEN = partnersToken
}
if (clientId) {
processEnv.SHOPIFY_FLAG_CLIENT_ID = clientId
}
if (storeFqdn) {
processEnv.SHOPIFY_FLAG_STORE = storeFqdn
}

const env: E2EEnv = {
partnersToken,
clientId,
storeFqdn,
secondaryClientId,
processEnv,
tempDir,
}

await use(env)

// Cleanup: remove temp directory
fs.rmSync(tempDir, {recursive: true, force: true})
},
{scope: 'worker'},
],
})
17 changes: 17 additions & 0 deletions packages/e2e/helpers/file-edit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import * as fs from 'fs'

/**
* Appends text to a file. Useful for triggering hot reload by modifying source files.
*/
export function appendToFile(filePath: string, text: string): void {
fs.appendFileSync(filePath, text)
}

/**
* Replaces text in a file. Useful for modifying source files to trigger hot reload.
*/
export function replaceInFile(filePath: string, search: string | RegExp, replacement: string): void {
const content = fs.readFileSync(filePath, 'utf-8')
const updated = content.replace(search, replacement)
fs.writeFileSync(filePath, updated)
}
5 changes: 5 additions & 0 deletions packages/e2e/helpers/strip-ansi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Re-export strip-ansi as a named export for easier use.
// strip-ansi v7+ is ESM-only and exports a default function.
import stripAnsiModule from 'strip-ansi'

export const stripAnsi: (text: string) => string = stripAnsiModule
Loading