Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/e2e/helpers/loadtest-header.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@ import type {BrowserContext} from '@playwright/test'
const LOADTEST_HEADER_PATTERN = /^X-Shopify-Loadtest-[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/i
const LOADTEST_HEADER_DOMAINS = ['shopify.com', 'myshopify.com']

/**
* Loadtest header as a plain record for direct API requests made by the
* harness. Empty when the env var is unset so local runs still work.
*/
export function loadtestHeaderRecord(): {[header: string]: string} {
const loadtestHeader = process.env.E2E_LOADTEST_HEADER?.trim()
if (!loadtestHeader || !LOADTEST_HEADER_PATTERN.test(loadtestHeader)) return {}
return {[loadtestHeader]: 'true'}
}

export async function addLoadtestHeader(context: BrowserContext): Promise<void> {
const loadtestHeader = process.env.E2E_LOADTEST_HEADER?.trim()

Expand Down
124 changes: 124 additions & 0 deletions packages/e2e/setup/admin-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/* eslint-disable no-restricted-globals -- the harness calls Shopify endpoints
directly, like the Playwright browser does; the cli-kit fetch wrapper (and
its proxy support) is for the CLI under test, which this package must not
depend on */
import {extractClientId} from './app.js'
import {CLI_TIMEOUT} from './constants.js'
import {loadtestHeaderRecord} from '../helpers/loadtest-header.js'
import {stripAnsi} from '../helpers/strip-ansi.js'
import type {CLIProcess} from './cli.js'

/** Admin API version for harness-side GraphQL requests. */
const ADMIN_API_VERSION = '2026-04'

interface AdminApiUninstallCtx {
cli: Pick<CLIProcess, 'exec'>
appDir: string
storeFqdn: string
}

/**
* Uninstall the app from the store over the Admin API instead of driving the
* store admin UI:
* 1. Read the app's client secret with `app env show`.
* 2. Mint an app access token with the client credentials grant. The grant
* works here because the E2E org owns both the app and the dev store.
* 3. Run the `appUninstall` mutation, which uninstalls the calling app.
*
* Throws on any failure so teardown surfaces the error instead of leaking.
*/
export async function uninstallAppWithAdminApi(ctx: AdminApiUninstallCtx): Promise<void> {
const clientId = extractClientId(ctx.appDir)
const clientSecret = await fetchClientSecret(ctx)
const accessToken = await mintAppAccessTokenWithRetry(ctx.storeFqdn, clientId, clientSecret)
await runAppUninstall(ctx.storeFqdn, accessToken)
}

/**
* Freshly created apps and stores can transiently 400 with
* `application_cannot_be_found` while records propagate — retry briefly.
*/
async function mintAppAccessTokenWithRetry(storeFqdn: string, clientId: string, clientSecret: string): Promise<string> {
const attempts = 3
const retryDelayMs = 5000

for (let attempt = 1; ; attempt++) {
try {
// eslint-disable-next-line no-await-in-loop
return await mintAppAccessToken(storeFqdn, clientId, clientSecret)
} catch (err) {
if (attempt === attempts) throw err
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => setTimeout(resolve, retryDelayMs))
}
}
}

async function fetchClientSecret(ctx: AdminApiUninstallCtx): Promise<string> {
const result = await ctx.cli.exec(['app', 'env', 'show', '--path', ctx.appDir], {timeout: CLI_TIMEOUT.short})
if (result.exitCode !== 0) {
throw new Error(`app env show failed (exit ${result.exitCode}): ${result.stderr}`)
}
const secret = stripAnsi(result.stdout).match(/SHOPIFY_API_SECRET=(\S+)/)?.[1]
if (!secret) {
throw new Error('app env show output did not include SHOPIFY_API_SECRET')
}
return secret
}

async function mintAppAccessToken(storeFqdn: string, clientId: string, clientSecret: string): Promise<string> {
const response = await fetch(`https://${storeFqdn}/admin/oauth/access_token`, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded', ...loadtestHeaderRecord()},
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: clientId,
client_secret: clientSecret,
}).toString(),
})
if (!response.ok) {
throw new Error(
`access token request failed for client_id ${clientId} on ${storeFqdn} ` +
`(status ${response.status}): ${summarizeErrorBody(await response.text())}`,
)
}
const payload = (await response.json()) as {access_token?: string}
if (!payload.access_token) {
throw new Error('access token response did not include access_token')
}
return payload.access_token
}

