Skip to content

Instantly share code, notes, and snippets.

@brian-mann
Last active August 6, 2020 18:56
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save brian-mann/96800d5e85bbc85f2b7050432bb4fe1e to your computer and use it in GitHub Desktop.
Save brian-mann/96800d5e85bbc85f2b7050432bb4fe1e to your computer and use it in GitHub Desktop.
network layer ideas

Use Cases

Network Needs

  • do not log / hide requests that match a certain URL, criteria, etc
  • gain access to the request in order to...
    • assert on the request URL
    • assert on the request headers
    • assert on the request body
    • modify the request URL?
    • modify the request headers (add / remove headers)
    • modify the request body
  • control the response in order to…
    • delay / simulate delay
    • pause the response until a condition is met
    • pass through the real ‘origins’ response
    • modify the real origins response
    • control the response entirely at the cypress proxy layer
    • match a response with a particular file or fixture
  • repeat the same things above but also for websockets / server sent events
    • wait on specific frame requests
    • act as the server for sending frame responses
    • modify origin server frame responses
  • programmatically access…
    • some particular requests / all requests
    • some particular responses / responses
    • routes to dynamically add or remove them
  • all things like graphql to be easy to stub / modify / accommodate
  • block / throttle requests made that match a certain criteria… domain, host, etc

Cypress Needs

  • wait in cypress for the request to be made
  • wait in cypress for the response to finish
  • flush all paused responses
  • create routes on a per test basis
  • reset routes before each test
  • coordinate routes + fixtures + aliases + commands

Visual Needs

  • Understand which requests have been made
  • When responses have been finished
  • Which requests match things like aliases
  • Which routes have been entered
  • Why a request did or didn’t match a route
  • The URL, status, response, etc
  • When requests pass / fail
  • Errors in callback handling per request

Power Features

  • Record / export requests as fixtures
  • Route / code generation for requests

Discussions

  • What to do with existing blacklistHosts?
  • Whether to mimic / expose a similar API for cy.visit()
  • How exactly to implement the next() pattern
// global defaults for all network traffic
Cypress.Network.defaults({
  hide: '...',
  ignore: '...',
})

// alias or fixture
cy.route('/login', '@someAlias')
cy.route('/login', 'fixture:some/user.json')

// default is now to match all method names
cy.route('/login', { some: 'response' })
cy.route('ALL', '/login', { some: 'response' })

// all of these are equivalent to modify the server response
cy.route('POST', '/login', { some: 'response' })
cy.route('POST', '/login', (req) => {
  req.reply(200)
  
  req.reply(200, { some: 'response' })
  
  req.reply(200, { some: 'response' }, { 'x-new-headers': 'from-server' })
  
  req.reply('fixture:some/user.json')
  
  req.reply({
    some: 'response'
  })
  
  req.reply({
    status: 200,
    body: {
      some: 'response'
    },
    headers: {
      'x-new-headers': 'from-server'
    }
  })

  // (passthrough) not intercept the response
  // (terminate) intercept and act as origin
  // (passthrough + modify)
  
  // use a function here to access the
  // response that was passed through
  // from the origin server...
  req.reply((res) => {
    // res.body
    // res.status
    // res.headers
    
    res.status = 200
    res.headers['x-new-headers'] = 'from-server'
    res.body = {
      some: 'response'
    }
    
    // dynamically alter the body
    res.body = res.body.replace('window.top', 'window.self')
  })
  
  // redirect the request
  req.redirect(...)
})

// we can also modify the original request
cy.route('/login', (req) => {
  req.headers = {...} // or req.setHeader(...)
  req.body = {...} // or req.setBody(...)
  
  // delay could take two numbers or { client: X, server: Y } values
  req.delay(100)
  req.delay(500, 1000)
  req.delay({
    client: 100, 
    server: 200,
  })
  req.throttle('3G')
})

//cy.route(['@blacklistGoogle'])

// can blacklist still...
cy.route('*.google.com', (req) => {
  req.reply(503, '').as('blacklistGoogle')
})

cy.route('ALL', '*.google.com', {
  status: 503,
  headers: {},
  body: '',
})

// ...MIDDLEWARE PATTERN...
// what will happen when one or more routes are matched?
// what is the order of preference? 
// do routes you define later have more priority?
// does the first one match?

route('*', (req, next)) // match any route
route('/foo', (req)) // is hit

route('/foo', (req))
route('/foo', (req)) // is never hit

// if we don't yield a second argument cypress will know to
// terminate the response here in this handler? probably, yes

