jj-dojo / Build, test and packaging

Build, test and packaging

How Jujutsu Dojo is built with Bazel, how its Jasmine tests run against a fake VS Code API, and what CI checks on every pull request.

On this page
  1. 1. Files
  2. 2. MODULE.bazel
  3. 3. Root targets
  4. 4. Restricting dependencies
  5. 5. In-repo Bazel rules
  6. ts_library
  7. jasmine_test
  8. addlicense
  9. //tools/bazel/jj
  10. 6. Tests and the fake VS Code API
  11. Jasmine helpers
  12. installVscode
  13. The fakes
  14. newRepo
  15. 7. CI
  16. 8. Generated VS Code enums
  17. 9. Loose ends at this pin

In short: everything builds with Bazel 9.1.0 and the Aspect JS/TS rules. npm run build is bazel build //:extension, npm run package builds //:vsix with vsce, and npm test is bazel test //.... Two in-repo macros wrap the rules: ts_library compiles a package and drops its test files, and jasmine_test compiles the tests and runs Jasmine with three helpers. One helper makes require('vscode') return an empty object, which each test fills with fakes from src/testing/ by calling installVscode. Another puts a pinned jj 0.45.1 binary on PATH. Bazel deps and visibility decide which packages may import Node or npm code. CI runs lint, a license-header check, a lockfile check and the full test suite.

1. Files

FileWhat it holds
MODULE.bazelBazel modules, the npm lockfile import, and pinned downloads of addlicense and jj.
BUILD.bazelThe root targets: extension, vsix, addlicense, tsconfig, jasmine_config, pnpm.
.bazelrc, .bazelversionTest output on errors only, colour, timeout warnings. Bazel 9.1.0.
tsconfig.jsonStrict CommonJS/ES2020 output with source maps, composite projects, path aliases for Lit and vscode-elements.
tools/bazel/The ts_library, jasmine_test and addlicense macros, and the jj binary target.
spec/support/jasmine.jsonJasmine's config: every *_test.js under the working directory.
src/testing/The fake VS Code API, the three Jasmine helpers, and newRepo for tests that need a real jj repository.
.github/workflows/Five workflows: ci, test-postsubmit, Restrict PR Merger and the two reusable ones they call.
scripts/populate_vscode_enum.pyRegenerates third_party/vscode/vscode_enums.ts from @types/vscode.
.vscodeignoreWhat vsce leaves out of the .vsix: sources, config, and everything under src/ except compiled JS, source maps, CSS and fonts.

2. MODULE.bazel

FieldTypeMeaningSource
modulename, versionjj-dojo 0.0.1, the same version as package.json.16–19
bazel_dep7 modulesaspect_rules_js 3.1.1, aspect_rules_ts 3.8.9, aspect_rules_esbuild 0.26.0, platforms 1.0.0, rules_nodejs 6.7.4, aspect_bazel_lib 2.22.5, rules_shell 0.6.1.21–27
npm.npm_translate_lockrepo @npmImports pnpm-lock.yaml so every npm package is a Bazel target under //:node_modules/….32–40
pnpm.pnpmversion from package.jsonThe pnpm used by //:pnpm to check the lockfile, read from the packageManager field (pnpm@9.1.0).43–45
rules_ts_ext.depsrepo @npm_typescriptThe TypeScript compiler for ts_project.47–49
addlicense_*6 http_archivesaddlicense v1.2.0 for Linux, macOS and Windows on x86-64 and arm64, each pinned by SRI hash.53–93
JJ_VERSION, JJ_PLATFORMS5 http_archivesThe jj release binary, v0.45.1, for macOS and Linux (musl) on both CPUs and Windows x86-64, each pinned by hash. A comment gives the steps to bump it.95–143

3. Root targets

TargetRuleMeaningSource
//:extensionjs_libraryWhat VS Code loads: package.json, the compiled //src, the webview bundle, its styles and the codicons. npm run build builds this.41–50
//:vsixvsce binaryRuns vsce package --no-dependencies on the same inputs plus README.md, LICENSE and .vscodeignore, writing jj-dojo.vsix. npm run package builds this.57–84
//:addlicense.check, .fixaddlicense macroChecks for, or adds, the license header from license_header.txt. npm run addlicense runs .fix; CI runs .check.23–25
//:tsconfigts_configThe shared tsconfig.json every TypeScript target compiles with.29–33
//:jasmine_configjs_libraryMakes spec/support/jasmine.json available to test runs.35–39
//:node_modulesnpm_link_all_packagesOne target per npm package from the lockfile.27
//:pnpmaliasThe pinned pnpm, so bazel run //:pnpm works without a global install. The dep:add, dep:update and dep:install scripts run it (package.json).52–55

There is no root //:test target. Each package declares its own :tests, and npm test runs bazel test //.... Ten packages have one: //src, //src/logging, //src/utils, //src/client, //src/file_system_watcher, //src/ui/icon_theme_service, three under //src/ui/commit_graph (algorithms, components, utils) and //third_party/vscode.

