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
1 change: 1 addition & 0 deletions .github/workflows/tests-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@ jobs:
E2E_ACCOUNT_PASSWORD: ${{ secrets.E2E_ACCOUNT_PASSWORD }}
E2E_STORE_FQDN: ${{ secrets.E2E_STORE_FQDN }}
E2E_SECONDARY_CLIENT_ID: ${{ secrets.E2E_SECONDARY_CLIENT_ID }}
E2E_ORG_ID: ${{ secrets.E2E_ORG_ID }}
run: npx playwright test
- name: Upload Playwright report
uses: actions/upload-artifact@v4
Expand Down
30 changes: 30 additions & 0 deletions packages/e2e/helpers/load-env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/* eslint-disable no-restricted-imports */
import * as fs from 'fs'
import * as path from 'path'
import {fileURLToPath} from 'url'

/**
* Load a .env file into process.env (without overwriting existing values).
* Handles quotes and inline comments (e.g. "VALUE # comment" → "VALUE").
*/
export function loadEnv(dirOrUrl: string): void {
const dir = dirOrUrl.startsWith('file://') ? path.dirname(fileURLToPath(dirOrUrl)) : dirOrUrl
const envPath = path.join(dir, '.env')
if (!fs.existsSync(envPath)) return

for (const line of fs.readFileSync(envPath, 'utf-8').split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const eqIdx = trimmed.indexOf('=')
if (eqIdx === -1) continue
const key = trimmed.slice(0, eqIdx).trim()
let value = trimmed.slice(eqIdx + 1).trim()
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1)
} else {
const commentIdx = value.indexOf(' #')
if (commentIdx !== -1) value = value.slice(0, commentIdx).trim()
}
process.env[key] ??= value
}
}
21 changes: 2 additions & 19 deletions packages/e2e/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,8 @@
/* eslint-disable line-comment-position */
/* eslint-disable no-restricted-imports */
import {loadEnv} from './helpers/load-env.js'
import {defineConfig} from '@playwright/test'
import * as fs from 'fs'
import * as path from 'path'
import {fileURLToPath} from 'url'

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

// Load .env file if present (CI provides env vars directly)
const envPath = path.join(__dirname, '.env')
if (fs.existsSync(envPath)) {
for (const line of fs.readFileSync(envPath, 'utf-8').split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const eqIdx = trimmed.indexOf('=')
if (eqIdx === -1) continue
const key = trimmed.slice(0, eqIdx).trim()
const value = trimmed.slice(eqIdx + 1).trim()
process.env[key] ??= value
}
}
loadEnv(import.meta.url)

const isCI = Boolean(process.env.CI)

Expand Down
199 changes: 113 additions & 86 deletions packages/e2e/setup/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import {authFixture} from './auth.js'
import * as path from 'path'
import * as fs from 'fs'
import type {ExecResult} from './cli.js'
import type {CLIProcess, ExecResult} from './cli.js'

export interface AppScaffold {
/** The directory where the app was created */
Expand Down Expand Up @@ -40,97 +40,124 @@ export interface AppInfoResult {
}[]
}

