npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

@contexts/http

v1.6.2

Published

The Http(s) Testing Context For Super-Test Style Assertions. Includes Standard Assertions (get, set, assert), And Allows To Be Extended With JSDocumented Custom Assertions.

Downloads

140

Readme

@contexts/http

npm version

@contexts/http is The Http(s) Testing Context For Super-Test Style Assertions.

yarn add @contexts/http

Table Of Contents

API

The package is available by importing its default and named classes. When extending the context, the Tester class is required. The CookiesContext is an extension of the HttpContext that provides assertions for the returned set-cookie header.

import HttpContext, { Tester } from '@contexts/http'
import CookiesContext, { CookiesTester } from '@contexts/http/cookie'

class HttpContext

This testing context is to be used with Zoroaster Context Testing Framework. Once it is defined as part of a test suite, it will be available to all inner tests via the arguments. It allows to specify the middleware function to start the server with, and provides an API to send requests, while setting headers, and then assert on the result that came back. It was inspired by supertest, but is asynchronous in nature so that no done has to be called — just the promise needs to be awaited on.

const users = {
  'secret-token': 'ExampleUser',
}

/**
 * User Authentication Route.
 * @param {http.IncomingMessage} req
 * @param {http.ServerResponse} res
 */
const middleware = (req, res) => {
  const token = req.headers['x-auth']
  if (!token) throw new Error('The authentication is required.')
  const user = users[token]
  if (!user) throw new Error('The user is not found.')
  res.setHeader('set-cookie', `user=${user}`)
  res.end(`Hello, ${user}`)
}

export default middleware

/**
 * @typedef {import('http').IncomingMessage} http.IncomingMessage
 * @typedef {import('http').ServerResponse} http.ServerResponse
 */
example/test/spec/default.js
  ✓  prevents unauthorised
  ✓  does not find the user
  ✓  authenticates known user

🦅  Executed 3 tests.

start(  fn: (req: IncomingMessage, res: ServerResponse),  secure: boolean=,): Tester

Starts the server with the given request listener function. It will setup an upper layer over the listener to try it and catch any errors in it. If there were errors, the status code will be set to 500 and the response will be ended with the error message. If there was no error, the status code will be set by Node.JS to 200 automatically, if the request listener didn't set it. This is done so that assertion methods can be called inside of the supplied function. If the server needs to be started without the wrapper handler, the startPlain method can be used instead.

When the secure option is passed, the HTTPS server with self-signed keys will be started and process.env.NODE_TLS_REJECT_UNAUTHORIZED will be set to 0 so make sure this context is only used for testing, and not on the production env.

// the handler installed by the `start` method.
const handler = async (req, res) => {
  try {
    await fn(req, res)
    res.statusCode = 200
  } catch (err) {
    res.statusCode = 500
    res.write(err.message)
    if (this._debug) console.error(error.stack)
  } finally {
    res.end()
  }
}
server.start(handler)
/**
 * Creates a middleware for the given list of users.
 * @param {Object<string, string>} users
 */
const makeMiddleware = (users) => {
  /**
   * Updates the request to have the user information if the token is found.
   * @param {http.IncomingMessage} req
   */
  const middleware = (req) => {
    const token = req.headers['x-auth']
    const user = users[token]
    if (user) req['user'] = user
  }
  return middleware
}

export default makeMiddleware

/**
 * @typedef {import('http').IncomingMessage} http.IncomingMessage
 * @typedef {import('http').ServerResponse} http.ServerResponse
 */
import { ok, equal } from 'assert'
import HttpContext from '@contexts/http'
import createMiddleware from '../../src/constructor'

class Context {
  /**
   * Creates a request listener for testing.
   * @param {function(http.IncomingMessage, http.ServerResponse)} next
   * Assertion method.
   * @param {Object<string, string>} [users] The list of tokens-users.
   */
  c(next, users = {}) {
    return (req, res) => {
      const mw = createMiddleware(users)
      mw(req, res) // set the user on request
      next(req, res)
    }
  }
}