4. Restricting dependencies

Inside Google the extension runs on Cider, a VS Code Web fork that can't import packages like node. The design notes ask for code to be split into interface files and implementation files, with only implementations allowed to import external-only packages, and use Bazel's deps to enforce it (docs/intro.md). A ts_project only sees the type declarations of what is in its deps, so a package without //:node_modules/@types/node can't compile an import of node:fs.

WhereHow it's appliedSource
//src/file_system_watcherThe clearest example. :file_system_watcher is the interface and depends only on @types/vscode. :local_file_system_watcher is the implementation and adds @parcel/watcher and @types/node.BUILD.bazel
//src/ui/commit_graph/utilsVisible only to //src/utils and the commit graph, which is why src/utils re-exports two of its files (see Shared utilities).BUILD.bazel
//third_party/vscode:vscode_enumsVisible only to //src/testing, so production code keeps using the real enums from vscode.BUILD.bazel
//srcStill one target over every non-test file under src/ that isn't in its own package, visible only to the root package.BUILD.bazel

planned A TODO in //src says it should become two targets: one meant to be imported into Google that depends on little more than vscode, and one for the rest of the extension that may depend on node (source).

5. In-repo Bazel rules

ts_library

tools/bazel/typescript/defs.bzl

FieldTypeMeaningSource
namestringTarget name.21
srcslist of pathsFiltered before compiling: a path is dropped if it starts with test_, ends with _test.ts or contains _test_.32–36
**kwargsanyPassed to ts_project, which is always called with composite, source_map, the tsc transpiler and //:tsconfig.37–45

jasmine_test

tools/bazel/jasmine/defs.bzl

FieldTypeMeaningSource
namestringThe test target. A <name>_ts_project is created next to it to compile the sources.35–46
srcslist of pathsThe *_test.ts files. Compiled with the same options as ts_library, plus @types/jasmine.36–46
depslist of labelsWhat the tests import.43
argsfixed--config pointing at spec/support/jasmine.json, and three --helpers: install_global_vscode, install_global_jj and source_map_stack_trace. Paths are made relative by counting the package's depth.48–60
node_optionsfixed--enable-source-maps, so stack traces point at .ts lines.61–63
chdir, size, datafixedRuns from the package directory as a small test, with the Jasmine config, all npm packages, //tools/bazel/jj and //src/testing available.52–72

addlicense

tools/bazel/private/addlicense/addlicense.bzl

FieldTypeMeaningSource
namestringPrefix for the two sh_binary targets, <name>.fix and <name>.check.41–73
copyright_holderstringDefaults to Google LLC.34
license_typestringDefaults to apache.34
extra_ignoreslist of globsAdded to DEFAULT_IGNORES: VCS and build directories, lockfiles, .vscode/ and every *.json.20–32

Both binaries run runner.sh, which finds the platform's addlicense through Bazel runfiles (picked by a select in private/addlicense/BUILD.bazel), refuses to run outside bazel run or without its template, changes to the workspace root and runs it over . with -f pointing at license_header.txt, adding -check for the check target. Both targets carry the template in their runfiles and pass its runfiles path as the second argument (source). The template starts with an @license line, then the copyright holder and the Apache text. The public tools/bazel/addlicense/defs.bzl only re-exports the macro.

//tools/bazel/jj

copy_jj picks the downloaded jj for the host's OS and CPU with a select over five config_settings and copies it into tools/bazel/jj/jj as an executable. Windows on arm64 has no entry here, unlike addlicense.

6. Tests and the fake VS Code API

Jasmine helpers

