Skip to content

Commit

Permalink
Merge branch 'canary' into remove/esm-loader
Browse files Browse the repository at this point in the history
  • Loading branch information
kodiakhq[bot] committed Sep 18, 2023
2 parents cb4a9a4 + 2edd564 commit 27e13b6
Show file tree
Hide file tree
Showing 4 changed files with 209 additions and 19 deletions.
36 changes: 18 additions & 18 deletions packages/next/src/experimental/testmode/playwright/next-fixture.ts
Expand Up @@ -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)
)
Expand All @@ -37,16 +40,18 @@ class NextFixtureImpl implements NextFixture {
}

private async handleFetch(request: Request): Promise<FetchHandlerResult> {
for (const handler of this.fetchHandlers.slice().reverse()) {
const result = handler(request)
if (result) {
return result
return reportFetch(this.testInfo, request, async (req) => {
for (const handler of this.fetchHandlers.slice().reverse()) {
const result = await 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
})
}
}

Expand All @@ -64,12 +69,7 @@ export async function applyNextFixture(
page: Page
}
): Promise<void> {
const fixture = new NextFixtureImpl(
testInfo.testId,
nextOptions,
nextWorker,
page
)
const fixture = new NextFixtureImpl(testInfo, nextOptions, nextWorker, page)

await use(fixture)

Expand Down
115 changes: 115 additions & 0 deletions packages/next/src/experimental/testmode/playwright/report.ts
@@ -0,0 +1,115 @@
import type { TestInfo } from '@playwright/test'
import type { FetchHandler } from './next-worker-fixture'
import { step } from './step'

async function parseBody(
r: Pick<Request, 'headers' | 'json' | 'text' | 'arrayBuffer' | 'formData'>
): Promise<Record<string, string>> {
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 {
...(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),
}
}

function parseHeaders(headers: Headers): Record<string, string> {
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,
handler: FetchHandler
): Promise<Awaited<ReturnType<FetchHandler>>> {
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())),
...parseHeaders(req.headers),
},
},
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<string, unknown>
if (typeof res === 'string') {
body = { response: res }
} else {
const { status, statusText } = res
body = {
status,
...(statusText ? { statusText } : null),
...(await parseBody(res.clone())),
...parseHeaders(res.headers),
}
}
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
}
)
}
57 changes: 57 additions & 0 deletions 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<string, string | number | boolean | null | undefined>
}

// Access the internal Playwright API until it's exposed publicly.
// See https://github.com/microsoft/playwright/issues/27059.
interface TestInfoWithRunAsStep extends TestInfo {
_runAsStep: <T>(
stepInfo: StepProps,
handler: (result: { complete: Complete }) => Promise<T>
) => Promise<T>
}

type Complete = (result: { error?: any }) => void

function isWithRunAsStep(
testInfo: TestInfo
): testInfo is TestInfoWithRunAsStep {
return '_runAsStep' in testInfo
}

export async function step<T>(
testInfo: TestInfo,
props: StepProps,
handler: (complete: Complete) => Promise<Awaited<T>>
): Promise<Awaited<T>> {
if (isWithRunAsStep(testInfo)) {
return testInfo._runAsStep(props, ({ complete }) => handler(complete))
}

// Fallback to the `test.step()`.
let result: Awaited<T>
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!
}
20 changes: 19 additions & 1 deletion packages/next/src/experimental/testmode/server.ts
Expand Up @@ -20,6 +20,24 @@ type Fetch = typeof fetch
type FetchInputArg = Parameters<Fetch>[0]
type FetchInitArg = Parameters<Fetch>[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
Expand All @@ -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,
Expand Down

0 comments on commit 27e13b6

Please sign in to comment.