├── .github └── workflows │ └── node.js.yml ├── .gitignore ├── LICENSE ├── README.md ├── package.json ├── src ├── bundle.ts ├── bundler-map.ts ├── format-error.ts ├── index.ts ├── test-entry-point.ts └── utils.ts ├── test ├── base.karma.conf.js ├── bundle-per-file-watch.test.ts ├── bundle-per-file.test.ts ├── fetch-polyfill.js ├── fixtures │ ├── bundle-per-file-watch │ │ ├── files │ │ │ ├── dep1.js │ │ │ ├── main-a.js │ │ │ └── main-b.js │ │ └── karma.conf.js │ ├── bundle-per-file │ │ ├── files │ │ │ ├── dep1.js │ │ │ ├── main-a.js │ │ │ └── main-b.js │ │ └── karma.conf.js │ ├── reentrant-initial-bundle │ │ ├── files │ │ │ ├── main-a.js │ │ │ ├── main-b.js │ │ │ └── sub │ │ │ │ └── dep1.js │ │ └── karma.conf.js │ ├── reentrant-rebundle │ │ ├── files │ │ │ ├── main-a.js │ │ │ ├── main-b.js │ │ │ └── sub │ │ │ │ └── dep1.js │ │ └── karma.conf.js │ ├── simple-bundle │ │ ├── files │ │ │ ├── dep1.js │ │ │ └── main-a.js │ │ └── karma.conf.js │ ├── simple │ │ ├── files │ │ │ └── main-a.js │ │ └── karma.conf.js │ ├── sourcemap-base-bundle │ │ ├── dep2.js │ │ ├── dep2.js.map │ │ ├── files │ │ │ ├── main-a.js │ │ │ └── sub │ │ │ │ └── main-b.js │ │ └── karma.conf.js │ ├── sourcemap-base │ │ ├── files │ │ │ ├── main-a.js │ │ │ └── sub │ │ │ │ ├── dep2.js │ │ │ │ └── main-b.js │ │ └── karma.conf.js │ ├── sourcemap-error │ │ ├── files │ │ │ ├── main-a.js │ │ │ └── sub │ │ │ │ └── dep1.js │ │ └── karma.conf.js │ ├── sourcemap-fetch │ │ ├── files │ │ │ └── main-a.js │ │ └── karma.conf.js │ ├── sourcemap │ │ ├── files │ │ │ ├── main-a.js │ │ │ └── sub │ │ │ │ ├── dep2.js │ │ │ │ └── main-b.js │ │ └── karma.conf.js │ ├── target-custom │ │ ├── files │ │ │ └── main-a.js │ │ └── karma.conf.js │ ├── typescript │ │ ├── files │ │ │ └── main-a.foo.ts │ │ └── karma.conf.js │ ├── watch-add │ │ ├── files │ │ │ ├── dep1.js │ │ │ └── main-a.js │ │ └── karma.conf.js │ ├── watch-bundle │ │ ├── files │ │ │ ├── dep1.js │ │ │ └── main-a.js │ │ └── karma.conf.js │ ├── watch-double │ │ ├── files │ │ │ ├── main-a.js │ │ │ └── main-b.js │ │ └── karma.conf.js │ ├── watch-error │ │ ├── files │ │ │ ├── dep1.js │ │ │ └── main-a.js │ │ └── karma.conf.js │ ├── watch-exclude │ │ ├── files │ │ │ ├── dep1.js │ │ │ ├── excluded │ │ │ │ └── excluded.ts │ │ │ └── main-a.js │ │ └── karma.conf.js │ └── watch-shared │ │ ├── files │ │ ├── dep1.js │ │ ├── main-a.js │ │ └── main-b.js │ │ └── karma.conf.js ├── reentrant-initial-bundle.test.ts ├── reentrant-rebundle.test.ts ├── run-karma.ts ├── run.ts ├── simple-bundle.test.ts ├── simple.test.ts ├── sourcemap-base-bundle.test.ts ├── sourcemap-base.test.ts ├── sourcemap-error.test.ts ├── sourcemap-fetch.test.ts ├── sourcemap.test.ts ├── target-custom.test.ts ├── test-utils.ts ├── typescript.test.ts ├── watch-add.test.ts ├── watch-bundle.test.ts ├── watch-double.test.ts ├── watch-error.test.ts ├── watch-exclude.test.ts └── watch-shared.test.ts ├── tsconfig.json └── yarn.lock /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [main] 9 | pull_request: 10 | branches: [main] 11 | 12 | jobs: 13 | test: 14 | runs-on: ${{ matrix.os }} 15 | strategy: 16 | matrix: 17 | os: [ubuntu-latest] 18 | node-version: [12.x, 14.x, 15.x] 19 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v1 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | - run: yarn install 27 | - run: yarn test 28 | test-os: 29 | runs-on: ${{ matrix.os }} 30 | strategy: 31 | matrix: 32 | os: [windows-latest, macos-latest] 33 | node-version: [14.x] 34 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 35 | steps: 36 | - uses: actions/checkout@v2 37 | - name: Use Node.js ${{ matrix.node-version }} 38 | uses: actions/setup-node@v1 39 | with: 40 | node-version: ${{ matrix.node-version }} 41 | - run: yarn install 42 | - run: yarn test 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | results.pdf 107 | screenshots/ 108 | .vscode/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Marvin Hagemeister 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # karma-esbuild 2 | 3 | An [esbuild](https://github.com/evanw/esbuild) preprocessor for the karma test runner. The main benefits of `esbuild` is speed and readability of the compiled output. 4 | 5 | ## Installation 6 | 7 | ```bash 8 | npm install --save-dev karma-esbuild 9 | ``` 10 | 11 | ## Usage 12 | 13 | Add `esbuild` as your preprocessor inside your `karma.conf.js`: 14 | 15 | ```js 16 | module.exports = function (config) { 17 | config.set({ 18 | preprocessors: { 19 | // Add esbuild to your preprocessors 20 | "test/**/*.test.js": ["esbuild"], 21 | }, 22 | }); 23 | }; 24 | ``` 25 | 26 | ### Advanced: Custom configuration 27 | 28 | A custom esbuild configuration can be passed via an additional property on karma's config. Check out the [documentation for esbuild](https://esbuild.github.io/api/) for available options. 29 | 30 | ```js 31 | module.exports = function (config) { 32 | config.set({ 33 | preprocessors: { 34 | // Add esbuild to your preprocessors 35 | "test/**/*.test.js": ["esbuild"], 36 | }, 37 | 38 | esbuild: { 39 | // Replace some global variables 40 | define: { 41 | COVERAGE: coverage, 42 | "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || ""), 43 | ENABLE_PERFORMANCE: true, 44 | }, 45 | plugins: [createEsbuildPlugin()], 46 | 47 | // Karma-esbuild specific options 48 | singleBundle: true, // Merge all test files into one bundle(default: true) 49 | }, 50 | }); 51 | }; 52 | ``` 53 | 54 | ## License 55 | 56 | `MIT`, see [the LICENSE](./LICENSE) file. 57 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "karma-esbuild", 3 | "version": "2.3.0", 4 | "description": "ESBuild preprocessor for karma test runner", 5 | "main": "dist/index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/marvinhagemeister/karma-esbuild.git" 9 | }, 10 | "scripts": { 11 | "build": "rimraf dist/ && tsc", 12 | "test": "ts-node test/run.ts", 13 | "run-karma": "ts-node test/run-karma.ts", 14 | "prepublishOnly": "npm run build" 15 | }, 16 | "keywords": [ 17 | "karma-plugin", 18 | "karma-preprocessor", 19 | "esbuild" 20 | ], 21 | "author": "Marvin Hagemeister ", 22 | "license": "MIT", 23 | "files": [ 24 | "dist/" 25 | ], 26 | "dependencies": { 27 | "chokidar": "^3.5.1", 28 | "source-map": "0.6.1" 29 | }, 30 | "peerDependencies": { 31 | "esbuild": ">=0.17.0" 32 | }, 33 | "devDependencies": { 34 | "@types/karma": "^6.3.0", 35 | "@types/mocha": "^8.2.2", 36 | "@types/node": "^15.0.3", 37 | "errorstacks": "^2.3.2", 38 | "esbuild": "^0.17.2", 39 | "husky": "^4.3.6", 40 | "jsdom": "16.5.3", 41 | "karma": "^5.2.3", 42 | "karma-jsdom-launcher": "9.0.0", 43 | "karma-mocha": "^2.0.1", 44 | "karma-mocha-reporter": "^2.2.5", 45 | "kolorist": "^1.4.1", 46 | "lint-staged": "^11.0.0", 47 | "mocha": "^8.4.0", 48 | "pentf": "^2.5.3", 49 | "prettier": "^2.3.0", 50 | "puppeteer": "^9.1.1", 51 | "rimraf": "^3.0.2", 52 | "ts-node": "^9.1.1", 53 | "typescript": "^4.2.4" 54 | }, 55 | "lint-staged": { 56 | "**/*.{js,jsx,ts,tsx,yml}": [ 57 | "prettier --write" 58 | ] 59 | }, 60 | "husky": { 61 | "hooks": { 62 | "pre-commit": "lint-staged" 63 | } 64 | }, 65 | "prettier": { 66 | "useTabs": true, 67 | "arrowParens": "avoid", 68 | "trailingComma": "all" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/bundle.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import * as esbuild from "esbuild"; 3 | import type { EventEmitter } from "events"; 4 | import { Deferred } from "./utils"; 5 | 6 | import type { Log } from "./utils"; 7 | import type { RawSourceMap } from "source-map"; 8 | 9 | interface BundledFile { 10 | code: Buffer; 11 | map: RawSourceMap; 12 | } 13 | 14 | export class Bundle { 15 | // Dirty signifies that that the current result is stale, and a new build is 16 | // needed. It's reset during the next build. 17 | private _dirty = true; 18 | // buildInProgress tracks the in-progress build. When a build takes too 19 | // long, a new build may have been requested before the original completed. 20 | // In this case, we resolve that in-progress build with the pending one. 21 | private buildInProgress: Promise | null = null; 22 | private deferred = new Deferred(); 23 | private context: esbuild.BuildContext | null = null; 24 | private startTime = 0; 25 | 26 | // The sourcemap must be synchronously available for formatError. 27 | sourcemap = {} as RawSourceMap; 28 | 29 | constructor( 30 | private file: string, 31 | private log: Log, 32 | private config: esbuild.BuildOptions, 33 | private emitter: EventEmitter, 34 | ) { 35 | this.config = { ...config, entryPoints: [file] }; 36 | } 37 | 38 | dirty() { 39 | if (this._dirty) return; 40 | this._dirty = true; 41 | this.deferred = new Deferred(); 42 | } 43 | 44 | isDirty() { 45 | return this._dirty; 46 | } 47 | 48 | async write() { 49 | if (this.buildInProgress === null) { 50 | this.beforeProcess(); 51 | } else { 52 | // Wait for the previous build to happen before we continue. This prevents 53 | // any reentrant behavior, and guarantees we can get an initial bundle to 54 | // create incremental builds from. 55 | await this.buildInProgress; 56 | 57 | // There have been multiple calls to write in the time we were 58 | // waiting for the in-progress build. Instead of making multiple 59 | // calls to rebuild, we resolve with the new in-progress build. One 60 | // of the write calls "won" this wait on the in-progress build, and 61 | // that winner will eventually resolve the deferred. 62 | if (this.buildInProgress !== null) { 63 | return this.deferred.promise; 64 | } 65 | } 66 | const { deferred } = this; 67 | this._dirty = false; 68 | 69 | const build = this.bundle(); 70 | this.buildInProgress = build; 71 | const result = await build; 72 | this.buildInProgress = null; 73 | 74 | // The build took so long, we've already had another test file dirty the 75 | // bundle. Instead of serving a stale build, let's wait for the new one 76 | // to resolve. The new build either hasn't called `write` yet, or it's 77 | // waiting in the `await this.buildInProgress` above. Either way, it'll 78 | // eventually fire off a new rebuild and resolve the deferred. 79 | if (deferred !== this.deferred) { 80 | const { promise } = this.deferred; 81 | deferred.resolve(promise); 82 | return promise; 83 | } 84 | 85 | this.afterProcess(); 86 | deferred.resolve(result); 87 | return result; 88 | } 89 | 90 | private beforeProcess() { 91 | this.startTime = Date.now(); 92 | this.emitter.emit("start", { 93 | type: "start", 94 | file: this.file, 95 | time: this.startTime, 96 | }); 97 | } 98 | 99 | private afterProcess() { 100 | this.emitter.emit("done", { 101 | type: "done", 102 | file: this.file, 103 | startTime: this.startTime, 104 | endTime: Date.now(), 105 | }); 106 | } 107 | 108 | read() { 109 | return this.deferred.promise; 110 | } 111 | 112 | async stop() { 113 | // Wait for any in-progress builds to finish. At this point, we know no 114 | // new ones will come in, we're just waiting for the current one to 115 | // finish running. 116 | if (this.buildInProgress || this._dirty) { 117 | // Wait on the deferred, not the buildInProgress, because the dirty flag 118 | // means a new build is imminent. The deferred will only be resolved after 119 | // that build is done. 120 | await this.deferred.promise; 121 | } 122 | // Releasing the result allows the child process to end. 123 | this.context?.dispose(); 124 | this.context = null; 125 | this.emitter.emit("stop", { type: "stop", file: this.file }); 126 | } 127 | 128 | private async bundle() { 129 | try { 130 | if (this.context == null) { 131 | this.context = await esbuild.context(this.config); 132 | } 133 | 134 | const result = await this.context.rebuild(); 135 | const { outputFiles } = result; 136 | 137 | if (outputFiles == null || outputFiles.length < 2) { 138 | return { 139 | code: Buffer.from( 140 | `console.error("No output files.", ${JSON.stringify(result)})`, 141 | ), 142 | map: {} as RawSourceMap, 143 | }; 144 | } else { 145 | return this.processResult(outputFiles); 146 | } 147 | } catch (err) { 148 | const { message } = err as Error; 149 | this.log.error(message); 150 | 151 | return { 152 | code: Buffer.from(`console.error(${JSON.stringify(message)})`), 153 | map: {} as RawSourceMap, 154 | }; 155 | } 156 | } 157 | 158 | private processResult(outputFiles: esbuild.OutputFile[]) { 159 | const map = JSON.parse(outputFiles[0].text) as RawSourceMap; 160 | const source = outputFiles[1]; 161 | 162 | const basename = path.basename(this.file); 163 | const code = Buffer.from(source.contents.buffer); 164 | const outdir = this.config.outdir!; 165 | map.sources = map.sources.map(s => path.join(outdir, s)); 166 | map.file = basename; 167 | 168 | this.sourcemap = map; 169 | 170 | return { code, map }; 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/bundler-map.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | import { Bundle } from "./bundle"; 3 | 4 | import type esbuild from "esbuild"; 5 | import type { Log } from "./utils"; 6 | 7 | export interface BundleEvents { 8 | start: { type: "start"; time: number; file: string }; 9 | stop: { type: "stop"; file: string }; 10 | done: { type: "done"; endTime: number; startTime: number; file: string }; 11 | } 12 | 13 | export class BundlerMap { 14 | private declare log: Log; 15 | private declare config: esbuild.BuildOptions; 16 | private potentials = new Set(); 17 | private bundlers = new Map(); 18 | private emitter = new EventEmitter(); 19 | 20 | constructor(log: Log, config: esbuild.BuildOptions) { 21 | this.log = log; 22 | this.config = config; 23 | } 24 | 25 | on( 26 | name: K, 27 | callback: (event: BundleEvents[K]) => void, 28 | ) { 29 | this.emitter.addListener(name, callback); 30 | } 31 | 32 | addPotential(file: string) { 33 | if (this.bundlers.has(file)) return; 34 | this.potentials.add(file); 35 | } 36 | 37 | has(file: string) { 38 | return this.bundlers.has(file) || this.potentials.has(file); 39 | } 40 | 41 | get(file: string) { 42 | let bundler = this.bundlers.get(file); 43 | if (!bundler) { 44 | bundler = new Bundle(file, this.log, this.config, this.emitter); 45 | this.bundlers.set(file, bundler); 46 | this.potentials.delete(file); 47 | } 48 | return bundler; 49 | } 50 | 51 | read(file: string) { 52 | const bundler = this.get(file); 53 | if (bundler.isDirty()) bundler.write(); 54 | return bundler.read(); 55 | } 56 | 57 | dirty() { 58 | this.bundlers.forEach(b => b.dirty()); 59 | } 60 | 61 | stop() { 62 | const promises = [...this.bundlers.values()].map(b => b.stop()); 63 | return Promise.all(promises); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/format-error.ts: -------------------------------------------------------------------------------- 1 | import { SourceMapConsumer } from "source-map"; 2 | import * as path from "path"; 3 | 4 | import type { Bundle } from "./bundle"; 5 | import type { BundlerMap } from "./bundler-map"; 6 | import type { RawSourceMap } from "source-map"; 7 | 8 | type Formatter = (m: string) => string; 9 | 10 | export function createFormatError( 11 | bundlerMap: BundlerMap, 12 | basePath: string, 13 | formatError?: Formatter, 14 | ) { 15 | const consumers = new WeakMap(); 16 | const regex = /((?:\b[A-Z]:)?[^ #?:(]+)[^ :]*:(\d+):(\d+)/gi; 17 | // | | || || |^^^^^^ Column 18 | // | | || |^^^^^^ Line 19 | // | | |^^^^^^ Eat any reamining URL (query in particular) 20 | // ^^^^^^^^^^^^^^^^^^^^^^^^ URL pathname extraction 21 | // ^^^^^^^^^^^^^^ Optionally find a leading win32 disk name (eg, C:) 22 | 23 | function get(sourcemap: RawSourceMap) { 24 | const existing = consumers.get(sourcemap); 25 | if (existing) return existing; 26 | const consumer = new SourceMapConsumer(sourcemap); 27 | consumers.set(sourcemap, consumer); 28 | return consumer; 29 | } 30 | 31 | function format(file: string, line: number, column: number) { 32 | return `${file}:${line}:${column}`; 33 | } 34 | 35 | return (message: string) => { 36 | const unminified = message.replace(regex, (match, source, line, column) => { 37 | source = path.normalize(source); 38 | if (!path.isAbsolute(source)) { 39 | source = path.join(basePath, source); 40 | } 41 | 42 | if (!bundlerMap.has(source)) return match; 43 | 44 | try { 45 | const bundle = bundlerMap.get(source); 46 | const consumer = get(bundle.sourcemap); 47 | const loc = consumer.originalPositionFor({ 48 | line: +line, 49 | column: +column - 1, 50 | }); 51 | return `${format(loc.source, loc.line, loc.column + 1)} <- ${format( 52 | source, 53 | line, 54 | column, 55 | )}`; 56 | } catch { 57 | return match; 58 | } 59 | }); 60 | return formatError ? formatError(unminified) : unminified + "\n"; 61 | }; 62 | } 63 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { debounce, formatTime } from "./utils"; 2 | import { BundlerMap } from "./bundler-map"; 3 | import { TestEntryPoint } from "./test-entry-point"; 4 | import { createFormatError } from "./format-error"; 5 | import chokidar from "chokidar"; 6 | import * as path from "path"; 7 | 8 | import type esbuild from "esbuild"; 9 | import type karma from "karma"; 10 | import type { IncomingMessage, ServerResponse } from "http"; 11 | import type { FSWatcher } from "chokidar"; 12 | import type { Log } from "./utils"; 13 | 14 | interface KarmaFile { 15 | originalPath: string; 16 | path: string; 17 | contentPath: string; 18 | type: karma.FilePatternTypes; 19 | } 20 | 21 | interface KarmaEsbuildConfig { 22 | esbuild?: esbuild.BuildOptions & { 23 | bundleDelay?: number; 24 | singleBundle?: boolean; 25 | }; 26 | } 27 | 28 | type KarmaPreprocess = ( 29 | content: any, 30 | file: KarmaFile, 31 | done: (err: Error | null, content?: string | null | Buffer) => void, 32 | ) => void; 33 | 34 | interface KarmaLogger { 35 | create(label: string): Log; 36 | } 37 | 38 | function getBasePath(config: karma.ConfigOptions) { 39 | return config.basePath ? path.normalize(config.basePath) : process.cwd(); 40 | } 41 | 42 | function createPreprocessor( 43 | config: karma.ConfigOptions & KarmaEsbuildConfig, 44 | emitter: karma.Server, 45 | testEntryPoint: TestEntryPoint, 46 | bundlerMap: BundlerMap, 47 | logger: KarmaLogger, 48 | ): KarmaPreprocess { 49 | const log = logger.create("esbuild"); 50 | const { 51 | bundleDelay = 700, 52 | format, 53 | singleBundle = true, 54 | } = config.esbuild || {}; 55 | 56 | // Inject middleware to handle the bundled file and map. 57 | config.middleware ||= []; 58 | config.middleware.push("esbuild"); 59 | 60 | // Create an empty file for Karma to track. Karma requires a real file in 61 | // order for it to be injected into the page, even though the middleware 62 | // will be responsible for serving it. 63 | config.files ||= []; 64 | 65 | if (singleBundle) { 66 | // Push the entry point so that Karma will load the file when the runner starts. 67 | config.files.push({ 68 | pattern: testEntryPoint.file, 69 | included: true, 70 | served: false, 71 | watched: false, 72 | type: format === "esm" ? "module" : "js", 73 | }); 74 | testEntryPoint.touch(); 75 | bundlerMap.addPotential(testEntryPoint.file); 76 | } 77 | 78 | const basePath = getBasePath(config); 79 | 80 | // Install our own error formatter to provide sourcemap unminification. 81 | // Karma's default error reporter will call it, after it does its own 82 | // unminification. It'd be awesome if we could just provide the maps for 83 | // them to consume, but it's impossibly difficult. 84 | config.formatError = createFormatError( 85 | bundlerMap, 86 | basePath, 87 | config.formatError, 88 | ); 89 | 90 | let watcher: FSWatcher | null = null; 91 | const watchMode = !config.singleRun && !!config.autoWatch; 92 | if (watchMode) { 93 | // Initialize watcher to listen for changes in basePath so 94 | // that we'll be notified of any new files 95 | watcher = chokidar.watch([basePath], { 96 | ignoreInitial: true, 97 | // Ignore dot files and anything from node_modules 98 | ignored: /(^|[/\\])(\.|node_modules[/\\])/, 99 | }); 100 | 101 | if (config.exclude) watcher.unwatch(config.exclude); 102 | 103 | const alreadyWatched = config.files.reduce((watched: string[], file) => { 104 | if (typeof file === "string") { 105 | watched.push(file); 106 | } else if (file.watched) { 107 | watched.push(file.pattern); 108 | } 109 | return watched; 110 | }, []); 111 | watcher.unwatch(alreadyWatched); 112 | 113 | // Register shutdown handler 114 | emitter.on("exit", done => { 115 | watcher!.close(); 116 | done(); 117 | }); 118 | 119 | const onWatch = debounce(() => { 120 | emitter.refreshFiles(); 121 | }, 100); 122 | watcher.on("change", onWatch); 123 | watcher.on("add", onWatch); 124 | } 125 | 126 | let stopped = false; 127 | emitter.on("exit", done => { 128 | stopped = true; 129 | bundlerMap.stop().then(done); 130 | }); 131 | 132 | // Logging 133 | const pendingBundles = new Set(); 134 | let start = 0; 135 | bundlerMap.on("start", ev => { 136 | if (singleBundle) { 137 | start = ev.time; 138 | log.info(`Compiling to ${ev.file}...`); 139 | } else if (!pendingBundles.size) { 140 | start = ev.time; 141 | log.info(`Compiling...`); 142 | } 143 | pendingBundles.add(ev.file); 144 | }); 145 | 146 | bundlerMap.on("stop", () => { 147 | pendingBundles.clear(); 148 | }); 149 | 150 | bundlerMap.on("done", ev => { 151 | pendingBundles.delete(ev.file); 152 | if (singleBundle || pendingBundles.size === 0) { 153 | log.info(`Compiling done (${formatTime(ev.endTime - start)})`); 154 | } 155 | }); 156 | 157 | const buildSingleBundle = debounce(() => { 158 | // Prevent service closed message when we are still processing 159 | if (stopped) return; 160 | testEntryPoint.write(); 161 | 162 | const bundle = bundlerMap.get(testEntryPoint.file); 163 | return bundle.write(); 164 | }, bundleDelay); 165 | 166 | return async function preprocess(content, file, done) { 167 | // We normalize the file extension to always be '.js', which allows us to 168 | // run '.ts' files as test entry-points in a `singleBundle: false` setup. 169 | const jsPath = file.originalPath.replace(/\.[^/.]+$/, ".js"); 170 | 171 | // Karma likes to turn a win32 path (C:\foo\bar) into a posix-like path (C:/foo/bar). 172 | // Normally this wouldn't be so bad, but `bundle.file` is a true win32 path, and we 173 | // need to test equality. 174 | let filePath = path.normalize(jsPath); 175 | 176 | if (singleBundle) { 177 | testEntryPoint.addFile(filePath); 178 | filePath = testEntryPoint.file; 179 | } else { 180 | bundlerMap.addPotential(filePath); 181 | } 182 | 183 | const bundle = bundlerMap.get(filePath); 184 | bundle.dirty(); 185 | 186 | if (singleBundle) { 187 | await buildSingleBundle(); 188 | // Turn the file into a `dom` type with empty contents to get Karma to 189 | // inject the contents as HTML text. Since the contents are empty, it 190 | // effectively drops the script from being included into the Karma runner. 191 | file.type = "dom"; 192 | done(null, ""); 193 | } else { 194 | const res = await bundlerMap.read(filePath); 195 | file.path = jsPath; 196 | done(null, res.code); 197 | } 198 | }; 199 | } 200 | createPreprocessor.$inject = [ 201 | "config", 202 | "emitter", 203 | "karmaEsbuildEntryPoint", 204 | "karmaEsbuildBundlerMap", 205 | "logger", 206 | ]; 207 | 208 | function createMiddleware(bundlerMap: BundlerMap, config: karma.ConfigOptions) { 209 | return async function ( 210 | req: IncomingMessage, 211 | res: ServerResponse, 212 | next: () => void, 213 | ) { 214 | const match = /^\/(absolute|base\/)([^?#]*?)(\.map)?(?:\?|#|$)/.exec( 215 | req.url || "", 216 | ); 217 | if (!match) { 218 | return next(); 219 | } 220 | 221 | const fileUrl = match[2]; 222 | const isSourceMap = match[3] === ".map"; 223 | 224 | let filePath = path.normalize(fileUrl); 225 | if (match[1] == "base/") { 226 | const basePath = getBasePath(config); 227 | const absolute = path.join(basePath, filePath); 228 | // Verify that we're in the same basepath if filePath is `../../foo` 229 | if (absolute.startsWith(basePath)) { 230 | filePath = absolute; 231 | } 232 | } 233 | if (!bundlerMap.has(filePath)) return next(); 234 | 235 | const item = await bundlerMap.read(filePath); 236 | if (isSourceMap) { 237 | res.setHeader("Content-Type", "application/json"); 238 | res.end(JSON.stringify(item.map, null, 2)); 239 | } else { 240 | res.setHeader("Content-Type", "text/javascript"); 241 | res.end(item.code); 242 | } 243 | }; 244 | } 245 | createMiddleware.$inject = ["karmaEsbuildBundlerMap", "config"]; 246 | 247 | function createEsbuildBundlerMap( 248 | logger: KarmaLogger, 249 | karmaConfig: karma.ConfigOptions & KarmaEsbuildConfig, 250 | ) { 251 | const log = logger.create("esbuild"); 252 | const basePath = getBasePath(karmaConfig); 253 | const { bundleDelay, singleBundle, ...userConfig } = 254 | karmaConfig.esbuild || {}; 255 | 256 | // Use some trickery to get the root in both posix and win32. win32 could 257 | // have multiple drive paths as root, so find root relative to the basePath. 258 | const outdir = path.resolve(basePath, "/"); 259 | 260 | const config: esbuild.BuildOptions = { 261 | target: "es2015", 262 | ...userConfig, 263 | outdir, 264 | sourcemap: true, 265 | bundle: true, 266 | write: false, 267 | platform: "browser", 268 | define: { 269 | "process.env.NODE_ENV": JSON.stringify( 270 | process.env.NODE_ENV || "development", 271 | ), 272 | ...userConfig.define, 273 | }, 274 | }; 275 | 276 | return new BundlerMap(log, config); 277 | } 278 | createEsbuildBundlerMap.$inject = ["logger", "config"]; 279 | 280 | function createTestEntryPoint() { 281 | return new TestEntryPoint(); 282 | } 283 | createTestEntryPoint.$inject = [] as const; 284 | 285 | module.exports = { 286 | "preprocessor:esbuild": ["factory", createPreprocessor], 287 | "middleware:esbuild": ["factory", createMiddleware], 288 | 289 | karmaEsbuildBundlerMap: ["factory", createEsbuildBundlerMap], 290 | karmaEsbuildEntryPoint: ["factory", createTestEntryPoint], 291 | }; 292 | -------------------------------------------------------------------------------- /src/test-entry-point.ts: -------------------------------------------------------------------------------- 1 | import * as os from "os"; 2 | import * as path from "path"; 3 | import * as fs from "fs"; 4 | import { random } from "./utils"; 5 | 6 | export class TestEntryPoint { 7 | // Dirty signifies that new test file has been addeed, and is cleared once the entryPoint is written. 8 | private dirty = false; 9 | private files = new Set(); 10 | 11 | private dir = fs.realpathSync(os.tmpdir()); 12 | // The `file` is a dummy, meant to allow Karma to work. But, we can't write 13 | // to it without causing Karma to refresh. So, we have a real file that we 14 | // write to, and allow esbuild to build from. 15 | file = path.join(this.dir, `${random(16)}-bundle.js`); 16 | 17 | addFile(file: string) { 18 | const normalized = path 19 | .relative(this.dir, file) 20 | .replace(/\\/g, path.posix.sep); 21 | 22 | if (this.files.has(normalized)) return; 23 | this.files.add(normalized); 24 | this.dirty = true; 25 | } 26 | 27 | write() { 28 | if (!this.dirty) return; 29 | this.dirty = false; 30 | const files = Array.from(this.files).map(file => { 31 | return `import "${file}";`; 32 | }); 33 | fs.writeFileSync(this.file, files.join("\n")); 34 | } 35 | 36 | touch() { 37 | fs.writeFileSync(this.file, ""); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from "crypto"; 2 | 3 | export type Log = Pick; 4 | export class Deferred { 5 | declare promise: Promise; 6 | declare resolve: (value: T | PromiseLike) => void; 7 | declare reject: (reason: Error) => void; 8 | 9 | constructor() { 10 | this.promise = new Promise((resolve, reject) => { 11 | this.resolve = resolve; 12 | this.reject = reject; 13 | }); 14 | } 15 | } 16 | 17 | export function random(length: number) { 18 | return crypto.randomBytes(length).toString("hex"); 19 | } 20 | 21 | export function debounce(fn: () => R, ms: number) { 22 | // This is really just for our tests. Don't do this in your tests, you'll 23 | // regret the constant CPU spikes. 24 | if (ms < 0) { 25 | return fn; 26 | } 27 | 28 | let timeout: NodeJS.Timeout; 29 | let _deferred: Deferred | undefined; 30 | function process() { 31 | const deferred = _deferred!; 32 | _deferred = undefined; 33 | try { 34 | deferred.resolve(fn()); 35 | } catch (e) { 36 | deferred.reject(e as Error); 37 | } 38 | } 39 | return (): Promise => { 40 | _deferred ||= new Deferred(); 41 | clearTimeout(timeout); 42 | timeout = setTimeout(process, ms); 43 | return _deferred.promise; 44 | }; 45 | } 46 | 47 | export function formatTime(ms: number): string { 48 | let seconds = Math.floor((ms / 1000) % 60); 49 | let minutes = Math.floor((ms / (1000 * 60)) % 60); 50 | let hours = Math.floor(ms / (1000 * 60 * 60)); 51 | 52 | let str = ""; 53 | if (hours > 0) { 54 | str += `${hours}h `; 55 | } 56 | if (minutes > 0) { 57 | str += `${minutes}min `; 58 | } 59 | if (seconds > 0) { 60 | str += `${seconds}s`; 61 | } 62 | if (str === "") { 63 | str += `${ms}ms`; 64 | } 65 | return str; 66 | } 67 | -------------------------------------------------------------------------------- /test/base.karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | baseConfig: { 3 | plugins: [ 4 | "karma-mocha", 5 | "karma-mocha-reporter", 6 | "karma-jsdom-launcher", 7 | require("../src/index"), 8 | ], 9 | 10 | browsers: ["jsdom"], 11 | 12 | frameworks: ["mocha"], 13 | reporters: ["mocha"], 14 | 15 | basePath: "", 16 | files: [{ pattern: "files/**/*main-*.js", watched: false }], 17 | exclude: [], 18 | 19 | preprocessors: { 20 | "files/**/*.{js,ts}": ["esbuild"], 21 | }, 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /test/bundle-per-file-watch.test.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from "fs"; 2 | import path from "path"; 3 | import { onTeardown } from "pentf/runner"; 4 | import { assertEventuallyProgresses, runKarma } from "./test-utils"; 5 | 6 | export const description = "Watch separate bundles per test file"; 7 | export async function run(config: any) { 8 | const { output, resetLog } = await runKarma(config, "bundle-per-file-watch"); 9 | 10 | await assertEventuallyProgresses(output.stdout, () => { 11 | return output.stdout.some(line => /2 tests completed/.test(line)); 12 | }); 13 | 14 | const filePath = path.join( 15 | __dirname, 16 | "fixtures", 17 | "bundle-per-file-watch", 18 | "files", 19 | "dep1.js", 20 | ); 21 | 22 | const content = await fs.readFile(filePath, "utf-8"); 23 | const write = (content: string) => fs.writeFile(filePath, content, "utf-8"); 24 | 25 | onTeardown(config, async () => { 26 | await write(content); 27 | }); 28 | 29 | resetLog(); 30 | await write(`export function foo() { return 2 }`); 31 | 32 | await assertEventuallyProgresses(output.stdout, () => { 33 | return output.stdout.some(line => /2 tests failed/.test(line)); 34 | }); 35 | 36 | resetLog(); 37 | await write(content); 38 | await assertEventuallyProgresses(output.stdout, () => { 39 | return output.stdout.some(line => /2 tests completed/.test(line)); 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /test/bundle-per-file.test.ts: -------------------------------------------------------------------------------- 1 | import { Config } from "pentf/config"; 2 | import { assertEventuallyProgresses, runKarma } from "./test-utils"; 3 | 4 | export const description = "Create a separate bundle per test file"; 5 | export async function run(config: Config) { 6 | const { output } = await runKarma(config, "bundle-per-file"); 7 | 8 | // Both main-*.js tests are necessary, so that we call the preprocessor twice. 9 | 10 | await assertEventuallyProgresses(output.stdout, () => { 11 | return output.stdout.some(line => /2 tests completed/.test(line)); 12 | }); 13 | } 14 | -------------------------------------------------------------------------------- /test/fetch-polyfill.js: -------------------------------------------------------------------------------- 1 | export function fetchPolyfill(input) { 2 | return new Promise(function (resolve, reject) { 3 | var xhr = new XMLHttpRequest(); 4 | xhr.open("GET", input, true); 5 | xhr.onreadystatechange = () => { 6 | if (xhr.readyState == /* COMPLETE */ 4) { 7 | resolve({ 8 | status: xhr.status, 9 | headers: { 10 | get: name => { 11 | return xhr.getResponseHeader(name); 12 | }, 13 | }, 14 | text: () => { 15 | return Promise.resolve(xhr.responseText); 16 | }, 17 | }); 18 | } 19 | }; 20 | xhr.onerror = () => { 21 | reject(new Error("Network failure")); 22 | }; 23 | xhr.onabort = () => { 24 | reject(new Error("Request aborted")); 25 | }; 26 | xhr.send(); 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /test/fixtures/bundle-per-file-watch/files/dep1.js: -------------------------------------------------------------------------------- 1 | export function foo() { 2 | return 42; 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/bundle-per-file-watch/files/main-a.js: -------------------------------------------------------------------------------- 1 | import { foo } from "./dep1"; 2 | 3 | describe("A", () => { 4 | it("should work", () => { 5 | if (foo() !== 42) { 6 | throw new Error("fail"); 7 | } 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /test/fixtures/bundle-per-file-watch/files/main-b.js: -------------------------------------------------------------------------------- 1 | import { foo } from "./dep1"; 2 | 3 | describe("B", () => { 4 | it("should work", () => { 5 | if (foo() !== 42) { 6 | throw new Error("fail"); 7 | } 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /test/fixtures/bundle-per-file-watch/karma.conf.js: -------------------------------------------------------------------------------- 1 | const { baseConfig } = require("../../base.karma.conf"); 2 | 3 | module.exports = function (config) { 4 | config.set({ 5 | ...baseConfig, 6 | esbuild: { 7 | singleBundle: false, 8 | }, 9 | }); 10 | }; 11 | -------------------------------------------------------------------------------- /test/fixtures/bundle-per-file/files/dep1.js: -------------------------------------------------------------------------------- 1 | let count = 0; 2 | export function foo() { 3 | return count++; 4 | } 5 | -------------------------------------------------------------------------------- /test/fixtures/bundle-per-file/files/main-a.js: -------------------------------------------------------------------------------- 1 | import { foo } from "./dep1"; 2 | 3 | describe("Suite A", () => { 4 | it("should work", () => { 5 | if (foo() !== 0) { 6 | throw new Error("fail"); 7 | } 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /test/fixtures/bundle-per-file/files/main-b.js: -------------------------------------------------------------------------------- 1 | import { foo } from "./dep1"; 2 | 3 | describe("Suite B", () => { 4 | it("should work", () => { 5 | if (foo() !== 0) { 6 | throw new Error("fail"); 7 | } 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /test/fixtures/bundle-per-file/karma.conf.js: -------------------------------------------------------------------------------- 1 | const { baseConfig } = require("../../base.karma.conf"); 2 | 3 | module.exports = function (config) { 4 | config.set({ 5 | ...baseConfig, 6 | esbuild: { 7 | singleBundle: false, 8 | }, 9 | }); 10 | }; 11 | -------------------------------------------------------------------------------- /test/fixtures/reentrant-initial-bundle/files/main-a.js: -------------------------------------------------------------------------------- 1 | import { foo } from "./sub/dep1"; 2 | 3 | describe("simple", () => { 4 | it("should work", () => { 5 | return foo == 42; 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /test/fixtures/reentrant-initial-bundle/files/main-b.js: -------------------------------------------------------------------------------- 1 | import { foo } from "./sub/dep1"; 2 | 3 | describe("simple", () => { 4 | it("should work", () => { 5 | return foo == 42; 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /test/fixtures/reentrant-initial-bundle/files/sub/dep1.js: -------------------------------------------------------------------------------- 1 | export const foo = 42; 2 | -------------------------------------------------------------------------------- /test/fixtures/reentrant-initial-bundle/karma.conf.js: -------------------------------------------------------------------------------- 1 | const { promises: fs } = require("fs"); 2 | const { baseConfig } = require("../../base.karma.conf"); 3 | 4 | module.exports = function (config) { 5 | let setups = 0; 6 | config.set({ 7 | ...baseConfig, 8 | 9 | esbuild: { 10 | // Make bundles to happen immediately. In large projects, karma can spend 11 | // huge amount of time calculating SHAs of file contents in between calls 12 | // to our preprocessor. We need to simulate that "took too long" behavior. 13 | bundleDelay: -1, 14 | 15 | plugins: [ 16 | { 17 | name: "delayer", 18 | 19 | setup(build) { 20 | if (setups++ > 0) { 21 | // We called setup twice! This is likely because we rebuilt before 22 | // the initial build was done. 23 | throw new Error(`setup #${setups}`); 24 | } 25 | 26 | build.onLoad({ filter: /.*/, namespace: "" }, async ({ path }) => { 27 | // Insert an arbitrary delay to make the initial build take longer 28 | // than bundleDelay. 29 | await new Promise(resolve => setTimeout(resolve, 10)); 30 | return { contents: await fs.readFile(path) }; 31 | }); 32 | }, 33 | }, 34 | ], 35 | }, 36 | }); 37 | }; 38 | -------------------------------------------------------------------------------- /test/fixtures/reentrant-rebundle/files/main-a.js: -------------------------------------------------------------------------------- 1 | import { foo } from "./sub/dep1"; 2 | 3 | describe("simple", () => { 4 | it("should work", () => { 5 | return foo == 42; 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /test/fixtures/reentrant-rebundle/files/main-b.js: -------------------------------------------------------------------------------- 1 | import { foo } from "./sub/dep1"; 2 | 3 | describe("simple", () => { 4 | it("should work", () => { 5 | return foo == 42; 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /test/fixtures/reentrant-rebundle/files/sub/dep1.js: -------------------------------------------------------------------------------- 1 | export const foo = 42; 2 | -------------------------------------------------------------------------------- /test/fixtures/reentrant-rebundle/karma.conf.js: -------------------------------------------------------------------------------- 1 | const { promises: fs } = require("fs"); 2 | const { baseConfig } = require("../../base.karma.conf"); 3 | const path = require("path"); 4 | 5 | module.exports = function (config) { 6 | config.set({ 7 | ...baseConfig, 8 | 9 | esbuild: { 10 | bundleDelay: -1, 11 | 12 | plugins: [ 13 | { 14 | name: "delayer", 15 | 16 | setup(build) { 17 | build.onLoad( 18 | { filter: /.*/, namespace: "" }, 19 | async ({ path: filePath }) => { 20 | console.log(`file: ${path.basename(filePath)}`); 21 | // Insert an arbitrary delay to make the build take longer than 22 | // bundleDelay. 23 | await new Promise(resolve => setTimeout(resolve, 50)); 24 | return { contents: await fs.readFile(filePath) }; 25 | }, 26 | ); 27 | }, 28 | }, 29 | ], 30 | }, 31 | }); 32 | }; 33 | -------------------------------------------------------------------------------- /test/fixtures/simple-bundle/files/dep1.js: -------------------------------------------------------------------------------- 1 | export function foo() { 2 | return 42; 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/simple-bundle/files/main-a.js: -------------------------------------------------------------------------------- 1 | import { foo } from "./dep1"; 2 | 3 | describe("simple", () => { 4 | it("should work", () => { 5 | if (foo() !== 42) { 6 | throw new Error("fail"); 7 | } 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /test/fixtures/simple-bundle/karma.conf.js: -------------------------------------------------------------------------------- 1 | const { baseConfig } = require("../../base.karma.conf"); 2 | 3 | module.exports = function (config) { 4 | config.set({ 5 | ...baseConfig, 6 | }); 7 | }; 8 | -------------------------------------------------------------------------------- /test/fixtures/simple/files/main-a.js: -------------------------------------------------------------------------------- 1 | describe("simple", () => { 2 | it("should work", () => { 3 | return true; 4 | }); 5 | }); 6 | -------------------------------------------------------------------------------- /test/fixtures/simple/karma.conf.js: -------------------------------------------------------------------------------- 1 | const { baseConfig } = require("../../base.karma.conf"); 2 | 3 | module.exports = function (config) { 4 | config.set({ 5 | ...baseConfig, 6 | }); 7 | }; 8 | -------------------------------------------------------------------------------- /test/fixtures/sourcemap-base-bundle/dep2.js: -------------------------------------------------------------------------------- 1 | export function bar() { 2 | throw new Error("fail"); 3 | } 4 | //# sourceMappingURL=dep2.js.map 5 | -------------------------------------------------------------------------------- /test/fixtures/sourcemap-base-bundle/dep2.js.map: -------------------------------------------------------------------------------- 1 | { 2 | "version": 3, 3 | "sources": [ 4 | "dep2.js" 5 | ], 6 | "names": [], 7 | "mappings": "AAAA,SAASA,OAAT,EAAkBC,SAAlB,EAA6BC,CAA7B,QAAsC,QAAtC;AAEA;;AACA,IAAIC,YAAJ;AAEA;;AACA,IAAIC,WAAJ;AAEA;;AACA,IAAIC,gBAAJ;AAEA;;AACA,IAAIC,iBAAiB,GAAG,EAAxB;AAEA;AACA;AACA;;AACA,IAAMC,KAAK,GAAG;AACbC,EAAAA,IAAI,EAAE,IAAIC,GAAJ,EADO;AAEbC,EAAAA,IAAI,EAAE,IAAID,GAAJ;AAFO,CAAd;;AAKA,IAAME,IAAI,GAAG,SAAPA,IAAO,GAAM,CAAE,CAArB;;AAEA,IAAIC,QAAQ,GAAG,CAAf;AAEA;;AACA,IAAMC,WAAW,GAAG,CAApB;AACA;;AACA,IAAMC,aAAa,GAAG,CAAtB;AACA;;AACA,IAAMC,aAAa,GAAG,CAAtB;AAEA,IAAIC,OAAO,GAAGhB,OAAH,IAAX;AACA,IAAIiB,SAAS,GAAGjB,OAAH,IAAb;AACA,IAAIkB,SAAS,GAAGlB,OAAO,CAACmB,MAAxB;AACA,IAAIC,gBAAgB,GAAGpB,OAAO,CAACqB,OAA/B;;AAEArB,OAAO,IAAP,GAAgB,UAAAsB,KAAK,EAAI;AACxBjB,EAAAA,gBAAgB,GAAG,IAAnB;AACA,MAAIW,OAAJ,EAAaA,OAAO,CAACM,KAAD,CAAP;AACb,CAHD;;AAKAtB,OAAO,IAAP,GAAkB,UAAAsB,KAAK,EAAI;AAC1B,MAAIL,SAAJ,EAAeA,SAAS,CAACK,KAAD,CAAT;AAEfjB,EAAAA,gBAAgB,GAAGiB,KAAH,IAAhB;AACAnB,EAAAA,YAAY,GAAG,CAAf;AAEA,MAAMoB,QAAQ,GAAGlB,gBAAH,IAAd;;AACA,MAAIkB,QAAJ,EAAc;AACbA,IAAAA,QAAQ,IAAR,CAAyBC,OAAzB,CAAiCC,aAAjC;AACA;AACD,CAVD;;AAYAzB,OAAO,CAACmB,MAAR,GAAiB,UAAAG,KAAK,EAAI;AACzB,MAAIJ,SAAJ,EAAeA,SAAS,CAACI,KAAD,CAAT;AAEf,MAAMI,CAAC,GAAGJ,KAAH,IAAP;;AACA,MAAII,CAAC,IAAIA,CAAJ,IAAL" 8 | } 9 | -------------------------------------------------------------------------------- /test/fixtures/sourcemap-base-bundle/files/main-a.js: -------------------------------------------------------------------------------- 1 | describe("simple", () => { 2 | it("should throw", async () => { 3 | throw new Error("Source thrown"); 4 | }); 5 | }); 6 | -------------------------------------------------------------------------------- /test/fixtures/sourcemap-base-bundle/files/sub/main-b.js: -------------------------------------------------------------------------------- 1 | import { bar } from "../../dep2"; 2 | 3 | describe("simple", () => { 4 | it("should fail", () => { 5 | bar(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /test/fixtures/sourcemap-base-bundle/karma.conf.js: -------------------------------------------------------------------------------- 1 | const { baseConfig } = require("../../base.karma.conf"); 2 | 3 | module.exports = function (config) { 4 | config.set({ 5 | ...baseConfig, 6 | basePath: undefined, 7 | preprocessors: { 8 | "files/**/*.js": ["esbuild"], 9 | }, 10 | 11 | esbuild: { 12 | singleBundle: false, 13 | }, 14 | }); 15 | }; 16 | -------------------------------------------------------------------------------- /test/fixtures/sourcemap-base/files/main-a.js: -------------------------------------------------------------------------------- 1 | import { fetchPolyfill } from "../../../fetch-polyfill.js"; 2 | // "env" module is synthetically created by the karma.conf.js using an esbuild plugin. 3 | import expectedSources from "env"; 4 | 5 | describe("simple", () => { 6 | it("should work", async () => { 7 | const script = document.querySelector('script[src*="main-a.js"]'); 8 | const { pathname } = new URL(script.src); 9 | const js = await fetchPolyfill(script.src).then(res => res.text()); 10 | 11 | const m = js.match(/\/\/# sourceMappingURL=(.*)/); 12 | if (!m || m.length < 1) { 13 | throw new Error("Unable to find source map url"); 14 | } 15 | 16 | const filename = /[^/]+$/.exec(pathname); 17 | if (m[1] !== `${filename}.map`) { 18 | throw new Error( 19 | `unexpected sourceMappingURL value, wanted "${filename}.map" but got "${m[1]}"`, 20 | ); 21 | } 22 | 23 | const mapText = await fetchPolyfill(`${pathname}.map`).then(res => 24 | res.text(), 25 | ); 26 | const ignore = /env-ns/; 27 | const sources = JSON.parse(mapText) 28 | .sources.filter(s => !ignore.test(s)) 29 | .sort(); 30 | 31 | if (sources.length !== expectedSources.length) { 32 | throw new Error( 33 | `source length differs, wanted ${expectedSources.length} but got ${sources.length}`, 34 | ); 35 | } 36 | expectedSources.forEach((expected, i) => { 37 | if (sources[i] !== expected) { 38 | throw new Error( 39 | `source ${i} differs, wanted "${expected}" but got "${sources[i]}"`, 40 | ); 41 | } 42 | }); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /test/fixtures/sourcemap-base/files/sub/dep2.js: -------------------------------------------------------------------------------- 1 | export function bar(a, b) { 2 | return a + b; 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/sourcemap-base/files/sub/main-b.js: -------------------------------------------------------------------------------- 1 | import { bar } from "./dep2"; 2 | 3 | describe("simple", () => { 4 | it("should work", () => { 5 | return bar(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /test/fixtures/sourcemap-base/karma.conf.js: -------------------------------------------------------------------------------- 1 | const { baseConfig } = require("../../base.karma.conf"); 2 | const path = require("path"); 3 | 4 | const expectedSources = [ 5 | path.join(process.cwd(), "test", "fetch-polyfill.js"), 6 | path.join( 7 | process.cwd(), 8 | "test", 9 | "fixtures", 10 | "sourcemap-base", 11 | "files", 12 | "main-a.js", 13 | ), 14 | ].sort(); 15 | 16 | const envPlugin = { 17 | name: "env", 18 | setup(build) { 19 | // Intercept import paths called "env" so esbuild doesn't attempt 20 | // to map them to a file system location. Tag them with the "env-ns" 21 | // namespace to reserve them for this plugin. 22 | build.onResolve({ filter: /^env$/ }, args => ({ 23 | path: args.path, 24 | namespace: "env-ns", 25 | })); 26 | 27 | // We're going to hook into esbuild and replace the "env" module loaded 28 | // by the tests with our expected results. 29 | build.onLoad({ filter: /^env$/, namespace: "env-ns" }, () => ({ 30 | contents: JSON.stringify(expectedSources), 31 | loader: "json", 32 | })); 33 | }, 34 | }; 35 | 36 | module.exports = function (config) { 37 | config.set({ 38 | ...baseConfig, 39 | preprocessors: { 40 | "files/**/*.js": ["esbuild"], 41 | }, 42 | 43 | esbuild: { 44 | singleBundle: false, 45 | plugins: [envPlugin], 46 | }, 47 | }); 48 | }; 49 | -------------------------------------------------------------------------------- /test/fixtures/sourcemap-error/files/main-a.js: -------------------------------------------------------------------------------- 1 | import { foo } from "./sub/dep1"; 2 | 3 | describe("simple", () => { 4 | it("should work", () => { 5 | return foo(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /test/fixtures/sourcemap-error/files/sub/dep1.js: -------------------------------------------------------------------------------- 1 | export function foo(a, b) { 2 | throw new Error("fail"); 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/sourcemap-error/karma.conf.js: -------------------------------------------------------------------------------- 1 | const { baseConfig } = require("../../base.karma.conf"); 2 | 3 | module.exports = function (config) { 4 | config.set({ 5 | ...baseConfig, 6 | preprocessors: { 7 | "**/*.js": ["esbuild"], 8 | }, 9 | }); 10 | }; 11 | -------------------------------------------------------------------------------- /test/fixtures/sourcemap-fetch/files/main-a.js: -------------------------------------------------------------------------------- 1 | import { fetchPolyfill } from "../../../fetch-polyfill.js"; 2 | 3 | describe("sourcemap-fetch", () => { 4 | async function getMap(selector) { 5 | const script = document.querySelector(selector); 6 | const url = script.src.replace(/[?#].*/, "") + ".map"; 7 | const resp = await fetchPolyfill(url); 8 | if (resp.status >= 400) { 9 | throw resp.status; 10 | } 11 | return resp.status; 12 | } 13 | 14 | it("should fetch real sourcemap", () => { 15 | return getMap('script[src*="-bundle.js"]'); 16 | }); 17 | 18 | it("should 404 unknown file", () => { 19 | // Mocha is not compiled by esbuild processor. 20 | return getMap('script[src*="node_modules/mocha/mocha.js"]').then( 21 | () => { 22 | throw new Error("expected this to fail"); 23 | }, 24 | s => { 25 | if (s !== 404) { 26 | throw new Error("expected a 404 response"); 27 | } 28 | }, 29 | ); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /test/fixtures/sourcemap-fetch/karma.conf.js: -------------------------------------------------------------------------------- 1 | const { baseConfig } = require("../../base.karma.conf"); 2 | 3 | module.exports = function (config) { 4 | config.set({ 5 | ...baseConfig, 6 | preprocessors: { 7 | "**/*.js": ["esbuild"], 8 | }, 9 | }); 10 | }; 11 | -------------------------------------------------------------------------------- /test/fixtures/sourcemap/files/main-a.js: -------------------------------------------------------------------------------- 1 | import { fetchPolyfill } from "../../../fetch-polyfill.js"; 2 | // "env" module is synthetically created by the karma.conf.js using an esbuild plugin. 3 | import expectedSources from "env"; 4 | 5 | describe("simple", () => { 6 | it("should work", async () => { 7 | const script = document.querySelector('script[src*="-bundle.js"]'); 8 | const { pathname } = new URL(script.src); 9 | const js = await fetchPolyfill(script.src).then(res => res.text()); 10 | 11 | const m = js.match(/\/\/# sourceMappingURL=(.*)/); 12 | if (!m || m.length < 1) { 13 | throw new Error("Unable to find source map url"); 14 | } 15 | 16 | const filename = /[^/]+$/.exec(pathname); 17 | if (m[1] !== `${filename}.map`) { 18 | throw new Error( 19 | `unexpected sourceMappingURL value, wanted "${filename}.map" but got "${m[1]}"`, 20 | ); 21 | } 22 | 23 | const mapText = await fetchPolyfill(`${pathname}.map`).then(res => 24 | res.text(), 25 | ); 26 | const ignore = /(env-ns)|-bundle.js$/; 27 | const sources = JSON.parse(mapText) 28 | .sources.filter(s => !ignore.test(s)) 29 | .sort(); 30 | 31 | if (sources.length !== expectedSources.length) { 32 | throw new Error( 33 | `source length differs, wanted ${expectedSources.length} but got ${sources.length}`, 34 | ); 35 | } 36 | expectedSources.forEach((expected, i) => { 37 | if (sources[i] !== expected) { 38 | throw new Error( 39 | `source ${i} differs, wanted "${expected}" but got "${sources[i]}"`, 40 | ); 41 | } 42 | }); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /test/fixtures/sourcemap/files/sub/dep2.js: -------------------------------------------------------------------------------- 1 | export function bar(a, b) { 2 | return a + b; 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/sourcemap/files/sub/main-b.js: -------------------------------------------------------------------------------- 1 | import { bar } from "./dep2"; 2 | 3 | describe("simple", () => { 4 | it("should work", () => { 5 | return bar(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /test/fixtures/sourcemap/karma.conf.js: -------------------------------------------------------------------------------- 1 | const { baseConfig } = require("../../base.karma.conf"); 2 | const path = require("path"); 3 | 4 | const expectedSources = [ 5 | path.join(process.cwd(), "test", "fetch-polyfill.js"), 6 | path.join( 7 | process.cwd(), 8 | "test", 9 | "fixtures", 10 | "sourcemap", 11 | "files", 12 | "main-a.js", 13 | ), 14 | path.join( 15 | process.cwd(), 16 | "test", 17 | "fixtures", 18 | "sourcemap", 19 | "files", 20 | "sub", 21 | "main-b.js", 22 | ), 23 | path.join( 24 | process.cwd(), 25 | "test", 26 | "fixtures", 27 | "sourcemap", 28 | "files", 29 | "sub", 30 | "dep2.js", 31 | ), 32 | ].sort(); 33 | 34 | const envPlugin = { 35 | name: "env", 36 | setup(build) { 37 | // Intercept import paths called "env" so esbuild doesn't attempt 38 | // to map them to a file system location. Tag them with the "env-ns" 39 | // namespace to reserve them for this plugin. 40 | build.onResolve({ filter: /^env$/ }, args => ({ 41 | path: args.path, 42 | namespace: "env-ns", 43 | })); 44 | 45 | // We're going to hook into esbuild and replace the "env" module loaded 46 | // by the tests with our expected results. 47 | build.onLoad({ filter: /^env$/, namespace: "env-ns" }, () => ({ 48 | contents: JSON.stringify(expectedSources), 49 | loader: "json", 50 | })); 51 | }, 52 | }; 53 | 54 | module.exports = function (config) { 55 | config.set({ 56 | ...baseConfig, 57 | preprocessors: { 58 | "**/*.js": ["esbuild"], 59 | }, 60 | 61 | esbuild: { 62 | plugins: [envPlugin], 63 | }, 64 | }); 65 | }; 66 | -------------------------------------------------------------------------------- /test/fixtures/target-custom/files/main-a.js: -------------------------------------------------------------------------------- 1 | describe("simple", () => { 2 | it("should work", () => { 3 | var test = () => {}; 4 | if (test.toString().includes("=>")) { 5 | throw new Error( 6 | "Looks like target setting failed to transpile arrow into regular functions", 7 | ); 8 | } 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /test/fixtures/target-custom/karma.conf.js: -------------------------------------------------------------------------------- 1 | const { baseConfig } = require("../../base.karma.conf"); 2 | 3 | module.exports = function (config) { 4 | config.set({ 5 | ...baseConfig, 6 | esbuild: { 7 | target: "es5", 8 | }, 9 | }); 10 | }; 11 | -------------------------------------------------------------------------------- /test/fixtures/typescript/files/main-a.foo.ts: -------------------------------------------------------------------------------- 1 | import { fetchPolyfill } from "../../../fetch-polyfill.js"; 2 | 3 | describe("simple", () => { 4 | it("should work", () => { 5 | return true; 6 | }); 7 | 8 | it("should change file extension to .js", () => { 9 | const script = document.querySelector('script[src*=".ts"]'); 10 | if (script) { 11 | throw new Error("found .ts script"); 12 | } 13 | }); 14 | 15 | it("should serve with correct content-type", async () => { 16 | const script = document.querySelector('script[src*="main-a.foo.js"]'); 17 | 18 | const resp = await fetchPolyfill(script.src); 19 | if (resp.status !== 200) { 20 | throw new Error(resp.status); 21 | } 22 | 23 | const type = resp.headers.get("content-type"); 24 | if (!/(text|application)\/javascript/.test(type)) { 25 | throw new Error(type); 26 | } 27 | }); 28 | 29 | it("should serve .js.map", async () => { 30 | const script = document.querySelector('script[src*="main-a.foo.js"]'); 31 | 32 | const src = `${script.src.replace(/[?#].*/, "")}.map`; 33 | const resp = await fetchPolyfill(src); 34 | if (resp.status !== 200) { 35 | throw new Error(resp.status); 36 | } 37 | 38 | JSON.parse(await resp.text()); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /test/fixtures/typescript/karma.conf.js: -------------------------------------------------------------------------------- 1 | const { baseConfig } = require("../../base.karma.conf"); 2 | 3 | module.exports = function (config) { 4 | config.set({ 5 | ...baseConfig, 6 | files: [{ pattern: "files/**/*main-*.ts", watched: false, type: "js" }], 7 | esbuild: { 8 | singleBundle: false, 9 | }, 10 | }); 11 | }; 12 | -------------------------------------------------------------------------------- /test/fixtures/watch-add/files/dep1.js: -------------------------------------------------------------------------------- 1 | export function foo() { 2 | return 42; 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/watch-add/files/main-a.js: -------------------------------------------------------------------------------- 1 | import { foo } from "./dep1"; 2 | 3 | describe("simple", () => { 4 | it("should work", () => { 5 | if (foo() !== 42) { 6 | throw new Error("fail"); 7 | } 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /test/fixtures/watch-add/karma.conf.js: -------------------------------------------------------------------------------- 1 | const { baseConfig } = require("../../base.karma.conf"); 2 | 3 | module.exports = function (config) { 4 | config.set({ 5 | ...baseConfig, 6 | }); 7 | }; 8 | -------------------------------------------------------------------------------- /test/fixtures/watch-bundle/files/dep1.js: -------------------------------------------------------------------------------- 1 | export function foo() { 2 | return 42; 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/watch-bundle/files/main-a.js: -------------------------------------------------------------------------------- 1 | import { foo } from "./dep1"; 2 | 3 | describe("simple", () => { 4 | it("should work", () => { 5 | if (foo() !== 42) { 6 | throw new Error("fail"); 7 | } 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /test/fixtures/watch-bundle/karma.conf.js: -------------------------------------------------------------------------------- 1 | const { baseConfig } = require("../../base.karma.conf"); 2 | 3 | module.exports = function (config) { 4 | config.set({ 5 | ...baseConfig, 6 | }); 7 | }; 8 | -------------------------------------------------------------------------------- /test/fixtures/watch-double/files/main-a.js: -------------------------------------------------------------------------------- 1 | describe("simple", () => { 2 | it("should work", () => { 3 | return "a"; 4 | }); 5 | }); 6 | -------------------------------------------------------------------------------- /test/fixtures/watch-double/files/main-b.js: -------------------------------------------------------------------------------- 1 | describe("simple", () => { 2 | it("should work", () => { 3 | return "b"; 4 | }); 5 | }); 6 | -------------------------------------------------------------------------------- /test/fixtures/watch-double/karma.conf.js: -------------------------------------------------------------------------------- 1 | const { baseConfig } = require("../../base.karma.conf"); 2 | 3 | module.exports = function (config) { 4 | config.set({ 5 | ...baseConfig, 6 | files: [ 7 | "files/**/main-a.js", 8 | { pattern: "files/**/main-b.js", watched: true }, 9 | ], 10 | }); 11 | }; 12 | -------------------------------------------------------------------------------- /test/fixtures/watch-error/files/dep1.js: -------------------------------------------------------------------------------- 1 | export function foo() { 2 | return 42; 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/watch-error/files/main-a.js: -------------------------------------------------------------------------------- 1 | import { foo } from "./dep1"; 2 | 3 | describe("simple", () => { 4 | it("should work", () => { 5 | if (foo() !== 42) { 6 | throw new Error("fail"); 7 | } 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /test/fixtures/watch-error/karma.conf.js: -------------------------------------------------------------------------------- 1 | const { baseConfig } = require("../../base.karma.conf"); 2 | 3 | module.exports = function (config) { 4 | config.set({ 5 | ...baseConfig, 6 | }); 7 | }; 8 | -------------------------------------------------------------------------------- /test/fixtures/watch-exclude/files/dep1.js: -------------------------------------------------------------------------------- 1 | import { excluded } from "./excluded/excluded"; 2 | 3 | export function foo() { 4 | return excluded(); 5 | } 6 | -------------------------------------------------------------------------------- /test/fixtures/watch-exclude/files/excluded/excluded.ts: -------------------------------------------------------------------------------- 1 | export function excluded(): number { 2 | return 123; 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/watch-exclude/files/main-a.js: -------------------------------------------------------------------------------- 1 | import { foo } from "./dep1"; 2 | 3 | describe("simple", () => { 4 | it("should work", () => { 5 | if (foo() !== 123) { 6 | throw new Error("fail: foo() is " + foo()); 7 | } 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /test/fixtures/watch-exclude/karma.conf.js: -------------------------------------------------------------------------------- 1 | const { promises: fs } = require("fs"); 2 | const { baseConfig } = require("../../base.karma.conf"); 3 | const path = require("path"); 4 | 5 | module.exports = function (config) { 6 | config.set({ 7 | ...baseConfig, 8 | exclude: ["files/excluded/*"], 9 | }); 10 | }; 11 | -------------------------------------------------------------------------------- /test/fixtures/watch-shared/files/dep1.js: -------------------------------------------------------------------------------- 1 | export function foo() { 2 | return 42; 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/watch-shared/files/main-a.js: -------------------------------------------------------------------------------- 1 | import { foo } from "./dep1"; 2 | 3 | describe("A", () => { 4 | it("should work", () => { 5 | if (foo() !== 42) { 6 | throw new Error("fail"); 7 | } 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /test/fixtures/watch-shared/files/main-b.js: -------------------------------------------------------------------------------- 1 | import { foo } from "./dep1"; 2 | 3 | describe("B", () => { 4 | it("should work", () => { 5 | if (foo() !== 42) { 6 | throw new Error("fail"); 7 | } 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /test/fixtures/watch-shared/karma.conf.js: -------------------------------------------------------------------------------- 1 | const { baseConfig } = require("../../base.karma.conf"); 2 | 3 | module.exports = function (config) { 4 | config.set({ 5 | ...baseConfig, 6 | }); 7 | }; 8 | -------------------------------------------------------------------------------- /test/reentrant-initial-bundle.test.ts: -------------------------------------------------------------------------------- 1 | import { Config } from "pentf/config"; 2 | import { assertEventuallyProgresses, runKarma } from "./test-utils"; 3 | 4 | export const description = "Rebuild happens before initial build finishes"; 5 | export async function run(config: Config) { 6 | const { output } = await runKarma(config, "reentrant-initial-bundle"); 7 | 8 | // Both main-*.js tests are necessary, so that we call the preprocessor twice. 9 | 10 | await assertEventuallyProgresses(output.stdout, () => { 11 | return output.stdout.some(line => /2 tests completed/.test(line)); 12 | }); 13 | } 14 | -------------------------------------------------------------------------------- /test/reentrant-rebundle.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEventuallyProgresses, runKarma } from "./test-utils"; 2 | import { promises as fs } from "fs"; 3 | import path from "path"; 4 | import { onTeardown } from "pentf/runner"; 5 | import { strict as assert } from "assert"; 6 | 7 | export const description = 8 | "Reentrant writes after initial build waits for in-progress build to finish, only one pending build may succeed"; 9 | export async function run(config: any) { 10 | const { output, resetLog } = await runKarma(config, "reentrant-rebundle"); 11 | 12 | // Both main-*.js tests are necessary, so that we call the preprocessor twice. 13 | 14 | await assertEventuallyProgresses(output.stdout, () => { 15 | return output.stdout.some(line => /2 tests completed/.test(line)); 16 | }); 17 | 18 | const filePath = path.join( 19 | __dirname, 20 | "fixtures", 21 | "reentrant-rebundle", 22 | "files", 23 | "sub", 24 | "dep1.js", 25 | ); 26 | 27 | const content = await fs.readFile(filePath, "utf-8"); 28 | const write = (content: string) => fs.writeFile(filePath, content, "utf-8"); 29 | 30 | onTeardown(config, async () => { 31 | await write(content); 32 | }); 33 | 34 | resetLog(); 35 | 36 | for (let i = 0; i < 6; i++) { 37 | await new Promise(resolve => { 38 | const exp = 2 ** i; 39 | setTimeout(resolve, 5 * exp); 40 | }); 41 | await write(content); 42 | } 43 | 44 | await assertEventuallyProgresses(output.stdout, () => { 45 | return output.stdout.some(line => /2 tests completed/.test(line)); 46 | }); 47 | 48 | const files = output.stdout.join("\n").match(/file: .*/g); 49 | assert(files !== null); 50 | assert(files.length >= 4); 51 | assert.equal(files.length % 4, 0); 52 | 53 | // We expect all 4 files to be built during each rebuild, with distinct 54 | // recompliations. That means no chunk can contain a duplicate file. 55 | const chunks = chunk(files, 4); 56 | for (let i = 0; i < chunks.length; i++) { 57 | const chunk = chunks[i]; 58 | const distint = new Set(chunk); 59 | assert.deepEqual([...distint], chunk, `Chunk ${i} had a duplicate file`); 60 | } 61 | 62 | // Only one build and run should happen, 63 | assert.equal( 64 | output.stdout.filter(line => /2 tests completed/.test(line)).length, 65 | 1, 66 | ); 67 | } 68 | 69 | function chunk(array: Array, size: number) { 70 | const chunks = []; 71 | for (let i = 0; i < array.length; i += size) { 72 | chunks.push(array.slice(i, i + size)); 73 | } 74 | return chunks; 75 | } 76 | -------------------------------------------------------------------------------- /test/run-karma.ts: -------------------------------------------------------------------------------- 1 | import child_process from "child_process"; 2 | import { runKarma } from "./test-utils"; 3 | 4 | const args = process.argv; 5 | if (args.length <= 2) { 6 | console.error("Missing fixture argument: run-karma [my-fixture]"); 7 | process.exit(1); 8 | } 9 | const mockConfig = { 10 | _teardown_hooks: [], 11 | }; 12 | runKarma(mockConfig, args[2], { inherit: true }); 13 | -------------------------------------------------------------------------------- /test/run.ts: -------------------------------------------------------------------------------- 1 | import { main } from "pentf"; 2 | 3 | main({ 4 | rootDir: __dirname, 5 | testsGlob: "{**/,}*.test.ts", 6 | }); 7 | -------------------------------------------------------------------------------- /test/simple-bundle.test.ts: -------------------------------------------------------------------------------- 1 | import { Config } from "pentf/config"; 2 | import { assertEventuallyProgresses, runKarma } from "./test-utils"; 3 | 4 | export const description = "Run a single test with a bundle"; 5 | export async function run(config: Config) { 6 | const { output } = await runKarma(config, "simple-bundle"); 7 | 8 | await assertEventuallyProgresses(output.stdout, () => { 9 | return output.stdout.some(line => /should work/.test(line)); 10 | }); 11 | await assertEventuallyProgresses(output.stdout, () => { 12 | return output.stdout.some(line => /1 test completed/.test(line)); 13 | }); 14 | } 15 | -------------------------------------------------------------------------------- /test/simple.test.ts: -------------------------------------------------------------------------------- 1 | import { Config } from "pentf/config"; 2 | import { assertEventuallyProgresses, runKarma } from "./test-utils"; 3 | 4 | export const description = "Run a single test"; 5 | export async function run(config: Config) { 6 | const { output } = await runKarma(config, "simple"); 7 | 8 | await assertEventuallyProgresses(output.stdout, () => { 9 | return output.stdout.some(line => /1 test completed/.test(line)); 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /test/sourcemap-base-bundle.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEventuallyProgresses, runKarma } from "./test-utils"; 2 | import { strict as assert } from "assert"; 3 | 4 | export const description = 5 | "Resolve source maps from files served via /base url"; 6 | export async function run(config: any) { 7 | const { output } = await runKarma(config, "sourcemap-base-bundle"); 8 | 9 | await assertEventuallyProgresses(output.stdout, () => { 10 | return output.stdout.some(line => /SUMMARY/.test(line)); 11 | }); 12 | 13 | // Check for mapped source file 14 | await assertEventuallyProgresses(output.stdout, () => { 15 | assert.match(output.stdout.join("\n"), /main-a\.js:3:9/); 16 | return true; 17 | }); 18 | 19 | // Check for mapped dependency file 20 | await assertEventuallyProgresses(output.stdout, () => { 21 | assert.match(output.stdout.join("\n"), /dep2\.js:2:8/); 22 | return true; 23 | }); 24 | } 25 | -------------------------------------------------------------------------------- /test/sourcemap-base.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEventuallyProgresses, runKarma } from "./test-utils"; 2 | 3 | export const description = 4 | "Resolve source maps from files served via /base url"; 5 | export async function run(config: any) { 6 | const { output } = await runKarma(config, "sourcemap-base"); 7 | 8 | await assertEventuallyProgresses(output.stdout, () => { 9 | return output.stdout.some(line => /2 tests completed/.test(line)); 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /test/sourcemap-error.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEventuallyProgresses, runKarma } from "./test-utils"; 2 | import path from "path"; 3 | import { strict as assert } from "assert"; 4 | import { parseStackTrace } from "errorstacks"; 5 | 6 | export const description = "Resolve source maps relative to an absolute root"; 7 | export async function run(config: any) { 8 | const { output } = await runKarma(config, "sourcemap-error"); 9 | 10 | await assertEventuallyProgresses(output.stdout, () => { 11 | return output.stdout.some(line => /Error: fail/.test(line)); 12 | }); 13 | 14 | const idx = output.stdout.findIndex(line => /Error: fail/.test(line)); 15 | const errLine = output.stdout.slice(idx)[0]; 16 | const err = errLine 17 | .slice(errLine.indexOf("Error: fail")) 18 | .split("\n") 19 | .filter(Boolean) 20 | .slice(1) 21 | .join("\n"); 22 | 23 | const stack = parseStackTrace(err); 24 | assert.deepStrictEqual( 25 | stack.map(x => { 26 | const location = path.relative(__dirname, x.fileName); 27 | return `${location}:${x.line}:${x.column}`; 28 | }), 29 | [ 30 | `${path.join( 31 | "fixtures", 32 | "sourcemap-error", 33 | "files", 34 | "sub", 35 | "dep1.js", 36 | )}:2:8`, 37 | `${path.join("fixtures", "sourcemap-error", "files", "main-a.js")}:5:10`, 38 | ], 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /test/sourcemap-fetch.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEventuallyProgresses, runKarma } from "./test-utils"; 2 | 3 | export const description = "Unknown files are not treated as sourcemaps"; 4 | export async function run(config: any) { 5 | const { output } = await runKarma(config, "sourcemap-fetch"); 6 | 7 | await assertEventuallyProgresses(output.stdout, () => { 8 | return output.stdout.some(line => /2 tests completed/.test(line)); 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /test/sourcemap.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEventuallyProgresses, runKarma } from "./test-utils"; 2 | 3 | export const description = "Resolve source maps relative to an absolute root"; 4 | export async function run(config: any) { 5 | const { output } = await runKarma(config, "sourcemap"); 6 | 7 | await assertEventuallyProgresses(output.stdout, () => { 8 | return output.stdout.some(line => /2 tests completed/.test(line)); 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /test/target-custom.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEventuallyProgresses, runKarma } from "./test-utils"; 2 | 3 | export const description = "Allow custom target setting"; 4 | export async function run(config: any) { 5 | const { output } = await runKarma(config, "target-custom"); 6 | 7 | await assertEventuallyProgresses(output.stdout, () => { 8 | return output.stdout.some(line => /1 test completed/.test(line)); 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /test/test-utils.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { onTeardown } from "pentf/runner"; 3 | import child_process from "child_process"; 4 | import { stripColors } from "kolorist"; 5 | import { assertEventually } from "pentf/assert_utils"; 6 | 7 | export async function runKarma( 8 | config: any, 9 | fixture: string, 10 | options: { inherit?: boolean } = {}, 11 | ) { 12 | const app = path.join("node_modules", ".bin", "karma"); 13 | const fixturePath = path.join(__dirname, "fixtures", fixture); 14 | const karmaConfig = path.join(fixturePath, "karma.conf.js"); 15 | const output = { 16 | stdout: [] as string[], 17 | stderr: [] as string[], 18 | }; 19 | 20 | const child = child_process.spawn( 21 | app, 22 | ["start", "--no-single-run", karmaConfig], 23 | { 24 | stdio: options.inherit ? "inherit" : undefined, 25 | shell: true, 26 | }, 27 | ); 28 | 29 | if (!options.inherit) { 30 | await new Promise((resolve, reject) => { 31 | child.stderr!.on("data", s => { 32 | output.stderr.push(stripColors(s.toString())); 33 | reject(); 34 | }); 35 | child.stdout!.on("data", s => { 36 | output.stdout.push(stripColors(s.toString())); 37 | resolve(null); 38 | }); 39 | }); 40 | 41 | onTeardown(config, () => { 42 | child.kill(); 43 | }); 44 | 45 | await assertEventuallyProgresses( 46 | output.stdout, 47 | () => output.stdout.some(line => /server started/.test(line)), 48 | { 49 | message: "Could not find karma server started message", 50 | }, 51 | ); 52 | } 53 | 54 | return { 55 | output, 56 | resetLog: () => { 57 | output.stdout = []; 58 | output.stderr = []; 59 | }, 60 | }; 61 | } 62 | 63 | export async function assertEventuallyProgresses( 64 | stdout: string[], 65 | cb: () => boolean, 66 | options?: Parameters[1], 67 | ) { 68 | options = { ...options, timeout: 5_000 }; 69 | while (true) { 70 | const { length } = stdout; 71 | try { 72 | return await assertEventually(cb, options); 73 | } catch (e) { 74 | if (stdout.length === length) { 75 | console.error(stdout); 76 | // We didn't make any progress. 77 | throw e; 78 | } 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /test/typescript.test.ts: -------------------------------------------------------------------------------- 1 | import { Config } from "pentf/config"; 2 | import { assertEventuallyProgresses, runKarma } from "./test-utils"; 3 | 4 | export const description = "Run a single test"; 5 | export async function run(config: Config) { 6 | const { output } = await runKarma(config, "typescript"); 7 | 8 | await assertEventuallyProgresses(output.stdout, () => { 9 | return output.stdout.some(line => /4 tests completed/.test(line)); 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /test/watch-add.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEventuallyProgresses, runKarma } from "./test-utils"; 2 | import { promises as fs } from "fs"; 3 | import path from "path"; 4 | import { onTeardown } from "pentf/runner"; 5 | 6 | export const description = "Register new entry files on watch"; 7 | export async function run(config: any) { 8 | const { output, resetLog } = await runKarma(config, "watch-add"); 9 | 10 | await assertEventuallyProgresses(output.stdout, () => { 11 | return output.stdout.some(line => /1 test completed/.test(line)); 12 | }); 13 | 14 | const filePath = path.join( 15 | __dirname, 16 | "fixtures", 17 | "watch-add", 18 | "files", 19 | "main-b.js", 20 | ); 21 | 22 | onTeardown(config, async () => fs.unlink(filePath)); 23 | 24 | resetLog(); 25 | 26 | // Add new test file 27 | await fs.writeFile(filePath, `it('bar', () => {})`, "utf-8"); 28 | 29 | await assertEventuallyProgresses(output.stdout, () => { 30 | return output.stdout.some(line => /2 tests completed/.test(line)); 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /test/watch-bundle.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEventuallyProgresses, runKarma } from "./test-utils"; 2 | import { promises as fs } from "fs"; 3 | import path from "path"; 4 | import { onTeardown } from "pentf/runner"; 5 | 6 | export const description = "Rebuild on watch"; 7 | export async function run(config: any) { 8 | const { output, resetLog } = await runKarma(config, "watch-bundle"); 9 | 10 | await assertEventuallyProgresses(output.stdout, () => { 11 | return output.stdout.some(line => /1 test completed/.test(line)); 12 | }); 13 | 14 | const filePath = path.join( 15 | __dirname, 16 | "fixtures", 17 | "watch-bundle", 18 | "files", 19 | "dep1.js", 20 | ); 21 | 22 | const content = await fs.readFile(filePath, "utf-8"); 23 | const write = (content: string) => fs.writeFile(filePath, content, "utf-8"); 24 | 25 | onTeardown(config, async () => { 26 | await write(content); 27 | }); 28 | 29 | resetLog(); 30 | await write(`export function foo() { return 2 }`); 31 | 32 | await assertEventuallyProgresses(output.stdout, () => { 33 | return output.stdout.some(line => /1 test failed/.test(line)); 34 | }); 35 | 36 | resetLog(); 37 | await write(content); 38 | await assertEventuallyProgresses(output.stdout, () => { 39 | return output.stdout.some(line => /1 test completed/.test(line)); 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /test/watch-double.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEventuallyProgresses, runKarma } from "./test-utils"; 2 | import { promises as fs } from "fs"; 3 | import path from "path"; 4 | import { strict as assert } from "assert"; 5 | 6 | export const description = "Rebuild on watch"; 7 | export async function run(config: any) { 8 | const { output, resetLog } = await runKarma(config, "watch-double"); 9 | 10 | await assertEventuallyProgresses(output.stdout, () => { 11 | return ( 12 | output.stdout.some(line => /\[esbuild\]: Compiling done/.test(line)) && 13 | output.stdout.some(line => /2 tests completed/.test(line)) 14 | ); 15 | }); 16 | 17 | const aPath = path.join( 18 | __dirname, 19 | "fixtures", 20 | "watch-double", 21 | "files", 22 | "main-a.js", 23 | ); 24 | const bPath = path.join( 25 | __dirname, 26 | "fixtures", 27 | "watch-double", 28 | "files", 29 | "main-b.js", 30 | ); 31 | 32 | const [aContent, bContent] = await Promise.all([ 33 | fs.readFile(aPath, "utf-8"), 34 | fs.readFile(bPath, "utf-8"), 35 | ]); 36 | const write = (filePath: string, content: string) => 37 | fs.writeFile(filePath, content, "utf-8"); 38 | 39 | resetLog(); 40 | await write(aPath, aContent); 41 | await assertEventuallyProgresses(output.stdout, () => { 42 | return ( 43 | output.stdout.some(line => /\[esbuild\]: Compiling done/.test(line)) && 44 | output.stdout.some(line => /2 tests completed/.test(line)) 45 | ); 46 | }); 47 | assert.equal( 48 | output.stdout.filter(line => /2 tests completed/.test(line)).length, 49 | 1, 50 | "Only one build and run should happen", 51 | ); 52 | 53 | resetLog(); 54 | await write(bPath, bContent); 55 | await assertEventuallyProgresses(output.stdout, () => { 56 | return ( 57 | output.stdout.some(line => /\[esbuild\]: Compiling done/.test(line)) && 58 | output.stdout.some(line => /2 tests completed/.test(line)) 59 | ); 60 | }); 61 | assert.equal( 62 | output.stdout.filter(line => /2 tests completed/.test(line)).length, 63 | 1, 64 | "Only one build and run should happen", 65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /test/watch-error.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEventuallyProgresses, runKarma } from "./test-utils"; 2 | import { promises as fs } from "fs"; 3 | import path from "path"; 4 | import { onTeardown } from "pentf/runner"; 5 | 6 | export const description = "Syntax errors should not break watcher"; 7 | export async function run(config: any) { 8 | const { output, resetLog } = await runKarma(config, "watch-error"); 9 | 10 | await assertEventuallyProgresses(output.stdout, () => { 11 | return output.stdout.some(line => /1 test completed/.test(line)); 12 | }); 13 | 14 | const filePath = path.join( 15 | __dirname, 16 | "fixtures", 17 | "watch-error", 18 | "files", 19 | "dep1.js", 20 | ); 21 | 22 | const content = await fs.readFile(filePath, "utf-8"); 23 | const write = (content: string) => fs.writeFile(filePath, content, "utf-8"); 24 | 25 | onTeardown(config, async () => { 26 | await write(content); 27 | }); 28 | 29 | resetLog(); 30 | await write(`export function;;;123+++++`); 31 | 32 | await assertEventuallyProgresses(output.stdout, () => { 33 | return output.stdout.some(line => 34 | /\[esbuild\]: Build failed with/.test(line), 35 | ); 36 | }); 37 | 38 | resetLog(); 39 | await write(content); 40 | await assertEventuallyProgresses(output.stdout, () => { 41 | return output.stdout.some(line => /1 test completed/.test(line)); 42 | }); 43 | } 44 | -------------------------------------------------------------------------------- /test/watch-exclude.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEventuallyProgresses, runKarma } from "./test-utils"; 2 | import { promises as fs } from "fs"; 3 | import path from "path"; 4 | import { onTeardown } from "pentf/runner"; 5 | import { assertAlways } from "pentf/assert_utils"; 6 | 7 | export const description = "Respects `exclude` param in karma config"; 8 | export async function run(config: any) { 9 | const { output, resetLog } = await runKarma(config, "watch-exclude"); 10 | // Change excluded file 11 | const write = (path: string, content: string) => 12 | fs.writeFile(path, content, "utf-8"); 13 | onTeardown(config, async () => { 14 | await write(excludedPath, fileContent); 15 | await write(testPath, testContent); 16 | }); 17 | 18 | await assertEventuallyProgresses(output.stdout, () => { 19 | return output.stdout.some(line => /1 test completed/.test(line)); 20 | }); 21 | 22 | const excludedPath = path.join( 23 | __dirname, 24 | "fixtures", 25 | "watch-exclude", 26 | "files", 27 | "excluded", 28 | "excluded.ts", 29 | ); 30 | const testPath = path.join( 31 | __dirname, 32 | "fixtures", 33 | "watch-exclude", 34 | "files", 35 | "main-a.js", 36 | ); 37 | 38 | const fileContent = await fs.readFile(excludedPath, "utf-8"); 39 | const testContent = await fs.readFile(testPath, "utf-8"); 40 | const changedContent = fileContent.replace("123", "321"); 41 | 42 | resetLog(); 43 | await write(excludedPath, changedContent); 44 | 45 | await assertAlways(() => output.stdout.length === 0, { 46 | message: `Unexpected compilation output when changing excluded file.`, 47 | }); 48 | 49 | resetLog(); 50 | await write(testPath, testContent); 51 | 52 | await assertEventuallyProgresses(output.stdout, () => { 53 | return output.stdout.some(line => /1 test failed/.test(line)); 54 | }); 55 | } 56 | -------------------------------------------------------------------------------- /test/watch-shared.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEventuallyProgresses, runKarma } from "./test-utils"; 2 | import { promises as fs } from "fs"; 3 | import path from "path"; 4 | import { onTeardown } from "pentf/runner"; 5 | 6 | export const description = 7 | "Rebuild all entry files on changing a shared dependency"; 8 | export async function run(config: any) { 9 | const { output, resetLog } = await runKarma(config, "watch-shared"); 10 | 11 | await assertEventuallyProgresses(output.stdout, () => { 12 | return output.stdout.some(line => /2 tests completed/.test(line)); 13 | }); 14 | 15 | const filePath = path.join( 16 | __dirname, 17 | "fixtures", 18 | "watch-shared", 19 | "files", 20 | "dep1.js", 21 | ); 22 | 23 | const content = await fs.readFile(filePath, "utf-8"); 24 | const write = (content: string) => fs.writeFile(filePath, content, "utf-8"); 25 | 26 | onTeardown(config, async () => { 27 | await write(content); 28 | }); 29 | 30 | resetLog(); 31 | await write(`export function foo() { return 2 }`); 32 | 33 | await assertEventuallyProgresses(output.stdout, () => { 34 | return output.stdout.some(line => /2 tests failed/.test(line)); 35 | }); 36 | 37 | resetLog(); 38 | await write(content); 39 | await assertEventuallyProgresses(output.stdout, () => { 40 | return output.stdout.some(line => /2 tests completed/.test(line)); 41 | }); 42 | } 43 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "moduleResolution": "node", 5 | "esModuleInterop": true, 6 | "module": "CommonJS", 7 | "target": "ES2018", 8 | "outDir": "dist/" 9 | }, 10 | "files": ["./src/index.ts"] 11 | } 12 | --------------------------------------------------------------------------------