diff --git a/README.md b/README.md index 9b4920f..b68d631 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,8 @@ If you want Node.js API, ```ts import { Runner } from 'wrightplay/node'; -const runner = new Runner({ +// Or manually calling `runner.dispose()` to release resources +await using runner = new Runner({ setup: 'test/setup.ts', tests: 'test/**/*.spec.ts', }); diff --git a/src/cli/api.ts b/src/cli/api.ts index d6029a9..ad379c2 100644 --- a/src/cli/api.ts +++ b/src/cli/api.ts @@ -77,7 +77,7 @@ export const program = command const runnerOptionsList = await parseRunnerOptionsFromCLI(testAndEntries, options); await runnerOptionsList.reduce(async (last, runnerOptions) => { await last; - using runner = new Runner(runnerOptions); + await using runner = new Runner(runnerOptions); const exitCode = await runner.runTests(); process.exitCode ||= exitCode; }, Promise.resolve()); diff --git a/src/common/utils/patchDisposable.ts b/src/common/utils/patchDisposable.ts index f0e9868..3e0b530 100644 --- a/src/common/utils/patchDisposable.ts +++ b/src/common/utils/patchDisposable.ts @@ -1,8 +1,286 @@ /** - * Simple polyfill that covers the `using` and `async using` use cases. + * Simple explicit resource management API polyfill. + * + * https://github.com/tc39/proposal-explicit-resource-management */ -// @ts-expect-error polyfill -Symbol.dispose ??= Symbol('Symbol.dispose'); -// @ts-expect-error polyfill -Symbol.asyncDispose ??= Symbol('Symbol.asyncDispose'); +/* eslint-disable max-classes-per-file */ +/* c8 ignore start */ + +if (!Symbol.dispose) { + Object.defineProperty(Symbol, 'dispose', { + value: Symbol('Symbol.dispose'), + writable: false, + enumerable: false, + configurable: false, + }); +} + +if (!Symbol.asyncDispose) { + Object.defineProperty(Symbol, 'asyncDispose', { + value: Symbol('Symbol.asyncDispose'), + writable: false, + enumerable: false, + configurable: false, + }); +} + +globalThis.SuppressedError ??= (() => { + const nonEnumerableDescriptor = { writable: true, enumerable: false, configurable: true }; + const SEConstructor = function SuppressedError( + this: SuppressedError, + error: unknown, + suppressed: unknown, + message?: string, + ) { + if (new.target === undefined) { + return new SEConstructor(error, suppressed, message); + } + if (message !== undefined) { + Object.defineProperty(this, 'message', { value: String(message), ...nonEnumerableDescriptor }); + } + Object.defineProperties(this, { + error: { value: error, ...nonEnumerableDescriptor }, + suppressed: { value: suppressed, ...nonEnumerableDescriptor }, + }); + } as SuppressedErrorConstructor; + + Object.setPrototypeOf(SEConstructor.prototype, Error.prototype); + Object.defineProperties(SEConstructor.prototype, { + message: { value: '', ...nonEnumerableDescriptor }, + name: { value: 'SuppressedError', ...nonEnumerableDescriptor }, + }); + + return SEConstructor; +})(); + +globalThis.DisposableStack ??= class DisposableStack { + #disposed = false; + + get disposed() { + return this.#disposed; + } + + #stack: { + v: Disposable | undefined, + m: ((this: Disposable | undefined) => unknown), + }[] = []; + + dispose() { + if (this.#disposed) return; + this.#disposed = true; + + const stack = this.#stack; + this.#stack = []; + + let hasError = false; + let error: unknown; + + while (stack.length > 0) { + const { m, v } = stack.pop()!; + try { + m.call(v); + } catch (e) { + error = hasError ? new SuppressedError(e, error, 'An error was suppressed during disposal.') : e; + hasError = true; + } + } + + if (hasError) { + throw error; + } + } + + use(value: T): T { + if (this.#disposed) { + throw new ReferenceError('This stack has already been disposed'); + } + + if (value !== null && value !== undefined) { + const method = Symbol.dispose in value + ? value[Symbol.dispose] + : undefined; + if (typeof method !== 'function') { + throw new TypeError('The value is not disposable'); + } + this.#stack.push({ v: value, m: method }); + } + + return value; + } + + adopt(value: T, onDispose: (value: T) => void): T { + if (this.#disposed) { + throw new ReferenceError('This stack has already been disposed'); + } + + if (typeof onDispose !== 'function') { + throw new TypeError('The callback is not a function'); + } + + this.#stack.push({ v: undefined, m: () => onDispose.call(undefined, value) }); + + return value; + } + + defer(onDispose: () => void): void { + if (this.#disposed) { + throw new ReferenceError('This stack has already been disposed'); + } + + if (typeof onDispose !== 'function') { + throw new TypeError('The callback is not a function'); + } + + this.#stack.push({ v: undefined, m: onDispose }); + } + + move(): DisposableStack { + if (this.#disposed) { + throw new ReferenceError('This stack has already been disposed'); + } + + const stack = new DisposableStack(); + stack.#stack = this.#stack; + + this.#disposed = true; + this.#stack = []; + + return stack; + } + + [Symbol.dispose]() { + return this.dispose(); + } + + declare readonly [Symbol.toStringTag]: string; + + static { + Object.defineProperty(this.prototype, Symbol.toStringTag, { + value: 'DisposableStack', + writable: false, + enumerable: false, + configurable: true, + }); + } +}; + +globalThis.AsyncDisposableStack ??= class AsyncDisposableStack { + #disposed = false; + + get disposed() { + return this.#disposed; + } + + #stack: { + v: AsyncDisposable | Disposable | undefined, + m: ((this: AsyncDisposable | Disposable | undefined) => unknown) | undefined, + }[] = []; + + async disposeAsync() { + if (this.#disposed) return; + this.#disposed = true; + + const stack = this.#stack; + this.#stack = []; + + let hasError = false; + let error: unknown; + + while (stack.length > 0) { + const { m, v } = stack.pop()!; + try { + // eslint-disable-next-line no-await-in-loop + await (m?.call(v)); + } catch (e) { + error = hasError ? new SuppressedError(e, error, 'An error was suppressed during disposal.') : e; + hasError = true; + } + } + + if (hasError) { + throw error; + } + } + + use(value: T): T { + if (this.#disposed) { + throw new ReferenceError('This async stack has already been disposed'); + } + + if (value === null || value === undefined) { + this.#stack.push({ v: undefined, m: undefined }); + } else { + let method = Symbol.asyncDispose in value + ? value[Symbol.asyncDispose] as () => unknown + : undefined; + if (method === undefined) { + const syncDispose = Symbol.dispose in value ? value[Symbol.dispose] : undefined; + if (typeof syncDispose === 'function') { + method = function omitReturnValue(this: unknown) { syncDispose.call(this); }; + } + } + if (typeof method !== 'function') { + throw new TypeError('The value is not disposable'); + } + this.#stack.push({ v: value, m: method }); + } + + return value; + } + + adopt(value: T, onDisposeAsync: (value: T) => PromiseLike | void): T { + if (this.#disposed) { + throw new ReferenceError('This async stack has already been disposed'); + } + + if (typeof onDisposeAsync !== 'function') { + throw new TypeError('The callback is not a function'); + } + + this.#stack.push({ v: undefined, m: () => onDisposeAsync.call(undefined, value) }); + + return value; + } + + defer(onDisposeAsync: () => PromiseLike | void): void { + if (this.#disposed) { + throw new ReferenceError('This async stack has already been disposed'); + } + + if (typeof onDisposeAsync !== 'function') { + throw new TypeError('The callback is not a function'); + } + + this.#stack.push({ v: undefined, m: onDisposeAsync }); + } + + move(): AsyncDisposableStack { + if (this.#disposed) { + throw new ReferenceError('This async stack has already been disposed'); + } + + const stack = new AsyncDisposableStack(); + stack.#stack = this.#stack; + + this.#disposed = true; + this.#stack = []; + + return stack; + } + + [Symbol.asyncDispose]() { + return this.disposeAsync(); + } + + declare readonly [Symbol.toStringTag]: string; + + static { + Object.defineProperty(this.prototype, Symbol.toStringTag, { + value: 'AsyncDisposableStack', + writable: false, + enumerable: false, + configurable: true, + }); + } +}; diff --git a/src/server/BrowserLogger.ts b/src/server/BrowserLogger.ts index 5065a73..62c54cc 100644 --- a/src/server/BrowserLogger.ts +++ b/src/server/BrowserLogger.ts @@ -1,10 +1,10 @@ -import path from 'path'; import util from 'util'; -import { pathToFileURL } from 'url'; import { SourceMap, SourceMapPayload } from 'module'; import chalk from 'chalk'; -import type { ConsoleMessage } from 'playwright-core'; +import type { BrowserContext, ConsoleMessage, WebError } from 'playwright'; + +import '../common/utils/patchDisposable.js'; /** * Playwright protocol type to console level mappings. @@ -37,11 +37,6 @@ export const protocolTypeToConsoleLevel = { export type ProtocolType = keyof typeof protocolTypeToConsoleLevel; export interface BrowserLogOptions { - /** - * The base dictionary of mapped file paths. - */ - cwd?: string; - /** * Browser type affects the output stack trace strings. * @@ -51,6 +46,11 @@ export interface BrowserLogOptions { */ browserType?: 'chromium' | 'firefox' | 'webkit'; + /** + * The browser context that the logger is attached to. + */ + browserContext?: BrowserContext; + /** * Pathname to source map payload map. * For instance, '/stdin.js' -> { version: 3, ... }. @@ -69,11 +69,14 @@ export interface PrintOptions { color?: (text: string) => string; } -export default class BrowserLogger { - readonly cwd: string; - +export default class BrowserLogger implements AsyncDisposable { readonly browserType: 'chromium' | 'firefox' | 'webkit'; + /** + * The browser context that the logger is attached to. + */ + readonly browserContext: BrowserContext | undefined; + /** * The prefix of stack traces in the target browser. * Chromium-based prefix stack trace paths with ` at ` (4-spaces, word "at", 1-space), @@ -107,14 +110,13 @@ export default class BrowserLogger { readonly stackTraceRegex: RegExp; constructor({ - cwd = process.cwd(), browserType = 'chromium', + browserContext, sourceMapPayloads = new Map(), originalStackBase = 'http://127.0.0.1', }: BrowserLogOptions = {}) { - this.cwd = cwd; - this.browserType = browserType; + this.browserContext = browserContext; this.stackTracePrefix = browserType === 'chromium' ? ' at ' : '@'; this.uncaughtErrorPrefix = browserType === 'webkit' ? '' : 'Uncaught '; @@ -138,7 +140,6 @@ export default class BrowserLogger { */ mapStack(text: string) { const { - cwd, stackTraceRegex, sourceMapPayloads, sourceMapCache, @@ -179,20 +180,11 @@ export default class BrowserLogger { return original; } - const baseDir = path.join(cwd, path.dirname(pathname)); - const originalSourcePath = path.resolve(baseDir, originalSource); - return `${pathToFileURL(originalSourcePath).href}:${originalLine + 1}:${originalColumn + 1}`; + return `${originalSource}:${originalLine + 1}:${originalColumn + 1}`; }); } - lastPrint: Promise = Promise.resolve(); - - /** - * Discard the last print error. - */ - discardLastPrintError() { - this.lastPrint = this.lastPrint.catch(() => {}); - } + private lastPrint: Promise = Promise.resolve(); /** * Print messages to console with specified log level and color. @@ -304,6 +296,10 @@ export default class BrowserLogger { } const argsPromise = (async () => { + if (argHandles.length === 0) { + return []; + } + /** * Parse the type of the first argument and the JSON presentation of each argument. * `evaluate` does the exact same serialize steps as `jsonValue` but a lot quicker @@ -311,10 +307,10 @@ export default class BrowserLogger { * Circular references are supported on Playwright >= 1.22 but undocumented yet. * @see import('playwright-core').JSHandle.jsonValue */ - const [firstIsString, args = []] = await (argHandles[0] as typeof argHandles[0] | undefined) - ?.evaluate((firstArg, passedArgs: unknown[]) => ( - [typeof firstArg === 'string', passedArgs] - ), argHandles) || []; + const [firstIsString, args = []] = await argHandles[0].evaluate( + (firstArg, passedArgs: unknown[]) => [typeof firstArg === 'string', passedArgs], + argHandles, + ); /** * If the first arg is not a string but mapped to a string, escape `%`. @@ -325,7 +321,9 @@ export default class BrowserLogger { } return args; - })(); + })() + // Fallback to the original message text. + .catch(() => [`[Incomplete] ${text}`]); switch (level) { case 'info': @@ -358,7 +356,9 @@ export default class BrowserLogger { /** * Print a playwright browser error to console. */ - readonly forwardError = (error: Error) => { + readonly forwardError = (webError: WebError) => { + const error = webError.error(); + // Leave errors without stack info as they are. if (!error.stack) { this.error([error]); @@ -373,4 +373,25 @@ export default class BrowserLogger { ), ]); }; + + startForwarding() { + if (!this.browserContext) return; + this.browserContext.on('console', this.forwardConsole); + this.browserContext.on('weberror', this.forwardError); + } + + pauseForwarding() { + if (!this.browserContext) return; + this.browserContext.off('console', this.forwardConsole); + this.browserContext.off('weberror', this.forwardError); + } + + async stopForwarding() { + this.pauseForwarding(); + await this.lastPrint; + } + + [Symbol.asyncDispose]() { + return this.stopForwarding(); + } } diff --git a/src/server/Runner.ts b/src/server/Runner.ts index c28115a..f789cb1 100644 --- a/src/server/Runner.ts +++ b/src/server/Runner.ts @@ -1,21 +1,12 @@ -import http from 'node:http'; import path from 'node:path'; import { randomUUID } from 'node:crypto'; -import { fileURLToPath } from 'node:url'; -import type { AddressInfo } from 'node:net'; -import type { SourceMapPayload } from 'node:module'; import playwright from 'playwright'; -import { lookup as mimeLookup } from 'mrmime'; -import getPort, { portNumbers } from 'get-port'; -import esbuild from 'esbuild'; -import sirv from 'sirv'; import '../common/utils/patchDisposable.js'; -import EventEmitter from './utils/TypedEventEmitter.js'; -import TestFinder from './TestFinder.js'; import BrowserLogger from './BrowserLogger.js'; import CoverageReporter from './CoverageReporter.js'; +import TestServer from './TestServer.js'; import WSServer from './ws/WSServer.js'; import * as clientRunner from '../client/runner.js'; @@ -77,36 +68,11 @@ export interface RunnerOptions { noCov?: boolean; } -export interface FileServer extends http.Server { - address(): AddressInfo; -} - export type BrowserServer = playwright.BrowserServer; -/** - * Absolute path to static file directory. - */ -export const staticDir = fileURLToPath(new URL('../../static', import.meta.url)); - -export default class Runner implements Disposable { +export default class Runner implements AsyncDisposable { readonly cwd: string; - /** - * File to run before the test files. - */ - readonly setupFile: string | undefined; - - /** - * Test file finder and watcher. - */ - readonly testFinder: TestFinder; - - /** - * Additional entry points to build. - * @see [Entry points | esbuild - API](https://esbuild.github.io/api/#entry-points) - */ - readonly entryPoints: Record; - /** * Monitor test file changes and trigger automatic test reruns. */ @@ -117,18 +83,22 @@ export default class Runner implements Disposable { */ readonly browserType: BrowserTypeName; - /** - * Options used to launch the test browser server. - * @see playwright.BrowserType.launchServer - */ - readonly browserServerOptions: BrowserServerOptions; - /** * Whether to run browser in headless mode. * @see BrowserServerOptions.headless */ readonly headless: boolean; + /** + * File server for the test files. + */ + readonly testServer: TestServer; + + /** + * Browser server launch promise. + */ + readonly browserServerPromise: Promise; + /** * Directory to save the coverage output file. Defaults to `NODE_V8_COVERAGE` * unless noCov option is `true`. @@ -151,325 +121,83 @@ export default class Runner implements Disposable { headless = browserServerOptions.headless ?? !browserServerOptions.devtools, noCov = browser !== 'chromium', }: RunnerOptions) { - this.setupFile = setup; - this.entryPoints = entryPoints; this.watch = watch; this.browserType = browser; - this.browserServerOptions = browserServerOptions; this.headless = headless; this.cwd = path.resolve(cwd); - this.testFinder = new TestFinder({ - patterns: tests, + this.testServer = new TestServer({ cwd: this.cwd, - watch: this.watch, - }); - - // Resolve coverage folder. Defaults to NODE_V8_COVERAGE - if (!noCov && process.env.NODE_V8_COVERAGE) { - this.reportCoverageDir = path.resolve(this.cwd, process.env.NODE_V8_COVERAGE); - } - - this.cwdRequestListener = sirv(this.cwd, { - dev: true, - }); - this.staticRequestListener = sirv(staticDir, { - dev: true, - onNoMatch: this.cwdRequestListener, - }); - } - - /** - * Pathname to built file hash & content map. - * For instance, '/stdin.js' -> { text: 'console.log(1)' hash: 'xxx' }. - */ - readonly fileContents: Map = new Map(); - - /** - * Pathname to source map payload map. - * For instance, '/stdin.js' -> { version: 3, ... }. - */ - readonly sourceMapPayloads: Map = new Map(); - - private updateBuiltFiles(files: esbuild.OutputFile[]) { - const { cwd, fileContents, sourceMapPayloads } = this; - return files.reduce((changed, { path: absPath, hash, text }) => { - const pathname = `/${path.relative(cwd, absPath).replace(/\\/g, '/')}`; - - // Skip unchanged files. - const same = fileContents.get(pathname)?.hash === hash; - if (same) return changed; - - fileContents.set(pathname, { text, hash }); - - // Cache source maps for stack trace and coverage. - if (pathname.endsWith('.map')) { - sourceMapPayloads.set( - pathname.slice(0, -4), - JSON.parse(text) as SourceMapPayload, - ); - } - - return true; - }, false); - } - - private readonly cwdRequestListener: http.RequestListener; - - private readonly staticRequestListener: http.RequestListener; - - async launchFileServer(): Promise { - const { - cwd, - setupFile, - testFinder, + setup, + tests, entryPoints, watch, - fileContents, - staticRequestListener, - } = this; - - let building = true; - const buildEventEmitter = new EventEmitter<{ - ready: []; - changed: [buildCount: number]; - }>(); - const buildContext = await esbuild.context({ - entryPoints: { - ...entryPoints, - // The stdin API doesn't support onLoad callbacks, - // so we use the entry point workaround. - // https://github.com/evanw/esbuild/issues/720 - '__wrightplay__/stdin': '', - }, - metafile: watch, - bundle: true, - format: 'esm', - sourcemap: 'linked', - outdir: './', - absWorkingDir: cwd, - define: { WRIGHTPLAY_CLIENT_UUID: `'${this.uuid}'` }, - plugins: [ - { - name: 'import files loader', - setup: (pluginBuild) => { - pluginBuild.onResolve({ filter: /^$/ }, () => ({ path: 'stdin', namespace: 'wrightplay' })); - pluginBuild.onLoad({ filter: /^/, namespace: 'wrightplay' }, async () => { - // Sort to make the output stable - const importFiles = await testFinder.getFiles(); - importFiles.sort(); - - // Prepend the setup file if any - if (setupFile) importFiles.unshift(path.resolve(cwd, setupFile).replace(/\\/g, '/')); - - if (importFiles.length === 0) { - if (watch) { - // eslint-disable-next-line no-console - console.error('No test file found'); - } else { - throw new Error('No test file found'); - } - } - - const importStatements = importFiles.map((file) => `import '${file}'`).join('\n'); - return { - contents: `${importStatements}\n(${clientRunner.init.toString()})('${this.uuid}')`, - resolveDir: cwd, - }; - }); - }, - }, - { - name: 'built files updater', - setup: (pluginBuild) => { - let buildCount = 0; - let lastBuildFailed = false; - pluginBuild.onStart(() => { - building = true; - }); - pluginBuild.onEnd((result) => { - building = false; - buildCount += 1; - const files = result.outputFiles!; - const changed = this.updateBuiltFiles(files); - buildEventEmitter.emit('ready'); // signals the http server to respond - - if (!watch) return; - - // Watch the errored files if any. - // This may not help the cases where the error may be resolved - // in another dir (TestFinder watches the dir instead of the file), - // but still better than nothing. - const watchFiles: string[] = []; - result.errors.forEach((error) => { - if (!error.location) return; - watchFiles.push(error.location.file); - }); - - if (watchFiles.length > 0) { - lastBuildFailed = true; - testFinder.setRelevantFiles(watchFiles); - return; - } - - // Return if the built content remains unchanged and no recovery is needed. - // Since built content remains the same during errors, we should identify a - // successful rerun that can replace previous esbuild error messages with - // the latest test results, even if the content has been run before. - if (!changed && !lastBuildFailed) return; - lastBuildFailed = false; - - // Watch the imported files. - const { inputs } = result.metafile!; - Object.values(inputs).forEach((input) => { - input.imports.forEach((im) => { - if (im.external || im.path.startsWith('(disabled):')) return; - watchFiles.push(im.path.replace(/[?#].+$/, '')); - }); - }); - - testFinder.setRelevantFiles(watchFiles); - - // Emit the updated event so as to trigger a rerun - buildEventEmitter.emit('changed', buildCount); - }); - }, - }, - ], - write: false, + uuid: this.uuid, }); - if (watch) { - testFinder.on('change', () => { - buildContext.rebuild() - // Do nothing as esbuild prints the errors itself - .catch(() => {}); - }); - - testFinder.updateFiles(); - } else { - // Non-watch mode automatically triggers `updateFiles` on construction, - // so we don't need to manually call it here. - await buildContext.rebuild(); - } - - const esbuildListener: http.RequestListener = (request, response) => { - const { pathname } = new URL(request.url!, `http://${request.headers.host}`); - - const handleRequest = () => { - const builtContent = fileContents.get(pathname); - if (!builtContent) { - staticRequestListener(request, response); - return; - } - - const mimeType = mimeLookup(pathname) || ''; - response.writeHead(200, { - 'Content-Type': `${mimeType}; charset=utf-8`, - }); - response.end(builtContent.text); - }; - - if (building) { - buildEventEmitter.once('ready', handleRequest); - } else { - handleRequest(); - } - }; - - const server = http.createServer(esbuildListener) as FileServer; - server.on('close', () => { - buildContext.dispose() - // Do nothing as esbuild prints the errors itself - .catch(() => {}); - }); - - // Forward file change event for the reruns. - buildEventEmitter.on('changed', (count) => { - // Bypass the first build. - if (count === 1) return; - server.emit('wrightplay:changed'); + this.browserServerPromise = playwright[browser].launchServer({ + ...browserServerOptions, + headless, }); - // This is helpful if one day esbuild Incremental API supports - // exiting with the main process without calling dispose. - // Currently it's just useless. - server.unref(); - - // Avoid browser blocked ports. - const port = await getPort({ port: portNumbers(10081, 65535) }); - await new Promise((resolve) => { - server.listen(port, '127.0.0.1', resolve); - }); - return server; - } - - async launchBrowserServer(): Promise { - const serverOptions: BrowserServerOptions = { - ...this.browserServerOptions, - headless: this.headless, - }; - return playwright[this.browserType].launchServer(serverOptions); + // Resolve coverage folder. Defaults to NODE_V8_COVERAGE + if (!noCov && process.env.NODE_V8_COVERAGE && browser === 'chromium') { + this.reportCoverageDir = path.resolve(this.cwd, process.env.NODE_V8_COVERAGE); + } } /** * Start the tests and return the exit code. */ async runTests(): Promise { - const fileServerLaunch = this.launchFileServer(); - - // esbuild Incremental API will hang until dispose is called, - // so be sure to dispose by closing the file server on errors. - return this.runTestsForFileServerLaunch(fileServerLaunch) - .catch(async (e) => { - (await fileServerLaunch).close(); - throw e; - }); - } + await using stack = new AsyncDisposableStack(); - /** - * Start the tests and return the exit code. - * Receive a file server promise to help the outer function to - * close the file server even on errors. - */ - async runTestsForFileServerLaunch( - fileServerLaunch: Promise, - ): Promise { - // const [fileServer, browserServer] = await Promise.all([ - // this.launchFileServer(), - // this.launchBrowserServer(), - // ]); - const [fileServer, browserServer] = await Promise.all([ - fileServerLaunch, - this.launchBrowserServer(), + const [addressInfo, browserServer] = await Promise.all([ + this.testServer.launch(), + this.browserServerPromise, ]); + stack.defer(() => this.testServer.close()); + const browser = await playwright[this.browserType].connect(browserServer.wsEndpoint()); - const { port } = fileServer.address(); - const baseURL = `http://127.0.0.1:${port}`; - const page = await browser.newPage({ + stack.defer(() => browser.close()); + + const { address, port } = addressInfo; + const baseURL = `http://${address}:${port}`; + const browserContext = await browser.newContext({ baseURL, }); - - const { cwd, browserType, sourceMapPayloads } = this; - const bLog = new BrowserLogger({ - cwd, + stack.defer(() => browserContext.close()); + + // Create the page to run the tests. + // This is intentionally created before the browser logger to avoid + // the page being disposed before the logger has finished. + // You can take it as the logger somehow depends on the page. + const page = await browserContext.newPage(); + stack.defer(() => page.close()); + + const { cwd, browserType, testServer } = this; + const { sourceMapPayloads, httpServer } = testServer; + const bLog = stack.use(new BrowserLogger({ browserType, + browserContext, sourceMapPayloads, originalStackBase: baseURL, - }); + })); // Forward browser console messages. - page.on('console', bLog.forwardConsole); - page.on('pageerror', bLog.forwardError); + bLog.startForwarding(); - const wsServer = new WSServer(this.uuid, fileServer, page); + const wsServer = stack.use(new WSServer(this.uuid, httpServer, page)); const run = async () => { + using runStack = new DisposableStack(); + // Listen to the file change event during the test run to // ignore the evaluate error caused by automatic test reruns. let fileChanged = false; const fileChangeListener = () => { fileChanged = true; }; - fileServer.once('wrightplay:changed', fileChangeListener); + testServer.once('changed', fileChangeListener); + runStack.defer(() => { testServer.off('changed', fileChangeListener); }); try { await wsServer.reset(); @@ -479,45 +207,26 @@ export default class Runner implements Disposable { // eslint-disable-next-line no-console if (!fileChanged) console.error(error); return 1; - } finally { - // Remove the listener to avoid potential memory leak. - fileServer.off('wrightplay:changed', fileChangeListener); } }; - await page.goto('/', { waitUntil: 'domcontentloaded' }); + await page.goto('/'); // Rerun the tests on file changes. - fileServer.on('wrightplay:changed', () => { - (async () => { - // Discard the print error on navigation. - page.off('console', bLog.forwardConsole); - page.off('pageerror', bLog.forwardError); - bLog.discardLastPrintError(); - - // Reload the page to rerun the tests. - await page.reload({ waitUntil: 'commit' }); - - // Restore the print forwarding. - page.on('console', bLog.forwardConsole); - page.on('pageerror', bLog.forwardError); - })().catch(() => { - // eslint-disable-next-line no-console - console.error('Failed to rerun the tests after file changes'); - }); + testServer.on('changed', () => { + // Reload the page to rerun the tests. + page.reload({ waitUntil: 'commit' }) + .catch(() => { + // eslint-disable-next-line no-console + console.error('Failed to rerun the tests after file changes'); + }); }); // Record coverage if required. // Only support chromium atm. - const recordingCoverage = this.reportCoverageDir - ? await page.coverage.startJSCoverage() - .then(() => true) - .catch(() => { - // eslint-disable-next-line no-console - console.error(`Failed to use Coverage APIs on ${this.browserServerOptions.channel ?? browserType} ${browser.version()}`); - return false; - }) - : false; + if (this.reportCoverageDir) { + await page.coverage.startJSCoverage(); + } let exitCodePromise = run(); page.on('load', () => { @@ -529,38 +238,39 @@ export default class Runner implements Disposable { }); // Wait the first run. - // The tests may run multiple times in headed mode. + // The tests may run multiple times in headed / watch mode. await exitCodePromise; - // Report coverage of the first run if recording. - // We only record the first run even in headed mode - // since we can't get coverage data on page close, which may happen at any time in that mode. - if (recordingCoverage) { + // Stop coverage recording and save the report. + // We only record the first run even in headed / watch mode since + // we can't get coverage data after the page is closed, and the + // close time is totally unpredictable in headed / watch mode. + if (this.reportCoverageDir) { const coverageResult = await page.coverage.stopJSCoverage(); const coverageReporter = new CoverageReporter(coverageResult, { cwd, sourceMapPayloads, - pid: browserServer.process().pid as number, + pid: browserServer.process().pid!, }); - await coverageReporter.save(this.reportCoverageDir as string); + await coverageReporter.save(this.reportCoverageDir); } - if (!this.watch && this.headless) { - page.off('console', bLog.forwardConsole); - page.off('pageerror', bLog.forwardError); - await bLog.lastPrint; - await page.close(); - } else if (!page.isClosed()) { + // In headed / watch mode, wait for the browser to close. + if (this.watch || !this.headless) { await page.waitForEvent('close', { timeout: 0 }); } - await browserServer.close(); - fileServer.close(); - return exitCodePromise; } - [Symbol.dispose]() { - this.testFinder[Symbol.dispose](); + async dispose() { + await Promise.all([ + this.browserServerPromise.then((browserServer) => browserServer.close()), + this.testServer[Symbol.asyncDispose](), + ]); + } + + [Symbol.asyncDispose]() { + return this.dispose(); } } diff --git a/src/server/TestFinder.ts b/src/server/TestFinder.ts index 1c75849..b1aba44 100644 --- a/src/server/TestFinder.ts +++ b/src/server/TestFinder.ts @@ -37,12 +37,7 @@ export default class TestFinder extends EventEmitter impleme this.cwd = cwd; this.watch = watch; - if (watch) { - this.filesPromise = Promise.resolve([]); - } else { - this.filesPromise = this.searchFiles(); - return; - } + if (!watch) return; // Parse pattern dirs to watch recursively let patternDirs: string[] = []; @@ -81,7 +76,7 @@ export default class TestFinder extends EventEmitter impleme ); } - private filesPromise: Promise; + private filesPromise = Promise.resolve([]); private searchFiles() { return globby(this.patterns, { @@ -93,12 +88,9 @@ export default class TestFinder extends EventEmitter impleme /** * Update the test file list. - * - In non-watch mode, - * - is automatically called once when constructed. - * - should be manually called to update the files. - * - In watch mode, - * - should be manually called once before getting the files. - * - is automatically called on file changes. + * + * In watch mode, this will be automatically called on file changes, + * but you may still need to call this once for the initial file list. */ updateFiles() { this.filesPromise = this.searchFiles().then((files) => { @@ -181,7 +173,7 @@ export default class TestFinder extends EventEmitter impleme }, 100); }; - [Symbol.dispose]() { + dispose() { if (!this.watch) return; this.patternWatcherMap.forEach((watcher) => { @@ -192,4 +184,8 @@ export default class TestFinder extends EventEmitter impleme watcher.close(); }); } + + [Symbol.dispose]() { + return this.dispose(); + } } diff --git a/src/server/TestServer.ts b/src/server/TestServer.ts new file mode 100644 index 0000000..5b73dec --- /dev/null +++ b/src/server/TestServer.ts @@ -0,0 +1,379 @@ +import http from 'node:http'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import type { AddressInfo } from 'node:net'; +import { SourceMapPayload } from 'node:module'; + +import { lookup as mimeLookup } from 'mrmime'; +import getPort, { portNumbers } from 'get-port'; +import esbuild from 'esbuild'; +import sirv from 'sirv'; + +import '../common/utils/patchDisposable.js'; +import EventEmitter from './utils/TypedEventEmitter.js'; +import TestFinder from './TestFinder.js'; +import * as clientRunner from '../client/runner.js'; + +export interface FileServerOptions { + cwd: string; + setup: string | undefined; + tests: string | string[]; + entryPoints: Record; + watch: boolean; + uuid: string; +} + +export interface FileServerEventMap { + ready: []; + changed: []; +} + +/** + * Absolute path to static file directory. + */ +export const staticDir = fileURLToPath(new URL('../../static', import.meta.url)); + +interface HttpServer extends http.Server { + address(): AddressInfo | null; +} + +export default class TestServer extends EventEmitter + implements AsyncDisposable { + /** + * Absolute path to the working directory. + */ + readonly cwd: string; + + /** + * File to run before the test files. + */ + readonly setupFile: string | undefined; + + /** + * Test file patterns. + */ + readonly testPatterns: string | string[]; + + /** + * Additional entry points to build. + * @see [Entry points | esbuild - API](https://esbuild.github.io/api/#entry-points) + */ + readonly entryPoints: Record; + + /** + * Monitor test file changes and trigger automatic test reruns. + */ + readonly watch: boolean; + + /** + * UUID for communications between Node and in-page scripts. + */ + readonly uuid: string; + + private readonly testFinder: TestFinder; + + private readonly cwdRequestListener: http.RequestListener; + + private readonly staticRequestListener: http.RequestListener; + + private readonly buildContextPromise: Promise; + + readonly httpServer: HttpServer; + + constructor({ + cwd, + setup, + tests, + entryPoints, + watch, + uuid, + }: FileServerOptions) { + super(); + this.cwd = cwd; + this.setupFile = setup; + this.testPatterns = tests; + this.entryPoints = entryPoints; + this.watch = watch; + this.uuid = uuid; + + this.testFinder = new TestFinder({ + patterns: tests, + cwd, + watch, + }); + + this.cwdRequestListener = sirv(this.cwd, { + dev: true, + }); + this.staticRequestListener = sirv(staticDir, { + dev: true, + onNoMatch: this.cwdRequestListener, + }); + + this.buildContextPromise = this.initBuildContext(); + + this.httpServer = http.createServer((request, response) => { + this.handleRequest(request, response) + .catch((e) => { + // eslint-disable-next-line no-console + console.error(e); + response.writeHead(500); + response.end(); + }); + }) as HttpServer; + + this.httpServer.unref(); + } + + /** + * Pathname to built file hash & content map. + * For instance, '/stdin.js' -> { text: 'console.log(1)' hash: 'xxx' }. + */ + readonly fileContents = new Map(); + + /** + * Pathname to source map payload map. + * For instance, '/stdin.js' -> { version: 3, ... }. + */ + readonly sourceMapPayloads = new Map(); + + private updateBuiltFiles(files: esbuild.OutputFile[]) { + const { cwd, fileContents, sourceMapPayloads } = this; + return files.reduce((changed, { path: absPath, hash, text }) => { + const pathname = `/${path.relative(cwd, absPath).replace(/\\/g, '/')}`; + + // Skip unchanged files. + const same = fileContents.get(pathname)?.hash === hash; + if (same) return changed; + + fileContents.set(pathname, { text, hash }); + + // Cache source maps for stack trace and coverage. + // Note that Node.js requires the sources field to be absolute file URLs. + if (pathname.endsWith('.map')) { + const payload = JSON.parse(text) as SourceMapPayload; + const baseURL = pathToFileURL(absPath); + payload.sources = payload.sources.map((source) => new URL(source, baseURL).href); + sourceMapPayloads.set(pathname.slice(0, -4), payload); + } + + return true; + }, false); + } + + private building = false; + + private readonly importFilesLoader: esbuild.Plugin = { + name: 'import files loader', + setup: (build) => { + // Resolve the setup file import path. + let setupFileImportPath = this.setupFile; + if (this.setupFile) { + setupFileImportPath = path.resolve(this.cwd, this.setupFile).replace(/\\/g, '/'); + } + + build.onResolve({ filter: /^$/ }, () => ({ path: 'stdin', namespace: 'wrightplay' })); + build.onLoad({ filter: /^/, namespace: 'wrightplay' }, async () => { + // Sort to make the output stable + const importPaths = await this.testFinder.getFiles(); + importPaths.sort(); + + // Prepend the setup file if any + if (setupFileImportPath) importPaths.unshift(setupFileImportPath); + + if (importPaths.length === 0) { + if (this.watch) { + // eslint-disable-next-line no-console + console.error('No test file found'); + } else { + throw new Error('No test file found'); + } + } + + const importStatements = importPaths.map((file) => `import '${file}'`).join('\n'); + return { + contents: `${importStatements}\n(${clientRunner.init.toString()})('${this.uuid}')`, + resolveDir: this.cwd, + }; + }); + }, + }; + + private readonly builtFilesUpdater: esbuild.Plugin = { + name: 'built files updater', + setup: (build) => { + let buildCount = 0; + let lastBuildFailed = false; + build.onStart(() => { + this.building = true; + }); + build.onEnd((result) => { + this.building = false; + buildCount += 1; + const files = result.outputFiles!; + const changed = this.updateBuiltFiles(files); + this.emit('ready'); // signals the http server to respond + + if (!this.watch) return; + + // Watch the errored files if any. + // This may not help the cases where the error may be resolved + // in another dir (TestFinder watches the dir instead of the file), + // but still better than nothing. + const watchFiles: string[] = []; + result.errors.forEach((error) => { + if (!error.location) return; + watchFiles.push(error.location.file); + }); + + if (watchFiles.length > 0) { + lastBuildFailed = true; + this.testFinder.setRelevantFiles(watchFiles); + return; + } + + // Return if the built content remains unchanged and no recovery is needed. + // Since built content remains the same during errors, we should identify a + // successful rerun that can replace previous esbuild error messages with + // the latest test results, even if the content has been run before. + if (!changed && !lastBuildFailed) return; + lastBuildFailed = false; + + // Watch the imported files. + const { inputs } = result.metafile!; + Object.values(inputs).forEach((input) => { + input.imports.forEach((im) => { + if (im.external || im.path.startsWith('(disabled):')) return; + watchFiles.push(im.path.replace(/[?#].+$/, '')); + }); + }); + + this.testFinder.setRelevantFiles(watchFiles); + + // Emit the updated event so as to trigger a rerun. + // Bypass the initial event to avoid unnecessary reruns. + if (buildCount > 1) this.emit('changed'); + }); + }, + }; + + private async initBuildContext() { + const buildContext = await esbuild.context({ + entryPoints: { + ...this.entryPoints, + // The stdin API doesn't support onLoad callbacks, + // so we use the entry point workaround. + // https://github.com/evanw/esbuild/issues/720 + '__wrightplay__/stdin': '', + }, + metafile: this.watch, + bundle: true, + format: 'esm', + sourcemap: 'linked', + outdir: './', + absWorkingDir: this.cwd, + define: { WRIGHTPLAY_CLIENT_UUID: `'${this.uuid}'` }, + plugins: [ + this.importFilesLoader, + this.builtFilesUpdater, + ], + write: false, + }); + + return buildContext; + } + + async handleRequest(request: http.IncomingMessage, response: http.ServerResponse) { + const { pathname } = new URL(request.url!, `http://${request.headers.host}`); + + if (this.building) { + await new Promise((resolve) => { + this.once('ready', resolve); + }); + } + + const builtContent = this.fileContents.get(pathname); + if (!builtContent) { + this.staticRequestListener(request, response); + return; + } + + const mimeType = mimeLookup(pathname) || ''; + response.writeHead(200, { + 'Content-Type': `${mimeType}; charset=utf-8`, + }); + response.end(builtContent.text); + } + + private async launchHttpServer(): Promise { + // Avoid browser blocked ports. + const port = await getPort({ port: portNumbers(10081, 65535) }); + await new Promise((resolve) => { + this.httpServer.listen(port, '127.0.0.1', resolve); + }); + + return this.httpServer.address()!; + } + + private fileChangeCallback: (() => void) | undefined; + + private async launchFileBuild() { + const buildContext = await this.buildContextPromise; + + if (this.watch) { + this.fileChangeCallback = () => { + buildContext.rebuild() + // Do nothing as esbuild prints the errors itself + .catch(() => {}); + }; + + this.testFinder.on('change', this.fileChangeCallback); + this.testFinder.updateFiles(); + } else { + this.testFinder.updateFiles(); + await buildContext.rebuild(); + } + } + + private async launchInternal() { + const [addressInfo] = await Promise.all([ + this.launchHttpServer(), + this.launchFileBuild(), + ]); + return addressInfo; + } + + private launchPromise: Promise | undefined; + + launch(): Promise { + if (!this.launchPromise) { + this.launchPromise = this.launchInternal() + .finally(() => { + this.launchPromise = undefined; + }); + } + return this.launchPromise; + } + + async close() { + if (this.fileChangeCallback) { + this.testFinder.off('change', this.fileChangeCallback); + } + + await new Promise((resolve) => { + this.httpServer.close(resolve); + }); + } + + async dispose() { + await Promise.all([ + this.buildContextPromise.then((buildContext) => buildContext.dispose()), + this.close(), + ]); + this.testFinder[Symbol.dispose](); + } + + [Symbol.asyncDispose]() { + return this.dispose(); + } +} diff --git a/src/server/api.ts b/src/server/api.ts index 34253b3..467f169 100644 --- a/src/server/api.ts +++ b/src/server/api.ts @@ -9,5 +9,11 @@ export { default as CoverageReporter } from './CoverageReporter.js'; export * from './Runner.js'; export { default as Runner } from './Runner.js'; +export * from './TestFinder.js'; +export { default as TestFinder } from './TestFinder.js'; + +export * from './TestServer.js'; +export { default as TestServer } from './TestServer.js'; + export type ConfigRunOptions = Partial; export type ConfigOptions = ConfigRunOptions | ConfigRunOptions[]; diff --git a/src/server/ws/WSServer.ts b/src/server/ws/WSServer.ts index 3439030..dc0befa 100644 --- a/src/server/ws/WSServer.ts +++ b/src/server/ws/WSServer.ts @@ -1,6 +1,8 @@ import type http from 'node:http'; import WebSocket, { WebSocketServer } from 'ws'; import type playwright from 'playwright'; + +import '../../common/utils/patchDisposable.js'; import * as Serializer from '../../common/serializer/index.js'; import { RouteClientMeta, @@ -12,7 +14,7 @@ import { /** * WebSocket Server that processes messages from the page client into Playwright actions */ -export default class WSServer { +export default class WSServer implements AsyncDisposable { private readonly uuid: string; private readonly wss: WebSocketServer; @@ -30,13 +32,14 @@ export default class WSServer { this.wss = new WebSocketServer({ server, path: '/__wrightplay__', + clientTracking: false, }); this.wss.on('connection', (ws) => { ws.addEventListener('message', (event) => { - if (event.data !== uuid) return; - ws.once('close', () => { - this.client = undefined; - }); + if (event.data !== uuid) { + ws.close(1008, 'Invalid UUID'); + return; + } ws.addEventListener('message', this.onMessage); this.client = ws; }, { once: true }); @@ -44,7 +47,7 @@ export default class WSServer { } hasClient() { - return this.client !== undefined; + return this.client?.readyState === WebSocket.OPEN; } async reset() { @@ -175,7 +178,7 @@ export default class WSServer { return; } const { client } = this; - if (!client || client.readyState !== WebSocket.OPEN) { + if (client?.readyState !== WebSocket.OPEN) { await route.continue(); return; } @@ -280,4 +283,15 @@ export default class WSServer { error, })); } + + async dispose() { + this.client?.close(1000, 'Server Disposed'); + await new Promise((resolve) => { + this.wss.close(resolve); + }); + } + + [Symbol.asyncDispose]() { + return this.dispose(); + } }