From 00435d669e16c47f3d9d7c20df5c377c738569ab Mon Sep 17 00:00:00 2001 From: Scott McGinness Date: Sun, 12 May 2024 22:34:08 +0100 Subject: [PATCH 1/4] misc: Add generic types to net-stubbing for use in intercept and wait --- cli/types/tests/cypress-tests.ts | 2 +- cli/types/tests/net-stubbing-tests.ts | 748 ++++++++++++++++++++ packages/net-stubbing/lib/external-types.ts | 238 ++++--- 3 files changed, 886 insertions(+), 102 deletions(-) create mode 100644 cli/types/tests/net-stubbing-tests.ts diff --git a/cli/types/tests/cypress-tests.ts b/cli/types/tests/cypress-tests.ts index b41822ec2cdb..d4a38fb61983 100644 --- a/cli/types/tests/cypress-tests.ts +++ b/cli/types/tests/cypress-tests.ts @@ -513,7 +513,7 @@ describe('then', () => { cy.wait(['@foo', '@bar']) .then(([first, second]) => { - first // $ExpectType Interception + first // $ExpectType Interception }) cy.wait(1234) // $ExpectType Chainable diff --git a/cli/types/tests/net-stubbing-tests.ts b/cli/types/tests/net-stubbing-tests.ts new file mode 100644 index 000000000000..c04646525a4e --- /dev/null +++ b/cli/types/tests/net-stubbing-tests.ts @@ -0,0 +1,748 @@ +import { + CyHttpMessages, + HttpRequestInterceptor, + HttpResponseInterceptor, + Interception, + Route, + RouteHandler, + RouteHandlerController, + RouteMap, + RouteMatcher, + StringMatcher +} from 'cypress/types/net-stubbing' + +interface CustomRequest { + payload: object +} + +interface CustomResponse { + data: object +} + +describe('net stubbing types', () => { + describe('BaseMessage', () => { + it('has any body by default', () => { + const sut: CyHttpMessages.BaseMessage = undefined! + sut.body // $ExpectType any + }) + + it('has typed body if given', () => { + const sut: CyHttpMessages.BaseMessage = undefined! + sut.body // $ExpectType CustomRequest + }) + }) + + describe('IncomingResponse', () => { + it('has any body by default', () => { + const sut: CyHttpMessages.IncomingResponse = undefined! + sut.body // $ExpectType any + }) + + it('has typed body if given', () => { + const sut: CyHttpMessages.IncomingResponse = undefined! + sut.body // $ExpectType CustomResponse + }) + }) + + describe('IncomingHttpResponse', () => { + it('has any body by default', () => { + const sut: CyHttpMessages.IncomingHttpResponse = undefined! + sut.body // $ExpectType any + }) + + it('has typed body if given', () => { + const sut: CyHttpMessages.IncomingHttpResponse = undefined! + sut.body // $ExpectType CustomResponse + }) + + it('returns the typed body from setDelay', () => { + const sut: CyHttpMessages.IncomingHttpResponse = undefined! + sut.setDelay(0) // $ExpectType IncomingHttpResponse + }) + + it('returns the typed body from setThrottle', () => { + const sut: CyHttpMessages.IncomingHttpResponse = undefined! + sut.setThrottle(0) // $ExpectType IncomingHttpResponse + }) + }) + + describe('IncomingRequest', () => { + it('has any body by default', () => { + const sut: CyHttpMessages.IncomingRequest = undefined! + sut.body // $ExpectType any + }) + + it('has typed body if given', () => { + const sut: CyHttpMessages.IncomingRequest = undefined! + sut.body // $ExpectType CustomRequest + }) + }) + + describe('IncomingHttpRequest', () => { + it('has any body by default', () => { + const sut: CyHttpMessages.IncomingHttpRequest = undefined! + sut.body // $ExpectType any + }) + + it('has typed body if given', () => { + const sut: CyHttpMessages.IncomingHttpRequest = undefined! + sut.body // $ExpectType CustomRequest + }) + + it('accepts a typed interceptor, of the same expected response type, in continue()', () => { + const sut: CyHttpMessages.IncomingHttpRequest = undefined! + const input: HttpResponseInterceptor = undefined! + + sut.continue(input) + }) + + it('accepts a typed interceptor, of the same expected response type, in reply()', () => { + const sut: CyHttpMessages.IncomingHttpRequest = undefined! + const input: HttpResponseInterceptor = undefined! + + sut.reply(input) + }) + }) + + describe('ResponseComplete', () => { + it('has any finalResBody by default', () => { + const sut: CyHttpMessages.ResponseComplete = undefined! + sut.finalResBody // $ExpectType any + }) + + it('has typed finalResBody if given', () => { + const sut: CyHttpMessages.ResponseComplete = undefined! + sut.finalResBody // $ExpectType CustomResponse | undefined + }) + }) + + describe('HttpRequestInterceptor', () => { + it('accepts a request with any req/res body by default', () => { + const sut: HttpRequestInterceptor = undefined! + const request: CyHttpMessages.IncomingHttpRequest = undefined! + + sut(request) + }) + + it('accepts a request with a typed req/res body if given', () => { + const sut: HttpRequestInterceptor = undefined! + const request: CyHttpMessages.IncomingHttpRequest = undefined! + + sut(request) + }) + + it('does not accept a request with a typed req/res body if mismatched', () => { + const sut: HttpRequestInterceptor = undefined! + + // Request and response are flipped, which is incorrect. + const request: CyHttpMessages.IncomingHttpRequest = undefined! + + // @ts-expect-error -- Argument of type ... is not assignable. + sut(request) + }) + }) + + describe('HttpResponseInterceptor', () => { + it('accepts a response with any body by default', () => { + const sut: HttpResponseInterceptor = undefined! + const response: CyHttpMessages.IncomingHttpResponse = undefined! + + sut(response) + }) + + it('accepts a response with a typed body if given', () => { + const sut: HttpResponseInterceptor = undefined! + const response: CyHttpMessages.IncomingHttpResponse = undefined! + + sut(response) + }) + + it('does not accept a response with a typed body if mismatched', () => { + const sut: HttpResponseInterceptor = undefined! + + // Expecting a custom response, but response is just a string. + const response: CyHttpMessages.IncomingHttpResponse = undefined! + + // @ts-expect-error -- Argument of type ... is not assignable. + sut(response) + }) + }) + + describe('RequestEvents (via IncomingHttpRequest)', () => { + it('accepts a response with any body by default, in each on()', () => { + const sut: CyHttpMessages.IncomingHttpRequest = undefined! + const cb: HttpResponseInterceptor = undefined! + const cbAfter: (res: CyHttpMessages.IncomingResponse) => void = undefined! + + sut.on('before:response', cb) // $ExpectType IncomingHttpRequest + sut.on('response', cb) // $ExpectType IncomingHttpRequest + sut.on('after:response', cbAfter) // $ExpectType IncomingHttpRequest + }) + + it('accepts a response with a typed body if given', () => { + const sut: CyHttpMessages.IncomingHttpRequest = undefined! + const cb: HttpResponseInterceptor = undefined! + const cbAfter: (res: CyHttpMessages.IncomingResponse) => void = undefined! + + sut.on('before:response', cb) // $ExpectType IncomingHttpRequest + sut.on('response', cb) // $ExpectType IncomingHttpRequest + sut.on('after:response', cbAfter) // $ExpectType IncomingHttpRequest + }) + + it('does not accept a response with a typed body if given but mismatched', () => { + const sut: CyHttpMessages.IncomingHttpRequest = undefined! + + // Expecting a custom response, but callbacks just have mismatched string responses. + const cb: HttpResponseInterceptor = undefined! + const cbAfter: (res: CyHttpMessages.IncomingResponse) => void = undefined! + + // @ts-expect-error -- Argument of type ... is not assignable. + sut.on('before:response', cb) + + // @ts-expect-error -- Argument of type ... is not assignable. + sut.on('response', cb) + + // @ts-expect-error -- Argument of type ... is not assignable. + sut.on('after:response', cbAfter) + }) + }) + + describe('Interception', () => { + it('has any req/res body by default', () => { + const sut: Interception = undefined! + + sut.request.body // $ExpectType any + sut.response!.body // $ExpectType any + }) + + it('has typed req/res body if given', () => { + const sut: Interception = undefined! + + sut.request.body // $ExpectType CustomRequest + sut.response!.body // $ExpectType CustomResponse + }) + }) + + describe('Route', () => { + it('has a handler typed as any by default', () => { + const sut: Route = undefined! + const request: CyHttpMessages.IncomingHttpRequest = undefined! + + sut.handler // $ExpectType RouteHandler + + if (typeof sut.handler === 'function') { + sut.handler(request) + } + }) + + it('has a typed handler if given', () => { + const sut: Route = undefined! + const request: CyHttpMessages.IncomingHttpRequest = undefined! + + sut.handler // $ExpectType RouteHandler + + if (typeof sut.handler === 'function') { + sut.handler(request) + } + }) + + it('does not accept a typed handler if given but mismatched', () => { + const sut: Route = undefined! + + // Request and response are flipped, which is incorrect. + const request: CyHttpMessages.IncomingHttpRequest = undefined! + + if (typeof sut.handler === 'function') { + // @ts-expect-error -- Argument of type ... is not assignable. + sut.handler(request) + } + }) + + it('contains requests with interceptions of any req/res body by default', () => { + const sut: Route = undefined! + sut.requests['r'] // $ExpectType Interception + }) + + it('contains requests with interceptions of typed req/res body if given', () => { + const sut: Route = undefined! + sut.requests['r'] // $ExpectType Interception + }) + + it('contains requests with interceptions, which do not accept mismatches, of typed req/res body if given', () => { + const sut: Route = undefined! + + // Request and response are flipped, which is incorrect. + const interception: Interception = undefined! + + // @ts-expect-error -- Type ... is not assignable. + sut.requests['r'] = interception + }) + }) + + describe('RouteMap', () => { + it('each item has a handler typed as any by default', () => { + const sut: RouteMap = undefined! + const request: CyHttpMessages.IncomingHttpRequest = undefined! + + sut['r'] // $ExpectType Route + + if (typeof sut['r'].handler === 'function') { + sut['r'].handler(request) + } + }) + + it('each item has a typed handler if given', () => { + const sut: RouteMap = undefined! + const request: CyHttpMessages.IncomingHttpRequest = undefined! + + sut['r'] // $ExpectType Route + + if (typeof sut['r'].handler === 'function') { + sut['r'].handler(request) + } + }) + + it('each item does not accept a typed handler if given but mismatched', () => { + const sut: RouteMap = undefined! + + // Request and response are flipped, which is incorrect. + const request: CyHttpMessages.IncomingHttpRequest = undefined! + + if (typeof sut['r'].handler === 'function') { + // @ts-expect-error -- Argument of type ... is not assignable. + sut['r'].handler(request) + } + }) + + it('each item contains requests with interceptions of any req/res body by default', () => { + const sut: RouteMap = undefined! + + sut['r'] // $ExpectType Route + sut['r'].requests['r'] // $ExpectType Interception + }) + + it('each item contains requests with interceptions of typed req/res body if given', () => { + const sut: RouteMap = undefined! + + sut['r'] // $ExpectType Route + sut['r'].requests['r'] // $ExpectType Interception + }) + + it('each item contains requests with interceptions, which do not accept mismatches, of typed req/res body if given', () => { + const sut: RouteMap = undefined! + + // Request and response are flipped, which is incorrect. + const interception: Interception = undefined! + + // @ts-expect-error -- Type ... is not assignable. + sut['r'].requests['r'] = interception + }) + }) + + describe('RouteHandlerController', () => { + it('accepts a request with any req/res body by default', () => { + const sut: RouteHandlerController = undefined! + const request: CyHttpMessages.IncomingHttpRequest = undefined! + + sut(request) + }) + + it('accepts a request with a typed req/res body if given', () => { + const sut: RouteHandlerController = undefined! + const request: CyHttpMessages.IncomingHttpRequest = undefined! + + sut(request) + }) + + it('does not accept a request with a typed req/res body if mismatched', () => { + const sut: RouteHandlerController = undefined! + + // Request and response are flipped, which is incorrect. + const request: CyHttpMessages.IncomingHttpRequest = undefined! + + // @ts-expect-error -- Argument of type ... is not assignable. + sut(request) + }) + }) + + describe('RouteHandler', () => { + it('accepts a request with any req/res body by default', () => { + const sut: RouteHandler = () => { } + const request: CyHttpMessages.IncomingHttpRequest = undefined! + + if (typeof sut === 'function') { + sut(request) + } + }) + + it('accepts a request with a typed req/res body if given', () => { + const sut: RouteHandler = () => { } + const request: CyHttpMessages.IncomingHttpRequest = undefined! + + if (typeof sut === 'function') { + sut(request) + } + }) + + it('does not accept a request with a typed req/res body if mismatched', () => { + const sut: RouteHandler = () => { } + + // Request and response are flipped, which is incorrect. + const request: CyHttpMessages.IncomingHttpRequest = undefined! + + if (typeof sut === 'function') { + // @ts-expect-error -- Argument of type ... is not assignable. + sut(request) + } + }) + }) + + describe('cy.intercept', () => { + describe('for a route matcher URL', () => { + it('accepts any req/res body as response handler by default', () => { + const sut: Cypress.Chainable = undefined! + const url: RouteMatcher = undefined! + + sut.intercept(url, (req) => { + req.body // $ExpectType any + + req.continue((res) => { + res.body // $ExpectType any + }) + }) + }) + + it('accepts typed req/res body as response handler if given', () => { + const sut: Cypress.Chainable = undefined! + const url: RouteMatcher = undefined! + + sut.intercept(url, (req) => { + req.body // $ExpectType CustomRequest + + req.continue((res) => { + res.body // $ExpectType CustomResponse + }) + }) + }) + + it('infers types for req/res body if given', () => { + const sut: Cypress.Chainable = undefined! + const url: RouteMatcher = undefined! + + type Req = CyHttpMessages.IncomingHttpRequest + + sut.intercept(url, (req: Req) => { + req.body // $ExpectType CustomRequest + + req.continue((res) => { + res.body // $ExpectType CustomResponse + }) + }) + }) + }) + + describe('for a method-restricted route matcher URL', () => { + it('accepts any req/res body as response handler by default', () => { + const sut: Cypress.Chainable = undefined! + const url: RouteMatcher = undefined! + + sut.intercept('POST', url, (req) => { + req.body // $ExpectType any + + req.continue((res) => { + res.body // $ExpectType any + }) + }) + }) + + it('accepts typed req/res body as response handler if given', () => { + const sut: Cypress.Chainable = undefined! + const url: RouteMatcher = undefined! + + sut.intercept('POST', url, (req) => { + req.body // $ExpectType CustomRequest + + req.continue((res) => { + res.body // $ExpectType CustomResponse + }) + }) + }) + + it('infers types for req/res body if given', () => { + const sut: Cypress.Chainable = undefined! + const url: RouteMatcher = undefined! + + type Req = CyHttpMessages.IncomingHttpRequest + + sut.intercept('POST', url, (req: Req) => { + req.body // $ExpectType CustomRequest + + req.continue((res) => { + res.body // $ExpectType CustomResponse + }) + }) + }) + }) + + describe('for a string matcher URL', () => { + it('accepts any req/res body as response handler by default', () => { + const sut: Cypress.Chainable = undefined! + const url: StringMatcher = undefined! + + sut.intercept(url, { middleware: true }, (req) => { + req.body // $ExpectType any + + req.continue((res) => { + res.body // $ExpectType any + }) + }) + }) + + it('accepts typed req/res body as response handler if given', () => { + const sut: Cypress.Chainable = undefined! + const url: StringMatcher = undefined! + + sut.intercept(url, { middleware: true }, (req) => { + req.body // $ExpectType CustomRequest + + req.continue((res) => { + res.body // $ExpectType CustomResponse + }) + }) + }) + + it('infers types for req/res body if given', () => { + const sut: Cypress.Chainable = undefined! + const url: StringMatcher = undefined! + + type Req = CyHttpMessages.IncomingHttpRequest + + sut.intercept(url, { middleware: true }, (req: Req) => { + req.body // $ExpectType CustomRequest + + req.continue((res) => { + res.body // $ExpectType CustomResponse + }) + }) + }) + }) + }) + + describe('cy.wait', () => { + describe('with a single alias', () => { + it('accepts any req/res body as response handler by default', () => { + const cy: Cypress.Chainable = undefined! + + cy.wait('@a').then(({ request, response }) => { + request.body // $ExpectType any + response!.body // $ExpectType any + }) + }) + + it('accepts typed req/res body as response handler if given', () => { + const cy: Cypress.Chainable = undefined! + + cy.wait('@a').then(({ request, response }) => { + request.body // $ExpectType CustomRequest + response!.body // $ExpectType CustomResponse + }) + }) + + it('infers types for req/res body if given', () => { + const cy: Cypress.Chainable = undefined! + + cy.wait('@a').then(({ request, response }: Interception) => { + request.body // $ExpectType CustomRequest + response!.body // $ExpectType CustomResponse + }) + }) + }) + + describe('with an array of aliases', () => { + interface AReq { a: CustomRequest } + interface BReq { b: CustomRequest } + interface ARes { a: CustomResponse } + interface BRes { b: CustomResponse } + + it('accepts any req/res body as response handler by default', () => { + const cy: Cypress.Chainable = undefined! + + cy.wait([]).then((interceptions) => { + interceptions // $ExpectType Interception[] + }) + + cy.wait(['@a']).then((interceptions) => { + interceptions.forEach(({ request, response }) => { + request.body // $ExpectType any + response!.body // $ExpectType any + }) + }) + + cy.wait(['@a', '@b']).then((interceptions) => { + interceptions.forEach(({ request, response }) => { + request.body // $ExpectType any + response!.body // $ExpectType any + }) + }) + }) + + it('accepts any req/res body, with const aliases inferring the number of interceptions, as response handler by default', () => { + const cy: Cypress.Chainable = undefined! + + cy.wait([] as const).then((interceptions) => { + interceptions // $ExpectType [] + }) + + cy.wait(['@a'] as const).then((interceptions) => { + interceptions.forEach(({ request, response }) => { + request.body // $ExpectType any + response!.body // $ExpectType any + }) + }) + + cy.wait(['@a', '@b'] as const).then((interceptions) => { + interceptions.forEach(({ request, response }) => { + request.body // $ExpectType any + response!.body // $ExpectType any + }) + }) + }) + + it('accepts typed req/res body as response handler if given', () => { + const cy: Cypress.Chainable = undefined! + + cy.wait<[[AReq, ARes]]>(['@a']).then((interceptions) => { + interceptions[0] // $ExpectType Interception + interceptions[1] // $ExpectType Interception + + interceptions.forEach(({ request, response }) => { + request.body // $ExpectType any + response!.body // $ExpectType any + }) + }) + + cy.wait<[[AReq, ARes], [BReq, BRes]]>(['@a', '@b']).then((interceptions) => { + interceptions[0] // $ExpectType Interception + interceptions[1] // $ExpectType Interception + interceptions[2] // $ExpectType Interception + + interceptions.forEach(({ request, response }) => { + request.body // $ExpectType any + response!.body // $ExpectType any + }) + }) + }) + + it('accepts typed req/res body, with const aliases, as response handler if given', () => { + const cy: Cypress.Chainable = undefined! + + cy.wait<[[AReq, ARes]]>(['@a'] as const).then((interceptions) => { + interceptions[0] // $ExpectType Interception + interceptions[1] // $ExpectType Interception + + interceptions.forEach(({ request, response }) => { + request.body // $ExpectType any + if (response) { + response.body // $ExpectType any + } + }) + }) + + cy.wait<[[AReq, ARes], [BReq, BRes]]>(['@a', '@b'] as const).then((interceptions) => { + interceptions[0] // $ExpectType Interception + interceptions[1] // $ExpectType Interception + interceptions[2] // $ExpectType Interception + + interceptions.forEach(({ request, response }) => { + request.body // $ExpectType any + if (response) { + response.body // $ExpectType any + } + }) + }) + }) + + it('accepts typed req/res body, with const aliases included in the type, as response handler if given', () => { + const cy: Cypress.Chainable = undefined! + + const aliases1 = ['@a'] as const + cy.wait<[[AReq, ARes]], typeof aliases1>(aliases1).then((interceptions) => { + interceptions[0] // $ExpectType Interception + + // @ts-expect-error -- Interceptions only has 1 element. + interceptions[1] + + interceptions.forEach(({ request, response }) => { + request.body // $ExpectType AReq + response!.body // $ExpectType ARes + }) + }) + + const aliases2 = ['@a', '@b'] as const + cy.wait<[[AReq, ARes], [BReq, BRes]], typeof aliases2>(aliases2).then((interceptions) => { + interceptions[0] // $ExpectType Interception + interceptions[1] // $ExpectType Interception + + // @ts-expect-error -- Interceptions only has 2 elements. + interceptions[2] + + interceptions.forEach(({ request, response }) => { + request.body // $ExpectType AReq | BReq + response!.body // $ExpectType ARes | BRes + }) + }) + }) + + it('infers types for req/res body if given', () => { + const cy: Cypress.Chainable = undefined! + + cy.wait(['@a']).then((interceptions: Array>) => { + interceptions.forEach(({ request, response }) => { + request.body // $ExpectType AReq + response!.body // $ExpectType ARes + }) + }) + + cy.wait(['@a', '@b']).then((interceptions: Array>) => { + interceptions.forEach(({ request, response }) => { + request.body // $ExpectType AReq | BReq + response!.body // $ExpectType ARes | BRes + }) + }) + }) + + it('infers types for req/res body, with const aliases inferring the number of possible interceptions, if given', () => { + const cy: Cypress.Chainable = undefined! + + cy.wait(['@a'] as const).then((interceptions: [Interception]) => { + interceptions.forEach(({ request, response }) => { + request.body // $ExpectType AReq + response!.body // $ExpectType ARes + }) + }) + + cy.wait(['@a', '@b'] as const).then((interceptions: [Interception, Interception]) => { + interceptions.forEach(({ request, response }) => { + request.body // $ExpectType AReq | BReq + response!.body // $ExpectType ARes | BRes + }) + }) + + cy.wait(['@a', '@b'] as const).then((interceptions: [Interception, Interception]) => { + interceptions.forEach(({ request, response }) => { + request.body // $ExpectType any + response!.body // $ExpectType any + }) + }) + + // @ts-expect-error -- not enough elements in interceptions tuple. + cy.wait(['@a', '@b'] as const).then((interceptions: [Interception]) => { + }) + + // @ts-expect-error -- too many elements in interceptions tuple. + cy.wait(['@a', '@b'] as const).then((interceptions: [Interception, Interception, Interception]) => { + }) + }) + }) + }) +}) diff --git a/packages/net-stubbing/lib/external-types.ts b/packages/net-stubbing/lib/external-types.ts index 1d013e555d23..8ee1a764cd00 100644 --- a/packages/net-stubbing/lib/external-types.ts +++ b/packages/net-stubbing/lib/external-types.ts @@ -1,91 +1,91 @@ // Copied from https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/methods/index.d.ts type Method = - | 'ACL' - | 'BIND' - | 'CHECKOUT' - | 'CONNECT' - | 'COPY' - | 'DELETE' - | 'GET' - | 'HEAD' - | 'LINK' - | 'LOCK' - | 'M-SEARCH' - | 'MERGE' - | 'MKACTIVITY' - | 'MKCALENDAR' - | 'MKCOL' - | 'MOVE' - | 'NOTIFY' - | 'OPTIONS' - | 'PATCH' - | 'POST' - | 'PROPFIND' - | 'PROPPATCH' - | 'PURGE' - | 'PUT' - | 'REBIND' - | 'REPORT' - | 'SEARCH' - | 'SOURCE' - | 'SUBSCRIBE' - | 'TRACE' - | 'UNBIND' - | 'UNLINK' - | 'UNLOCK' - | 'UNSUBSCRIBE' - | 'acl' - | 'bind' - | 'checkout' - | 'connect' - | 'copy' - | 'delete' - | 'get' - | 'head' - | 'link' - | 'lock' - | 'm-search' - | 'merge' - | 'mkactivity' - | 'mkcalendar' - | 'mkcol' - | 'move' - | 'notify' - | 'options' - | 'patch' - | 'post' - | 'propfind' - | 'proppatch' - | 'purge' - | 'put' - | 'rebind' - | 'report' - | 'search' - | 'source' - | 'subscribe' - | 'trace' - | 'unbind' - | 'unlink' - | 'unlock' - | 'unsubscribe' + | 'ACL' + | 'BIND' + | 'CHECKOUT' + | 'CONNECT' + | 'COPY' + | 'DELETE' + | 'GET' + | 'HEAD' + | 'LINK' + | 'LOCK' + | 'M-SEARCH' + | 'MERGE' + | 'MKACTIVITY' + | 'MKCALENDAR' + | 'MKCOL' + | 'MOVE' + | 'NOTIFY' + | 'OPTIONS' + | 'PATCH' + | 'POST' + | 'PROPFIND' + | 'PROPPATCH' + | 'PURGE' + | 'PUT' + | 'REBIND' + | 'REPORT' + | 'SEARCH' + | 'SOURCE' + | 'SUBSCRIBE' + | 'TRACE' + | 'UNBIND' + | 'UNLINK' + | 'UNLOCK' + | 'UNSUBSCRIBE' + | 'acl' + | 'bind' + | 'checkout' + | 'connect' + | 'copy' + | 'delete' + | 'get' + | 'head' + | 'link' + | 'lock' + | 'm-search' + | 'merge' + | 'mkactivity' + | 'mkcalendar' + | 'mkcol' + | 'move' + | 'notify' + | 'options' + | 'patch' + | 'post' + | 'propfind' + | 'proppatch' + | 'purge' + | 'put' + | 'rebind' + | 'report' + | 'search' + | 'source' + | 'subscribe' + | 'trace' + | 'unbind' + | 'unlink' + | 'unlock' + | 'unsubscribe' export type ResourceType = 'document' | 'fetch' | 'xhr' | 'websocket' | 'stylesheet' | 'script' | 'image' | 'font' | 'cspviolationreport' | 'ping' | 'manifest' | 'other' export namespace CyHttpMessages { - export interface BaseMessage { + export interface BaseMessage { /** * The body of the HTTP message. * If a JSON Content-Type was used and the body was valid JSON, this will be an object. * If the body was binary content, this will be a buffer. */ - body: any + body: T /** * The headers of the HTTP message. */ headers: { [key: string]: string | string[] } } - export type IncomingResponse = BaseMessage & { + export type IncomingResponse = BaseMessage & { /** * The HTTP status code of the response. */ @@ -104,7 +104,7 @@ export namespace CyHttpMessages { delay?: number } - export type IncomingHttpResponse = IncomingResponse & { + export type IncomingHttpResponse = IncomingResponse & { /** * Continue the HTTP response, merging the supplied values with the real response. */ @@ -118,14 +118,14 @@ export namespace CyHttpMessages { /** * Wait for `delay` milliseconds before sending the response to the client. */ - setDelay: (delay: number) => IncomingHttpResponse + setDelay: (delay: number) => IncomingHttpResponse /** * Serve the response at `throttleKbps` kilobytes per second. */ - setThrottle: (throttleKbps: number) => IncomingHttpResponse + setThrottle: (throttleKbps: number) => IncomingHttpResponse } - export type IncomingRequest = BaseMessage & { + export type IncomingRequest = BaseMessage & { /** * Request HTTP method (GET, POST, ...). */ @@ -137,7 +137,7 @@ export namespace CyHttpMessages { /** * URL query string as object. */ - query: Record + query: Record /** * The HTTP version used in the request. Read only. */ @@ -163,7 +163,7 @@ export namespace CyHttpMessages { alias?: string } - export interface IncomingHttpRequest extends IncomingRequest, RequestEvents { + export interface IncomingHttpRequest extends IncomingRequest, RequestEvents { /** * Destroy the request and respond with a network error. */ @@ -173,7 +173,7 @@ export namespace CyHttpMessages { * If a function is passed, the request will be sent outgoing, and the function will be called * with the response from the upstream server. */ - continue(interceptor?: HttpResponseInterceptor): void + continue(interceptor?: HttpResponseInterceptor): void /** * Control the response to this request. * If a function is passed, the request will be sent outgoing, and the function will be called @@ -181,7 +181,7 @@ export namespace CyHttpMessages { * If a `StaticResponse` is passed, it will be used as the response, and no request will be made * to the upstream server. */ - reply(interceptor?: StaticResponse | HttpResponseInterceptor): void + reply(interceptor?: StaticResponse | HttpResponseInterceptor): void /** * Shortcut to reply to the request with a body and optional headers. */ @@ -197,8 +197,8 @@ export namespace CyHttpMessages { redirect(location: string, statusCode?: number): void } - export interface ResponseComplete { - finalResBody?: BaseMessage['body'] + export interface ResponseComplete { + finalResBody?: BaseMessage['body'] } export interface NetworkError { @@ -220,14 +220,14 @@ export type GlobPattern = string * request to the next handler (if there is one), otherwise the request will be passed to the next * handler synchronously. */ -export type HttpRequestInterceptor = (req: CyHttpMessages.IncomingHttpRequest) => void | Promise +export type HttpRequestInterceptor = (req: CyHttpMessages.IncomingHttpRequest) => void | Promise /** * Interceptor for an HTTP response. If a Promise is returned, it will be awaited before passing the * request to the next handler (if there is one), otherwise the request will be passed to the next * handler synchronously. */ -export type HttpResponseInterceptor = (res: CyHttpMessages.IncomingHttpResponse) => void | Promise +export type HttpResponseInterceptor = (res: CyHttpMessages.IncomingHttpResponse) => void | Promise /** * Matches a single number or any of an array of acceptable numbers. @@ -248,44 +248,44 @@ export interface Subscription { skip?: boolean } -interface RequestEvents { +interface RequestEvents { /** * Emitted before `response` and before any `req.continue` handlers. * Modifications to `res` will be applied to the incoming response. * If a promise is returned from `cb`, it will be awaited before processing other event handlers. */ - on(eventName: 'before:response', cb: HttpResponseInterceptor): this + on(eventName: 'before:response', cb: HttpResponseInterceptor): this /** * Emitted after `before:response` and after any `req.continue` handlers - before the response is sent to the browser. * Modifications to `res` will be applied to the incoming response. * If a promise is returned from `cb`, it will be awaited before processing other event handlers. */ - on(eventName: 'response', cb: HttpResponseInterceptor): this + on(eventName: 'response', cb: HttpResponseInterceptor): this /** * Emitted once the response to a request has finished sending to the browser. * Modifications to `res` have no impact. * If a promise is returned from `cb`, it will be awaited before processing other event handlers. */ - on(eventName: 'after:response', cb: (res: CyHttpMessages.IncomingResponse) => void | Promise): this + on(eventName: 'after:response', cb: (res: CyHttpMessages.IncomingResponse) => void | Promise): this } /** * Request/response cycle. */ -export interface Interception { +export interface Interception { id: string /* @internal */ browserRequestId?: string routeId: string /* @internal */ setLogFlag: (flag: 'spied' | 'stubbed' | 'reqModified' | 'resModified') => void - request: CyHttpMessages.IncomingRequest + request: CyHttpMessages.IncomingRequest /** * Was `cy.wait()` used to wait on this request? * @internal */ requestWaited: boolean - response?: CyHttpMessages.IncomingResponse + response?: CyHttpMessages.IncomingResponse /** * The error that occurred during this request. */ @@ -312,17 +312,17 @@ export type InterceptionState = 'Complete' | 'Errored' -export interface Route { +export interface Route { alias?: string log: any options: RouteMatcherOptions - handler: RouteHandler + handler: RouteHandler hitCount: number - requests: { [key: string]: Interception } + requests: { [key: string]: Interception } command: any } -export interface RouteMap { [key: string]: Route } +export interface RouteMap { [key: string]: Route } /** * A `RouteMatcher` describes a filter for HTTP requests. @@ -393,9 +393,9 @@ export interface RouteMatcherOptionsGeneric { url?: S } -export type RouteHandlerController = HttpRequestInterceptor +export type RouteHandlerController = HttpRequestInterceptor -export type RouteHandler = string | StaticResponseWithOptions | RouteHandlerController | object +export type RouteHandler = string | StaticResponseWithOptions | RouteHandlerController | object export type InterceptOptions = { /** @@ -488,6 +488,42 @@ interface WaitOptions { timeout: number } +/** + * Maps the array of `[Req, Res]` tuples to an array or tuple of `Interception`s. + * This is used below in the `wait` method that takes an array of aliases. + * We try to work out the number of aliases and use that for the length of the `Interception`s tuple. + * Otherwise return a normal array. + */ +type MapToInterceptions = A extends [] + ? [] // Empty aliases always implies empty interceptions. + : (A extends readonly [string, ...readonly string[]] + ? MapToInterceptionsFromConstAliases + : MapToInterceptionsFromArrayAliases); + +type MapToInterceptionsFromConstAliases = A extends [] + ? [] // Empty aliases always implies empty interceptions. + : (A extends readonly [string, ...infer Aliases] + ? (T extends [[infer Req, infer Res], ...infer Rest] + + // Can infer first position, then recurse. Pass tail of aliases array *and* tail of types. + ? [Interception, ...MapToInterceptionsFromConstAliases] + + // Cannot infer types in first position. Pass tail of aliases array. + // Pass full types because length cannot be inferred. + : [Interception, ...MapToInterceptionsFromConstAliases]) + : []) + +type MapToInterceptionsFromArrayAliases = + (T extends [[infer Req, infer Res], ...infer Rest] + // Can infer first position, then recurse. Pass tail of types. + // Pass full aliases because length cannot be inferred. + ? [Interception, ...MapToInterceptionsFromArrayAliases] + + // Alias list is now empty or we couldn't infer how long it was. + // If empty and inferrable, we are at the end of recursion, so return definitely empty. + // Otherwise we couldn't infer anything about the types, so we only know that it's a list. + : Interception[]) + declare global { namespace Cypress { // TODO: Why is Subject unused? @@ -510,7 +546,7 @@ declare global { * }) * }) */ - intercept(url: RouteMatcher, response?: RouteHandler): Chainable + intercept(url: RouteMatcher, response?: RouteHandler): Chainable /** * Use `cy.intercept()` to stub and intercept HTTP requests and responses. * @@ -518,7 +554,7 @@ declare global { * @example * cy.intercept('GET', 'http://foo.com/fruits', ['apple', 'banana', 'cherry']) */ - intercept(method: Method, url: RouteMatcher, response?: RouteHandler): Chainable + intercept(method: Method, url: RouteMatcher, response?: RouteHandler): Chainable /** * Use `cy.intercept()` to stub and intercept HTTP requests and responses. * @@ -529,7 +565,7 @@ declare global { * * @param mergeRouteMatcher Additional route matcher options to merge with `url`. Typically used for middleware. */ - intercept(url: StringMatcher, mergeRouteMatcher: Omit, response: RouteHandler): Chainable + intercept(url: StringMatcher, mergeRouteMatcher: Omit, response: RouteHandler): Chainable /** * Wait for a specific request to complete. * @@ -548,7 +584,7 @@ declare global { }) ``` */ - wait(alias: string, options?: Partial): Chainable + wait(alias: string, options?: Partial): Chainable> /** * Wait for list of requests to complete. * @@ -568,7 +604,7 @@ declare global { }) ``` */ - wait(alias: string[], options?: Partial): Chainable + wait(aliases: A, options?: Partial): Chainable> } } } From a6a6524d144319dfacf526d1d57321f9c2077715 Mon Sep 17 00:00:00 2001 From: Scott McGinness Date: Mon, 13 May 2024 19:54:49 +0100 Subject: [PATCH 2/4] misc: Add changelog entry (#29507) --- cli/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index a1f73e110524..c7899099c1ac 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -7,6 +7,10 @@ _Released 5/21/2024 (PENDING)_ - Fixed an issue where Cypress was unable to search in the Specs list for files or folders containing numbers. Fixes [#29034](https://github.com/cypress-io/cypress/issues/29034). +**Misc:** + +- Enhanced the type definitions available to `cy.intercept` and `cy.wait`. The `body` property of both the request and response in an interception can optionally be specified with user-defined types. Addresses [#29507](https://github.com/cypress-io/cypress/issues/29507). + **Dependency Updates:** - Updated js-cookie from `2.2.1` to `3.0.5`. Addressed in [#29497](https://github.com/cypress-io/cypress/pull/29497). From 5dd4c79c655103e81b843da5fbc4d6b19c9e1b33 Mon Sep 17 00:00:00 2001 From: Scott McGinness Date: Mon, 13 May 2024 22:17:14 +0100 Subject: [PATCH 3/4] misc: Revert to `any` for Interception type of multiple waits (#29507) The nested types were apparently not supported (as written) before TypeScript ~4.1.Remove them in favour of just using `Interception` in this overload of `wait`. --- cli/types/tests/net-stubbing-tests.ts | 140 -------------------- packages/net-stubbing/lib/external-types.ts | 38 +----- 2 files changed, 1 insertion(+), 177 deletions(-) diff --git a/cli/types/tests/net-stubbing-tests.ts b/cli/types/tests/net-stubbing-tests.ts index c04646525a4e..0af4230846c9 100644 --- a/cli/types/tests/net-stubbing-tests.ts +++ b/cli/types/tests/net-stubbing-tests.ts @@ -586,113 +586,6 @@ describe('net stubbing types', () => { }) }) - it('accepts any req/res body, with const aliases inferring the number of interceptions, as response handler by default', () => { - const cy: Cypress.Chainable = undefined! - - cy.wait([] as const).then((interceptions) => { - interceptions // $ExpectType [] - }) - - cy.wait(['@a'] as const).then((interceptions) => { - interceptions.forEach(({ request, response }) => { - request.body // $ExpectType any - response!.body // $ExpectType any - }) - }) - - cy.wait(['@a', '@b'] as const).then((interceptions) => { - interceptions.forEach(({ request, response }) => { - request.body // $ExpectType any - response!.body // $ExpectType any - }) - }) - }) - - it('accepts typed req/res body as response handler if given', () => { - const cy: Cypress.Chainable = undefined! - - cy.wait<[[AReq, ARes]]>(['@a']).then((interceptions) => { - interceptions[0] // $ExpectType Interception - interceptions[1] // $ExpectType Interception - - interceptions.forEach(({ request, response }) => { - request.body // $ExpectType any - response!.body // $ExpectType any - }) - }) - - cy.wait<[[AReq, ARes], [BReq, BRes]]>(['@a', '@b']).then((interceptions) => { - interceptions[0] // $ExpectType Interception - interceptions[1] // $ExpectType Interception - interceptions[2] // $ExpectType Interception - - interceptions.forEach(({ request, response }) => { - request.body // $ExpectType any - response!.body // $ExpectType any - }) - }) - }) - - it('accepts typed req/res body, with const aliases, as response handler if given', () => { - const cy: Cypress.Chainable = undefined! - - cy.wait<[[AReq, ARes]]>(['@a'] as const).then((interceptions) => { - interceptions[0] // $ExpectType Interception - interceptions[1] // $ExpectType Interception - - interceptions.forEach(({ request, response }) => { - request.body // $ExpectType any - if (response) { - response.body // $ExpectType any - } - }) - }) - - cy.wait<[[AReq, ARes], [BReq, BRes]]>(['@a', '@b'] as const).then((interceptions) => { - interceptions[0] // $ExpectType Interception - interceptions[1] // $ExpectType Interception - interceptions[2] // $ExpectType Interception - - interceptions.forEach(({ request, response }) => { - request.body // $ExpectType any - if (response) { - response.body // $ExpectType any - } - }) - }) - }) - - it('accepts typed req/res body, with const aliases included in the type, as response handler if given', () => { - const cy: Cypress.Chainable = undefined! - - const aliases1 = ['@a'] as const - cy.wait<[[AReq, ARes]], typeof aliases1>(aliases1).then((interceptions) => { - interceptions[0] // $ExpectType Interception - - // @ts-expect-error -- Interceptions only has 1 element. - interceptions[1] - - interceptions.forEach(({ request, response }) => { - request.body // $ExpectType AReq - response!.body // $ExpectType ARes - }) - }) - - const aliases2 = ['@a', '@b'] as const - cy.wait<[[AReq, ARes], [BReq, BRes]], typeof aliases2>(aliases2).then((interceptions) => { - interceptions[0] // $ExpectType Interception - interceptions[1] // $ExpectType Interception - - // @ts-expect-error -- Interceptions only has 2 elements. - interceptions[2] - - interceptions.forEach(({ request, response }) => { - request.body // $ExpectType AReq | BReq - response!.body // $ExpectType ARes | BRes - }) - }) - }) - it('infers types for req/res body if given', () => { const cy: Cypress.Chainable = undefined! @@ -710,39 +603,6 @@ describe('net stubbing types', () => { }) }) }) - - it('infers types for req/res body, with const aliases inferring the number of possible interceptions, if given', () => { - const cy: Cypress.Chainable = undefined! - - cy.wait(['@a'] as const).then((interceptions: [Interception]) => { - interceptions.forEach(({ request, response }) => { - request.body // $ExpectType AReq - response!.body // $ExpectType ARes - }) - }) - - cy.wait(['@a', '@b'] as const).then((interceptions: [Interception, Interception]) => { - interceptions.forEach(({ request, response }) => { - request.body // $ExpectType AReq | BReq - response!.body // $ExpectType ARes | BRes - }) - }) - - cy.wait(['@a', '@b'] as const).then((interceptions: [Interception, Interception]) => { - interceptions.forEach(({ request, response }) => { - request.body // $ExpectType any - response!.body // $ExpectType any - }) - }) - - // @ts-expect-error -- not enough elements in interceptions tuple. - cy.wait(['@a', '@b'] as const).then((interceptions: [Interception]) => { - }) - - // @ts-expect-error -- too many elements in interceptions tuple. - cy.wait(['@a', '@b'] as const).then((interceptions: [Interception, Interception, Interception]) => { - }) - }) }) }) }) diff --git a/packages/net-stubbing/lib/external-types.ts b/packages/net-stubbing/lib/external-types.ts index 8ee1a764cd00..464546d73b3f 100644 --- a/packages/net-stubbing/lib/external-types.ts +++ b/packages/net-stubbing/lib/external-types.ts @@ -488,42 +488,6 @@ interface WaitOptions { timeout: number } -/** - * Maps the array of `[Req, Res]` tuples to an array or tuple of `Interception`s. - * This is used below in the `wait` method that takes an array of aliases. - * We try to work out the number of aliases and use that for the length of the `Interception`s tuple. - * Otherwise return a normal array. - */ -type MapToInterceptions = A extends [] - ? [] // Empty aliases always implies empty interceptions. - : (A extends readonly [string, ...readonly string[]] - ? MapToInterceptionsFromConstAliases - : MapToInterceptionsFromArrayAliases); - -type MapToInterceptionsFromConstAliases = A extends [] - ? [] // Empty aliases always implies empty interceptions. - : (A extends readonly [string, ...infer Aliases] - ? (T extends [[infer Req, infer Res], ...infer Rest] - - // Can infer first position, then recurse. Pass tail of aliases array *and* tail of types. - ? [Interception, ...MapToInterceptionsFromConstAliases] - - // Cannot infer types in first position. Pass tail of aliases array. - // Pass full types because length cannot be inferred. - : [Interception, ...MapToInterceptionsFromConstAliases]) - : []) - -type MapToInterceptionsFromArrayAliases = - (T extends [[infer Req, infer Res], ...infer Rest] - // Can infer first position, then recurse. Pass tail of types. - // Pass full aliases because length cannot be inferred. - ? [Interception, ...MapToInterceptionsFromArrayAliases] - - // Alias list is now empty or we couldn't infer how long it was. - // If empty and inferrable, we are at the end of recursion, so return definitely empty. - // Otherwise we couldn't infer anything about the types, so we only know that it's a list. - : Interception[]) - declare global { namespace Cypress { // TODO: Why is Subject unused? @@ -604,7 +568,7 @@ declare global { }) ``` */ - wait(aliases: A, options?: Partial): Chainable> + wait(aliases: string[], options?: Partial): Chainable } } } From eaf3cdfd48c5a72d7191341b88d949565ebe2115 Mon Sep 17 00:00:00 2001 From: Scott McGinness Date: Sat, 25 May 2024 12:28:18 +0100 Subject: [PATCH 4/4] misc: Move changelog entry (#29507) --- cli/CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 96656accc18e..201d03c2cf31 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -7,6 +7,10 @@ _Released 5/28/2024 (PENDING)_ - Pre-emptively fix behavior with Chrome for when `unload` events are forcefully deprecated by using `pagehide` as a proxy. Fixes [#29241](https://github.com/cypress-io/cypress/issues/29241). +**Misc:** + +- Enhanced the type definitions available to `cy.intercept` and `cy.wait`. The `body` property of both the request and response in an interception can optionally be specified with user-defined types. Addresses [#29507](https://github.com/cypress-io/cypress/issues/29507). + ## 13.10.0 _Released 5/21/2024_ @@ -23,10 +27,6 @@ _Released 5/21/2024_ - Fixed an issue setting the `x-cypress-file-path` header when there are invalid header characters in the file path. Fixes [#25839](https://github.com/cypress-io/cypress/issues/25839). - Fixed the display of some command assertions. Fixed in [#29517](https://github.com/cypress-io/cypress/pull/29517). -**Misc:** - -- Enhanced the type definitions available to `cy.intercept` and `cy.wait`. The `body` property of both the request and response in an interception can optionally be specified with user-defined types. Addresses [#29507](https://github.com/cypress-io/cypress/issues/29507). - **Dependency Updates:** - Updated js-cookie from `2.2.1` to `3.0.5`. Addressed in [#29497](https://github.com/cypress-io/cypress/pull/29497).