Skip to content

Commit

Permalink
feat!: simplify creating and observing test runs
Browse files Browse the repository at this point in the history
BREAKING CHANGE: `TestRunStack` is no longer aggregated from individual `TestRun` instances. Create `TestRunStack` per `createTestRun` function instead.
BREAKING CHANGE: `ReporterServer` has been removed. Listen to events directly on test nodes instead.
BREAKING CHANGE: `TmpFileServer` has been removed.
  • Loading branch information
ph-fritsche committed Oct 27, 2023
1 parent db7b9fb commit 5a13b50
Show file tree
Hide file tree
Showing 78 changed files with 3,623 additions and 3,001 deletions.
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export default [
{
files: ['test/**'],
rules: {
'@typescript-eslint/no-non-null-assertion': 0,
'@typescript-eslint/no-unsafe-argument': 0,
'@typescript-eslint/no-unsafe-assignment': 0,
'@typescript-eslint/no-unsafe-member-access': 0,
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"@types/istanbul-lib-report": "^3.0.0",
"@types/istanbul-lib-source-maps": "^4.0.1",
"@types/istanbul-reports": "^3.0.1",
"@types/node": "^16",
"@types/node": "^18",
"@types/resolve": "^1.20.2",
"eslint": "^8.39.0",
"expect": "^29.5.0",
Expand All @@ -41,6 +41,9 @@
"swc-plugin-coverage-instrument": "^0.0.18",
"typescript": "^5.0.4"
},
"engines": {
"node": ">=18"
},
"type": "module",
"scripts": {
"build": "scripts ts-build2 && cp src/conductor/node/experimental.cjs dist/conductor/node/experimental.cjs",
Expand Down
5 changes: 3 additions & 2 deletions src/builder/BuildProvider.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Stats } from 'fs'
import { FSWatcher, watch } from 'chokidar'
import { EventEmitter } from '../event'
import { EventEmitter, getEventDispatch } from '../event'
import { Builder } from './Builder'

type BuilderEntry = {
Expand Down Expand Up @@ -46,6 +46,7 @@ export class BuildProvider {
readonly files = new Map<string, Stats|undefined>()

readonly emitter = new EventEmitter<BuildProviderEventMap>()
protected readonly dispatch = getEventDispatch(this.emitter)

private _builders = new Map<string, BuilderEntry>()
connect(
Expand All @@ -61,7 +62,7 @@ export class BuildProvider {
this.syncFileWithBuilders([entry], 'add', filepath, stats)
})
builder.emitter.addListener('done', () => {
this.emitter.dispatch('done', {
this.dispatch('done', {
builder,
pending: Array.from(this._builders.values()).some(b => !b.builder.idle),
})
Expand Down
15 changes: 8 additions & 7 deletions src/builder/Builder.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Stats } from 'fs'
import path from 'path'
import { OutputOptions, Plugin, PreRenderedChunk, rollup, RollupBuild, RollupCache, RollupError, RollupOutput } from 'rollup'
import { EventEmitter } from '../event'
import { EventEmitter, getEventDispatch } from '../event'
import { isBuiltin } from 'node:module'

type InputFilesMap = Map<string, Stats | undefined>
Expand Down Expand Up @@ -105,6 +105,7 @@ export class Builder {
) => boolean

readonly emitter = new EventEmitter<BuildEventMap>()
protected readonly dispatch = getEventDispatch(this.emitter)

readonly inputFiles: InputFilesMap = new Map()

Expand Down Expand Up @@ -140,7 +141,7 @@ export class Builder {
})
})
if (this.promisedBuildId !== buildId) {
this.emitter.dispatch('activate', {
this.dispatch('activate', {
buildId,
inputFiles: this.inputFiles,
})
Expand Down Expand Up @@ -213,7 +214,7 @@ export class Builder {
})
: Promise.resolve(undefined)

this.emitter.dispatch('start', {
this.dispatch('start', {
buildId,
build: this.currentBuild,
inputFiles: this.inputFiles,
Expand All @@ -238,7 +239,7 @@ export class Builder {
}
}

this.emitter.dispatch('externals', {
this.dispatch('externals', {
buildId,
externals,
imports,
Expand All @@ -251,7 +252,7 @@ export class Builder {
})
}

this.emitter.dispatch('complete', {
this.dispatch('complete', {
buildId,
build: b,
inputFiles: this.inputFiles,
Expand All @@ -261,7 +262,7 @@ export class Builder {
await b?.close()
},
(error: RollupError) => {
this.emitter.dispatch('error', {
this.dispatch('error', {
buildId,
error,
})
Expand All @@ -271,7 +272,7 @@ export class Builder {
if (this.promisedBuildId === buildId) {
this.promisedBuildId = undefined
}
this.emitter.dispatch('done', {
this.dispatch('done', {
buildId,
})
})
Expand Down
65 changes: 40 additions & 25 deletions src/conductor/ChromeTestConductor.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,24 @@
import puppeteer from 'puppeteer-core'
import { makeId } from '../test/Entity'
import { TestConductor } from './TestConductor'
import { makeId } from '../util/id'
import { HttpReporterServer } from './HttpReporterServer'
import { TestReporter } from './TestReporter'
import { ErrorStackResolver } from './ErrorStackResolver'

export class ChromeTestConductor extends TestConductor {
static readonly supportedFilesProtocols: string[] = ['http:']
constructor(
public readonly testRunnerModule: string,
title?: string,
setupFiles: URL[] = [],
coverageVar = '__coverage__',
errorStackResolver = new ErrorStackResolver([]),
) {
super(title, setupFiles, coverageVar)

this.reporterServer = new HttpReporterServer(errorStackResolver)
}

readonly reporterServer: HttpReporterServer
readonly browser = puppeteer.launch({
dumpio: true,
executablePath: '/usr/bin/chromium',
Expand All @@ -16,14 +30,16 @@ export class ChromeTestConductor extends TestConductor {
})

async close(): Promise<void> {
return (await this.browser).close()
await Promise.all([
this.reporterServer.close(),
(await this.browser).close(),
])
}

protected async runTestSuite(
runId: string,
testFile: string,
id: string,
name: string,
async runTestSuite(
reporter: TestReporter,
suiteUrl: string,
filter?: RegExp,
) {
const page = await (await this.browser).newPage()
page.setDefaultNavigationTimeout(600000)
Expand All @@ -37,29 +53,28 @@ export class ChromeTestConductor extends TestConductor {

page.on('console', m => console.log(m.type(), m.text()))

const reporterId = this.reporterServer.registerReporter(reporter)

const childCode = `
import { setTestContext, TestGroup, TestRunner } from "${this.testRunnerModule}"
import { TestRunner, HttpReporterClient } from "${this.testRunnerModule}"
const fetch = window.fetch.bind(window)
const setTimeout = window.setTimeout.bind(window)
await ((async () => {
const execModule = async (moduleId) => {
const defaultExport = await import(moduleId)
if (typeof defaultExport === 'function') {
await defaultExport()
}
}
const suite = new TestGroup(${JSON.stringify({ id, title: name })})
setTestContext(globalThis, suite)
${this.setupFiles.map(f => `await execModule(${JSON.stringify(f)})`).join(';')}
await execModule(${JSON.stringify(testFile)})
const runner = new TestRunner(${JSON.stringify(await this.reporterServer.url)}, fetch, setTimeout, ${JSON.stringify(this.coverageVar)})
await runner.run(${JSON.stringify(runId)}, suite)
await new TestRunner(
new HttpReporterClient(
${JSON.stringify(await this.reporterServer.url)},
fetch,
${JSON.stringify(reporterId)},
),
setTimeout,
).run(
${JSON.stringify(this.setupFiles)},
${JSON.stringify(suiteUrl)},
${filter ? `new RegExp(${JSON.stringify(filter.source)}, ${JSON.stringify(filter.flags)})` : JSON.stringify(undefined)},
${JSON.stringify(this.coverageVar)},
)
})()).then(
r => window['__${callbackId}-resolve'](String(r)),
r => window['__${callbackId}-reject'](r instanceof Error ? r.stack : String(r)),
Expand Down
72 changes: 72 additions & 0 deletions src/conductor/ErrorStackResolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { RawSourceMap, SourceMapConsumer } from 'source-map'

export type FileServer = {
url: string
getFile: (path: string) => Promise<string>
origin: string
}

export class ErrorStackResolver {
constructor(
readonly fileServers: Array<FileServer>,
) {
}

async rewriteStack(
stack: string,
) {
const re = /(?<pre>\s+at [^\n)]+\()(?<url>\w+:\/\/[^/?]*[^)]*):(?<line>\d+):(?<column>\d+)(?<post>\)$)/gm
let r: RegExpExecArray|null
// eslint-disable-next-line no-cond-assign
while ((r = re.exec(stack)) && r?.groups) {
const url = r.groups.url
const line = Number(r.groups.line)
const column = Number(r.groups.column)
for (const server of this.fileServers) {
if (url.startsWith(server.url) && (server.url.endsWith('/') || url[server.url.length] === '/')) {
const subpath = trimStart(url.substring(server.url.length), '/')
let subPathAndPos = `${subpath}:${line}:${column}`

// TODO: handle errors when resolving code positions

const content = await server.getFile(subpath)
const mapMatch = String(content).match(/\n\/\/# sourceMappingURL=data:application\/json;charset=utf-8;base64,(?<encodedMap>[0-9a-zA-Z+/]+)\s*$/)
if (mapMatch?.groups) {
const map = JSON.parse(Buffer.from(mapMatch.groups.encodedMap, 'base64').toString('utf8')) as RawSourceMap
const original = await SourceMapConsumer.with(map, null, consumer => {
return consumer.originalPositionFor({ line, column })
})
if (original.source) {
subPathAndPos = `${subpath.substring(0, subpath.length - map.file.length)}${original.source}:${String(original.line)}:${String(original.column)}`
}
}
const newStackEntry = r.groups.pre
+ server.origin
+ (server.origin.endsWith('/') ? '' : '/')
+ subPathAndPos
+ r.groups.post

stack = stack.substring(0, r.index)
+ newStackEntry
+ stack.substring(r.index + r[0].length)

re.lastIndex += newStackEntry.length - r[0].length

break
}
}
}
return stack
}
}

function trimStart(
str: string,
chars: string,
) {
for (let i = 0; ; i++) {
if (i >= str.length || !chars.includes(str[i])) {
return str.substring(i)
}
}
}

0 comments on commit 5a13b50

Please sign in to comment.