jasmine.json sets spec_dir to . and spec_files to **/*_test.js. Each jasmine_test loads three helpers before any spec.

HelperWhat it doesSource
install_global_vscodePatches Module.prototype.require so require('vscode') returns one shared empty object, marked __esModule so import * as vscode works. Without it, importing vscode outside the editor fails.28–49
install_global_jjPrepends $TEST_SRCDIR/_main/tools/bazel/jj to PATH, so a test that runs jj gets the pinned binary. It also prepends /usr/local/git/current/bin and /opt/homebrew/bin: jj git clone, fetch and push need Git 2.41 or later, and Bazel's sanitized PATH would otherwise find macOS's older Xcode Git. The src/client tests rely on this.23–44
source_map_stack_traceWraps Error.prepareStackTrace: after Node's source-map handler runs, it swaps the sandbox …runfiles/_main/ prefix for the real workspace path. It finds that path by following the execroot's BUILD.bazel symlink. Paths in failures then open in an editor.42–93

installVscode

Tests call installVscode in a beforeEach (for example extension_test.ts). It fills the shared vscode object in three passes:

  1. Every enum from third_party/vscode/vscode_enums.ts, 64 of them, generated from @types/vscode.
  2. Classes: CancellationTokenSource, CodeLens, Disposable, Position, Range, Selection, ThemeColor, EventEmitter, LogOutputChannel, and Uri as vscode-uri's URI with its Utils merged in.
  3. Fresh namespace objects: workspace, languages, commands, window, extensions.

Because the namespaces are new instances each time, every test starts clean. It returns the object typed as the fakes, so tests can reach helpers the real API doesn't have. fakeVscode() returns the same thing later, and logs() returns the fake JJ Extension output channel.

The fakes

fakes.ts re-exports 23 classes from two directories, plus fakeCommit, which builds a MutableCommit with empty defaults and overrides whatever fields the test passes. The commit-topology tests use it.

DirectoryClassesMeaningSource
custom_fakes/FakeCancellationTokenSource, FakeCodeLens, FakeCommands, FakeDisposable, FakeEventEmitter, FakeExtensions, FakeLanguages, FakeLogOutputChannel, FakePosition, FakeRange, FakeSelection, FakeThemeColor, FakeWebview, FakeWindow, FakeWorkspaceWritten for this repository. Methods the code under test doesn't need call unimplemented, which throws and suggests writing the fake.custom_fakes/
google_fakes/FakeEnvironmentVariableCollection, FakeExtension, FakeExtensionContext, FakeLanguageModelAccessInformation, FakeMemento, FakeSecretStorage, FakeTextDocument, FakeTextEditorBrought over from Google's internal tree. Their comments still point at the real implementations by google3/… paths.google_fakes/

//src/testing is a ts_library over all of these (BUILD.bazel). Six test targets depend on it: //src, //src/client, //src/logging, //src/file_system_watcher, //src/ui/icon_theme_service and //src/utils. The others test plain logic and don't need the fake API.

newRepo

newRepo() is called inside a describe. Before each test it makes a fresh temporary directory, resolves it with realpath and runs jj git init there, or, given {remote}, runs jj git clone from another newRepo's root (which must be declared first, or it throws); after each test it deletes the directory. It returns the repository plus helpers that run jj in it, write or remove a file by relative path, and read the working-copy commit's change ID (source). Callers destructure the result when the describe runs, before any beforeEach, so the root path lives on a TestRepo object rather than in a string that would be captured stale (source). It runs jj through the client's subprocess helper, which is why //src/testing now depends on //src/client:subprocess_util. The SubprocessClient tests are its only users.

7. CI

WorkflowTriggerMeaningSource
cipull request, merge queueCalls _lint_run and _test_run, then all-checks-passed fails unless both succeeded. A newer push to the same PR cancels the running one.ci.yaml
test-postsubmitpush to mainRuns _test_run again, without cancelling runs in progress.test-postsubmit.yaml
_test_runcalledSets up Bazel with caches keyed on the lockfile, MODULE.bazel, .bazelrc and tsconfig.json. It checks the pnpm lockfile is in sync (--frozen-lockfile --lockfile-only), then runs bazel test //.... 15-minute limit._test_run.yaml
_lint_runcalledUses abcxyz/actions to find lint targets, then splits them. JS/TS targets run pnpm install --frozen-lockfile and pnpm run lint (gts lint) on Node 22 and the pnpm named by packageManager. Other targets go to abcxyz's lint-all. A separate job runs bazel run //:addlicense.check._lint_run.yaml
Restrict PR MergerPR opened, synchronized, reopenedFails whenever the user who triggered the event isn't the PR's author, so a push to someone else's PR leaves a red check. Its error message says only the author may trigger actions or merge.restrict-merge.yaml

Third-party actions are pinned to commit hashes. The Node version is pinned to 22 by hand, and a comment says to keep it in step with the Node that Bazel resolves.

8. Generated VS Code enums

Tests can't import the real enums, because the real vscode module only exists inside the editor. populate_vscode_enum.py generates third_party/vscode/vscode_enums.ts instead. It refuses to run unless npm run lint is clean. Then it reads node_modules/@types/vscode/index.d.ts, pulls out every export enum with its doc comment using a regex that tolerates one level of braces, writes them after the original license header and a "do not edit" line, runs npm run fix to format the file, and copies @types/vscode's LICENSE next to it.

9. Loose ends at this pin

  • The .vsix ships no npm packages. vsce runs with --no-dependencies and the compiled host code is not bundled, so a runtime require of an npm package from the extension host would fail in the packaged extension. A TODO says this will matter once @parcel/watcher is used (source). At this pin nothing that reaches activate imports it.
  • Stale pointer in a helper. install_global_vscode.ts tells the reader to see vscode_test.ts for the setup and teardown helpers. No such file exists; the helpers are in install_vscode.ts (source).
  • Prefix match on paths. ts_library's test_ check uses startswith on the whole path, so sub/test_x.ts from a recursive glob would not be dropped. No such file exists today (source).
  • No Windows arm64 jj. addlicense is fetched for six platforms but jj for five, so tests that need jj can't build there.
  • Hand-synced Node version. pnpm is now read from package.json by both Bazel and CI, but Node 22 is still written in the lint workflow and kept in step with the Bazel lockfile only by a comment (_lint_run.yaml).
  • One //src target. The split between Google-importable code and the rest is still a TODO, so the deps scheme isn't yet enforced for most of src/.