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
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)
|
|
}
|