From 9d54439e7c0812c4bfc700d2caf5f0f5ea085b98 Mon Sep 17 00:00:00 2001 From: dvoytenko Date: Fri, 15 Sep 2023 12:15:54 -0700 Subject: [PATCH 1/3] Test Mode: report onFetch interceptions in the test --- .../testmode/playwright/next-fixture.ts | 36 +++--- .../testmode/playwright/report.ts | 104 ++++++++++++++++++ .../experimental/testmode/playwright/step.ts | 57 ++++++++++ 3 files changed, 179 insertions(+), 18 deletions(-) create mode 100644 packages/next/src/experimental/testmode/playwright/report.ts create mode 100644 packages/next/src/experimental/testmode/playwright/step.ts diff --git a/packages/next/src/experimental/testmode/playwright/next-fixture.ts b/packages/next/src/experimental/testmode/playwright/next-fixture.ts index ca68f233ed646..4cb59931c699f 100644 --- a/packages/next/src/experimental/testmode/playwright/next-fixture.ts +++ b/packages/next/src/experimental/testmode/playwright/next-fixture.ts @@ -3,26 +3,29 @@ import type { NextWorkerFixture, FetchHandler } from './next-worker-fixture' import type { NextOptions } from './next-options' import type { FetchHandlerResult } from '../proxy' import { handleRoute } from './page-route' +import { reportFetch } from './report' export interface NextFixture { onFetch: (handler: FetchHandler) => void } class NextFixtureImpl implements NextFixture { + public readonly testId: string private fetchHandlers: FetchHandler[] = [] constructor( - public testId: string, + private testInfo: TestInfo, private options: NextOptions, private worker: NextWorkerFixture, private page: Page ) { + this.testId = testInfo.testId const testHeaders = { 'Next-Test-Proxy-Port': String(worker.proxyPort), - 'Next-Test-Data': testId, + 'Next-Test-Data': this.testId, } const handleFetch = this.handleFetch.bind(this) - worker.onFetch(testId, handleFetch) + worker.onFetch(this.testId, handleFetch) this.page.route('**', (route) => handleRoute(route, page, testHeaders, handleFetch) ) @@ -37,16 +40,18 @@ class NextFixtureImpl implements NextFixture { } private async handleFetch(request: Request): Promise { - for (const handler of this.fetchHandlers.slice().reverse()) { - const result = handler(request) - if (result) { - return result + return reportFetch(this.testInfo, request, (req) => { + for (const handler of this.fetchHandlers.slice().reverse()) { + const result = handler(req.clone()) + if (result) { + return result + } } - } - if (this.options.fetchLoopback) { - return fetch(request) - } - return undefined + if (this.options.fetchLoopback) { + return fetch(req.clone()) + } + return undefined + }) } } @@ -64,12 +69,7 @@ export async function applyNextFixture( page: Page } ): Promise { - const fixture = new NextFixtureImpl( - testInfo.testId, - nextOptions, - nextWorker, - page - ) + const fixture = new NextFixtureImpl(testInfo, nextOptions, nextWorker, page) await use(fixture) diff --git a/packages/next/src/experimental/testmode/playwright/report.ts b/packages/next/src/experimental/testmode/playwright/report.ts new file mode 100644 index 0000000000000..6b1e1cf6a2538 --- /dev/null +++ b/packages/next/src/experimental/testmode/playwright/report.ts @@ -0,0 +1,104 @@ +import type { TestInfo } from '@playwright/test' +import type { FetchHandler } from './next-worker-fixture' +import { step } from './step' + +async function parseBody( + r: Pick +): Promise> { + const contentType = r.headers.get('content-type') + let error: string | undefined + let text: string | undefined + let json: unknown + let formData: FormData | undefined + let buffer: ArrayBuffer | undefined + if (contentType?.includes('text')) { + try { + text = await r.text() + } catch (e) { + error = 'failed to parse text' + } + } else if (contentType?.includes('json')) { + try { + json = await r.json() + } catch (e) { + error = 'failed to parse json' + } + } else if (contentType?.includes('form-data')) { + try { + formData = await r.formData() + } catch (e) { + error = 'failed to parse formData' + } + } else { + try { + buffer = await r.arrayBuffer() + } catch (e) { + error = 'failed to parse arrayBuffer' + } + } + return { + ...(contentType ? { contentType } : null), + ...(error ? { error } : null), + ...(text ? { text } : null), + ...(json ? { json: JSON.stringify(json) } : null), + ...(formData ? { formData: JSON.stringify(Array.from(formData)) } : null), + ...(buffer && buffer.byteLength > 0 + ? { buffer: `base64;${Buffer.from(buffer).toString('base64')}` } + : null), + } +} + +export async function reportFetch( + testInfo: TestInfo, + req: Request, + handler: FetchHandler +): Promise>> { + return step( + testInfo, + { + title: `next.onFetch: ${req.method} ${req.url}`, + category: 'next.onFetch', + apiName: 'next.onFetch', + params: { + method: req.method, + url: req.url, + ...(await parseBody(req.clone())), + }, + }, + async (complete) => { + const res = await handler(req) + if (res === undefined || res == null) { + complete({ error: { message: 'unhandled' } }) + } else if (typeof res === 'string' && res !== 'continue') { + complete({ error: { message: res } }) + } else { + let body: Record + if (typeof res === 'string') { + body = { response: res } + } else { + const { status, statusText } = res + body = { + status, + ...(statusText ? { statusText } : null), + ...(await parseBody(res.clone())), + } + } + await step( + testInfo, + { + title: `next.onFetch.fulfilled: ${req.method} ${req.url}`, + category: 'next.onFetch', + apiName: 'next.onFetch.fulfilled', + params: { + ...body, + 'request.url': req.url, + 'request.method': req.method, + }, + }, + async () => undefined + ).catch(() => undefined) + } + return res + } + ) +} diff --git a/packages/next/src/experimental/testmode/playwright/step.ts b/packages/next/src/experimental/testmode/playwright/step.ts new file mode 100644 index 0000000000000..3a25386ec5178 --- /dev/null +++ b/packages/next/src/experimental/testmode/playwright/step.ts @@ -0,0 +1,57 @@ +import type { TestInfo } from '@playwright/test' +// eslint-disable-next-line import/no-extraneous-dependencies +import { test } from '@playwright/test' + +export interface StepProps { + category: string + title: string + apiName?: string + params?: Record +} + +// Access the internal Playwright API until it's exposed publicly. +// See https://github.com/microsoft/playwright/issues/27059. +interface TestInfoWithRunAsStep extends TestInfo { + _runAsStep: ( + stepInfo: StepProps, + handler: (result: { complete: Complete }) => Promise + ) => Promise +} + +type Complete = (result: { error?: any }) => void + +function isWithRunAsStep( + testInfo: TestInfo +): testInfo is TestInfoWithRunAsStep { + return '_runAsStep' in testInfo +} + +export async function step( + testInfo: TestInfo, + props: StepProps, + handler: (complete: Complete) => Promise> +): Promise> { + if (isWithRunAsStep(testInfo)) { + return testInfo._runAsStep(props, ({ complete }) => handler(complete)) + } + + // Fallback to the `test.step()`. + let result: Awaited + let reportedError: any + try { + console.log(props.title, props) + await test.step(props.title, async () => { + result = await handler(({ error }) => { + reportedError = error + if (reportedError) { + throw reportedError + } + }) + }) + } catch (error) { + if (error !== reportedError) { + throw error + } + } + return result! +} From 250450a876ffd5d47a8a78b30aa8edfc067bb852 Mon Sep 17 00:00:00 2001 From: dvoytenko Date: Fri, 15 Sep 2023 16:40:23 -0700 Subject: [PATCH 2/3] await the handler --- .../next/src/experimental/testmode/playwright/next-fixture.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/next/src/experimental/testmode/playwright/next-fixture.ts b/packages/next/src/experimental/testmode/playwright/next-fixture.ts index 4cb59931c699f..2426e2e15167a 100644 --- a/packages/next/src/experimental/testmode/playwright/next-fixture.ts +++ b/packages/next/src/experimental/testmode/playwright/next-fixture.ts @@ -40,9 +40,9 @@ class NextFixtureImpl implements NextFixture { } private async handleFetch(request: Request): Promise { - return reportFetch(this.testInfo, request, (req) => { + return reportFetch(this.testInfo, request, async (req) => { for (const handler of this.fetchHandlers.slice().reverse()) { - const result = handler(req.clone()) + const result = await handler(req.clone()) if (result) { return result } From e6fa0e6af461d95b1ff4ce8adc4869805220edcb Mon Sep 17 00:00:00 2001 From: dvoytenko Date: Sun, 17 Sep 2023 13:59:07 -0700 Subject: [PATCH 3/3] Report request/response headers; add request stack frame --- .../testmode/playwright/report.ts | 15 ++++++++++++-- .../next/src/experimental/testmode/server.ts | 20 ++++++++++++++++++- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/next/src/experimental/testmode/playwright/report.ts b/packages/next/src/experimental/testmode/playwright/report.ts index 6b1e1cf6a2538..4cbe9a4305696 100644 --- a/packages/next/src/experimental/testmode/playwright/report.ts +++ b/packages/next/src/experimental/testmode/playwright/report.ts @@ -4,7 +4,7 @@ import { step } from './step' async function parseBody( r: Pick -): Promise> { +): Promise> { const contentType = r.headers.get('content-type') let error: string | undefined let text: string | undefined @@ -37,7 +37,6 @@ async function parseBody( } } return { - ...(contentType ? { contentType } : null), ...(error ? { error } : null), ...(text ? { text } : null), ...(json ? { json: JSON.stringify(json) } : null), @@ -48,6 +47,16 @@ async function parseBody( } } +function parseHeaders(headers: Headers): Record { + return Object.fromEntries( + Array.from(headers) + .sort(([key1], [key2]) => key1.localeCompare(key2)) + .map(([key, value]) => { + return [`header.${key}`, value] + }) + ) +} + export async function reportFetch( testInfo: TestInfo, req: Request, @@ -63,6 +72,7 @@ export async function reportFetch( method: req.method, url: req.url, ...(await parseBody(req.clone())), + ...parseHeaders(req.headers), }, }, async (complete) => { @@ -81,6 +91,7 @@ export async function reportFetch( status, ...(statusText ? { statusText } : null), ...(await parseBody(res.clone())), + ...parseHeaders(res.headers), } } await step( diff --git a/packages/next/src/experimental/testmode/server.ts b/packages/next/src/experimental/testmode/server.ts index ff889a5b80f15..6ae34bdf2fac1 100644 --- a/packages/next/src/experimental/testmode/server.ts +++ b/packages/next/src/experimental/testmode/server.ts @@ -20,6 +20,24 @@ type Fetch = typeof fetch type FetchInputArg = Parameters[0] type FetchInitArg = Parameters[1] +function getTestStack(): string { + let stack = (new Error().stack ?? '').split('\n') + // Skip the first line and find first non-empty line. + for (let i = 1; i < stack.length; i++) { + if (stack[i].length > 0) { + stack = stack.slice(i) + break + } + } + // Filter out franmework lines. + stack = stack.filter((f) => !f.includes('/next/dist/')) + // At most 5 lines. + stack = stack.slice(0, 5) + // Cleanup some internal info and trim. + stack = stack.map((s) => s.replace('webpack-internal:///(rsc)/', '').trim()) + return stack.join(' ') +} + async function buildProxyRequest( testData: string, request: Request @@ -43,7 +61,7 @@ async function buildProxyRequest( request: { url, method, - headers: Array.from(headers), + headers: [...Array.from(headers), ['next-test-stack', getTestStack()]], body: body ? Buffer.from(await request.arrayBuffer()).toString('base64') : null,