Created
January 7, 2021 17:05
-
-
Save myobie/12435e146a09bc14c8548f6bb4e44fd9 to your computer and use it in GitHub Desktop.
Using estrella to build tests, serve-handler to serve the results, and playwright to execute the tests and stream the logs back to the node console
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const { join, resolve } = require('path') | |
const { basename, build, file, log, glob, cliopts, stdoutStyle, watch } = require('estrella') | |
async function serve (cwd, opts = null) { | |
opts || (opts = {}) | |
let pkgOpts = { devServerPort: null, publicPath: null, rewrites: null, redirects: null } | |
if (isNodeProject(cwd)) { | |
const pkg = JSON.parse(await file.read(join(cwd, 'package.json'), { encoding: 'utf8' })) | |
const { devServerPort, publicPath, rewrites, redirects } = pkg | |
pkgOpts = { devServerPort, publicPath, rewrites, redirects } | |
} | |
// NOTE: the port selection is weird becuase 0 is falsey, but it is a valid | |
// port value so we look for the first not-null item | |
const port = [opts.port, pkgOpts.devServerPort, 0].find(p => p !== undefined || p !== null) | |
const publicPath = opts.publicPath || pkgOpts.publicPath || 'public' | |
const public = join(cwd, publicPath) | |
const rewrites = pkgOpts.rewrites || [] | |
const redirects = pkgOpts.redirects || [] | |
const server = createServer((request, response) => { | |
const handlerOpts = { public, rewrites, redirects, directoryListing: false } | |
return handler(request, response, handlerOpts) | |
}) | |
const baseURL = `http://localhost:${port}` | |
server.listen(port, async () => { | |
if (!opts.quiet) { | |
log.info(stdoutStyle.green(`Serving ${public} at ${baseURL}`)) | |
} | |
}) | |
return server | |
} | |
async function buildBrowserTests (cwd) { | |
file.mkdirs(join(cwd, '_tests')) | |
await file.write(join(cwd, '_tests/tests.html'), `<!doctype html> | |
<html> | |
<head> | |
<meta charset="utf-8"> | |
<title>Tests</title> | |
</head> | |
<body> | |
<p>⌘⌥C</p> | |
<script src="/tests.js"></script> | |
</body> | |
</html>`) | |
return new Promise(resolve => { | |
let isResolved = false | |
const { rebuild } = build({ | |
cwd, | |
entry: glob(join(cwd, '**/*-test.ts')), | |
sourcemap: 'inline', | |
quiet: true, | |
bundle: true, | |
minify: false, | |
target: ['esnext'], | |
outfile: join(cwd, '_tests/tests.js'), | |
format: 'esm', | |
platform: 'browser', | |
tslint: false, | |
watch: false, | |
clear: false, | |
plugins: [findWorkspacePackagesPlugin], | |
onEnd: () => { | |
if (!isResolved) { | |
isResolved = true | |
resolve(rebuild) | |
} | |
} | |
}) | |
}) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { TimeoutTracker } from './timeout-tracker' | |
import { chromium, firefox, webkit, devices } from 'playwright' | |
import type { BrowserType, ChromiumBrowser, FirefoxBrowser, WebKitBrowser } from 'playwright' | |
type BrowserTypes = BrowserType<ChromiumBrowser> | BrowserType<FirefoxBrowser> | BrowserType<WebKitBrowser> | |
const browsers: {[key: string]: BrowserTypes } = { chromium, firefox, webkit } | |
type OptionalTestOpts = { | |
port?: number | |
browser?: 'webkit' | 'chromium' | 'firefox' | |
device?: string | |
onEnd?: () => Promise<void> | void | |
} | |
type TestOpts = { | |
port: number | |
browser: 'webkit' | 'chromium' | 'firefox' | |
device: string | |
} | |
const defaultTestOpts: TestOpts = { | |
port: 0, | |
browser: 'webkit', | |
device: 'none' | |
} | |
export async function runTestsInBrowser (maybeOpts?: OptionalTestOpts): Promise<boolean> { | |
const opts: TestOpts = Object.assign({}, defaultTestOpts, maybeOpts) | |
console.debug(`# browser: ${opts.browser}; device: ${opts.device}`) | |
const browserType = browsers[opts.browser] || webkit | |
const browser = await browserType.launch({ timeout: 120000 }) | |
const device = devices[opts.device] || {} | |
const t = new TimeoutTracker() | |
t.start() | |
async function close () { | |
await browser.close() | |
t.stop() | |
} | |
const context = await browser.newContext({ | |
...device | |
}) | |
const page = await context.newPage() | |
let failed = false | |
page.on('console', msg => { | |
t.receive() | |
const type = msg.type() | |
const text = msg.text() | |
// NOTE: Very basic way to detect failure from the TAP output | |
// TODO: parse the amount of failures, successes, etc | |
if (text.startsWith('# fail ') && !text.startsWith('# fail 0')) { | |
failed = true | |
} | |
// NOTE: We output a special comment when the custom test harness is done running | |
if (type === 'log' && text.startsWith('# %%DONE%%')) { | |
close() | |
} else { | |
if (type === 'debug' || type === 'log' || type === 'error') { | |
console[type](text) | |
} else { | |
console.debug(text) | |
} | |
} | |
}) | |
page.on('pageerror', exception => { | |
failed = true | |
console.error('# uncaught exception', exception.name, exception.message, exception.stack) | |
close() | |
}) | |
await page.goto(`http://localhost:${opts.port}/tests.html`) | |
await t.wait | |
return !failed | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { join } from 'path' | |
import { basename, cliopts, file, log, stdoutStyle, watch } from 'estrella' | |
import { runTestsInBrowser } from './run-tests' | |
const { buildBrowserTests, serve } = require('./build-utils') | |
// NOTE: we are just assuming _tests is an alright place to render to | |
export async function buildAndRunTests (cwd: string): Promise<void> { | |
if (cliopts.watch) { | |
log.error(stdoutStyle.red('tests are not compatible with -w, -watch; tests will watch for changes by default; use -o, -once to not watch')) | |
process.exit(1) | |
} | |
const [{ once }] = cliopts.parse( | |
['o, once', 'run the tests and then exit'] | |
) | |
await file.mkdirs(join(cwd, '_tests')) | |
await buildBrowserTests(cwd) | |
const server = await serve(cwd, { publicPath: '_tests' }) | |
const port = server.address().port | |
if (!once) { clear() } | |
const isSuccess = await runTestsInBrowser({ port }) | |
if (once) { | |
server.close() | |
process.exit(isSuccess ? 0 : 1) | |
} else { | |
await watch(cwd, async paths => { | |
if (meaningfulFileChange(paths)) { | |
clear() | |
await buildBrowserTests(cwd) | |
await runTestsInBrowser({ port }) | |
} | |
}) | |
} | |
} | |
function clear () { | |
process.stdout.write('\x1bc') | |
} | |
function meaningfulFileChange (paths: string[]): boolean { | |
return paths.some(path => { | |
if (basename(path).startsWith('.')) { | |
return false | |
} | |
if (path.includes('/_tests/') || path.startsWith('_tests/')) { | |
return false | |
} | |
return true | |
}) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export class TimeoutTracker { | |
timeout: number; | |
promise: Promise<void>; | |
resolve: () => void; | |
reject: (e: Error) => void; | |
isComplete; | |
constructor () { | |
this.timeout = 0 | |
this.isComplete = false | |
// NOTE: typescript is technically correct that if we only assign these | |
// values in the Promise constructor that it isn't happening "during init" | |
// of the class, but it's also like basically impossible. Either way, this | |
// is how we satisy tsc. | |
this.resolve = () => { console.error('resolve called at “impossible” time') } | |
this.reject = e => { console.error('reject called at “impossible” time', e) } | |
this.promise = new Promise((resolve, reject) => { | |
this.resolve = () => { resolve() } | |
this.reject = e => { reject(e) } | |
}) | |
} | |
get wait (): Promise<void> { | |
return this.promise | |
} | |
start (): void { | |
this.receive() | |
} | |
stop (): void { | |
if (!this.isComplete) { | |
this.isComplete = true | |
this.resolve() | |
} | |
} | |
receive (): void { | |
// eslint-disable-next-line @typescript-eslint/no-explicit-any | |
clearTimeout(this.timeout as any) | |
this.timeout = setTimeout(() => { | |
this.isComplete = true | |
this.reject(new Error('timeout')) | |
}, 30000) as unknown as number | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment