| import type * as Playwright from 'playwright' |
| import { diff } from 'jest-diff' |
| import { equals } from '@jest/expect-utils' |
|
|
| type Batch = { |
| pendingRequestChecks: Set<Promise<void>> |
| pendingRequests: Set<PendingRSCRequest> |
| } |
|
|
| type PendingRSCRequest = { |
| url: string |
| route: Playwright.Route | null |
| result: Promise<{ |
| text: string |
| body: any |
| headers: Record<string, string> |
| status: number |
| }> |
| didProcess: boolean |
| } |
|
|
| let currentBatch: Batch | null = null |
|
|
| type ExpectedResponseConfig = { |
| includes: string |
| block?: boolean | 'reject' |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| type ActConfig = |
| | ExpectedResponseConfig |
| | Array<ExpectedResponseConfig> |
| | 'block' |
| | 'no-requests' |
| | null |
|
|
| export function createRouterAct( |
| page: Playwright.Page, |
| options?: { |
| /** |
| * Status codes that are allowed to be returned by the server. If not |
| * provided, all error status codes are disallowed (400+). |
| */ |
| allowErrorStatusCodes?: number[] |
| } |
| ): <T>(scope: () => Promise<T> | T, config?: ActConfig) => Promise<T> { |
| |
| |
| |
| |
| async function waitForIdleCallback(): Promise<void> { |
| const maxRetries = 3 |
| const retryDelayMs = 100 |
|
|
| for (let attempt = 0; attempt < maxRetries; attempt++) { |
| try { |
| await page.evaluate( |
| () => |
| new Promise<void>((res) => |
| requestIdleCallback(() => res(), { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| timeout: 100, |
| }) |
| ) |
| ) |
| return |
| } catch (err) { |
| const isLastAttempt = attempt === maxRetries - 1 |
| const isExecutionContextError = |
| err instanceof Error && |
| err.message.includes('Execution context was destroyed') |
|
|
| if (isExecutionContextError && !isLastAttempt) { |
| await new Promise((resolve) => setTimeout(resolve, retryDelayMs)) |
| continue |
| } |
|
|
| throw err |
| } |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| async function act<T>( |
| scope: () => Promise<T> | T, |
| config?: ActConfig |
| ): Promise<T> { |
| |
| const error = new Error() |
| if (Error.captureStackTrace) { |
| Error.captureStackTrace(error, act) |
| } |
|
|
| let expectedResponses: Array<ExpectedResponseConfig> | null |
| let forbiddenResponses: Array<ExpectedResponseConfig> | null = null |
| let shouldBlockAll = false |
| const allowStatuses = options?.allowErrorStatusCodes ?? null |
|
|
| if (config === undefined || config === null) { |
| |
| expectedResponses = [] |
| } else if (config === 'block') { |
| |
| if (currentBatch === null) { |
| error.message = |
| '`block` option only supported when nested inside an outer ' + |
| '`act` scope.' |
| throw error |
| } |
| expectedResponses = [] |
| shouldBlockAll = true |
| } else if (config === 'no-requests') { |
| |
| expectedResponses = null |
| } else if (!Array.isArray(config)) { |
| |
| if (config.block === true && currentBatch === null) { |
| error.message = |
| '`block: true` option only supported when nested inside an outer ' + |
| '`act` scope.' |
| throw error |
| } |
| if (config.block !== 'reject') { |
| expectedResponses = [config] |
| } else { |
| expectedResponses = [] |
| forbiddenResponses = [config] |
| } |
| } else { |
| expectedResponses = [] |
| for (const item of config) { |
| if (item.block === true && currentBatch === null) { |
| error.message = |
| '`block: true` option only supported when nested inside an outer ' + |
| '`act` scope.' |
| throw error |
| } |
| if (item.block !== 'reject') { |
| expectedResponses.push(item) |
| } else { |
| if (forbiddenResponses === null) { |
| forbiddenResponses = [item] |
| } else { |
| forbiddenResponses.push(item) |
| } |
| } |
| } |
| } |
|
|
| |
| |
| let onDidIssueFirstRequest: (() => void) | null = null |
| const routeHandler = async (route: Playwright.Route) => { |
| const request = route.request() |
|
|
| const pendingRequests = batch.pendingRequests |
| const pendingRequestChecks = batch.pendingRequestChecks |
|
|
| |
| |
| |
| |
| |
| |
| |
| const checkIfRouterRequest = (async () => { |
| const headers = request.headers() |
|
|
| |
| const isRouterRequest = |
| headers['rsc'] !== undefined || |
| headers['next-action'] !== undefined |
|
|
| if (isRouterRequest) { |
| |
| |
| pendingRequests.add({ |
| url: request.url(), |
| route, |
| |
| |
| |
| result: (async () => { |
| const originalResponse = await page.request.fetch(request, { |
| maxRedirects: 0, |
| }) |
|
|
| |
| |
| |
| |
| |
| const headers = originalResponse.headers() |
| delete headers['transfer-encoding'] |
|
|
| return { |
| text: await originalResponse.text(), |
| body: await originalResponse.body(), |
| headers, |
| status: originalResponse.status(), |
| } |
| })(), |
| didProcess: false, |
| }) |
| if (onDidIssueFirstRequest !== null) { |
| onDidIssueFirstRequest() |
| onDidIssueFirstRequest = null |
| } |
| return |
| } |
| |
| |
| route.continue() |
| })() |
|
|
| pendingRequestChecks.add(checkIfRouterRequest) |
| await checkIfRouterRequest |
| |
| pendingRequestChecks.delete(checkIfRouterRequest) |
| } |
|
|
| let didHardNavigate = false |
| const hardNavigationHandler = async () => { |
| |
| |
| |
| |
| const orphanedRequests = batch.pendingRequests |
| batch.pendingRequests = new Set() |
| batch.pendingRequestChecks = new Set() |
| await Promise.all( |
| Array.from(orphanedRequests).map((item) => item.route?.continue()) |
| ) |
| didHardNavigate = true |
| } |
|
|
| const waitForPendingRequestChecks = async () => { |
| const prevChecks = batch.pendingRequestChecks |
| batch.pendingRequestChecks = new Set() |
| await Promise.all(prevChecks) |
| } |
|
|
| const prevBatch = currentBatch |
| const batch: Batch = { |
| pendingRequestChecks: new Set(), |
| pendingRequests: new Set(), |
| } |
| currentBatch = batch |
| await page.route('**/*', routeHandler) |
| page.on('framedetached', hardNavigationHandler) |
| try { |
| |
| const returnValue = await scope() |
|
|
| |
| if (expectedResponses !== null && batch.pendingRequests.size === 0) { |
| await new Promise<void>((resolve, reject) => { |
| const timerId = setTimeout(() => { |
| error.message = 'Timed out waiting for a request to be initiated.' |
| reject(error) |
| }, 500) |
| onDidIssueFirstRequest = () => { |
| clearTimeout(timerId) |
| resolve() |
| } |
| }) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| await waitForIdleCallback() |
|
|
| |
| |
| |
| await waitForPendingRequestChecks() |
|
|
| |
| |
| const remaining = new Set<PendingRSCRequest>() |
| let actualResponses: Array<ExpectedResponseConfig> = [] |
|
|
| let claimedExpectations = new Set<ExpectedResponseConfig>() |
|
|
| |
| let queueEmptyStartTime: number | null = null |
| const SETTLING_PERIOD_MS = 500 |
|
|
| while ( |
| batch.pendingRequests.size > 0 || |
| queueEmptyStartTime === null || |
| Date.now() - queueEmptyStartTime < SETTLING_PERIOD_MS |
| ) { |
| if (batch.pendingRequests.size > 0) { |
| |
| queueEmptyStartTime = null |
| } else if (queueEmptyStartTime === null) { |
| |
| queueEmptyStartTime = Date.now() |
| } |
|
|
| if (batch.pendingRequests.size === 0) { |
| |
| await new Promise((resolve) => setTimeout(resolve, 50)) |
| await waitForIdleCallback() |
| await waitForPendingRequestChecks() |
| continue |
| } |
|
|
| const pending = batch.pendingRequests |
| batch.pendingRequests = new Set() |
| for (const item of pending) { |
| const route = item.route |
| const url = item.url |
|
|
| let shouldBlock = false |
| const fulfilled = await item.result |
| if (item.didProcess) { |
| |
| } else { |
| item.didProcess = true |
| if (expectedResponses === null) { |
| error.message = ` |
| Expected no network requests to be initiated. |
| |
| URL: ${url} |
| Headers: ${JSON.stringify(fulfilled.headers)} |
| |
| Response: |
| ${fulfilled.body} |
| ` |
|
|
| throw error |
| } |
| if ( |
| fulfilled.status >= 400 && |
| (allowStatuses === null || |
| !allowStatuses.includes(fulfilled.status)) |
| ) { |
| error.message = ` |
| Received a response with an error status code. |
| |
| Status: ${fulfilled.status} |
| URL: ${url} |
| Headers: ${JSON.stringify(fulfilled.headers)} |
| |
| Response: |
| ${fulfilled.body} |
| ` |
| throw error |
| } |
| if (forbiddenResponses !== null) { |
| for (const forbiddenResponse of forbiddenResponses) { |
| const includes = forbiddenResponse.includes |
| if (fulfilled.body.includes(includes)) { |
| error.message = ` |
| Received a response containing an unexpected substring: |
| |
| Rejected substring: ${includes} |
| |
| Response: |
| ${fulfilled.body} |
| ` |
| throw error |
| } |
| } |
| } |
| if (expectedResponses !== null) { |
| |
| |
| |
| |
| |
| |
| |
| const entireResponseBody = fulfilled.body |
| let remainingUnclaimedBody = entireResponseBody |
|
|
| |
| |
| |
| |
| |
| let responseWasClaimed = false |
| let firstAlreadyClaimedMatch: ExpectedResponseConfig | null = null |
| for (const expectedResponse of expectedResponses) { |
| const includes = expectedResponse.includes |
| const block = expectedResponse.block |
| if (!claimedExpectations.has(expectedResponse)) { |
| |
| |
| if (remainingUnclaimedBody.includes(includes)) { |
| |
| responseWasClaimed = true |
| |
| |
| remainingUnclaimedBody = remainingUnclaimedBody.slice( |
| remainingUnclaimedBody.indexOf(includes) + includes.length |
| ) |
| claimedExpectations.add(expectedResponse) |
| actualResponses.push(expectedResponse) |
| if (block) { |
| shouldBlock = true |
| } |
| continue |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| if ( |
| firstAlreadyClaimedMatch === null && |
| remainingUnclaimedBody.includes(includes) |
| ) { |
| firstAlreadyClaimedMatch = expectedResponse |
| } |
| } |
|
|
| if (!responseWasClaimed && firstAlreadyClaimedMatch !== null) { |
| |
| |
| |
| |
| |
| error.message = ` |
| The same expected substring was sent multiple times by the server: |
| |
| ${firstAlreadyClaimedMatch.includes} |
| |
| Choose a more specific substring to assert on. |
| ` |
| throw error |
| } |
| } |
| } |
|
|
| if (shouldBlock || shouldBlockAll) { |
| |
| |
| remaining.add(item) |
| if (route === null) { |
| error.message = ` |
| The "block" option is not supported for requests that are redirected. |
| |
| URL: ${url} |
| Headers: ${JSON.stringify(fulfilled.headers)} |
| |
| Response: |
| ${fulfilled.body} |
| ` |
|
|
| throw error |
| } |
| } else { |
| if (route !== null) { |
| const request = route.request() |
| await route.fulfill({ |
| body: fulfilled.body, |
| headers: fulfilled.headers, |
| status: fulfilled.status, |
| }) |
| const browserResponse = await request.response() |
| if (browserResponse !== null) { |
| |
| |
| if (fulfilled.status < 400) { |
| await browserResponse.finished() |
| } |
| } |
| } |
| } |
|
|
| if (fulfilled.status === 307 || fulfilled.status === 308) { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| await new Promise<void>((resolve, reject) => { |
| page.once('request', (req) => { |
| const handleResponse = (res: Playwright.Response) => { |
| if (res.url() === req.url()) { |
| batch.pendingRequests.add({ |
| url: req.url(), |
| route: null, |
| result: (async () => { |
| return { |
| |
| |
| text: await res.text().catch(() => ''), |
| body: await res.body().catch(() => Buffer.from('')), |
| headers: res.headers(), |
| status: res.status(), |
| } |
| })(), |
| didProcess: false, |
| }) |
| page.off('response', handleResponse) |
| page.off('requestfailed', handleFailure) |
| resolve() |
| } |
| } |
| const handleFailure = (failedReq: Playwright.Request) => { |
| if (failedReq.url() === req.url()) { |
| page.off('response', handleResponse) |
| page.off('requestfailed', handleFailure) |
| error.message = `Request failed: ${failedReq.failure()?.errorText || 'Unknown error'}\n\nURL: ${req.url()}` |
| reject(error) |
| } |
| } |
| page.on('response', handleResponse) |
| page.on('requestfailed', handleFailure) |
| }) |
| }) |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| await waitForIdleCallback() |
|
|
| await waitForPendingRequestChecks() |
| } |
|
|
| if (didHardNavigate) { |
| error.message = |
| 'A hard navigation or refresh was triggerd during the `act` scope. ' + |
| 'This is not supported.' |
| throw error |
| } |
|
|
| if (expectedResponses !== null) { |
| |
| if (!equals(actualResponses, expectedResponses)) { |
| |
|
|
| if (expectedResponses.length === 1) { |
| error.message = |
| 'Expected a response containing the given string:\n\n' + |
| expectedResponses[0].includes + |
| '\n' |
| } else { |
| const expectedSubstrings = expectedResponses.map( |
| (item) => item.includes |
| ) |
| const actualSubstrings = actualResponses.map( |
| (item) => item.includes |
| ) |
| error.message = |
| 'Expected sequence of responses does not match:\n\n' + |
| diff(expectedSubstrings, actualSubstrings) + |
| '\n\n' + |
| 'NOTE: Assertions are checked in order, so if an expectation ' + |
| 'is missing, it may have actually appeared earlier in the ' + |
| 'sequence than expected. Make sure the order is correct.' |
| } |
| throw error |
| } |
| } |
|
|
| |
| |
| if (remaining.size !== 0 && prevBatch !== null) { |
| for (const item of remaining) { |
| prevBatch.pendingRequests.add(item) |
| } |
| } |
|
|
| return returnValue |
| } finally { |
| |
| currentBatch = prevBatch |
| await page.unroute('**/*', routeHandler) |
| page.off('framedetached', hardNavigationHandler) |
| } |
| } |
|
|
| return act |
| } |
|
|