Skip to content

Commit c6d8bce

Browse files
isaacroldanclaude
andcommitted
Make teardown app resolution and browser delete resilient
- appByKey now sends organizationId: without it a deleted app cannot resolve an organization and 404s instead of returning null. - Client IDs are only taken from /apps/{segment} URL parts that are non-numeric; deploy output yields /apps/{numericAppId} URLs, which now fall back to a name search. - The settings-page navigation clicks through the accounts.shopify.com account picker, which cold browser contexts bounce to — the main reason direct-URL app deletion has been failing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent b8e696b commit c6d8bce

4 files changed

Lines changed: 96 additions & 11 deletions

File tree

packages/e2e/scripts/probe-api.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/* eslint-disable no-console */
2+
// Throwaway diagnostic: exercise the new app-management-api helper with
3+
// read-only queries against the real API. Not committed — local debugging only.
4+
import {config} from 'dotenv'
5+
import * as path from 'path'
6+
import {fileURLToPath} from 'url'
7+
import {findAppByClientId, findAppByName, appInstallCount} from '../setup/app-management-api.js'
8+
9+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
10+
config({path: path.resolve(__dirname, '../.env')})
11+
12+
const orgId = (process.env.E2E_ORG_ID ?? '').trim()
13+
14+
async function main() {
15+
console.log('[probe-api] org:', orgId)
16+
17+
// 1. appByKey with a bogus key — proves auth + query validity; expects undefined
18+
const bogus = await findAppByClientId(process.env, '0'.repeat(32))
19+
console.log('[probe-api] findAppByClientId(bogus):', bogus === undefined ? 'undefined (expected)' : bogus)
20+
21+
// 2. name search for a name that should not exist — proves appsConnection + org routing
22+
const missing = await findAppByName(process.env, 'E2E-probe-does-not-exist', orgId)
23+
console.log('[probe-api] findAppByName(missing):', missing === undefined ? 'undefined (expected)' : missing)
24+
25+
// 3. name search with a broad E2E prefix — if any leaked app exists, exercise installCount on it
26+
const anyApp = await findAppByName(process.env, 'E2E-', orgId)
27+
if (anyApp) {
28+
const count = await appInstallCount(process.env, anyApp.id)
29+
console.log(`[probe-api] installCount(${anyApp.key.slice(0, 8)}…):`, count)
30+
} else {
31+
console.log('[probe-api] no app named exactly "E2E-" (expected) — resolving one via secondary client id instead')
32+
const secondary = (process.env.E2E_SECONDARY_CLIENT_ID ?? '').trim()
33+
if (secondary) {
34+
const app = await findAppByClientId(process.env, secondary)
35+
console.log('[probe-api] findAppByClientId(secondary):', app ? `id=${app.id}` : 'undefined')
36+
if (app) {
37+
const count = await appInstallCount(process.env, app.id)
38+
console.log('[probe-api] installCount(secondary):', count)
39+
}
40+
}
41+
}
42+
43+
console.log('[probe-api] all queries executed without errors')
44+
}
45+
46+
main().catch((err) => {
47+
console.error('[probe-api] fatal:', err instanceof Error ? err.message : err)
48+
process.exitCode = 1
49+
})