/** @type {Object<string, (c: Context, h: HttpContext)} */
const TS = {
  context: [Context, HttpContext],
  async 'does not set the user without token'({ c }, { start }) {
    await start(c((req) => {
      ok(!req.user)
    }))
      .get('/')
      .assert(200)
  },
  async 'does not set the user with missing token'({ c }, { start }) {
    await start(c((req) => {
      ok(!req.user)
    }), { 'secret-token': 'User' })
      .set('x-auth', 'missing-token')
      .get('/')
      .assert(200)
  },
  async 'sets the user with https'({ c }, { start }) {
    await start(c((req) => {
      ok(req.user)
      ok(req.connection.encrypted)
    }, { 'secret-token': 'User' }), true)
      .set('x-auth', 'secret-token')
      .get('/')
      .assert(200)
  },
  async 'sets the correct name'({ c }, { start }) {
    await start(c((req) => {
      equal(req.user, 'Expected-User')
    }, { 'secret-token': 'Actual-User' }))
      .set('x-auth', 'secret-token')
      .get('/')
      .assert(200) // expecting fail
  },
}

export default TS

/**
 * @typedef {import('http').IncomingMessage} http.IncomingMessage
 * @typedef {import('http').ServerResponse} http.ServerResponse
 */
example/test/spec/constructor.js
  ✓  does not set the user without token
  ✓  does not set the user with missing token
  ✓  sets the user with https
  ✗  sets the correct name
  | Error: 500 == 200 'Actual-User' == 'Expected-User'
  |     at sets the correct name (example/test/spec/constructor.js:54:8)

example/test/spec/constructor.js > sets the correct name
  Error: 500 == 200 'Actual-User' == 'Expected-User'
      at sets the correct name (example/test/spec/constructor.js:54:8)

🦅  Executed 4 tests: 1 error.

startPlain(  fn: (req: IncomingMessage, res: ServerResponse),  secure: boolean=,): Tester

Starts the server without wrapping the listener in the handler that would set status 200 on success and status 500 on error, and automatically finish the request. This means that the listener must manually do these things. Any uncaught error will result in run-time errors which will be caught by Zoroaster's error handling mechanism outside of the test scope, but ideally they should be dealt with by the developer. If the middleware did not end the request, the test will timeout and the connection will be destroyed by the context to close the request.

import Http from '@contexts/http'

/** @type {Object<string, (h: Http)} */
const TS = {
  context: Http,
  async 'sets the status code and body'(
    { startPlain }) {
    await startPlain((req, res) => {
      res.statusCode = 200
      res.end('Hello World')
    })
      .get('/')
      .assert(200, 'Hello World')
  },
  // expect to fail with global error
  async 'throws an error'({ startPlain }) {
    await startPlain(() => {
      throw new Error('Unhandled error.')
    })
      .get('/')
  },
  // expect to timeout
  async 'does not finish the request'(
    { startPlain }) {
    await startPlain((req, res) => {
      res.write('hello')
    })
      .get('/')
  },
}

export default TS
class C {
  c(listener) {
    return (req, res) => {
      try {
        listener(req, res)
      } catch (err) {
        res.statusCode = 500
      } finally {
        res.end()
      }
    }
  }
}

/** @type {Object<string, (c:C, h: Http)} */
export const handled = {
  context: [C, Http],
  async 'throws an error'({ c },
    { startPlain }) {
    await startPlain(c(() => {
      throw new Error('Unhandled error.')
    }))
      .get('/')
      .assert(500)
  },
  async 'times out'({ c }, { startPlain }) {
    await startPlain(c((req, res) => {
      res.write('hello')
    }))
      .get('/')
      .assert(200, 'hello')
  },
}
example/test/spec/plain
   handled
    ✓  throws an error
    ✓  times out
   plain
    ✓  sets the status code and body
    ✗  throws an error
    | Error: Unhandled error.
    |     at startPlain (example/test/spec/plain/plain.js:18:13)
    |     at Server.handler (src/index.js:88:15)
    ✗  does not finish the request
    | Error: Test has timed out after 200ms

example/test/spec/plain > plain > throws an error
  Error: Unhandled error.
      at startPlain (example/test/spec/plain/plain.js:18:13)
      at Server.handler (src/index.js:88:15)

example/test/spec/plain > plain > does not finish the request
  Error: Test has timed out after 200ms

🦅  Executed 5 tests: 2 errors.

listen(  server: http.Server|https.Server,): Tester