route('localhost', (req, next) => {
  req.headers = { }
  
  if (req.body...) {
    doSomething()
  }
  
  return next() // require a return or ?
}).as('l1')

route('localhost', (req, next) => {
  next()
  next() // what to do?
})
.as('l2')

// route all things based on general request type
route({ type: 'javascript' })
route({ type: 'json' })
route({ type: 'html' })
route({ type: 'image' })
route({ contentType: 'application/script' }, (req, next) => {
  req.reply('fixture:pdfs/foo.pdf')
  
  // possibly capable of implmenting but might
  // not be performant at all
  req.pipe((chunk, enc, cb) => {
    
  })
  
  req.reply((res) => {
    res.body.replace('window.top', 'window.self')
    
    // indicate we want to proceed to the next handler...?
    // there's likely no way we can or should do this...
    next(res)
  })
})

// route('*.google.com', (req, next) => {
//   req
//   .filter(...) // could this automatically call next?
//   .modify((url, body, headers) => {

//   })
//   .reply(200, ...)
// })

route('*', (req, next) => {
  next() // what if there are none others?
})

// whenever a request matches a route we should visually indicate it
// and let the user know how many routes it matched and why (?)
// possibly also add an area that lets them test why the route didnt
// match the defined routes

// can route based on strings, regex, or globs
// that match the URL, or URI parsed objects
cy.route('/foo', (req) => {})
cy.route('/bar?baz=quux', (req) => {})
cy.route('http//:localhost:8080/do/the/thing', (req) => {})
cy.route('http//:localhost:8080/**/foo.json', (req) => {})
cy.route('*.github.com/oauth', (req) => {})
cy.route('github.com/login', (req) => {})
cy.route({
  url: '*',
  onRequest: (req) => {
    
  }
})
cy.route({ protocol: 'https' }, (req) => {})
cy.route({ host: 'localhost', query: { foo: 'bar' } }, (req) => {})
cy.route({ pathname: '/'}, (req) => {
  // THESE ARE FILTERING EXPERIMENTS BUT
  // YOU CAN LARGELY IGNORE, THEY AREN'T NECESSARY
  
  if (req.body) {
    // bad because we don't know why or what you did..  
  }
  
  // better because we know why something didn't match
  req
  .filter(someExpression === 'something...')
  .set({
    
  })
  .
  .filter((url, body, headers) => {
    
  })
  .filter({
    // useful for graphql
    body: (body) => {}
  })
  .filter({
    url: {
      pathname: {
        
      }
    }
  })
  
  req
  .match({
    url: '...',
    body: '...',
    headers: '...',
  })
  .reply(...)
  .as('someStricterThing')
  
  req
  .filter({
    
  })
  .delay(..., ...)
  .delay(true) // holds indefinitely
  .reply((res) => {
    
  })
  
  // control how many times this applies
  req
  .times(3)
  .reply(200, '@bobUser').as('bobUser')
  .reply(200, '@sallyUser').as('sallyUser')
  .pause()
  .once()
  .end()
  .persist()
  .record() // saves
  .save('getUser.json') // records the thing
})

// we want to be able to get all the requests or responses for 
// a particular route or just get at all of the available routes
// or delay / throttle / respond to all requests currently paused / delayed
cy.network('reply', '@getUsers')
cy.network('reply', ['@getUsers', '@getFoo'])
cy.network('throttle', '...')
cy.network('replyAll')
cy.network('reply', '@websockets', { ... })
cy.network((routes, requests, responses) => {
  routes.remove(...)
  routes.delete()
})
cy.network('routes', 'remove', '...')
cy.network('requests').should('have.length', '...')
cy.network('responses').should('have.length', '...')
cy.network('route', '@getUser').its('requests' | 'responses')
cy.network('@getUser', 'request')
cy.network('@getUser', 'response')
cy.network('@getUser', 'route')
cy.network((net) => {
  net.routes
  net.requests
  net.responses
})

cy.network('holdAllRequest')
cy.route(...)
cy.visit(...)
cy.get('#loading')
cy.network('release')
cy.network((ret) => {
  expect(ret.requests).to.be.gt(20)
})

// can programmatically alter the rules or 
// access requests or responses
cy.network((rules) => {
  rules.disable('@blacklistGoogle')
  rules.enable('@blacklistGoogle')
})

// or keep cy.server(...) ?
cy.network((net) => {
  // programmatically alter cy.route(...)
  net.routes.add('...')
  net.routes.remove('...')
})

