Skip to content

Commit 3512a7f

Browse files
Merge pull request #7358 from Shopify/psyw-0421-E2E-teardown-polish
E2E: automatic teardown polish
2 parents 243750a + 0e41053 commit 3512a7f

14 files changed

Lines changed: 460 additions & 257 deletions

packages/e2e/helpers/browser-login.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import {isVisibleWithin} from '../setup/browser.js'
12
import {BROWSER_TIMEOUT} from '../setup/constants.js'
23
import type {Page} from '@playwright/test'
34

@@ -47,12 +48,12 @@ export async function completeLogin(page: Page, loginUrl: string, email: string,
4748
]).catch(() => {})
4849

4950
// If passkey page shown, navigate to password login
50-
if (await differentMethodBtn.isVisible({timeout: BROWSER_TIMEOUT.short}).catch(() => false)) {
51+
if (await isVisibleWithin(differentMethodBtn, BROWSER_TIMEOUT.short)) {
5152
await differentMethodBtn.click()
5253
await page.waitForTimeout(BROWSER_TIMEOUT.short)
5354

5455
const continueWithPassword = page.locator('text=Continue with password')
55-
if (await continueWithPassword.isVisible({timeout: BROWSER_TIMEOUT.medium}).catch(() => false)) {
56+
if (await isVisibleWithin(continueWithPassword, BROWSER_TIMEOUT.medium)) {
5657
await continueWithPassword.click()
5758
await page.waitForTimeout(BROWSER_TIMEOUT.short)
5859
}
@@ -67,7 +68,7 @@ export async function completeLogin(page: Page, loginUrl: string, email: string,
6768
await page.waitForTimeout(BROWSER_TIMEOUT.medium)
6869
try {
6970
const btn = page.locator('button[type="submit"]').first()
70-
if (await btn.isVisible({timeout: BROWSER_TIMEOUT.long})) await btn.click()
71+
if (await isVisibleWithin(btn, BROWSER_TIMEOUT.long)) await btn.click()
7172
// eslint-disable-next-line no-catch-all/no-catch-all
7273
} catch (_error) {
7374
// No confirmation page — expected

packages/e2e/setup/app.ts

Lines changed: 193 additions & 79 deletions
Large diffs are not rendered by default.

packages/e2e/setup/browser.ts

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {cliFixture} from './cli.js'
22
import {BROWSER_TIMEOUT} from './constants.js'
3-
import {chromium, type Page} from '@playwright/test'
3+
import {chromium, type Locator, type Page} from '@playwright/test'
44
import * as fs from 'fs'
55

66
// ---------------------------------------------------------------------------
@@ -11,6 +11,33 @@ export interface BrowserContext {
1111
browserPage: Page
1212
}
1313

14+
// ---------------------------------------------------------------------------
15+
// Main-frame response status tracking
16+
// ---------------------------------------------------------------------------
17+
18+
/**
19+
* Records the HTTP status of the most recent main-frame document response per
20+
* page. Populated by a `response` listener attached when the page is created
21+
* (see fixture below). Read via `getLastPageStatus(page)`.
22+
*
23+
* A WeakMap lets the entry be garbage-collected with the page — no manual
24+
* cleanup required.
25+
*/
26+
const lastMainFrameStatus = new WeakMap<Page, number>()
27+
28+
export function trackMainFrameStatus(page: Page): void {
29+
page.on('response', (response) => {
30+
if (response.frame() !== page.mainFrame()) return
31+
if (response.request().resourceType() !== 'document') return
32+
lastMainFrameStatus.set(page, response.status())
33+
})
34+
}
35+
36+
/** Get the HTTP status of the last main-frame document response on `page`. */
37+
export function getLastPageStatus(page: Page): number | undefined {
38+
return lastMainFrameStatus.get(page)
39+
}
40+
1441
// ---------------------------------------------------------------------------
1542
// Fixture
1643
// ---------------------------------------------------------------------------
@@ -40,6 +67,7 @@ export const browserFixture = cliFixture.extend<{}, {browserPage: Page}>({
4067
context.setDefaultTimeout(BROWSER_TIMEOUT.max)
4168
context.setDefaultNavigationTimeout(BROWSER_TIMEOUT.max)
4269
const page = await context.newPage()
70+
trackMainFrameStatus(page)
4371
await use(page)
4472
await browser.close()
4573
},
@@ -52,13 +80,32 @@ export const browserFixture = cliFixture.extend<{}, {browserPage: Page}>({
5280
// ---------------------------------------------------------------------------
5381

5482
/**
55-
* Check if the current page shows a server error (500, 502). If so, refresh and return true.
56-
* Call this in retry loops when a selector isn't found — the page might be an error page.
83+
* Wait up to `timeoutMs` for `locator` to become visible. Returns `true` if it
84+
* appears in time, `false` on timeout or any other locator error (detached
85+
* element, closed context, etc.).
86+
*
87+
* Use this instead of `locator.isVisible({timeout})` — that API does not
88+
* actually wait in modern Playwright; it returns the current visibility state.
89+
* This helper uses `waitFor({state: 'visible', timeout})` for true polling.
90+
*/
91+
export async function isVisibleWithin(locator: Locator, timeoutMs: number): Promise<boolean> {
92+
return locator
93+
.waitFor({state: 'visible', timeout: timeoutMs})
94+
.then(() => true)
95+
.catch(() => false)
96+
}
97+
98+
/**
99+
* If the most recent main-frame response was a 5xx server error, reload the
100+
* page and return true. Otherwise return false. Call this in retry loops when
101+
* a selector isn't found — the page might be an error page.
102+
*
103+
* Uses the HTTP status captured by `trackMainFrameStatus` rather than scraping
104+
* body text, so it works regardless of how an error page is rendered.
57105
*/
58106
export async function refreshIfPageError(page: Page): Promise<boolean> {
59-
const pageText = (await page.textContent('body')) ?? ''
60-
if (!pageText.includes('Internal Server Error') && !pageText.includes('502 Bad Gateway')) return false
61-
// if (process.env.DEBUG === '1') process.stdout.write(' page refreshing...\n')
107+
const status = getLastPageStatus(page)
108+
if (status === undefined || status < 500) return false
62109
await page.reload({waitUntil: 'domcontentloaded'})
63110
await page.waitForTimeout(BROWSER_TIMEOUT.medium)
64111
return true
@@ -83,7 +130,7 @@ export async function navigateToDashboard(
83130
// Handle account picker (skip if email not provided)
84131
if (ctx.email) {
85132
const accountButton = browserPage.locator(`text=${ctx.email}`).first()
86-
if (await accountButton.isVisible({timeout: BROWSER_TIMEOUT.medium}).catch(() => false)) {
133+
if (await isVisibleWithin(accountButton, BROWSER_TIMEOUT.medium)) {
87134
await accountButton.click()
88135
await browserPage.waitForTimeout(BROWSER_TIMEOUT.medium)
89136
}

packages/e2e/setup/cli.ts

Lines changed: 53 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,19 @@ export interface ExecResult {
1111
exitCode: number
1212
}
1313

14+
export interface WaitForOutputOptions {
15+
timeoutMs?: number
16+
/** Cancel the wait early — frees the timer and removes the waiter entry. */
17+
signal?: AbortSignal
18+
}
19+
1420
export interface SpawnedProcess {
15-
/** Wait for a string to appear in the PTY output */
16-
waitForOutput(text: string, timeoutMs?: number): Promise<void>
21+
/**
22+
* Wait for a string to appear in the PTY output.
23+
* Pass a number for the legacy positional `timeoutMs` form, or an options
24+
* object to also supply an `AbortSignal` for cancellation.
25+
*/
26+
waitForOutput(text: string, opts?: number | WaitForOutputOptions): Promise<void>
1727
/** Send a single key to the PTY */
1828
sendKey(key: string): void
1929
/** Send a line of text followed by Enter */
@@ -132,13 +142,13 @@ export const cliFixture = envFixture.extend<{cli: CLIProcess}>({
132142
process.stdout.write(data)
133143
}
134144

135-
// Check if any waiters are satisfied (check both raw and stripped output)
145+
// Check if any waiters are satisfied (check both raw and stripped
146+
// output). resolve() removes the waiter from outputWaiters internally,
147+
// so we iterate over a snapshot to avoid index shifting during the loop.
136148
const stripped = stripAnsi(output)
137-
for (let idx = outputWaiters.length - 1; idx >= 0; idx--) {
138-
const waiter = outputWaiters[idx]
139-
if (waiter && (stripped.includes(waiter.text) || output.includes(waiter.text))) {
149+
for (const waiter of [...outputWaiters]) {
150+
if (stripped.includes(waiter.text) || output.includes(waiter.text)) {
140151
waiter.resolve()
141-
outputWaiters.splice(idx, 1)
142152
}
143153
}
144154
})
@@ -151,26 +161,39 @@ export const cliFixture = envFixture.extend<{cli: CLIProcess}>({
151161
if (exitResolve) {
152162
exitResolve(code)
153163
}
154-
// Reject any remaining output waiters
155-
for (const waiter of outputWaiters) {
164+
// Reject any remaining output waiters. reject() removes each waiter
165+
// from outputWaiters, so iterate over a snapshot to avoid skipping.
166+
for (const waiter of [...outputWaiters]) {
156167
waiter.reject(new Error(`Process exited (code ${code}) while waiting for output: "${waiter.text}"`))
157168
}
158-
outputWaiters.length = 0
159169
})
160170

161171
const spawned: SpawnedProcess = {
162172
ptyProcess,
163173

164-
waitForOutput(text: string, timeoutMs = CLI_TIMEOUT.medium) {
174+
waitForOutput(text: string, opts: number | WaitForOutputOptions = {}) {
175+
const {timeoutMs = CLI_TIMEOUT.medium, signal} =
176+
typeof opts === 'number' ? {timeoutMs: opts, signal: undefined} : opts
177+
165178
// Check if already in output (raw or stripped)
166179
if (stripAnsi(output).includes(text) || output.includes(text)) {
167180
return Promise.resolve()
168181
}
182+
if (signal?.aborted) {
183+
return Promise.reject(new Error(`Cancelled waiting for output: "${text}"`))
184+
}
169185

170186
return new Promise<void>((resolve, reject) => {
187+
// eslint-disable-next-line prefer-const
188+
let waiter: {text: string; resolve: () => void; reject: (err: Error) => void}
189+
190+
const removeWaiter = () => {
191+
const idx = outputWaiters.indexOf(waiter)
192+
if (idx >= 0) outputWaiters.splice(idx, 1)
193+
}
194+
171195
const timer = setTimeout(() => {
172-
const waiterIdx = outputWaiters.findIndex((waiter) => waiter.text === text)
173-
if (waiterIdx >= 0) outputWaiters.splice(waiterIdx, 1)
196+
removeWaiter()
174197
reject(
175198
new Error(
176199
`Timed out after ${timeoutMs}ms waiting for output: "${text}"\n\nCaptured output:\n${stripAnsi(
@@ -180,17 +203,32 @@ export const cliFixture = envFixture.extend<{cli: CLIProcess}>({
180203
)
181204
}, timeoutMs)
182205

183-
outputWaiters.push({
206+
waiter = {
184207
text,
185208
resolve: () => {
186209
clearTimeout(timer)
210+
removeWaiter()
187211
resolve()
188212
},
189213
reject: (err) => {
190214
clearTimeout(timer)
215+
removeWaiter()
191216
reject(err)
192217
},
193-
})
218+
}
219+
outputWaiters.push(waiter)
220+
221+
if (signal) {
222+
signal.addEventListener(
223+
'abort',
224+
() => {
225+
clearTimeout(timer)
226+
removeWaiter()
227+
reject(new Error(`Cancelled waiting for output: "${text}"`))
228+
},
229+
{once: true},
230+
)
231+
}
194232
})
195233
},
196234

packages/e2e/setup/global-auth.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77

88
/* eslint-disable no-restricted-imports */
9+
import {isVisibleWithin} from './browser.js'
910
import {directories, executables, globalLog} from './env.js'
1011
import {CLI_TIMEOUT, BROWSER_TIMEOUT} from './constants.js'
1112
import {stripAnsi} from '../helpers/strip-ansi.js'
@@ -154,7 +155,7 @@ async function visitAndHandleAccountPicker(page: Page, url: string, email: strin
154155
await page.waitForTimeout(BROWSER_TIMEOUT.medium)
155156
if (isAccountsShopifyUrl(page.url())) {
156157
const accountButton = page.locator(`text=${email}`).first()
157-
if (await accountButton.isVisible({timeout: BROWSER_TIMEOUT.long}).catch(() => false)) {
158+
if (await isVisibleWithin(accountButton, BROWSER_TIMEOUT.long)) {
158159
await accountButton.click()
159160
await page.waitForTimeout(BROWSER_TIMEOUT.medium)
160161
}

0 commit comments

Comments
 (0)