Starts the given server by calling the listen method. This method is used to test apps such as Koa, Express, Connect etc, or many middleware chained together, therefore it's a higher level of testing aka integration testing that does not allow to access the response object because no middleware is inserted into the server itself. It only allows to open URLs and assert on the results received by the request library, such as status codes, body and the headers. The server will be closed by the end of each test by the context.

import { createServer } from 'http'
import connect from 'connect'

const app = connect()
app.use((req, res, next) => {
  if (req.url == '/error')
    throw new Error('Uncaught error')
  res.write('hello, ')
  next()
})
app.use((req, res) => {
  res.statusCode = 200
  res.end('world!')
})

export default createServer(app)
import H from '@contexts/http'
import server from '../../src/server'

/** @type {Object<string, (h: H)} */
const TS = {
  context: H,
  async 'access the server'({ listen }) {
    await listen(server)
      .get('/')
      .assert(200, 'hello, world!')
  },
  async 'connect catches errors'({ listen }) {
    await listen(server)
      .get('/error')
      .assert(500)
  },
}

export default TS
example/test/spec/listen.js
  ✓  access the server
  ✓  connect catches errors

🦅  Executed 2 tests.

debug(  on: boolean=,): void

Switches on the debugging for the start method, because it catches the error and sets the response to 500, without giving any info about the error. This will log the error that happened during assertions in the request listener. Useful to see at what point the request failed.

async 'sets the code to 200'({ start, debug }) {
  debug()
  await start(middleware)
    .get()
    .assert(200)
},
example/test/spec/debug.js
  ✗  sets the code to 200
  | Error: 500 == 200 The authentication is required.
  |     at sets the code to 200 (example/test/spec/debug.js:12:8)

example/test/spec/debug.js > sets the code to 200
  Error: 500 == 200 The authentication is required.
      at sets the code to 200 (example/test/spec/debug.js:12:8)

🦅  Executed 1 test: 1 error.
Error: The authentication is required.
    at middleware (/Users/zavr/idiocc/http/example/src/index.js:12:21)
    at Server.handler (/Users/zavr/idiocc/http/src/index.js:67:15)

Tester: The instance of a Tester class is returned by the start, startPlain and listen methods. It is used to chain the actions together and extends the promise that should be awaited for during the test. It provides a testing API similar to the SuperTest package, but does not require calling done method, because the Tester class is asynchronous.

Send a GET request. View examples at Wiki

async 'redirects to /'({ start }) {
  await start(middleware)
    .get()
    .assert(302)
    .assert('location', 'index.html')
},
async 'opens sitemap'({ start }) {
  await start(middleware)
    .get('/sitemap')
    .assert(200)
},

Send a request for the Allow and CORS pre-flight headers.

async 'sends options request'({ start }) {
  let method
  await start((req, res) => {
    method = req.method
    res.setHeader('allow', 'HEAD, GET')
    res.statusCode = 204
  })
    .options('/')
    .assert(204)
    .assert('allow', 'HEAD, GET')
  equal(method, 'OPTIONS')
},

Send a HEAD request. View examples at Wiki

async 'sends redirect for index'({ start }) {
  await start(middleware)
    .head()
    .assert(302)
    .assert('location', 'index.html')
},
async 'sends 200 for sitemap'({ start }) {
  await start(middleware)
    .head('/sitemap')
    .assert(200)
},

assert(  code: number,  body: (string|RegExp|Object)=,): Tester

Assert on the status code and body. The error message will contain the body if it was present. If the response was in JSON, it will be automatically parses by the request library, and the deep assertion will be performed.

async 'status code'({ startPlain }) {
  await startPlain((_, res) => {
    res.statusCode = 205
    res.end()
  })
    .get()
    .assert(205)
},
async 'status code with message'({ startPlain }) {
  await startPlain((_, res) => {
    res.statusCode = 205
    res.end('example')
  })
    .get('/sitemap')
    .assert(205, 'example')
},
async 'status code with regexp'({ startPlain }) {
  await startPlain((_, res) => {
    res.statusCode = 205
    res.end('Example')
  })
    .get('/sitemap')
    .assert(205, /example/i)
},
async 'status code with json'({ startPlain }) {
  await startPlain((_, res) => {
    res.statusCode = 205
    res.setHeader('content-type', 'application/json')
    res.end(JSON.stringify({ hello: 'world' }))
  })
    .get('/sitemap')
    .assert(205, { hello: 'world' })
},
example/test/spec/assert/code.js
  ✓  status code
  ✓  status code with message
  ✓  status code with regexp
  ✓  status code with json

🦅  Executed 4 tests.

assert(  header: string,  value: ?(string|RegExp),): Tester

Assert on the response header. The value must be either a string, regular expression to match the value of the header, or null to assert that the header was not set.

// pass
async 'header'({ startPlain }) {
  await startPlain((_, res) => {
    res.statusCode = 205
    res.setHeader('content-type',
      'application/json')
    res.end('[]')
  })
    .get('/sitemap')
    .assert(205)
    .assert('content-type',
      'application/json')
},
async 'header with regexp'({ startPlain }) {
  await startPlain((_, res) => {
    res.setHeader('content-type',
      'application/json; charset=utf-8')
    res.end('[]')
  })
    .get('/')
    .assert('content-type',
      /application\/json/)
},
async 'absence of a header'({ startPlain }) {
  await startPlain((_, res) => {


    res.end()
  })
    .get('/sitemap')
    .assert('content-type', null)
},
// fail
async 'header'({ startPlain }) {
  await startPlain((_, res) => {
    res.statusCode = 205
    res.setHeader('content-type',
      'application/xml')
    res.end('<pages />')
  })
    .get('/sitemap')
    .assert(205)
    .assert('content-type',
      'application/json')
},
async 'header with regexp'({ startPlain }) {
  await startPlain((_, res) => {
    res.setHeader('content-type',
      'application/json; charset=utf-8')
    res.end('[]')
  })
    .get('/')
    .assert('content-type',
      /application\/xml/)
},
async 'absence of a header'({ startPlain }) {
  await startPlain((_, res) => {
    res.setHeader('content-type',
      'text/plain')
    res.end()
  })
    .get('/sitemap')
    .assert('content-type', null)
},
example/test/spec/assert/header.js
  ✓  header
  ✓  header with regexp
  ✓  absence of a header
 example/test/spec/assert/header-fail.js
  ✗  header
  | Error: Header content-type did not match value:
  | - application/json
  | + application/xml
  |     at header (example/test/spec/assert/header-fail.js:17:8)
  ✗  header with regexp
  | Error: Header content-type did not match RexExp:
  |   - /application//xml/
  |   + application/json; charset=utf-8
  |     at header with regexp (example/test/spec/assert/header-fail.js:27:8)
  ✗  absence of a header
  | Error: Header content-type was not expected:
  |   + text/plain
  |     at absence of a header (example/test/spec/assert/header-fail.js:37:8)

example/test/spec/assert/header-fail.js > header
  Error: Header content-type did not match value:
  - application/json
  + application/xml
      at header (example/test/spec/assert/header-fail.js:17:8)

example/test/spec/assert/header-fail.js > header with regexp
  Error: Header content-type did not match RexExp:
    - /application//xml/
    + application/json; charset=utf-8
      at header with regexp (example/test/spec/assert/header-fail.js:27:8)

example/test/spec/assert/header-fail.js > absence of a header
  Error: Header content-type was not expected:
    + text/plain
      at absence of a header (example/test/spec/assert/header-fail.js:37:8)

🦅  Executed 6 tests: 3 errors.

assert(  assertion: function(Aqt.Return),): Tester

Perform an assertion using the function that will receive the response object which is the result of the request operation with aqt. If the tester was started with start or startPlain methods, it is possible to get the response object from the request listener by calling the getResponse method on the context.

import('http').IncomingHttpHeaders http.IncomingHttpHeaders: The hash map of headers that are set by the server (e.g., when accessed via IncomingMessage.headers)

_rqt.AqtReturn: The return type of the function.

| Name | Type | Description | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | body* | !(string | Object | Buffer) | The return from the server. In case the json content-type was set by the server, the response will be parsed into an object. If binary option was used for the request, a Buffer will be returned. Otherwise, a string response is returned. | | headers* | !http.IncomingHttpHeaders | Incoming headers returned by the server. | | statusCode* | number | The status code returned by the server. | | statusMessage* | string | The status message set by the server. |