cy.server((server) => {
  server.routes.add('...')
  server.routes.remove('...')
})

//cy.route('enable', '@getUser')
//cy.route('disable', '@getUser')

// WEBSOCKET HANDLING...
cy.route('websockets', '/ws', (ws) => {
  ws.onRequestMessage((msg) => {
    
  })
  
  ws.onResponseMessage((msg) => {
    
  })
  
  ws.encode((frame) => {
    return engineio.encode(frame)
  })
  
  ws.decode((msg) => {
    return engineio.decode(msg)
  })
  
  ws.onFrameReceived((frame) => {
    
  })
})

cy.route('websockets', '/ws', (ws) => {
  ws
  .filter((msg, direction = 'sent | received') => {
    return /something/test(msg)
  })
  .on('request', (frame) => {
    
  })
  .on('response', (frame) => {
    
  })
  .reply(...)
  .as('chatMessage')
})

// CY.VISIT() ONE-OFF HANDLING
// with a similar API as above
cy.visit('....', (req) => {
  req.whatever().reply()
})

cy.visit({
  url: '...',
  method: 'POST',
  headers: { ... },
  reply: (res) => { ... },
})

cy.visit('POST', '...', (req) => {
  req.headers
  req.body
  
  req.whatever().reply()
  req.reply((res) => {
    
  })
})

// REQUEST RECORDING / PLAYBACK
cy.server((server) => {
  server.save()
  server.record()
  server.record(false)
  server.load(...)
  server.replay(...)
})


// we can also add something in the UI that records
// all the requests (like a button when pressed)

----

// import gqlMocks from 'cypress-graphql-mocks'

// const mockApi = gqlMocks.configure({
//   method: 'POST',
//   url: '/graphql',
//   name: 'api',
//   schema: '...',
//   mocks: '...',
// })

import mockApi from '../helpers/graphql'

Cypress.Network.defaults({
  rules: [
    {
      
    }
  ]
})

// TODO: support array of routes
const GetMe = mockApi('GetMe', {
  currentUser: {
    firstName: 'brian',
    lastName: 'mann',
  }
})

GetMe.asRoute((req) => req.cy.get('#something'))
cy.visit()

it('gives us a current user', () => {
  
})

cy.route([
  mockApi.getMe,
  mockApi.users
])

cy.route(mockApi('GetMe', {
  currentUser: {
    firstName: 'brian',
    lastName: 'mann',
  }
}))

// cy.route('POST', '/graphql', (req, next) => {
//   const { body } = req

//   if (!body) {
//     return next()
//   }

//   const { operationName, query, variables } = payload


// })
// cypress-graphql-mocks

export const configure = (options) => {
  const schema = makeExecutableSchema({
    typeDefs: schemaAsSDL(options.schema)
  })

  return (alias, operation) => {

    return {
      url: options.url,
      onRequest: (req, next) => {
        const { body } = req

        if (!body) {
          return next()
        }

        const { operationName, query, variables } = body

        req.alias = alias

        // req.reply(200, {
          
        // })

        return graphql({
          schema,
          source: query,
          variableValues: variables,
          operationName,
          rootValue: getRootValue<AllOperations>(
            operation,
            operationName,
            variables
          )
        })
        .then(req.reply)
      }
    }    
  }
}

-------------------------------------------


import gqlMocks from 'cypress-graphql-mocks'

const mockApi = gqlMocks.configure({
  method: 'POST',
  url: '/graphql',
  name: 'api',
  schema: '...',
  mocks: '...',
})

// mockApi.getMe = 

export const mockApi



-------------------------------------



/// <reference types="cypress" />
import { graphql, IntrospectionQuery } from "graphql";
import { buildClientSchema, printSchema } from "graphql";
import {
  makeExecutableSchema,
  addMockFunctionsToSchema,
  IMocks
} from "graphql-tools";

interface MockGraphQLOptions<AllOperations extends Record<string, any>> {
  schema: string | string[] | IntrospectionQuery;
  name?: string;
  mocks?: IMocks;
  endpoint?: string;
  operations?: Partial<AllOperations>;
}

interface SetOperationsOpts<AllOperations> {
  name?: string;
  endpoint?: string;
  operations?: Partial<AllOperations>;
}

interface GQLRequestPayload<AllOperations extends Record<string, any>> {
  operationName: Extract<keyof AllOperations, string>;
  query: string;
  variables: any;
}

