Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
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
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)
}
}
})
})
}
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
}
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
})
}
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