/**
* Test-scoped fixture that creates a fresh app in a temp directory.
* Depends on authLogin (worker-scoped) for OAuth session.
*/
export const appScaffoldFixture = authFixture.extend<{appScaffold: AppScaffold}>({
appScaffold: async ({cli, env, authLogin: _authLogin}, use) => {
const appTmpDir = fs.mkdtempSync(path.join(env.tempDir, 'app-'))
let appDir = ''

const scaffold: AppScaffold = {
get appDir() {
if (!appDir) throw new Error('App has not been initialized yet. Call init() first.')
return appDir
},

async init(opts: AppInitOptions) {
const name = opts.name ?? 'e2e-test-app'
const template = opts.template ?? 'reactRouter'
const packageManager = opts.packageManager ?? 'npm'

const args = [
'--name',
name,
'--path',
appTmpDir,
'--package-manager',
packageManager,
'--local',
'--template',
template,
]
if (opts.flavor) args.push('--flavor', opts.flavor)

const result = await cli.execCreateApp(args, {
env: {FORCE_COLOR: '0'},
timeout: 5 * 60 * 1000,
})

const allOutput = `${result.stdout}\n${result.stderr}`
const match = allOutput.match(/([\w-]+) is ready for you to build!/)

if (match?.[1]) {
appDir = path.join(appTmpDir, match[1])
/** Shared scaffold builder. defaultName is used when opts.name is omitted. */
function buildScaffold(
cli: CLIProcess,
appTmpDir: string,
defaultName: string,
orgId?: string,
): {scaffold: AppScaffold} {
let appDir = ''

const scaffold: AppScaffold = {
get appDir() {
if (!appDir) throw new Error('App has not been initialized yet. Call init() first.')
return appDir
},

async init(opts: AppInitOptions) {
const name = opts.name ?? defaultName
const template = opts.template ?? 'reactRouter'
const packageManager = opts.packageManager ?? 'npm'

const args = [
'--name',
name,
'--path',
appTmpDir,
'--package-manager',
packageManager,
'--local',
'--template',
template,
]
if (orgId) args.push('--organization-id', orgId)
if (opts.flavor) args.push('--flavor', opts.flavor)

const result = await cli.execCreateApp(args, {
env: {FORCE_COLOR: '0'},
timeout: 5 * 60 * 1000,
})

if (result.exitCode !== 0) {
return result
}

const allOutput = `${result.stdout}\n${result.stderr}`
const match = allOutput.match(/([\w-]+) is ready for you to build!/)

if (match?.[1]) {
appDir = path.join(appTmpDir, match[1])
} else {
const entries = fs.readdirSync(appTmpDir, {withFileTypes: true})
const appEntry = entries.find(
(entry) => entry.isDirectory() && fs.existsSync(path.join(appTmpDir, entry.name, 'shopify.app.toml')),
)
if (appEntry) {
appDir = path.join(appTmpDir, appEntry.name)
} else {
const entries = fs.readdirSync(appTmpDir, {withFileTypes: true})
const appEntry = entries.find(
(entry) => entry.isDirectory() && fs.existsSync(path.join(appTmpDir, entry.name, 'shopify.app.toml')),
throw new Error(
`Could not find created app directory in ${appTmpDir}.\n` +
`Exit code: ${result.exitCode}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`,
)
if (appEntry) {
appDir = path.join(appTmpDir, appEntry.name)
} else {
throw new Error(
`Could not find created app directory in ${appTmpDir}.\n` +
`Exit code: ${result.exitCode}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`,
)
}
}
}

const npmrcPath = path.join(appDir, '.npmrc')
if (!fs.existsSync(npmrcPath)) fs.writeFileSync(npmrcPath, '')
fs.appendFileSync(npmrcPath, 'frozen-lockfile=false\n')
const npmrcPath = path.join(appDir, '.npmrc')
if (!fs.existsSync(npmrcPath)) fs.writeFileSync(npmrcPath, '')
fs.appendFileSync(npmrcPath, 'frozen-lockfile=false\n')

return result
},

async generateExtension(opts: ExtensionOptions) {
const args = [
'app',
'generate',
'extension',
'--name',
opts.name,
'--path',
appDir,
'--template',
opts.template,
]
if (opts.flavor) args.push('--flavor', opts.flavor)
return cli.exec(args, {timeout: 5 * 60 * 1000})
},

async build() {
return cli.exec(['app', 'build', '--path', appDir], {timeout: 5 * 60 * 1000})
},

async appInfo(): Promise<AppInfoResult> {
const result = await cli.exec(['app', 'info', '--path', appDir, '--json'])
return JSON.parse(result.stdout)
},
}
return result
},

async generateExtension(opts: ExtensionOptions) {
const args = ['app', 'generate', 'extension', '--name', opts.name, '--path', appDir, '--template', opts.template]
if (opts.flavor) args.push('--flavor', opts.flavor)
return cli.exec(args, {timeout: 5 * 60 * 1000})
},

async build() {
return cli.exec(['app', 'build', '--path', appDir], {timeout: 5 * 60 * 1000})
},

async appInfo(): Promise<AppInfoResult> {
const result = await cli.exec(['app', 'info', '--path', appDir, '--json'])
return JSON.parse(result.stdout)
},
}

return {scaffold}
}

/** Fixture: scaffolds a local app linked to a pre-existing remote app (via SHOPIFY_FLAG_CLIENT_ID). */
export const appScaffoldFixture = authFixture.extend<{appScaffold: AppScaffold}>({
appScaffold: async ({cli, env, authLogin: _authLogin}, use) => {
const appTmpDir = fs.mkdtempSync(path.join(env.tempDir, 'app-'))
const {scaffold} = buildScaffold(cli, appTmpDir, 'e2e-test-app')
await use(scaffold)
fs.rmSync(appTmpDir, {recursive: true, force: true})
},
})

/** CLI wrapper that strips SHOPIFY_FLAG_CLIENT_ID so commands use the toml's client_id. */
function makeFreshCli(baseCli: CLIProcess, baseProcessEnv: NodeJS.ProcessEnv): CLIProcess {
const freshEnv = {...baseProcessEnv, SHOPIFY_FLAG_CLIENT_ID: undefined}
return {
exec: (args, opts = {}) => baseCli.exec(args, {...opts, env: {...freshEnv, ...opts.env}}),
execCreateApp: (args, opts = {}) => baseCli.execCreateApp(args, {...opts, env: {...freshEnv, ...opts.env}}),
spawn: (args, opts = {}) => baseCli.spawn(args, {...opts, env: {...freshEnv, ...opts.env}}),
}
}

/** Fixture: creates a brand-new app on every run. Requires E2E_ORG_ID. */
export const freshAppScaffoldFixture = authFixture.extend<{appScaffold: AppScaffold; cli: CLIProcess}>({
cli: async ({cli: baseCli, env}, use) => {
await use(makeFreshCli(baseCli, env.processEnv))
},

appScaffold: async ({cli, env, authLogin: _authLogin}, use) => {
const appTmpDir = fs.mkdtempSync(path.join(env.tempDir, 'fresh-app-'))
const {scaffold} = buildScaffold(cli, appTmpDir, `QA-E2E-1st-${Date.now()}`, env.orgId || undefined)
await use(scaffold)
fs.rmSync(appTmpDir, {recursive: true, force: true})
},
Expand Down
6 changes: 2 additions & 4 deletions packages/e2e/setup/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,7 @@ export const authFixture = cliFixture.extend<{}, {authLogin: void}>({
if (value !== undefined) spawnEnv[key] = value
}
spawnEnv.CI = ''
// Pretend we're in a cloud environment so the CLI prints the login URL
// directly instead of opening a system browser (BROWSER=none doesn't work on macOS)
// Print login URL directly instead of opening system browser
spawnEnv.CODESPACES = 'true'

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

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

await use()
Expand Down
39 changes: 17 additions & 22 deletions packages/e2e/setup/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,19 +45,22 @@ export const cliFixture = envFixture.extend<{cli: CLIProcess}>({
cli: async ({env}, use) => {
const spawnedProcesses: SpawnedProcess[] = []

// Merge env with opts, filtering out undefined values
function buildEnv(optsEnv?: Record<string, string | undefined>): {[key: string]: string} {
const result: {[key: string]: string} = {}
for (const [key, value] of Object.entries({...env.processEnv, ...optsEnv})) {
if (value !== undefined) result[key] = value
}
return result
}

const cli: CLIProcess = {
async exec(args, opts = {}) {
// 3 min default
const timeout = opts.timeout ?? 3 * 60 * 1000
const execEnv: {[key: string]: string} = {}
for (const [key, value] of Object.entries({...env.processEnv, ...opts.env})) {
if (value !== undefined) {
execEnv[key] = value
}
}
const execaOpts: ExecaOptions = {
cwd: opts.cwd,
env: execEnv,
env: buildEnv(opts.env),
extendEnv: false,
timeout,
reject: false,
Expand All @@ -79,15 +82,9 @@ export const cliFixture = envFixture.extend<{cli: CLIProcess}>({
async execCreateApp(args, opts = {}) {
// 5 min default for scaffolding
const timeout = opts.timeout ?? 5 * 60 * 1000
const execEnv: {[key: string]: string} = {}
for (const [key, value] of Object.entries({...env.processEnv, ...opts.env})) {
if (value !== undefined) {
execEnv[key] = value
}
}
const execaOpts: ExecaOptions = {
cwd: opts.cwd,
env: execEnv,
env: buildEnv(opts.env),
extendEnv: false,
timeout,
reject: false,
Expand All @@ -99,6 +96,11 @@ export const cliFixture = envFixture.extend<{cli: CLIProcess}>({

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

if (process.env.DEBUG === '1') {
if (result.stdout) console.log(`[e2e] execCreateApp stdout:\n${result.stdout}`)
if (result.stderr) console.log(`[e2e] execCreateApp stderr:\n${result.stderr}`)
}

return {
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
Expand All @@ -110,13 +112,6 @@ export const cliFixture = envFixture.extend<{cli: CLIProcess}>({
// 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(' ')}`)
}
Expand All @@ -126,7 +121,7 @@ export const cliFixture = envFixture.extend<{cli: CLIProcess}>({
cols: 120,
rows: 30,
cwd: opts.cwd,
env: spawnEnv,
env: buildEnv(opts.env),
})

let output = ''
Expand Down
Loading
Loading