declare global {
  namespace Cypress {
    interface Chainable {
      mockGraphql<AllOperations = any>(
        options?: MockGraphQLOptions<AllOperations>
      ): Cypress.Chainable;
      mockGraphqlOps<AllOperations = any>(
        options?: SetOperationsOpts<AllOperations>
      ): Cypress.Chainable;
    }
  }
}

/**
 * Adds a .mockGraphql() and .mockGraphqlOps() methods to the cypress chain.
 *
 * The .mockGraphql should be called in the cypress "before" or "beforeEach" block
 * config to setup the server.
 *
 * By default, it will use the /graphql endpoint, but this can be changed
 * depending on the server implementation
 *
 * It takes an "operations" object, representing the named operations
 * of the GraphQL server. This is combined with the "mocks" option,
 * to modify the output behavior per test.
 *
 * The .mockGraphqlOps() allows you to configure the mock responses at a
 * more granular level
 *
 * For example, if we has a query called "UserQuery" and wanted to
 * explicitly force a state where a viewer is null (logged out), it would
 * look something like:
 *
 * .mockGraphqlOps({
 *   operations: {
 *     UserQuery: {
 *       viewer: null
 *     }
 *   }
 * })
 */
Cypress.Commands.add(
  "mockGraphql",
  <AllOperations extends Record<string, any>>(
    options: MockGraphQLOptions<AllOperations>
  ) => {
    const { endpoint = "/graphql", operations = {}, mocks = {} } = options;

    const schema = makeExecutableSchema({
      typeDefs: schemaAsSDL(options.schema)
    });

    addMockFunctionsToSchema({
      schema,
      mocks
    });

    let currentOps = operations;

    cy.on("window:before:load", win => {
      const originalFetch = win.fetch;
      function fetch(input: RequestInfo, init?: RequestInit) {
        if (typeof input !== "string") {
          throw new Error(
            "Currently only support fetch(url, options), saw fetch(Request)"
          );
        }
        if (input.indexOf(endpoint) !== -1 && init && init.method === "POST") {
          const payload: GQLRequestPayload<AllOperations> = JSON.parse(
            init.body as string
          );
          const { operationName, query, variables } = payload;
          return graphql({
            schema,
            source: query,
            variableValues: variables,
            operationName,
            rootValue: getRootValue<AllOperations>(
              currentOps,
              operationName,
              variables
            )
          }).then((data: any) => new Response(JSON.stringify(data)));
        }
        return originalFetch(input, init);
      }
      cy.stub(win, "fetch", fetch).as("fetchStub");
    });
    //
    cy.wrap({
      setOperations: (newOperations: Partial<AllOperations>) => {
        currentOps = {
          ...(operations as object),
          ...(newOperations as object)
        };
      }
    }).as(getAlias(options));
  }
);

Cypress.Commands.add(
  "mockGraphqlOps",
  <AllOperations extends Record<string, any>>(
    options: SetOperationsOpts<AllOperations>
  ) => {
    cy.get(`@${getAlias(options)}`).invoke(
      "setOperations" as any,
      options.operations || {}
    );
  }
);

const getAlias = ({ name, endpoint }: { name?: string; endpoint?: string }) => {
  if (name || endpoint) {
    return `mockGraphqlOps:${name || endpoint}`;
  }
  return "mockGraphqlOps";
};

// Takes the schema either as the full .graphql file (string) or
// the introspection object.
function schemaAsSDL(schema: string | string[] | IntrospectionQuery) {
  if (typeof schema === "string" || Array.isArray(schema)) {
    return schema;
  }
  return printSchema(buildClientSchema(schema));
}

function getRootValue<AllOperations>(
  operations: Partial<AllOperations>,
  operationName: Extract<keyof AllOperations, string>,
  variables: any
) {
  if (!operationName || !operations[operationName]) {
    return {};
  }
  const op = operations[operationName];
  if (typeof op === "function") {
    return op(variables);
  }
  return op;
}
@jennifer-shehane
Copy link

jennifer-shehane commented Feb 27, 2019

These are the most requested features involving network rewrite (IN ORDER OF POPULARITY)

@flotwig
Copy link

flotwig commented Apr 8, 2019

I also like the chainable API that Nock exposes: https://github.com/nock/nock

It would be nice to expose this as sort of a simple API for filtering/static responses/callback responses, and then expose maybe a more advanced API like the one @brian-mann has described for users to do complex things with.

@joshribakoff
Copy link

joshribakoff commented Aug 6, 2020

Interop with RxJs would be nice for websockets:

// send 10 heartbeats 1s apart for this test case
timer(0, 1000).pipe(take(10), tap(()=>sendHeartBeat()))

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment