diff --git a/.github/workflows/tests-pr.yml b/.github/workflows/tests-pr.yml index 931f155bd1c..f4afe424ad9 100644 --- a/.github/workflows/tests-pr.yml +++ b/.github/workflows/tests-pr.yml @@ -246,6 +246,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/package.json b/packages/e2e/package.json index 8b9d5de63e8..12f14cbf09c 100644 --- a/packages/e2e/package.json +++ b/packages/e2e/package.json @@ -31,6 +31,7 @@ "devDependencies": { "@playwright/test": "^1.50.0", "@types/node": "18.19.70", + "dotenv": "16.4.7", "execa": "^7.2.0", "node-pty": "^1.0.0", "strip-ansi": "^7.1.0", diff --git a/packages/e2e/playwright.config.ts b/packages/e2e/playwright.config.ts index f2260472c55..04d15dd66f6 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 {config} from 'dotenv' 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 - } -} +config() const isCI = Boolean(process.env.CI) diff --git a/packages/e2e/setup/app.ts b/packages/e2e/setup/app.ts index dbd3da4b995..1b9e84c0ffd 100644 --- a/packages/e2e/setup/app.ts +++ b/packages/e2e/setup/app.ts @@ -1,36 +1,121 @@ -/* eslint-disable no-restricted-imports */ +/* eslint-disable no-restricted-imports, no-await-in-loop */ import {authFixture} from './auth.js' +import {navigateToDashboard} from './browser.js' import * as path from 'path' import * as fs from 'fs' -import type {ExecResult} from './cli.js' - -export interface AppScaffold { - /** The directory where the app was created */ - appDir: string - /** Create a new app from a template */ - init(opts: AppInitOptions): Promise - /** Generate an extension in the app */ - generateExtension(opts: ExtensionOptions): Promise - /** Build the app */ - build(): Promise - /** Get app info as JSON */ - appInfo(): Promise -} +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() +// --------------------------------------------------------------------------- -export interface AppInitOptions { +export async function createApp(ctx: { + cli: CLIProcess + parentDir: string name?: string template?: 'reactRouter' | 'remix' | 'none' flavor?: 'javascript' | 'typescript' packageManager?: 'npm' | 'yarn' | 'pnpm' | 'bun' + orgId?: string +}): Promise { + const {cli, parentDir} = ctx + const name = ctx.name ?? 'e2e-test-app' + const template = ctx.template ?? 'reactRouter' + const packageManager = ctx.packageManager ?? 'npm' + + const args = [ + '--name', + name, + '--path', + parentDir, + '--package-manager', + packageManager, + '--local', + '--template', + template, + ] + if (ctx.orgId) args.push('--organization-id', ctx.orgId) + 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}, + timeout: 5 * 60 * 1000, + }) + + let appDir = '' + if (result.exitCode === 0) { + const allOutput = `${result.stdout}\n${result.stderr}` + const match = allOutput.match(/([\w-]+) is ready for you to build!/) + + if (match?.[1]) { + appDir = path.join(parentDir, match[1]) + } else { + const entries = fs.readdirSync(parentDir, {withFileTypes: true}) + const appEntry = entries.find( + (entry) => entry.isDirectory() && fs.existsSync(path.join(parentDir, entry.name, 'shopify.app.toml')), + ) + if (appEntry) { + appDir = path.join(parentDir, appEntry.name) + } else { + throw new Error( + `Could not find created app directory in ${parentDir}.\n` + + `Exit code: ${result.exitCode}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`, + ) + } + } + + // Ensure npm doesn't enforce frozen lockfile + const npmrcPath = path.join(appDir, '.npmrc') + if (!fs.existsSync(npmrcPath)) fs.writeFileSync(npmrcPath, '') + fs.appendFileSync(npmrcPath, 'frozen-lockfile=false\n') + } + + return {...result, appDir} +} + +export async function generateExtension( + ctx: CLIContext & { + name: string + template: string + flavor?: string + }, +): 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}) } -export interface ExtensionOptions { - name: string - template: string - flavor?: string +export async function buildApp(ctx: CLIContext): Promise { + return ctx.cli.exec(['app', 'build', '--path', ctx.appDir], {env: FRESH_APP_ENV, timeout: 5 * 60 * 1000}) } -export interface AppInfoResult { +export async function deployApp( + ctx: CLIContext & { + version?: string + message?: string + config?: string + force?: boolean + noBuild?: boolean + }, +): Promise { + const args = ['app', 'deploy', '--path', ctx.appDir] + if (ctx.force ?? true) args.push('--force') + if (ctx.noBuild) args.push('--no-build') + 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}) +} + +export async function appInfo(ctx: CLIContext): Promise<{ packageManager: string allExtensions: { configuration: {name: string; type: string; handle?: string} @@ -38,100 +123,266 @@ export interface AppInfoResult { outputPath: string entrySourceFilePath: string }[] +}> { + const result = await ctx.cli.exec(['app', 'info', '--path', ctx.appDir, '--json'], {env: FRESH_APP_ENV}) + if (result.exitCode !== 0) { + throw new Error(`app info failed (exit ${result.exitCode}):\nstdout: ${result.stdout}\nstderr: ${result.stderr}`) + } + return JSON.parse(result.stdout) } -/** - * 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]) - } 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 { - throw new Error( - `Could not find created app directory in ${appTmpDir}.\n` + - `Exit code: ${result.exitCode}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`, - ) - } - } +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}) +} + +export async function functionRun( + ctx: CLIContext & { + inputPath: string + }, +): 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, + }) +} + +export async function configLink( + ctx: CLIContext & { + clientId: string + }, +): Promise { + return ctx.cli.exec(['app', 'config', 'link', '--path', ctx.appDir, '--client-id', ctx.clientId], { + env: FRESH_APP_ENV, + timeout: 2 * 60 * 1000, + }) +} + +// --------------------------------------------------------------------------- +// Browser helpers — app-specific dashboard automation +// --------------------------------------------------------------------------- + +/** Find apps matching a name pattern on the dashboard. Call navigateToDashboard first. */ +export async function findAppsOnDashboard( + ctx: BrowserContext & { + namePattern: string + }, +): Promise<{name: string; url: string}[]> { + const appCards = await ctx.browserPage.locator('a[href*="/apps/"]').all() + const apps: {name: string; url: string}[] = [] - 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) - }, + for (const card of appCards) { + const href = await card.getAttribute('href') + const text = await card.textContent() + if (!href || !text || !href.match(/\/apps\/\d+/)) continue + + const name = text.split(/\d+\s+install/i)[0]?.trim() ?? text.split('\n')[0]?.trim() ?? text.trim() + if (!name || name.length > 200) continue + if (!name.includes(ctx.namePattern)) continue + + const url = href.startsWith('http') ? href : `https://dev.shopify.com${href}` + apps.push({name, url}) + } + + return apps +} + +/** Uninstall an app from all stores it's installed on. Returns true if fully uninstalled. */ +export async function uninstallApp( + ctx: BrowserContext & { + appUrl: string + appName: string + orgId?: string + }, +): Promise { + const {browserPage, appUrl, appName} = ctx + const orgId = ctx.orgId ?? (process.env.E2E_ORG_ID ?? '').trim() + + await browserPage.goto(`${appUrl}/installs`, {waitUntil: 'domcontentloaded'}) + await browserPage.waitForTimeout(3000) + + const rows = await browserPage.locator('table tbody tr').all() + const storeNames: string[] = [] + for (const row of rows) { + const firstCell = row.locator('td').first() + const text = (await firstCell.textContent())?.trim() + if (text && !text.toLowerCase().includes('no installed')) storeNames.push(text) + } + + if (storeNames.length === 0) return true + + let allUninstalled = true + for (const storeName of storeNames) { + try { + // Navigate to store admin via the dev dashboard dropdown + const dashboardUrl = orgId + ? `https://dev.shopify.com/dashboard/${orgId}/apps` + : 'https://dev.shopify.com/dashboard' + let navigated = false + for (let attempt = 1; attempt <= 3; attempt++) { + await browserPage.goto(dashboardUrl, {waitUntil: 'domcontentloaded'}) + await browserPage.waitForTimeout(3000) + + const pageText = (await browserPage.textContent('body')) ?? '' + if (pageText.includes('500') || pageText.includes('Internal Server Error')) continue + + const orgButton = browserPage.locator('header button').last() + if (!(await orgButton.isVisible({timeout: 5000}).catch(() => false))) continue + await orgButton.click() + await browserPage.waitForTimeout(1000) + + const storeLink = browserPage.locator('a, button').filter({hasText: storeName}).first() + if (!(await storeLink.isVisible({timeout: 5000}).catch(() => false))) continue + await storeLink.click() + await browserPage.waitForTimeout(3000) + navigated = true + break + } + + if (!navigated) { + allUninstalled = false + continue + } + + // Navigate to store's apps settings page + const storeAdminUrl = browserPage.url() + await browserPage.goto(`${storeAdminUrl.replace(/\/$/, '')}/settings/apps`, {waitUntil: 'domcontentloaded'}) + await browserPage.waitForTimeout(5000) + + // Dismiss any Dev Console dialog + const cancelButton = browserPage.locator('button:has-text("Cancel")') + if (await cancelButton.isVisible({timeout: 2000}).catch(() => false)) { + await cancelButton.click() + await browserPage.waitForTimeout(1000) + } + + // Find the app in the installed list (plain span, not Dev Console's Polaris text) + const appSpan = browserPage.locator(`span:has-text("${appName}"):not([class*="Polaris"])`).first() + if (!(await appSpan.isVisible({timeout: 5000}).catch(() => false))) { + allUninstalled = false + continue + } + + // Click the ⋯ menu button next to the app name + const menuButton = appSpan.locator('xpath=./following::button[1]') + await menuButton.click() + await browserPage.waitForTimeout(1000) + + // Click "Uninstall" in the dropdown menu + const uninstallOption = browserPage.locator('text=Uninstall').last() + if (!(await uninstallOption.isVisible({timeout: 3000}).catch(() => false))) { + allUninstalled = false + continue + } + await uninstallOption.click() + await browserPage.waitForTimeout(2000) + + // Handle confirmation dialog + const confirmButton = browserPage.locator('button:has-text("Uninstall"), button:has-text("Confirm")').last() + if (await confirmButton.isVisible({timeout: 3000}).catch(() => false)) { + await confirmButton.click() + await browserPage.waitForTimeout(3000) + } + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (_err) { + allUninstalled = false } + } - await use(scaffold) - fs.rmSync(appTmpDir, {recursive: true, force: true}) + return allUninstalled +} + +/** Delete an app from the partner dashboard. Should be uninstalled first. */ +export async function deleteApp( + ctx: BrowserContext & { + appUrl: string + }, +): Promise { + const {browserPage, appUrl} = ctx + + await browserPage.goto(`${appUrl}/settings`, {waitUntil: 'domcontentloaded'}) + await browserPage.waitForTimeout(3000) + + // Retry if delete button is disabled (uninstall propagation delay) + const deleteButton = browserPage.locator('button:has-text("Delete app")').first() + for (let attempt = 1; attempt <= 5; attempt++) { + await deleteButton.scrollIntoViewIfNeeded() + const isDisabled = await deleteButton.getAttribute('disabled') + if (!isDisabled) break + await browserPage.waitForTimeout(5000) + await browserPage.reload({waitUntil: 'domcontentloaded'}) + await browserPage.waitForTimeout(3000) + } + + await deleteButton.click({timeout: 10_000}) + await browserPage.waitForTimeout(2000) + + // Handle confirmation dialog — may need to type "DELETE" + const confirmInput = browserPage.locator('input[type="text"]').last() + if (await confirmInput.isVisible({timeout: 3000}).catch(() => false)) { + await confirmInput.fill('DELETE') + await browserPage.waitForTimeout(500) + } + + const confirmButton = browserPage.locator('button:has-text("Delete app")').last() + await confirmButton.click() + await browserPage.waitForTimeout(3000) +} + +/** Best-effort teardown: find app on dashboard by name, uninstall from all stores, delete. */ +export async function teardownApp( + ctx: BrowserContext & { + appName: string + email?: string + orgId?: string }, +): Promise { + try { + await navigateToDashboard(ctx) + const apps = await findAppsOnDashboard({browserPage: ctx.browserPage, namePattern: ctx.appName}) + for (const app of apps) { + try { + await uninstallApp({browserPage: ctx.browserPage, appUrl: app.url, appName: app.name, orgId: ctx.orgId}) + await deleteApp({browserPage: ctx.browserPage, appUrl: app.url}) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (err) { + // Best-effort per app — continue teardown of remaining apps + if (process.env.DEBUG === '1') { + const msg = err instanceof Error ? err.message : String(err) + process.stderr.write(`[e2e] Teardown failed for app ${app.name}: ${msg}\n`) + } + } + } + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (err) { + // Best-effort — don't fail the test if teardown fails + if (process.env.DEBUG === '1') { + const msg = err instanceof Error ? err.message : String(err) + process.stderr.write(`[e2e] Teardown failed for ${ctx.appName}: ${msg}\n`) + } + } +} + +// --------------------------------------------------------------------------- +// Fixture — ensures auth runs before tests. Tests use helper functions directly. +// --------------------------------------------------------------------------- + +export const appTestFixture = authFixture.extend<{authReady: void}>({ + // Auto-trigger authLogin and strip CLIENT_ID so tests create their own apps + authReady: [ + async ( + {authLogin: _authLogin, env}: {authLogin: void; env: import('./env.js').E2EEnv}, + use: () => Promise, + ) => { + delete env.processEnv.SHOPIFY_FLAG_CLIENT_ID + await use() + }, + {auto: true}, + ], }) diff --git a/packages/e2e/setup/auth.ts b/packages/e2e/setup/auth.ts index 3bb1e9cf0ca..e7b1fdee1e2 100644 --- a/packages/e2e/setup/auth.ts +++ b/packages/e2e/setup/auth.ts @@ -1,18 +1,22 @@ -import {cliFixture} from './cli.js' +import {browserFixture} from './browser.js' import {executables} from './env.js' import {stripAnsi} from '../helpers/strip-ansi.js' import {waitForText} from '../helpers/wait-for-text.js' import {completeLogin} from '../helpers/browser-login.js' -import {chromium, type Browser} from '@playwright/test' import {execa} from 'execa' /** - * Worker-scoped fixture that performs OAuth login via browser automation. - * Runs once per worker, stores the session in shared XDG dirs. + * Worker-scoped fixture that performs OAuth login using the shared browser page. + * + * Extends browserFixture — the browser is already running when auth starts. + * After login, the CLI session is stored in XDG dirs and the browser page + * remains available for other browser-based actions (dashboard navigation, etc.). + * + * Fixture chain: envFixture → cliFixture → browserFixture → authFixture */ -export const authFixture = cliFixture.extend<{}, {authLogin: void}>({ +export const authFixture = browserFixture.extend<{}, {authLogin: void}>({ authLogin: [ - async ({env}, use) => { + async ({env, browserPage}, use) => { const email = process.env.E2E_ACCOUNT_EMAIL const password = process.env.E2E_ACCOUNT_PASSWORD @@ -21,6 +25,8 @@ export const authFixture = cliFixture.extend<{}, {authLogin: void}>({ return } + process.stdout.write('[e2e] Authenticating automatically — no action required.\n') + // Clear any existing session await execa('node', [executables.cli, 'auth', 'logout'], { env: env.processEnv, @@ -34,8 +40,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'], { @@ -59,19 +64,7 @@ export const authFixture = cliFixture.extend<{}, {authLogin: void}>({ throw new Error(`Could not find login URL in output:\n${stripped}`) } - let browser: Browser | undefined - try { - browser = await chromium.launch({headless: !process.env.E2E_HEADED}) - const context = await browser.newContext({ - extraHTTPHeaders: { - 'X-Shopify-Loadtest-Bf8d22e7-120e-4b5b-906c-39ca9d5499a9': 'true', - }, - }) - const page = await context.newPage() - await completeLogin(page, urlMatch[0], email, password) - } finally { - await browser?.close() - } + await completeLogin(browserPage, urlMatch[0], email, password) await waitForText(() => output, 'Logged in', 60_000) try { @@ -81,10 +74,6 @@ 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) - delete env.processEnv.SHOPIFY_CLI_PARTNERS_TOKEN - await use() }, {scope: 'worker'}, diff --git a/packages/e2e/setup/browser.ts b/packages/e2e/setup/browser.ts new file mode 100644 index 00000000000..d4dd9fa3017 --- /dev/null +++ b/packages/e2e/setup/browser.ts @@ -0,0 +1,77 @@ +import {cliFixture} from './cli.js' +import {chromium, type Page} from '@playwright/test' + +// --------------------------------------------------------------------------- +// Shared browser context type +// --------------------------------------------------------------------------- + +export interface BrowserContext { + browserPage: Page +} + +// --------------------------------------------------------------------------- +// Fixture +// --------------------------------------------------------------------------- + +/** + * Worker-scoped fixture providing a persistent Playwright browser page. + * + * The browser launches once per worker and stays open for the entire run. + * Downstream fixtures (auth) and tests use `browserPage` for any browser-based + * actions: OAuth login, dashboard navigation, app uninstall/deletion, etc. + * + * Fixture chain: envFixture → cliFixture → browserFixture + */ +export const browserFixture = cliFixture.extend<{}, {browserPage: Page}>({ + browserPage: [ + // eslint-disable-next-line no-empty-pattern + async ({}, use) => { + const browser = await chromium.launch({headless: !process.env.E2E_HEADED}) + const context = await browser.newContext({ + extraHTTPHeaders: { + 'X-Shopify-Loadtest-Bf8d22e7-120e-4b5b-906c-39ca9d5499a9': 'true', + }, + }) + context.setDefaultTimeout(60_000) + context.setDefaultNavigationTimeout(60_000) + const page = await context.newPage() + await use(page) + await browser.close() + }, + {scope: 'worker'}, + ], +}) + +// --------------------------------------------------------------------------- +// Browser helpers — generic dashboard navigation +// --------------------------------------------------------------------------- +/** Navigate to the dev dashboard for the configured org. */ +export async function navigateToDashboard( + ctx: BrowserContext & { + email?: string + orgId?: string + }, +): Promise { + const {browserPage} = ctx + const orgId = ctx.orgId ?? (process.env.E2E_ORG_ID ?? '').trim() + const dashboardUrl = orgId ? `https://dev.shopify.com/dashboard/${orgId}/apps` : 'https://dev.shopify.com/dashboard' + await browserPage.goto(dashboardUrl, {waitUntil: 'domcontentloaded'}) + await browserPage.waitForTimeout(3000) + + // Handle account picker (skip if email not provided) + if (ctx.email) { + const accountButton = browserPage.locator(`text=${ctx.email}`).first() + if (await accountButton.isVisible({timeout: 5000}).catch(() => false)) { + await accountButton.click() + await browserPage.waitForTimeout(3000) + } + } + + // Retry on 500 errors + for (let attempt = 1; attempt <= 3; attempt++) { + const pageText = (await browserPage.textContent('body')) ?? '' // eslint-disable-line no-await-in-loop + if (!pageText.includes('500') && !pageText.includes('Internal Server Error')) break + await browserPage.waitForTimeout(3000) // eslint-disable-line no-await-in-loop + await browserPage.reload({waitUntil: 'domcontentloaded'}) // eslint-disable-line no-await-in-loop + } +} diff --git a/packages/e2e/setup/cli.ts b/packages/e2e/setup/cli.ts index 1f39f38ca68..85f1d5f05c7 100644 --- a/packages/e2e/setup/cli.ts +++ b/packages/e2e/setup/cli.ts @@ -239,4 +239,9 @@ export const cliFixture = envFixture.extend<{cli: CLIProcess}>({ }, }) +export interface CLIContext { + cli: CLIProcess + appDir: string +} + export {type E2EEnv} diff --git a/packages/e2e/setup/env.ts b/packages/e2e/setup/env.ts index ea22c69ceb6..f119757ba1f 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 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-deploy.spec.ts b/packages/e2e/tests/app-deploy.spec.ts index e2d9953da60..4e85bd524be 100644 --- a/packages/e2e/tests/app-deploy.spec.ts +++ b/packages/e2e/tests/app-deploy.spec.ts @@ -1,46 +1,46 @@ -import {appScaffoldFixture as test} from '../setup/app.js' +import {appTestFixture as test, createApp, deployApp, versionsList, 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' // eslint-disable-line no-restricted-imports test.describe('App deploy', () => { - test('deploy and verify version exists', async ({appScaffold, cli, env}) => { - requireEnv(env, 'clientId') + test('deploy and verify version exists', async ({cli, env, browserPage}) => { + test.setTimeout(10 * 60 * 1000) + requireEnv(env, 'orgId') - // Step 1: Create an extension-only app (no scopes needed for deploy) - const initResult = await appScaffold.init({ - template: 'none', - packageManager: 'npm', - }) - expect(initResult.exitCode).toBe(0) + const parentDir = fs.mkdtempSync(path.join(env.tempDir, 'app-')) + const appName = `E2E-deploy-${Date.now()}` - // Step 2: Deploy with a tagged version - const versionTag = `e2e-v-${Date.now()}` - const deployResult = await cli.exec( - [ - 'app', - 'deploy', - '--path', - appScaffold.appDir, - '--force', - '--version', - versionTag, - '--message', - 'E2E test deployment', - ], - {timeout: 5 * 60 * 1000}, - ) - const deployOutput = deployResult.stdout + deployResult.stderr - expect(deployResult.exitCode, `deploy failed:\n${deployOutput}`).toBe(0) + try { + // Step 1: Create an extension-only app (no scopes needed for deploy) + const initResult = await createApp({ + cli, + parentDir, + name: appName, + template: 'none', + packageManager: 'npm', + orgId: env.orgId, + }) + expect(initResult.exitCode, `createApp failed:\nstdout: ${initResult.stdout}\nstderr: ${initResult.stderr}`).toBe( + 0, + ) + const appDir = initResult.appDir - // Step 3: Verify the version exists via versions 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, `versions list failed:\n${listOutput}`).toBe(0) + // Step 2: Deploy with a tagged version + const versionTag = `e2e-v-${Date.now()}` + const deployResult = await deployApp({cli, appDir, version: versionTag, message: 'E2E test deployment'}) + const deployOutput = deployResult.stdout + deployResult.stderr + expect(deployResult.exitCode, `deploy failed:\n${deployOutput}`).toBe(0) - // Check that our version tag appears in the output - const allOutput = listResult.stdout + listResult.stderr - expect(allOutput).toContain(versionTag) + // Step 3: Verify the version exists via versions list + const listResult = await versionsList({cli, appDir}) + const listOutput = listResult.stdout + listResult.stderr + expect(listResult.exitCode, `versions list failed:\n${listOutput}`).toBe(0) + expect(listOutput).toContain(versionTag) + } finally { + 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/app-dev-server.spec.ts b/packages/e2e/tests/app-dev-server.spec.ts index 38832a30dea..358553b458d 100644 --- a/packages/e2e/tests/app-dev-server.spec.ts +++ b/packages/e2e/tests/app-dev-server.spec.ts @@ -1,34 +1,50 @@ -import {appScaffoldFixture as test} from '../setup/app.js' +import {appTestFixture as test, createApp, 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' // eslint-disable-line no-restricted-imports test.describe('App dev server', () => { - test('dev starts, shows ready message, and quits with q', async ({appScaffold, cli, env}) => { - requireEnv(env, 'clientId', 'storeFqdn') + test('dev starts, shows ready message, and quits with q', async ({cli, env, browserPage}) => { + test.setTimeout(10 * 60 * 1000) + requireEnv(env, 'orgId', 'storeFqdn') - // Step 1: Create an extension-only app (no scopes needed) - const initResult = await appScaffold.init({ - template: 'none', - packageManager: 'npm', - }) - expect(initResult.exitCode).toBe(0) + const parentDir = fs.mkdtempSync(path.join(env.tempDir, 'app-')) + const appName = `E2E-dev-${Date.now()}` - // Step 2: Start dev server via PTY - // Unset CI so keyboard shortcuts are enabled in the Dev UI - const dev = await cli.spawn(['app', 'dev', '--path', appScaffold.appDir], {env: {CI: ''}}) + try { + // Step 1: Create an extension-only app (no scopes needed) + const initResult = await createApp({ + cli, + parentDir, + name: appName, + template: 'none', + packageManager: 'npm', + orgId: env.orgId, + }) + expect(initResult.exitCode).toBe(0) + const appDir = initResult.appDir - // Step 3: Wait for the ready message - await dev.waitForOutput('Ready, watching for changes in your app', 3 * 60 * 1000) + // Step 2: Start dev server via PTY + // Unset CI so keyboard shortcuts are enabled in the Dev UI + const dev = await cli.spawn(['app', 'dev', '--path', appDir], {env: {CI: ''}}) - // Step 4: Verify keyboard shortcuts are shown (indicates TTY mode is working) - const output = dev.getOutput() - expect(output).toContain('q') + // Step 3: Wait for the ready message + await dev.waitForOutput('Ready, watching for changes in your app', 3 * 60 * 1000) - // Step 5: Press q to quit - dev.sendKey('q') + // Step 4: Verify keyboard shortcuts are shown (indicates TTY mode is working) + const output = dev.getOutput() + expect(output).toContain('q') - // Step 6: Wait for clean exit - const exitCode = await dev.waitForExit(30_000) - expect(exitCode).toBe(0) + // Step 5: Press q to quit + dev.sendKey('q') + + // Step 6: Wait for clean exit + const exitCode = await dev.waitForExit(30_000) + expect(exitCode).toBe(0) + } finally { + 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/app-scaffold.spec.ts b/packages/e2e/tests/app-scaffold.spec.ts index c7d6ee42783..5155fd55510 100644 --- a/packages/e2e/tests/app-scaffold.spec.ts +++ b/packages/e2e/tests/app-scaffold.spec.ts @@ -1,72 +1,112 @@ /* eslint-disable no-restricted-imports */ -import {appScaffoldFixture as test} from '../setup/app.js' +import {appTestFixture as test, createApp, buildApp, generateExtension, 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' test.describe('App scaffold', () => { - test('init creates a react-router app and builds', async ({appScaffold, env}) => { - requireEnv(env, 'clientId') + test('init creates a react-router app and builds', async ({cli, env, browserPage}) => { + test.setTimeout(10 * 60 * 1000) + requireEnv(env, 'orgId') - // Step 1: Create a new app from the react-router template - const initResult = await appScaffold.init({ - template: 'reactRouter', - flavor: 'javascript', - packageManager: 'npm', - }) - expect(initResult.exitCode).toBe(0) - // Ink writes to stderr - const initOutput = initResult.stdout + initResult.stderr - expect(initOutput).toContain('is ready for you to build!') + const parentDir = fs.mkdtempSync(path.join(env.tempDir, 'app-')) + const appName = `E2E-scaffold-${Date.now()}` - // Step 2: Verify the app directory was created with expected files - expect(fs.existsSync(appScaffold.appDir)).toBe(true) - expect(fs.existsSync(path.join(appScaffold.appDir, 'shopify.app.toml'))).toBe(true) - expect(fs.existsSync(path.join(appScaffold.appDir, 'package.json'))).toBe(true) + try { + // Step 1: Create a new app from the react-router template + const initResult = await createApp({ + cli, + parentDir, + name: appName, + template: 'reactRouter', + flavor: 'javascript', + packageManager: 'npm', + orgId: env.orgId, + }) + expect(initResult.exitCode).toBe(0) + const initOutput = initResult.stdout + initResult.stderr + expect(initOutput).toContain('is ready for you to build!') + const appDir = initResult.appDir - // Step 3: Build the app - const buildResult = await appScaffold.build() - expect(buildResult.exitCode, `build failed:\nstderr: ${buildResult.stderr}`).toBe(0) + // Step 2: Verify the app directory was created with expected files + expect(fs.existsSync(appDir)).toBe(true) + expect(fs.existsSync(path.join(appDir, 'shopify.app.toml'))).toBe(true) + expect(fs.existsSync(path.join(appDir, 'package.json'))).toBe(true) + + // Step 3: Build the app + const buildResult = await buildApp({cli, appDir}) + expect(buildResult.exitCode, `build failed:\nstderr: ${buildResult.stderr}`).toBe(0) + } finally { + fs.rmSync(parentDir, {recursive: true, force: true}) + await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + } }) - test('init creates an extension-only app', async ({appScaffold, env}) => { - requireEnv(env, 'clientId') + test('init creates an extension-only app', async ({cli, env, browserPage}) => { + test.setTimeout(10 * 60 * 1000) + requireEnv(env, 'orgId') - const initResult = await appScaffold.init({ - name: 'e2e-ext-only', - template: 'none', - packageManager: 'npm', - }) - expect(initResult.exitCode).toBe(0) - expect(fs.existsSync(appScaffold.appDir)).toBe(true) - expect(fs.existsSync(path.join(appScaffold.appDir, 'shopify.app.toml'))).toBe(true) + const parentDir = fs.mkdtempSync(path.join(env.tempDir, 'app-')) + const appName = `E2E-ext-only-${Date.now()}` + + try { + const initResult = await createApp({ + cli, + parentDir, + name: appName, + template: 'none', + packageManager: 'npm', + orgId: env.orgId, + }) + expect(initResult.exitCode).toBe(0) + expect(fs.existsSync(initResult.appDir)).toBe(true) + expect(fs.existsSync(path.join(initResult.appDir, 'shopify.app.toml'))).toBe(true) + } finally { + fs.rmSync(parentDir, {recursive: true, force: true}) + await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + } }) // Extension generation hits businessPlatformOrganizationsRequest which returns 401 // even with a valid OAuth session. The Business Platform Organizations API token // exchange needs investigation. OAuth login works, but this specific API rejects it. - test.skip('generate extensions and build', async ({appScaffold, env}) => { - requireEnv(env, 'clientId') + test.skip('generate extensions and build', async ({cli, env, browserPage}) => { + test.setTimeout(10 * 60 * 1000) + requireEnv(env, 'orgId') - await appScaffold.init({ - template: 'reactRouter', - flavor: 'javascript', - packageManager: 'npm', - }) + const parentDir = fs.mkdtempSync(path.join(env.tempDir, 'app-')) + const appName = `E2E-ext-gen-${Date.now()}` - const extensionConfigs = [ - {name: 'test-product-sub', template: 'product_subscription_ui', flavor: 'react'}, - {name: 'test-theme-ext', template: 'theme_app_extension'}, - ] + try { + const initResult = await createApp({ + cli, + parentDir, + name: appName, + template: 'reactRouter', + flavor: 'javascript', + packageManager: 'npm', + orgId: env.orgId, + }) + expect(initResult.exitCode).toBe(0) + const appDir = initResult.appDir - for (const ext of extensionConfigs) { - // eslint-disable-next-line no-await-in-loop - const result = await appScaffold.generateExtension(ext) - expect(result.exitCode, `generate "${ext.name}" failed:\nstderr: ${result.stderr}`).toBe(0) - } + const extensionConfigs = [ + {name: 'test-product-sub', template: 'product_subscription_ui', flavor: 'react'}, + {name: 'test-theme-ext', template: 'theme_app_extension'}, + ] + + for (const ext of extensionConfigs) { + // eslint-disable-next-line no-await-in-loop + const result = await generateExtension({cli, appDir, ...ext}) + expect(result.exitCode, `generate "${ext.name}" failed:\nstderr: ${result.stderr}`).toBe(0) + } - const buildResult = await appScaffold.build() - expect(buildResult.exitCode, `build failed:\nstderr: ${buildResult.stderr}`).toBe(0) + const buildResult = await buildApp({cli, appDir}) + expect(buildResult.exitCode, `build failed:\nstderr: ${buildResult.stderr}`).toBe(0) + } finally { + fs.rmSync(parentDir, {recursive: true, force: true}) + await teardownApp({browserPage, appName, email: process.env.E2E_ACCOUNT_EMAIL, orgId: env.orgId}) + } }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 779bdad0570..1c01b6a66f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -554,6 +554,9 @@ importers: '@types/node': specifier: 18.19.70 version: 18.19.70 + dotenv: + specifier: 16.4.7 + version: 16.4.7 execa: specifier: ^7.2.0 version: 7.2.0