Skip to content

Commit c19eb9e

Browse files
committed
Add e2e test package with infrastructure and smoke tests
New packages/e2e/ package with Playwright test runner, node-pty for interactive CLI testing, and isolated XDG environments. Includes: - Package config (package.json, tsconfig, playwright.config, project.json) - Fixtures: env (XDG isolation, auth tokens), cli-process (execa + PTY) - Helpers: strip-ansi, wait-for-port, file-edit - Smoke tests validating both execa and PTY execution paths
1 parent a5fee80 commit c19eb9e

File tree

16 files changed

+4432
-2237
lines changed

16 files changed

+4432
-2237
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,9 @@ packages/*/docs/
151151

152152
packaging/dist
153153

154+
# E2E test temp directories and artifacts
155+
.e2e-tmp
156+
154157
# Shadowenv generates user-specific files that shouldn't be committed
155158
.shadowenv.d/
156159

packages/e2e/.env.example

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Required: Client ID of the primary test app (must be in the genghis account's org)
2+
# CI secret: E2E_CLIENT_ID
3+
SHOPIFY_FLAG_CLIENT_ID=
4+
5+
# Required: Genghis account email for browser-based OAuth login
6+
# CI secret: E2E_ACCOUNT_EMAIL
7+
E2E_ACCOUNT_EMAIL=
8+
9+
# Required: Genghis account password for browser-based OAuth login
10+
# CI secret: E2E_ACCOUNT_PASSWORD
11+
E2E_ACCOUNT_PASSWORD=
12+
13+
# Required: Dev store FQDN for dev server / deploy tests (e.g. my-store.myshopify.com)
14+
# CI secret: E2E_STORE_FQDN
15+
E2E_STORE_FQDN=
16+
17+
# Optional: Client ID of a secondary app for config link tests
18+
# CI secret: E2E_SECONDARY_CLIENT_ID
19+
E2E_SECONDARY_CLIENT_ID=

packages/e2e/.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
node_modules/
2+
test-results/
3+
playwright-report/
4+
dist/
5+
.env
6+
.env.local
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
import {envFixture, executables} from './env.js'
2+
import {stripAnsi} from '../helpers/strip-ansi.js'
3+
import {execa, type Options as ExecaOptions} from 'execa'
4+
import type {E2EEnv} from './env.js'
5+
import type * as pty from 'node-pty'
6+
7+
export interface ExecResult {
8+
stdout: string
9+
stderr: string
10+
exitCode: number
11+
}
12+
13+
export interface SpawnedProcess {
14+
/** Wait for a string to appear in the PTY output */
15+
waitForOutput(text: string, timeoutMs?: number): Promise<void>
16+
/** Send a single key to the PTY */
17+
sendKey(key: string): void
18+
/** Send a line of text followed by Enter */
19+
sendLine(line: string): void
20+
/** Wait for the process to exit */
21+
waitForExit(timeoutMs?: number): Promise<number>
22+
/** Kill the process */
23+
kill(): void
24+
/** Get all output captured so far (ANSI stripped) */
25+
getOutput(): string
26+
/** The underlying node-pty process */
27+
readonly ptyProcess: pty.IPty
28+
}
29+
30+
export interface CLIProcess {
31+
/** Execute a CLI command non-interactively via execa */
32+
exec(args: string[], opts?: {cwd?: string; env?: NodeJS.ProcessEnv; timeout?: number}): Promise<ExecResult>
33+
/** Execute the create-app binary non-interactively via execa */
34+
execCreateApp(args: string[], opts?: {cwd?: string; env?: NodeJS.ProcessEnv; timeout?: number}): Promise<ExecResult>
35+
/** Spawn an interactive CLI command via node-pty */
36+
spawn(args: string[], opts?: {cwd?: string; env?: NodeJS.ProcessEnv}): Promise<SpawnedProcess>
37+
}
38+
39+
/**
40+
* Test-scoped fixture providing CLI process management.
41+
* Tracks all spawned processes and kills them in teardown.
42+
*/
43+
export const cliFixture = envFixture.extend<{cli: CLIProcess}>({
44+
cli: async ({env}, use) => {
45+
const spawnedProcesses: SpawnedProcess[] = []
46+
47+
const cli: CLIProcess = {
48+
async exec(args, opts = {}) {
49+
// 3 min default
50+
const timeout = opts.timeout ?? 3 * 60 * 1000
51+
const execaOpts: ExecaOptions = {
52+
cwd: opts.cwd,
53+
env: {...env.processEnv, ...opts.env},
54+
timeout,
55+
reject: false,
56+
}
57+
58+
if (process.env.DEBUG === '1') {
59+
console.log(`[e2e] exec: node ${executables.cli} ${args.join(' ')}`)
60+
}
61+
62+
const result = await execa('node', [executables.cli, ...args], execaOpts)
63+
64+
return {
65+
stdout: result.stdout ?? '',
66+
stderr: result.stderr ?? '',
67+
exitCode: result.exitCode ?? 1,
68+
}
69+
},
70+
71+
async execCreateApp(args, opts = {}) {
72+
// 5 min default for scaffolding
73+
const timeout = opts.timeout ?? 5 * 60 * 1000
74+
const execaOpts: ExecaOptions = {
75+
cwd: opts.cwd,
76+
env: {...env.processEnv, ...opts.env},
77+
timeout,
78+
reject: false,
79+
}
80+
81+
if (process.env.DEBUG === '1') {
82+
console.log(`[e2e] exec: node ${executables.createApp} ${args.join(' ')}`)
83+
}
84+
85+
const result = await execa('node', [executables.createApp, ...args], execaOpts)
86+
87+
return {
88+
stdout: result.stdout ?? '',
89+
stderr: result.stderr ?? '',
90+
exitCode: result.exitCode ?? 1,
91+
}
92+
},
93+
94+
async spawn(args, opts = {}) {
95+
// Dynamic import to avoid requiring node-pty for Phase 1 tests
96+
const nodePty = await import('node-pty')
97+
98+
const spawnEnv: {[key: string]: string} = {}
99+
for (const [key, value] of Object.entries({...env.processEnv, ...opts.env})) {
100+
if (value !== undefined) {
101+
spawnEnv[key] = value
102+
}
103+
}
104+
105+
if (process.env.DEBUG === '1') {
106+
console.log(`[e2e] spawn: node ${executables.cli} ${args.join(' ')}`)
107+
}
108+
109+
const ptyProcess = nodePty.spawn('node', [executables.cli, ...args], {
110+
name: 'xterm-color',
111+
cols: 120,
112+
rows: 30,
113+
cwd: opts.cwd,
114+
env: spawnEnv,
115+
})
116+
117+
let output = ''
118+
const outputWaiters: {text: string; resolve: () => void; reject: (err: Error) => void}[] = []
119+
120+
ptyProcess.onData((data: string) => {
121+
output += data
122+
if (process.env.DEBUG === '1') {
123+
process.stdout.write(data)
124+
}
125+
126+
// Check if any waiters are satisfied (check both raw and stripped output)
127+
const stripped = stripAnsi(output)
128+
for (let idx = outputWaiters.length - 1; idx >= 0; idx--) {
129+
const waiter = outputWaiters[idx]
130+
if (waiter && (stripped.includes(waiter.text) || output.includes(waiter.text))) {
131+
waiter.resolve()
132+
outputWaiters.splice(idx, 1)
133+
}
134+
}
135+
})
136+
137+
let exitCode: number | undefined
138+
let exitResolve: ((code: number) => void) | undefined
139+
140+
ptyProcess.onExit(({exitCode: code}) => {
141+
exitCode = code
142+
if (exitResolve) {
143+
exitResolve(code)
144+
}
145+
// Reject any remaining output waiters
146+
for (const waiter of outputWaiters) {
147+
waiter.reject(new Error(`Process exited (code ${code}) while waiting for output: "${waiter.text}"`))
148+
}
149+
outputWaiters.length = 0
150+
})
151+
152+
const spawned: SpawnedProcess = {
153+
ptyProcess,
154+
155+
waitForOutput(text: string, timeoutMs = 3 * 60 * 1000) {
156+
// Check if already in output (raw or stripped)
157+
if (stripAnsi(output).includes(text) || output.includes(text)) {
158+
return Promise.resolve()
159+
}
160+
161+
return new Promise<void>((resolve, reject) => {
162+
const timer = setTimeout(() => {
163+
const waiterIdx = outputWaiters.findIndex((waiter) => waiter.text === text)
164+
if (waiterIdx >= 0) outputWaiters.splice(waiterIdx, 1)
165+
reject(
166+
new Error(
167+
`Timed out after ${timeoutMs}ms waiting for output: "${text}"\n\nCaptured output:\n${stripAnsi(
168+
output,
169+
)}`,
170+
),
171+
)
172+
}, timeoutMs)
173+
174+
outputWaiters.push({
175+
text,
176+
resolve: () => {
177+
clearTimeout(timer)
178+
resolve()
179+
},
180+
reject: (err) => {
181+
clearTimeout(timer)
182+
reject(err)
183+
},
184+
})
185+
})
186+
},
187+
188+
sendKey(key: string) {
189+
ptyProcess.write(key)
190+
},
191+
192+
sendLine(line: string) {
193+
ptyProcess.write(`${line}\r`)
194+
},
195+
196+
waitForExit(timeoutMs = 60 * 1000) {
197+
if (exitCode !== undefined) {
198+
return Promise.resolve(exitCode)
199+
}
200+
201+
return new Promise<number>((resolve, reject) => {
202+
const timer = setTimeout(() => {
203+
reject(new Error(`Timed out after ${timeoutMs}ms waiting for process exit`))
204+
}, timeoutMs)
205+
206+
exitResolve = (code) => {
207+
clearTimeout(timer)
208+
resolve(code)
209+
}
210+
})
211+
},
212+
213+
kill() {
214+
try {
215+
ptyProcess.kill()
216+
// eslint-disable-next-line no-catch-all/no-catch-all
217+
} catch (_error) {
218+
// Process may already be dead
219+
}
220+
},
221+
222+
getOutput() {
223+
return stripAnsi(output)
224+
},
225+
}
226+
227+
spawnedProcesses.push(spawned)
228+
return spawned
229+
},
230+
}
231+
232+
await use(cli)
233+
234+
// Teardown: kill all spawned processes
235+
for (const proc of spawnedProcesses) {
236+
proc.kill()
237+
}
238+
},
239+
})
240+
241+
export {type E2EEnv}

0 commit comments

Comments
 (0)