This commit is contained in:
Garrett Mills 2023-05-15 00:03:49 -05:00
commit b780eb7292
16 changed files with 4198 additions and 0 deletions

4
.eslintignore Normal file
View File

@ -0,0 +1,4 @@
node_modules
lib
dist

114
.eslintrc.json Normal file
View File

@ -0,0 +1,114 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 12,
"sourceType": "module"
},
"plugins": [
"@typescript-eslint"
],
"rules": {
"indent": [
"error",
4
],
"linebreak-style": [
"error",
"unix"
],
"quotes": [
"error",
"single",
{
"allowTemplateLiterals": true
}
],
"semi": [
"error",
"never"
],
"no-console": "error",
"curly": "error",
"eqeqeq": "error",
"guard-for-in": "error",
"no-alert": "error",
"no-caller": "error",
"no-constructor-return": "error",
"no-eval": "error",
"no-implicit-coercion": "error",
"no-implied-eval": "error",
"no-invalid-this": "error",
"no-return-await": "error",
"no-throw-literal": "error",
"no-useless-call": "error",
"radix": "error",
"yoda": "error",
"@typescript-eslint/no-shadow": "error",
"brace-style": "error",
"camelcase": "error",
"comma-dangle": [
"error",
"always-multiline"
],
"comma-spacing": [
"error",
{
"before": false,
"after": true
}
],
"comma-style": [
"error",
"last"
],
"computed-property-spacing": [
"error",
"never"
],
"eol-last": "error",
"func-call-spacing": [
"error",
"never"
],
"keyword-spacing": [
"error",
{
"before": true,
"after": true
}
],
"lines-between-class-members": "error",
"max-params": [
"error",
4
],
"new-parens": [
"error",
"always"
],
"newline-per-chained-call": "error",
"no-trailing-spaces": "error",
"no-underscore-dangle": "error",
"no-unneeded-ternary": "error",
"no-whitespace-before-property": "error",
"object-property-newline": "error",
"prefer-exponentiation-operator": "error",
"prefer-object-spread": "error",
"spaced-comment": [
"error",
"always"
],
"prefer-const": "error",
"@typescript-eslint/no-explicit-any": "off"
}
}

192
.gitignore vendored Normal file
View File

@ -0,0 +1,192 @@
# ---> JetBrains
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
# ---> Node
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
/lib

8
.idea/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

6
.idea/misc.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

8
.idea/modules.xml Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/puppeteer.iml" filepath="$PROJECT_DIR$/.idea/puppeteer.iml" />
</modules>
</component>
</project>

9
.idea/puppeteer.iml Normal file
View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

9
LICENSE Normal file
View File

@ -0,0 +1,9 @@
MIT License
Copyright (c) <year> <copyright holders>
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.

3
README.md Normal file
View File

@ -0,0 +1,3 @@
# `puppeteer-jest-example`

16
jest.config.ts Normal file
View File

@ -0,0 +1,16 @@
export default {
roots: ['<rootDir>/src'],
transform: {
'^.+\\.tsx?$': 'ts-jest',
},
testRegex: '(\\.|/)(test|spec)\\.tsx?$',
moduleFileExtensions: [
'ts',
'tsx',
'js',
'jsx',
'json',
'node',
],
}

49
package.json Normal file
View File

@ -0,0 +1,49 @@
{
"name": "@garrettmills/puppeteer",
"version": "0.1.0",
"description": "An example of e2e tests using Puppeteer, TypeScript, and Jest",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"directories": {
"lib": "lib"
},
"scripts": {
"test": "jest --runInBand",
"build": "pnpm run lint && rimraf lib && tsc",
"app": "pnpm run build && node lib/index.js",
"prepare": "pnpm run build",
"lint": "eslint . --ext .ts",
"lint:fix": "eslint --fix . --ext .ts"
},
"files": [
"lib/**/*"
],
"prepare": "pnpm run build",
"postversion": "git push && git push --tags",
"repository": {
"type": "git",
"url": "https://code.garrettmills.dev/garrettmills/puppeteer-e2e"
},
"author": "Garrett Mills <shout@garrettmills.dev>",
"license": "MIT",
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^5.59.5",
"@typescript-eslint/parser": "^5.59.5",
"eslint": "^8.40.0"
},
"dependencies": {
"@types/jest": "^29.5.1",
"@types/node": "^20.1.4",
"@types/rimraf": "^3.0.2",
"@types/uuid": "^8.3.4",
"dotenv": "^10.0.0",
"jest": "^29.5.0",
"mkdirp": "^1.0.4",
"puppeteer": "^20.2.0",
"rimraf": "^3.0.2",
"ts-jest": "^29.1.0",
"ts-node": "^10.9.1",
"typescript": "^4.9.5",
"uuid": "^8.3.2"
}
}

3385
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

356
src/gtlib.ts Normal file
View 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
View 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))

13
tsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"declaration": true,
"outDir": "./lib",
"strict": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["src"],
"exclude": ["node_modules"]
}