async 'using a function'({ start }) {
  await start((_, res) => {
    res.statusCode = 205
    res.setHeader('content-type', 'application/xml')
    res.end()
  })
    .get('/sitemap')
    .assert((res) => {
      equal(res.headers['content-type'],
        'application/xml')
    })
},
async 'with response object'({ start, getResponse }) {
  await start((_, res) => {
    res.setHeader('content-type', 'application/xml')
    res.end()
  })
    .get('/sitemap')
    .assert(() => {
      const res = getResponse()
      equal(res.getHeader('content-type'),
        'application/xml')
    })
},
example/test/spec/assert/function.js
  ✓  using a function
  ✓  with response object

🦅  Executed 2 tests.

set(  header: string,  value: string,): Tester

Sets the outgoing headers. Must be called before the get method. It is possible to remember the result of the first request using the assert method by storing it in a variable, and then use it for headers in the second request (see example).

async 'sets the header'({ startPlain }) {
  await startPlain((req, res) => {
    if (req.headers['x-auth'] == 'token') {
      res.statusCode = 205
      res.end('hello')
    } else {
      res.statusCode = 403
      res.end('forbidden')
    }
  })
    .set('x-auth', 'token')
    .get()
    .assert(205)
},
async 'sets a header with a function'({ start }) {
  let cookie
  await start((req, res) => {
    res.setHeader('x-test', 'world')
    res.end(req.headers['test'])
  })
    .set('test', 'hello')
    .get('/')
    .assert(200, 'hello')
    .assert(({ headers: h }) => {
      cookie = h['x-test']
    })
    .set('test', () => cookie)
    .get('/')
    .assert(200, 'world')
},
example/test/spec/assert/set.js
  ✓  sets the header
 example/test/spec/assert/set-fn.js
  ✓  sets a header with a function

🦅  Executed 2 tests.

post(  path: string?,  data: string|Object?,  options: AqtOptions?,): Tester

Posts data to the server. By default, a string will be sent with the text/plain Content-Type, whereas an object will be encoded as the application/json type, or it can be sent as application/x-www-form-urlencoded data by specifying type: form in options. To send multipart/form-data requests, use the postForm method.

async 'posts string data'({ startPlain }, { middleware }) {
  await startPlain(middleware)
    .post('/submit', 'hello')
    .assert(200, `Received data: hello `
      + `with Content-Type text/plain`)
},
async 'posts object data'({ startPlain }, { middleware }) {
  await startPlain(middleware)
    .post('/submit', { test: 'ok' })
    .assert(200, `Received data: {"test":"ok"} `
      + `with Content-Type application/json`)
},
async 'posts urlencoded data'({ startPlain }, { middleware }) {
  await startPlain(middleware)
    .post('/submit', { test: 'ok' }, {
      type: 'form',
      headers: {
        'User-Agent': 'testing',
      },
    })
    .assert(200, `Received data: test=ok `
      + `with Content-Type application/x-www-form-urlencoded `
      + `and User-Agent testing`)
},
example/test/spec/assert/post.js
  ✓  posts string data
  ✓  posts object data
  ✓  posts urlencoded data

🦅  Executed 3 tests.

postForm(  path: string?,  cb: async function(Form),  options: AqtOptions?,): Tester

Creates a form instance, to which data and files can be appended via the supplied callback, and sends the request as multipart/form-data to the server. See the Form interface full documentation.

async 'posts multipart/form-data'({ startPlain }, { middleware }) {
  await startPlain(middleware)
    .postForm('/submit', async (form) => {
      form.addSection('field', 'hello-world')
      await form.addFile('test/fixture/test.txt', 'file')
      await form.addFile('test/fixture/test.txt', 'file', {
        filename: 'testfile.txt',
      })
    })
    .assert(200, [
      [ 'field', 'hello-world', '7bit', 'text/plain' ],
      [ 'file',  'test.txt', '7bit', 'application/octet-stream' ],
      [ 'file',  'testfile.txt', '7bit', 'application/octet-stream' ],
    ])
},
example/test/spec/assert/post-form.js
  ✓  posts multipart/form-data

🦅  Executed 1 test.

session(): Tester

Turns the session mode on. In the session mode, the cookies received from the server will be stored in the internal variable, and sent along with each following request. If the server removed the cookies by setting them to an empty string, or by setting the expiry date to be in the past, they will be removed from the tester and not sent to the server.

