/* * 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 page: Maybe } /** 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 => { 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 => { 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 => { 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 = (cond: Maybe | Nullable, 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 | Promise /** Type alias for something that may be undefined. */ export type Maybe = T | undefined /** Type alias for something that may be null. */ export type Nullable = T | null /** A typescript-compatible version of Object.hasOwnProperty. */ export function hasOwnProperty(obj: X, prop: Y): obj is X & Record { // 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 { ((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) }