async function runAppUninstall(storeFqdn: string, accessToken: string): Promise<void> {
const response = await fetch(`https://${storeFqdn}/admin/api/${ADMIN_API_VERSION}/graphql.json`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Access-Token': accessToken,
...loadtestHeaderRecord(),
},
body: JSON.stringify({query: 'mutation { appUninstall { userErrors { field message } } }'}),
})
if (!response.ok) {
throw new Error(
`appUninstall request failed (status ${response.status}): ${summarizeErrorBody(await response.text())}`,
)
}
const payload = (await response.json()) as {
errors?: unknown
data?: {appUninstall?: {userErrors?: {message: string}[]}}
}
if (payload.errors) {
throw new Error(`appUninstall returned errors: ${JSON.stringify(payload.errors)}`)
}
const userErrors = payload.data?.appUninstall?.userErrors ?? []
if (userErrors.length > 0) {
throw new Error(`appUninstall returned user errors: ${userErrors.map((error) => error.message).join(', ')}`)
}
}

/** Shopify OAuth errors come back as full HTML pages — keep only the <title>, which carries the error code. */
function summarizeErrorBody(body: string): string {
const htmlTitle = body.match(/<title>([^<]*)<\/title>/)?.[1]
return htmlTitle ?? body.slice(0, 300)
}
37 changes: 25 additions & 12 deletions packages/e2e/setup/teardown.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/* eslint-disable no-await-in-loop */
import {uninstallAppWithAdminApi} from './admin-api.js'
import {findAppOnDevDashboard, deleteAppFromDevDashboard} from './app.js'
import {refreshIfPageError} from './browser.js'
import {createLogger, e2eSection} from './env.js'
Expand All @@ -14,6 +15,8 @@ interface BaseTeardownCtx {
appName: string
/** Direct Dev Dashboard app URL. Prefer this when available to avoid slow org-wide pagination. */
appUrl?: string
/** Local app directory. When present, uninstall goes through the Admin API instead of the store admin UI. */
appDir?: string
workerIndex?: number
}

