Skip to content

Commit cfdcf26

Browse files
committed
Add OAuth login, app scaffold, deploy, and dev server e2e tests
Browser-automated OAuth login via Playwright + genghis account with Cloudflare bypass header for CI compatibility. Tests: - App scaffold: init (react-router + extension-only) and build - App deploy: deploy with version tag + versions list verification - App dev server: start, ready detection, quit with 'q' key - Extension generation (skipped pending BP API auth fix) Includes create-test-apps.ts script for provisioning test apps.
1 parent c19eb9e commit cfdcf26

File tree

5 files changed

+639
-0
lines changed

5 files changed

+639
-0
lines changed
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
/* eslint-disable no-restricted-imports */
2+
import {cliFixture} from './cli-process.js'
3+
import {executables} from './env.js'
4+
import {stripAnsi} from '../helpers/strip-ansi.js'
5+
import {chromium, type Browser, type Page} from '@playwright/test'
6+
import {execa} from 'execa'
7+
import * as path from 'path'
8+
import * as fs from 'fs'
9+
import type {ExecResult} from './cli-process.js'
10+
11+
export interface AppScaffold {
12+
/** The directory where the app was created */
13+
appDir: string
14+
/** Create a new app from a template */
15+
init(opts: AppInitOptions): Promise<ExecResult>
16+
/** Generate an extension in the app */
17+
generateExtension(opts: ExtensionOptions): Promise<ExecResult>
18+
/** Build the app */
19+
build(): Promise<ExecResult>
20+
/** Get app info as JSON */
21+
appInfo(): Promise<AppInfoResult>
22+
}
23+
24+
export interface AppInitOptions {
25+
name?: string
26+
template?: 'reactRouter' | 'remix' | 'none'
27+
flavor?: 'javascript' | 'typescript'
28+
packageManager?: 'npm' | 'yarn' | 'pnpm' | 'bun'
29+
}
30+
31+
export interface ExtensionOptions {
32+
name: string
33+
template: string
34+
flavor?: string
35+
}
36+
37+
export interface AppInfoResult {
38+
packageManager: string
39+
allExtensions: {
40+
configuration: {name: string; type: string; handle?: string}
41+
directory: string
42+
outputPath: string
43+
entrySourceFilePath: string
44+
}[]
45+
}
46+
47+
/**
48+
* Worker-scoped fixture that performs OAuth login via browser automation.
49+
* Runs once per worker, stores the session in shared XDG dirs.
50+
*/
51+
// eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-invalid-void-type
52+
const withAuth = cliFixture.extend<{}, {authLogin: void}>({
53+
authLogin: [
54+
async ({env}, use) => {
55+
const email = process.env.E2E_ACCOUNT_EMAIL
56+
const password = process.env.E2E_ACCOUNT_PASSWORD
57+
58+
if (!email || !password) {
59+
await use()
60+
return
61+
}
62+
63+
// Clear any existing session
64+
await execa('node', [executables.cli, 'auth', 'logout'], {
65+
env: env.processEnv,
66+
reject: false,
67+
})
68+
69+
// Spawn auth login via PTY (must not have CI=1)
70+
const nodePty = await import('node-pty')
71+
const spawnEnv: {[key: string]: string} = {}
72+
for (const [key, value] of Object.entries(env.processEnv)) {
73+
if (value !== undefined) spawnEnv[key] = value
74+
}
75+
spawnEnv.CI = ''
76+
spawnEnv.BROWSER = 'none'
77+
78+
const ptyProcess = nodePty.spawn('node', [executables.cli, 'auth', 'login'], {
79+
name: 'xterm-color',
80+
cols: 120,
81+
rows: 30,
82+
env: spawnEnv,
83+
})
84+
85+
let output = ''
86+
ptyProcess.onData((data: string) => {
87+
output += data
88+
if (process.env.DEBUG === '1') process.stdout.write(data)
89+
})
90+
91+
await waitForText(() => output, 'Press any key to open the login page', 30_000)
92+
ptyProcess.write(' ')
93+
await waitForText(() => output, 'start the auth process', 10_000)
94+
95+
const stripped = stripAnsi(output)
96+
const urlMatch = stripped.match(/https:\/\/accounts\.shopify\.com\S+/)
97+
if (!urlMatch) {
98+
throw new Error(`Could not find login URL in output:\n${stripped}`)
99+
}
100+
101+
let browser: Browser | undefined
102+
try {
103+
browser = await chromium.launch({headless: !process.env.E2E_HEADED})
104+
const context = await browser.newContext({
105+
extraHTTPHeaders: {
106+
'X-Shopify-Loadtest-Bf8d22e7-120e-4b5b-906c-39ca9d5499a9': 'true',
107+
},
108+
})
109+
const page = await context.newPage()
110+
await completeLogin(page, urlMatch[0], email, password)
111+
} finally {
112+
await browser?.close()
113+
}
114+
115+
await waitForText(() => output, 'Logged in', 60_000)
116+
try {
117+
ptyProcess.kill()
118+
// eslint-disable-next-line no-catch-all/no-catch-all
119+
} catch (_error) {
120+
// Process may already be dead
121+
}
122+
123+
// Remove the partners token so CLI uses the OAuth session
124+
// instead of the token (which can't auth against Business Platform API)
125+
delete env.processEnv.SHOPIFY_CLI_PARTNERS_TOKEN
126+
127+
await use()
128+
},
129+
{scope: 'worker'},
130+
],
131+
})
132+
133+
/**
134+
* Test-scoped fixture that creates a fresh app in a temp directory.
135+
* Depends on authLogin (worker-scoped) for OAuth session.
136+
*/
137+
export const appScaffoldFixture = withAuth.extend<{appScaffold: AppScaffold}>({
138+
appScaffold: async ({cli, env, authLogin: _authLogin}, use) => {
139+
const appTmpDir = fs.mkdtempSync(path.join(env.tempDir, 'app-'))
140+
let appDir = ''
141+
142+
const scaffold: AppScaffold = {
143+
get appDir() {
144+
if (!appDir) throw new Error('App has not been initialized yet. Call init() first.')
145+
return appDir
146+
},
147+
148+
async init(opts: AppInitOptions) {
149+
const name = opts.name ?? 'e2e-test-app'
150+
const template = opts.template ?? 'reactRouter'
151+
const packageManager = opts.packageManager ?? 'npm'
152+
153+
const args = [
154+
'--name',
155+
name,
156+
'--path',
157+
appTmpDir,
158+
'--package-manager',
159+
packageManager,
160+
'--local',
161+
'--template',
162+
template,
163+
]
164+
if (opts.flavor) args.push('--flavor', opts.flavor)
165+
166+
const result = await cli.execCreateApp(args, {
167+
env: {FORCE_COLOR: '0'},
168+
timeout: 5 * 60 * 1000,
169+
})
170+
171+
const allOutput = `${result.stdout}\n${result.stderr}`
172+
const match = allOutput.match(/([\w-]+) is ready for you to build!/)
173+
174+
if (match?.[1]) {
175+
appDir = path.join(appTmpDir, match[1])
176+
} else {
177+
const entries = fs.readdirSync(appTmpDir, {withFileTypes: true})
178+
const appEntry = entries.find(
179+
(entry) => entry.isDirectory() && fs.existsSync(path.join(appTmpDir, entry.name, 'shopify.app.toml')),
180+
)
181+
if (appEntry) {
182+
appDir = path.join(appTmpDir, appEntry.name)
183+
} else {
184+
throw new Error(
185+
`Could not find created app directory in ${appTmpDir}.\n` +
186+
`Exit code: ${result.exitCode}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`,
187+
)
188+
}
189+
}
190+
191+
const npmrcPath = path.join(appDir, '.npmrc')
192+
if (!fs.existsSync(npmrcPath)) fs.writeFileSync(npmrcPath, '')
193+
fs.appendFileSync(npmrcPath, 'frozen-lockfile=false\n')
194+
195+
return result
196+
},
197+
198+
async generateExtension(opts: ExtensionOptions) {
199+
const args = [
200+
'app',
201+
'generate',
202+
'extension',
203+
'--name',
204+
opts.name,
205+
'--path',
206+
appDir,
207+
'--template',
208+
opts.template,
209+
]
210+
if (opts.flavor) args.push('--flavor', opts.flavor)
211+
return cli.exec(args, {timeout: 5 * 60 * 1000})
212+
},
213+
214+
async build() {
215+
return cli.exec(['app', 'build', '--path', appDir], {timeout: 5 * 60 * 1000})
216+
},
217+
218+
async appInfo(): Promise<AppInfoResult> {
219+
const result = await cli.exec(['app', 'info', '--path', appDir, '--json'])
220+
return JSON.parse(result.stdout)
221+
},
222+
}
223+
224+
await use(scaffold)
225+
fs.rmSync(appTmpDir, {recursive: true, force: true})
226+
},
227+
})
228+
229+
async function completeLogin(page: Page, loginUrl: string, email: string, password: string): Promise<void> {
230+
await page.goto(loginUrl)
231+
232+
try {
233+
// Fill in email
234+
await page.waitForSelector('input[name="account[email]"], input[type="email"]', {timeout: 60_000})
235+
await page.locator('input[name="account[email]"], input[type="email"]').first().fill(email)
236+
await page.locator('button[type="submit"]').first().click()
237+
238+
// Fill in password
239+
await page.waitForSelector('input[name="account[password]"], input[type="password"]', {timeout: 60_000})
240+
await page.locator('input[name="account[password]"], input[type="password"]').first().fill(password)
241+
await page.locator('button[type="submit"]').first().click()
242+
243+
// Handle any confirmation/approval page
244+
await page.waitForTimeout(3000)
245+
try {
246+
const btn = page.locator('button[type="submit"]').first()
247+
if (await btn.isVisible({timeout: 5000})) await btn.click()
248+
// eslint-disable-next-line no-catch-all/no-catch-all
249+
} catch (_error) {
250+
// No confirmation page — expected
251+
}
252+
} catch (error) {
253+
const pageContent = await page.content().catch(() => '(failed to get content)')
254+
const pageUrl = page.url()
255+
throw new Error(
256+
`Login failed at ${pageUrl}\n` +
257+
`Original error: ${error}\n` +
258+
`Page HTML (first 2000 chars): ${pageContent.slice(0, 2000)}`,
259+
)
260+
}
261+
}
262+
263+
function waitForText(getOutput: () => string, text: string, timeoutMs: number): Promise<void> {
264+
return new Promise((resolve, reject) => {
265+
const interval = setInterval(() => {
266+
if (stripAnsi(getOutput()).includes(text)) {
267+
clearInterval(interval)
268+
clearTimeout(timer)
269+
resolve()
270+
}
271+
}, 200)
272+
const timer = setTimeout(() => {
273+
clearInterval(interval)
274+
reject(new Error(`Timed out after ${timeoutMs}ms waiting for: "${text}"\n\nOutput:\n${stripAnsi(getOutput())}`))
275+
}, timeoutMs)
276+
})
277+
}

0 commit comments

Comments
 (0)