Skip to content

Commit a60ce2c

Browse files
committed
QA to E2E: app basic flow from scratch (no extensions)
1 parent 570abb2 commit a60ce2c

9 files changed

Lines changed: 350 additions & 152 deletions

File tree

.github/workflows/tests-pr.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,7 @@ jobs:
317317
E2E_ACCOUNT_PASSWORD: ${{ secrets.E2E_ACCOUNT_PASSWORD }}
318318
E2E_STORE_FQDN: ${{ secrets.E2E_STORE_FQDN }}
319319
E2E_SECONDARY_CLIENT_ID: ${{ secrets.E2E_SECONDARY_CLIENT_ID }}
320+
E2E_ORG_ID: ${{ secrets.E2E_ORG_ID }}
320321
run: npx playwright test
321322
- name: Upload Playwright report
322323
uses: actions/upload-artifact@v4

packages/e2e/helpers/load-env.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/* eslint-disable no-restricted-imports */
2+
import * as fs from 'fs'
3+
import * as path from 'path'
4+
import {fileURLToPath} from 'url'
5+
6+
/**
7+
* Load a .env file into process.env (without overwriting existing values).
8+
* Handles quotes and inline comments (e.g. "VALUE # comment" → "VALUE").
9+
*/
10+
export function loadEnv(dirOrUrl: string): void {
11+
const dir = dirOrUrl.startsWith('file://') ? path.dirname(fileURLToPath(dirOrUrl)) : dirOrUrl
12+
const envPath = path.join(dir, '.env')
13+
if (!fs.existsSync(envPath)) return
14+
15+
for (const line of fs.readFileSync(envPath, 'utf-8').split('\n')) {
16+
const trimmed = line.trim()
17+
if (!trimmed || trimmed.startsWith('#')) continue
18+
const eqIdx = trimmed.indexOf('=')
19+
if (eqIdx === -1) continue
20+
const key = trimmed.slice(0, eqIdx).trim()
21+
let value = trimmed.slice(eqIdx + 1).trim()
22+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
23+
value = value.slice(1, -1)
24+
} else {
25+
const commentIdx = value.indexOf(' #')
26+
if (commentIdx !== -1) value = value.slice(0, commentIdx).trim()
27+
}
28+
process.env[key] ??= value
29+
}
30+
}

packages/e2e/playwright.config.ts

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,8 @@
11
/* eslint-disable line-comment-position */
2-
/* eslint-disable no-restricted-imports */
2+
import {loadEnv} from './helpers/load-env.js'
33
import {defineConfig} from '@playwright/test'
4-
import * as fs from 'fs'
5-
import * as path from 'path'
6-
import {fileURLToPath} from 'url'
74

8-
const __dirname = path.dirname(fileURLToPath(import.meta.url))
9-
10-
// Load .env file if present (CI provides env vars directly)
11-
const envPath = path.join(__dirname, '.env')
12-
if (fs.existsSync(envPath)) {
13-
for (const line of fs.readFileSync(envPath, 'utf-8').split('\n')) {
14-
const trimmed = line.trim()
15-
if (!trimmed || trimmed.startsWith('#')) continue
16-
const eqIdx = trimmed.indexOf('=')
17-
if (eqIdx === -1) continue
18-
const key = trimmed.slice(0, eqIdx).trim()
19-
const value = trimmed.slice(eqIdx + 1).trim()
20-
process.env[key] ??= value
21-
}
22-
}
5+
loadEnv(import.meta.url)
236

247
const isCI = Boolean(process.env.CI)
258

packages/e2e/setup/app.ts

Lines changed: 113 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import {authFixture} from './auth.js'
33
import * as path from 'path'
44
import * as fs from 'fs'
5-
import type {ExecResult} from './cli.js'
5+
import type {CLIProcess, ExecResult} from './cli.js'
66

77
export interface AppScaffold {
88
/** The directory where the app was created */
@@ -40,97 +40,124 @@ export interface AppInfoResult {
4040
}[]
4141
}
4242

