diff options
author | 2023-08-06 06:30:23 -0700 | |
---|---|---|
committer | 2023-08-06 06:30:23 -0700 | |
commit | 14624454196370e08309d4f0b0463b494e4df9ca (patch) | |
tree | 538421bfffc3d804807a4ec70a1323fbcbe3416f | |
parent | ecdf2ffa6c615d8a431c2919c0b9bdc4cbe2c4f0 (diff) | |
download | bun-14624454196370e08309d4f0b0463b494e4df9ca.tar.gz bun-14624454196370e08309d4f0b0463b494e4df9ca.tar.zst bun-14624454196370e08309d4f0b0463b494e4df9ca.zip |
Code coverage for `bun test` (#3975)
* WIP code coverage initial commit
* almost works
* one approach
* Code Coverage
* Update WebKit
* it works but is not yet accurate
* skip double ascii check
* wrapper
* it works but i'm not sure what to do about blocks
* hide blocks for now
* Update ZigSourceProvider.cpp
* Create coverage.md
* Update nav.ts
---------
Co-authored-by: Jarred Sumner <709451+Jarred-Sumner@users.noreply.github.com>
32 files changed, 1211 insertions, 61 deletions
diff --git a/.github/workflows/bun-linux-aarch64.yml b/.github/workflows/bun-linux-aarch64.yml index f40c0b5b5..d29fa0b7b 100644 --- a/.github/workflows/bun-linux-aarch64.yml +++ b/.github/workflows/bun-linux-aarch64.yml @@ -36,7 +36,7 @@ jobs: arch: aarch64 build_arch: arm64 runner: linux-arm64 - webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-1/bun-webkit-linux-arm64-lto.tar.gz" + webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-2/bun-webkit-linux-arm64-lto.tar.gz" webkit_basename: "bun-webkit-linux-arm64-lto" build_machine_arch: aarch64 diff --git a/.github/workflows/bun-linux-build.yml b/.github/workflows/bun-linux-build.yml index 02c9b94eb..f4b288d8e 100644 --- a/.github/workflows/bun-linux-build.yml +++ b/.github/workflows/bun-linux-build.yml @@ -46,7 +46,7 @@ jobs: arch: x86_64 build_arch: amd64 runner: big-ubuntu - webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-1/bun-webkit-linux-amd64-lto.tar.gz" + webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-2/bun-webkit-linux-amd64-lto.tar.gz" webkit_basename: "bun-webkit-linux-amd64-lto" build_machine_arch: x86_64 - cpu: nehalem @@ -54,7 +54,7 @@ jobs: arch: x86_64 build_arch: amd64 runner: big-ubuntu - webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-1/bun-webkit-linux-amd64-lto.tar.gz" + webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-2/bun-webkit-linux-amd64-lto.tar.gz" webkit_basename: "bun-webkit-linux-amd64-lto" build_machine_arch: x86_64 diff --git a/.github/workflows/bun-mac-aarch64.yml b/.github/workflows/bun-mac-aarch64.yml index 8946f109d..52258374e 100644 --- a/.github/workflows/bun-mac-aarch64.yml +++ b/.github/workflows/bun-mac-aarch64.yml @@ -117,7 +117,7 @@ jobs: # obj: bun-obj-darwin-x64-baseline # runner: macos-11 # artifact: bun-obj-darwin-x64-baseline - # webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-1/bun-webkit-macos-amd64-lto.tar.gz" + # webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-2/bun-webkit-macos-amd64-lto.tar.gz" # dependencies: true # compile_obj: false # - cpu: haswell @@ -126,7 +126,7 @@ jobs: # obj: bun-obj-darwin-x64 # runner: macos-11 # artifact: bun-obj-darwin-x64 - # webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-1/bun-webkit-macos-amd64-lto.tar.gz" + # webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-2/bun-webkit-macos-amd64-lto.tar.gz" # dependencies: true # compile_obj: false # - cpu: nehalem @@ -135,7 +135,7 @@ jobs: # obj: bun-obj-darwin-x64-baseline # runner: macos-11 # artifact: bun-obj-darwin-x64-baseline - # webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-1/bun-webkit-macos-amd64-lto.tar.gz" + # webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-2/bun-webkit-macos-amd64-lto.tar.gz" # dependencies: false # compile_obj: true # - cpu: haswell @@ -144,7 +144,7 @@ jobs: # obj: bun-obj-darwin-x64 # runner: macos-11 # artifact: bun-obj-darwin-x64 - # webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-1/bun-webkit-macos-amd64-lto.tar.gz" + # webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-2/bun-webkit-macos-amd64-lto.tar.gz" # dependencies: false # compile_obj: true - cpu: native @@ -152,7 +152,7 @@ jobs: tag: bun-darwin-aarch64 obj: bun-obj-darwin-aarch64 artifact: bun-obj-darwin-aarch64 - webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-1/bun-webkit-macos-arm64-lto.tar.gz" + webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-2/bun-webkit-macos-arm64-lto.tar.gz" runner: macos-arm64 dependencies: true compile_obj: true @@ -257,7 +257,7 @@ jobs: # package: bun-darwin-x64 # runner: macos-11 # artifact: bun-obj-darwin-x64-baseline - # webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-1/bun-webkit-macos-amd64-lto.tar.gz" + # webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-2/bun-webkit-macos-amd64-lto.tar.gz" # - cpu: haswell # arch: x86_64 # tag: bun-darwin-x64 @@ -265,14 +265,14 @@ jobs: # package: bun-darwin-x64 # runner: macos-11 # artifact: bun-obj-darwin-x64 - # webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-1/bun-webkit-macos-amd64-lto.tar.gz" + # webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-2/bun-webkit-macos-amd64-lto.tar.gz" - cpu: native arch: aarch64 tag: bun-darwin-aarch64 obj: bun-obj-darwin-aarch64 package: bun-darwin-aarch64 artifact: bun-obj-darwin-aarch64 - webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-1/bun-webkit-macos-arm64-lto.tar.gz" + webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-2/bun-webkit-macos-arm64-lto.tar.gz" runner: macos-arm64 steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/bun-mac-x64-baseline.yml b/.github/workflows/bun-mac-x64-baseline.yml index 83feeaf20..049a96691 100644 --- a/.github/workflows/bun-mac-x64-baseline.yml +++ b/.github/workflows/bun-mac-x64-baseline.yml @@ -117,7 +117,7 @@ jobs: obj: bun-obj-darwin-x64-baseline runner: macos-11 artifact: bun-obj-darwin-x64-baseline - webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-1/bun-webkit-macos-amd64-lto.tar.gz" + webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-2/bun-webkit-macos-amd64-lto.tar.gz" dependencies: true compile_obj: false # - cpu: haswell @@ -126,7 +126,7 @@ jobs: # obj: bun-obj-darwin-x64 # runner: macos-11 # artifact: bun-obj-darwin-x64 - # webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-1/bun-webkit-macos-amd64-lto.tar.gz" + # webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-2/bun-webkit-macos-amd64-lto.tar.gz" # dependencies: true # compile_obj: false - cpu: nehalem @@ -135,7 +135,7 @@ jobs: obj: bun-obj-darwin-x64-baseline runner: macos-11 artifact: bun-obj-darwin-x64-baseline - webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-1/bun-webkit-macos-amd64-lto.tar.gz" + webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-2/bun-webkit-macos-amd64-lto.tar.gz" dependencies: false compile_obj: true # - cpu: haswell @@ -144,7 +144,7 @@ jobs: # obj: bun-obj-darwin-x64 # runner: macos-11 # artifact: bun-obj-darwin-x64 - # webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-1/bun-webkit-macos-amd64-lto.tar.gz" + # webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-2/bun-webkit-macos-amd64-lto.tar.gz" # dependencies: false # compile_obj: true # - cpu: native @@ -152,7 +152,7 @@ jobs: # tag: bun-darwin-aarch64 # obj: bun-obj-darwin-aarch64 # artifact: bun-obj-darwin-aarch64 - # webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-1/bun-webkit-macos-amd64-lto.tar.gz" + # webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-2/bun-webkit-macos-amd64-lto.tar.gz" # runner: macos-arm64 # dependencies: true # compile_obj: true @@ -258,7 +258,7 @@ jobs: package: bun-darwin-x64 runner: macos-11 artifact: bun-obj-darwin-x64-baseline - webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-1/bun-webkit-macos-amd64-lto.tar.gz" + webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-2/bun-webkit-macos-amd64-lto.tar.gz" # - cpu: haswell # arch: x86_64 # tag: bun-darwin-x64 @@ -266,14 +266,14 @@ jobs: # package: bun-darwin-x64 # runner: macos-11 # artifact: bun-obj-darwin-x64 - # webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-1/bun-webkit-macos-amd64-lto.tar.gz" + # webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-2/bun-webkit-macos-amd64-lto.tar.gz" # - cpu: native # arch: aarch64 # tag: bun-darwin-aarch64 # obj: bun-obj-darwin-aarch64 # package: bun-darwin-aarch64 # artifact: bun-obj-darwin-aarch64 - # webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-1/bun-webkit-macos-amd64-lto.tar.gz" + # webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-2/bun-webkit-macos-amd64-lto.tar.gz" # runner: macos-arm64 steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/bun-mac-x64.yml b/.github/workflows/bun-mac-x64.yml index d76a4abb5..4289c578f 100644 --- a/.github/workflows/bun-mac-x64.yml +++ b/.github/workflows/bun-mac-x64.yml @@ -117,7 +117,7 @@ jobs: # obj: bun-obj-darwin-x64-baseline # runner: macos-11 # artifact: bun-obj-darwin-x64-baseline - # webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-1/bun-webkit-macos-amd64-lto.tar.gz" + # webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-2/bun-webkit-macos-amd64-lto.tar.gz" # dependencies: true # compile_obj: false - cpu: haswell @@ -126,7 +126,7 @@ jobs: obj: bun-obj-darwin-x64 runner: macos-11 artifact: bun-obj-darwin-x64 - webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-1/bun-webkit-macos-amd64-lto.tar.gz" + webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-2/bun-webkit-macos-amd64-lto.tar.gz" dependencies: true compile_obj: false # - cpu: nehalem @@ -135,7 +135,7 @@ jobs: # obj: bun-obj-darwin-x64-baseline # runner: macos-11 # artifact: bun-obj-darwin-x64-baseline - # webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-1/bun-webkit-macos-amd64-lto.tar.gz" + # webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-2/bun-webkit-macos-amd64-lto.tar.gz" # dependencies: false # compile_obj: true - cpu: haswell @@ -144,7 +144,7 @@ jobs: obj: bun-obj-darwin-x64 runner: macos-11 artifact: bun-obj-darwin-x64 - webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-1/bun-webkit-macos-amd64-lto.tar.gz" + webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-2/bun-webkit-macos-amd64-lto.tar.gz" dependencies: false compile_obj: true # - cpu: native @@ -152,7 +152,7 @@ jobs: # tag: bun-darwin-aarch64 # obj: bun-obj-darwin-aarch64 # artifact: bun-obj-darwin-aarch64 - # webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-1/bun-webkit-macos-arm64-lto.tar.gz" + # webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-2/bun-webkit-macos-arm64-lto.tar.gz" # runner: macos-arm64 # dependencies: true # compile_obj: true @@ -260,7 +260,7 @@ jobs: # package: bun-darwin-x64 # runner: macos-11 # artifact: bun-obj-darwin-x64-baseline - # webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-1/bun-webkit-macos-amd64-lto.tar.gz" + # webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-2/bun-webkit-macos-amd64-lto.tar.gz" - cpu: haswell arch: x86_64 tag: bun-darwin-x64 @@ -268,14 +268,14 @@ jobs: package: bun-darwin-x64 runner: macos-11 artifact: bun-obj-darwin-x64 - webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-1/bun-webkit-macos-amd64-lto.tar.gz" + webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-2/bun-webkit-macos-amd64-lto.tar.gz" # - cpu: native # arch: aarch64 # tag: bun-darwin-aarch64 # obj: bun-obj-darwin-aarch64 # package: bun-darwin-aarch64 # artifact: bun-obj-darwin-aarch64 - # webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-1/bun-webkit-macos-arm64-lto.tar.gz" + # webkit_url: "https://github.com/oven-sh/WebKit/releases/download/2023-aug3-2/bun-webkit-macos-arm64-lto.tar.gz" # runner: macos-arm64 steps: - uses: actions/checkout@v3 diff --git a/Dockerfile b/Dockerfile index 7e829ef30..9042ea802 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,7 @@ ARG ARCH=x86_64 ARG BUILD_MACHINE_ARCH=x86_64 ARG TRIPLET=${ARCH}-linux-gnu ARG BUILDARCH=amd64 -ARG WEBKIT_TAG=2023-aug3-1 +ARG WEBKIT_TAG=2023-aug3-2 ARG ZIG_TAG=jul1 ARG ZIG_VERSION="0.11.0-dev.4006+bf827d0b5" ARG WEBKIT_BASENAME="bun-webkit-linux-$BUILDARCH" Binary files differdiff --git a/docs/nav.ts b/docs/nav.ts index 9eadf59be..ff74e16c3 100644 --- a/docs/nav.ts +++ b/docs/nav.ts @@ -207,6 +207,9 @@ export default { page("test/dom", "DOM testing", { description: "Write headless tests for UI and React/Vue/Svelte/Lit components with happy-dom", }), + page("test/coverage", "Code coverage", { + description: "Generate code coverage reports with `bun test --coverage`", + }), divider("Package runner"), page("cli/bunx", "`bunx`", { diff --git a/docs/test/coverage.md b/docs/test/coverage.md new file mode 100644 index 000000000..abf8cf94c --- /dev/null +++ b/docs/test/coverage.md @@ -0,0 +1,57 @@ +`bun:test` supports seeing which lines of code are covered by tests. To use this feature, pass `--coverage` to the CLI: + +```sh +bun test --coverage +``` + +It will print out a coverage report to the console: + +```js +-------------|---------|---------|------------------- +File | % Funcs | % Lines | Uncovered Line #s +-------------|---------|---------|------------------- +All files | 38.89 | 42.11 | + index-0.ts | 33.33 | 36.84 | 10-15,19-24 + index-1.ts | 33.33 | 36.84 | 10-15,19-24 + index-10.ts | 33.33 | 36.84 | 10-15,19-24 + index-2.ts | 33.33 | 36.84 | 10-15,19-24 + index-3.ts | 33.33 | 36.84 | 10-15,19-24 + index-4.ts | 33.33 | 36.84 | 10-15,19-24 + index-5.ts | 33.33 | 36.84 | 10-15,19-24 + index-6.ts | 33.33 | 36.84 | 10-15,19-24 + index-7.ts | 33.33 | 36.84 | 10-15,19-24 + index-8.ts | 33.33 | 36.84 | 10-15,19-24 + index-9.ts | 33.33 | 36.84 | 10-15,19-24 + index.ts | 100.00 | 100.00 | +-------------|---------|---------|------------------- +``` + +If coverage is below a threshold, `bun:test` will exit with a non-zero exit code to indicate the failure. + +### Configuring coverage + +`bunfig.toml` supports configuring coverage: + +```toml +[test] + +# Always enable coverage +coverage = true + +# Anything less than 90% coverage will fail the test +# coverageThreshold = 0.9 +coverageThreshold = { line = 0.9, function = 0.9 } + + +# Don't include .test.* files in coverage reports +coverageSkipTestFiles = true + +# Disable sourcemap support in coverage reports +# By default, coverage reports will automatically use Bun's internal sourcemap. +# You probably don't want to configure this +# coverageIgnoreSourcemaps = false +``` + +`coverageThreshold` can be either a number or an object with `line` and `function` keys. When a number, it is treated as both the line and function threshold. + +Coverage support was added in Bun v0.7.2. diff --git a/package.json b/package.json index 5cb6cf8e7..4697689f9 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "@types/react": "^18.0.25", "@typescript-eslint/eslint-plugin": "^5.31.0", "@typescript-eslint/parser": "^5.31.0", - "bun-webkit": "0.0.1-9d9172d3242b40f16fa980ead750dc9ab248065f" + "bun-webkit": "0.0.1-74609640b2a7c5a1588b824f870d1b0ff91bfd8e" }, "version": "0.0.0", "prettier": "./.prettierrc.cjs" diff --git a/src/bun.js/WebKit b/src/bun.js/WebKit -Subproject 9d9172d3242b40f16fa980ead750dc9ab248065 +Subproject 74609640b2a7c5a1588b824f870d1b0ff91bfd8 diff --git a/src/bun.js/bindings/CodeCoverage.cpp b/src/bun.js/bindings/CodeCoverage.cpp new file mode 100644 index 000000000..1cb3b6ba2 --- /dev/null +++ b/src/bun.js/bindings/CodeCoverage.cpp @@ -0,0 +1,44 @@ +#include "root.h" +#include "ZigSourceProvider.h" +#include <JavaScriptCore/ControlFlowProfiler.h> + +using namespace JSC; + +extern "C" bool CodeCoverage__withBlocksAndFunctions( + JSC::VM* vmPtr, + JSC::SourceID sourceID, + void* ctx, + bool ignoreSourceMap, + void (*blockCallback)(void* ctx, JSC::BasicBlockRange* range, size_t len, size_t functionOffset, bool ignoreSourceMap)) +{ + + VM& vm = *vmPtr; + + auto basicBlocks = vm.controlFlowProfiler()->getBasicBlocksForSourceIDWithoutFunctionRange( + sourceID, vm); + + if (basicBlocks.isEmpty()) { + blockCallback(ctx, nullptr, 0, 0, ignoreSourceMap); + return true; + } + + size_t functionStartOffset = basicBlocks.size(); + + const Vector<std::tuple<bool, unsigned, unsigned>>& functionRanges = vm.functionHasExecutedCache()->getFunctionRanges(sourceID); + + basicBlocks.reserveCapacity(functionRanges.size() + basicBlocks.size()); + + for (const auto& functionRange : functionRanges) { + BasicBlockRange range; + range.m_hasExecuted = std::get<0>(functionRange); + range.m_startOffset = static_cast<int>(std::get<1>(functionRange)); + range.m_endOffset = static_cast<int>(std::get<2>(functionRange)); + range.m_executionCount = range.m_hasExecuted + ? 1 + : 0; // This is a hack. We don't actually count this. + basicBlocks.append(range); + } + + blockCallback(ctx, basicBlocks.data(), basicBlocks.size(), functionStartOffset, ignoreSourceMap); + return true; +} diff --git a/src/bun.js/bindings/InternalModuleRegistry.cpp b/src/bun.js/bindings/InternalModuleRegistry.cpp index e6b574d7b..841360502 100644 --- a/src/bun.js/bindings/InternalModuleRegistry.cpp +++ b/src/bun.js/bindings/InternalModuleRegistry.cpp @@ -12,6 +12,20 @@ namespace Bun { +extern "C" bool BunTest__shouldGenerateCodeCoverage(BunString sourceURL); +extern "C" void ByteRangeMapping__generate(BunString sourceURL, BunString code, int sourceID); + +static void maybeAddCodeCoverage(JSC::VM& vm, const JSC::SourceCode& code) +{ +#ifdef BUN_DEBUG + bool isCodeCoverageEnabled = !!vm.controlFlowProfiler(); + bool shouldGenerateCodeCoverage = isCodeCoverageEnabled && BunTest__shouldGenerateCodeCoverage(Bun::toString(code.provider()->sourceURL())); + if (shouldGenerateCodeCoverage) { + ByteRangeMapping__generate(Bun::toString(code.provider()->sourceURL()), Bun::toString(code.provider()->source().toStringWithoutCopying()), code.provider()->asID()); + } +#endif +} + // The `INTERNAL_MODULE_REGISTRY_GENERATE` macro handles inlining code to compile and run a // JS builtin that acts as a module. In debug mode, we use a different implementation that reads // from the developer's filesystem. This allows reloading code without recompiling bindings. @@ -20,7 +34,7 @@ namespace Bun { auto throwScope = DECLARE_THROW_SCOPE(vm); \ auto&& origin = SourceOrigin(WTF::URL(makeString("builtin://"_s, moduleName))); \ SourceCode source = JSC::makeSource(SOURCE, origin, moduleName); \ - \ + maybeAddCodeCoverage(vm, source); \ JSFunction* func \ = JSFunction::create( \ vm, \ diff --git a/src/bun.js/bindings/ZigSourceProvider.cpp b/src/bun.js/bindings/ZigSourceProvider.cpp index 2c448b5a6..d11c748da 100644 --- a/src/bun.js/bindings/ZigSourceProvider.cpp +++ b/src/bun.js/bindings/ZigSourceProvider.cpp @@ -58,11 +58,48 @@ static SourceOrigin toSourceOrigin(const String& sourceURL, bool isBuiltin) return SourceOrigin(WTF::URL::fileURLWithFileSystemPath(sourceURL)); } +void forEachSourceProvider(const WTF::Function<void(JSC::SourceID)>& func) +{ + // if (sourceProviderMap == nullptr) { + // return; + // } + + // for (auto& pair : *sourceProviderMap) { + // auto sourceProvider = pair.value; + // if (sourceProvider) { + // func(sourceProvider); + // } + // } +} +extern "C" int ByteRangeMapping__getSourceID(void* mappings, BunString sourceURL); +extern "C" void* ByteRangeMapping__find(BunString sourceURL); +void* sourceMappingForSourceURL(const WTF::String& sourceURL) +{ + return ByteRangeMapping__find(Bun::toString(sourceURL)); +} + +extern "C" void ByteRangeMapping__generate(BunString sourceURL, BunString code, int sourceID); + +JSC::SourceID sourceIDForSourceURL(const WTF::String& sourceURL) +{ + void* mappings = ByteRangeMapping__find(Bun::toString(sourceURL)); + if (!mappings) { + return 0; + } + + return ByteRangeMapping__getSourceID(mappings, Bun::toString(sourceURL)); +} + +extern "C" bool BunTest__shouldGenerateCodeCoverage(BunString sourceURL); + Ref<SourceProvider> SourceProvider::create(Zig::GlobalObject* globalObject, ResolvedSource resolvedSource, JSC::SourceProviderSourceType sourceType, bool isBuiltin) { auto stringImpl = Bun::toWTFString(resolvedSource.source_code); auto sourceURLString = toStringCopy(resolvedSource.source_url); + bool isCodeCoverageEnabled = !!globalObject->vm().controlFlowProfiler(); + + bool shouldGenerateCodeCoverage = isCodeCoverageEnabled && !isBuiltin && BunTest__shouldGenerateCodeCoverage(Bun::toString(sourceURLString)); auto provider = adoptRef(*new SourceProvider( globalObject->isThreadLocalDefaultGlobalObject ? globalObject : nullptr, @@ -71,6 +108,10 @@ Ref<SourceProvider> SourceProvider::create(Zig::GlobalObject* globalObject, Reso sourceURLString.impl(), TextPosition(), sourceType)); + if (shouldGenerateCodeCoverage) { + ByteRangeMapping__generate(Bun::toString(provider->sourceURL()), Bun::toString(provider->source().toStringWithoutCopying()), provider->asID()); + } + return provider; } diff --git a/src/bun.js/bindings/ZigSourceProvider.h b/src/bun.js/bindings/ZigSourceProvider.h index c189cc454..364e6ee23 100644 --- a/src/bun.js/bindings/ZigSourceProvider.h +++ b/src/bun.js/bindings/ZigSourceProvider.h @@ -22,6 +22,10 @@ namespace Zig { class GlobalObject; +void forEachSourceProvider(WTF::Function<void(JSC::SourceID)>); +JSC::SourceID sourceIDForSourceURL(const WTF::String& sourceURL); +void* sourceMappingForSourceURL(const WTF::String& sourceURL); + class SourceProvider final : public JSC::SourceProvider { WTF_MAKE_FAST_ALLOCATED; using Base = JSC::SourceProvider; diff --git a/src/bun.js/bindings/bindings.cpp b/src/bun.js/bindings/bindings.cpp index b0a291c2e..201fc0959 100644 --- a/src/bun.js/bindings/bindings.cpp +++ b/src/bun.js/bindings/bindings.cpp @@ -4552,3 +4552,12 @@ CPP_DECL void JSC__JSMap__set(JSC__JSMap* map, JSC__JSGlobalObject* arg1, JSC__J { map->set(arg1, JSC::JSValue::decode(JSValue2), JSC::JSValue::decode(JSValue3)); } + +CPP_DECL void JSC__VM__setControlFlowProfiler(JSC__VM* vm, bool isEnabled) +{ + if (isEnabled) { + vm->enableControlFlowProfiler(); + } else { + vm->disableControlFlowProfiler(); + } +}
\ No newline at end of file diff --git a/src/bun.js/bindings/bindings.zig b/src/bun.js/bindings/bindings.zig index 4f533b9d9..3b6116a0d 100644 --- a/src/bun.js/bindings/bindings.zig +++ b/src/bun.js/bindings/bindings.zig @@ -102,8 +102,9 @@ pub const ZigString = extern struct { }; pub fn fromBytes(slice_: []const u8) ZigString { - if (!strings.isAllASCII(slice_)) - return fromUTF8(slice_); + if (!strings.isAllASCII(slice_)) { + return initUTF8(slice_); + } return init(slice_); } @@ -4978,6 +4979,7 @@ pub const VM = extern struct { SmallHeap = 0, LargeHeap = 1, }; + pub fn create(heap_type: HeapType) *VM { return cppFn("create", .{@intFromEnum(heap_type)}); } @@ -4986,6 +4988,10 @@ pub const VM = extern struct { return cppFn("deinit", .{ vm, global_object }); } + pub fn setControlFlowProfiler(vm: *VM, enabled: bool) void { + return cppFn("setControlFlowProfiler", .{ vm, enabled }); + } + pub fn isJITEnabled() bool { return cppFn("isJITEnabled", .{}); } @@ -5093,7 +5099,7 @@ pub const VM = extern struct { return cppFn("blockBytesAllocated", .{vm}); } - pub const Extern = [_][]const u8{ "collectAsync", "externalMemorySize", "blockBytesAllocated", "heapSize", "releaseWeakRefs", "throwError", "deferGC", "holdAPILock", "runGC", "generateHeapSnapshot", "isJITEnabled", "deleteAllCode", "create", "deinit", "setExecutionForbidden", "executionForbidden", "isEntered", "throwError", "drainMicrotasks", "whenIdle", "shrinkFootprint", "setExecutionTimeLimit", "clearExecutionTimeLimit" }; + pub const Extern = [_][]const u8{ "setControlFlowProfiler", "collectAsync", "externalMemorySize", "blockBytesAllocated", "heapSize", "releaseWeakRefs", "throwError", "deferGC", "holdAPILock", "runGC", "generateHeapSnapshot", "isJITEnabled", "deleteAllCode", "create", "deinit", "setExecutionForbidden", "executionForbidden", "isEntered", "throwError", "drainMicrotasks", "whenIdle", "shrinkFootprint", "setExecutionTimeLimit", "clearExecutionTimeLimit" }; }; pub const ThrowScope = extern struct { diff --git a/src/bun.js/bindings/headers.h b/src/bun.js/bindings/headers.h index 05c708a48..9a6d1b72a 100644 --- a/src/bun.js/bindings/headers.h +++ b/src/bun.js/bindings/headers.h @@ -428,6 +428,7 @@ CPP_DECL bool JSC__VM__isEntered(JSC__VM* arg0); CPP_DECL bool JSC__VM__isJITEnabled(); CPP_DECL void JSC__VM__releaseWeakRefs(JSC__VM* arg0); CPP_DECL JSC__JSValue JSC__VM__runGC(JSC__VM* arg0, bool arg1); +CPP_DECL void JSC__VM__setControlFlowProfiler(JSC__VM* arg0, bool arg1); CPP_DECL void JSC__VM__setExecutionForbidden(JSC__VM* arg0, bool arg1); CPP_DECL void JSC__VM__setExecutionTimeLimit(JSC__VM* arg0, double arg1); CPP_DECL void JSC__VM__shrinkFootprint(JSC__VM* arg0); diff --git a/src/bun.js/bindings/headers.zig b/src/bun.js/bindings/headers.zig index fbca33a30..d39793c07 100644 --- a/src/bun.js/bindings/headers.zig +++ b/src/bun.js/bindings/headers.zig @@ -322,6 +322,7 @@ pub extern fn JSC__VM__isEntered(arg0: *bindings.VM) bool; pub extern fn JSC__VM__isJITEnabled(...) bool; pub extern fn JSC__VM__releaseWeakRefs(arg0: *bindings.VM) void; pub extern fn JSC__VM__runGC(arg0: *bindings.VM, arg1: bool) JSC__JSValue; +pub extern fn JSC__VM__setControlFlowProfiler(arg0: *bindings.VM, arg1: bool) void; pub extern fn JSC__VM__setExecutionForbidden(arg0: *bindings.VM, arg1: bool) void; pub extern fn JSC__VM__setExecutionTimeLimit(arg0: *bindings.VM, arg1: f64) void; pub extern fn JSC__VM__shrinkFootprint(arg0: *bindings.VM) void; diff --git a/src/bun.js/javascript.zig b/src/bun.js/javascript.zig index 62b00cf42..1c8d91d52 100644 --- a/src/bun.js/javascript.zig +++ b/src/bun.js/javascript.zig @@ -128,6 +128,7 @@ pub fn OpaqueWrap(comptime Context: type, comptime Function: fn (this: *Context) pub const bun_file_import_path = "/node_modules.server.bun"; const SourceMap = @import("../sourcemap/sourcemap.zig"); +const ParsedSourceMap = SourceMap.Mapping.ParsedSourceMap; const MappingList = SourceMap.Mapping.List; pub const SavedSourceMap = struct { @@ -138,7 +139,7 @@ pub const SavedSourceMap = struct { data: [*]u8, pub fn vlq(this: SavedMappings) []u8 { - return this.data[16..this.len()]; + return this.data[24..this.len()]; } pub inline fn len(this: SavedMappings) usize { @@ -149,12 +150,13 @@ pub const SavedSourceMap = struct { default_allocator.free(this.data[0..this.len()]); } - pub fn toMapping(this: SavedMappings, allocator: Allocator, path: string) anyerror!MappingList { + pub fn toMapping(this: SavedMappings, allocator: Allocator, path: string) anyerror!ParsedSourceMap { const result = SourceMap.Mapping.parse( allocator, - this.data[16..this.len()], + this.data[24..this.len()], @as(usize, @bitCast(this.data[8..16].*)), 1, + @as(usize, @bitCast(this.data[16..24].*)), ); switch (result) { .fail => |fail| { @@ -183,7 +185,7 @@ pub const SavedSourceMap = struct { } }; - pub const Value = TaggedPointerUnion(.{ MappingList, SavedMappings }); + pub const Value = TaggedPointerUnion(.{ ParsedSourceMap, SavedMappings }); pub const HashTable = std.HashMap(u64, *anyopaque, IdentityContext(u64), 80); /// This is a pointer to the map located on the VirtualMachine struct @@ -203,8 +205,8 @@ pub const SavedSourceMap = struct { var entry = try this.map.getOrPut(bun.hash(source.path.text)); if (entry.found_existing) { var value = Value.from(entry.value_ptr.*); - if (value.get(MappingList)) |source_map_| { - var source_map: *MappingList = source_map_; + if (value.get(ParsedSourceMap)) |source_map_| { + var source_map: *ParsedSourceMap = source_map_; source_map.deinit(default_allocator); } else if (value.get(SavedMappings)) |saved_mappings| { var saved = SavedMappings{ .data = @as([*]u8, @ptrCast(saved_mappings)) }; @@ -216,16 +218,16 @@ pub const SavedSourceMap = struct { entry.value_ptr.* = Value.init(bun.cast(*SavedMappings, mappings.list.items.ptr)).ptr(); } - pub fn get(this: *SavedSourceMap, path: string) ?MappingList { + pub fn get(this: *SavedSourceMap, path: string) ?ParsedSourceMap { var mapping = this.map.getEntry(bun.hash(path)) orelse return null; switch (Value.from(mapping.value_ptr.*).tag()) { - (@field(Value.Tag, @typeName(MappingList))) => { - return Value.from(mapping.value_ptr.*).as(MappingList).*; + Value.Tag.ParsedSourceMap => { + return Value.from(mapping.value_ptr.*).as(ParsedSourceMap).*; }, Value.Tag.SavedMappings => { - var saved = SavedMappings{ .data = @as([*]u8, @ptrCast(Value.from(mapping.value_ptr.*).as(MappingList))) }; + var saved = SavedMappings{ .data = @as([*]u8, @ptrCast(Value.from(mapping.value_ptr.*).as(ParsedSourceMap))) }; defer saved.deinit(); - var result = default_allocator.create(MappingList) catch unreachable; + var result = default_allocator.create(ParsedSourceMap) catch unreachable; result.* = saved.toMapping(default_allocator, path) catch { _ = this.map.remove(mapping.key_ptr.*); return null; @@ -246,8 +248,8 @@ pub const SavedSourceMap = struct { this.mutex.lock(); defer this.mutex.unlock(); - var mappings = this.get(path) orelse return null; - return SourceMap.Mapping.find(mappings, line, column); + const parsed_mappings = this.get(path) orelse return null; + return SourceMap.Mapping.find(parsed_mappings.mappings, line, column); } }; const uws = @import("root").bun.uws; diff --git a/src/bun.js/modules/BunJSCModule.h b/src/bun.js/modules/BunJSCModule.h index d7548dcdf..73823e16e 100644 --- a/src/bun.js/modules/BunJSCModule.h +++ b/src/bun.js/modules/BunJSCModule.h @@ -28,14 +28,17 @@ #include "wtf/text/WTFString.h" #include "Process.h" - +#include <JavaScriptCore/SourceProviderCache.h> #if ENABLE(REMOTE_INSPECTOR) #include "JavaScriptCore/RemoteInspectorServer.h" #endif #include "JSDOMConvertBase.h" +#include "ZigSourceProvider.h" #include "mimalloc.h" +#include "JavaScriptCore/ControlFlowProfiler.h" + using namespace JSC; using namespace WTF; using namespace WebCore; @@ -650,6 +653,60 @@ JSC_DEFINE_HOST_FUNCTION(functionDeserialize, (JSGlobalObject * globalObject, RELEASE_AND_RETURN(throwScope, JSValue::encode(result)); } +extern "C" EncodedJSValue ByteRangeMapping__findExecutedLines( + JSC::JSGlobalObject *, BunString sourceURL, BasicBlockRange *ranges, + size_t len, size_t functionOffset, bool ignoreSourceMap); + +JSC_DEFINE_HOST_FUNCTION(functionCodeCoverageForFile, + (JSGlobalObject * globalObject, + CallFrame *callFrame)) { + VM &vm = globalObject->vm(); + auto throwScope = DECLARE_THROW_SCOPE(vm); + + String fileName = callFrame->argument(0).toWTFString(globalObject); + RETURN_IF_EXCEPTION(throwScope, encodedJSValue()); + bool ignoreSourceMap = callFrame->argument(1).toBoolean(globalObject); + + auto sourceID = Zig::sourceIDForSourceURL(fileName); + if (!sourceID) { + throwException(globalObject, throwScope, + createError(globalObject, "No source for file"_s)); + return JSValue::encode(jsUndefined()); + } + + auto basicBlocks = + vm.controlFlowProfiler()->getBasicBlocksForSourceIDWithoutFunctionRange( + sourceID, vm); + + if (basicBlocks.isEmpty()) { + return JSC::JSValue::encode( + JSC::constructEmptyArray(globalObject, nullptr, 0)); + } + + size_t functionStartOffset = basicBlocks.size(); + + const Vector<std::tuple<bool, unsigned, unsigned>> &functionRanges = + vm.functionHasExecutedCache()->getFunctionRanges(sourceID); + + basicBlocks.reserveCapacity(functionRanges.size() + basicBlocks.size()); + + for (const auto &functionRange : functionRanges) { + BasicBlockRange range; + range.m_hasExecuted = std::get<0>(functionRange); + range.m_startOffset = static_cast<int>(std::get<1>(functionRange)); + range.m_endOffset = static_cast<int>(std::get<2>(functionRange)); + range.m_executionCount = + range.m_hasExecuted + ? 1 + : 0; // This is a hack. We don't actually count this. + basicBlocks.append(range); + } + + return ByteRangeMapping__findExecutedLines( + globalObject, Bun::toString(fileName), basicBlocks.data(), + basicBlocks.size(), functionStartOffset, ignoreSourceMap); +} + // clang-format off /* Source for BunJSCModuleTable.lut.h @begin BunJSCModuleTable @@ -718,6 +775,7 @@ DEFINE_NATIVE_MODULE(BunJSC) putNativeFn(Identifier::fromString(vm, "getProtectedObjects"_s), functionGetProtectedObjects); putNativeFn(Identifier::fromString(vm, "generateHeapSnapshotForDebugging"_s), functionGenerateHeapSnapshotForDebugging); putNativeFn(Identifier::fromString(vm, "profile"_s), functionRunProfiler); + putNativeFn(Identifier::fromString(vm, "codeCoverageForFile"_s), functionCodeCoverageForFile); putNativeFn(Identifier::fromString(vm, "setTimeZone"_s), functionSetTimeZone); putNativeFn(Identifier::fromString(vm, "serialize"_s), functionSerialize); putNativeFn(Identifier::fromString(vm, "deserialize"_s), functionDeserialize); diff --git a/src/bun.js/test/jest.zig b/src/bun.js/test/jest.zig index aacf671ce..8691e5a2d 100644 --- a/src/bun.js/test/jest.zig +++ b/src/bun.js/test/jest.zig @@ -89,6 +89,7 @@ pub const TestRunner = struct { test_timeout_timer: ?*bun.uws.Timer = null, last_test_timeout_timer_duration: u32 = 0, active_test_for_timeout: ?TestRunner.Test.ID = null, + test_options: *const bun.CLI.Command.TestOptions = undefined, global_callbacks: struct { beforeAll: std.ArrayListUnmanaged(JSC.JSValue) = .{}, diff --git a/src/bun.zig b/src/bun.zig index 4dc5a296d..c26fb4781 100644 --- a/src/bun.zig +++ b/src/bun.zig @@ -1187,7 +1187,10 @@ pub const MultiArrayList = @import("./multi_array_list.zig").MultiArrayList; pub const Joiner = @import("./string_joiner.zig"); pub const renamer = @import("./renamer.zig"); -pub const sourcemap = @import("./sourcemap/sourcemap.zig"); +pub const sourcemap = struct { + pub usingnamespace @import("./sourcemap/sourcemap.zig"); + pub usingnamespace @import("./sourcemap/CodeCoverage.zig"); +}; pub fn asByteSlice(buffer: anytype) []const u8 { return switch (@TypeOf(buffer)) { diff --git a/src/bunfig.zig b/src/bunfig.zig index 7302907d9..e1f1ca4b0 100644 --- a/src/bunfig.zig +++ b/src/bunfig.zig @@ -242,6 +242,47 @@ pub const Bunfig = struct { try this.expect(expr, .e_boolean); this.ctx.runtime_options.smol = expr.data.e_boolean.value; } + + if (test_.get("coverage")) |expr| { + try this.expect(expr, .e_boolean); + this.ctx.test_options.coverage.enabled = expr.data.e_boolean.value; + } + + if (test_.get("coverageThreshold")) |expr| outer: { + if (expr.data == .e_number) { + this.ctx.test_options.coverage.fractions.functions = expr.data.e_number.value; + this.ctx.test_options.coverage.fractions.lines = expr.data.e_number.value; + this.ctx.test_options.coverage.fractions.stmts = expr.data.e_number.value; + break :outer; + } + + try this.expect(expr, .e_object); + if (expr.get("functions")) |functions| { + try this.expect(functions, .e_number); + this.ctx.test_options.coverage.fractions.functions = functions.data.e_number.value; + } + + if (expr.get("lines")) |lines| { + try this.expect(lines, .e_number); + this.ctx.test_options.coverage.fractions.lines = lines.data.e_number.value; + } + + if (expr.get("statements")) |stmts| { + try this.expect(stmts, .e_number); + this.ctx.test_options.coverage.fractions.stmts = stmts.data.e_number.value; + } + } + + // This mostly exists for debugging. + if (test_.get("coverageIgnoreSourcemaps")) |expr| { + try this.expect(expr, .e_boolean); + this.ctx.test_options.coverage.ignore_sourcemap = expr.data.e_boolean.value; + } + + if (test_.get("coverageSkipTestFiles")) |expr| { + try this.expect(expr, .e_boolean); + this.ctx.test_options.coverage.skip_test_files = expr.data.e_boolean.value; + } } } diff --git a/src/cli.zig b/src/cli.zig index aa36c63fb..ca0993dbb 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -219,6 +219,7 @@ pub const Arguments = struct { clap.parseParam("--rerun-each <NUMBER> Re-run each test file <NUMBER> times, helps catch certain bugs") catch unreachable, clap.parseParam("--only Only run tests that are marked with \"test.only()\"") catch unreachable, clap.parseParam("--todo Include tests that are marked with \"test.todo()\"") catch unreachable, + clap.parseParam("--coverage Generate a coverage profile") catch unreachable, clap.parseParam("--bail <NUMBER>? Exit the test suite after <NUMBER> failures. If you do not specify a number, it defaults to 1.") catch unreachable, clap.parseParam("-t, --test-name-pattern <STR> Run only tests with a name that matches the given regex.") catch unreachable, }; @@ -387,6 +388,11 @@ pub const Arguments = struct { }; } } + + if (!ctx.test_options.coverage.enabled) { + ctx.test_options.coverage.enabled = args.flag("--coverage"); + } + if (args.option("--bail")) |bail| { if (bail.len > 0) { ctx.test_options.bail = std.fmt.parseInt(u32, bail, 10) catch |e| { @@ -965,6 +971,7 @@ pub const Command = struct { run_todo: bool = false, only: bool = false, bail: u32 = 0, + coverage: TestCommand.CodeCoverageOptions = .{}, test_filter_regex: ?*RegularExpression = null, }; diff --git a/src/cli/test_command.zig b/src/cli/test_command.zig index 819aceb19..c4d78f4d5 100644 --- a/src/cli/test_command.zig +++ b/src/cli/test_command.zig @@ -261,6 +261,103 @@ pub const CommandLineReporter = struct { Output.prettyError("Ran {d} tests across {d} files. ", .{ tests, files }); Output.printStartEnd(bun.start_time, std.time.nanoTimestamp()); } + + pub fn printCodeCoverage(this: *CommandLineReporter, vm: *JSC.VirtualMachine, opts: *TestCommand.CodeCoverageOptions, comptime enable_ansi_colors: bool) !void { + const trace = bun.tracy.traceNamed(@src(), "TestCommand.printCodeCoverage"); + defer trace.end(); + + _ = this; + var map = bun.sourcemap.ByteRangeMapping.map orelse return; + var iter = map.valueIterator(); + var max_filepath_length: usize = "All files".len; + const relative_dir = vm.bundler.fs.top_level_dir; + + var byte_ranges = try std.ArrayList(bun.sourcemap.ByteRangeMapping).initCapacity(bun.default_allocator, map.count()); + + while (iter.next()) |entry| { + const value: bun.sourcemap.ByteRangeMapping = entry.*; + var utf8 = value.source_url.slice(); + byte_ranges.appendAssumeCapacity(value); + max_filepath_length = @max(bun.path.relative(relative_dir, utf8).len, max_filepath_length); + } + + if (byte_ranges.items.len == 0) { + return; + } + + std.sort.block(bun.sourcemap.ByteRangeMapping, byte_ranges.items, void{}, bun.sourcemap.ByteRangeMapping.isLessThan); + + iter = map.valueIterator(); + var writer = Output.errorWriter(); + var base_fraction = opts.fractions; + var failing = false; + + writer.writeAll(Output.prettyFmt("<r><d>", enable_ansi_colors)) catch return; + writer.writeByteNTimes('-', max_filepath_length + 2) catch return; + writer.writeAll(Output.prettyFmt("|---------|---------|-------------------<r>\n", enable_ansi_colors)) catch return; + writer.writeAll("File") catch return; + writer.writeByteNTimes(' ', max_filepath_length - "File".len + 1) catch return; + // writer.writeAll(Output.prettyFmt(" <d>|<r> % Funcs <d>|<r> % Blocks <d>|<r> % Lines <d>|<r> Uncovered Line #s\n", enable_ansi_colors)) catch return; + writer.writeAll(Output.prettyFmt(" <d>|<r> % Funcs <d>|<r> % Lines <d>|<r> Uncovered Line #s\n", enable_ansi_colors)) catch return; + writer.writeAll(Output.prettyFmt("<d>", enable_ansi_colors)) catch return; + writer.writeByteNTimes('-', max_filepath_length + 2) catch return; + writer.writeAll(Output.prettyFmt("|---------|---------|-------------------<r>\n", enable_ansi_colors)) catch return; + + var coverage_buffer = bun.MutableString.initEmpty(bun.default_allocator); + var coverage_buffer_buffer = coverage_buffer.bufferedWriter(); + var coverage_writer = coverage_buffer_buffer.writer(); + + var avg = bun.sourcemap.CoverageFraction{ + .functions = 0.0, + .lines = 0.0, + .stmts = 0.0, + }; + var avg_count: f64 = 0; + + for (byte_ranges.items) |*entry| { + var report = bun.sourcemap.CodeCoverageReport.generate(vm.global, bun.default_allocator, entry, opts.ignore_sourcemap) orelse continue; + defer report.deinit(bun.default_allocator); + var fraction = base_fraction; + report.writeFormat(max_filepath_length, &fraction, relative_dir, coverage_writer, enable_ansi_colors) catch continue; + avg.functions += fraction.functions; + avg.lines += fraction.lines; + avg.stmts += fraction.stmts; + avg_count += 1.0; + if (fraction.failing) { + failing = true; + } + + coverage_writer.writeAll("\n") catch continue; + } + + { + avg.functions /= avg_count; + avg.lines /= avg_count; + avg.stmts /= avg_count; + + try bun.sourcemap.CodeCoverageReport.writeFormatWithValues( + "All files", + max_filepath_length, + avg, + base_fraction, + failing, + writer, + false, + enable_ansi_colors, + ); + + try writer.writeAll(Output.prettyFmt("<r><d> |<r>\n", enable_ansi_colors)); + } + + coverage_buffer_buffer.flush() catch return; + try writer.writeAll(coverage_buffer.list.items); + try writer.writeAll(Output.prettyFmt("<r><d>", enable_ansi_colors)); + writer.writeByteNTimes('-', max_filepath_length + 2) catch return; + writer.writeAll(Output.prettyFmt("|---------|---------|-------------------<r>\n", enable_ansi_colors)) catch return; + + opts.fractions.failing = failing; + Output.flush(); + } }; const Scanner = struct { @@ -334,6 +431,42 @@ const Scanner = struct { "_spec", }; + export fn BunTest__shouldGenerateCodeCoverage(test_name_str: bun.String) callconv(.C) bool { + var zig_slice: bun.JSC.ZigString.Slice = .{}; + defer zig_slice.deinit(); + + // In this particular case, we don't actually care about non-ascii latin1 characters. + // so we skip the ascii check + const slice = if (test_name_str.is8Bit()) test_name_str.latin1() else brk: { + zig_slice = test_name_str.toUTF8(bun.default_allocator); + break :brk zig_slice.slice(); + }; + + // always ignore node_modules. + if (strings.contains(slice, "/" ++ "node_modules" ++ "/")) { + return false; + } + + const ext = std.fs.path.extension(slice); + const loader_by_ext = JSC.VirtualMachine.get().bundler.options.loader(ext); + + // allow file loader just incase they use a custom loader with a non-standard extension + if (!(loader_by_ext.isJavaScriptLike() or loader_by_ext == .file)) { + return false; + } + + if (jest.Jest.runner.?.test_options.coverage.skip_test_files) { + const name_without_extension = slice[0 .. slice.len - ext.len]; + inline for (test_name_suffixes) |suffix| { + if (strings.endsWithComptime(name_without_extension, suffix)) { + return false; + } + } + } + + return true; + } + pub fn couldBeTestFile(this: *Scanner, name: string) bool { const extname = std.fs.path.extension(name); if (!this.options.loader(extname).isJavaScriptLike()) return false; @@ -419,6 +552,13 @@ pub const TestCommand = struct { pub const name = "test"; pub const old_name = "wiptest"; + pub const CodeCoverageOptions = struct { + skip_test_files: bool = !Environment.allow_assert, + fractions: bun.sourcemap.CoverageFraction = .{}, + ignore_sourcemap: bool = false, + enabled: bool = false, + }; + pub fn exec(ctx: Command.Context) !void { if (comptime is_bindgen) unreachable; @@ -480,7 +620,7 @@ pub const TestCommand = struct { reporter.repeat_count = @max(ctx.test_options.repeat_count, 1); reporter.jest.callback = &reporter.callback; jest.Jest.runner = &reporter.jest; - + reporter.jest.test_options = &ctx.test_options; js_ast.Expr.Data.Store.create(default_allocator); js_ast.Stmt.Data.Store.create(default_allocator); var vm = try JSC.VirtualMachine.init( @@ -507,6 +647,14 @@ pub const TestCommand = struct { vm.is_main_thread = true; JSC.VirtualMachine.is_main_thread_vm = true; + if (ctx.test_options.coverage.enabled) { + vm.bundler.options.code_coverage = true; + vm.bundler.options.minify_syntax = false; + vm.bundler.options.minify_identifiers = false; + vm.bundler.options.minify_whitespace = false; + vm.global.vm().setControlFlowProfiler(true); + } + // For tests, we default to UTC time zone // unless the user inputs TZ="", in which case we use local time zone var TZ_NAME: string = @@ -538,7 +686,6 @@ pub const TestCommand = struct { scanner.scan(dir_to_scan); scanner.dirs_to_scan.deinit(); - const test_files = try scanner.results.toOwnedSlice(); if (test_files.len > 0) { vm.hot_reload = ctx.debug.hot_reload; @@ -549,6 +696,7 @@ pub const TestCommand = struct { } try jest.Jest.runner.?.snapshots.writeSnapshotFile(); + var coverage = ctx.test_options.coverage; if (reporter.summary.pass > 20) { if (reporter.summary.skip > 0) { @@ -604,6 +752,12 @@ pub const TestCommand = struct { } else { Output.prettyError("\n", .{}); + if (coverage.enabled) { + switch (Output.enable_ansi_colors_stderr) { + inline else => |colors| reporter.printCodeCoverage(vm, &coverage, colors) catch {}, + } + } + if (reporter.summary.pass > 0) { Output.prettyError("<r><green>", .{}); } @@ -688,7 +842,7 @@ pub const TestCommand = struct { } } - if (reporter.summary.fail > 0) { + if (reporter.summary.fail > 0 or (coverage.enabled and coverage.fractions.failing)) { Global.exit(1); } } diff --git a/src/js_printer.zig b/src/js_printer.zig index 61c13c25f..56b6d0fae 100644 --- a/src/js_printer.zig +++ b/src/js_printer.zig @@ -5650,6 +5650,7 @@ pub fn getSourceMapBuilder( is_bun_platform, ), .cover_lines_without_mappings = true, + .approximate_input_line_count = tree.approximate_newline_count, .prepend_count = is_bun_platform and generate_source_map == .lazy, .line_offset_tables = opts.line_offset_tables orelse brk: { if (generate_source_map == .lazy) break :brk SourceMap.LineOffsetTable.generate( diff --git a/src/options.zig b/src/options.zig index 4f64f60c6..647c83786 100644 --- a/src/options.zig +++ b/src/options.zig @@ -1435,6 +1435,8 @@ pub const BundleOptions = struct { minify_syntax: bool = false, minify_identifiers: bool = false, + code_coverage: bool = false, + compile: bool = false, /// This is a list of packages which even when require() is used, we will diff --git a/src/sourcemap/CodeCoverage.zig b/src/sourcemap/CodeCoverage.zig new file mode 100644 index 000000000..2b063bbbe --- /dev/null +++ b/src/sourcemap/CodeCoverage.zig @@ -0,0 +1,646 @@ +const bun = @import("root").bun; +const std = @import("std"); +const LineOffsetTable = bun.sourcemap.LineOffsetTable; +const SourceMap = bun.sourcemap; +const Bitset = bun.bit_set.DynamicBitSetUnmanaged; +const Output = bun.Output; +const prettyFmt = Output.prettyFmt; + +/// Our code coverage currently only deals with lines of code, not statements or branches. +/// JSC doesn't expose function names in their coverage data, so we don't include that either :(. +/// Since we only need to store line numbers, our job gets simpler +/// +/// We can use two bitsets to store code coverage data for a given file +/// 1. executable_lines +/// 2. lines_which_have_executed +/// +/// Not all lines of code are executable. Comments, whitespace, empty lines, etc. are not executable. +/// It's not a problem for anyone if comments, whitespace, empty lines etc are not executed, so those should always be omitted from coverage reports +/// +/// We use two bitsets since the typical size will be decently small, +/// bitsets are simple and bitsets are relatively fast to construct and query +/// +pub const CodeCoverageReport = struct { + source_url: bun.JSC.ZigString.Slice, + executable_lines: Bitset, + lines_which_have_executed: Bitset, + functions: std.ArrayListUnmanaged(Block), + functions_which_have_executed: Bitset, + stmts_which_have_executed: Bitset, + stmts: std.ArrayListUnmanaged(Block), + total_lines: u32 = 0, + + pub const Block = struct { + start_line: u32 = 0, + end_line: u32 = 0, + }; + + pub fn linesCoverageFraction(this: *const CodeCoverageReport) f64 { + var intersected = this.executable_lines.clone(bun.default_allocator) catch @panic("OOM"); + defer intersected.deinit(bun.default_allocator); + intersected.setIntersection(this.lines_which_have_executed); + + const total_count: f64 = @floatFromInt(this.executable_lines.count()); + if (total_count == 0) { + return 1.0; + } + + const intersected_count: f64 = @floatFromInt(intersected.count()); + + return (intersected_count / total_count); + } + + pub fn stmtsCoverageFraction(this: *const CodeCoverageReport) f64 { + const total_count: f64 = @floatFromInt(this.stmts.items.len); + + if (total_count == 0) { + return 1.0; + } + + return ((@as(f64, @floatFromInt(this.stmts_which_have_executed.count()))) / (total_count)); + } + + pub fn functionCoverageFraction(this: *const CodeCoverageReport) f64 { + const total_count: f64 = @floatFromInt(this.functions.items.len); + return (@as(f64, @floatFromInt(this.functions_which_have_executed.count())) / total_count); + } + + pub fn writeFormatWithValues( + filename: []const u8, + max_filename_length: usize, + vals: CoverageFraction, + failing: CoverageFraction, + failed: bool, + writer: anytype, + indent_name: bool, + comptime enable_colors: bool, + ) !void { + if (comptime enable_colors) { + if (failed) { + try writer.writeAll(comptime prettyFmt("<r><b><red>", true)); + } else { + try writer.writeAll(comptime prettyFmt("<r><b><green>", true)); + } + } + + if (indent_name) { + try writer.writeAll(" "); + } + + try writer.writeAll(filename); + try writer.writeByteNTimes(' ', (max_filename_length - filename.len + @as(usize, @intFromBool(!indent_name)))); + try writer.writeAll(comptime prettyFmt("<r><d> | <r>", enable_colors)); + + if (comptime enable_colors) { + if (vals.functions < failing.functions) { + try writer.writeAll(comptime prettyFmt("<b><red>", true)); + } else { + try writer.writeAll(comptime prettyFmt("<b><green>", true)); + } + } + + try writer.print("{d: >7.2}", .{vals.functions * 100.0}); + // try writer.writeAll(comptime prettyFmt("<r><d> | <r>", enable_colors)); + // if (comptime enable_colors) { + // // if (vals.stmts < failing.stmts) { + // try writer.writeAll(comptime prettyFmt("<d>", true)); + // // } else { + // // try writer.writeAll(comptime prettyFmt("<d>", true)); + // // } + // } + // try writer.print("{d: >8.2}", .{vals.stmts * 100.0}); + try writer.writeAll(comptime prettyFmt("<r><d> | <r>", enable_colors)); + + if (comptime enable_colors) { + if (vals.lines < failing.lines) { + try writer.writeAll(comptime prettyFmt("<b><red>", true)); + } else { + try writer.writeAll(comptime prettyFmt("<b><green>", true)); + } + } + + try writer.print("{d: >7.2}", .{vals.lines * 100.0}); + } + + pub fn writeFormat( + report: *const CodeCoverageReport, + max_filename_length: usize, + fraction: *CoverageFraction, + base_path: []const u8, + writer: anytype, + comptime enable_colors: bool, + ) !void { + var failing = fraction.*; + const fns = report.functionCoverageFraction(); + const lines = report.linesCoverageFraction(); + const stmts = report.stmtsCoverageFraction(); + fraction.functions = fns; + fraction.lines = lines; + fraction.stmts = stmts; + + const failed = fns < failing.functions or lines < failing.lines; // or stmts < failing.stmts; + fraction.failing = failed; + + var filename = report.source_url.slice(); + if (base_path.len > 0) { + filename = bun.path.relative(base_path, filename); + } + + try writeFormatWithValues( + filename, + max_filename_length, + fraction.*, + failing, + failed, + writer, + true, + enable_colors, + ); + + try writer.writeAll(comptime prettyFmt("<r><d> | <r>", enable_colors)); + + var executable_lines_that_havent_been_executed = report.lines_which_have_executed.clone(bun.default_allocator) catch @panic("OOM"); + defer executable_lines_that_havent_been_executed.deinit(bun.default_allocator); + executable_lines_that_havent_been_executed.toggleAll(); + + // This sets statements in executed scopes + executable_lines_that_havent_been_executed.setIntersection(report.executable_lines); + + var iter = executable_lines_that_havent_been_executed.iterator(.{}); + var start_of_line_range: usize = 0; + var prev_line: usize = 0; + var is_first = true; + + while (iter.next()) |next_line| { + if (next_line == (prev_line + 1)) { + prev_line = next_line; + continue; + } else if (is_first and start_of_line_range == 0 and prev_line == 0) { + start_of_line_range = next_line; + prev_line = next_line; + continue; + } + + if (is_first) { + is_first = false; + } else { + try writer.print(comptime prettyFmt("<r><d>,<r>", enable_colors), .{}); + } + + if (start_of_line_range == prev_line) { + try writer.print(comptime prettyFmt("<red>{d}", enable_colors), .{start_of_line_range + 1}); + } else { + try writer.print(comptime prettyFmt("<red>{d}-{d}", enable_colors), .{ start_of_line_range + 1, prev_line + 1 }); + } + + prev_line = next_line; + start_of_line_range = next_line; + } + + if (prev_line != start_of_line_range) { + if (is_first) { + is_first = false; + } else { + try writer.print(comptime prettyFmt("<r><d>,<r>", enable_colors), .{}); + } + + if (start_of_line_range == prev_line) { + try writer.print(comptime prettyFmt("<red>{d}", enable_colors), .{start_of_line_range + 1}); + } else { + try writer.print(comptime prettyFmt("<red>{d}-{d}", enable_colors), .{ start_of_line_range + 1, prev_line + 1 }); + } + } + } + + pub fn deinit(this: *CodeCoverageReport, allocator: std.mem.Allocator) void { + this.executable_lines.deinit(allocator); + this.lines_which_have_executed.deinit(allocator); + this.functions.deinit(allocator); + this.stmts.deinit(allocator); + this.functions_which_have_executed.deinit(allocator); + this.stmts_which_have_executed.deinit(allocator); + } + + extern fn CodeCoverage__withBlocksAndFunctions( + *bun.JSC.VM, + i32, + *anyopaque, + bool, + *const fn ( + *Generator, + [*]const BasicBlockRange, + usize, + usize, + bool, + ) callconv(.C) void, + ) bool; + + const Generator = struct { + allocator: std.mem.Allocator, + byte_range_mapping: *ByteRangeMapping, + result: *?CodeCoverageReport, + + pub fn do( + this: *@This(), + blocks_ptr: [*]const BasicBlockRange, + blocks_len: usize, + function_start_offset: usize, + ignore_sourcemap: bool, + ) callconv(.C) void { + const blocks: []const BasicBlockRange = blocks_ptr[0..function_start_offset]; + var function_blocks: []const BasicBlockRange = blocks_ptr[function_start_offset..blocks_len]; + if (function_blocks.len > 1) { + function_blocks = function_blocks[1..]; + } + + if (blocks.len == 0) { + return; + } + + this.result.* = this.byte_range_mapping.generateCodeCoverageReportFromBlocks( + this.allocator, + this.byte_range_mapping.source_url, + blocks, + function_blocks, + ignore_sourcemap, + ) catch null; + } + }; + + pub fn generate( + globalThis: *bun.JSC.JSGlobalObject, + allocator: std.mem.Allocator, + byte_range_mapping: *ByteRangeMapping, + ignore_sourcemap_: bool, + ) ?CodeCoverageReport { + bun.JSC.markBinding(@src()); + var vm = globalThis.vm(); + + var result: ?CodeCoverageReport = null; + + var generator = Generator{ + .result = &result, + .allocator = allocator, + .byte_range_mapping = byte_range_mapping, + }; + + if (!CodeCoverage__withBlocksAndFunctions( + vm, + byte_range_mapping.source_id, + &generator, + ignore_sourcemap_, + &Generator.do, + )) { + return null; + } + + return result; + } +}; + +const BasicBlockRange = extern struct { + startOffset: c_int = 0, + endOffset: c_int = 0, + hasExecuted: bool = false, + executionCount: usize = 0, +}; + +pub const ByteRangeMapping = struct { + line_offset_table: LineOffsetTable.List = .{}, + source_id: i32, + source_url: bun.JSC.ZigString.Slice, + + pub fn isLessThan(_: void, a: ByteRangeMapping, b: ByteRangeMapping) bool { + return bun.strings.order(a.source_url.slice(), b.source_url.slice()) == .lt; + } + + pub const HashMap = std.HashMap(u64, ByteRangeMapping, bun.IdentityContext(u64), std.hash_map.default_max_load_percentage); + + pub fn deinit(this: *ByteRangeMapping) void { + this.line_offset_table.deinit(bun.default_allocator); + } + + pub threadlocal var map: ?*HashMap = null; + pub fn generate(str: bun.String, source_contents_str: bun.String, source_id: i32) callconv(.C) void { + var _map = map orelse brk: { + map = bun.JSC.VirtualMachine.get().allocator.create(HashMap) catch @panic("OOM"); + map.?.* = HashMap.init(bun.JSC.VirtualMachine.get().allocator); + break :brk map.?; + }; + var slice = str.toUTF8(bun.default_allocator); + const hash = bun.hash(slice.slice()); + var entry = _map.getOrPut(hash) catch @panic("Out of memory"); + if (entry.found_existing) { + entry.value_ptr.deinit(); + } + + var source_contents = source_contents_str.toUTF8(bun.default_allocator); + defer source_contents.deinit(); + + entry.value_ptr.* = compute(source_contents.slice(), source_id, slice); + } + + pub fn getSourceID(this: *ByteRangeMapping) callconv(.C) i32 { + return this.source_id; + } + + pub fn find(path: bun.String) callconv(.C) ?*ByteRangeMapping { + var slice = path.toUTF8(bun.default_allocator); + defer slice.deinit(); + + var map_ = map orelse return null; + const hash = bun.hash(slice.slice()); + var entry = map_.getPtr(hash) orelse return null; + return entry; + } + + pub fn generateCodeCoverageReportFromBlocks( + this: *ByteRangeMapping, + allocator: std.mem.Allocator, + source_url: bun.JSC.ZigString.Slice, + blocks: []const BasicBlockRange, + function_blocks: []const BasicBlockRange, + ignore_sourcemap: bool, + ) !CodeCoverageReport { + var line_starts = this.line_offset_table.items(.byte_offset_to_start_of_line); + + var executable_lines: Bitset = Bitset{}; + var lines_which_have_executed: Bitset = Bitset{}; + const parsed_mappings_ = bun.JSC.VirtualMachine.get().source_mappings.get( + source_url.slice(), + ); + + var functions = std.ArrayListUnmanaged(CodeCoverageReport.Block){}; + try functions.ensureTotalCapacityPrecise(allocator, function_blocks.len); + errdefer functions.deinit(allocator); + var functions_which_have_executed: Bitset = try Bitset.initEmpty(allocator, function_blocks.len); + errdefer functions_which_have_executed.deinit(allocator); + var stmts_which_have_executed: Bitset = try Bitset.initEmpty(allocator, blocks.len); + errdefer stmts_which_have_executed.deinit(allocator); + + var stmts = std.ArrayListUnmanaged(CodeCoverageReport.Block){}; + try stmts.ensureTotalCapacityPrecise(allocator, function_blocks.len); + errdefer stmts.deinit(allocator); + + errdefer executable_lines.deinit(allocator); + errdefer lines_which_have_executed.deinit(allocator); + var line_count: u32 = 0; + + if (ignore_sourcemap or parsed_mappings_ == null) { + line_count = @truncate(line_starts.len); + executable_lines = try Bitset.initEmpty(allocator, line_count); + lines_which_have_executed = try Bitset.initEmpty(allocator, line_count); + for (blocks, 0..) |block, i| { + const min: usize = @intCast(@min(block.startOffset, block.endOffset)); + const max: usize = @intCast(@max(block.startOffset, block.endOffset)); + var min_line: u32 = std.math.maxInt(u32); + var max_line: u32 = 0; + + const has_executed = block.hasExecuted or block.executionCount > 0; + + for (min..max) |byte_offset| { + const new_line_index = LineOffsetTable.findIndex(line_starts, .{ .start = @intCast(byte_offset) }) orelse continue; + const line_start_byte_offset = line_starts[new_line_index]; + if (line_start_byte_offset >= byte_offset) { + continue; + } + + const line: u32 = @intCast(new_line_index); + min_line = @min(min_line, line); + max_line = @max(max_line, line); + + executable_lines.set(@intCast(new_line_index)); + if (has_executed) { + lines_which_have_executed.set(@intCast(new_line_index)); + } + } + + if (min_line != std.math.maxInt(u32)) { + if (has_executed) + stmts_which_have_executed.set(i); + + try stmts.append(allocator, .{ + .start_line = min_line, + .end_line = max_line, + }); + } + } + + for (function_blocks, 0..) |function, i| { + const min: usize = @intCast(@min(function.startOffset, function.endOffset)); + const max: usize = @intCast(@max(function.startOffset, function.endOffset)); + var min_line: u32 = std.math.maxInt(u32); + var max_line: u32 = 0; + + for (min..max) |byte_offset| { + const new_line_index = LineOffsetTable.findIndex(line_starts, .{ .start = @intCast(byte_offset) }) orelse continue; + const line_start_byte_offset = line_starts[new_line_index]; + if (line_start_byte_offset >= byte_offset) { + continue; + } + + const line: u32 = @intCast(new_line_index); + min_line = @min(min_line, line); + max_line = @max(max_line, line); + } + + const did_fn_execute = function.executionCount > 0 or function.hasExecuted; + + // only mark the lines as executable if the function has not executed + // functions that have executed have non-executable lines in them and thats fine. + if (!did_fn_execute) { + const end = @min(max_line, line_count); + for (min_line..end) |line| { + executable_lines.set(line); + lines_which_have_executed.unset(line); + } + } + + try functions.append(allocator, .{ + .start_line = min_line, + .end_line = max_line, + }); + + if (did_fn_execute) + functions_which_have_executed.set(i); + } + } else if (parsed_mappings_) |parsed_mapping| { + line_count = @as(u32, @truncate(parsed_mapping.input_line_count)) + 1; + executable_lines = try Bitset.initEmpty(allocator, line_count); + lines_which_have_executed = try Bitset.initEmpty(allocator, line_count); + + for (blocks, 0..) |block, i| { + const min: usize = @intCast(@min(block.startOffset, block.endOffset)); + const max: usize = @intCast(@max(block.startOffset, block.endOffset)); + var min_line: u32 = std.math.maxInt(u32); + var max_line: u32 = 0; + const has_executed = block.hasExecuted or block.executionCount > 0; + + for (min..max) |byte_offset| { + const new_line_index = LineOffsetTable.findIndex(line_starts, .{ .start = @intCast(byte_offset) }) orelse continue; + const line_start_byte_offset = line_starts[new_line_index]; + if (line_start_byte_offset >= byte_offset) { + continue; + } + const column_position = byte_offset -| line_start_byte_offset; + + if (SourceMap.Mapping.find(parsed_mapping.mappings, @intCast(new_line_index), @intCast(column_position))) |point| { + if (point.original.lines < 0) continue; + + const line: u32 = @as(u32, @intCast(point.original.lines)); + + executable_lines.set(line); + if (has_executed) { + lines_which_have_executed.set(line); + } + + min_line = @min(min_line, line); + max_line = @max(max_line, line); + } + } + + if (min_line != std.math.maxInt(u32)) { + try stmts.append(allocator, .{ + .start_line = min_line, + .end_line = max_line, + }); + + if (has_executed) + stmts_which_have_executed.set(i); + } + } + + for (function_blocks, 0..) |function, i| { + const min: usize = @intCast(@min(function.startOffset, function.endOffset)); + const max: usize = @intCast(@max(function.startOffset, function.endOffset)); + var min_line: u32 = std.math.maxInt(u32); + var max_line: u32 = 0; + + for (min..max) |byte_offset| { + const new_line_index = LineOffsetTable.findIndex(line_starts, .{ .start = @intCast(byte_offset) }) orelse continue; + const line_start_byte_offset = line_starts[new_line_index]; + if (line_start_byte_offset >= byte_offset) { + continue; + } + + const column_position = byte_offset -| line_start_byte_offset; + + if (SourceMap.Mapping.find(parsed_mapping.mappings, @intCast(new_line_index), @intCast(column_position))) |point| { + if (point.original.lines < 0) continue; + + const line: u32 = @as(u32, @intCast(point.original.lines)); + min_line = @min(min_line, line); + max_line = @max(max_line, line); + } + } + + // no sourcemaps? ignore it + if (min_line == std.math.maxInt(u32) and max_line == 0) { + continue; + } + + const did_fn_execute = function.executionCount > 0 or function.hasExecuted; + + // only mark the lines as executable if the function has not executed + // functions that have executed have non-executable lines in them and thats fine. + if (!did_fn_execute) { + const end = @min(max_line, line_count); + for (min_line..end) |line| { + executable_lines.set(line); + lines_which_have_executed.unset(line); + } + } + + try functions.append(allocator, .{ + .start_line = min_line, + .end_line = max_line, + }); + if (did_fn_execute) + functions_which_have_executed.set(i); + } + } else { + unreachable; + } + + return CodeCoverageReport{ + .source_url = source_url, + .functions = functions, + .executable_lines = executable_lines, + .lines_which_have_executed = lines_which_have_executed, + .total_lines = line_count, + .stmts = stmts, + .functions_which_have_executed = functions_which_have_executed, + .stmts_which_have_executed = stmts_which_have_executed, + }; + } + + pub fn findExecutedLines( + globalThis: *bun.JSC.JSGlobalObject, + source_url: bun.String, + blocks_ptr: [*]const BasicBlockRange, + blocks_len: usize, + function_start_offset: usize, + ignore_sourcemap: bool, + ) callconv(.C) bun.JSC.JSValue { + var this = ByteRangeMapping.find(source_url) orelse return bun.JSC.JSValue.null; + + const blocks: []const BasicBlockRange = blocks_ptr[0..function_start_offset]; + var function_blocks: []const BasicBlockRange = blocks_ptr[function_start_offset..blocks_len]; + if (function_blocks.len > 1) { + function_blocks = function_blocks[1..]; + } + var url_slice = source_url.toUTF8(bun.default_allocator); + defer url_slice.deinit(); + var report = this.generateCodeCoverageReportFromBlocks(bun.default_allocator, url_slice, blocks, function_blocks, ignore_sourcemap) catch { + globalThis.throwOutOfMemory(); + return .zero; + }; + defer report.deinit(bun.default_allocator); + + var coverage_fraction = CoverageFraction{}; + + var mutable_str = bun.MutableString.initEmpty(bun.default_allocator); + defer mutable_str.deinit(); + var buffered_writer = mutable_str.bufferedWriter(); + var writer = buffered_writer.writer(); + + report.writeFormat(source_url.utf8ByteLength(), &coverage_fraction, "", &writer, false) catch { + globalThis.throwOutOfMemory(); + return .zero; + }; + + buffered_writer.flush() catch { + globalThis.throwOutOfMemory(); + return .zero; + }; + + var str = bun.String.create(mutable_str.toOwnedSliceLeaky()); + defer str.deref(); + return str.toJS(globalThis); + } + + pub fn compute(source_contents: []const u8, source_id: i32, source_url: bun.JSC.ZigString.Slice) ByteRangeMapping { + return ByteRangeMapping{ + .line_offset_table = LineOffsetTable.generate(bun.JSC.VirtualMachine.get().allocator, source_contents, 0), + .source_id = source_id, + .source_url = source_url, + }; + } +}; + +comptime { + @export(ByteRangeMapping.generate, .{ .name = "ByteRangeMapping__generate" }); + @export(ByteRangeMapping.findExecutedLines, .{ .name = "ByteRangeMapping__findExecutedLines" }); + @export(ByteRangeMapping.find, .{ .name = "ByteRangeMapping__find" }); + @export(ByteRangeMapping.getSourceID, .{ .name = "ByteRangeMapping__getSourceID" }); +} + +pub const CoverageFraction = struct { + functions: f64 = 0.9, + lines: f64 = 0.9, + + // This metric is less accurate right now + stmts: f64 = 0.75, + + failing: bool = false, +}; diff --git a/src/sourcemap/sourcemap.zig b/src/sourcemap/sourcemap.zig index 59dc2f0c6..0bcb4021d 100644 --- a/src/sourcemap/sourcemap.zig +++ b/src/sourcemap/sourcemap.zig @@ -179,6 +179,7 @@ pub const Mapping = struct { bytes: []const u8, estimated_mapping_count: ?usize, sources_count: i32, + input_line_count: usize, ) ParseResult { var mapping = Mapping.List{}; if (estimated_mapping_count) |count| { @@ -366,7 +367,12 @@ pub const Mapping = struct { }) catch unreachable; } - return ParseResult{ .success = mapping }; + return ParseResult{ + .success = .{ + .mappings = mapping, + .input_line_count = input_line_count, + }, + }; } pub const ParseResult = union(enum) { @@ -386,7 +392,17 @@ pub const Mapping = struct { }; } }, - success: Mapping.List, + success: ParsedSourceMap, + }; + + pub const ParsedSourceMap = struct { + input_line_count: usize = 0, + mappings: Mapping.List = .{}, + + pub fn deinit(this: *ParsedSourceMap, allocator: std.mem.Allocator) void { + this.mappings.deinit(allocator); + allocator.destroy(this); + } }; }; @@ -845,6 +861,38 @@ pub const LineOffsetTable = struct { return @as(i32, @intCast(original_line)) - 1; } + pub fn findIndex(byte_offsets_to_start_of_line: []const u32, loc: Logger.Loc) ?usize { + std.debug.assert(loc.start > -1); // checked by caller + var original_line: usize = 0; + const loc_start = @as(usize, @intCast(loc.start)); + + var count = @as(usize, @truncate(byte_offsets_to_start_of_line.len)); + var i: usize = 0; + while (count > 0) { + const step = count / 2; + i = original_line + step; + const byte_offset = byte_offsets_to_start_of_line[i]; + if (byte_offset == loc_start) { + return i; + } + if (i + 1 < byte_offsets_to_start_of_line.len) { + const next_byte_offset = byte_offsets_to_start_of_line[i + 1]; + if (byte_offset < loc_start and loc_start < next_byte_offset) { + return i; + } + } + + if (byte_offset < loc_start) { + original_line = i + 1; + count = count - step - 1; + } else { + count = step; + } + } + + return null; + } + pub fn generate(allocator: std.mem.Allocator, contents: []const u8, approximate_line_count: i32) List { var list = List{}; // Preallocate the top-level table using the approximate line count from the lexer @@ -1142,6 +1190,7 @@ pub const Chunk = struct { data: MutableString, count: usize = 0, offset: usize = 0, + approximate_input_line_count: usize = 0, pub const Format = SourceMapFormat(VLQSourceMap); @@ -1152,8 +1201,8 @@ pub const Chunk = struct { // For bun.js, we store the number of mappings and how many bytes the final list is at the beginning of the array if (prepend_count) { - map.offset = 16; - map.data.append(&[16]u8{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }) catch unreachable; + map.offset = 24; + map.data.append(&([_]u8{0} ** 24)) catch unreachable; } return map; @@ -1211,6 +1260,8 @@ pub const Chunk = struct { line_starts_with_mapping: bool = false, cover_lines_without_mappings: bool = false, + approximate_input_line_count: usize = 0, + /// When generating sourcemappings for bun, we store a count of how many mappings there were prepend_count: bool = false, @@ -1221,6 +1272,7 @@ pub const Chunk = struct { if (b.prepend_count) { b.source_map.getBuffer().list.items[0..8].* = @as([8]u8, @bitCast(b.source_map.getBuffer().list.items.len)); b.source_map.getBuffer().list.items[8..16].* = @as([8]u8, @bitCast(b.source_map.getCount())); + b.source_map.getBuffer().list.items[16..24].* = @as([8]u8, @bitCast(b.approximate_input_line_count)); } return Chunk{ .buffer = b.source_map.getBuffer(), diff --git a/test/harness.ts b/test/harness.ts index ab3ae5ca2..2da12e094 100644 --- a/test/harness.ts +++ b/test/harness.ts @@ -1,8 +1,4 @@ import { gc as bunGC, unsafe, which } from "bun"; -import { heapStats } from "bun:jsc"; -import path from "path"; -import fs from "fs"; -import os from "os"; export const bunEnv: any = { ...process.env, @@ -43,6 +39,8 @@ export async function expectMaxObjectTypeCount( count: number, maxWait = 1000, ) { + var { heapStats } = require("bun:jsc"); + gc(); if (heapStats().objectTypeCounts[type] <= count) return; gc(true); @@ -85,7 +83,11 @@ export function hideFromStackTrace(block: CallableFunction) { } export function tempDirWithFiles(basename: string, files: Record<string, string | Record<string, string>>) { - const dir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), basename + "_")); + var fs = require("fs"); + var path = require("path"); + var { tmpdir } = require("os"); + + const dir = fs.mkdtempSync(path.join(fs.realpathSync(tmpdir()), basename + "_")); for (const [name, contents] of Object.entries(files)) { if (typeof contents === "object") { fs.mkdirSync(path.join(dir, name)); @@ -100,6 +102,7 @@ export function tempDirWithFiles(basename: string, files: Record<string, string } export function bunRun(file: string, env?: Record<string, string>) { + var path = require("path"); const result = Bun.spawnSync([bunExe(), file], { cwd: path.dirname(file), env: { @@ -116,6 +119,7 @@ export function bunRun(file: string, env?: Record<string, string>) { } export function bunTest(file: string, env?: Record<string, string>) { + var path = require("path"); const result = Bun.spawnSync([bunExe(), "test", path.basename(file)], { cwd: path.dirname(file), env: { diff --git a/test/js/node/path/path.test.js b/test/js/node/path/path.test.js index 103809d2e..fb6063968 100644 --- a/test/js/node/path/path.test.js +++ b/test/js/node/path/path.test.js @@ -547,7 +547,6 @@ it("path.normalize", () => { // "..\\..\\..\\..\\baz" // ); // strictEqual(path.win32.normalize("foo/bar\\baz"), "foo\\bar\\baz"); - strictEqual(path.posix.normalize("./fixtures///b/../b/c.js"), "fixtures/b/c.js"); strictEqual(path.posix.normalize("/foo/../../../bar"), "/bar"); strictEqual(path.posix.normalize("a//b//../b"), "a/b"); |