You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

357 lines
9.9 KiB

/*
* gtlib.ts (Garrett's Testing LIBrary)
* A quick-n-dirty (TM) helper library for writing e2e tests using
* Puppeteer, TypeScript, and Jest.
*
* Portions of this library are taken from the Extollo framework, which
* is released under the MIT license.
*
* Copyright © 2023 Garrett Mills
*
* Permission is hereby granted, free of charge, to any person obtaining
* a copy of this software and associated documentation files (the “Software”),
* to deal in the Software without restriction, including without limitation
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
* THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import {Browser, Page, launch as launchPuppeteer} from 'puppeteer'
/** State object for GTLib's helpers. */
export interface GTState {
browser: Maybe<Browser>
page: Maybe<Page>
}
/** Get a new, empty state object. */
export const gtState = (): GTState => ({
browser: undefined,
page: undefined,
})
/** Launch a new Puppeteer page. */
export const launch = async (state: GTState): Promise<void> => {
state.browser = await launchPuppeteer({
headless: 'new',
})
state.page = await state.browser.newPage()
}
/** Terminate running Puppeteer instance after tests. */
export const cleanup = async (state: GTState): Promise<void> => {
await state.browser?.close?.()
}
/** Open a URL in the Puppeteer page and wait 5 seconds for the network to be idle. */
export const safeNavigate = async (state: GTState, url: string): Promise<Page> => {
const page = failUnless(state.page, 'Error while initializing Puppeteer')
await page.goto(url)
await page.waitForNetworkIdle({
idleTime: (5).seconds(),
})
return page
}
/** Convert minutes -> milliseconds. */
export const min = (mins: number) => sec(mins * 60)
/** Convert seconds -> milliseconds. */
export const sec = (s: number) => s * 1000
/** Cause a test to fail unless a condition is true. */
export const failUnless = <T>(cond: Maybe<T> | Nullable<T>, message: string): T => {
failIf(!cond, message)
return cond! // eslint-disable-line @typescript-eslint/no-non-null-assertion
}
/** Cause a test to fail if a condition is true. */
export const failIf = (cond: unknown, message: string) => {
if ( cond ) {
fail(message)
}
}
/** Cause a test to fail. */
export const fail = (message: string) => {
throw new Error(message)
}
/** Returns a promise that resolves after the specified # of milliseconds (approx). */
export const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
/** Type alias for something that may or may not be wrapped in a promise. */
export type Awaitable<T> = T | Promise<T>
/** Type alias for something that may be undefined. */
export type Maybe<T> = T | undefined
/** Type alias for something that may be null. */
export type Nullable<T> = T | null
/** A typescript-compatible version of Object.hasOwnProperty. */
export function hasOwnProperty<X extends {}, Y extends PropertyKey>(obj: X, prop: Y): obj is X & Record<Y, unknown> { // eslint-disable-line @typescript-eslint/ban-types
return Object.hasOwnProperty.call(obj, prop)
}
export function isDebugging(key: string): boolean {
const env = 'EXTOLLO_DEBUG_' + key.split(/(?:\s|\.)+/).join('_')
.toUpperCase()
return process.env[env] === 'yes'
}
export function ifDebugging(key: string, run: () => any): void {
if ( isDebugging(key) ) {
run()
}
}
export function logIfDebugging(key: string, ...output: any[]): void {
ifDebugging(key, () => console.log(`[debug: ${key}]`, ...output)) // eslint-disable-line no-console
}
/**
* UNSAFE
*
* Sometimes, we need to make a literal `import()` call from within commonJS
* modules in order to pull in ES modules from commonJS.
*
* However, when tsc renders the modules to commonJS, it rewrites _all_ calls
* to `import` as calls to `require`, which means we cannot actually use ES
* modules from commonJS-transpiled TypeScript.
*
* To bypass this, we can eval the literal string. This is a stupid hack and
* I hate it so much, but unfortunately it works.
*
* So, this is a wrapper function that results in a call to the literal
* `import(...)` function in the transpiled code. It should be used VERY
* sparingly.
*
* @see https://github.com/microsoft/TypeScript/issues/43329
* @param path
*/
export function unsafeESMImport(path: string): Promise<any> {
((p: string) => p)(path)
return eval('import(path)') // eslint-disable-line no-eval
}
/**
* Enum of HTTP statuses.
* @example
* HTTPStatus.http200 // => 200
*
* @example
* HTTPStatus.REQUEST_TIMEOUT // => 408
*/
export enum HTTPStatus {
http100 = 100,
http101 = 101,
http102 = 102,
http200 = 200,
http201 = 201,
http202 = 202,
http203 = 203,
http204 = 204,
http205 = 205,
http206 = 206,
http207 = 207,
http300 = 300,
http301 = 301,
http302 = 302,
http303 = 303,
http304 = 304,
http305 = 305,
http307 = 307,
http308 = 308,
http400 = 400,
http401 = 401,
http402 = 402,
http403 = 403,
http404 = 404,
http405 = 405,
http406 = 406,
http407 = 407,
http408 = 408,
http409 = 409,
http410 = 410,
http411 = 411,
http412 = 412,
http413 = 413,
http414 = 414,
http415 = 415,
http416 = 416,
http417 = 417,
http418 = 418,
http419 = 419,
http420 = 420,
http422 = 422,
http423 = 423,
http424 = 424,
http428 = 428,
http429 = 429,
http431 = 431,
http500 = 500,
http501 = 501,
http502 = 502,
http503 = 503,
http504 = 504,
http505 = 505,
http507 = 507,
http511 = 511,
CONTINUE = 100,
SWITCHING_PROTOCOLS = 101,
PROCESSING = 102,
OK = 200,
CREATED = 201,
ACCEPTED = 202,
NON_AUTHORITATIVE_INFORMATION = 203,
NO_CONTENT = 204,
RESET_CONTENT = 205,
PARTIAL_CONTENT = 206,
MULTI_STATUS = 207,
MULTIPLE_CHOICES = 300,
MOVED_PERMANENTLY = 301,
MOVED_TEMPORARILY = 302,
SEE_OTHER = 303,
NOT_MODIFIED = 304,
USE_PROXY = 305,
TEMPORARY_REDIRECT = 307,
PERMANENT_REDIRECT = 308,
BAD_REQUEST = 400,
UNAUTHORIZED = 401,
PAYMENT_REQUIRED = 402,
FORBIDDEN = 403,
NOT_FOUND = 404,
METHOD_NOT_ALLOWED = 405,
NOT_ACCEPTABLE = 406,
PROXY_AUTHENTICATION_REQUIRED = 407,
REQUEST_TIMEOUT = 408,
CONFLICT = 409,
GONE = 410,
LENGTH_REQUIRED = 411,
PRECONDITION_FAILED = 412,
REQUEST_TOO_LONG = 413,
REQUEST_URI_TOO_LONG = 414,
UNSUPPORTED_MEDIA_TYPE = 415,
REQUESTED_RANGE_NOT_SATISFIABLE = 416,
EXPECTATION_FAILED = 417,
IM_A_TEAPOT = 418,
INSUFFICIENT_SPACE_ON_RESOURCE = 419,
METHOD_FAILURE = 420,
UNPROCESSABLE_ENTITY = 422,
LOCKED = 423,
FAILED_DEPENDENCY = 424,
PRECONDITION_REQUIRED = 428,
TOO_MANY_REQUESTS = 429,
REQUEST_HEADER_FIELDS_TOO_LARGE = 431,
INTERNAL_SERVER_ERROR = 500,
NOT_IMPLEMENTED = 501,
BAD_GATEWAY = 502,
SERVICE_UNAVAILABLE = 503,
GATEWAY_TIMEOUT = 504,
HTTP_VERSION_NOT_SUPPORTED = 505,
INSUFFICIENT_STORAGE = 507,
NETWORK_AUTHENTICATION_REQUIRED = 511,
}
/**
* Maps HTTP status code to default status message.
*/
export const HTTPMessage = {
100: 'Continue',
101: 'Switching Protocols',
102: 'Processing',
200: 'OK',
201: 'Created',
202: 'Accepted',
203: 'Non Authoritative Information',
204: 'No Content',
205: 'Reset Content',
206: 'Partial Content',
207: 'Multi-Status',
300: 'Multiple Choices',
301: 'Moved Permanently',
302: 'Moved Temporarily',
303: 'See Other',
304: 'Not Modified',
305: 'Use Proxy',
307: 'Temporary Redirect',
308: 'Permanent Redirect',
400: 'Bad Request',
401: 'Unauthorized',
402: 'Payment Required',
403: 'Forbidden',
404: 'Not Found',
405: 'Method Not Allowed',
406: 'Not Acceptable',
407: 'Proxy Authentication Required',
408: 'Request Timeout',
409: 'Conflict',
410: 'Gone',
411: 'Length Required',
412: 'Precondition Failed',
413: 'Request Entity Too Large',
414: 'Request-URI Too Long',
415: 'Unsupported Media Type',
416: 'Request Range Not Satisfiable',
417: 'Expectation Failed',
418: 'I\'m a teapot',
419: 'Insufficient Space on Resource',
420: 'Method Failure',
422: 'Unprocessable Entity',
423: 'Locked',
424: 'Failed Dependency',
428: 'Precondition Required',
429: 'Too Many Requests',
431: 'Request Header Fields Too Large',
500: 'Server Error',
501: 'Not Implemented',
502: 'Bad Gateway',
503: 'Service Unavailable',
504: 'Gateway Timeout',
505: 'HTTP Version Not Supported',
507: 'Insufficient Storage',
511: 'Network Authentication Required',
}
/*
* Here's some crazy shit. We're going to mutate the Number prototype to add
* some handy fluent-style aliases. You should never use this in prod, but in
* a test suite, it's fine.
*/
declare global {
interface Number {
seconds: () => number
second: () => number
minutes: () => number
}
}
Number.prototype.seconds = function() {
return sec(this as number)
}
Number.prototype.second = function() {
return sec(this as number)
}
Number.prototype.minutes = function() {
return min(this as number)
}