packages/e2e/setup/app-management-api.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,20 +89,24 @@ async function appManagementQuery(
8989
* Look up an app by its API key (client_id). Returns undefined when the app
9090
* does not exist — for teardown that means it was already deleted.
9191
*
92-
* The variable must be named `apiKey`: the API resolves the request's
93-
* organization from specifically-named variables (`organizationId`, `apiKey`,
94-
* `appId`) and rejects the request with "Cannot find a valid organization"
95-
* otherwise.
92+
* Variable names matter: the API resolves the request's organization from
93+
* specifically-named variables (`organizationId`, `apiKey`, `appId`) and
94+
* rejects the request with "Cannot find a valid organization" otherwise.
95+
* `organizationId` must be sent even though `apiKey` alone can resolve it:
96+
* a deleted app resolves no organization, which would surface as that same
97+
* rejection instead of a null `appByKey`.
9698
*/
9799
export async function findAppByClientId(
98100
sessionEnv: NodeJS.ProcessEnv,
99101
clientId: string,
102+
orgId: string,
100103
): Promise<AppManagementApp | undefined> {
101104
const data = (await appManagementQuery(
102105
sessionEnv,
103106
'query appByKey($apiKey: String!) { appByKey(key: $apiKey) { id key } }',
104107
{
105108
apiKey: clientId,
109+
organizationId: orgId,
106110
},
107111
)) as {appByKey?: AppManagementApp | null}
108112
return data.appByKey ?? undefined

packages/e2e/setup/app.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,29 @@ export async function configLink(
323323
// Dev dashboard browser actions — delete apps
324324
// ---------------------------------------------------------------------------
325325

326+
/**
327+
* Load the app's settings page, clicking through the accounts.shopify.com
328+
* account picker if the session bounces there — which is common when the
329+
* browser context has not visited the Dev Dashboard yet.
330+
*/
331+
async function gotoAppSettings(page: Page, appUrl: string): Promise<void> {
332+
await page.goto(`${appUrl}/settings`, {waitUntil: 'domcontentloaded'})
333+
await page.waitForTimeout(BROWSER_TIMEOUT.medium)
334+
335+
if (!page.url().startsWith('https://accounts.shopify.com')) return
336+
337+
const email = process.env.E2E_ACCOUNT_EMAIL
338+
if (email) {
339+
const accountButton = page.locator(`text=${email}`).first()
340+
if (await isVisibleWithin(accountButton, BROWSER_TIMEOUT.long)) {
341+
await accountButton.click()
342+
await page.waitForTimeout(BROWSER_TIMEOUT.medium)
343+
}
344+
}
345+
await page.goto(`${appUrl}/settings`, {waitUntil: 'domcontentloaded'})
346+
await page.waitForTimeout(BROWSER_TIMEOUT.medium)
347+
}
348+
326349
/**
327350
* Delete an app from its dev dashboard settings page. Returns true if deleted.
328351
*
@@ -333,8 +356,7 @@ export async function configLink(
333356
*/
334357
export async function deleteAppFromDevDashboard(page: Page, appUrl: string): Promise<boolean> {
335358
// Step 1: Navigate to the app's settings page. 404 → already deleted. 5xx → throw for retry.
336-
await page.goto(`${appUrl}/settings`, {waitUntil: 'domcontentloaded'})
337-
await page.waitForTimeout(BROWSER_TIMEOUT.medium)
359+
await gotoAppSettings(page, appUrl)
338360
const gotoStatus = getLastPageStatus(page)
339361
if (gotoStatus === 404) return true
340362
if (gotoStatus !== undefined && gotoStatus >= 500) {
@@ -345,6 +367,11 @@ export async function deleteAppFromDevDashboard(page: Page, appUrl: string): Pro
345367
// Button can be below the fold, and takes ~1-2s to enable after uninstall (one reload covers propagation lag).
346368
// If it stays disabled after reload, installs remain — fail fast for caller.
347369
const deleteBtn = page.locator('button:has-text("Delete app")').first()
370+
if (!(await isVisibleWithin(deleteBtn, BROWSER_TIMEOUT.long))) {
371+
// Include the landed URL: the usual cause is a session bounce that the
372+
// account-picker handling above did not cover.
373+
throw new Error(`Delete app button not found (page: ${page.url()})`)
374+
}
348375
await deleteBtn.scrollIntoViewIfNeeded({timeout: BROWSER_TIMEOUT.long})
349376
if (!(await deleteBtn.isEnabled())) {
350377
await page.reload({waitUntil: 'domcontentloaded'})

packages/e2e/setup/teardown.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,10 @@ export async function teardownAll(ctx: TeardownCtx): Promise<void> {
5252
const clientId = resolveClientId(ctx)
5353
for (let attempt = 1; attempt <= 3; attempt++) {
5454
try {
55-
if (clientId) {
56-
app = await findAppByClientId(sessionEnv, clientId)
57-
} else if (ctx.env.orgId) {
55+
if (clientId && ctx.env.orgId) {
56+
app = await findAppByClientId(sessionEnv, clientId, ctx.env.orgId)
57+
}
58+
if (!app && ctx.env.orgId) {
5859
app = await findAppByName(sessionEnv, ctx.appName, ctx.env.orgId)
5960
}
6061
appResolved = true
@@ -170,7 +171,10 @@ export async function teardownAll(ctx: TeardownCtx): Promise<void> {
170171

171172
/**
172173
* The app's client_id: read from the local TOML when the app dir is known,
173-
* otherwise take the last segment of the Dev Dashboard app URL.
174+
* otherwise from the Dev Dashboard app URL. Dashboard URLs come in two
175+
* shapes — apps/[clientId] (built by devDashboardAppUrl) and
176+
* apps/[numericAppId] (parsed from deploy output) — and only the former is
177+
* usable as an API key, so numeric segments resolve via name search instead.
174178
*/
175179
function resolveClientId(ctx: TeardownCtx): string | undefined {
176180
if (ctx.appDir) {
@@ -181,5 +185,6 @@ function resolveClientId(ctx: TeardownCtx): string | undefined {
181185
// TOML may be missing when the test failed before app creation.
182186
}
183187
}
184-
return ctx.appUrl?.split('/').filter(Boolean).at(-1)
188+
const urlSegment = ctx.appUrl?.match(/\/apps\/([^/?#]+)/)?.[1]
189+
return urlSegment && !/^\d+$/.test(urlSegment) ? urlSegment : undefined
185190
}

0 commit comments

Comments
 (0)