43-
/**
44-
* Test-scoped fixture that creates a fresh app in a temp directory.
45-
* Depends on authLogin (worker-scoped) for OAuth session.
46-
*/
47-
export const appScaffoldFixture = authFixture.extend<{appScaffold: AppScaffold}>({
48-
appScaffold: async ({cli, env, authLogin: _authLogin}, use) => {
49-
const appTmpDir = fs.mkdtempSync(path.join(env.tempDir, 'app-'))
50-
let appDir = ''
51-
52-
const scaffold: AppScaffold = {
53-
get appDir() {
54-
if (!appDir) throw new Error('App has not been initialized yet. Call init() first.')
55-
return appDir
56-
},
57-
58-
async init(opts: AppInitOptions) {
59-
const name = opts.name ?? 'e2e-test-app'
60-
const template = opts.template ?? 'reactRouter'
61-
const packageManager = opts.packageManager ?? 'npm'
62-
63-
const args = [
64-
'--name',
65-
name,
66-
'--path',
67-
appTmpDir,
68-
'--package-manager',
69-
packageManager,
70-
'--local',
71-
'--template',
72-
template,
73-
]
74-
if (opts.flavor) args.push('--flavor', opts.flavor)
75-
76-
const result = await cli.execCreateApp(args, {
77-
env: {FORCE_COLOR: '0'},
78-
timeout: 5 * 60 * 1000,
79-
})
80-
81-
const allOutput = `${result.stdout}\n${result.stderr}`
82-
const match = allOutput.match(/([\w-]+) is ready for you to build!/)
83-
84-
if (match?.[1]) {
85-
appDir = path.join(appTmpDir, match[1])
43+
/** Shared scaffold builder. defaultName is used when opts.name is omitted. */
44+
function buildScaffold(
45+
cli: CLIProcess,
46+
appTmpDir: string,
47+
defaultName: string,
48+
orgId?: string,
49+
): {scaffold: AppScaffold} {
50+
let appDir = ''
51+
52+
const scaffold: AppScaffold = {
53+
get appDir() {
54+
if (!appDir) throw new Error('App has not been initialized yet. Call init() first.')
55+
return appDir
56+
},
57+
58+
async init(opts: AppInitOptions) {
59+
const name = opts.name ?? defaultName
60+
const template = opts.template ?? 'reactRouter'
61+
const packageManager = opts.packageManager ?? 'npm'
62+
63+
const args = [
64+
'--name',
65+
name,
66+
'--path',
67+
appTmpDir,
68+
'--package-manager',
69+
packageManager,
70+
'--local',
71+
'--template',
72+
template,
73+
]
74+
if (orgId) args.push('--organization-id', orgId)
75+
if (opts.flavor) args.push('--flavor', opts.flavor)
76+
77+
const result = await cli.execCreateApp(args, {
78+
env: {FORCE_COLOR: '0'},
79+
timeout: 5 * 60 * 1000,
80+
})
81+
82+
if (result.exitCode !== 0) {
83+
return result
84+
}
85+
86+
const allOutput = `${result.stdout}\n${result.stderr}`
87+
const match = allOutput.match(/([\w-]+) is ready for you to build!/)
88+
89+
if (match?.[1]) {
90+
appDir = path.join(appTmpDir, match[1])
91+
} else {
92+
const entries = fs.readdirSync(appTmpDir, {withFileTypes: true})
93+
const appEntry = entries.find(
94+
(entry) => entry.isDirectory() && fs.existsSync(path.join(appTmpDir, entry.name, 'shopify.app.toml')),
95+
)
96+
if (appEntry) {
97+
appDir = path.join(appTmpDir, appEntry.name)
8698
} else {
87-
const entries = fs.readdirSync(appTmpDir, {withFileTypes: true})
88-
const appEntry = entries.find(
89-
(entry) => entry.isDirectory() && fs.existsSync(path.join(appTmpDir, entry.name, 'shopify.app.toml')),
99+
throw new Error(
100+
`Could not find created app directory in ${appTmpDir}.\n` +
101+
`Exit code: ${result.exitCode}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`,
90102
)
91-
if (appEntry) {
92-
appDir = path.join(appTmpDir, appEntry.name)
93-
} else {
94-
throw new Error(
95-
`Could not find created app directory in ${appTmpDir}.\n` +
96-
`Exit code: ${result.exitCode}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`,
97-
)
98-
}
99103
}
104+
}
100105

101-
const npmrcPath = path.join(appDir, '.npmrc')
102-
if (!fs.existsSync(npmrcPath)) fs.writeFileSync(npmrcPath, '')
103-
fs.appendFileSync(npmrcPath, 'frozen-lockfile=false\n')
106+
const npmrcPath = path.join(appDir, '.npmrc')
107+
if (!fs.existsSync(npmrcPath)) fs.writeFileSync(npmrcPath, '')
108+
fs.appendFileSync(npmrcPath, 'frozen-lockfile=false\n')
104109

105-
return result
106-
},
107-
108-
async generateExtension(opts: ExtensionOptions) {
109-
const args = [
110-
'app',
111-
'generate',
112-
'extension',
113-
'--name',
114-
opts.name,
115-
'--path',
116-
appDir,
117-
'--template',
118-
opts.template,
119-
]
120-
if (opts.flavor) args.push('--flavor', opts.flavor)
121-
return cli.exec(args, {timeout: 5 * 60 * 1000})
122-
},
123-
124-
async build() {
125-
return cli.exec(['app', 'build', '--path', appDir], {timeout: 5 * 60 * 1000})
126-
},
127-
128-
async appInfo(): Promise<AppInfoResult> {
129-
const result = await cli.exec(['app', 'info', '--path', appDir, '--json'])
130-
return JSON.parse(result.stdout)
131-
},
132-
}
110+
return result
111+
},
112+
113+
async generateExtension(opts: ExtensionOptions) {
114+
const args = ['app', 'generate', 'extension', '--name', opts.name, '--path', appDir, '--template', opts.template]
115+
if (opts.flavor) args.push('--flavor', opts.flavor)
116+
return cli.exec(args, {timeout: 5 * 60 * 1000})
117+
},
133118

119+
async build() {
120+
return cli.exec(['app', 'build', '--path', appDir], {timeout: 5 * 60 * 1000})
121+
},
122+
123+
async appInfo(): Promise<AppInfoResult> {
124+
const result = await cli.exec(['app', 'info', '--path', appDir, '--json'])
125+
return JSON.parse(result.stdout)
126+
},
127+
}
128+
129+
return {scaffold}
130+
}
131+
132+
/** Fixture: scaffolds a local app linked to a pre-existing remote app (via SHOPIFY_FLAG_CLIENT_ID). */
133+
export const appScaffoldFixture = authFixture.extend<{appScaffold: AppScaffold}>({
134+
appScaffold: async ({cli, env, authLogin: _authLogin}, use) => {
135+
const appTmpDir = fs.mkdtempSync(path.join(env.tempDir, 'app-'))
136+
const {scaffold} = buildScaffold(cli, appTmpDir, 'e2e-test-app')
137+
await use(scaffold)
138+
fs.rmSync(appTmpDir, {recursive: true, force: true})
139+
},
140+
})
141+
142+
/** CLI wrapper that strips SHOPIFY_FLAG_CLIENT_ID so commands use the toml's client_id. */
143+
function makeFreshCli(baseCli: CLIProcess, baseProcessEnv: NodeJS.ProcessEnv): CLIProcess {
144+
const freshEnv = {...baseProcessEnv, SHOPIFY_FLAG_CLIENT_ID: undefined}
145+
return {
146+
exec: (args, opts = {}) => baseCli.exec(args, {...opts, env: {...freshEnv, ...opts.env}}),
147+
execCreateApp: (args, opts = {}) => baseCli.execCreateApp(args, {...opts, env: {...freshEnv, ...opts.env}}),
148+
spawn: (args, opts = {}) => baseCli.spawn(args, {...opts, env: {...freshEnv, ...opts.env}}),
149+
}
150+
}
151+
152+
/** Fixture: creates a brand-new app on every run. Requires E2E_ORG_ID. */
153+
export const freshAppScaffoldFixture = authFixture.extend<{appScaffold: AppScaffold; cli: CLIProcess}>({
154+
cli: async ({cli: baseCli, env}, use) => {
155+
await use(makeFreshCli(baseCli, env.processEnv))
156+
},
157+
158+
appScaffold: async ({cli, env, authLogin: _authLogin}, use) => {
159+
const appTmpDir = fs.mkdtempSync(path.join(env.tempDir, 'fresh-app-'))
160+
const {scaffold} = buildScaffold(cli, appTmpDir, `QA-E2E-1st-${Date.now()}`, env.orgId || undefined)
134161
await use(scaffold)
135162
fs.rmSync(appTmpDir, {recursive: true, force: true})
136163
},

packages/e2e/setup/auth.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,7 @@ export const authFixture = cliFixture.extend<{}, {authLogin: void}>({
3636
if (value !== undefined) spawnEnv[key] = value
3737
}
3838
spawnEnv.CI = ''
39-
// Pretend we're in a cloud environment so the CLI prints the login URL
40-
// directly instead of opening a system browser (BROWSER=none doesn't work on macOS)
39+
// Print login URL directly instead of opening system browser
4140
spawnEnv.CODESPACES = 'true'
4241

4342
const ptyProcess = nodePty.spawn('node', [executables.cli, 'auth', 'login'], {
@@ -83,8 +82,7 @@ export const authFixture = cliFixture.extend<{}, {authLogin: void}>({
8382
// Process may already be dead
8483
}
8584

86-
// Remove the partners token so CLI uses the OAuth session
87-
// instead of the token (which can't auth against Business Platform API)
85+
// Drop token so CLI uses the OAuth session instead
8886
delete env.processEnv.SHOPIFY_CLI_PARTNERS_TOKEN
8987

9088
await use()

packages/e2e/setup/cli.ts

Lines changed: 17 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -45,19 +45,22 @@ export const cliFixture = envFixture.extend<{cli: CLIProcess}>({
4545
cli: async ({env}, use) => {
4646
const spawnedProcesses: SpawnedProcess[] = []
4747

48+
// Merge env with opts, filtering out undefined values
49+
function buildEnv(optsEnv?: Record<string, string | undefined>): {[key: string]: string} {
50+
const result: {[key: string]: string} = {}
51+
for (const [key, value] of Object.entries({...env.processEnv, ...optsEnv})) {
52+
if (value !== undefined) result[key] = value
53+
}
54+
return result
55+
}
56+
4857
const cli: CLIProcess = {
4958
async exec(args, opts = {}) {
5059
// 3 min default
5160
const timeout = opts.timeout ?? 3 * 60 * 1000
52-
const execEnv: {[key: string]: string} = {}
53-
for (const [key, value] of Object.entries({...env.processEnv, ...opts.env})) {
54-
if (value !== undefined) {
55-
execEnv[key] = value
56-
}
57-
}
5861
const execaOpts: ExecaOptions = {
5962
cwd: opts.cwd,
60-
env: execEnv,
63+
env: buildEnv(opts.env),
6164
extendEnv: false,
6265
timeout,
6366
reject: false,
@@ -79,15 +82,9 @@ export const cliFixture = envFixture.extend<{cli: CLIProcess}>({
7982
async execCreateApp(args, opts = {}) {
8083
// 5 min default for scaffolding
8184
const timeout = opts.timeout ?? 5 * 60 * 1000
82-
const execEnv: {[key: string]: string} = {}
83-
for (const [key, value] of Object.entries({...env.processEnv, ...opts.env})) {
84-
if (value !== undefined) {
85-
execEnv[key] = value
86-
}
87-
}
8885
const execaOpts: ExecaOptions = {
8986
cwd: opts.cwd,
90-
env: execEnv,
87+
env: buildEnv(opts.env),
9188
extendEnv: false,
9289
timeout,
9390
reject: false,
@@ -99,6 +96,11 @@ export const cliFixture = envFixture.extend<{cli: CLIProcess}>({
9996

10097
const result = await execa('node', [executables.createApp, ...args], execaOpts)
10198

99+
if (process.env.DEBUG === '1') {
100+
if (result.stdout) console.log(`[e2e] execCreateApp stdout:\n${result.stdout}`)
101+
if (result.stderr) console.log(`[e2e] execCreateApp stderr:\n${result.stderr}`)
102+
}
103+
102104
return {
103105
stdout: result.stdout ?? '',
104106
stderr: result.stderr ?? '',
@@ -110,13 +112,6 @@ export const cliFixture = envFixture.extend<{cli: CLIProcess}>({
110112
// Dynamic import to avoid requiring node-pty for Phase 1 tests
111113
const nodePty = await import('node-pty')
112114

113-
const spawnEnv: {[key: string]: string} = {}
114-
for (const [key, value] of Object.entries({...env.processEnv, ...opts.env})) {
115-
if (value !== undefined) {
116-
spawnEnv[key] = value
117-
}
118-
}
119-
120115
if (process.env.DEBUG === '1') {
121116
console.log(`[e2e] spawn: node ${executables.cli} ${args.join(' ')}`)
122117
}
@@ -126,7 +121,7 @@ export const cliFixture = envFixture.extend<{cli: CLIProcess}>({
126121
cols: 120,
127122
rows: 30,
128123
cwd: opts.cwd,
129-
env: spawnEnv,
124+
env: buildEnv(opts.env),
130125
})
131126

132127
let output = ''

0 commit comments

Comments
 (0)