Skip to content

Commit 06dc881

Browse files
Playwright test failures (#581)
Co-authored-by: me <[email protected]> Co-authored-by: Cursor Agent <[email protected]>
1 parent 06418ff commit 06dc881

File tree

3 files changed

+62
-29
lines changed

3 files changed

+62
-29
lines changed

app/utils/user-info.server.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,19 @@ type UserInfo = {
2020
} | null
2121
}
2222

23-
function abortTimeoutSignal(timeMs: number) {
24-
const abortController = new AbortController()
25-
void new Promise((resolve) => setTimeout(resolve, timeMs)).then(() => {
26-
abortController.abort()
23+
// Note: We intentionally do NOT use AbortSignal or AbortController here.
24+
// Node.js v24 has a bug where aborting fetch requests causes a crash:
25+
// "uv__stream_destroy: Assertion `!uv__io_active...` failed"
26+
// Instead, we use Promise.race to implement timeouts without aborting.
27+
async function fetchWithTimeout(
28+
url: string,
29+
options: RequestInit,
30+
timeoutMs: number,
31+
): Promise<Response> {
32+
const timeoutPromise = new Promise<never>((_, reject) => {
33+
setTimeout(() => reject(new Error('Request timeout')), timeoutMs)
2734
})
28-
return abortController.signal
35+
return Promise.race([fetch(url, options), timeoutPromise])
2936
}
3037

3138
export async function gravatarExistsForEmail({
@@ -51,12 +58,12 @@ export async function gravatarExistsForEmail({
5158
getFreshValue: async (context) => {
5259
const gravatarUrl = getAvatar(email, { fallback: '404' })
5360
try {
54-
const avatarResponse = await fetch(gravatarUrl, {
55-
method: 'HEAD',
56-
signal: abortTimeoutSignal(
57-
context.background || forceFresh ? 1000 * 10 : 100,
58-
),
59-
})
61+
const timeoutMs = context.background || forceFresh ? 1000 * 10 : 100
62+
const avatarResponse = await fetchWithTimeout(
63+
gravatarUrl,
64+
{ method: 'HEAD' },
65+
timeoutMs,
66+
)
6067
if (avatarResponse.status === 200) {
6168
context.metadata.ttl = 1000 * 60 * 60 * 24 * 365
6269
return true

e2e/calls.spec.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,15 @@ test('Call Kent recording flow', async ({ page, login }) => {
4545
.type(faker.lorem.words(3).split(' ').join(','))
4646
await mainContent.getByRole('button', { name: /submit/i }).click()
4747

48+
// Wait for the redirect to confirm the call was created
49+
await expect(page).toHaveURL(/.*calls\/record\/[a-z0-9-]+/, { timeout: 10_000 })
50+
4851
await login({ role: 'ADMIN' })
4952
await page.goto('/calls/admin')
5053

51-
await page.getByRole('link', { name: new RegExp(title, 'i') }).click()
54+
const callLink = page.getByRole('link', { name: new RegExp(title, 'i') })
55+
await expect(callLink).toBeVisible({ timeout: 10_000 })
56+
await callLink.click()
5257

5358
await page.getByRole('button', { name: /start/i }).click()
5459
await page.waitForTimeout(500) // let the sample.wav file play for a bit more
@@ -65,7 +70,11 @@ test('Call Kent recording flow', async ({ page, login }) => {
6570
.getByRole('heading', { level: 2, name: /calls with kent/i }),
6671
).toBeVisible({ timeout: 10_000 })
6772

68-
const email = await readEmail((em) => em.to.includes(user.email))
73+
// Email sending is async and may take time to be written to the mock fixture
74+
const email = await readEmail((em) => em.to.includes(user.email), {
75+
maxRetries: 10,
76+
retryDelay: 500,
77+
})
6978
invariant(email, 'Notification email not found')
7079
expect(email.subject).toMatch(/published/i)
7180
// NOTE: domain is hard coded for image generation and stuff

e2e/utils.ts

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import {
88
PrismaClient,
99
type User,
1010
} from '#app/utils/prisma-generated.server/client.ts'
11-
import '../app/entry.server.tsx'
1211
import { getSession } from '../app/utils/session.server.ts'
1312
import { createUser } from '../prisma/seed-utils.ts'
1413

@@ -24,26 +23,44 @@ type Email = {
2423
html: string
2524
}
2625

26+
async function sleep(ms: number) {
27+
return new Promise((resolve) => setTimeout(resolve, ms))
28+
}
29+
2730
export async function readEmail(
2831
recipientOrFilter: string | ((email: Email) => boolean),
32+
{ maxRetries = 5, retryDelay = 200 }: { maxRetries?: number; retryDelay?: number } = {},
2933
) {
30-
try {
31-
const mswOutput = fsExtra.readJsonSync(
32-
path.join(process.cwd(), './mocks/msw.local.json'),
33-
) as unknown as MSWData
34-
const emails = Object.values(mswOutput.email).reverse() // reverse so we get the most recent email first
35-
// TODO: add validation
36-
if (typeof recipientOrFilter === 'string') {
37-
return emails.find(
38-
(email: Email) => email.to === recipientOrFilter,
39-
) as Email | null
40-
} else {
41-
return emails.find(recipientOrFilter) as Email | null
34+
for (let attempt = 0; attempt < maxRetries; attempt++) {
35+
try {
36+
const mswOutput = fsExtra.readJsonSync(
37+
path.join(process.cwd(), './mocks/msw.local.json'),
38+
) as unknown as MSWData
39+
const emails = Object.values(mswOutput.email).reverse() // reverse so we get the most recent email first
40+
// TODO: add validation
41+
let email: Email | undefined
42+
if (typeof recipientOrFilter === 'string') {
43+
email = emails.find(
44+
(email: Email) => email.to === recipientOrFilter,
45+
)
46+
} else {
47+
email = emails.find(recipientOrFilter)
48+
}
49+
if (email) {
50+
return email
51+
}
52+
// Email not found yet, retry after a delay
53+
if (attempt < maxRetries - 1) {
54+
await sleep(retryDelay)
55+
}
56+
} catch (error: unknown) {
57+
console.error(`Error reading the email fixture (attempt ${attempt + 1})`, error)
58+
if (attempt < maxRetries - 1) {
59+
await sleep(retryDelay)
60+
}
4261
}
43-
} catch (error: unknown) {
44-
console.error(`Error reading the email fixture`, error)
45-
return null
4662
}
63+
return null
4764
}
4865

4966
export function extractUrl(text: string) {

0 commit comments

Comments
 (0)