diff --git a/.github/workflows/tests-pr.yml b/.github/workflows/tests-pr.yml index f4afe424ad9..e04fe7b08d7 100644 --- a/.github/workflows/tests-pr.yml +++ b/.github/workflows/tests-pr.yml @@ -219,7 +219,7 @@ jobs: name: 'E2E tests' if: github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 30 continue-on-error: true steps: - uses: actions/checkout@v3 @@ -241,11 +241,9 @@ jobs: - name: Run E2E tests working-directory: packages/e2e env: - SHOPIFY_FLAG_CLIENT_ID: ${{ secrets.E2E_CLIENT_ID }} E2E_ACCOUNT_EMAIL: ${{ secrets.E2E_ACCOUNT_EMAIL }} 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 diff --git a/packages/e2e/data/invalid-tomls/bad-syntax.toml b/packages/e2e/data/invalid-tomls/bad-syntax.toml index 931f7de6161..14c739fc70b 100644 --- a/packages/e2e/data/invalid-tomls/bad-syntax.toml +++ b/packages/e2e/data/invalid-tomls/bad-syntax.toml @@ -1,5 +1,5 @@ # Invalid TOML: malformed syntax (missing closing quote) -client_id = "__E2E_CLIENT_ID__" +client_id = "1234567890" name = "Bad Syntax App application_url = "https://example.com" embedded = true diff --git a/packages/e2e/data/invalid-tomls/invalid-webhook.toml b/packages/e2e/data/invalid-tomls/invalid-webhook.toml index ff5eaa830dd..d46971fecff 100644 --- a/packages/e2e/data/invalid-tomls/invalid-webhook.toml +++ b/packages/e2e/data/invalid-tomls/invalid-webhook.toml @@ -1,5 +1,5 @@ # Invalid TOML: bad webhook config (missing required uri) -client_id = "__E2E_CLIENT_ID__" +client_id = "1234567890" name = "Invalid Webhook App" application_url = "https://example.com" embedded = true diff --git a/packages/e2e/data/invalid-tomls/wrong-type.toml b/packages/e2e/data/invalid-tomls/wrong-type.toml index e47192d94db..9b2b568b959 100644 --- a/packages/e2e/data/invalid-tomls/wrong-type.toml +++ b/packages/e2e/data/invalid-tomls/wrong-type.toml @@ -1,5 +1,5 @@ # Invalid TOML: wrong types for known fields -client_id = "__E2E_CLIENT_ID__" +client_id = "1234567890" name = "Wrong Type App" application_url = "https://example.com" embedded = "not-a-boolean" diff --git a/packages/e2e/data/valid-app/shopify.app.toml b/packages/e2e/data/valid-app/shopify.app.toml index 131fefd0d1c..5d12a715933 100644 --- a/packages/e2e/data/valid-app/shopify.app.toml +++ b/packages/e2e/data/valid-app/shopify.app.toml @@ -1,7 +1,7 @@ # Comprehensive shopify.app.toml for E2E regression testing -# client_id is injected at runtime by the toml-app fixture -client_id = "__E2E_CLIENT_ID__" -name = "E2E TOML Regression Test" +# client_id is injected at runtime via injectFixtureToml() +client_id = "__CLIENT_ID__" +name = "__NAME__" application_url = "https://example.com" embedded = true diff --git a/packages/e2e/playwright.config.ts b/packages/e2e/playwright.config.ts index 04d15dd66f6..6df0b514aef 100644 --- a/packages/e2e/playwright.config.ts +++ b/packages/e2e/playwright.config.ts @@ -15,7 +15,7 @@ export default defineConfig({ maxFailures: isCI ? 3 : 0, // Stop early in CI after 3 failures reporter: isCI ? [['html', {open: 'never'}], ['list']] : [['list']], timeout: 3 * 60 * 1000, // 3 minutes per test - globalTimeout: 15 * 60 * 1000, // 15 minutes total + globalTimeout: 30 * 60 * 1000, // 30 minutes total use: { trace: isCI ? 'on' : 'off', diff --git a/packages/e2e/setup/app.ts b/packages/e2e/setup/app.ts index 1b9e84c0ffd..d79375d82b4 100644 --- a/packages/e2e/setup/app.ts +++ b/packages/e2e/setup/app.ts @@ -6,12 +6,6 @@ import * as fs from 'fs' import type {CLIContext, CLIProcess, ExecResult} from './cli.js' import type {BrowserContext} from './browser.js' -// Env override applied to all CLI helpers — strips CLIENT_ID so commands use the app's own toml. -// NOTE: Do NOT add SHOPIFY_CLI_PARTNERS_TOKEN here. The partners token overrides OAuth in the -// CLI's auth priority, and the App Management API token it exchanges to lacks permissions to -// create apps (403). OAuth provides the full set of required permissions. -const FRESH_APP_ENV = {SHOPIFY_FLAG_CLIENT_ID: undefined} - // --------------------------------------------------------------------------- // CLI helpers — thin wrappers around cli.exec() // --------------------------------------------------------------------------- @@ -45,8 +39,8 @@ export async function createApp(ctx: { if (ctx.flavor) args.push('--flavor', ctx.flavor) const result = await cli.execCreateApp(args, { - // Strip CLIENT_ID so the CLI creates a new app instead of linking to a pre-existing one - env: {FORCE_COLOR: '0', ...FRESH_APP_ENV}, + // Disable color output and strip CLIENT_ID to prevent leaking from parent process.env + env: {FORCE_COLOR: '0', SHOPIFY_FLAG_CLIENT_ID: undefined}, timeout: 5 * 60 * 1000, }) @@ -81,6 +75,33 @@ export async function createApp(ctx: { return {...result, appDir} } +// --------------------------------------------------------------------------- +// Fixture helpers — TOML manipulation for test setup +// --------------------------------------------------------------------------- + +/** + * Read the client_id from a shopify.app.toml file. + */ +export function extractClientId(appDir: string): string { + const toml = fs.readFileSync(path.join(appDir, 'shopify.app.toml'), 'utf8') + const match = toml.match(/client_id\s*=\s*"([^"]+)"/) + if (!match?.[1]) { + throw new Error(`Could not find client_id in ${path.join(appDir, 'shopify.app.toml')}`) + } + return match[1] +} + +/** + * Overwrite a created app's shopify.app.toml with a fixture TOML template. + * The template should contain `__CLIENT_ID__` and `__NAME__` placeholders which get + * replaced with the app's real client_id and the provided name. + */ +export function injectFixtureToml(appDir: string, fixtureTomlContent: string, name: string): void { + const clientId = extractClientId(appDir) + const toml = fixtureTomlContent.replace(/__CLIENT_ID__/g, clientId).replace(/__NAME__/g, name) + fs.writeFileSync(path.join(appDir, 'shopify.app.toml'), toml) +} + export async function generateExtension( ctx: CLIContext & { name: string @@ -90,11 +111,11 @@ export async function generateExtension( ): Promise { const args = ['app', 'generate', 'extension', '--name', ctx.name, '--path', ctx.appDir, '--template', ctx.template] if (ctx.flavor) args.push('--flavor', ctx.flavor) - return ctx.cli.exec(args, {env: FRESH_APP_ENV, timeout: 5 * 60 * 1000}) + return ctx.cli.exec(args, {timeout: 5 * 60 * 1000}) } export async function buildApp(ctx: CLIContext): Promise { - return ctx.cli.exec(['app', 'build', '--path', ctx.appDir], {env: FRESH_APP_ENV, timeout: 5 * 60 * 1000}) + return ctx.cli.exec(['app', 'build', '--path', ctx.appDir], {timeout: 5 * 60 * 1000}) } export async function deployApp( @@ -112,7 +133,7 @@ export async function deployApp( if (ctx.version) args.push('--version', ctx.version) if (ctx.message) args.push('--message', ctx.message) if (ctx.config) args.push('--config', ctx.config) - return ctx.cli.exec(args, {env: FRESH_APP_ENV, timeout: 5 * 60 * 1000}) + return ctx.cli.exec(args, {timeout: 5 * 60 * 1000}) } export async function appInfo(ctx: CLIContext): Promise<{ @@ -124,7 +145,7 @@ export async function appInfo(ctx: CLIContext): Promise<{ entrySourceFilePath: string }[] }> { - const result = await ctx.cli.exec(['app', 'info', '--path', ctx.appDir, '--json'], {env: FRESH_APP_ENV}) + const result = await ctx.cli.exec(['app', 'info', '--path', ctx.appDir, '--json']) if (result.exitCode !== 0) { throw new Error(`app info failed (exit ${result.exitCode}):\nstdout: ${result.stdout}\nstderr: ${result.stderr}`) } @@ -132,7 +153,7 @@ export async function appInfo(ctx: CLIContext): Promise<{ } export async function functionBuild(ctx: CLIContext): Promise { - return ctx.cli.exec(['app', 'function', 'build', '--path', ctx.appDir], {env: FRESH_APP_ENV, timeout: 3 * 60 * 1000}) + return ctx.cli.exec(['app', 'function', 'build', '--path', ctx.appDir], {timeout: 3 * 60 * 1000}) } export async function functionRun( @@ -141,14 +162,12 @@ export async function functionRun( }, ): Promise { return ctx.cli.exec(['app', 'function', 'run', '--path', ctx.appDir, '--input', ctx.inputPath], { - env: FRESH_APP_ENV, timeout: 60 * 1000, }) } export async function versionsList(ctx: CLIContext): Promise { return ctx.cli.exec(['app', 'versions', 'list', '--path', ctx.appDir, '--json'], { - env: FRESH_APP_ENV, timeout: 60 * 1000, }) } @@ -159,7 +178,6 @@ export async function configLink( }, ): Promise { return ctx.cli.exec(['app', 'config', 'link', '--path', ctx.appDir, '--client-id', ctx.clientId], { - env: FRESH_APP_ENV, timeout: 2 * 60 * 1000, }) } @@ -374,13 +392,9 @@ export async function teardownApp( // --------------------------------------------------------------------------- export const appTestFixture = authFixture.extend<{authReady: void}>({ - // Auto-trigger authLogin and strip CLIENT_ID so tests create their own apps + // Auto-trigger authLogin so the OAuth session is available for all app tests authReady: [ - async ( - {authLogin: _authLogin, env}: {authLogin: void; env: import('./env.js').E2EEnv}, - use: () => Promise, - ) => { - delete env.processEnv.SHOPIFY_FLAG_CLIENT_ID + async ({authLogin: _authLogin}: {authLogin: void}, use: () => Promise) => { await use() }, {auto: true}, diff --git a/packages/e2e/setup/env.ts b/packages/e2e/setup/env.ts index f119757ba1f..eacc835af87 100644 --- a/packages/e2e/setup/env.ts +++ b/packages/e2e/setup/env.ts @@ -8,14 +8,8 @@ const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) export interface E2EEnv { - /** Partners token for API auth (empty string if not set) */ - partnersToken: string - /** Primary test app client ID (empty string if not set) */ - clientId: string /** Dev store FQDN (e.g. cli-e2e-test.myshopify.com) */ storeFqdn: string - /** Secondary app client ID for config link tests */ - secondaryClientId: string /** Dedicated e2e org ID for fresh-app tests (empty string if not set) */ orgId: string /** Environment variables to pass to CLI processes */ @@ -64,19 +58,13 @@ export function createIsolatedEnv(baseDir: string): {tempDir: string; xdgEnv: {[ /** * Asserts that a required environment variable is set. - * Call this at the top of tests that need auth. + * Call this at the top of tests that need specific env vars. */ -export function requireEnv( - env: E2EEnv, - ...keys: (keyof Pick)[] -): void { +export function requireEnv(env: E2EEnv, ...keys: (keyof Pick)[]): void { for (const key of keys) { if (!env[key]) { const envVarNames: {[key: string]: string} = { - partnersToken: 'SHOPIFY_CLI_PARTNERS_TOKEN', - 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`) @@ -85,17 +73,14 @@ export function requireEnv( } /** - * Worker-scoped fixture providing auth tokens and environment configuration. - * Auth tokens are optional — tests that need them should call requireEnv(). + * Worker-scoped fixture providing environment configuration. + * Env vars are optional — tests that need them should call requireEnv(). */ export const envFixture = base.extend<{}, {env: E2EEnv}>({ env: [ // eslint-disable-next-line no-empty-pattern async ({}, use) => { - const partnersToken = process.env.SHOPIFY_CLI_PARTNERS_TOKEN ?? '' - 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') @@ -112,21 +97,12 @@ export const envFixture = base.extend<{}, {env: E2EEnv}>({ SHOPIFY_CLI_1P_DEV: undefined, } - if (partnersToken) { - processEnv.SHOPIFY_CLI_PARTNERS_TOKEN = partnersToken - } - if (clientId) { - processEnv.SHOPIFY_FLAG_CLIENT_ID = clientId - } if (storeFqdn) { processEnv.SHOPIFY_FLAG_STORE = storeFqdn } const env: E2EEnv = { - partnersToken, - clientId, storeFqdn, - secondaryClientId, orgId, processEnv, tempDir, diff --git a/packages/e2e/setup/toml-app.ts b/packages/e2e/setup/toml-app.ts deleted file mode 100644 index ddc7409eb32..00000000000 --- a/packages/e2e/setup/toml-app.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* eslint-disable no-restricted-imports */ -import {authFixture} from './auth.js' -import * as path from 'path' -import * as fs from 'fs' -import {fileURLToPath} from 'url' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -const FIXTURE_DIR = path.join(__dirname, '../data/valid-app') - -/** - * Test fixture that copies the full-toml fixture into a temp directory, - * injects the real client_id, and exposes the path to tests. - */ -export const tomlAppFixture = authFixture.extend<{tomlAppDir: string}>({ - tomlAppDir: async ({env, authLogin: _authLogin}, use) => { - const appDir = fs.mkdtempSync(path.join(env.tempDir, 'toml-app-')) - - // Copy fixture files - for (const file of fs.readdirSync(FIXTURE_DIR)) { - fs.copyFileSync(path.join(FIXTURE_DIR, file), path.join(appDir, file)) - } - - // Inject real client_id - const tomlPath = path.join(appDir, 'shopify.app.toml') - const toml = fs.readFileSync(tomlPath, 'utf8') - fs.writeFileSync(tomlPath, toml.replace('__E2E_CLIENT_ID__', env.clientId)) - - await use(appDir) - - fs.rmSync(appDir, {recursive: true, force: true}) - }, -}) diff --git a/packages/e2e/tests/dev-hot-reload.spec.ts b/packages/e2e/tests/dev-hot-reload.spec.ts index 13c731c14cd..33c7b6e4118 100644 --- a/packages/e2e/tests/dev-hot-reload.spec.ts +++ b/packages/e2e/tests/dev-hot-reload.spec.ts @@ -1,39 +1,14 @@ /* eslint-disable no-console */ /* eslint-disable no-restricted-imports */ -import {tomlAppFixture as test} from '../setup/toml-app.js' +import {appTestFixture as test, createApp, injectFixtureToml, teardownApp} 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' +import {fileURLToPath} from 'url' -/** - * Dev hot-reload tests. - * - * These exercise the file watcher and dev-session reload pipeline end-to-end: - * - Editing the app config TOML triggers a reload - * - Creating a new extension mid-dev is detected by the file watcher - * - Deleting an extension mid-dev is detected by the file watcher - * - * All tests start `shopify app dev` via PTY (with no custom extensions — just the - * built-in config extensions from the app TOML), wait for the initial "Ready" message, - * then mutate the filesystem and assert on the CLI's output messages. - * - * Key output strings we assert on (from dev-session-logger.ts / dev-session-status-manager.ts): - * - "Ready, watching for changes in your app" — initial ready - * - "Updated dev preview on " — successful dev session update - * - "App config updated" — app TOML change detected - * - "Extension created" — new extension detected by watcher - * - "Extension deleted" — extension removal detected by watcher - * - * For the app config edit test, we modify scopes in shopify.app.toml which triggers - * the reload pipeline through the config extensions (app_access, etc.) without needing - * any custom extensions — this matches the existing toml-config fixture exactly. - * - * For create/delete tests, we use flow_trigger extensions (build mode "none" — no - * compilation, no theme-check). The dev session API may reject these extensions with - * a validation error, but the watcher detection and app reload still happen and are - * what we assert on. The CLI stays alive after API errors. - */ +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const FIXTURE_TOML = fs.readFileSync(path.join(__dirname, '../data/valid-app/shopify.app.toml'), 'utf8') const READY_MESSAGE = 'Ready, watching for changes in your app' const UPDATED_MESSAGE = 'Updated dev preview' @@ -60,139 +35,149 @@ description = "E2E test trigger" } test.describe('Dev hot reload', () => { - test('editing app config TOML triggers reload', async ({cli, env, tomlAppDir}) => { - test.setTimeout(6 * 60 * 1000) - requireEnv(env, 'clientId', 'storeFqdn') + test('editing app config TOML triggers reload', async ({cli, env, browserPage}) => { + test.setTimeout(10 * 60 * 1000) + requireEnv(env, 'orgId', 'storeFqdn') - // Start dev with no custom extensions — just the app config - const proc = await cli.spawn(['app', 'dev', '--path', tomlAppDir, '--skip-dependencies-installation'], { - env: {CI: ''}, - }) + const parentDir = fs.mkdtempSync(path.join(env.tempDir, 'app-')) + const appName = `E2E-hot-reload-${Date.now()}` try { - await proc.waitForOutput(READY_MESSAGE, 3 * 60 * 1000) - - // Edit the app config TOML — change the scopes. This fires 'extensions_config_updated' - // on the app config path, triggering a full app reload. The app_access config extension - // will be detected as changed in the diff. - const tomlPath = path.join(tomlAppDir, 'shopify.app.toml') - const original = fs.readFileSync(tomlPath, 'utf8') - fs.writeFileSync( - tomlPath, - original.replace( - 'scopes = "read_products,write_products,read_orders"', - 'scopes = "read_products,write_products"', - ), - ) - - // The reload pipeline fires: file watcher → app reload → diff → dev session UPDATE. - // The logger emits "App config updated" for app config extension events. - await proc.waitForOutput('App config updated', 2 * 60 * 1000) - - // After the update completes, "Updated dev preview" is logged. - await proc.waitForOutput(UPDATED_MESSAGE, 2 * 60 * 1000) - - const output = proc.getOutput() - - // The scopes change was detected and the dev session was updated. - expect(output, 'Expected app config update in output').toContain('App config updated') - expect(output, 'Expected dev preview update in output').toContain(UPDATED_MESSAGE) - - // Clean exit - proc.sendKey('q') - const exitCode = await proc.waitForExit(30_000) - expect(exitCode, `dev exited with non-zero code. Output:\n${output}`).toBe(0) - } catch (error) { - console.error(`[hot-reload app-config] Captured PTY output:\n${proc.getOutput()}`) - throw error + const initResult = await createApp({cli, parentDir, name: appName, template: 'none', orgId: env.orgId}) + expect(initResult.exitCode, `createApp failed:\nstderr: ${initResult.stderr}`).toBe(0) + const appDir = initResult.appDir + + injectFixtureToml(appDir, FIXTURE_TOML, appName) + + const proc = await cli.spawn(['app', 'dev', '--path', appDir, '--skip-dependencies-installation'], { + env: {CI: ''}, + }) + + try { + await proc.waitForOutput(READY_MESSAGE, 3 * 60 * 1000) + + // Edit scopes in the TOML to trigger a reload + const tomlPath = path.join(appDir, 'shopify.app.toml') + const original = fs.readFileSync(tomlPath, 'utf8') + fs.writeFileSync( + tomlPath, + original.replace( + 'scopes = "read_products,write_products,read_orders"', + 'scopes = "read_products,write_products"', + ), + ) + + await proc.waitForOutput('App config updated', 2 * 60 * 1000) + await proc.waitForOutput(UPDATED_MESSAGE, 2 * 60 * 1000) + + const output = proc.getOutput() + expect(output, 'Expected app config update in output').toContain('App config updated') + expect(output, 'Expected dev preview update in output').toContain(UPDATED_MESSAGE) + + proc.sendKey('q') + const exitCode = await proc.waitForExit(30_000) + expect(exitCode, `dev exited with non-zero code. Output:\n${output}`).toBe(0) + } catch (error) { + console.error(`[hot-reload app-config] Captured PTY output:\n${proc.getOutput()}`) + throw error + } finally { + proc.kill() + } } finally { - proc.kill() + fs.rmSync(parentDir, {recursive: true, force: true}) + await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) } }) - test('creating a new extension mid-dev is detected', async ({cli, env, tomlAppDir}) => { - test.setTimeout(6 * 60 * 1000) - requireEnv(env, 'clientId', 'storeFqdn') + test('creating a new extension mid-dev is detected', async ({cli, env, browserPage}) => { + test.setTimeout(10 * 60 * 1000) + requireEnv(env, 'orgId', 'storeFqdn') - // Start dev with no custom extensions - const proc = await cli.spawn(['app', 'dev', '--path', tomlAppDir, '--skip-dependencies-installation'], { - env: {CI: ''}, - }) + const parentDir = fs.mkdtempSync(path.join(env.tempDir, 'app-')) + const appName = `E2E-hot-create-${Date.now()}` try { - await proc.waitForOutput(READY_MESSAGE, 3 * 60 * 1000) - - // Create a new extension on disk while dev is running. - // The file watcher sees the new shopify.extension.toml, waits for the - // .shopify.lock file to be absent, then fires 'extension_folder_created'. - // This triggers a full app reload. - writeFlowTriggerExtension(tomlAppDir, 'mid-dev-ext') - - // Wait for the watcher to detect the new extension. The reload-app handler - // diffs old vs new app and logs "Extension created" for new extensions. - // NOTE: The dev session API may reject the extension with a validation error, - // but the watcher detection and reload still happen — that's what we test here. - await proc.waitForOutput('Extension created', 2 * 60 * 1000) - - const output = proc.getOutput() - expect(output, 'Expected extension created event in output').toContain('Extension created') - - // The CLI should NOT crash after an API error — it stays alive for further changes. - // Verify this by checking the process is still running (sendKey would throw if dead). - proc.sendKey('q') - const exitCode = await proc.waitForExit(30_000) - expect(exitCode, `dev exited with non-zero code. Output:\n${output}`).toBe(0) - } catch (error) { - console.error(`[hot-reload create] Captured PTY output:\n${proc.getOutput()}`) - throw error + const initResult = await createApp({cli, parentDir, name: appName, template: 'none', orgId: env.orgId}) + expect(initResult.exitCode, `createApp failed:\nstderr: ${initResult.stderr}`).toBe(0) + const appDir = initResult.appDir + + injectFixtureToml(appDir, FIXTURE_TOML, appName) + + const proc = await cli.spawn(['app', 'dev', '--path', appDir, '--skip-dependencies-installation'], { + env: {CI: ''}, + }) + + try { + await proc.waitForOutput(READY_MESSAGE, 3 * 60 * 1000) + + writeFlowTriggerExtension(appDir, 'mid-dev-ext') + + await proc.waitForOutput('Extension created', 2 * 60 * 1000) + + const output = proc.getOutput() + expect(output, 'Expected extension created event in output').toContain('Extension created') + + proc.sendKey('q') + const exitCode = await proc.waitForExit(30_000) + expect(exitCode, `dev exited with non-zero code. Output:\n${output}`).toBe(0) + } catch (error) { + console.error(`[hot-reload create] Captured PTY output:\n${proc.getOutput()}`) + throw error + } finally { + proc.kill() + } } finally { - proc.kill() + fs.rmSync(parentDir, {recursive: true, force: true}) + await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) } }) - test('deleting an extension mid-dev is detected', async ({cli, env, tomlAppDir}) => { - test.setTimeout(6 * 60 * 1000) - requireEnv(env, 'clientId', 'storeFqdn') + test('deleting an extension mid-dev is detected', async ({cli, env, browserPage}) => { + test.setTimeout(10 * 60 * 1000) + requireEnv(env, 'orgId', 'storeFqdn') - // Start dev with no custom extensions - const proc = await cli.spawn(['app', 'dev', '--path', tomlAppDir, '--skip-dependencies-installation'], { - env: {CI: ''}, - }) + const parentDir = fs.mkdtempSync(path.join(env.tempDir, 'app-')) + const appName = `E2E-hot-delete-${Date.now()}` try { - await proc.waitForOutput(READY_MESSAGE, 3 * 60 * 1000) - - // First, create an extension mid-dev so we have something to delete. - // We know from the previous test that creation is detected even if the API - // rejects the extension. - writeFlowTriggerExtension(tomlAppDir, 'doomed-ext') - await proc.waitForOutput('Extension created', 2 * 60 * 1000) - - // Give the dev session time to settle (process the create event fully) - // before triggering the delete. The watcher debounce is 200ms. - await new Promise((resolve) => setTimeout(resolve, 5000)) - - // Delete the extension directory while dev is running. - // The file watcher detects the shopify.extension.toml unlink and fires - // 'extension_folder_deleted'. The handler removes the extension from the - // app and emits a Deleted event. - fs.rmSync(path.join(tomlAppDir, 'extensions', 'doomed-ext'), {recursive: true, force: true}) - - // Wait for the watcher to detect the deletion. - await proc.waitForOutput('Extension deleted', 2 * 60 * 1000) - - const output = proc.getOutput() - expect(output, 'Expected extension deleted event in output').toContain('Extension deleted') - - // The CLI should stay alive and exit cleanly after create+delete cycle. - proc.sendKey('q') - const exitCode = await proc.waitForExit(30_000) - expect(exitCode, `dev exited with non-zero code. Output:\n${output}`).toBe(0) - } catch (error) { - console.error(`[hot-reload delete] Captured PTY output:\n${proc.getOutput()}`) - throw error + const initResult = await createApp({cli, parentDir, name: appName, template: 'none', orgId: env.orgId}) + expect(initResult.exitCode, `createApp failed:\nstderr: ${initResult.stderr}`).toBe(0) + const appDir = initResult.appDir + + injectFixtureToml(appDir, FIXTURE_TOML, appName) + + const proc = await cli.spawn(['app', 'dev', '--path', appDir, '--skip-dependencies-installation'], { + env: {CI: ''}, + }) + + try { + await proc.waitForOutput(READY_MESSAGE, 3 * 60 * 1000) + + writeFlowTriggerExtension(appDir, 'doomed-ext') + await proc.waitForOutput('Extension created', 2 * 60 * 1000) + + // Wait for the dev session to settle before deleting + await new Promise((resolve) => setTimeout(resolve, 5000)) + + fs.rmSync(path.join(appDir, 'extensions', 'doomed-ext'), {recursive: true, force: true}) + + await proc.waitForOutput('Extension deleted', 2 * 60 * 1000) + + const output = proc.getOutput() + expect(output, 'Expected extension deleted event in output').toContain('Extension deleted') + + proc.sendKey('q') + const exitCode = await proc.waitForExit(30_000) + expect(exitCode, `dev exited with non-zero code. Output:\n${output}`).toBe(0) + } catch (error) { + console.error(`[hot-reload delete] Captured PTY output:\n${proc.getOutput()}`) + throw error + } finally { + proc.kill() + } } finally { - proc.kill() + fs.rmSync(parentDir, {recursive: true, force: true}) + await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) } }) }) diff --git a/packages/e2e/tests/multi-config-dev.spec.ts b/packages/e2e/tests/multi-config-dev.spec.ts index da593166d45..4bb00985f12 100644 --- a/packages/e2e/tests/multi-config-dev.spec.ts +++ b/packages/e2e/tests/multi-config-dev.spec.ts @@ -1,41 +1,36 @@ /* eslint-disable no-console */ /* eslint-disable no-restricted-imports */ -import {tomlAppFixture as test} from '../setup/toml-app.js' +import {appTestFixture as test, createApp, extractClientId, injectFixtureToml, teardownApp} 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' +import {fileURLToPath} from 'url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const FIXTURE_TOML = fs.readFileSync(path.join(__dirname, '../data/valid-app/shopify.app.toml'), 'utf8') -/** - * Multi-config dev tests. - * - * These verify the three-stage pipeline (Project → config selection → app loading) - * correctly selects and isolates configs when multiple shopify.app..toml files exist. - * - * The tomlAppFixture creates a temp directory with the valid-app fixture and injects the - * real client_id. We add a second config (shopify.app.staging.toml) that uses the same - * client_id but a different set of scopes and extension_directories, then verify that: - * 1. `shopify app dev -c staging` uses the staging config (filename in App info) - * 2. Without -c, the default shopify.app.toml is used - * - * NOTE: The `-c` / `--config` flag is exclusive with `--client-id` (and its env var - * SHOPIFY_FLAG_CLIENT_ID). When passing `-c`, we must clear SHOPIFY_FLAG_CLIENT_ID - * from the process environment so oclif doesn't reject the mutually exclusive flags. - * - * NOTE: The "App:" row in the App info tab shows the remote app title from Partners, - * not the local TOML `name` field. Since both configs use the same client_id, the - * remote title is identical. We assert on the Config: row (filename) instead. - */ test.describe('Multi-config dev', () => { - test('dev with -c flag loads the named config', async ({cli, env, tomlAppDir}) => { - test.setTimeout(6 * 60 * 1000) - requireEnv(env, 'clientId', 'storeFqdn') - - // Create a second config: shopify.app.staging.toml - // Uses the same client_id (required to hit the same Partners app) but different - // scopes and extension_directories so we can verify isolation. - const stagingToml = ` -client_id = "${env.clientId}" + test('dev with -c flag loads the named config', async ({cli, env, browserPage}) => { + test.setTimeout(10 * 60 * 1000) + requireEnv(env, 'orgId', 'storeFqdn') + + const parentDir = fs.mkdtempSync(path.join(env.tempDir, 'app-')) + const appName = `E2E-multi-cfg-${Date.now()}` + + try { + const initResult = await createApp({cli, parentDir, name: appName, template: 'none', orgId: env.orgId}) + expect(initResult.exitCode, `createApp failed:\nstderr: ${initResult.stderr}`).toBe(0) + const appDir = initResult.appDir + + // Inject the fully populated TOML as the default config + injectFixtureToml(appDir, FIXTURE_TOML, appName) + const clientId = extractClientId(appDir) + + // Create a second config: shopify.app.staging.toml + // Uses the same client_id but different scopes to verify isolation + const stagingToml = ` +client_id = "${clientId}" name = "E2E Staging Config" application_url = "https://example.com" embedded = true @@ -56,71 +51,58 @@ automatically_update_urls_on_dev = true include_config_on_deploy = true `.trimStart() - fs.writeFileSync(path.join(tomlAppDir, 'shopify.app.staging.toml'), stagingToml) + fs.writeFileSync(path.join(appDir, 'shopify.app.staging.toml'), stagingToml) + fs.mkdirSync(path.join(appDir, 'staging-ext'), {recursive: true}) + + // Start dev with -c staging. SHOPIFY_FLAG_CLIENT_ID must be unset + // because --config and --client-id are mutually exclusive. + const proc = await cli.spawn( + ['app', 'dev', '--path', appDir, '-c', 'staging', '--skip-dependencies-installation'], + {env: {CI: '', SHOPIFY_FLAG_CLIENT_ID: undefined} as NodeJS.ProcessEnv}, + ) + + try { + await proc.waitForOutput('Ready, watching for changes in your app', 3 * 60 * 1000) + + const output = proc.getOutput() + + expect(output, 'Expected staging config in info banner').toContain('Using shopify.app.staging.toml') + expect(output, 'Expected staging scopes in output').toContain('read_products') + expect(output, 'Should not contain write_products from default config').not.toContain('write_products') + + proc.sendKey('q') + const exitCode = await proc.waitForExit(30_000) + expect(exitCode, `dev exited with non-zero code. Output:\n${output}`).toBe(0) + } catch (error) { + console.error(`[multi-config dev] Captured PTY output:\n${proc.getOutput()}`) + throw error + } finally { + proc.kill() + } + } finally { + fs.rmSync(parentDir, {recursive: true, force: true}) + await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + } + }) - // Create the staging extension directory (empty — no extensions). - fs.mkdirSync(path.join(tomlAppDir, 'staging-ext'), {recursive: true}) + test('dev without -c flag uses default config', async ({cli, env, browserPage}) => { + test.setTimeout(10 * 60 * 1000) + requireEnv(env, 'orgId', 'storeFqdn') - // Start dev with the -c flag pointing to the staging config. - // IMPORTANT: --config and --client-id are mutually exclusive in appFlags, - // so we must remove SHOPIFY_FLAG_CLIENT_ID from the env when using -c. - // Setting to undefined (not '') causes the spawn helper to exclude it - // from the child process environment entirely. - const proc = await cli.spawn( - ['app', 'dev', '--path', tomlAppDir, '-c', 'staging', '--skip-dependencies-installation'], - { - env: {CI: '', SHOPIFY_FLAG_CLIENT_ID: undefined} as NodeJS.ProcessEnv, - }, - ) + const parentDir = fs.mkdtempSync(path.join(env.tempDir, 'app-')) + const appName = `E2E-mcfg-def-${Date.now()}` try { - // Wait for the dev session to become ready. This proves: - // 1. The CLI resolved shopify.app.staging.toml (not default) - // 2. Project.load + selectActiveConfig(project, 'staging') worked - // 3. The app loaded and created a dev session successfully - await proc.waitForOutput('Ready, watching for changes in your app', 3 * 60 * 1000) - - const output = proc.getOutput() - - // The info banner at startup confirms which config file is being used. - // It prints "Using shopify.app.staging.toml for default values:" - expect(output, 'Expected staging config in info banner').toContain('Using shopify.app.staging.toml') - - // The staging config has scopes = "read_products" (vs the default's - // "read_products,write_products,read_orders"). The access scopes line - // in the dev output confirms the correct config was loaded. - expect(output, 'Expected staging scopes in output').toContain('read_products') - expect(output, 'Should not contain write_products from default config').not.toContain('write_products') - - // Press 'a' to switch to the "App info" tab, which displays the config filename. - proc.sendKey('a') - await new Promise((resolve) => setTimeout(resolve, 2000)) - - const outputAfterTab = proc.getOutput() - - // Verify the staging config filename appears in the App info tab. - // The UI renders `Config: shopify.app.staging.toml` (just the basename). - expect(outputAfterTab, 'Expected staging config filename in App info tab').toContain('shopify.app.staging.toml') - - // Clean exit - proc.sendKey('q') - const exitCode = await proc.waitForExit(30_000) - expect(exitCode, `dev exited with non-zero code. Output:\n${outputAfterTab}`).toBe(0) - } catch (error) { - console.error(`[multi-config dev] Captured PTY output:\n${proc.getOutput()}`) - throw error - } finally { - proc.kill() - } - }) + const initResult = await createApp({cli, parentDir, name: appName, template: 'none', orgId: env.orgId}) + expect(initResult.exitCode, `createApp failed:\nstderr: ${initResult.stderr}`).toBe(0) + const appDir = initResult.appDir - test('dev without -c flag uses default config', async ({cli, env, tomlAppDir}) => { - test.setTimeout(6 * 60 * 1000) - requireEnv(env, 'clientId', 'storeFqdn') + injectFixtureToml(appDir, FIXTURE_TOML, appName) + const clientId = extractClientId(appDir) - // Add a staging config so multiple configs exist - const stagingToml = ` -client_id = "${env.clientId}" + // Add a staging config so multiple configs exist + const stagingToml = ` +client_id = "${clientId}" name = "E2E Staging Config" application_url = "https://example.com" embedded = true @@ -135,33 +117,33 @@ redirect_urls = ["https://example.com/auth/callback"] api_version = "2025-01" `.trimStart() - fs.writeFileSync(path.join(tomlAppDir, 'shopify.app.staging.toml'), stagingToml) - - // Start dev without -c flag — should use shopify.app.toml - const proc = await cli.spawn(['app', 'dev', '--path', tomlAppDir, '--skip-dependencies-installation'], { - env: {CI: ''}, - }) + fs.writeFileSync(path.join(appDir, 'shopify.app.staging.toml'), stagingToml) - try { - await proc.waitForOutput('Ready, watching for changes in your app', 3 * 60 * 1000) + // Start dev without -c flag — should use shopify.app.toml + const proc = await cli.spawn(['app', 'dev', '--path', appDir, '--skip-dependencies-installation'], { + env: {CI: ''}, + }) - const output = proc.getOutput() + try { + await proc.waitForOutput('Ready, watching for changes in your app', 3 * 60 * 1000) - // The info banner should reference the default config. - // Match the full phrase to avoid a false match against "shopify.app.staging.toml". - expect(output, 'Expected default config in info banner').toContain('Using shopify.app.toml for default values') + const output = proc.getOutput() - // The default config has the broader scopes including write_products - expect(output, 'Expected default scopes (write_products) in output').toContain('write_products') + expect(output, 'Expected default config in info banner').toContain('Using shopify.app.toml for default values') + expect(output, 'Expected default scopes (write_products) in output').toContain('write_products') - proc.sendKey('q') - const exitCode = await proc.waitForExit(30_000) - expect(exitCode, `dev exited with non-zero code. Output:\n${output}`).toBe(0) - } catch (error) { - console.error(`[multi-config default] Captured PTY output:\n${proc.getOutput()}`) - throw error + proc.sendKey('q') + const exitCode = await proc.waitForExit(30_000) + expect(exitCode, `dev exited with non-zero code. Output:\n${output}`).toBe(0) + } catch (error) { + console.error(`[multi-config default] Captured PTY output:\n${proc.getOutput()}`) + throw error + } finally { + proc.kill() + } } finally { - proc.kill() + fs.rmSync(parentDir, {recursive: true, force: true}) + await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) } }) }) diff --git a/packages/e2e/tests/toml-config-invalid.spec.ts b/packages/e2e/tests/toml-config-invalid.spec.ts index f1fdf080c4e..df455be7cc2 100644 --- a/packages/e2e/tests/toml-config-invalid.spec.ts +++ b/packages/e2e/tests/toml-config-invalid.spec.ts @@ -1,7 +1,6 @@ /* eslint-disable no-console */ /* eslint-disable no-restricted-imports */ -import {authFixture as test} from '../setup/auth.js' -import {requireEnv} from '../setup/env.js' +import {appTestFixture as test} from '../setup/app.js' import {expect} from '@playwright/test' import * as path from 'path' import * as fs from 'fs' @@ -17,14 +16,11 @@ test.describe('TOML config invalid', () => { const label = tomlFile.replace('.toml', '') test(`deploy rejects invalid toml: ${label}`, async ({cli, env}) => { - requireEnv(env, 'clientId') - - // Set up temp dir with invalid toml + minimal package.json + // Invalid TOMLs use a dummy client_id — the CLI rejects them at validation + // before any network request, so no real app is needed. const appDir = fs.mkdtempSync(path.join(env.tempDir, `invalid-toml-${label}-`)) try { - const toml = fs - .readFileSync(path.join(INVALID_TOMLS_DIR, tomlFile), 'utf8') - .replace('__E2E_CLIENT_ID__', env.clientId) + const toml = fs.readFileSync(path.join(INVALID_TOMLS_DIR, tomlFile), 'utf8') fs.writeFileSync(path.join(appDir, 'shopify.app.toml'), toml) fs.writeFileSync( path.join(appDir, 'package.json'), diff --git a/packages/e2e/tests/toml-config.spec.ts b/packages/e2e/tests/toml-config.spec.ts index 58b5429b23c..7d478aa9d0b 100644 --- a/packages/e2e/tests/toml-config.spec.ts +++ b/packages/e2e/tests/toml-config.spec.ts @@ -1,39 +1,73 @@ /* eslint-disable no-console */ -import {tomlAppFixture as test} from '../setup/toml-app.js' +/* eslint-disable no-restricted-imports */ +import {appTestFixture as test, createApp, injectFixtureToml, teardownApp} 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' +import {fileURLToPath} from 'url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const FIXTURE_TOML = fs.readFileSync(path.join(__dirname, '../data/valid-app/shopify.app.toml'), 'utf8') test.describe('TOML config regression', () => { - test('deploy succeeds with fully populated toml', async ({cli, env, tomlAppDir}) => { - requireEnv(env, 'clientId') - - const result = await cli.exec(['app', 'deploy', '--path', tomlAppDir, '--force'], { - timeout: 5 * 60 * 1000, - }) - const output = result.stdout + result.stderr - expect(result.exitCode, `deploy failed:\n${output}`).toBe(0) + test('deploy succeeds with fully populated toml', async ({cli, env, browserPage}) => { + test.setTimeout(10 * 60 * 1000) + requireEnv(env, 'orgId') + + const parentDir = fs.mkdtempSync(path.join(env.tempDir, 'app-')) + const appName = `E2E-toml-deploy-${Date.now()}` + + try { + const initResult = await createApp({cli, parentDir, name: appName, template: 'none', orgId: env.orgId}) + expect(initResult.exitCode, `createApp failed:\nstderr: ${initResult.stderr}`).toBe(0) + const appDir = initResult.appDir + + // Overwrite with fully populated TOML fixture (injects the real client_id) + injectFixtureToml(appDir, FIXTURE_TOML, appName) + + const result = await cli.exec(['app', 'deploy', '--path', appDir, '--force'], { + timeout: 5 * 60 * 1000, + }) + const output = result.stdout + result.stderr + expect(result.exitCode, `deploy failed:\n${output}`).toBe(0) + } finally { + fs.rmSync(parentDir, {recursive: true, force: true}) + await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + } }) - test('dev starts with fully populated toml', async ({cli, env, tomlAppDir}) => { - test.setTimeout(6 * 60 * 1000) - requireEnv(env, 'clientId', 'storeFqdn') + test('dev starts with fully populated toml', async ({cli, env, browserPage}) => { + test.setTimeout(10 * 60 * 1000) + requireEnv(env, 'orgId', 'storeFqdn') - const proc = await cli.spawn(['app', 'dev', '--path', tomlAppDir], { - env: {CI: ''}, - }) + const parentDir = fs.mkdtempSync(path.join(env.tempDir, 'app-')) + const appName = `E2E-toml-dev-${Date.now()}` try { - await proc.waitForOutput('Ready, watching for changes in your app', 3 * 60 * 1000) - - proc.sendKey('q') - const exitCode = await proc.waitForExit(30_000) - expect(exitCode, `dev exited with non-zero code. Output:\n${proc.getOutput()}`).toBe(0) - } catch (error) { - const captured = proc.getOutput() - console.error(`[toml-config dev] Captured PTY output:\n${captured}`) - throw error + const initResult = await createApp({cli, parentDir, name: appName, template: 'none', orgId: env.orgId}) + expect(initResult.exitCode, `createApp failed:\nstderr: ${initResult.stderr}`).toBe(0) + const appDir = initResult.appDir + + injectFixtureToml(appDir, FIXTURE_TOML, appName) + + const proc = await cli.spawn(['app', 'dev', '--path', appDir], {env: {CI: ''}}) + + try { + await proc.waitForOutput('Ready, watching for changes in your app', 3 * 60 * 1000) + + proc.sendKey('q') + const exitCode = await proc.waitForExit(30_000) + expect(exitCode, `dev exited with non-zero code. Output:\n${proc.getOutput()}`).toBe(0) + } catch (error) { + console.error(`[toml-config dev] Captured PTY output:\n${proc.getOutput()}`) + throw error + } finally { + proc.kill() + } } finally { - proc.kill() + fs.rmSync(parentDir, {recursive: true, force: true}) + await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) } }) })