Expand All @@ -40,20 +43,30 @@ export async function teardownAll(ctx: TeardownCtx): Promise<void> {
const storeSlug = ctx.storeFqdn.replace('.myshopify.com', '')
e2eSection(wCtx, `Teardown: store ${ctx.storeFqdn}`)

// Phase 1: Uninstall app from store
// Phase 1: Uninstall app from store — Admin API when the app dir is known.
// No browser fallback: an API failure must surface loudly so it gets fixed
// instead of hiding behind the flaky store-admin click-through.
let uninstalled = false
log.log(wCtx, 'uninstalling app from store')
for (let attempt = 1; attempt <= 3; attempt++) {
try {
uninstalled = await uninstallAppFromStore(page, storeSlug, ctx.appName)
if (uninstalled) {
log.log(wCtx, 'app uninstalled')
break
if (ctx.appDir) {
log.log(wCtx, 'uninstalling app via admin API')
await uninstallAppWithAdminApi({cli: ctx.cli, appDir: ctx.appDir, storeFqdn: ctx.storeFqdn})
uninstalled = true
log.log(wCtx, 'app uninstalled via admin API')
}
if (!uninstalled) {
log.log(wCtx, 'uninstalling app from store')
for (let attempt = 1; attempt <= 3; attempt++) {
try {
uninstalled = await uninstallAppFromStore(page, storeSlug, ctx.appName)
if (uninstalled) {
log.log(wCtx, 'app uninstalled')
break
}
log.log(wCtx, `(${attempt}/3) app uninstall attempt failed, app still visible`)
// eslint-disable-next-line no-catch-all/no-catch-all
} catch (err) {
log.log(wCtx, `(${attempt}/3) app uninstall attempt failed: ${err instanceof Error ? err.message : err}`)
}
log.log(wCtx, `(${attempt}/3) app uninstall attempt failed, app still visible`)
// eslint-disable-next-line no-catch-all/no-catch-all
} catch (err) {
log.log(wCtx, `(${attempt}/3) app uninstall attempt failed: ${err instanceof Error ? err.message : err}`)
}
}
if (!uninstalled) {
Expand Down
6 changes: 4 additions & 2 deletions packages/e2e/tests/app-dev-server.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ test.describe('App dev server', () => {
const parentDir = fs.mkdtempSync(path.join(env.tempDir, 'app-'))
const appName = e2eAppName('dev')
let appUrl: string | undefined
let appDir: string | undefined

try {
// Step 1: Create an extension-only app (no scopes needed)
Expand All @@ -29,7 +30,7 @@ test.describe('App dev server', () => {
expect(initResult.exitCode, `createApp failed:\nstdout: ${initResult.stdout}\nstderr: ${initResult.stderr}`).toBe(
0,
)
const appDir = initResult.appDir
appDir = initResult.appDir
appUrl = devDashboardAppUrl(appDir, env.orgId)

// Step 2: Start dev server via PTY, targeting the worker's store
Expand All @@ -53,16 +54,17 @@ test.describe('App dev server', () => {
} finally {
// E2E_SKIP_TEARDOWN=1 skips teardown for debugging. Run cleanup scripts afterward.
if (!process.env.E2E_SKIP_TEARDOWN) {
fs.rmSync(parentDir, {recursive: true, force: true})
await teardownAll({
cli,
browserPage,
appName,
appUrl,
appDir,
orgId: env.orgId,
storeFqdn,
workerIndex: env.workerIndex,
})
fs.rmSync(parentDir, {recursive: true, force: true})
}
}
})
Expand Down
18 changes: 12 additions & 6 deletions packages/e2e/tests/dev-hot-reload.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,12 @@ test.describe('Dev hot reload', () => {
const parentDir = fs.mkdtempSync(path.join(env.tempDir, 'app-'))
const appName = e2eAppName('hot-reload')
let appUrl: string | undefined
let appDir: string | undefined

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
appDir = initResult.appDir
appUrl = devDashboardAppUrl(appDir, env.orgId)

injectFixtureToml(appDir, FIXTURE_TOML, appName)
Expand Down Expand Up @@ -88,16 +89,17 @@ test.describe('Dev hot reload', () => {
} finally {
// E2E_SKIP_TEARDOWN=1 skips teardown for debugging. Run cleanup scripts afterward.
if (!process.env.E2E_SKIP_TEARDOWN) {
fs.rmSync(parentDir, {recursive: true, force: true})
await teardownAll({
cli,
browserPage,
appName,
appUrl,
appDir,
orgId: env.orgId,
storeFqdn,
workerIndex: env.workerIndex,
})
fs.rmSync(parentDir, {recursive: true, force: true})
}
}
})
Expand All @@ -109,11 +111,12 @@ test.describe('Dev hot reload', () => {
const parentDir = fs.mkdtempSync(path.join(env.tempDir, 'app-'))
const appName = e2eAppName('hot-create')
let appUrl: string | undefined
let appDir: string | undefined

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
appDir = initResult.appDir
appUrl = devDashboardAppUrl(appDir, env.orgId)

injectFixtureToml(appDir, FIXTURE_TOML, appName)
Expand Down Expand Up @@ -144,16 +147,17 @@ test.describe('Dev hot reload', () => {
} finally {
// E2E_SKIP_TEARDOWN=1 skips teardown for debugging. Run cleanup scripts afterward.
if (!process.env.E2E_SKIP_TEARDOWN) {
fs.rmSync(parentDir, {recursive: true, force: true})
await teardownAll({
cli,
browserPage,
appName,
appUrl,
appDir,
orgId: env.orgId,
storeFqdn,
workerIndex: env.workerIndex,
})
fs.rmSync(parentDir, {recursive: true, force: true})
}
}
})
Expand All @@ -165,11 +169,12 @@ test.describe('Dev hot reload', () => {
const parentDir = fs.mkdtempSync(path.join(env.tempDir, 'app-'))
const appName = e2eAppName('hot-delete')
let appUrl: string | undefined
let appDir: string | undefined

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
appDir = initResult.appDir
appUrl = devDashboardAppUrl(appDir, env.orgId)

injectFixtureToml(appDir, FIXTURE_TOML, appName)
Expand Down Expand Up @@ -206,16 +211,17 @@ test.describe('Dev hot reload', () => {
} finally {
// E2E_SKIP_TEARDOWN=1 skips teardown for debugging. Run cleanup scripts afterward.
if (!process.env.E2E_SKIP_TEARDOWN) {
fs.rmSync(parentDir, {recursive: true, force: true})
await teardownAll({
cli,
browserPage,
appName,
appUrl,
appDir,
orgId: env.orgId,
storeFqdn,
workerIndex: env.workerIndex,
})
fs.rmSync(parentDir, {recursive: true, force: true})
}
}
})
Expand Down
Loading
Loading