This feature can also be switched on by setting session=true on the context itself, so that .session() calls are not required.

Additional cookies can be set using the .set('Cookie', {value}) method, and they will be concatenated to the cookies maintained by the session.

At the moment, only expire property is handled, without the path, or httpOnly directives. This will be added in future versions.

import HttpContext from '../../src'

/** @type {TestSuite} */
export const viaSessionMethod = {
  context: HttpContext,
  async 'maintains the session'({ start, debug }) {
    debug()
    await start((req, res) => {
      if (req.url == '/') {
        res.setHeader('set-cookie', 'koa:sess=eyJtZ; path=/; httponly')
        res.end('hello world')
      } else if (req.url == '/exit') {
        res.setHeader('set-cookie', 'koa:sess=; path=/; httponly')
        res.end()
      } else if (req.url == '/test') {
        res.end(req.headers['cookie'])
      }
    })
      .session()
      .get('/')
      .assert(200, 'hello world')
      .set('Cookie', 'testing=true')
      .get('/test')
      .assert(200, 'koa:sess=eyJtZ;testing=true')
      .get('/exit')
      .get('/test')
      .assert(200, 'testing=true')
  },
}

/** @type {TestSuite} */
export const viaExtendingContext = {
  context: class extends HttpContext {
    constructor() {
      super()
      this.session = true
    }
  },
  async 'maintains the session'({ start, debug }) {
    debug()
    await start((req, res) => {
      if (req.url == '/') {
        res.setHeader('set-cookie', 'koa:sess=eyJtZ; path=/; httponly')
        res.end('hello world')
      } else if (req.url == '/exit') {
        res.setHeader('set-cookie', 'koa:sess=; path=/; httponly')
        res.end()
      } else if (req.url == '/test') {
        res.end(req.headers['cookie'])
      }
    })
      .get('/')
      .assert(200, 'hello world')
      .get('/test')
      .assert(200, 'koa:sess=eyJtZ')
      .get('/exit')
      .get('/test')
      .assert(200, '')
  },
}

/** @typedef {import('../context').TestSuite} TestSuite */
test/spec/session.js
   viaSessionMethod
    ✓  maintains the session
   viaExtendingContext
    ✓  maintains the session

🦅  Executed 2 tests.
/ Setting cookie koa:sess to eyJtZ 
/exit Server deleted cookie koa:sess
/ Setting cookie koa:sess to eyJtZ 
/exit Server deleted cookie koa:sess

Extending

The package was designed to be extended with custom assertions which are easily documented for use in tests. The only thing required is to import the Tester class, and extend it, following a few simple rules.

There are 2 parts of the @contexts/Http software: the context and the tester. The context is used to start the server, remember the response object as well as to destroy the server. The tester is what is returned by the start/startPlain/listen methods, and is used to query the server. To implement the custom assertions with support for JSDoc, the HttpContext needs to be extended to include any private methods that could be used by the tester's assertions, but might not have to be part of the Tester API, and then implement those assertions in the tester by calling the private _addLink method which will add the action to the promise chain, so that the await syntax is available.

import Http from '@context/http'
import CookiesTester from './tester'
import mistmatch from 'mismatch'

/**
 * Extends _HTTPContext_ to assert on the cookies.
 */
