Big Bang
This commit is contained in:
356
src/gtlib.ts
Normal file
356
src/gtlib.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
/*
|
||||
* 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)
|
||||
}
|
||||
20
src/index.test.ts
Normal file
20
src/index.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import {failUnless, gtState, safeNavigate, launch, sleep, cleanup} from './gtlib'
|
||||
|
||||
jest.setTimeout((2).minutes())
|
||||
|
||||
const state = gtState()
|
||||
|
||||
beforeEach(() => launch(state), (30).seconds())
|
||||
|
||||
test('it should display my name in the hero box', async () => {
|
||||
await sleep((1).second())
|
||||
const page = await safeNavigate(state, 'https://garrettmills.dev')
|
||||
|
||||
const heroBox =
|
||||
failUnless(await page.$('.hero-box'), 'Unable to find .hero-box')
|
||||
|
||||
const name = await heroBox.evaluate(x => x.innerHTML.trim().toLowerCase())
|
||||
failUnless(name.includes('garrett mills'), 'Unable to find name in .hero-box HTML')
|
||||
})
|
||||
|
||||
afterAll(() => cleanup(state))
|
||||
Reference in New Issue
Block a user