|
1 | 1 | /* eslint-disable no-restricted-imports, no-await-in-loop */ |
2 | 2 | import {authFixture} from './auth.js' |
3 | | -import {navigateToDashboard} from './browser.js' |
| 3 | +import {navigateToDashboard, refreshIfPageError} from './browser.js' |
4 | 4 | import {CLI_TIMEOUT, BROWSER_TIMEOUT} from './constants.js' |
5 | 5 | import {updateTomlValues} from '@shopify/toml-patch' |
6 | 6 | import * as toml from '@iarna/toml' |
7 | 7 | import * as path from 'path' |
8 | 8 | import * as fs from 'fs' |
9 | 9 | import type {CLIContext, CLIProcess, ExecResult} from './cli.js' |
10 | | -import type {BrowserContext} from './browser.js' |
| 10 | +import type {Page} from '@playwright/test' |
11 | 11 |
|
12 | 12 | // --------------------------------------------------------------------------- |
13 | 13 | // CLI helpers — thin wrappers around cli.exec() |
@@ -190,208 +190,105 @@ export async function configLink( |
190 | 190 | } |
191 | 191 |
|
192 | 192 | // --------------------------------------------------------------------------- |
193 | | -// Browser helpers — app-specific dashboard automation |
| 193 | +// Dev dashboard browser actions — find and delete apps |
194 | 194 | // --------------------------------------------------------------------------- |
195 | 195 |
|
196 | | -/** Find apps matching a name pattern on the dashboard. Call navigateToDashboard first. */ |
197 | | -export async function findAppsOnDashboard( |
198 | | - ctx: BrowserContext & { |
199 | | - namePattern: string |
200 | | - }, |
201 | | -): Promise<{name: string; url: string}[]> { |
202 | | - const appCards = await ctx.browserPage.locator('a[href*="/apps/"]').all() |
203 | | - const apps: {name: string; url: string}[] = [] |
204 | | - |
205 | | - for (const card of appCards) { |
206 | | - const href = await card.getAttribute('href') |
207 | | - const text = await card.textContent() |
208 | | - if (!href || !text || !href.match(/\/apps\/\d+/)) continue |
209 | | - |
210 | | - const name = text.split(/\d+\s+install/i)[0]?.trim() ?? text.split('\n')[0]?.trim() ?? text.trim() |
211 | | - if (!name || name.length > 200) continue |
212 | | - if (!name.includes(ctx.namePattern)) continue |
213 | | - |
214 | | - const url = href.startsWith('http') ? href : `https://dev.shopify.com${href}` |
215 | | - apps.push({name, url}) |
216 | | - } |
217 | | - |
218 | | - return apps |
219 | | -} |
220 | | - |
221 | | -/** Uninstall an app from all stores it's installed on. Returns true if fully uninstalled. */ |
222 | | -export async function uninstallApp( |
223 | | - ctx: BrowserContext & { |
224 | | - appUrl: string |
225 | | - appName: string |
226 | | - orgId?: string |
227 | | - }, |
228 | | -): Promise<boolean> { |
229 | | - const {browserPage, appUrl, appName} = ctx |
230 | | - const orgId = ctx.orgId ?? (process.env.E2E_ORG_ID ?? '').trim() |
231 | | - |
232 | | - await browserPage.goto(`${appUrl}/installs`, {waitUntil: 'domcontentloaded'}) |
233 | | - await browserPage.waitForTimeout(BROWSER_TIMEOUT.medium) |
234 | | - |
235 | | - const rows = await browserPage.locator('table tbody tr').all() |
236 | | - const storeNames: string[] = [] |
237 | | - for (const row of rows) { |
238 | | - const firstCell = row.locator('td').first() |
239 | | - const text = (await firstCell.textContent())?.trim() |
240 | | - if (text && !text.toLowerCase().includes('no installed')) storeNames.push(text) |
241 | | - } |
242 | | - |
243 | | - if (storeNames.length === 0) return true |
244 | | - |
245 | | - let allUninstalled = true |
246 | | - for (const storeName of storeNames) { |
247 | | - try { |
248 | | - // Navigate to store admin via the dev dashboard dropdown |
249 | | - const dashboardUrl = orgId |
250 | | - ? `https://dev.shopify.com/dashboard/${orgId}/apps` |
251 | | - : 'https://dev.shopify.com/dashboard' |
252 | | - let navigated = false |
253 | | - for (let attempt = 1; attempt <= 3; attempt++) { |
254 | | - await browserPage.goto(dashboardUrl, {waitUntil: 'domcontentloaded'}) |
255 | | - await browserPage.waitForTimeout(BROWSER_TIMEOUT.medium) |
256 | | - |
257 | | - const pageText = (await browserPage.textContent('body')) ?? '' |
258 | | - if (pageText.includes('500') || pageText.includes('Internal Server Error')) continue |
259 | | - |
260 | | - const orgButton = browserPage.locator('header button').last() |
261 | | - if (!(await orgButton.isVisible({timeout: BROWSER_TIMEOUT.medium}).catch(() => false))) continue |
262 | | - await orgButton.click() |
263 | | - await browserPage.waitForTimeout(BROWSER_TIMEOUT.short) |
264 | | - |
265 | | - const storeLink = browserPage.locator('a, button').filter({hasText: storeName}).first() |
266 | | - if (!(await storeLink.isVisible({timeout: BROWSER_TIMEOUT.medium}).catch(() => false))) continue |
267 | | - await storeLink.click() |
268 | | - await browserPage.waitForTimeout(BROWSER_TIMEOUT.medium) |
269 | | - navigated = true |
270 | | - break |
271 | | - } |
272 | | - |
273 | | - if (!navigated) { |
274 | | - allUninstalled = false |
275 | | - continue |
276 | | - } |
277 | | - |
278 | | - // Navigate to store's apps settings page |
279 | | - const storeAdminUrl = browserPage.url() |
280 | | - await browserPage.goto(`${storeAdminUrl.replace(/\/$/, '')}/settings/apps`, {waitUntil: 'domcontentloaded'}) |
281 | | - await browserPage.waitForTimeout(BROWSER_TIMEOUT.long) |
282 | | - |
283 | | - // Dismiss any Dev Console dialog |
284 | | - const cancelButton = browserPage.locator('button:has-text("Cancel")') |
285 | | - if (await cancelButton.isVisible({timeout: BROWSER_TIMEOUT.medium}).catch(() => false)) { |
286 | | - await cancelButton.click() |
287 | | - await browserPage.waitForTimeout(BROWSER_TIMEOUT.short) |
| 196 | +/** Search dev dashboard for an app by name. Returns the app URL or null. */ |
| 197 | +export async function findAppOnDevDashboard(page: Page, appName: string, orgId?: string): Promise<string | null> { |
| 198 | + const org = orgId ?? (process.env.E2E_ORG_ID ?? '').trim() |
| 199 | + const email = process.env.E2E_ACCOUNT_EMAIL |
| 200 | + |
| 201 | + await navigateToDashboard({browserPage: page, email, orgId: org}) |
| 202 | + |
| 203 | + // Scan current page + pagination for the app |
| 204 | + while (true) { |
| 205 | + const allLinks = await page.locator('a[href*="/apps/"]').all() |
| 206 | + for (const link of allLinks) { |
| 207 | + const text = (await link.textContent()) ?? '' |
| 208 | + if (text.includes(appName)) { |
| 209 | + const href = await link.getAttribute('href') |
| 210 | + if (href) return href.startsWith('http') ? href : `https://dev.shopify.com${href}` |
288 | 211 | } |
289 | | - |
290 | | - // Find the app in the installed list (plain span, not Dev Console's Polaris text) |
291 | | - const appSpan = browserPage.locator(`span:has-text("${appName}"):not([class*="Polaris"])`).first() |
292 | | - if (!(await appSpan.isVisible({timeout: BROWSER_TIMEOUT.medium}).catch(() => false))) { |
293 | | - allUninstalled = false |
294 | | - continue |
295 | | - } |
296 | | - |
297 | | - // Click the ⋯ menu button next to the app name |
298 | | - const menuButton = appSpan.locator('xpath=./following::button[1]') |
299 | | - await menuButton.click() |
300 | | - await browserPage.waitForTimeout(BROWSER_TIMEOUT.short) |
301 | | - |
302 | | - // Click "Uninstall" in the dropdown menu |
303 | | - const uninstallOption = browserPage.locator('text=Uninstall').last() |
304 | | - if (!(await uninstallOption.isVisible({timeout: BROWSER_TIMEOUT.medium}).catch(() => false))) { |
305 | | - allUninstalled = false |
306 | | - continue |
307 | | - } |
308 | | - await uninstallOption.click() |
309 | | - await browserPage.waitForTimeout(BROWSER_TIMEOUT.medium) |
310 | | - |
311 | | - // Handle confirmation dialog |
312 | | - const confirmButton = browserPage.locator('button:has-text("Uninstall"), button:has-text("Confirm")').last() |
313 | | - if (await confirmButton.isVisible({timeout: BROWSER_TIMEOUT.medium}).catch(() => false)) { |
314 | | - await confirmButton.click() |
315 | | - await browserPage.waitForTimeout(BROWSER_TIMEOUT.medium) |
316 | | - } |
317 | | - // eslint-disable-next-line no-catch-all/no-catch-all |
318 | | - } catch (_err) { |
319 | | - allUninstalled = false |
320 | 212 | } |
| 213 | + |
| 214 | + // Check for next page |
| 215 | + const nextLink = page.locator('a[href*="next_cursor"]').first() |
| 216 | + if (!(await nextLink.isVisible({timeout: BROWSER_TIMEOUT.medium}).catch(() => false))) break |
| 217 | + const nextHref = await nextLink.getAttribute('href') |
| 218 | + if (!nextHref) break |
| 219 | + const nextUrl = nextHref.startsWith('http') ? nextHref : `https://dev.shopify.com${nextHref}` |
| 220 | + await page.goto(nextUrl, {waitUntil: 'domcontentloaded'}) |
| 221 | + await page.waitForTimeout(BROWSER_TIMEOUT.medium) |
| 222 | + await refreshIfPageError(page) |
321 | 223 | } |
322 | 224 |
|
323 | | - return allUninstalled |
| 225 | + return null |
324 | 226 | } |
325 | 227 |
|
326 | | -/** Delete an app from the partner dashboard. Should be uninstalled first. */ |
327 | | -export async function deleteApp( |
328 | | - ctx: BrowserContext & { |
329 | | - appUrl: string |
330 | | - }, |
331 | | -): Promise<void> { |
332 | | - const {browserPage, appUrl} = ctx |
333 | | - |
334 | | - await browserPage.goto(`${appUrl}/settings`, {waitUntil: 'domcontentloaded'}) |
335 | | - await browserPage.waitForTimeout(BROWSER_TIMEOUT.medium) |
336 | | - |
337 | | - // Retry if delete button is disabled (uninstall propagation delay) |
338 | | - const deleteButton = browserPage.locator('button:has-text("Delete app")').first() |
339 | | - for (let attempt = 1; attempt <= 5; attempt++) { |
340 | | - await deleteButton.scrollIntoViewIfNeeded() |
341 | | - const isDisabled = await deleteButton.getAttribute('disabled') |
| 228 | +/** Delete an app from its dev dashboard settings page. Returns true if deleted, false if not. */ |
| 229 | +export async function deleteAppFromDevDashboard(page: Page, appUrl: string): Promise<boolean> { |
| 230 | + // Step 1: Navigate to settings page |
| 231 | + await page.goto(`${appUrl}/settings`, {waitUntil: 'domcontentloaded'}) |
| 232 | + await page.waitForTimeout(BROWSER_TIMEOUT.medium) |
| 233 | + await refreshIfPageError(page) |
| 234 | + |
| 235 | + // Step 2: Wait for "Delete app" button to be enabled, then click (retry with error check) |
| 236 | + const deleteAppBtn = page.locator('button:has-text("Delete app")').first() |
| 237 | + for (let attempt = 1; attempt <= 3; attempt++) { |
| 238 | + if (await refreshIfPageError(page)) continue |
| 239 | + const isDisabled = await deleteAppBtn.getAttribute('disabled').catch(() => 'true') |
342 | 240 | if (!isDisabled) break |
343 | | - await browserPage.waitForTimeout(BROWSER_TIMEOUT.long) |
344 | | - await browserPage.reload({waitUntil: 'domcontentloaded'}) |
345 | | - await browserPage.waitForTimeout(BROWSER_TIMEOUT.medium) |
| 241 | + await page.reload({waitUntil: 'domcontentloaded'}) |
| 242 | + await page.waitForTimeout(BROWSER_TIMEOUT.medium) |
346 | 243 | } |
347 | 244 |
|
348 | | - await deleteButton.click({timeout: BROWSER_TIMEOUT.long}) |
349 | | - await browserPage.waitForTimeout(BROWSER_TIMEOUT.medium) |
350 | | - |
351 | | - // Handle confirmation dialog — may need to type "DELETE" |
352 | | - const confirmInput = browserPage.locator('input[type="text"]').last() |
353 | | - if (await confirmInput.isVisible({timeout: BROWSER_TIMEOUT.medium}).catch(() => false)) { |
354 | | - await confirmInput.fill('DELETE') |
355 | | - await browserPage.waitForTimeout(BROWSER_TIMEOUT.short) |
| 245 | + // Click the delete button — if it's not found, the page didn't load properly |
| 246 | + const deleteClicked = await deleteAppBtn |
| 247 | + .click({timeout: BROWSER_TIMEOUT.long}) |
| 248 | + .then(() => true) |
| 249 | + .catch(() => false) |
| 250 | + if (!deleteClicked) return false |
| 251 | + await page.waitForTimeout(BROWSER_TIMEOUT.medium) |
| 252 | + |
| 253 | + // Step 3: Click confirm "Delete" in the modal (retry step 2+3 if not visible) |
| 254 | + // The dev dashboard modal has a submit button with class "critical" inside a form |
| 255 | + const confirmAppBtn = page.locator('button.critical[type="submit"]') |
| 256 | + for (let attempt = 1; attempt <= 3; attempt++) { |
| 257 | + if (await confirmAppBtn.isVisible({timeout: BROWSER_TIMEOUT.medium}).catch(() => false)) break |
| 258 | + if (attempt === 3) return false |
| 259 | + // Retry: re-click the delete button to reopen modal |
| 260 | + await page.keyboard.press('Escape') |
| 261 | + await page.waitForTimeout(BROWSER_TIMEOUT.short) |
| 262 | + const retryClicked = await deleteAppBtn |
| 263 | + .click({timeout: BROWSER_TIMEOUT.long}) |
| 264 | + .then(() => true) |
| 265 | + .catch(() => false) |
| 266 | + if (!retryClicked) return false |
| 267 | + await page.waitForTimeout(BROWSER_TIMEOUT.medium) |
356 | 268 | } |
357 | 269 |
|
358 | | - const confirmButton = browserPage.locator('button:has-text("Delete app")').last() |
359 | | - await confirmButton.click() |
360 | | - await browserPage.waitForTimeout(BROWSER_TIMEOUT.medium) |
361 | | -} |
| 270 | + const urlBefore = page.url() |
| 271 | + const confirmClicked = await confirmAppBtn |
| 272 | + .click({timeout: BROWSER_TIMEOUT.long}) |
| 273 | + .then(() => true) |
| 274 | + .catch(() => false) |
| 275 | + if (!confirmClicked) return false |
362 | 276 |
|
363 | | -/** Best-effort teardown: find app on dashboard by name, uninstall from all stores, delete. */ |
364 | | -export async function teardownApp( |
365 | | - ctx: BrowserContext & { |
366 | | - appName: string |
367 | | - email?: string |
368 | | - orgId?: string |
369 | | - }, |
370 | | -): Promise<void> { |
| 277 | + // Wait for page to navigate away after deletion |
371 | 278 | try { |
372 | | - await navigateToDashboard(ctx) |
373 | | - const apps = await findAppsOnDashboard({browserPage: ctx.browserPage, namePattern: ctx.appName}) |
374 | | - for (const app of apps) { |
375 | | - try { |
376 | | - await uninstallApp({browserPage: ctx.browserPage, appUrl: app.url, appName: app.name, orgId: ctx.orgId}) |
377 | | - await deleteApp({browserPage: ctx.browserPage, appUrl: app.url}) |
378 | | - // eslint-disable-next-line no-catch-all/no-catch-all |
379 | | - } catch (err) { |
380 | | - // Best-effort per app — continue teardown of remaining apps |
381 | | - if (process.env.DEBUG === '1') { |
382 | | - const msg = err instanceof Error ? err.message : String(err) |
383 | | - process.stderr.write(`[e2e] Teardown failed for app ${app.name}: ${msg}\n`) |
384 | | - } |
385 | | - } |
386 | | - } |
| 279 | + await page.waitForURL((url) => url.toString() !== urlBefore, {timeout: BROWSER_TIMEOUT.max}) |
387 | 280 | // eslint-disable-next-line no-catch-all/no-catch-all |
388 | | - } catch (err) { |
389 | | - // Best-effort — don't fail the test if teardown fails |
390 | | - if (process.env.DEBUG === '1') { |
391 | | - const msg = err instanceof Error ? err.message : String(err) |
392 | | - process.stderr.write(`[e2e] Teardown failed for ${ctx.appName}: ${msg}\n`) |
| 281 | + } catch (_err) { |
| 282 | + // URL didn't change — check if page error occurred during redirect |
| 283 | + if (await refreshIfPageError(page)) { |
| 284 | + // After refresh, 404 means the app was deleted (settings page no longer exists) |
| 285 | + const bodyText = (await page.textContent('body')) ?? '' |
| 286 | + if (bodyText.includes('404: Not Found')) return true |
| 287 | + return false |
393 | 288 | } |
| 289 | + await page.waitForTimeout(BROWSER_TIMEOUT.medium) |
394 | 290 | } |
| 291 | + return page.url() !== urlBefore |
395 | 292 | } |
396 | 293 |
|
397 | 294 | // --------------------------------------------------------------------------- |
|
0 commit comments