diff --git a/.github/workflows/tests-pr.yml b/.github/workflows/tests-pr.yml index 657d8f2a91f..952d0b24ef4 100644 --- a/.github/workflows/tests-pr.yml +++ b/.github/workflows/tests-pr.yml @@ -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 diff --git a/packages/e2e/helpers/load-env.ts b/packages/e2e/helpers/load-env.ts new file mode 100644 index 00000000000..4d749acafd2 --- /dev/null +++ b/packages/e2e/helpers/load-env.ts @@ -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 + } +} diff --git a/packages/e2e/playwright.config.ts b/packages/e2e/playwright.config.ts index f2260472c55..755b044fde6 100644 --- a/packages/e2e/playwright.config.ts +++ b/packages/e2e/playwright.config.ts @@ -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) diff --git a/packages/e2e/setup/app.ts b/packages/e2e/setup/app.ts index dbd3da4b995..8b9d6cd5150 100644 --- a/packages/e2e/setup/app.ts +++ b/packages/e2e/setup/app.ts @@ -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 */ @@ -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 { - 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 { + 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}) }, diff --git a/packages/e2e/setup/auth.ts b/packages/e2e/setup/auth.ts index 93f224c1844..8b049474b41 100644 --- a/packages/e2e/setup/auth.ts +++ b/packages/e2e/setup/auth.ts @@ -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'], { @@ -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() diff --git a/packages/e2e/setup/cli.ts b/packages/e2e/setup/cli.ts index f3e178abaea..9f84f9eeb64 100644 --- a/packages/e2e/setup/cli.ts +++ b/packages/e2e/setup/cli.ts @@ -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): {[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, @@ -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, @@ -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 ?? '', @@ -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(' ')}`) } @@ -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 = '' diff --git a/packages/e2e/setup/env.ts b/packages/e2e/setup/env.ts index ea22c69ceb6..02e0f0f718f 100644 --- a/packages/e2e/setup/env.ts +++ b/packages/e2e/setup/env.ts @@ -16,6 +16,8 @@ export interface E2EEnv { storeFqdn: string /** Secondary app client ID for config link tests */ secondaryClientId: string + /** Dedicated e2e Partners org ID for fresh-app tests (empty string if not set) */ + orgId: string /** Environment variables to pass to CLI processes */ processEnv: NodeJS.ProcessEnv /** Temporary directory root for this worker */ @@ -66,7 +68,7 @@ export function createIsolatedEnv(baseDir: string): {tempDir: string; xdgEnv: {[ */ export function requireEnv( env: E2EEnv, - ...keys: (keyof Pick)[] + ...keys: (keyof Pick)[] ): void { for (const key of keys) { if (!env[key]) { @@ -75,6 +77,7 @@ export function requireEnv( clientId: 'SHOPIFY_FLAG_CLIENT_ID', storeFqdn: 'E2E_STORE_FQDN', secondaryClientId: 'E2E_SECONDARY_CLIENT_ID', + orgId: 'E2E_ORG_ID', } throw new Error(`${envVarNames[key]} environment variable is required for this test`) } @@ -93,6 +96,7 @@ export const envFixture = base.extend<{}, {env: E2EEnv}>({ const clientId = process.env.SHOPIFY_FLAG_CLIENT_ID ?? '' const storeFqdn = process.env.E2E_STORE_FQDN ?? '' const secondaryClientId = process.env.E2E_SECONDARY_CLIENT_ID ?? '' + const orgId = process.env.E2E_ORG_ID ?? '' const tmpBase = process.env.E2E_TEMP_DIR ?? path.join(directories.root, '.e2e-tmp') fs.mkdirSync(tmpBase, {recursive: true}) @@ -104,8 +108,8 @@ export const envFixture = base.extend<{}, {env: E2EEnv}>({ ...xdgEnv, SHOPIFY_RUN_AS_USER: '0', NODE_OPTIONS: '', - // Prevent interactive prompts CI: '1', + SHOPIFY_CLI_1P_DEV: undefined, } if (partnersToken) { @@ -123,13 +127,14 @@ export const envFixture = base.extend<{}, {env: E2EEnv}>({ clientId, storeFqdn, secondaryClientId, + orgId, processEnv, tempDir, } await use(env) - // Cleanup: remove temp directory + // Cleanup fs.rmSync(tempDir, {recursive: true, force: true}) }, {scope: 'worker'}, diff --git a/packages/e2e/tests/app-basic.spec.ts b/packages/e2e/tests/app-basic.spec.ts index 0ca04eb118d..21589a15942 100644 --- a/packages/e2e/tests/app-basic.spec.ts +++ b/packages/e2e/tests/app-basic.spec.ts @@ -11,12 +11,10 @@ test.describe('App basic flow (no extensions)', () => { cli, env, }) => { - // Full flow: init + dev (3 min) + deploy + config link + secondary deploy — needs 10 min test.setTimeout(10 * 60 * 1000) - requireEnv(env, 'clientId', 'storeFqdn', 'secondaryClientId') - // Step 1: Create a React Router app + // Step 1: Scaffold app (links to pre-existing app via SHOPIFY_FLAG_CLIENT_ID) const initResult = await appScaffold.init({ template: 'reactRouter', flavor: 'javascript', @@ -24,15 +22,14 @@ test.describe('App basic flow (no extensions)', () => { }) expect(initResult.exitCode, '‼️ Step 1 - app init failed').toBe(0) - // Step 2: Start dev server via PTY - // Unset CI so keyboard shortcuts are enabled in the Dev UI + // Step 2: Start dev server (CI='' enables keyboard shortcuts) const dev = await cli.spawn(['app', 'dev', '--path', appScaffold.appDir], {env: {CI: ''}}) try { await dev.waitForOutput('Ready, watching for changes in your app', 3 * 60 * 1000).catch((err: Error) => { throw new Error(`‼️ Step 2 - app dev failed\n${err.message}`) }) - // Step 3: Run a GraphQL query while the dev server is running + // Step 3: Run a GraphQL query const executeResult = await cli.exec( ['app', 'execute', '--query', 'query { shop { name } }', '--path', appScaffold.appDir], {timeout: 60 * 1000}, @@ -41,14 +38,14 @@ test.describe('App basic flow (no extensions)', () => { expect(executeResult.exitCode, '‼️ Step 3 - app execute failed').toBe(0) expect(executeOutput, '‼️ Step 3 - app execute: response missing "shop" field').toContain('"shop"') - // Step 4: Press q to quit the dev server + // Step 4: Quit dev server dev.sendKey('q') const devExitCode = await dev.waitForExit(30_000).catch((err: Error) => { throw new Error(`‼️ Step 4 - app dev did not exit after pressing q\n${err.message}`) }) expect(devExitCode, '‼️ Step 4 - app dev quit failed').toBe(0) } finally { - // Step 5: Always clean up the dev preview, even if the test fails + // Step 5: Clean up dev preview (runs even if test fails) dev.kill() const cleanResult = await cli.exec(['app', 'dev', 'clean', '--path', appScaffold.appDir]) const cleanOutput = cleanResult.stdout + cleanResult.stderr @@ -58,7 +55,7 @@ test.describe('App basic flow (no extensions)', () => { ) } - // Step 6: Deploy the primary app + // Step 6: Deploy const versionTag = `QA-E2E-1st-${Date.now()}` const deployResult = await cli.exec( [ @@ -76,7 +73,7 @@ test.describe('App basic flow (no extensions)', () => { ) expect(deployResult.exitCode, '‼️ Step 6 - app deploy failed').toBe(0) - // Step 7: List versions and verify our tag appears + // Step 7: Verify version appears in list const listResult = await cli.exec(['app', 'versions', 'list', '--path', appScaffold.appDir, '--json'], { timeout: 60 * 1000, }) @@ -84,13 +81,8 @@ test.describe('App basic flow (no extensions)', () => { expect(listResult.exitCode, '‼️ Step 7 - app versions list failed').toBe(0) expect(listOutput, `‼️ Step 7 - app versions list: missing version tag "${versionTag}"`).toContain(versionTag) - // Step 8: Config link to the secondary app - // Pre-create a minimal TOML stub so getTomls() finds the secondary client ID and skips - // the interactive "Configuration file name" prompt entirely. This avoids PTY timing races - // where the Enter key arrives before ink has fully initialized the text prompt, which - // causes renderTextPrompt to return '' → filenameFromName('') = 'shopify.app.toml' → - // that file already exists → overwrite confirmation prompt hangs. - // (--config and --client-id are mutually exclusive flags, so we can't pass both directly.) + // Step 8: Config link to secondary app + // TOML stub so config link skips the "Configuration file name" prompt fs.writeFileSync( path.join(appScaffold.appDir, 'shopify.app.secondary.toml'), `client_id = "${env.secondaryClientId}"\n`, @@ -106,7 +98,7 @@ test.describe('App basic flow (no extensions)', () => { const configLinkExitCode = await configLink.waitForExit(30_000) expect(configLinkExitCode, '‼️ Step 8 - app config link failed').toBe(0) - // Step 9: Deploy to the secondary app using the linked config file + // Step 9: Deploy to secondary app const secondaryVersionTag = `QA-E2E-2nd-${Date.now()}` const secondaryDeployResult = await cli.exec( [ diff --git a/packages/e2e/tests/app-from-scratch.spec.ts b/packages/e2e/tests/app-from-scratch.spec.ts new file mode 100644 index 00000000000..e57945d68bc --- /dev/null +++ b/packages/e2e/tests/app-from-scratch.spec.ts @@ -0,0 +1,167 @@ +import {freshAppScaffoldFixture as test} from '../setup/app.js' +import {requireEnv} from '../setup/env.js' +import {expect} from '@playwright/test' +import * as fs from 'fs' +import * as path from 'path' // eslint-disable-line no-restricted-imports + +test.describe('App basic flow — from scratch', () => { + test('init, dev, execute, quit, clean, deploy, versions, config link, deploy to secondary', async ({ + appScaffold, + cli, + env, + }) => { + test.setTimeout(15 * 60 * 1000) + requireEnv(env, 'storeFqdn', 'orgId') + + // Step 1: Create a new app (non-interactive via --organization-id + --name) + const initResult = await appScaffold.init({ + template: 'reactRouter', + flavor: 'typescript', + packageManager: 'npm', + }) + expect(initResult.exitCode, '‼️ Step 1 - app init failed').toBe(0) + + // Step 2: Start dev server (CI='' enables keyboard shortcuts) + const dev = await cli.spawn(['app', 'dev', '--path', appScaffold.appDir], {env: {CI: ''}}) + try { + await dev.waitForOutput('Ready, watching for changes in your app', 3 * 60 * 1000).catch((err: Error) => { + throw new Error(`‼️ Step 2 - app dev failed\n${err.message}`) + }) + + // Step 3: Run a GraphQL query + const executeResult = await cli.exec( + ['app', 'execute', '--query', 'query { shop { name } }', '--path', appScaffold.appDir], + {timeout: 60 * 1000}, + ) + const executeOutput = executeResult.stdout + executeResult.stderr + expect(executeResult.exitCode, '‼️ Step 3 - app execute failed').toBe(0) + expect(executeOutput, '‼️ Step 3 - app execute: response missing "shop" field').toContain('shop') + + // Step 4: Quit dev server + dev.sendKey('q') + const devExitCode = await dev.waitForExit(30_000).catch((err: Error) => { + throw new Error(`‼️ Step 4 - app dev did not exit after pressing q\n${err.message}`) + }) + expect(devExitCode, '‼️ Step 4 - app dev quit failed').toBe(0) + } finally { + // Step 5: Clean up dev preview (runs even if test fails) + dev.kill() + const cleanResult = await cli.exec(['app', 'dev', 'clean', '--path', appScaffold.appDir]) + const cleanOutput = cleanResult.stdout + cleanResult.stderr + expect(cleanResult.exitCode, '‼️ Step 5 - app dev clean failed').toBe(0) + expect(cleanOutput, '‼️ Step 5 - app dev clean: missing "Dev preview stopped" in output').toContain( + 'Dev preview stopped', + ) + } + + // Step 6: Deploy + const versionTag = `QA-E2E-1st-${Date.now()}` + const deployResult = await cli.exec( + [ + 'app', + 'deploy', + '--path', + appScaffold.appDir, + '--force', + '--version', + versionTag, + '--message', + 'E2E basic flow deployment', + ], + {timeout: 5 * 60 * 1000}, + ) + expect(deployResult.exitCode, '‼️ Step 6 - app deploy failed').toBe(0) + + // Step 7: Verify version appears in list + const listResult = await cli.exec(['app', 'versions', 'list', '--path', appScaffold.appDir, '--json'], { + timeout: 60 * 1000, + }) + const listOutput = listResult.stdout + listResult.stderr + expect(listResult.exitCode, '‼️ Step 7 - app versions list failed').toBe(0) + expect(listOutput, `‼️ Step 7 - app versions list: missing version tag "${versionTag}"`).toContain(versionTag) + + // Step 8: Create a second app, then config link to it + const secondaryAppName = `QA-E2E-2nd-${Date.now()}` + const secondaryTmpDir = fs.mkdtempSync(path.join(appScaffold.appDir, '..', 'secondary-')) + const secondaryInitResult = await cli.execCreateApp( + [ + '--name', + secondaryAppName, + '--path', + secondaryTmpDir, + '--package-manager', + 'npm', + '--local', + '--template', + 'reactRouter', + '--flavor', + 'typescript', + '--organization-id', + env.orgId, + ], + {env: {FORCE_COLOR: '0'}, timeout: 5 * 60 * 1000}, + ) + expect(secondaryInitResult.exitCode, '‼️ Step 8a - secondary app init failed').toBe(0) + + // Read client_id from the new app's toml + const secondaryAppDir = fs + .readdirSync(secondaryTmpDir, {withFileTypes: true}) + .find((entry) => entry.isDirectory() && fs.existsSync(path.join(secondaryTmpDir, entry.name, 'shopify.app.toml'))) + expect(secondaryAppDir, '‼️ Step 8a - secondary app dir not found').toBeTruthy() + const secondaryToml = fs.readFileSync( + path.join(secondaryTmpDir, secondaryAppDir!.name, 'shopify.app.toml'), + 'utf-8', + ) + const clientIdMatch = secondaryToml.match(/client_id\s*=\s*"([^"]+)"/) + expect(clientIdMatch, '‼️ Step 8a - client_id not found in secondary toml').toBeTruthy() + const secondaryClientId = clientIdMatch![1]! + + // TOML stub so config link skips the "Configuration file name" prompt + fs.writeFileSync( + path.join(appScaffold.appDir, 'shopify.app.secondary.toml'), + `client_id = "${secondaryClientId}"\n`, + ) + + // Link to the secondary app + const configLinkResult = await cli.exec( + ['app', 'config', 'link', '--path', appScaffold.appDir, '--client-id', secondaryClientId], + {timeout: 2 * 60 * 1000}, + ) + expect( + configLinkResult.exitCode, + `‼️ Step 8b - app config link failed\nstdout: ${configLinkResult.stdout}\nstderr: ${configLinkResult.stderr}`, + ).toBe(0) + const configLinkOutput = configLinkResult.stdout + configLinkResult.stderr + expect(configLinkOutput, '‼️ Step 8b - config link: missing "is now linked" in output').toContain( + 'is now linked to', + ) + + fs.rmSync(secondaryTmpDir, {recursive: true, force: true}) + + // Step 9: Deploy to the secondary app + const tomlFiles = fs + .readdirSync(appScaffold.appDir) + .filter( + (file: string) => file.startsWith('shopify.app.') && file.endsWith('.toml') && file !== 'shopify.app.toml', + ) + const secondaryConfig = tomlFiles[0]?.replace('shopify.app.', '').replace('.toml', '') ?? 'secondary' + const secondaryVersionTag = `QA-E2E-2nd-${Date.now()}` + const secondaryDeployResult = await cli.exec( + [ + 'app', + 'deploy', + '--path', + appScaffold.appDir, + '--config', + secondaryConfig, + '--force', + '--version', + secondaryVersionTag, + '--message', + 'E2E secondary app deployment', + ], + {timeout: 5 * 60 * 1000}, + ) + expect(secondaryDeployResult.exitCode, '‼️ Step 9 - app deploy (secondary) failed').toBe(0) + }) +})