export default class Cookies extends Http {
  constructor() {
    super()
    this.TesterConstructor = CookiesTester
    /**
     * Parsed cookies.
     * @private
     */
    this._cookies = null
  }
  /**
   * Creates a server and wraps the supplied listener in the handler that will
   * set status code `500` if the listener threw and the body to the error text.
   * @param {function(http.IncomingMessage, http.ServerResponse)} fn
   * @param {boolean} secure
   */
  start(fn, secure) {
    const tester = /** @type {CookiesTester} */ (super.start(fn, secure))
    return tester
  }
  /**
   * Creates a server with the supplied listener.
   * @param {function(http.IncomingMessage, http.ServerResponse)} fn
   * @param {boolean} secure
   */
  startPlain(fn, secure) {
    const tester = /** @type {CookiesTester} */ (super.startPlain(fn, secure))
    return tester
  }
  getCookies() {
    if (this._cookies) return this._cookies
    const setCookies = /** @type {Array<string>} */
      (this.tester.res.headers['set-cookie']) || []
    const res = setCookies.map(Cookies.parseSetCookie)
    this._cookies = res
    return res
  }
  /**
   * Parses the `set-cookie` header.
   * @param {string} header
   */
  static parseSetCookie(header) {
    const pattern = /\s*([^=;]+)(?:=([^;]*);?|;|$)/g

    const pairs = mistmatch(pattern, header, ['name', 'value'])

    /** @type {{ name: string, value: string }} */
    const cookie = pairs.shift()

    for (let i = 0; i < pairs.length; i++) {
      const match = pairs[i]
      cookie[match.name.toLowerCase()] = (match.value || true)
    }

    return cookie
  }
  /**
   * Returns the cookie record for the given name.
   * @param {string} name
   */
  getCookieForName(name) {
    const cookies = this.getCookies()

    return cookies.find(({ name: n }) => {
      return name == n
    })
  }
  _reset() {
    super._reset()
    this._cookies = null
  }
}
import { Tester } from '@context/http'
import erotic from 'erotic'

/**
 * The tester for assertion on cookies.
 */
export class CookiesTester extends Tester {
  constructor() {
    super()
    /** @type {import('./').default} */
    this.context = null
  }
  /**
   * Assert on the number of times the cookie was set.
   * @param {number} num The expected count.
   */
  count(num) {
    const e = erotic(true)
    this._addLink(() => {
      const count = this.context.getCookies().length
      equal(count, num, 'Should set cookie ' + num + ' times, not ' + count + '.')
    }, e)
    return this
  }

  /**
   * Asserts on the value of the cookie.
   * @param {string} name The name of the cookie.
   * @param {string} val The value of the cookie.
   */
  value(name, val) {
    const e = erotic(true)
    this._addLink(() => {
      const cookie = this.context.getCookieForName(name)
      ok(cookie, wasExpectedError('Cookie', name, val))
      equal(cookie.value, val,
        didNotMatchValue('Cookie', name, val, cookie.value))
    }, e)
    return this
  }
  /**
   * Asserts on the presence of an attribute in the cookie.
   * @param {string} name The name of the cookie.
   * @param {string} attrib The name of the attribute.
   */
  attribute(name, attrib) {
    const e = erotic(true)
    this._addLink(() => {
      const cookie = this.context.getCookieForName(name)
      assertAttribute(name, cookie, attrib)
    }, e)
    return this
  }
}
example/test/spec/cookie/
  ✓  sets the HttpOnly cookie
  ✓  deletes the cookie
  ✗  sets cookie for a path
  | Error: Attribute path of cookie example was expected.
  |     at sets cookie for a path (example/test/spec/cookie/default.js:32:8)

example/test/spec/cookie/ > sets cookie for a path
  Error: Attribute path of cookie example was expected.
      at sets cookie for a path (example/test/spec/cookie/default.js:32:8)

🦅  Executed 3 tests: 1 error.

CookiesContext

The CookiesContext provides assertion methods on the set-cookie header returned by the server. It allows to check how many times cookies were set as well as what attributes and values they had.

  • count(number): Assert on the number of times the cookie was set.
  • name(string): Assert on the presence of a cookie with the given name. Same as .assert('set-cookie', /name/).
  • value(name, value): Asserts on the value of the cookie.
  • attribute(name, attrib): Asserts on the presence of an attribute in the cookie.
  • attributeAndValue(name, attrib, value): Asserts on the value of the cookie's attribute.
  • noAttribute(name, attrib): Asserts on the absence of an attribute in the cookie.

The context was adapted from the work in https://github.com/pillarjs/cookies. See how the tests are implemented for more info.

Examples:

  1. Testing Session Middleware.
    async 'sets the cookie again after a change'({ app, startApp }) {
      app.use((ctx) => {
        if (ctx.path == '/set') {
          ctx.session.message = 'hello'
          ctx.status = 204
        } else {
          ctx.body = ctx.session.message
          ctx.session.money = '$$$'
        }
      })
      await startApp()
        .get('/set').assert(204)
        .count(2)
        .get('/').assert(200, 'hello')
        .name('koa:sess')
        .count(2)
    },

Copyright