Conversation
This stack of pull requests is managed by Graphite. Learn more about stacking. |
47a7f22 to
1c6838f
Compare
There was a problem hiding this comment.
Pull request overview
Adds a standalone E2E utility script to bulk-discover and clean up leftover dev stores created by failing/aborted E2E runs, using Playwright browser automation against the Dev Dashboard + Store Admin.
Changes:
- Introduces
packages/e2e/scripts/cleanup-stores.tswith--list,--delete, and default “full” cleanup modes. - Implements store discovery via Dev Dashboard pagination and regex extraction of store FQDNs from page HTML.
- Automates app uninstall (with pagination) and store deletion flows with retries and safety checks (empty-state confirmation).
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const isDirectRun = process.argv[1] === fileURLToPath(import.meta.url) | ||
| if (isDirectRun) { | ||
| main().catch((err) => { | ||
| console.error('[cleanup-stores] Fatal error:', err) | ||
| process.exitCode = 1 | ||
| }) |
There was a problem hiding this comment.
isDirectRun compares process.argv[1] to fileURLToPath(import.meta.url) using strict string equality. When running via npx tsx packages/e2e/scripts/cleanup-stores.ts, process.argv[1] is typically a relative path, so this check can be false and main() won’t run at all. Consider normalizing both sides (e.g., path.resolve(process.argv[1])) or comparing URLs via pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url.
| /** | ||
| * Delete a store via the admin settings plan page. | ||
| * Caller must ensure all apps are already uninstalled. | ||
| * Retries the full flow if the page redirects to store home instead of access_account. | ||
| */ | ||
| async function deleteStore(page: Page, storeSlug: string): Promise<void> { | ||
| const planUrl = `https://admin.shopify.com/store/${storeSlug}/settings/plan` | ||
|
|
||
| for (let attempt = 1; attempt <= 3; attempt++) { | ||
| try { | ||
| // Step 1: Navigate to plan page (wait for full hydration before clicking) |
There was a problem hiding this comment.
This script defines a local deleteStore() helper that largely overlaps with the existing deleteStore browser helper in packages/e2e/setup/store.ts. Having two different implementations with the same name risks divergence and makes future maintenance/debugging harder. Consider reusing the shared helper (and extending it if needed) or renaming this one to avoid confusion (e.g., deleteStoreViaPlanPage).
1c6838f to
73d2cae
Compare
73d2cae to
de46b8d
Compare
b2d3b01 to
3a6b042
Compare
3a6b042 to
5a5c2f7
Compare
d5b7839 to
54c3837
Compare
54c3837 to
1f9585b
Compare
5a5c2f7 to
60d2a44
Compare
60d2a44 to
38c34a9
Compare
1f9585b to
65a37b0
Compare
38c34a9 to
425707b
Compare
|
|
||
| // Check for pagination — if there's a next page, navigate to it | ||
| const nextBtn = page.locator('button#nextURL') | ||
| if (!(await nextBtn.isVisible({timeout: BROWSER_TIMEOUT.short}).catch(() => false))) break |
There was a problem hiding this comment.
locator.isVisible({timeout}) is treated as a wait, so slow UI is misclassified as absent
The script uses locator.isVisible({timeout: ...}) as though it polls until visibility, but modern Playwright does not wait there. This repository already added isVisibleWithin() for that exact reason.
That pattern appears in multiple places around account selection, pagination, app menus, uninstall options, and confirmation buttons. Because these checks return the current state immediately, slightly slow admin pages can be treated as if the controls do not exist.
Reachable consequences include missing the account picker before store discovery, stopping pagination early in countInstalledApps(), and skipping app uninstall actions in uninstallAllAppsFromStore(). The impact is incomplete or inaccurate cleanup rather than a hard crash, which still makes --list unreliable and can require repeated cleanup runs.
React with 👍/👎 — all feedback helps improve the agent.
| ]) | ||
|
|
||
| // Check empty state after page has settled | ||
| if (await isStoreAppsEmpty(page)) return 0 |
There was a problem hiding this comment.
Swallowing both branches of the apps-page race can falsely treat an unloaded page as empty
Both "wait for the apps page to settle" blocks wrap each branch of a Promise.race() in .catch(() => {}), so the code continues even when neither the empty state nor the first app row ever becomes visible.
After that, the script immediately calls isStoreAppsEmpty(). In packages/e2e/setup/store.ts, that helper falls back to treating the absence of More actions buttons as proof the store has no apps. On a page that never finished loading, redirected to login, or is showing an error/loading shell, that fallback becomes a false positive.
As a result, --list can report 0 apps for a store whose apps page never loaded, --delete can treat a non-empty store as safe to delete, and full cleanup can skip uninstall work entirely because the page was mistaken for an empty state. Shopify may still block deletion later, but this breaks the script's safety assumption that only a positively confirmed empty state proves zero apps.
React with 👍/👎 — all feedback helps improve the agent.

WHY are these changes introduced?
E2E tests create dev stores that can accumulate when tests fail mid-run, CI times out, or teardown fails. This script automates bulk-clean for leftover stores.
WHAT is this pull request doing?
cleanup-stores.tsStandalone cleanup script that finds leftover E2E dev stores, uninstalls their apps, and deletes them via browser automation.
Logic
Discovery phase:
completeLoginhelperdev.shopify.com/dashboard/{orgId}/storesrefreshIfPageError#stores-tbody trto render, then scroll-to-bottom in a loop until row count stabilizes (lazy-loaded table)e2e-w), deduplicate viaSet--listmode (per store):admin.shopify.com/store/{slug}/settings/appsPromise.race— either empty state or first app menu buttonbutton#nextURL)Default mode (per store):
admin.shopify.com/store/{slug}/settings/appsPromise.race— either empty state or first app menu buttondeleteStore()(fromsetup/store.ts) with up to 3 retries--deletemode: same as default but skips stores that have apps installed (step 4 → skip if not empty).Uninstall logic (
uninstallAllAppsFromStore):isStoreAppsEmpty— if true, done (primary termination)button[aria-label="More actions"]at positionconsecutiveSkipsdiv[role="listitem"]→a spanconsecutiveSkips, try next buttonconsecutiveSkips(prevents infinite loop)button#nextURLpagination → continue on next pageFeatures:
Promise.racebefore checking empty state (avoids false negatives from slow renders)cleanupStores()for use from other scriptsHow is this different from per-test teardown?
setup/teardown.ts) — knows the specific app name and store FQDN, uses direct URLs, no discovery. Runs automatically in testfinallyblocks.cleanup-stores.ts(bulk, manual) — discovers all matching stores via scroll-based lazy loading. Safety net for orphaned stores from failed/interrupted test runs.How to test your changes?
pnpm --filter e2e exec tsx scripts/cleanup-stores.ts --listpnpm --filter e2e exec tsx scripts/cleanup-stores.ts --headedExample:
pnpm --filter e2e exec tsx scripts/cleanup-stores.ts --headedcleanup-stores.mov
Expand for complete log
Post-release steps
Checklist
patchfor bug fixes ·minorfor new features ·majorfor breaking changes) and added a changeset withpnpm changeset add