Migrate apps/vscode from npm/node to bun (#11632)

* chore(vscode): migrate package management & build from npm/node to bun

Fold apps/vscode (+ webview-ui, testing-platform) into the root bun
workspace so the extension consumes the local @cline/* SDK packages via
workspace symlinks instead of pinned published versions, eliminating the
SDK vendoring cycle. Node remains the runtime (extension host, standalone
cline-core, esbuild platform:node, prebuild-install ABI target).

- root: drop "!apps/vscode", add nested members, relocate overrides to
  root, add trustedDependencies [better-sqlite3, grpc-tools]
- apps/vscode: @cline/* -> workspace:*, scripts -> bun/bunx,
  npm-run-all -> bun --parallel, drop cross-env; keep esbuild + vite;
  declare previously-hoisted phantom deps (nice-grpc-common, playwright)
- package-standalone.mjs: npm install -> bun install (isolated dist dir)
- CI: setup-bun + single root bun install --frozen-lockfile, build:sdk
  before extension build, better-sqlite3 binary + zero-test guards;
  publish workflows intentionally keep setup-node for vsce/ovsx
- docs/comments: curated pass (keep-list vs rewrite-list), add
  apps/vscode/docs/bun-migration-notes.md guard doc
- delete npm lockfiles (root bun.lock authoritative)

Deferred to follow-up PRs: test-runner migration to bun test (Phase 4)
and devDep cleanup (Phase 6).

* test(vscode): add bun test foundation for the vitest-native unit suites

Phase 4a of the test-runner migration. Adds a bun test runner that
reaches full parity (582 pass / 0 fail / 50 files) with the existing
vitest SDK-adapter + model-catalog suite, without touching the
@vscode/test-cli integration tests or the webview vitest suite.

- bunfig.toml: [test] preload
- src/test/bun-test-preload.ts: mock.module() shadows `vscode` and
  `@cline/core` with their unit-test stubs (bun's onResolve plugin hook
  does not intercept host/symlinked specifiers); seeds real @cline/core
  export names as undefined to satisfy bun's strict ESM named-import
  linking; full vitest->bun:test shim (vi.fn/mocked/spyOn, describe/it/
  expect/before*/after*)
- scripts/run-bun-tests.ts: mirrors vitest.config.ts include[] exactly and
  runs with --parallel for per-file mock isolation (bun test's single-process
  default lets mock.module clobber across files)
- test:bun script

* test(vscode): migrate node-side unit suite from mocha to bun test

Phase 4b of the test-runner migration. The standalone mocha unit runner
(.mocharc spec: __tests__/* + test/services/**) was already broken under
bun (mocha was a phantom dependency — only @types/mocha/ts-node were
declared, npm hoisted mocha transitively). Migrate it to `bun test`.

- codemod 77 files: import { ... } from "mocha" -> "bun:test", renaming
  before->beforeAll / after->afterAll at imports and call-sites; chai,
  should and sinon kept as libraries (they work under bun test)
- convert sinon.stub() on ESM namespace exports to mock.module()/spyOn
  (bun loads real ESM: "ES Modules cannot be stubbed")
- scripts/run-bun-unit-tests.ts: runs the .mocharc spec set with one
  isolated `bun test` process per file (Bun.spawn + concurrency pool),
  restoring vitest-forks module-registry isolation (bun's single-process
  default lets mock.module leak across files)
- scripts/codemod-mocha-{to-bun,this}.ts: one-shot migration tooling
- test:unit now runs the bun unit runner; CI calls bun + a non-zero
  pass-count guard instead of `bunx nyc ... mocha`
- tsconfig: add root node_modules/@types to typeRoots so `bun:test`
  types resolve under tsc; cast loose os.userInfo mocks in shell.test

Result: unit suite 58 files / 880 pass / 0 fail; vitest set still
582/0. @vscode/test-cli integration tests and webview vitest unchanged.

* chore(vscode): remove dead mocha-runner deps and artifacts

Phase 6 cleanup after the bun test migration. The standalone mocha unit
runner is gone (replaced by scripts/run-bun-unit-tests.ts), so its
config and now-unused devDependencies are removed.

- remove dead files: .mocharc.json, tsconfig.unit-test.json,
  src/test/requires.ts, .nycrc.unit.json
- remove unused devDeps: @types/mocha, @types/proxyquire, ts-node,
  tsconfig-paths, cross-env, npm-run-all, nyc, proxyquire, husky
  (root owns the husky hook; chai/should/sinon stay — used as libs)
- install:all -> single root `bun install` (workspace covers webview-ui)
- drop .mocharc.json / .nycrc*.json from CI paths-filters and
  .vscodeignore; add bunfig.toml to the filters

Verified: check-types clean, unit 880/0, vitest 582/0.

* fix(vscode): import bun:test globals in tests that relied on ambient @types/mocha

CI Quality Checks (clean `bun install` without @types/mocha) surfaced
TS2582/TS2304 "Cannot find name 'describe'/'it'/'beforeEach'" in test
files that used the global mocha/jest test functions without importing
them. The Phase 4b codemod only rewrote files that imported from
"mocha"; these used ambient globals, so they were missed (and passed
locally because a stale @types/mocha lingered in node_modules).

Add explicit `bun:test` imports (before->beforeAll, after->afterAll in
TelemetryService.test.ts). chai/sinon stay as libraries.

Verified against a clean tree (no @types/mocha): check-types 0 errors,
unit suite 58 files / 880 pass / 0 fail.

* style(vscode): biome-format migrated test files + codemod scripts

The mocha->bun:test codemod and manual import edits left formatting that
didn't match biome (the CI `format` check, which validates files changed
since main, flagged them). Also narrow setup.ts's bun:test import to the
actually-used beforeEach/afterEach (describe/it only appear in a JSDoc
example), fixing a noUnusedImports lint error.

ci:check-all (check-types + lint + format) now passes locally.

* fix(webview-ui): declare phantom deps + pin React 18 types under bun workspace

Folding webview-ui into the bun workspace changed its install topology
from an isolated npm flat tree to the shared hoisted store, surfacing
two classes of pre-existing latent issues that npm hoisting had masked:

1. Phantom dependencies: src imports `marked`, `unist`, `unist-util-visit`
   and `@heroui/theme` directly but never declared them. Declared them
   (marked ^15, unist-util-visit ^5, @types/unist ^3, @heroui/theme 2.4.26).
2. React types: @testing-library/react's optional peer pulls @types/react@19
   into a resolvable location; tsc mixed it with the toolkit's React 18
   types (React 19 dropped Component.refs), breaking 452 JSX usages. Pin
   react/react-dom type resolution to webview-ui's React 18 copy via
   tsconfig paths.

build:webview (tsc -b && vite build) and ci:check-all now pass.

* fix(vscode): restore @types/mocha for integration build + add bun:test types

The @vscode/test-cli integration runner still uses mocha, and
tsconfig.test.json compiles all src/**/*.test.ts (including bun-migrated
files) to out/. So:
- restore @types/mocha (integration compile needs the mocha ambient types)
- add `bun` to tsconfig.test.json types + root @types to both tsconfig
  typeRoots so `bun:test` resolves under tsc for the migrated tests

* fix(vscode): declare glob — phantom dep used by package-standalone.mjs

scripts/package-standalone.mjs imports `glob` but it was never declared
(resolved transitively under npm's flat hoist). Under the bun workspace
store it's unresolvable, failing postcompile-standalone with
ERR_MODULE_NOT_FOUND. Declare glob ^11 (modern named-export API).

compile-standalone now produces dist-standalone/standalone.zip.

* fix(ci): strip ANSI before vitest zero-test guard grep

The vitest summary line colorizes the count ("Tests  <ansi>582 passed"),
so the count isn't adjacent to the "Tests" label in raw bytes and the
guard regex failed even though 582 tests passed. Strip ANSI escapes
before matching.

* fix(vscode): declare minimist — phantom dep in testing-platform-orchestrator

scripts/testing-platform-orchestrator.ts imports `minimist` (undeclared,
resolved transitively under npm hoist). Declare it so the testing-platform
integration job runs under the bun workspace store.

* fix(vscode): restore tsconfig-paths for integration runner; tp-orchestrator uses bun

Phase 6 over-removed tsconfig-paths: test-setup.js (loaded by the
@vscode/test-cli mocha integration runner) requires it to resolve @/
aliases in the compiled out/ tree — the extension host test runner failed
with "Cannot find module 'tsconfig-paths'". Restore it. Also switch the
testing-platform spawn from `npx ts-node index.ts` to `bun index.ts`
(bun runs TS natively; avoids the removed ts-node).

* fix(vscode): route tests by bun:test import marker; integration runner stays mocha

The mocha->bun codemod swept up tests that the Node-based @vscode/test-cli
integration runner compiles/runs, which cannot load the `bun:test` builtin
(and some need the real VSCode host). Establish a single source of truth:
a *.test.ts is bun-runner-owned IFF it imports "bun:test".

- run-bun-unit-tests.ts: discover files by the bun:test import marker
  (not fixed globs), so every migrated file runs under bun.
- build-tests.js: generate a tsconfig that excludes all bun:test files
  from the integration compile (json5-parsed), so out/ never contains
  bun:test; gitignore the generated config.
- .vscode-test.mjs: exclude the bun unit dirs from the runner globs.
- revert host-dependent tests (hostbridge/*, extension, terminal,
  FileContextTracker host bits) and 3 files with sinon-on-ESM/behavioral
  issues (ClineIgnoreController, mentions, TelemetryService) back to
  mocha; they run on @vscode/test-cli as before.

Verified: check-types 0 errors; compile-tests 0 bun:test in out/;
bun unit 65 files/962 pass/0 fail; vitest 582/0.

* fix(vscode): declare mocha — phantom dep for @vscode/test-cli integration runner

The @vscode/test-cli extension host loads `mocha` at runtime to run the
integration suite, but only @types/mocha was declared (npm hoisted the
mocha package transitively; bun's store does not expose it). The host
failed with "Cannot find module 'mocha'". Declare mocha ^11.7.4 (matches
@vscode/test-cli's own range).

* fix(vscode): robust Windows protoc-gen-ts_proto plugin resolution under bun

build-proto.mjs hardcoded node_modules/.bin/protoc-gen-ts_proto.cmd for
Windows, but bun's workspace store places/extensions the bin shim
differently (hoist + .cmd/.bunx), so Windows protos failed with
"protoc-gen-ts_proto: The system cannot find the file specified". Probe
the local + root .bin with known shim extensions instead. Also update
the testing-platform usage string (ts-node -> bun).

* fix(vscode): generate node .cmd wrapper for ts-proto plugin on Windows

The previous probe found bun's `.bunx` shim, but protoc cannot exec it
("%1 is not a valid Win32 application"). Instead, on Windows generate a
small .cmd wrapper that runs the resolved protoc-gen-ts_proto JS via
`node`, which protoc can execute regardless of package manager. POSIX
path (direct JS bin) is unchanged.

* fix(vscode): package VSIX with --no-dependencies (bundled) to stop monorepo traversal

Under the bun workspace, @cline/* are workspace:* symlinks pointing to
../../../../sdk/packages/*. vsce, walking the dependency tree, followed
them out of apps/vscode and packaged the whole monorepo (../, ~84MB incl.
root node_modules and .env), which crashed vsce's secret scanner and
failed all e2e jobs.

The extension is fully esbuild-bundled into dist/extension.js, so vsce
should not walk node_modules at all. Add --no-dependencies to every
vsce/ovsx package/publish path (e2e build, marketplace, nightly), and
tighten .vscodeignore to drop nested node_modules and dev-only inputs
(scripts, proto, testing-platform, bunfig, esbuild.mjs, etc.).

Result: VSIX is 39 files / ~7 MB and the secret scan passes.

* docs(vscode): tighten bun/node comments and consolidate into a clinerule

- add .clinerules/bun-and-node.md (eternal-now: bun=tooling, node=runtime,
  keep-list, and the bun:test-vs-mocha test routing rule); remove the
  apps/vscode/docs/bun-migration-notes.md migration doc and point
  .clinerules/general.md at the rule (single-line bullet matching the file).
- fix the hotfix-release note: there is no infra step that regenerates the
  lockfile; a CHANGELOG+version bump leaves bun.lock consistent (workspace
  versions aren't pinned) and publish runs --frozen-lockfile.
- reframe runner/preload comments to describe the code as-is (drop
  "migrated off mocha"/codemod history); add a TODO on the bun-test preload
  to migrate suites off the vitest `vi` shim to native bun:test and delete it.
- remove the one-shot mocha->bun codemod scripts.

* fix(debug-harness): pin debugee VSCode version so bundled Playwright can drive it

The harness downloaded "stable" VSCode (currently 1.125 / Electron 42),
which the bundled Playwright cannot drive — `_electron.launch()` hangs
until its 60s timeout (Electron started and a window appeared, but the
launch handshake never completed). Default to a known-good version
(1.103.0, matching the e2e CI matrix) and allow override via
VSCODE_TEST_VERSION.

* fix(webview): render under bun workspace — dedupe React, drop stale codicons link

The webview mounted but crashed before rendering (blank sidebar; e2e
"Login to Cline" never visible) with "Cannot read properties of null
(reading 'useRef')" — the classic two-React-copies / null hook dispatcher.
Under the bun workspace, sibling packages pull react@19 into the shared
store and a transitive webview dep resolved a second React instance into
the vite bundle. Add resolve.dedupe + pin react/react-dom to webview-ui's
own React 18 copy.

Also drop the separate `<link>` to node_modules/@vscode/codicons in the
webview HTML: the webview's index.css already @imports codicons, so the
font is bundled into the build assets. Under bun that node_modules path
is a symlink to the root store (outside the webview localResourceRoots)
and isn't packaged with --no-dependencies, so the link 404'd; the bundle
covers it. Re-scope the .vscodeignore nested-node_modules exclude so it
no longer shadows the codicons re-include.

* fix(debug-harness): disable GPU so the debugee renders in headless/VM envs

On headless/VM GPU stacks the debugee Electron's GPU process crash-loops
("Exiting GPU process during initialization" / CreateCommandBuffer
kTransientFailure), killing the window before Playwright finishes
attaching and tripping the 60s launch timeout. Force software rendering
(--disable-gpu and friends) for a stable harness launch.

* fix(debug-harness): survive launch failures; configurable, longer launch timeout

The harness crashed (whole bun process exited) whenever VSCode launch
failed/timed out: Playwright emits a late unhandled rejection on the dead
CDP transport after we've already handled the launch error, and the
default behavior takes the HTTP server down with it — forcing a full
restart just to retry.

- Add process-level unhandledRejection/uncaughtException guards so stray
  async errors are logged and the server keeps serving (retry via `launch`).
- On launch failure, close the orphaned Electron so a retry isn't blocked.
- Make the _electron.launch timeout configurable (--launch-timeout) and
  raise the default to 120s for cold launches; document VSCODE_TEST_VERSION.

* fix(ci): address review feedback — vsix --no-dependencies, drop stale coverage path, Windows shell

- ext-vscode-publish-stable.yml: add --no-dependencies to the release-artifact
  `vsce package` (Max's catch). Without it, vsce follows the @cline/* workspace
  symlinks out of the package and bloats the .vsix with the whole monorepo.
- ext-vscode-test.yml: drop the stale apps/vscode/coverage-unit/lcov.info upload
  path (Max's catch). That file was produced by the removed nyc unit-coverage
  step (.nycrc.unit.json); nothing generates it now.
- ext-vscode-test-e2e.yml: the better-sqlite3 assert step ran under the Windows
  runner's default pwsh and failed to parse the POSIX test. Pin it to `shell: bash`
  (Git Bash ships on windows-latest); the non-e2e job already defaults to bash.

---------

Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
This commit is contained in:
Dominic Cooney
2026-06-19 07:55:15 +09:00
committed by GitHub
co-authored by Cline Agent
parent d29c5ceb88
commit 2946698eee
129 changed files with 5740 additions and 40291 deletions
+55
View File
@@ -0,0 +1,55 @@
# Bun (tooling) and Node (runtime)
This repo uses **bun** for package management and task running, and **Node** as
the execution runtime. Both are correct at the same time; the distinction is the
source of most confusion, so keep it straight before editing scripts, configs,
docs, or comments.
## Use bun for tooling
- `bun install` (never `npm install` / `npm ci`)
- `bun run <script>` (never `npm run <script>`)
- `bunx <bin>` (never `npx <bin>`)
- `bun <file>.ts` to run a TS entrypoint directly (no `ts-node` / `tsx`)
- `bun esbuild.mjs` to drive the build (esbuild/vite are still the bundlers)
- `bun run --parallel ...` for parallel tasks
The root `bun.lock` is the single lockfile for the whole workspace, including
`apps/vscode`, `webview-ui`, and `testing-platform`. There are no per-package npm
lockfiles.
## Node is the runtime — do NOT rewrite these to bun
The build product runs on Node: the VS Code extension host loads
`dist/extension.js` as CommonJS under Node, and the standalone `cline-core` is a
Node process. The following are Node runtime/ABI references and are correct as-is:
| Reference | Why it is Node |
|-----------|----------------|
| esbuild `platform: "node"` / `target: "node..."` | The bundle targets the Node runtime (extension host, standalone core). |
| `TARGET_NODE_VERSION` (`scripts/package-standalone.mjs`) | Pins the Node ABI of the bundled standalone runtime (matches the JetBrains-packaged Node). |
| `prebuild-install --target=<node version>` | Downloads native `.node` binaries for that Node ABI. |
| `NODE_PATH=... node cline-core.js` | The standalone core is launched by Node, not bun. |
| `node:` import specifiers (e.g. `node:fs`) | Node builtin module scheme; unrelated to tooling. |
| `process.versions.node`, `engines.node`, `@types/node` | Runtime version probe / declared runtime / its types. |
| `ELECTRON_RUN_AS_NODE` | VS Code/Electron runs the extension host as Node. |
When a file legitimately uses both bun and node (e.g. `package-standalone.mjs`
does `bun install` but `prebuild-install --target=<node>`), the `node` token is
the runtime/ABI target, not tooling. If unsure, leave it.
## Tests: bun vs the VS Code host
A test file's runner is decided by its import:
- **`import ... from "bun:test"`** → runs under `bun test` (the node-side unit
suites + the SDK/model-catalog suites). `scripts/run-bun-unit-tests.ts`
discovers these by the `bun:test` import and runs one isolated bun process per
file. `build-tests.js` excludes them from the integration compile so the
`bun:test` builtin never reaches Node.
- **`import ... from "mocha"`** → runs under `@vscode/test-cli` in a real VS Code
extension host (Node). These exercise the live `vscode` API and cannot run
under bun.
So a file imports `bun:test` XOR `mocha`. Don't add `bun:test` to a test that
needs the real extension host.
+3 -3
View File
@@ -6,10 +6,10 @@ HTTP-controlled debugger for the VSCode extension at `src/dev/debug-harness/serv
```bash
# Build extension first if needed (protos + esbuild):
npm run protos && IS_DEV=true node esbuild.mjs
bun run protos && IS_DEV=true bun esbuild.mjs
# Launch (skip-build if already built):
npx tsx src/dev/debug-harness/server.ts --skip-build --auto-launch
bun src/dev/debug-harness/server.ts --skip-build --auto-launch
# In another terminal:
curl localhost:19229/api -d '{"method":"status"}'
@@ -51,7 +51,7 @@ debug-only hook via `ext.evaluate` (with `awaitPromise: true`):
It runs the same `SharedUriHandler.handleUri` as VSCode's real URI handler and exists only
when `CLINE_CAPTURE_BROWSER` is set (the harness always sets it; never ships in prod).
For end-to-end MCP OAuth, get a real `code` from the local MCP OAuth test server
(`npm run dev:mcp-oauth-test-server`).
(`bun run dev:mcp-oauth-test-server`).
## Navigating Views — Use Commands, Not Clicks
+5 -4
View File
@@ -13,8 +13,9 @@ This file is the secret sauce for working effectively in this codebase. It captu
**What NOT to add:** Stuff you can figure out from reading a few files, obvious patterns, or standard practices. This file should be high-signal, not comprehensive.
## Miscellaneous
- The whole repo (including `apps/vscode`) uses **bun** for package management and task running. Emit `bun run X` / `bun install` / `bunx <bin>` / `bun file.ts`, never npm/npx. Node remains the *runtime* (VS Code's extension host and the standalone cline-core are Node), so Node-runtime tokens are legitimate and must not be "fixed" to bun — see @.clinerules/bun-and-node.md for the keep-list vs rewrite-list.
- Avoid provider-specific string matching / hardcoded provider branches when fixing provider/config plumbing. Prefer provider metadata, shared catalog/defaults, explicit protocol/client capabilities, or centralized normalization utilities that apply by data shape rather than `providerId === "..."`. If a provider exception seems necessary, stop and explain why instead of adding ad-hoc string matching.
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `npm run compile`, not `npm run build`).
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `bun run compile`, not `bun run build`).
- When creating PRs, contributors should not create changelog-entry files. Maintainers handle release versioning and changelog curation during the release process.
- When adding new feature flags, see this PR as a reference https://github.com/cline/cline/pull/7566
- Additional instructions about making requests: @.clinerules/network.md
@@ -72,7 +73,7 @@ The extension and webview communicate via gRPC-like protocol over VS Code messag
- Naming: Services `PascalCaseService`, RPCs `camelCase`, Messages `PascalCase`
- For streaming responses, use `stream` keyword (see `subscribeToAuthCallback` in `account.proto`)
**Run `npm run protos`** after any proto changes—generates types in:
**Run `bun run protos`** after any proto changes—generates types in:
- `src/shared/proto/` - Shared type definitions
- `src/generated/grpc-js/` - Service implementations
- `src/generated/nice-grpc/` - Promise-based clients
@@ -108,7 +109,7 @@ Settings plumbing gotcha: if a key is user-toggleable from settings, wire both c
Missing one path causes a toggle to appear to change in one surface while the backend state stays unchanged.
Webview toggle gotcha: settings changes must also round-trip back in state payloads.
- Add the field to `UpdateSettingsRequest` in `proto/cline/state.proto` (for webview update requests), then run `npm run protos`
- Add the field to `UpdateSettingsRequest` in `proto/cline/state.proto` (for webview update requests), then run `bun run protos`
- Include the key in `Controller.getStateToPostToWebview()` (`src/core/controller/index.ts`)
- Ensure `ExtensionState` and webview defaults include the key (`src/shared/ExtensionMessage.ts`, `webview-ui/src/context/ExtensionStateContext.tsx`)
If this round-trip wiring is missing, the backend value can update but the toggle in webview appears stuck or reverts.
@@ -181,7 +182,7 @@ env -u ELECTRON_RUN_AS_NODE -u ELECTRON_NO_ATTACH_CONSOLE \
-u VSCODE_CLI -u VSCODE_CODE_CACHE_PATH -u VSCODE_CRASH_REPORTER_PROCESS_TYPE \
-u VSCODE_CWD -u VSCODE_ESM_ENTRYPOINT -u VSCODE_HANDLES_UNCAUGHT_ERRORS \
-u VSCODE_IPC_HOOK -u VSCODE_NLS_CONFIG -u VSCODE_PID -u VSCODE_L10N_BUNDLE_LOCATION \
npx tsx src/dev/debug-harness/server.ts --auto-launch --skip-build
bun src/dev/debug-harness/server.ts --auto-launch --skip-build
```
Check your own env with `env | grep -iE 'electron|vscode_'` first; `ELECTRON_RUN_AS_NODE=1`
+1 -1
View File
@@ -42,7 +42,7 @@ Here, we use the common `StringRequest` and `KeyValuePair` types.
After editing a `.proto` file, regenerate the TypeScript code. From the project root, run:
```bash
npm run protos
bun run protos
```
This command compiles all `.proto` files and outputs the generated code to `src/generated/` and `src/shared/`. Do not edit these generated files manually.
+1 -1
View File
@@ -91,7 +91,7 @@ On the main branch, create a commit that updates:
3. No changelog-entry file cleanup is needed. Contributors do not create changelog-entry files in this repo.
**Skip running `npm run install:all`** - release automation handles lockfile consistency as needed.
**No dependency install is needed.** A CHANGELOG + `version` bump does not change any dependency, and `bun.lock` does not pin workspace-package versions, so the lockfile stays consistent. The publish workflow runs `bun install --frozen-lockfile`, which would *fail* on an out-of-sync lock — so only run `bun install` here if you actually change dependencies (then commit the updated `bun.lock`).
Commit with message format: `v{VERSION} Release Notes (hotfix)`
@@ -53,25 +53,47 @@ jobs:
echo "Building sha: $GITHUB_SHA"
git --no-pager log -1 --oneline
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
# Node is still REQUIRED in the publish job (not just for install): the publish
# scripts run as `node ./scripts/publish-*.mjs` and shell out to `npx ovsx`.
# setup-bun does not provide a Node runtime, so keep setup-node here.
# Pinned to Node 22 because newer LTS (Node 24 / npm 11) can make vsce's
# `npm list` dependency detection fail with ELSPROBLEMS during packaging.
- name: Setup Node.js
uses: actions/setup-node@v4
with:
# Keep publish environment aligned with test workflow/tooling lockfile expectations.
# Newer LTS (Node 24 / npm 11) can make `npm list` fail with ELSPROBLEMS during vsce packaging.
node-version: 22
cache: 'npm'
cache-dependency-path: |
apps/vscode/package-lock.json
apps/vscode/webview-ui/package-lock.json
- name: Install extension dependencies
# Single root install resolves the whole bun workspace at once (replaces the
# per-package `npm ci` steps for apps/vscode + webview-ui).
- name: Install workspace dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode ci --include=optional
run: bun install --frozen-lockfile
- name: Install webview-ui dependencies
# @cline/* are local workspace symlinks to source packages; build dist/ before
# packaging/publishing the extension.
- name: Build SDK packages
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui ci --include=optional
run: bun run build:sdk
- name: Assert better-sqlite3 native binary present
run: |
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
if [ ! -f "$NODE_FILE" ]; then
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
echo "(bun trustedDependencies postinstall likely did not run)"
exit 1
fi
echo "Found better-sqlite3 native binary: $NODE_FILE"
# vsce is now a workspace devDependency (on node_modules/.bin), but ovsx is not
# vendored and the publish script invokes it via `npx ovsx`, so install ovsx
# globally here (npm is available via setup-node). vsce is installed globally
# too to preserve the script's existing PATH expectations.
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
@@ -89,7 +111,9 @@ jobs:
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
run: npm run publish:marketplace:nightly
# The script itself runs under `node ./scripts/publish-nightly.mjs`; bun run
# just launches it. Node + npm (for `npx ovsx`) are provided by setup-node above.
run: bun run publish:marketplace:nightly
- name: Tag published commit
working-directory: ${{ github.workspace }}
@@ -109,19 +109,48 @@ jobs:
echo "tag=$TAG" >> $GITHUB_OUTPUT
echo "resolved_sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
# Node is still REQUIRED in the publish job (not just for install): the
# publish scripts run as `node scripts/publish-*.mjs`, the version step uses
# `node -p`, and `npx ovsx` needs npm. setup-bun does not provide a Node
# runtime, so keep setup-node. Pinned to Node 22 because newer LTS
# (Node 24 / npm 11) can make vsce's `npm list` detection fail with
# ELSPROBLEMS during packaging.
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install extension dependencies
# Single root install resolves the whole bun workspace at once (replaces the
# per-package `npm install` steps for apps/vscode + webview-ui).
- name: Install workspace dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode install --include=optional
run: bun install --frozen-lockfile
- name: Install webview-ui dependencies
# @cline/* are local workspace symlinks to source packages; build dist/ before
# packaging/publishing the extension.
- name: Build SDK packages
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui install --include=optional
run: bun run build:sdk
- name: Assert better-sqlite3 native binary present
run: |
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
if [ ! -f "$NODE_FILE" ]; then
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
echo "(bun trustedDependencies postinstall likely did not run)"
exit 1
fi
echo "Found better-sqlite3 native binary: $NODE_FILE"
# vsce is a workspace devDependency (on node_modules/.bin), but ovsx is not
# vendored and the publish script invokes it via `npx ovsx`, so install ovsx
# globally (npm is available via setup-node). vsce is installed globally too
# to preserve the script's existing PATH expectations.
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
@@ -164,14 +193,20 @@ jobs:
node scripts/marketplace-readme.mjs swap-in
trap 'node scripts/marketplace-readme.mjs restore' EXIT
# Required to generate the .vsix
vsce package --allow-package-secrets sendgrid --out "cline-${{ steps.get_version.outputs.version }}.vsix"
# Required to generate the .vsix. --no-dependencies: the extension
# is fully esbuild-bundled, and under the bun workspace the @cline/*
# deps are symlinks pointing outside the package, so without this vsce
# would walk them and pull the whole monorepo into the .vsix.
vsce package --no-dependencies --allow-package-secrets sendgrid --out "cline-${{ steps.get_version.outputs.version }}.vsix"
# These scripts run under `node scripts/publish-marketplace.mjs`;
# bun run just launches them. Node + npm (for `npx ovsx`) come from
# setup-node above.
if [ "$RELEASE_TYPE" = "pre-release" ]; then
npm run publish:marketplace:prerelease
bun run publish:marketplace:prerelease
echo "Successfully published pre-release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
else
npm run publish:marketplace
bun run publish:marketplace
echo "Successfully published release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
fi
+44 -27
View File
@@ -45,12 +45,16 @@ jobs:
- 'apps/vscode/assets/**'
- 'apps/vscode/walkthrough/**'
- 'apps/vscode/package.json'
- 'apps/vscode/package-lock.json'
- 'apps/vscode/webview-ui/package.json'
# Root bun lockfile is authoritative for the whole workspace (incl. apps/vscode).
- 'bun.lock'
# SDK source packages are local workspace symlinks (@cline/*), so SDK changes affect the build.
- 'sdk/packages/**'
- 'apps/vscode/buf.yaml'
- 'apps/vscode/tsconfig*.json'
- 'apps/vscode/biome.jsonc'
- 'apps/vscode/esbuild.mjs'
- 'apps/vscode/.mocharc.json'
- 'apps/vscode/bunfig.toml'
- 'apps/vscode/.vscode-test.mjs'
- 'apps/vscode/.vscodeignore'
- 'apps/vscode/playwright*.ts'
@@ -84,26 +88,20 @@ jobs:
working-directory: apps/vscode
steps:
- uses: actions/checkout@v4
- name: Setup Node.js environment
uses: actions/setup-node@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
node-version: 22
bun-version: 1.3.14
# Cache root dependencies - only reuse if package-lock.json exactly matches
- name: Cache root dependencies
# Cache Bun's global install cache - keyed on the authoritative root bun.lock.
- name: Cache Bun install cache
uses: actions/cache@v4
id: root-cache
id: bun-cache
with:
path: apps/vscode/node_modules
key: ${{ runner.os }}-npm-${{ hashFiles('apps/vscode/package-lock.json') }}
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
- name: Cache webview-ui dependencies
uses: actions/cache@v4
id: webview-cache
with:
path: apps/vscode/webview-ui/node_modules
key: ${{ runner.os }}-npm-webview-${{ hashFiles('apps/vscode/webview-ui/package-lock.json') }}
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
# Cache VS Code installation
- name: Cache VS Code
@@ -124,22 +122,41 @@ jobs:
~/.cache/ms-playwright
~/Library/Caches/ms-playwright
~/AppData/Local/ms-playwright
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('apps/vscode/package-lock.json') }}
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('bun.lock') }}
restore-keys: |
playwright-browsers-${{ runner.os }}-
- name: Install extension dependencies
# Single root install resolves the entire bun workspace at once (replaces
# the per-package `npm ci` steps for apps/vscode + webview-ui).
- name: Install workspace dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode ci
run: bun install --frozen-lockfile
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install webview-ui dependencies
# @cline/* are local workspace symlinks to source packages; build dist/
# before building/packaging the extension for E2E.
- name: Build SDK packages
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui ci
run: bun run build:sdk
- name: Install vsce
run: npm install -g @vscode/vsce
- name: Assert better-sqlite3 native binary present
# Force bash: the Windows runner defaults to pwsh, which can't parse this
# POSIX test. Git Bash ships on GitHub's windows-latest images.
shell: bash
run: |
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
if [ ! -f "$NODE_FILE" ]; then
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
echo "(bun trustedDependencies postinstall likely did not run)"
exit 1
fi
echo "Found better-sqlite3 native binary: $NODE_FILE"
# NOTE: vsce is no longer installed globally. @vscode/vsce is a workspace
# devDependency of apps/vscode (resolved into node_modules/.bin), and the
# `test:e2e:build` script invokes `vsce` via `bun run`, which puts the local
# .bin on PATH. No global install needed.
- name: Install xvfb on Linux
if: matrix.runner == 'ubuntu'
@@ -148,11 +165,11 @@ jobs:
# Run optimized E2E tests (eliminates redundant builds)
- name: Run E2E tests - Linux
if: matrix.runner == 'ubuntu'
run: xvfb-run -a npm run test:e2e:optimal
run: xvfb-run -a bun run test:e2e:optimal
- name: Run E2E tests - Non-Linux
if: matrix.runner != 'ubuntu'
run: npm run test:e2e:optimal
run: bun run test:e2e:optimal
- uses: actions/upload-artifact@v4
if: ${{ failure() }}
+118 -60
View File
@@ -45,13 +45,16 @@ jobs:
- 'apps/vscode/assets/**'
- 'apps/vscode/walkthrough/**'
- 'apps/vscode/package.json'
- 'apps/vscode/package-lock.json'
- 'apps/vscode/webview-ui/package.json'
# Root bun lockfile is authoritative for the whole workspace (incl. apps/vscode).
- 'bun.lock'
# SDK source packages are local workspace symlinks (@cline/*), so SDK changes affect the build.
- 'sdk/packages/**'
- 'apps/vscode/buf.yaml'
- 'apps/vscode/tsconfig*.json'
- 'apps/vscode/biome.jsonc'
- 'apps/vscode/esbuild.mjs'
- 'apps/vscode/.mocharc.json'
- 'apps/vscode/.nycrc*.json'
- 'apps/vscode/bunfig.toml'
- 'apps/vscode/.vscode-test.mjs'
- 'apps/vscode/test-setup.js'
- '.github/workflows/ext-vscode-test.yml'
@@ -60,9 +63,13 @@ jobs:
- 'apps/vscode/proto/**'
- 'apps/vscode/standalone/**'
- 'apps/vscode/testing-platform/**'
- 'apps/vscode/testing-platform/package.json'
- 'apps/vscode/tests/specs/**'
- 'apps/vscode/package.json'
- 'apps/vscode/package-lock.json'
# Root bun lockfile is authoritative for the whole workspace (incl. apps/vscode).
- 'bun.lock'
# SDK source packages are local workspace symlinks (@cline/*), so SDK changes affect the build.
- 'sdk/packages/**'
- 'apps/vscode/buf.yaml'
- 'apps/vscode/tsconfig*.json'
- 'apps/vscode/esbuild.mjs'
@@ -82,27 +89,38 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js environment
uses: actions/setup-node@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
node-version: 22
cache: 'npm'
cache-dependency-path: |
apps/vscode/package-lock.json
apps/vscode/webview-ui/package-lock.json
bun-version: 1.3.14
- name: Install extension dependencies
# Single root install resolves the entire bun workspace (apps/vscode,
# webview-ui, testing-platform and the @cline/* SDK symlinks) at once,
# so the previous per-package `npm ci` steps collapse into one.
- name: Install workspace dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode ci --include=optional
run: bun install --frozen-lockfile
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install webview-ui dependencies
# @cline/* are local workspace symlinks to source packages; their dist/
# output must be built before the extension can type-check/compile.
- name: Build SDK packages
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui ci --include=optional
run: bun run build:sdk
- name: Assert better-sqlite3 native binary present
run: |
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
if [ ! -f "$NODE_FILE" ]; then
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
echo "(bun trustedDependencies postinstall likely did not run)"
exit 1
fi
echo "Found better-sqlite3 native binary: $NODE_FILE"
- name: Run Quality Checks (Parallel)
run: npm run ci:check-all
run: bun run ci:check-all
vscode-test:
needs: [detect-changes, quality-checks]
@@ -123,30 +141,43 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js environment
uses: actions/setup-node@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
node-version: 22
cache: 'npm'
cache-dependency-path: |
apps/vscode/package-lock.json
apps/vscode/webview-ui/package-lock.json
bun-version: 1.3.14
- name: Install extension dependencies
# Single root install resolves the entire bun workspace at once (replaces
# the per-package `npm ci` steps for apps/vscode + webview-ui).
- name: Install workspace dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode ci --include=optional
run: bun install --frozen-lockfile
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install webview-ui dependencies
# @cline/* are local workspace symlinks to source packages; build dist/
# before compiling/testing the extension.
- name: Build SDK packages
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui ci --include=optional
run: bun run build:sdk
- name: Set up NPM on Windows
if: runner.os == 'Windows'
working-directory: ${{ github.workspace }}
- name: Assert better-sqlite3 native binary present
run: |
npm config set script-shell "C:\\Program Files\\Git\\bin\\bash.exe"
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
if [ ! -f "$NODE_FILE" ]; then
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
echo "(bun trustedDependencies postinstall likely did not run)"
exit 1
fi
echo "Found better-sqlite3 native binary: $NODE_FILE"
# NOTE: The old `npm config set script-shell bash` step is intentionally
# removed. Scripts are now launched with `bun run`, which uses Bun's own
# built-in cross-platform shell rather than npm's configured script-shell,
# so that npm-specific Windows workaround no longer applies. Bash-dependent
# scripts (e.g. scripts/proto-lint.sh, standalone/runclinecore.sh) are
# invoked explicitly via `bash ...` from within the package scripts, and
# this job's `defaults.run.shell: bash` (Git Bash on Windows) still covers
# the workflow `run:` blocks below.
- name: Cache VS Code test runtime
if: runner.os == 'Windows'
@@ -158,29 +189,51 @@ jobs:
# Build the extension and tests (without redundant checks)
- name: Build Tests and Extension
id: build_step
run: npm run ci:build
run: bun run ci:build
- name: Vitest Suites (SDK adapter + model catalog)
id: vitest_tests
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
run: npm run test:vitest
# The vitest config sets passWithNoTests: true, so a broken glob/alias
# would "pass" with zero tests. Capture output and assert a non-zero
# test count to guard against silent skips.
run: |
set -o pipefail
bun run test:vitest 2>&1 | tee vitest-output.log
# Strip ANSI color codes before matching — vitest colorizes the
# "Tests N passed" summary, so the count is not adjacent to the
# "Tests" label in the raw bytes.
if ! sed -r 's/\x1b\[[0-9;]*m//g' vitest-output.log | grep -Eq 'Tests[[:space:]]+[0-9]*[1-9][0-9]* (passed|failed)'; then
echo "ERROR: vitest reported zero tests (possible silent skip)."
exit 1
fi
- name: Unit Tests with coverage - Linux
- name: Unit Tests (bun) - Linux
id: unit_tests_linux
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os == 'Linux' }}
# The node-side unit suite (.mocharc spec set) now runs under `bun test`
# via scripts/run-bun-unit-tests.ts (one isolated bun process per file).
# The runner exits non-zero on any failure and prints a final
# "Files: N Pass: P Fail: F" summary; assert a non-zero pass count to
# guard against an empty glob silently "passing".
run: |
npx nyc --nycrc-path .nycrc.unit.json --reporter=lcov npm run test:unit
set -o pipefail
bun run test:unit 2>&1 | tee unit-output.log
if ! grep -Eq 'Pass:[[:space:]]+[0-9]*[1-9][0-9]*' unit-output.log; then
echo "ERROR: bun unit runner reported zero passing tests (possible silent skip)."
exit 1
fi
- name: Unit Tests - Non-Linux
- name: Unit Tests (bun) - Non-Linux
id: unit_tests_non_linux
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os != 'Linux' }}
run: |
npm run test:unit
bun run test:unit
- name: Extension Integration Tests - Linux
id: integration_tests_linux
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os == 'Linux' }}
run: xvfb-run -a npm run test:coverage
run: xvfb-run -a bun run test:coverage
- name: Extension Integration Tests - Non-Linux
id: integration_tests_non_linux
@@ -188,7 +241,7 @@ jobs:
run: |
for attempt in 1 2 3; do
echo "Running extension integration tests (attempt ${attempt}/3)"
if npm run test:integration; then
if bun run test:integration; then
exit 0
fi
@@ -206,7 +259,7 @@ jobs:
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
run: |
cd webview-ui
npm run test:coverage
bun run test:coverage
- name: Save Coverage Reports
uses: actions/upload-artifact@v4
@@ -215,7 +268,6 @@ jobs:
with:
name: pr-coverage-reports
path: |
apps/vscode/coverage-unit/lcov.info
apps/vscode/webview-ui/coverage/lcov.info
test-platform-integration:
@@ -229,39 +281,45 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js environment
uses: actions/setup-node@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
node-version: 22
cache: 'npm'
cache-dependency-path: |
apps/vscode/package-lock.json
apps/vscode/webview-ui/package-lock.json
apps/vscode/testing-platform/package-lock.json
bun-version: 1.3.14
- name: Install extension dependencies
# Single root install resolves the whole bun workspace, including the
# testing-platform package, so the separate per-package `npm ci` steps
# (extension + webview-ui + testing-platform) collapse into one.
- name: Install workspace dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode ci --include=optional
run: bun install --frozen-lockfile
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install webview-ui dependencies
# @cline/* are local workspace symlinks to source packages; build dist/
# before compiling the standalone core.
- name: Build SDK packages
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui ci --include=optional
run: bun run build:sdk
- name: Assert better-sqlite3 native binary present
run: |
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
if [ ! -f "$NODE_FILE" ]; then
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
echo "(bun trustedDependencies postinstall likely did not run)"
exit 1
fi
echo "Found better-sqlite3 native binary: $NODE_FILE"
- name: Download ripgrep binaries
run: npm run download-ripgrep
run: bun run download-ripgrep
- name: Compile Standalone
run: npm run compile-standalone
- name: Install testing platform dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/testing-platform ci --include=optional
run: bun run compile-standalone
- name: Running testing platform integration spec tests
timeout-minutes: 7
run: npm run test:tp-orchestrator -- tests/specs/ --count=1 --coverage
run: bun run test:tp-orchestrator -- tests/specs/ --count=1 --coverage
- name: Save Coverage Reports
uses: actions/upload-artifact@v4
+1
View File
@@ -84,3 +84,4 @@ apps/vscode/webview-ui/src/**/*.js.map
*.db-wal
.cline/**/managed.json
.cline/**/bundle.json
apps/vscode/tsconfig.test.generated.json
+1 -1
View File
@@ -7,5 +7,5 @@ fi
gitleaks git --pre-commit --redact --staged --verbose || exit 1
cd apps/vscode && lint-staged
cd apps/vscode && bunx lint-staged
+14 -14
View File
@@ -45,7 +45,7 @@ We also welcome contributions to our [documentation](https://github.com/cline/cl
3. Install [bun](https://bun.com)
4. Install the necessary dependencies for the extension and webview-gui:
```bash
cd apps/vscode && npm run install:all && cd ../..
cd apps/vscode && bun run install:all && cd ../..
cd sdk && bun run build && cd ..
```
5. Generate Protocol Buffer files (required before first build):
@@ -61,8 +61,8 @@ We also welcome contributions to our [documentation](https://github.com/cline/cl
2. Push your branch and create a PR on GitHub. Our CI will:
- Run tests and checks
3. Testing
- Run `cd apps/vscode && npm run test` to run tests locally.
- Before submitting PR, run `npm run format:fix` to format your code
- Run `cd apps/vscode && bun run test` to run tests locally.
- Before submitting PR, run `bun run format:fix` to format your code
### Extension
@@ -74,12 +74,12 @@ We also welcome contributions to our [documentation](https://github.com/cline/cl
2. **Local Development**
- cd into the vscode extension, `cd apps/vscode`
- Run `npm run install:all` to install dependencies
- Run `npm run protos` to generate Protocol Buffer files (required before first build)
- Run `npm run test` to run tests locally
- Run `bun run install:all` to install dependencies
- Run `bun run protos` to generate Protocol Buffer files (required before first build)
- Run `bun run test` to run tests locally
- Run → Start Debugging or `>Debug: Select and Start Debugging` and wait for a new VS Code instance to open
- **Terminal Workflow**: Use `npm run dev` (generates protos + runs watch mode) or `npm run watch` (if protos already generated)
- Before submitting PR, run `npm run format:fix` to format your code
- **Terminal Workflow**: Use `bun run dev` (generates protos + runs watch mode) or `bun run watch` (if protos already generated)
- Before submitting PR, run `bun run format:fix` to format your code
3. **Linux-specific Setup**
VS Code extension tests on Linux require the following system libraries:
@@ -135,8 +135,8 @@ Anyone can contribute code to Cline, but we ask that you follow these guidelines
2. **Code Quality**
- Run `npm run lint` to check code style
- Run `npm run format` to automatically format code
- Run `bun run lint` to check code style
- Run `bun run format` to automatically format code
- All PRs must pass CI checks which include both linting and formatting
- Address any warnings or errors from linter before submitting
- Follow TypeScript best practices and maintain type safety
@@ -144,7 +144,7 @@ Anyone can contribute code to Cline, but we ask that you follow these guidelines
3. **Testing**
- Add tests for new features
- Run `npm test` to ensure all tests pass
- Run `bun test` to ensure all tests pass
- Update existing tests if your changes affect them
- Include both unit tests and integration tests where appropriate
@@ -154,9 +154,9 @@ Anyone can contribute code to Cline, but we ask that you follow these guidelines
- **Running E2E tests:**
```bash
npm run test:e2e # Build and run all E2E tests
npm run e2e # Run tests without rebuilding
npm run test:e2e -- --debug # Run with interactive debugger
bun run test:e2e # Build and run all E2E tests
bun run e2e # Run tests without rebuilding
bun run test:e2e -- --debug # Run with interactive debugger
```
- **Writing E2E tests:**
-23
View File
@@ -1,23 +0,0 @@
{
"extension": [
"ts"
],
"spec": [
"src/**/__tests__/*.ts",
"src/test/services/**/*.test.ts"
],
"ignore": [
"src/core/controller/models/__tests__/providerCatalogHandlers.test.ts",
"src/core/controller/models/__tests__/providerCatalogSmoke.test.ts",
"src/core/controller/models/__tests__/providerSwitchNormalization.test.ts",
"src/core/controller/models/__tests__/resolveModelInfo.test.ts",
"src/core/controller/models/__tests__/refreshClineRecommendedModels.test.ts"
],
"require": [
"ts-node/register",
"source-map-support/register",
"./src/test/requires.ts"
],
"recursive": true,
"exit": true
}
-48
View File
@@ -1,48 +0,0 @@
{
"all": true,
"check-coverage": false,
"reporter": [
"text",
"lcov"
],
"include": [
"src/**/*.ts"
],
"exclude": [
"**/*.d.ts",
"**/*.{test,spec}.{js,jsx,ts,tsx,mjs,cjs}",
"**/__tests__/**",
"**/test/**",
"**/tests/**",
"**/.nyc_output/**",
"**/.vscode-test/**",
"**/tests-results/**",
"src/test/**",
"src/generated/**",
"**/node_modules/**",
"**/dist/**",
"**/out/**",
"**/build/**",
"**/coverage/**",
"**/coverage-unit/**",
"**/proto/**",
"**/*.{config,setup}.{js,ts,mjs,cjs}",
"**/vite-env.d.ts",
"**/*.{css,scss,sass,less,styl}",
"**/*.{svg,png,jpg,jpeg,gif,ico}",
"**/*.{json,yaml,yml}"
],
"extension": [
".ts",
".js"
],
"cache": true,
"sourceMap": true,
"instrument": true,
"report-dir": "./coverage-unit"
}
+7
View File
@@ -7,6 +7,13 @@ export default defineConfig({
files: [
"out/src/{core,test,utils,shared,integrations,hosts,services}/**/*.test.js",
"src/{core,test,utils,shared,integrations,hosts,services}/**/*.test.js",
// The bun unit suite (src/**/__tests__/* and src/test/services/**) runs under
// `bun test` (run-bun-unit-tests.ts) and imports `bun:test`, which this
// Node-based runner cannot load. Exclude it here.
"!out/src/**/__tests__/**/*.test.js",
"!out/src/test/services/**/*.test.js",
"!src/**/__tests__/**/*.test.js",
"!src/test/services/**/*.test.js",
],
mocha: {
ui: "bdd",
+17 -1
View File
@@ -10,8 +10,25 @@ CLAUDE.local.md
out/
dist-standalone/
node_modules/
# Nested workspace-member node_modules (bun links these under each package).
# Scoped to the sub-package dirs so it doesn't shadow the top-level
# node_modules/@vscode/codicons re-include below.
webview-ui/node_modules/**
testing-platform/node_modules/**
standalone/**/node_modules/**
src/**
standalone/**
# Build/dev tooling and inputs — bundled into dist/extension.js, not needed in the VSIX.
bunfig.toml
esbuild.mjs
knip.json
biome.jsonc
test-setup.js
.env.example
scripts/**
proto/**
testing-platform/**
tests/**
.gitignore
.yarnrc
esbuild.js
@@ -46,7 +63,6 @@ eslint-rules/
old_docs/
evals/
.codespellrc
.mocharc.json
buf.yaml
.clinerules/
+7
View File
@@ -0,0 +1,7 @@
[test]
# Module-substitution aliases for `bun test`. bun resolves tsconfig `paths`
# (@/*, @core/*, @shared/*, …) and the real @cline/llms + @cline/shared dist
# builds on its own; the preload only shadows `vscode` and `@cline/core` with
# their unit-test stubs (mirrors vitest.config.ts resolve.alias). See
# src/test/bun-test-preload.ts for details.
preload = ["./src/test/bun-test-preload.ts"]
-22071
View File
File diff suppressed because it is too large Load Diff
+46 -56
View File
@@ -342,65 +342,66 @@
}
},
"scripts": {
"vscode:prepublish": "npm run package",
"compile": "npm run check-types && npm run lint && node esbuild.mjs",
"compile-standalone": "npm run check-types && npm run lint && node esbuild.mjs --standalone",
"vscode:prepublish": "bun run package",
"compile": "bun run check-types && bun run lint && bun esbuild.mjs",
"compile-standalone": "bun run check-types && bun run lint && bun esbuild.mjs --standalone",
"postcompile-standalone": "node scripts/package-standalone.mjs",
"dev": "npm run protos && npm run watch",
"watch": "npx npm-run-all -p watch:*",
"watch:esbuild": "node esbuild.mjs --watch",
"dev": "bun run protos && bun run watch",
"watch": "bun run --parallel watch:esbuild watch:tsc",
"watch:esbuild": "bun esbuild.mjs --watch",
"watch:tsc": "tsc --noEmit --watch --project tsconfig.json",
"package": "npm run check-types && npm run build:webview && npm run lint && node esbuild.mjs --production",
"package": "bun run check-types && bun run build:webview && bun run lint && bun esbuild.mjs --production",
"protos": "node scripts/build-proto.mjs",
"protos-python": "node scripts/build-python-proto.mjs",
"download-ripgrep": "node scripts/download-ripgrep.mjs",
"postprotos": "biome format --config-path ./biome.jsonc src/shared/proto src/core/controller src/hosts/ webview-ui/src/services src/generated --write --no-errors-on-unmatched",
"clean:build": "rimraf dist dist-standalone webview-ui/build src/generated out/",
"clean:deps": "rimraf node_modules webview-ui/node_modules",
"clean:all": "npm run clean:build && npm run clean:deps",
"clean:all": "bun run clean:build && bun run clean:deps",
"compile-tests": "node ./scripts/build-tests.js",
"watch-tests": "tsc -p . -w --outDir out",
"check-types": "npm run protos && npx tsc --noEmit && cd webview-ui && npx tsc --noEmit",
"lint": "biome lint --config-path ./biome.jsonc --no-errors-on-unmatched --files-ignore-unknown=true --diagnostic-level=error && npm run lint:proto",
"check-types": "bun run protos && bunx tsc --noEmit && cd webview-ui && bunx tsc --noEmit",
"lint": "biome lint --config-path ./biome.jsonc --no-errors-on-unmatched --files-ignore-unknown=true --diagnostic-level=error && bun run lint:proto",
"lint:proto": "bash ./scripts/proto-lint.sh",
"analyze:unused": "npx --yes knip --config knip.json --include files,exports,types,enumMembers,duplicates",
"analyze:unused:prod": "npx --yes knip --config knip.json --production --include files,exports,types,enumMembers,dependencies",
"analyze:unused": "bunx --yes knip --config knip.json --include files,exports,types,enumMembers,duplicates",
"analyze:unused:prod": "bunx --yes knip --config knip.json --production --include files,exports,types,enumMembers,dependencies",
"analyze:unused:fix-exports": "node scripts/remove-unused-exports.mjs --apply",
"analyze:unused:fix-exports:dry": "node scripts/remove-unused-exports.mjs",
"format": "biome format --config-path ./biome.jsonc --changed --since main --no-errors-on-unmatched --files-ignore-unknown=true --diagnostic-level=error --semicolons=as-needed",
"format:fix": "biome check --config-path ./biome.jsonc --changed --since main --no-errors-on-unmatched --files-ignore-unknown=true --write --semicolons=as-needed",
"fix:all": "biome check --config-path ./biome.jsonc --no-errors-on-unmatched --files-ignore-unknown=true --write --diagnostic-level=error --unsafe --semicolons=as-needed",
"ci:check-all": "npx npm-run-all -p check-types lint format",
"ci:build": "npm run protos && npm run build:webview && node esbuild.mjs && npm run compile-tests",
"pretest": "npm run compile && npm run compile-tests && npm run compile-standalone && npm run lint",
"test": "npx npm-run-all test:unit test:integration",
"test:integration": "npm run compile-tests && vscode-test",
"test:unit": "cross-env TS_NODE_PROJECT=./tsconfig.unit-test.json mocha",
"ci:check-all": "bun run --parallel check-types lint format",
"ci:build": "bun run protos && bun run build:webview && bun esbuild.mjs && bun run compile-tests",
"pretest": "bun run compile && bun run compile-tests && bun run compile-standalone && bun run lint",
"test": "bun run test:unit && bun run test:integration",
"test:integration": "bun run compile-tests && vscode-test",
"test:unit": "bun scripts/run-bun-unit-tests.ts",
"test:vitest": "vitest run --config vitest.config.ts",
"test:vitest:watch": "vitest --config vitest.config.ts",
"test:coverage": "npm run compile-tests && vscode-test --coverage",
"test:sca-server": "npx tsx watch scripts/test-standalone-core-api-server.ts",
"test:tp-orchestrator": "npx tsx scripts/testing-platform-orchestrator.ts",
"dev:mcp-oauth-test-server": "npx tsx src/dev/mcp-oauth-test-server/server.ts",
"test:bun": "bun scripts/run-bun-tests.ts",
"test:bun:unit": "bun scripts/run-bun-unit-tests.ts",
"test:coverage": "bun run compile-tests && vscode-test --coverage",
"test:sca-server": "bun --watch scripts/test-standalone-core-api-server.ts",
"test:tp-orchestrator": "bun scripts/testing-platform-orchestrator.ts",
"dev:mcp-oauth-test-server": "bun src/dev/mcp-oauth-test-server/server.ts",
"e2e": "playwright test -c playwright.config.ts",
"test:e2e:build": "vsce package --allow-package-secrets sendgrid --out dist/e2e.vsix",
"test:e2e": "playwright install && npm run test:e2e:build && node src/test/e2e/utils/build.mjs && playwright test",
"test:e2e:optimal": "npm run test:e2e:build && node src/test/e2e/utils/build.mjs && playwright test",
"test:e2e:ui": "npx tsx scripts/interactive-playwright.ts",
"install:all": "npm install && cd webview-ui && npm install",
"dev:webview": "cd webview-ui && npm run dev",
"build:webview": "cd webview-ui && npm run build",
"test:webview": "cd webview-ui && npm run test",
"test:e2e:build": "vsce package --no-dependencies --allow-package-secrets sendgrid --out dist/e2e.vsix",
"test:e2e": "playwright install && bun run test:e2e:build && node src/test/e2e/utils/build.mjs && playwright test",
"test:e2e:optimal": "bun run test:e2e:build && node src/test/e2e/utils/build.mjs && playwright test",
"test:e2e:ui": "bun scripts/interactive-playwright.ts",
"install:all": "bun install",
"dev:webview": "cd webview-ui && bun run dev",
"build:webview": "cd webview-ui && bun run build",
"test:webview": "cd webview-ui && bun run test",
"publish:marketplace": "node scripts/publish-marketplace.mjs",
"publish:marketplace:prerelease": "node scripts/publish-marketplace.mjs --pre-release",
"publish:marketplace:nightly": "node ./scripts/publish-nightly.mjs",
"prepare": "npx husky",
"docs": "cd docs && npm run dev",
"docs:check-links": "cd docs && npm run check",
"docs:rename-file": "cd docs && npm run rename",
"docs": "cd docs && bun run dev",
"docs:check-links": "cd docs && bun run check",
"docs:rename-file": "cd docs && bun run rename",
"report-issue": "node scripts/report-issue.js",
"storybook": "cd webview-ui && npm run storybook",
"eval:smoke:run": "npx tsx evals/smoke-tests/run-smoke-tests.ts"
"storybook": "cd webview-ui && bun run storybook",
"eval:smoke:run": "bun evals/smoke-tests/run-smoke-tests.ts"
},
"lint-staged": {
"src/shared/storage/state-keys.ts": [
@@ -424,7 +425,6 @@
"@types/node": "20.x",
"@types/pdf-parse": "^1.1.4",
"@types/picomatch": "^4.0.2",
"@types/proxyquire": "^1.3.31",
"@types/shell-quote": "^1.7.5",
"@types/should": "^11.2.0",
"@types/sinon": "^21.0.0",
@@ -436,24 +436,22 @@
"c8": "^10.1.3",
"chai": "^4.3.10",
"chalk": "5.6.2",
"cross-env": "^10.1.0",
"dotenv": "^17.2.3",
"esbuild": "^0.25.0",
"glob": "^11.0.0",
"grpc-tools": "^1.13.0",
"husky": "^9.1.7",
"lint-staged": "^16.1.0",
"minimatch": "^3.0.3",
"npm-run-all": "^4.1.5",
"nyc": "^17.1.0",
"minimist": "^1.2.8",
"mocha": "^11.7.4",
"playwright": "^1.55.1",
"prebuild-install": "^7.1.3",
"protoc-gen-ts": "^0.8.7",
"proxyquire": "^2.1.3",
"rimraf": "^6.0.1",
"should": "^13.2.3",
"sinon": "^21.0.3",
"tar": "^7.5.2",
"tree-kill": "^1.2.2",
"ts-node": "^10.9.2",
"ts-proto": "^2.6.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.4.5",
@@ -462,10 +460,10 @@
"dependencies": {
"@anthropic-ai/sdk": "^0.37.0",
"@bufbuild/protobuf": "^2.2.5",
"@cline/agents": "0.0.47",
"@cline/core": "0.0.47",
"@cline/llms": "0.0.47",
"@cline/shared": "0.0.47",
"@cline/agents": "workspace:*",
"@cline/core": "workspace:*",
"@cline/llms": "workspace:*",
"@cline/shared": "workspace:*",
"@google/genai": "^1.30.0",
"@grpc/grpc-js": "^1.9.15",
"@grpc/proto-loader": "^0.7.13",
@@ -524,6 +522,7 @@
"mammoth": "^1.11.0",
"nanoid": "^5.1.6",
"nice-grpc": "^2.1.12",
"nice-grpc-common": "^2.0.3",
"node-machine-id": "^1.1.12",
"open": "^10.1.2",
"open-graph-scraper": "^6.9.0",
@@ -551,15 +550,6 @@
"vscode-uri": "^3.1.0",
"zod": "^4.3.6"
},
"overrides": {
"tar-fs": ">=3.1.1",
"tar": "^7.5.2",
"vite": "^7.1.11",
"js-yaml": "^4.1.1",
"serialize-javascript": ">=7.0.3",
"protobufjs": "7.5.8",
"diff": "8.0.4"
},
"c8": {
"reporter": [
"lcov",
+20 -3
View File
@@ -34,9 +34,26 @@ const GRPC_JS_OUT_DIR = path.resolve("src/generated/grpc-js")
const NICE_JS_OUT_DIR = path.resolve("src/generated/nice-grpc")
const DESCRIPTOR_OUT_DIR = path.resolve("dist-standalone/proto")
const TS_PROTO_PLUGIN = isWindows
? path.resolve("node_modules/.bin/protoc-gen-ts_proto.cmd") // Use the .bin directory path for Windows
: require.resolve("ts-proto/protoc-gen-ts_proto")
// protoc invokes the ts-proto plugin as a child process, so it needs a path it can
// directly execute. On POSIX the package's JS bin (with its shebang) works. On
// Windows protoc cannot exec a bare .js or bun's `.bunx` shim ("%1 is not a valid
// Win32 application"), and the package manager's `.cmd` shim location/name varies
// (npm vs bun's hoisted store). To be package-manager-agnostic, generate a tiny
// .cmd wrapper that runs the resolved plugin JS via `node`.
function resolveTsProtoPlugin() {
const pluginJs = require.resolve("ts-proto/protoc-gen-ts_proto")
if (!isWindows) {
return pluginJs
}
const wrapperDir = path.resolve("dist-standalone")
fsSync.mkdirSync(wrapperDir, { recursive: true })
const wrapperPath = path.join(wrapperDir, "protoc-gen-ts_proto.cmd")
// %* forwards protoc's plugin args/stdio to the JS entry run under node.
fsSync.writeFileSync(wrapperPath, `@echo off\r\nnode "${pluginJs}" %*\r\n`)
return wrapperPath
}
const TS_PROTO_PLUGIN = resolveTsProtoPlugin()
const TS_PROTO_OPTIONS = [
"env=both",
+31 -1
View File
@@ -61,7 +61,37 @@ async function main() {
fs.rmSync(path.join(__dirname, "..", "out", "src"), { recursive: true, force: true })
fs.rmSync(path.join(__dirname, "..", "out", "packages"), { recursive: true, force: true })
execSync("tsc -p ./tsconfig.test.json --outDir out", { encoding: "utf-8" })
// Single source of truth for the bun-vs-integration test split: any *.test.ts that
// imports from "bun:test" is owned by the bun runner (scripts/run-bun-unit-tests.ts)
// and must NOT be compiled into the Node-based @vscode/test-cli `out/` tree (Node
// cannot load the `bun:test` builtin, and these files use bun-only APIs like
// `mock.module` / 3-arg `it`). Generate a tsconfig that excludes them so the
// integration compile only ever sees mocha-owned tests.
const projectRoot = path.join(__dirname, "..")
const bunTestImport = /from\s+["']bun:test["']/
function collectBunTestFiles(dir, acc) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === "node_modules") continue
const full = path.join(dir, entry.name)
if (entry.isDirectory()) {
collectBunTestFiles(full, acc)
} else if (entry.isFile() && entry.name.endsWith(".test.ts")) {
if (bunTestImport.test(fs.readFileSync(full, "utf-8"))) {
acc.push(path.relative(projectRoot, full).split(path.sep).join("/"))
}
}
}
return acc
}
const bunOwnedTests = collectBunTestFiles(path.join(projectRoot, "src"), [])
// tsconfig.test.json is JSONC (contains comments); parse with json5 (a project dep).
const JSON5 = require("json5")
const baseTestConfig = JSON5.parse(fs.readFileSync(path.join(projectRoot, "tsconfig.test.json"), "utf-8"))
baseTestConfig.exclude = [...(baseTestConfig.exclude ?? []), ...bunOwnedTests]
const generatedConfigPath = path.join(projectRoot, "tsconfig.test.generated.json")
fs.writeFileSync(generatedConfigPath, JSON.stringify(baseTestConfig, null, "\t"))
execSync(`tsc -p ${JSON.stringify(generatedConfigPath)} --outDir out`, { encoding: "utf-8" })
main().catch((e) => {
console.error(e)
+11 -3
View File
@@ -47,8 +47,13 @@ async function installNodeDependencies() {
await cpr(RUNTIME_DEPS_DIR, BUILD_DIR)
console.log("Running npm install in distribution directory...")
execSync("npm install", { stdio: "inherit", cwd: BUILD_DIR })
// This is an ISOLATED runtime install inside the standalone distribution
// directory (dist-standalone), driven by the "cline-core" runtime-files
// manifest — it is NOT part of the monorepo workspace install. TARGET_NODE_VERSION
// and the prebuild-install calls below target the Node ABI of the bundled
// runtime (matching the JetBrains-packaged Node), not the build tooling.
console.log("Running bun install in distribution directory...")
execSync("bun install", { stdio: "inherit", cwd: BUILD_DIR })
// Move the vscode directory into node_modules.
// It can't be installed using npm because it will create a symlink which cannot be unzipped correctly on windows.
@@ -92,7 +97,10 @@ async function packageAllBinaryDeps() {
const dest = path.join(binaryDir, module)
await cpr(src, dest)
// Download the binary libs
// Download the binary libs.
// `--target=${TARGET_NODE_VERSION}` selects the Node ABI of the bundled
// standalone runtime (NOT the bun/build tooling) so the prebuilt native
// `.node` binaries load in the Node that runs cline-core.
const v = IS_VERBOSE ? "--verbose" : ""
const cmd = `npx prebuild-install --platform=${platform} --arch=${arch} --target=${TARGET_NODE_VERSION} ${v}`
log_verbose(`${module}: ${cmd}`)
+5 -2
View File
@@ -37,13 +37,16 @@ process.on("SIGINT", cleanupOnSignal(130))
process.on("SIGTERM", cleanupOnSignal(143))
try {
const vsceArgs = ["publish", "--allow-package-secrets", "sendgrid"]
// --no-dependencies: the extension is fully esbuild-bundled into dist/extension.js,
// so vsce must not walk node_modules (the @cline/* workspace symlinks point out of
// the package and would drag the whole monorepo into the VSIX).
const vsceArgs = ["publish", "--no-dependencies", "--allow-package-secrets", "sendgrid"]
if (isPrerelease) {
vsceArgs.push("--pre-release")
}
execFileSync("vsce", vsceArgs, { stdio: "inherit" })
const ovsxArgs = ["ovsx", "publish"]
const ovsxArgs = ["ovsx", "publish", "--no-dependencies"]
if (isPrerelease) {
ovsxArgs.push("--pre-release")
}
+3
View File
@@ -388,6 +388,9 @@ class NightlyPublisher {
const args = [
"package",
...(isPreRelease ? ["--pre-release"] : []),
// The extension is fully esbuild-bundled, so vsce must not walk node_modules
// (the @cline/* workspace symlinks point outside the package).
"--no-dependencies",
"--no-update-package-json",
"--no-git-tag-version",
"--allow-package-secrets",
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bun
import { spawn } from "node:child_process"
import path from "node:path"
/**
* Runner for the SDK-adapter + model-catalog `bun test` suites (the same set
* `vitest.config.ts` covers; `test:vitest` runs them under vitest).
*
* Why a script instead of a bare `bun test <globs>`:
*
* 1. Curated include set. These suites are an explicit list (see
* INCLUDE_PATTERNS, kept in sync with `vitest.config.ts` `test.include`),
* not the whole tree, so the node-side unit and @vscode/test-cli suites are
* not pulled in. `bun test`'s positional args don't expand `**` the way we
* need, so we resolve the globs ourselves with bun's `Glob`.
*
* 2. One process per file. `bun test` runs all files in a single process by
* default, so `mock.module(...)` registrations leak between files — suites
* mocking the same specifier with different shapes (e.g.
* `@/core/storage/StateManager`, `@cline/core`) clobber each other.
* `--parallel` runs each file in its own worker process, giving each a fresh
* module registry.
*
* Usage:
* bun scripts/run-bun-tests.ts # run the curated set, isolated
* bun scripts/run-bun-tests.ts --list # print the resolved file list only
*/
import { Glob } from "bun"
// Mirror of vitest.config.ts `test.include`. Keep these in sync.
const INCLUDE_PATTERNS = [
"src/sdk/**/*.test.ts",
"src/shared/vsCodeSelectorUtils.test.ts",
"src/core/storage/remote-config/**/*.test.ts",
"src/shared/model-catalog/provider-helpers.test.ts",
"src/core/controller/models/__tests__/providerCatalogHandlers.test.ts",
"src/core/controller/models/__tests__/providerSwitchNormalization.test.ts",
"src/core/controller/models/__tests__/resolveModelInfo.test.ts",
"src/core/controller/models/__tests__/providerCatalogSmoke.test.ts",
"src/core/controller/models/__tests__/refreshClineRecommendedModels.test.ts",
]
const projectRoot = path.resolve(import.meta.dir, "..")
async function resolveFiles(): Promise<string[]> {
const seen = new Set<string>()
for (const pattern of INCLUDE_PATTERNS) {
const glob = new Glob(pattern)
for await (const match of glob.scan({ cwd: projectRoot, onlyFiles: true })) {
seen.add(match)
}
}
return [...seen].sort()
}
async function main(): Promise<void> {
const files = await resolveFiles()
if (files.length === 0) {
console.error("run-bun-tests: no test files matched the include patterns")
process.exit(1)
}
const passthrough = process.argv.slice(2)
if (passthrough.includes("--list")) {
console.log(files.join("\n"))
return
}
const args = ["test", "--parallel", ...passthrough.filter((arg) => arg !== "--list"), ...files]
const child = spawn("bun", args, { cwd: projectRoot, stdio: "inherit" })
child.on("exit", (code, signal) => {
if (signal) {
process.kill(process.pid, signal)
return
}
process.exit(code ?? 1)
})
}
void main()
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env bun
import path from "node:path"
/**
* Runner for the node-side `bun test` unit suites.
*
* A test file belongs to this runner iff it imports from "bun:test" (files that
* need the real VS Code extension host import from "mocha" and run under
* @vscode/test-cli instead). We glob all `*.test.ts` and keep only the
* bun:test ones; the SDK/model-catalog suites listed in IGNORED run through
* `run-bun-tests.ts`, so they're skipped here to avoid double-running.
*
* Why one process per file: `bun test --parallel <allFiles>` reuses a pool of
* worker processes, and `mock.module(...)` registrations accumulate across files
* sharing a worker. Suites that mock the same specifier with different shapes
* (e.g. `@core/storage/disk`, `@cline/core`, `fs/promises`, `os`) then clobber
* each other and fail only at scale. Spawning one `bun test` process per file
* (bounded by a small concurrency pool, via `Bun.spawn`) gives each file a fresh
* module registry.
*
* Usage:
* bun scripts/run-bun-unit-tests.ts # run the suite, isolated
* bun scripts/run-bun-unit-tests.ts --list # print the resolved file list
* bun scripts/run-bun-unit-tests.ts --all # include ELECTRON_HOST_ONLY
* bun scripts/run-bun-unit-tests.ts -c 6 # concurrency (default 4)
*/
import { Glob } from "bun"
const projectRoot = path.resolve(import.meta.dir, "..")
// A file runs under `bun test` iff it imports "bun:test"; mocha-owned files are
// skipped by the import filter in resolveFiles().
const INCLUDE_PATTERNS = ["src/**/*.test.ts"]
const BUN_TEST_IMPORT = /from\s+["']bun:test["']/
// SDK + model-catalog suites run through `run-bun-tests.ts`; skip them here so
// they aren't run twice.
const IGNORED = new Set<string>([
"src/core/controller/models/__tests__/providerCatalogHandlers.test.ts",
"src/core/controller/models/__tests__/providerCatalogSmoke.test.ts",
"src/core/controller/models/__tests__/providerSwitchNormalization.test.ts",
"src/core/controller/models/__tests__/resolveModelInfo.test.ts",
"src/core/controller/models/__tests__/refreshClineRecommendedModels.test.ts",
])
// Files that require the real VSCode Electron host (@vscode/test-cli). Excluded
// by default; they continue to run under @vscode/test-cli.
const ELECTRON_HOST_ONLY = new Set<string>([])
async function resolveFiles(includeHostOnly: boolean): Promise<string[]> {
const seen = new Set<string>()
for (const pattern of INCLUDE_PATTERNS) {
const glob = new Glob(pattern)
for await (const match of glob.scan({ cwd: projectRoot, onlyFiles: true })) {
const normalized = match.split(path.sep).join("/")
if (IGNORED.has(normalized)) {
continue
}
if (!includeHostOnly && ELECTRON_HOST_ONLY.has(normalized)) {
continue
}
// Only bun-runner-owned files (those importing "bun:test"). Files still on
// the @vscode/test-cli Electron host import from "mocha" and are skipped.
const source = await Bun.file(path.join(projectRoot, normalized)).text()
if (!BUN_TEST_IMPORT.test(source)) {
continue
}
seen.add(normalized)
}
}
return [...seen].sort()
}
type FileResult = {
file: string
code: number
pass: number
fail: number
output: string
}
// `bun test` prints its summary as e.g. " 12 pass\n 0 fail".
function parseCounts(output: string): { pass: number; fail: number } {
let pass = 0
let fail = 0
for (const m of output.matchAll(/^\s*(\d+)\s+pass\b/gm)) {
pass += Number(m[1])
}
for (const m of output.matchAll(/^\s*(\d+)\s+fail\b/gm)) {
fail += Number(m[1])
}
return { pass, fail }
}
const PER_FILE_TIMEOUT_MS = 120_000
async function runOne(file: string): Promise<FileResult> {
const proc = Bun.spawn(["bun", "test", file], {
cwd: projectRoot,
stdout: "pipe",
stderr: "pipe",
env: { ...process.env, FORCE_COLOR: "0" },
})
// Guard against a single hung file stalling the whole pool: kill it after a
// generous per-file budget and surface it as a failure.
let timedOut = false
const timer = setTimeout(() => {
timedOut = true
proc.kill()
}, PER_FILE_TIMEOUT_MS)
const [stdout, stderr, code] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
])
clearTimeout(timer)
const output = stdout + stderr + (timedOut ? `\n[runner] TIMEOUT after ${PER_FILE_TIMEOUT_MS}ms — killed\n` : "")
const { pass, fail } = parseCounts(output)
// A nonzero exit with no parsed counts (load/parse error, timeout) must count
// as a failure so the gate cannot pass silently.
const effectiveFail = timedOut && fail === 0 ? Math.max(fail, 1) : fail
return { file, code, pass, fail: effectiveFail, output }
}
async function runPool(files: string[], concurrency: number): Promise<FileResult[]> {
const results: FileResult[] = []
let next = 0
const launch = async (): Promise<void> => {
while (next < files.length) {
const file = files[next++]
const result = await runOne(file)
results.push(result)
const failed = result.fail > 0 || result.code !== 0
const status = failed ? "FAIL" : "ok"
const counts = `${result.pass} pass / ${result.fail} fail`
process.stdout.write(`[${results.length}/${files.length}] ${status.padEnd(4)} ${counts.padEnd(20)} ${file}\n`)
if (failed) {
process.stdout.write(result.output.trimEnd() + "\n")
}
}
}
const workers: Promise<void>[] = []
for (let i = 0; i < Math.min(concurrency, files.length); i++) {
workers.push(launch())
}
await Promise.all(workers)
return results
}
function parseConcurrency(argv: string[]): number {
const flagIdx = argv.findIndex((a) => a === "-c" || a === "--concurrency")
if (flagIdx !== -1 && argv[flagIdx + 1]) {
const n = Number(argv[flagIdx + 1])
if (Number.isFinite(n) && n > 0) {
return Math.floor(n)
}
}
return 4
}
async function main(): Promise<void> {
const passthrough = process.argv.slice(2)
const includeHostOnly = passthrough.includes("--all")
const files = await resolveFiles(includeHostOnly)
if (files.length === 0) {
console.error("run-bun-unit-tests: no test files matched")
process.exit(1)
}
if (passthrough.includes("--list")) {
console.log(files.join("\n"))
return
}
const concurrency = parseConcurrency(passthrough)
const started = Date.now()
console.log(`Running ${files.length} unit test files, isolated (concurrency ${concurrency})…\n`)
const results = await runPool(files, concurrency)
const totalPass = results.reduce((sum, r) => sum + r.pass, 0)
const totalFail = results.reduce((sum, r) => sum + r.fail, 0)
const failedFiles = results.filter((r) => r.fail > 0 || r.code !== 0).sort((a, b) => a.file.localeCompare(b.file))
const elapsed = ((Date.now() - started) / 1000).toFixed(1)
console.log("\n──────────────────────────────────────────────")
console.log(`Files: ${results.length} Pass: ${totalPass} Fail: ${totalFail} Time: ${elapsed}s`)
if (failedFiles.length > 0) {
console.log(`\nFailing files (${failedFiles.length}):`)
for (const r of failedFiles) {
console.log(` ${r.file} (${r.pass} pass / ${r.fail} fail, exit ${r.code})`)
}
process.exit(1)
}
console.log("All unit test files passed.")
}
void main()
@@ -126,7 +126,7 @@ function stopServer(server: ChildProcess): Promise<void> {
function runTestingPlatform(specFile: string, grpcPort: string): Promise<void> {
return new Promise((resolve, reject) => {
const testProcess = spawn("npx", ["ts-node", "index.ts", specFile, ...(fix ? ["--fix"] : [])], {
const testProcess = spawn("bun", ["index.ts", specFile, ...(fix ? ["--fix"] : [])], {
cwd: path.join(process.cwd(), "testing-platform"),
stdio: "inherit",
env: {
+17 -4
View File
@@ -1,9 +1,21 @@
import { afterEach, beforeEach, describe, it } from "mocha"
import { afterEach, beforeEach, describe, it, mock } from "bun:test"
import "should"
import fs from "fs/promises"
import os from "os"
import * as actualOs from "os"
import path from "path"
import sinon from "sinon"
// The SUT does `import * as os from "os"; os.homedir()`. Under bun, sinon's
// `stub(os, "homedir")` on the test's own `os` binding does NOT propagate to the
// SUT's namespace import, so inject a module-level homedir stub via mock.module
// (the rest of `os` — tmpdir() etc. — keeps its real behavior).
const homedirStub = sinon.stub()
const osMockNamespace = { ...actualOs, homedir: homedirStub }
const osMock = () => ({ ...osMockNamespace, default: osMockNamespace })
mock.module("os", osMock)
mock.module("node:os", osMock)
import os from "os"
import { ClineConfigurationError, ClineEndpoint, ClineEnv, Environment } from "../config"
describe("ClineEndpoint configuration", () => {
@@ -19,9 +31,10 @@ describe("ClineEndpoint configuration", () => {
// Create .cline directory
await fs.mkdir(path.join(tempDir, ".cline"), { recursive: true })
// Stub os.homedir to return our temp directory
// Stub os.homedir to return our temp directory (via mock.module homedirStub)
originalHomedir = os.homedir
sandbox.stub(os, "homedir").returns(tempDir)
homedirStub.reset()
homedirStub.returns(tempDir)
// Reset the singleton state using internal method
;(ClineEndpoint as any)._instance = null
@@ -1,13 +1,32 @@
import * as diskModule from "@core/storage/disk"
import { afterEach, beforeEach, describe, it, mock } from "bun:test"
import * as actualDiskModule from "@core/storage/disk"
import { expect } from "chai"
import chokidar from "chokidar"
import { afterEach, beforeEach, describe, it } from "mocha"
import * as actualChokidar from "chokidar"
import * as path from "path"
import * as sinon from "sinon"
import * as vscode from "vscode"
import { Controller } from "@/core/controller"
import { setVscodeHostProviderMock } from "@/test/host-provider-test-utils"
import type { FileMetadataEntry, TaskMetadata } from "./ContextTrackerTypes"
// bun loads real ESM, so sinon cannot stub the `@core/storage/disk` and
// `chokidar` namespace exports ("ES Modules cannot be stubbed"). Inject
// module-level sinon stubs via mock.module so the full sinon stub API keeps
// working. (`vscode` is the writable unit-test stub, still sinon-stubbed below.)
const getTaskMetadataStub: sinon.SinonStub = sinon.stub()
const saveTaskMetadataStub: sinon.SinonStub = sinon.stub()
const chokidarWatchStub: sinon.SinonStub = sinon.stub()
const diskMock = () => ({
...actualDiskModule,
getTaskMetadata: getTaskMetadataStub,
saveTaskMetadata: saveTaskMetadataStub,
})
const chokidarNamespace = { ...actualChokidar, watch: chokidarWatchStub }
const chokidarMock = () => ({ ...chokidarNamespace, default: chokidarNamespace })
mock.module("@core/storage/disk", diskMock)
mock.module("@/core/storage/disk", diskMock)
mock.module("chokidar", chokidarMock)
import { FileContextTracker } from "./FileContextTracker"
describe("FileContextTracker", () => {
@@ -17,11 +36,8 @@ describe("FileContextTracker", () => {
let sandbox: sinon.SinonSandbox
let _mockWorkspace: sinon.SinonStub
let mockFileSystemWatcher: any
let chokidarWatchStub: sinon.SinonStub
let tracker: FileContextTracker
let mockTaskMetadata: TaskMetadata
let getTaskMetadataStub: sinon.SinonStub
let saveTaskMetadataStub: sinon.SinonStub
beforeEach(() => {
sandbox = sinon.createSandbox()
@@ -43,13 +59,16 @@ describe("FileContextTracker", () => {
// Return the watcher itself for chaining
mockFileSystemWatcher.on.returns(mockFileSystemWatcher)
// Stub chokidar.watch to return our mock watcher
chokidarWatchStub = sandbox.stub(chokidar, "watch").returns(mockFileSystemWatcher as any)
// Reset the module-level chokidar.watch stub to return our mock watcher
chokidarWatchStub.reset()
chokidarWatchStub.returns(mockFileSystemWatcher as any)
// Mock disk module functions
// Reset the module-level disk stubs
mockTaskMetadata = { files_in_context: [], model_usage: [], environment_history: [] }
getTaskMetadataStub = sandbox.stub(diskModule, "getTaskMetadata").resolves(mockTaskMetadata)
saveTaskMetadataStub = sandbox.stub(diskModule, "saveTaskMetadata").resolves()
getTaskMetadataStub.reset()
getTaskMetadataStub.resolves(mockTaskMetadata)
saveTaskMetadataStub.reset()
saveTaskMetadataStub.resolves()
setVscodeHostProviderMock()
@@ -1,3 +1,4 @@
import { describe, it } from "bun:test"
import { expect } from "chai"
import { parseYamlFrontmatter } from "../frontmatter"
@@ -1,3 +1,4 @@
import { describe, it } from "bun:test"
import { expect } from "chai"
import { evaluateRuleConditionals, extractPathLikeStrings } from "../rule-conditionals"
@@ -1,3 +1,4 @@
import { describe, it } from "bun:test"
import { expect } from "chai"
import fs from "fs/promises"
import os from "os"
@@ -3,15 +3,57 @@
* Tests skill discovery, override resolution, toggle filtering, and content loading
*/
import { afterEach, beforeEach, describe, it, mock } from "bun:test"
import { expect } from "chai"
import * as fs from "fs"
import { afterEach, beforeEach, describe, it } from "mocha"
import * as actualFsPromises from "fs/promises"
import * as path from "path"
import * as sinon from "sinon"
import * as skillDirectories from "@/core/storage/skill-directories"
import * as actualSkillDirectories from "@/core/storage/skill-directories"
import { Logger } from "@/shared/services/Logger"
import * as fsUtils from "@/utils/fs"
import * as actualFsUtils from "@/utils/fs"
// bun loads real ESM, so sinon cannot stub the `@utils/fs`,
// `@core/storage/skill-directories`, or the `fs/promises` namespace exports
// ("ES Modules cannot be stubbed"). Crucially, under bun `fs.promises` and the
// `fs/promises` module are NOT the same object, so stubbing `fs.promises.X`
// (which worked under mocha/ts-node) does not affect the SUT's
// `import * as fs from "fs/promises"` bindings. Inject module-level sinon stubs
// via bun's mock.module so the full sinon stub API (.withArgs/.resolves/etc.)
// keeps working through the exact specifiers the SUT imports.
const fileExistsAtPathStub = sinon.stub()
const isDirectoryStub_ = sinon.stub()
const getSkillsDirectoriesForScanStub = sinon.stub()
const readdirStub_ = sinon.stub()
const statStub_ = sinon.stub()
const readFileStub_ = sinon.stub()
const writeFileStub_ = sinon.stub()
const fsUtilsMock = () => ({
...actualFsUtils,
fileExistsAtPath: fileExistsAtPathStub,
isDirectory: isDirectoryStub_,
})
const skillDirsMock = () => ({
...actualSkillDirectories,
getSkillsDirectoriesForScan: getSkillsDirectoriesForScanStub,
})
const fsPromisesMockNamespace = {
...actualFsPromises,
readdir: readdirStub_,
stat: statStub_,
readFile: readFileStub_,
writeFile: writeFileStub_,
}
const fsPromisesMock = () => ({ ...fsPromisesMockNamespace, default: fsPromisesMockNamespace })
// The SUT imports via the `@utils/*` and `@core/*` tsconfig path aliases;
// register both the `@/`-prefixed and bare-alias forms to be safe.
mock.module("@utils/fs", fsUtilsMock)
mock.module("@/utils/fs", fsUtilsMock)
mock.module("@core/storage/skill-directories", skillDirsMock)
mock.module("@/core/storage/skill-directories", skillDirsMock)
mock.module("fs/promises", fsPromisesMock)
mock.module("node:fs/promises", fsPromisesMock)
import { parseYamlFrontmatter } from "../frontmatter"
import {
discoverSkills,
@@ -40,13 +82,22 @@ describe("Skills Utility Functions", () => {
// Stub Logger.warn to avoid noise in test output
sandbox.stub(Logger, "warn")
// Stub filesystem utilities
fileExistsStub = sandbox.stub(fsUtils, "fileExistsAtPath")
isDirectoryStub = sandbox.stub(fsUtils, "isDirectory")
readdirStub = sandbox.stub(fs.promises, "readdir")
statStub = sandbox.stub(fs.promises, "stat")
readFileStub = sandbox.stub(fs.promises, "readFile")
sandbox.stub(skillDirectories, "getSkillsDirectoriesForScan").returns([
// Reset the module-level sinon stubs (injected via mock.module above) and
// re-point the per-test handles at them.
fileExistsAtPathStub.reset()
isDirectoryStub_.reset()
getSkillsDirectoriesForScanStub.reset()
readdirStub_.reset()
statStub_.reset()
readFileStub_.reset()
writeFileStub_.reset()
fileExistsStub = fileExistsAtPathStub
isDirectoryStub = isDirectoryStub_
readdirStub = readdirStub_
statStub = statStub_
readFileStub = readFileStub_
getSkillsDirectoriesForScanStub.returns([
{ path: path.join(TEST_CWD, ".clinerules", "skills"), source: "project" },
{ path: path.join(TEST_CWD, ".cline", "skills"), source: "project" },
{ path: path.join(TEST_CWD, ".claude", "skills"), source: "project" },
@@ -743,8 +794,13 @@ describe("setSkillDisabledInFrontmatter", () => {
beforeEach(() => {
sandbox = sinon.createSandbox()
sandbox.stub(Logger, "warn")
readFileStub = sandbox.stub(fs.promises, "readFile")
writeFileStub = sandbox.stub(fs.promises, "writeFile").resolves()
// Use the module-level fs/promises stubs (mock.module above) since under
// bun fs.promises !== the `fs/promises` module the SUT imports.
readFileStub_.reset()
writeFileStub_.reset()
readFileStub = readFileStub_
writeFileStub = writeFileStub_
writeFileStub.resolves()
})
afterEach(() => sandbox.restore())
@@ -1,28 +1,33 @@
import { afterEach, beforeEach, describe, it, mock } from "bun:test"
import { Controller } from "@core/controller"
import { BooleanResponse, StringRequest } from "@shared/proto/cline/common"
import * as pathUtils from "@utils/path"
import * as actualPathUtils from "@utils/path"
import { expect } from "chai"
import { afterEach, beforeEach, describe, it } from "mocha"
import * as sinon from "sinon"
// bun loads real ESM, so sinon cannot stub the `@utils/path` namespace export
// ("ES Modules cannot be stubbed"). Inject a module-level sinon stub via
// mock.module so the full sinon stub API keeps working.
const getWorkspacePathStub: sinon.SinonStub = sinon.stub()
const pathUtilsMock = () => ({ ...actualPathUtils, getWorkspacePath: getWorkspacePathStub })
mock.module("@utils/path", pathUtilsMock)
mock.module("@/utils/path", pathUtilsMock)
import { ifFileExistsRelativePath } from "../ifFileExistsRelativePath"
describe("ifFileExistsRelativePath", () => {
let sandbox: sinon.SinonSandbox
let mockController: Controller
let getWorkspacePathStub: sinon.SinonStub
beforeEach(() => {
sandbox = sinon.createSandbox()
// Create a mock controller
mockController = {} as any
// Stub getWorkspacePath utility
getWorkspacePathStub = sandbox.stub(pathUtils, "getWorkspacePath")
// Reset the module-level getWorkspacePath stub
getWorkspacePathStub.reset()
})
afterEach(() => {
sandbox.restore()
getWorkspacePathStub.reset()
})
it("should return BooleanResponse with boolean value", async () => {
@@ -1,19 +1,31 @@
import { afterEach, beforeEach, describe, it, mock } from "bun:test"
import { Controller } from "@core/controller"
import * as openFileIntegration from "@integrations/misc/open-file"
import * as actualOpenFileIntegration from "@integrations/misc/open-file"
import { Empty, StringRequest } from "@shared/proto/cline/common"
import * as pathUtils from "@utils/path"
import * as actualPathUtils from "@utils/path"
import { expect } from "chai"
import { afterEach, beforeEach, describe, it } from "mocha"
import * as path from "path"
import * as sinon from "sinon"
import { Logger } from "@/shared/services/Logger"
// bun loads real ESM, so sinon cannot stub the `@integrations/misc/open-file`
// and `@utils/path` namespace exports ("ES Modules cannot be stubbed"). Inject
// module-level sinon stubs via mock.module so the full sinon stub API keeps
// working. (`Logger` is a class with static methods and is still sinon-stubbed
// directly below.)
const openFileIntegrationStub: sinon.SinonStub = sinon.stub()
const getWorkspacePathStub: sinon.SinonStub = sinon.stub()
const openFileMock = () => ({ ...actualOpenFileIntegration, openFile: openFileIntegrationStub })
const pathUtilsMock = () => ({ ...actualPathUtils, getWorkspacePath: getWorkspacePathStub })
mock.module("@integrations/misc/open-file", openFileMock)
mock.module("@utils/path", pathUtilsMock)
mock.module("@/utils/path", pathUtilsMock)
import { openFileRelativePath } from "../openFileRelativePath"
describe("openFileRelativePath", () => {
let sandbox: sinon.SinonSandbox
let mockController: Controller
let openFileIntegrationStub: sinon.SinonStub
let getWorkspacePathStub: sinon.SinonStub
let consoleErrorStub: sinon.SinonStub
beforeEach(() => {
@@ -22,11 +34,9 @@ describe("openFileRelativePath", () => {
// Create a mock controller
mockController = {} as any
// Stub the openFileIntegration function
openFileIntegrationStub = sandbox.stub(openFileIntegration, "openFile")
// Stub getWorkspacePath utility
getWorkspacePathStub = sandbox.stub(pathUtils, "getWorkspacePath")
// Reset the module-level sinon stubs (injected via mock.module above)
openFileIntegrationStub.reset()
getWorkspacePathStub.reset()
// Stub console.error to prevent test output pollution
consoleErrorStub = sandbox.stub(Logger, "error")
@@ -34,6 +44,8 @@ describe("openFileRelativePath", () => {
afterEach(() => {
sandbox.restore()
openFileIntegrationStub.reset()
getWorkspacePathStub.reset()
})
it("should return Empty response on successful execution", async () => {
@@ -1,8 +1,8 @@
import { afterEach, beforeEach, describe, it } from "mocha"
import { Controller } from "@core/controller"
import { serviceHandlers } from "@generated/hosts/vscode/protobus-services"
import { GrpcCancel, GrpcRequest } from "@shared/WebviewMessage"
import { expect } from "chai"
import { afterEach, beforeEach, describe, it } from "mocha"
import * as sinon from "sinon"
import { getRequestRegistry, handleGrpcRequest, handleGrpcRequestCancel } from "./grpc-handler"
@@ -1,4 +1,4 @@
import { describe, it } from "mocha"
import { describe, it } from "bun:test"
import "should"
import { GrpcRecorderNoops } from "@/core/controller/grpc-recorder/grpc-recorder"
import { GrpcRecorderBuilder } from "@/core/controller/grpc-recorder/grpc-recorder.builder"
@@ -1,3 +1,4 @@
import { beforeAll, describe, it } from "bun:test"
import { GrpcRecorder, IRecorder } from "@core/controller/grpc-recorder/grpc-recorder"
import { expect } from "chai"
import { ExtensionMessage } from "@/shared/ExtensionMessage"
@@ -6,7 +7,7 @@ import { GrpcRequest } from "@/shared/WebviewMessage"
describe("grpc-recorder", () => {
let recorder: IRecorder
before(async () => {
beforeAll(async () => {
recorder = GrpcRecorder.builder()
.withFilters((req: GrpcRequest) => req.service === "the-unwanted-service")
.enableIf(true)
@@ -1,11 +1,11 @@
import { beforeAll, describe, it } from "bun:test"
import { expect } from "chai"
import { before, describe, it } from "mocha"
import { LogFileHandler } from "@/core/controller/grpc-recorder/log-file-handler"
describe("log-file-handler", () => {
let logHandler: LogFileHandler
before(async () => {
beforeAll(async () => {
logHandler = new LogFileHandler()
expect(logHandler.getFilePath()).not.empty
})
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, it } from "mocha"
import { afterEach, beforeEach, describe, it } from "bun:test"
import "should"
import { Controller } from "@core/controller"
import { IRecorder } from "@core/controller/grpc-recorder/grpc-recorder"
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, it } from "mocha"
import { afterEach, beforeEach, describe, it } from "bun:test"
import "should"
import fs from "fs/promises"
import path from "path"
@@ -19,9 +19,8 @@ describe("Hook System", () => {
await writeHookScriptForPlatform(hookPath, nodeScript)
}
beforeEach(async function () {
beforeEach(async () => {
if (process.platform === "win32") {
this.timeout(WINDOWS_TEST_TIMEOUT_MS)
}
setDistinctId("test-id")
hookTestEnv = await createHookTestEnv()
@@ -52,14 +51,15 @@ describe("Hook System", () => {
})
describe("StdioHookRunner", () => {
it("should execute workspace hook from its respective workspace root directory", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
it(
"should execute workspace hook from its respective workspace root directory",
async () => {
if (process.platform === "win32") {
}
// Create a test hook script that outputs the current working directory
const hookPath = path.join(tempDir, ".clinerules", "hooks", "PreToolUse")
const hookScript = `#!/usr/bin/env node
// Create a test hook script that outputs the current working directory
const hookPath = path.join(tempDir, ".clinerules", "hooks", "PreToolUse")
const hookScript = `#!/usr/bin/env node
const input = require('fs').readFileSync(0, 'utf-8');
// Output the current working directory
console.log(JSON.stringify({
@@ -67,28 +67,30 @@ console.log(JSON.stringify({
contextModification: "CWD: " + process.cwd()
}))`
await writeHookScript(hookPath, hookScript)
await writeHookScript(hookPath, hookScript)
// Test execution
const factory = new HookFactory()
const runner = await factory.create("PreToolUse")
// Test execution
const factory = new HookFactory()
const runner = await factory.create("PreToolUse")
const result = await runner.run({
taskId: "test-task",
preToolUse: {
toolName: "test_tool",
parameters: {},
},
})
const result = await runner.run({
taskId: "test-task",
preToolUse: {
toolName: "test_tool",
parameters: {},
},
})
result.cancel.should.be.false()
// The hook should execute from its workspace root (tempDir)
// Use fs.realpath to normalize paths (handles macOS /private prefix)
const cwdFromHook = result.contextModification?.replace("CWD: ", "")
const normalizedCwd = await fs.realpath(cwdFromHook)
const normalizedTempDir = await fs.realpath(tempDir)
normalizedCwd.should.equal(normalizedTempDir)
})
result.cancel.should.be.false()
// The hook should execute from its workspace root (tempDir)
// Use fs.realpath to normalize paths (handles macOS /private prefix)
const cwdFromHook = result.contextModification?.replace("CWD: ", "")
const normalizedCwd = await fs.realpath(cwdFromHook)
const normalizedTempDir = await fs.realpath(tempDir)
normalizedCwd.should.equal(normalizedTempDir)
},
WINDOWS_HOOK_TEST_TIMEOUT_MS,
)
it("should execute hook script and parse output", async () => {
// Create a test hook script
@@ -617,42 +619,45 @@ console.log(JSON.stringify({
result.errorMessage?.should.match(/Workspace error/)
})
it("should execute global hook from primary workspace root directory", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
it(
"should execute global hook from primary workspace root directory",
async () => {
if (process.platform === "win32") {
}
// Create a global hook script that outputs the current working directory
const globalHookPath = path.join(globalHooksDir, "PreToolUse")
const globalHookScript = `#!/usr/bin/env node
// Create a global hook script that outputs the current working directory
const globalHookPath = path.join(globalHooksDir, "PreToolUse")
const globalHookScript = `#!/usr/bin/env node
const input = require('fs').readFileSync(0, 'utf-8');
// Output the current working directory
console.log(JSON.stringify({
cancel: false,
contextModification: "CWD: " + process.cwd()
}))`
await writeHookScript(globalHookPath, globalHookScript)
await writeHookScript(globalHookPath, globalHookScript)
// Test execution
const factory = new HookFactory()
const runner = await factory.create("PreToolUse")
// Test execution
const factory = new HookFactory()
const runner = await factory.create("PreToolUse")
const result = await runner.run({
taskId: "test-task",
preToolUse: {
toolName: "test_tool",
parameters: {},
},
})
const result = await runner.run({
taskId: "test-task",
preToolUse: {
toolName: "test_tool",
parameters: {},
},
})
result.cancel.should.be.false()
// Global hooks should execute from the primary workspace root (tempDir)
// Use fs.realpath to normalize paths (handles macOS /private prefix)
const cwdFromHook = result.contextModification?.replace("CWD: ", "")
const normalizedCwd = await fs.realpath(cwdFromHook)
const normalizedTempDir = await fs.realpath(tempDir)
normalizedCwd.should.equal(normalizedTempDir)
})
result.cancel.should.be.false()
// Global hooks should execute from the primary workspace root (tempDir)
// Use fs.realpath to normalize paths (handles macOS /private prefix)
const cwdFromHook = result.contextModification?.replace("CWD: ", "")
const normalizedCwd = await fs.realpath(cwdFromHook)
const normalizedTempDir = await fs.realpath(tempDir)
normalizedCwd.should.equal(normalizedTempDir)
},
WINDOWS_HOOK_TEST_TIMEOUT_MS,
)
it("should work with global PostToolUse hooks", async () => {
// Create global PostToolUse hook
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, it } from "mocha"
import { afterEach, beforeEach, describe, it } from "bun:test"
import "should"
import { getHookLaunchConfig, resetHookLaunchConfigCacheForTesting } from "../HookProcess"
import { withPlatform } from "./test-utils"
@@ -1,4 +1,4 @@
import { describe, it } from "mocha"
import { describe, it } from "bun:test"
import "should"
import { getHooksEnabledSafe } from "../hooks-utils"
import { withPlatform } from "./test-utils"
@@ -1,3 +1,4 @@
import { afterEach, beforeEach } from "bun:test"
import * as fs from "fs/promises"
import * as os from "os"
import * as path from "path"
@@ -1,4 +1,4 @@
import { describe, it } from "mocha"
import { afterAll, beforeAll, describe, it } from "bun:test"
import "should"
import { escapeShellPath } from "../shell-escape"
@@ -15,7 +15,7 @@ describe("Shell Path Escaping", () => {
}
// Restore platform after tests
after(() => {
afterAll(() => {
Object.defineProperty(process, "platform", {
value: originalPlatform,
writable: true,
@@ -24,7 +24,7 @@ describe("Shell Path Escaping", () => {
})
describe("Unix/Linux/macOS path escaping", () => {
before(() => {
beforeAll(() => {
setPlatform("darwin") // macOS, but same escaping as Linux
})
@@ -131,7 +131,7 @@ describe("Shell Path Escaping", () => {
})
describe("Windows path escaping", () => {
before(() => {
beforeAll(() => {
setPlatform("win32")
})
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, it } from "mocha"
import { afterEach, beforeEach, describe, it } from "bun:test"
import "should"
import fs from "fs/promises"
import path from "path"
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, it } from "mocha"
import { afterEach, beforeEach, describe, it } from "bun:test"
import "should"
import fs from "fs/promises"
import path from "path"
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, it } from "mocha"
import { afterEach, beforeEach, describe, it } from "bun:test"
import "should"
import fs from "fs/promises"
import path from "path"
@@ -111,13 +111,14 @@ console.log(JSON.stringify({
})
describe("Time-Based Calculations", () => {
it("should correctly calculate minutes ago for recent resumes", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
it(
"should correctly calculate minutes ago for recent resumes",
async () => {
if (process.platform === "win32") {
}
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskResume")
const hookScript = `#!/usr/bin/env node
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskResume")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const lastTs = parseInt(input.taskResume.previousState.lastMessageTs);
const now = Date.now();
@@ -127,43 +128,46 @@ console.log(JSON.stringify({
contextModification: "Minutes ago: " + minutesAgo
}))`
await writeHookScript(hookPath, hookScript)
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskResume")
const factory = new HookFactory()
const runner = await factory.create("TaskResume")
// Test various time intervals
const testCases = [
{ offset: 2 * 60 * 1000, expected: 2 }, // 2 minutes
{ offset: 30 * 60 * 1000, expected: 30 }, // 30 minutes
{ offset: 90 * 60 * 1000, expected: 90 }, // 90 minutes
]
// Test various time intervals
const testCases = [
{ offset: 2 * 60 * 1000, expected: 2 }, // 2 minutes
{ offset: 30 * 60 * 1000, expected: 30 }, // 30 minutes
{ offset: 90 * 60 * 1000, expected: 90 }, // 90 minutes
]
for (const { offset, expected } of testCases) {
const timestamp = Date.now() - offset
const result = await runner.run({
taskId: "test-task",
taskResume: {
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
previousState: {
lastMessageTs: timestamp.toString(),
messageCount: "5",
conversationHistoryDeleted: "false",
for (const { offset, expected } of testCases) {
const timestamp = Date.now() - offset
const result = await runner.run({
taskId: "test-task",
taskResume: {
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
previousState: {
lastMessageTs: timestamp.toString(),
messageCount: "5",
conversationHistoryDeleted: "false",
},
},
},
})
})
result.contextModification?.should.equal(`Minutes ago: ${expected}`)
}
})
result.contextModification?.should.equal(`Minutes ago: ${expected}`)
}
},
WINDOWS_HOOK_TEST_TIMEOUT_MS,
)
it("should handle very old timestamps (days ago)", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
it(
"should handle very old timestamps (days ago)",
async () => {
if (process.platform === "win32") {
}
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskResume")
const hookScript = `#!/usr/bin/env node
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskResume")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const lastTs = parseInt(input.taskResume.previousState.lastMessageTs);
const now = Date.now();
@@ -173,27 +177,29 @@ console.log(JSON.stringify({
contextModification: daysAgo > 0 ? "Days ago: " + daysAgo : "Recent"
}))`
await writeHookScript(hookPath, hookScript)
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskResume")
const factory = new HookFactory()
const runner = await factory.create("TaskResume")
// Test 7 days ago
const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000
const result = await runner.run({
taskId: "test-task",
taskResume: {
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
previousState: {
lastMessageTs: sevenDaysAgo.toString(),
messageCount: "5",
conversationHistoryDeleted: "false",
// Test 7 days ago
const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000
const result = await runner.run({
taskId: "test-task",
taskResume: {
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
previousState: {
lastMessageTs: sevenDaysAgo.toString(),
messageCount: "5",
conversationHistoryDeleted: "false",
},
},
},
})
})
result.contextModification?.should.equal("Days ago: 7")
})
result.contextModification?.should.equal("Days ago: 7")
},
WINDOWS_HOOK_TEST_TIMEOUT_MS,
)
it("should handle edge case: future timestamp", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskResume")
@@ -230,13 +236,14 @@ console.log(JSON.stringify({
})
describe("Message Count Analysis", () => {
it("should analyze message count thresholds", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
it(
"should analyze message count thresholds",
async () => {
if (process.platform === "win32") {
}
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskResume")
const hookScript = `#!/usr/bin/env node
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskResume")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const count = parseInt(input.taskResume.previousState.messageCount);
let category;
@@ -248,33 +255,35 @@ console.log(JSON.stringify({
contextModification: "Conversation length: " + category + " (" + count + " messages)"
}))`
await writeHookScript(hookPath, hookScript)
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskResume")
const factory = new HookFactory()
const runner = await factory.create("TaskResume")
const testCases = [
{ count: "2", expected: "short (2 messages)" },
{ count: "10", expected: "medium (10 messages)" },
{ count: "50", expected: "long (50 messages)" },
]
const testCases = [
{ count: "2", expected: "short (2 messages)" },
{ count: "10", expected: "medium (10 messages)" },
{ count: "50", expected: "long (50 messages)" },
]
for (const { count, expected } of testCases) {
const result = await runner.run({
taskId: "test-task",
taskResume: {
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
previousState: {
lastMessageTs: Date.now().toString(),
messageCount: count,
conversationHistoryDeleted: "false",
for (const { count, expected } of testCases) {
const result = await runner.run({
taskId: "test-task",
taskResume: {
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
previousState: {
lastMessageTs: Date.now().toString(),
messageCount: count,
conversationHistoryDeleted: "false",
},
},
},
})
})
result.contextModification?.should.equal(`Conversation length: ${expected}`)
}
})
result.contextModification?.should.equal(`Conversation length: ${expected}`)
}
},
WINDOWS_HOOK_TEST_TIMEOUT_MS,
)
it("should handle zero message count", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskResume")
@@ -552,94 +561,101 @@ console.log(JSON.stringify({
})
describe("Fixture-Based Tests", () => {
it("should validate representative fixtures end-to-end", async function () {
// Multiple fixture scenarios spawn child processes sequentially,
// which can easily exceed the default 2 s Mocha timeout.
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
const scenarios: FixtureScenario[] = [
{
fixtureName: "success",
lastMessageTs: Date.now().toString(),
messageCount: "5",
conversationHistoryDeleted: "false",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal("TaskResume hook executed successfully")
},
},
{
fixtureName: "recent-resume",
lastMessageTs: (Date.now() - 2 * 60 * 1000).toString(),
messageCount: "5",
conversationHistoryDeleted: "false",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.match(/Recently paused task/)
},
},
{
fixtureName: "long-pause",
lastMessageTs: (Date.now() - 48 * 60 * 60 * 1000).toString(),
messageCount: "5",
conversationHistoryDeleted: "false",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.match(/paused 48 hours ago/)
},
},
{
fixtureName: "context-deleted",
lastMessageTs: Date.now().toString(),
messageCount: "50",
conversationHistoryDeleted: "true",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.match(/truncated/)
},
},
{
fixtureName: "message-count",
lastMessageTs: Date.now().toString(),
messageCount: "25",
conversationHistoryDeleted: "false",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal("TASK_CONTEXT: Resuming task with 25 previous messages")
},
},
{
fixtureName: "context-injection",
lastMessageTs: Date.now().toString(),
messageCount: "5",
conversationHistoryDeleted: "false",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal(
"WORKSPACE_RULES: Task test-task resumed - review previous context",
)
},
},
]
for (const scenario of scenarios) {
await withFixtureRunner("TaskResume", `hooks/taskresume/${scenario.fixtureName}`, hookTestEnv, async (runner) => {
const result = await runner.run({
taskId: "test-task",
taskResume: {
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
previousState: {
lastMessageTs: scenario.lastMessageTs,
messageCount: scenario.messageCount,
conversationHistoryDeleted: scenario.conversationHistoryDeleted,
},
it(
"should validate representative fixtures end-to-end",
async () => {
// Multiple fixture scenarios spawn child processes sequentially,
// which can easily exceed the default 2 s Mocha timeout.
const scenarios: FixtureScenario[] = [
{
fixtureName: "success",
lastMessageTs: Date.now().toString(),
messageCount: "5",
conversationHistoryDeleted: "false",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal("TaskResume hook executed successfully")
},
})
},
{
fixtureName: "recent-resume",
lastMessageTs: (Date.now() - 2 * 60 * 1000).toString(),
messageCount: "5",
conversationHistoryDeleted: "false",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.match(/Recently paused task/)
},
},
{
fixtureName: "long-pause",
lastMessageTs: (Date.now() - 48 * 60 * 60 * 1000).toString(),
messageCount: "5",
conversationHistoryDeleted: "false",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.match(/paused 48 hours ago/)
},
},
{
fixtureName: "context-deleted",
lastMessageTs: Date.now().toString(),
messageCount: "50",
conversationHistoryDeleted: "true",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.match(/truncated/)
},
},
{
fixtureName: "message-count",
lastMessageTs: Date.now().toString(),
messageCount: "25",
conversationHistoryDeleted: "false",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal("TASK_CONTEXT: Resuming task with 25 previous messages")
},
},
{
fixtureName: "context-injection",
lastMessageTs: Date.now().toString(),
messageCount: "5",
conversationHistoryDeleted: "false",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal(
"WORKSPACE_RULES: Task test-task resumed - review previous context",
)
},
},
]
scenario.assert(result)
})
}
})
for (const scenario of scenarios) {
await withFixtureRunner(
"TaskResume",
`hooks/taskresume/${scenario.fixtureName}`,
hookTestEnv,
async (runner) => {
const result = await runner.run({
taskId: "test-task",
taskResume: {
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
previousState: {
lastMessageTs: scenario.lastMessageTs,
messageCount: scenario.messageCount,
conversationHistoryDeleted: scenario.conversationHistoryDeleted,
},
},
})
scenario.assert(result)
},
)
}
},
WINDOWS_HOOK_TEST_TIMEOUT_MS,
)
it("should preserve fixture-based failure behavior", async () => {
await withFixtureRunner("TaskResume", "hooks/taskresume/error", hookTestEnv, async (runner) => {
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, it } from "mocha"
import { afterEach, beforeEach, describe, it } from "bun:test"
import "should"
import fs from "fs/promises"
import path from "path"
@@ -1,9 +1,12 @@
import { spyOn } from "bun:test"
import * as fs from "fs/promises"
import * as os from "os"
import * as path from "path"
import should from "should"
import sinon from "sinon"
import { HostProvider } from "../../../hosts/host-provider"
import { HookOutput } from "../../../shared/proto/cline/hooks"
import { setVscodeHostProviderMock } from "../../../test/host-provider-test-utils"
import * as diskModule from "../../storage/disk"
import { StateManager } from "../../storage/StateManager"
import { HookDiscoveryCache } from "../HookDiscoveryCache"
@@ -60,17 +63,36 @@ export function hookPath(hooksDir: string, hookName: string, platform: NodeJS.Pl
return path.join(hooksDir, hookFileName(hookName, platform))
}
export function stubHookDirs(sandbox: sinon.SinonSandbox, dirs: string[]): sinon.SinonStub {
const existing = diskModule.getAllHooksDirs as unknown as sinon.SinonStub
if (existing && typeof existing.getCall === "function") {
existing.resolves(dirs)
return existing
}
// bun loads real ESM, so sinon cannot stub the `getAllHooksDirs` namespace
// export ("ES Modules cannot be stubbed"). Use bun's spyOn, which can replace
// ESM namespace bindings in place. The spy is tracked module-locally so the
// per-env cleanup can restore it (the sandbox arg is retained for call-site
// compatibility but is unused for this export).
let hooksDirsSpy: ReturnType<typeof spyOn> | undefined
return sandbox.stub(diskModule, "getAllHooksDirs").resolves(dirs)
export function stubHookDirs(_sandbox: sinon.SinonSandbox, dirs: string[]): ReturnType<typeof spyOn> {
if (!hooksDirsSpy) {
hooksDirsSpy = spyOn(diskModule, "getAllHooksDirs")
}
hooksDirsSpy.mockImplementation(async () => dirs)
return hooksDirsSpy
}
function restoreHookDirsSpy(): void {
hooksDirsSpy?.mockRestore()
hooksDirsSpy = undefined
}
export async function createHookTestEnv(): Promise<HookTestEnv> {
// Hook execution emits telemetry, which lazily constructs TelemetryService
// via HostProvider.env.getHostVersion(). Under mocha's single-process run an
// earlier suite left HostProvider initialized; bun's per-file isolation does
// not, so initialize it here (idempotent) to keep the telemetry path from
// throwing "HostProvider not setup".
if (!HostProvider.isInitialized()) {
setVscodeHostProviderMock()
}
const sandbox = sinon.createSandbox()
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hook-test-"))
const hooksDir = await createHooksDirectory(tempDir)
@@ -96,6 +118,7 @@ export async function createHookTestEnv(): Promise<HookTestEnv> {
sandbox,
cleanup: async () => {
sandbox.restore()
restoreHookDirsSpy()
resetHookCache()
await removeTempDirWithRetry(tempDir)
},
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, it } from "mocha"
import { afterEach, beforeEach, describe, it } from "bun:test"
import "should"
import fs from "fs/promises"
import path from "path"
@@ -36,9 +36,7 @@ describe("UserPromptSubmit Hook", () => {
})
describe("Hook Input Format", () => {
it("should receive prompt text from user content", async function () {
this.timeout(5000)
it("should receive prompt text from user content", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "UserPromptSubmit")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
@@ -63,7 +61,7 @@ console.log(JSON.stringify({
result.cancel.should.be.false()
result.contextModification?.should.equal("Received prompt")
})
}, 5000)
it("should handle multiline prompts", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "UserPromptSubmit")
@@ -370,92 +368,94 @@ console.log(JSON.stringify({
// Fixtures serve as both test data and examples for manual testing
const isWindows = process.platform === "win32"
it("should validate representative fixtures end-to-end", async function () {
// Multiple fixture scenarios spawn child processes sequentially,
// which can easily exceed the default 2 s Mocha timeout.
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
it(
"should validate representative fixtures end-to-end",
async () => {
// Multiple fixture scenarios spawn child processes sequentially,
// which can easily exceed the default 2 s Mocha timeout.
const scenarios: FixtureScenario[] = [
{
fixtureName: "success",
prompt: "Create a feature",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal("Prompt approved")
},
},
{
fixtureName: "blocking",
prompt: "Do something forbidden",
assert: (result: HookOutput) => {
result.cancel.should.be.true()
result.errorMessage?.should.equal("Prompt violates policy")
},
},
{
fixtureName: "context-injection",
prompt: "Build something",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal("CONTEXT_INJECTION: User is in plan mode")
},
},
{
fixtureName: "multiline",
prompt: "Line 1\nLine 2\nLine 3",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal("Line count: 3")
},
},
{
fixtureName: "special-chars",
prompt: "Test @user #feature $cost",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal("Special chars preserved")
},
},
{
fixtureName: "empty-prompt",
prompt: "",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal("Prompt length: 0")
},
},
]
const scenarios: FixtureScenario[] = [
{
fixtureName: "success",
prompt: "Create a feature",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal("Prompt approved")
},
},
{
fixtureName: "blocking",
prompt: "Do something forbidden",
assert: (result: HookOutput) => {
result.cancel.should.be.true()
result.errorMessage?.should.equal("Prompt violates policy")
},
},
{
fixtureName: "context-injection",
prompt: "Build something",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal("CONTEXT_INJECTION: User is in plan mode")
},
},
{
fixtureName: "multiline",
prompt: "Line 1\nLine 2\nLine 3",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal("Line count: 3")
},
},
{
fixtureName: "special-chars",
prompt: "Test @user #feature $cost",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal("Special chars preserved")
},
},
{
fixtureName: "empty-prompt",
prompt: "",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal("Prompt length: 0")
},
},
]
if (!isWindows) {
scenarios.push({
fixtureName: "large-prompt",
prompt: "x".repeat(10000),
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal("Prompt size: 10000")
},
})
}
if (!isWindows) {
scenarios.push({
fixtureName: "large-prompt",
prompt: "x".repeat(10000),
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal("Prompt size: 10000")
},
})
}
for (const scenario of scenarios) {
await withFixtureRunner(
"UserPromptSubmit",
`hooks/userpromptsubmit/${scenario.fixtureName}`,
hookTestEnv,
async (runner) => {
const result = await runner.run({
taskId: "test-task",
userPromptSubmit: {
prompt: scenario.prompt,
attachments: [],
},
})
for (const scenario of scenarios) {
await withFixtureRunner(
"UserPromptSubmit",
`hooks/userpromptsubmit/${scenario.fixtureName}`,
hookTestEnv,
async (runner) => {
const result = await runner.run({
taskId: "test-task",
userPromptSubmit: {
prompt: scenario.prompt,
attachments: [],
},
})
scenario.assert(result)
},
)
}
})
scenario.assert(result)
},
)
}
},
WINDOWS_HOOK_TEST_TIMEOUT_MS,
)
it("should cover malformed-json fixture path", async () => {
await withFixtureRunner("UserPromptSubmit", "hooks/userpromptsubmit/malformed-json", hookTestEnv, async (runner) => {
@@ -1,5 +1,5 @@
import fs from "fs/promises"
import { after, beforeEach, describe, it } from "mocha"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { ClineIgnoreController } from "./ClineIgnoreController"
@@ -1,3 +1,4 @@
import { afterEach, beforeEach, describe, it } from "mocha"
import { FileContextTracker } from "@core/context/context-tracking/FileContextTracker"
import * as extractTextModule from "@integrations/misc/extract-text"
import { UrlContentFetcher } from "@services/browser/UrlContentFetcher"
@@ -1,4 +1,4 @@
import { describe, it } from "mocha"
import { describe, it } from "bun:test"
import "should"
import { formatResponse } from "../responses"
@@ -1,4 +1,4 @@
import { describe, it } from "mocha"
import { describe, it } from "bun:test"
import "should"
import { formatResponse } from "../responses"
@@ -1,12 +1,24 @@
import { afterEach, beforeEach, describe, it } from "mocha"
import { afterAll, afterEach, beforeAll, beforeEach, describe, it, mock } from "bun:test"
import "should"
import * as fsUtils from "@utils/fs"
import * as actualFsUtils from "@utils/fs"
import fs from "fs/promises"
import os from "os"
import path from "path"
import sinon from "sinon"
import { HostProvider } from "@/hosts/host-provider"
import { setVscodeHostProviderMock } from "@/test/host-provider-test-utils"
// bun loads real ESM, so sinon cannot stub the `@utils/fs` namespace export
// ("ES Modules cannot be stubbed"). Inject a module-level sinon stub for
// `isDirectory` via mock.module so the full sinon stub API keeps working. It
// defaults to the real implementation; only the error-propagation test overrides
// it. Register both the alias form and the SUT's relative form.
const realIsDirectory = actualFsUtils.isDirectory
const isDirectoryStub: sinon.SinonStub = sinon.stub()
const fsUtilsMock = () => ({ ...actualFsUtils, isDirectory: isDirectoryStub })
mock.module("@utils/fs", fsUtilsMock)
mock.module("@/utils/fs", fsUtilsMock)
import { getAllHooksDirs, getWorkspaceHooksDirs, setRuntimeHooksDir } from "../disk"
import { StateManager } from "../StateManager"
@@ -16,6 +28,10 @@ describe("disk - hooks functionality", () => {
beforeEach(async () => {
sandbox = sinon.createSandbox()
// Default the module-level isDirectory stub to the real implementation;
// individual tests override it as needed.
isDirectoryStub.reset()
isDirectoryStub.callsFake((...args: unknown[]) => (realIsDirectory as (...a: unknown[]) => Promise<boolean>)(...args))
tempDir = path.join(os.tmpdir(), `disk-test-${Date.now()}-${Math.random().toString(36).slice(2)}`)
await fs.mkdir(tempDir, { recursive: true })
})
@@ -151,7 +167,7 @@ describe("disk - hooks functionality", () => {
} as any)
// Stub isDirectory to throw an error
sandbox.stub(fsUtils, "isDirectory").rejects(new Error("Permission denied"))
isDirectoryStub.rejects(new Error("Permission denied"))
// Should propagate the error
try {
@@ -204,7 +220,7 @@ describe("disk - hooks functionality", () => {
getGlobalStateKey: () => [],
} as any)
sandbox.stub(fsUtils, "isDirectory").callsFake(async (targetPath: string) => targetPath === runtimeHooksDir)
isDirectoryStub.callsFake(async (targetPath: string) => targetPath === runtimeHooksDir)
setRuntimeHooksDir(runtimeHooksDir)
@@ -220,7 +236,7 @@ describe("disk - hooks functionality", () => {
getGlobalStateKey: () => [],
} as any)
sandbox.stub(fsUtils, "isDirectory").resolves(false)
isDirectoryStub.resolves(false)
setRuntimeHooksDir(runtimeHooksDir)
@@ -235,7 +251,7 @@ describe("disk - atomic writes", () => {
let testGlobalStorageDir: string
// Setup HostProvider for tests with real temp directory
before(async () => {
beforeAll(async () => {
// Create a real temp directory for the tests
testGlobalStorageDir = path.join(os.tmpdir(), `cline-test-storage-${Date.now()}-${Math.random().toString(36).slice(2)}`)
await fs.mkdir(testGlobalStorageDir, { recursive: true })
@@ -246,7 +262,7 @@ describe("disk - atomic writes", () => {
})
})
after(async () => {
afterAll(async () => {
HostProvider.reset()
// Clean up temp directory
@@ -1,18 +1,50 @@
import * as diskStorage from "@core/storage/disk"
import * as remoteConfigFetch from "@core/storage/remote-config/fetch"
import * as remoteConfigUtils from "@core/storage/remote-config/utils"
import { afterEach, beforeEach, describe, it, mock } from "bun:test"
import * as actualDiskStorage from "@core/storage/disk"
import * as actualRemoteConfigUtils from "@core/storage/remote-config/utils"
import * as assert from "assert"
import { afterEach, beforeEach, describe, it } from "mocha"
import sinon from "sinon"
import { ClineAccountService } from "@/services/account/ClineAccountService"
import { AuthService } from "@/services/auth/AuthService"
// bun loads real ESM, so sinon cannot stub the `@core/storage/disk` and
// `@core/storage/remote-config/utils` namespace exports ("ES Modules cannot be
// stubbed"). Inject module-level sinon stubs via mock.module so the full sinon
// stub API keeps working. `AuthService`/`ClineAccountService` statics and the
// `accountService` instance method are still sinon-stubbed directly below.
const isRemoteConfigEnabledStub: sinon.SinonStub = sinon.stub()
const applyRemoteConfigStub: sinon.SinonStub = sinon.stub()
const clearRemoteConfigStub: sinon.SinonStub = sinon.stub()
const writeRemoteConfigToCacheStub: sinon.SinonStub = sinon.stub()
const readRemoteConfigFromCacheStub: sinon.SinonStub = sinon.stub()
const deleteRemoteConfigFromCacheStub: sinon.SinonStub = sinon.stub()
const diskMock = () => ({
...actualDiskStorage,
writeRemoteConfigToCache: writeRemoteConfigToCacheStub,
readRemoteConfigFromCache: readRemoteConfigFromCacheStub,
deleteRemoteConfigFromCache: deleteRemoteConfigFromCacheStub,
})
const utilsMock = () => ({
...actualRemoteConfigUtils,
isRemoteConfigEnabled: isRemoteConfigEnabledStub,
applyRemoteConfig: applyRemoteConfigStub,
clearRemoteConfig: clearRemoteConfigStub,
})
// Register both the alias form (this test's imports) and the relative form the
// SUT (remote-config/fetch.ts) uses, since bun's mock.module matches specifiers.
mock.module("@core/storage/disk", diskMock)
mock.module("@/core/storage/disk", diskMock)
mock.module("../disk", diskMock)
mock.module("@core/storage/remote-config/utils", utilsMock)
mock.module("@/core/storage/remote-config/utils", utilsMock)
mock.module("./utils", utilsMock)
import * as remoteConfigFetch from "@core/storage/remote-config/fetch"
describe("fetchRemoteConfig", () => {
let sandbox: sinon.SinonSandbox
let accountService: ClineAccountService
let authServiceStub: Partial<AuthService>
let fetchUserRemoteConfigStub: sinon.SinonStub
let isRemoteConfigEnabledStub: sinon.SinonStub
beforeEach(() => {
sandbox = sinon.createSandbox()
@@ -21,12 +53,19 @@ describe("fetchRemoteConfig", () => {
accountService = new ClineAccountService()
sandbox.stub(ClineAccountService, "getInstance").returns(accountService)
fetchUserRemoteConfigStub = sandbox.stub(accountService, "fetchUserRemoteConfig")
isRemoteConfigEnabledStub = sandbox.stub(remoteConfigUtils, "isRemoteConfigEnabled").returns(true)
sandbox.stub(remoteConfigUtils, "applyRemoteConfig").resolves()
sandbox.stub(remoteConfigUtils, "clearRemoteConfig")
sandbox.stub(diskStorage, "writeRemoteConfigToCache").resolves()
sandbox.stub(diskStorage, "readRemoteConfigFromCache").resolves({ version: "v1" })
sandbox.stub(diskStorage, "deleteRemoteConfigFromCache").resolves()
// Reset and (re)configure the module-level sinon stubs injected above.
isRemoteConfigEnabledStub.reset()
applyRemoteConfigStub.reset()
clearRemoteConfigStub.reset()
writeRemoteConfigToCacheStub.reset()
readRemoteConfigFromCacheStub.reset()
deleteRemoteConfigFromCacheStub.reset()
isRemoteConfigEnabledStub.returns(true)
applyRemoteConfigStub.resolves()
writeRemoteConfigToCacheStub.resolves()
readRemoteConfigFromCacheStub.resolves({ version: "v1" })
deleteRemoteConfigFromCacheStub.resolves()
})
afterEach(() => {
@@ -55,7 +94,7 @@ describe("fetchRemoteConfig", () => {
assert.strictEqual(controller.accountService.switchAccount.callCount, 1)
assert.strictEqual(controller.accountService.switchAccount.firstCall.args[0], "org-target")
assert.ok((remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).calledOnce)
assert.ok(applyRemoteConfigStub.calledOnce)
})
it("skips switchAccount when already in the chosen org", async () => {
@@ -79,7 +118,7 @@ describe("fetchRemoteConfig", () => {
await remoteConfigFetch.fetchRemoteConfig(controller as any)
assert.strictEqual(controller.accountService.switchAccount.callCount, 0)
assert.ok((remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).calledOnce)
assert.ok(applyRemoteConfigStub.calledOnce)
})
it("uses discoveredValue inline and skips org-level config fetch", async () => {
@@ -103,11 +142,11 @@ describe("fetchRemoteConfig", () => {
await remoteConfigFetch.fetchRemoteConfig(controller as any)
assert.ok((remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).calledOnce)
assert.ok(applyRemoteConfigStub.calledOnce)
// writeRemoteConfigToCache is called with the parsed config, proving inline parse succeeded.
// If it had fallen through to fetchRemoteConfigForOrganization, it would need getAuthToken
// and make an HTTP call — but no axios stub is set up, so the test would fail.
assert.ok((diskStorage.writeRemoteConfigToCache as sinon.SinonStub).calledOnce)
assert.ok(writeRemoteConfigToCacheStub.calledOnce)
})
it("falls back to org-level fetch when discoveredValue fails to parse", async () => {
@@ -132,8 +171,8 @@ describe("fetchRemoteConfig", () => {
await remoteConfigFetch.fetchRemoteConfig(controller as any)
// Parse failed → fetchRemoteConfigForOrganization → no auth → cache fallback
assert.ok((diskStorage.readRemoteConfigFromCache as sinon.SinonStub).called)
assert.ok((remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).calledOnce)
assert.ok(readRemoteConfigFromCacheStub.called)
assert.ok(applyRemoteConfigStub.calledOnce)
})
it("does not switch org when resolve fails", async () => {
@@ -149,7 +188,7 @@ describe("fetchRemoteConfig", () => {
})
// Both inline parse and org-level fetch fail (no auth → no fetch), cache is empty
;(diskStorage.readRemoteConfigFromCache as sinon.SinonStub).resolves(undefined)
readRemoteConfigFromCacheStub.resolves(undefined)
const controller = {
accountService: { switchAccount: sandbox.stub() },
@@ -162,8 +201,8 @@ describe("fetchRemoteConfig", () => {
// Config resolution failed — user should stay in their current org
assert.strictEqual(controller.accountService.switchAccount.callCount, 0)
assert.ok((remoteConfigUtils.clearRemoteConfig as sinon.SinonStub).called)
assert.strictEqual((remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).callCount, 0)
assert.ok(clearRemoteConfigStub.called)
assert.strictEqual(applyRemoteConfigStub.callCount, 0)
})
it("falls back to next locally-allowed org when backend org is opted-out", async () => {
@@ -187,7 +226,7 @@ describe("fetchRemoteConfig", () => {
isRemoteConfigEnabledStub.withArgs("org-3").returns(true)
// Fallback org has no discoveredValue, so it will go through fetchRemoteConfigForOrganization
// which needs auth → will fall back to cache
;(diskStorage.readRemoteConfigFromCache as sinon.SinonStub).resolves({ version: "v1" })
readRemoteConfigFromCacheStub.resolves({ version: "v1" })
const controller = {
accountService: { switchAccount: sandbox.stub().resolves() },
@@ -198,7 +237,7 @@ describe("fetchRemoteConfig", () => {
await remoteConfigFetch.fetchRemoteConfig(controller as any)
assert.ok((remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).calledOnce)
assert.ok(applyRemoteConfigStub.calledOnce)
})
it("clears remote config when all orgs are locally opted-out", async () => {
@@ -222,9 +261,9 @@ describe("fetchRemoteConfig", () => {
await remoteConfigFetch.fetchRemoteConfig(controller as any)
assert.ok((remoteConfigUtils.clearRemoteConfig as sinon.SinonStub).called)
assert.ok(clearRemoteConfigStub.called)
assert.strictEqual(controller.accountService.switchAccount.callCount, 0)
assert.strictEqual((remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).callCount, 0)
assert.strictEqual(applyRemoteConfigStub.callCount, 0)
})
it("calls clearRemoteConfig when discovery returns no qualifying org", async () => {
@@ -239,9 +278,9 @@ describe("fetchRemoteConfig", () => {
await remoteConfigFetch.fetchRemoteConfig(controller as any)
assert.ok((remoteConfigUtils.clearRemoteConfig as sinon.SinonStub).called)
assert.ok(clearRemoteConfigStub.called)
assert.strictEqual(controller.accountService.switchAccount.callCount, 0)
assert.strictEqual((remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).callCount, 0)
assert.strictEqual(applyRemoteConfigStub.callCount, 0)
})
it("clears remote config when isRemoteConfigEnabled toggled off mid-flight", async () => {
@@ -268,9 +307,9 @@ describe("fetchRemoteConfig", () => {
await remoteConfigFetch.fetchRemoteConfig(controller as any)
assert.ok((diskStorage.writeRemoteConfigToCache as sinon.SinonStub).calledOnce)
assert.ok((remoteConfigUtils.clearRemoteConfig as sinon.SinonStub).called)
assert.strictEqual((remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).callCount, 0)
assert.ok(writeRemoteConfigToCacheStub.calledOnce)
assert.ok(clearRemoteConfigStub.called)
assert.strictEqual(applyRemoteConfigStub.callCount, 0)
})
it("preserves existing config on unexpected network error", async () => {
@@ -286,7 +325,7 @@ describe("fetchRemoteConfig", () => {
await remoteConfigFetch.fetchRemoteConfig(controller as any)
// Transient errors should NOT clear existing remote config
assert.strictEqual((remoteConfigUtils.clearRemoteConfig as sinon.SinonStub).callCount, 0)
assert.strictEqual(clearRemoteConfigStub.callCount, 0)
assert.strictEqual(controller.postStateToWebview.callCount, 0)
})
@@ -311,7 +350,7 @@ describe("fetchRemoteConfig", () => {
await remoteConfigFetch.fetchRemoteConfig(controller as any)
// switchAccount failure should NOT clear existing remote config
assert.strictEqual((remoteConfigUtils.clearRemoteConfig as sinon.SinonStub).callCount, 0)
assert.strictEqual((remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).callCount, 0)
assert.strictEqual(clearRemoteConfigStub.callCount, 0)
assert.strictEqual(applyRemoteConfigStub.callCount, 0)
})
})
@@ -3,8 +3,8 @@
* Covers: transformRemoteConfigToStateShape and toggle synchronisation logic.
*/
import { describe, it } from "bun:test"
import { expect } from "chai"
import { describe, it } from "mocha"
import { synchronizeRemoteRuleToggles } from "@/core/context/instructions/user-instructions/rule-helpers"
import { parseRemoteSkillEntries } from "@/core/context/instructions/user-instructions/skills"
import { transformRemoteConfigToStateShape } from "@/core/storage/remote-config/utils"
@@ -1,10 +1,21 @@
import { afterEach, beforeEach, describe, it } from "mocha"
import { afterEach, beforeEach, describe, it, mock } from "bun:test"
import "should"
import * as diskModule from "@core/storage/disk"
import * as actualDiskModule from "@core/storage/disk"
import fs from "fs/promises"
import os from "os"
import path from "path"
import sinon from "sinon"
// bun loads real ESM, so sinon cannot stub the `@core/storage/disk` namespace
// export ("ES Modules cannot be stubbed"). Inject a module-level sinon stub for
// `getMcpSettingsFilePath` via mock.module so the full sinon stub API keeps
// working. Register both the alias form and the relative form the SUT uses.
const getMcpSettingsFilePathStub: sinon.SinonStub = sinon.stub()
const diskMock = () => ({ ...actualDiskModule, getMcpSettingsFilePath: getMcpSettingsFilePathStub })
mock.module("@core/storage/disk", diskMock)
mock.module("@/core/storage/disk", diskMock)
mock.module("../../disk", diskMock)
import { syncRemoteMcpServersToSettings } from "../remote-config/syncRemoteMcpServers"
describe("syncRemoteMcpServersToSettings", () => {
@@ -18,7 +29,8 @@ describe("syncRemoteMcpServersToSettings", () => {
await fs.mkdir(tempDir, { recursive: true })
settingsPath = path.join(tempDir, "cline_mcp_settings.json")
sandbox.stub(diskModule, "getMcpSettingsFilePath").callsFake(async () => {
getMcpSettingsFilePathStub.reset()
getMcpSettingsFilePathStub.callsFake(async () => {
try {
await fs.access(settingsPath)
} catch {
@@ -30,6 +42,7 @@ describe("syncRemoteMcpServersToSettings", () => {
afterEach(async () => {
sandbox.restore()
getMcpSettingsFilePathStub.reset()
try {
await fs.rm(tempDir, { recursive: true, force: true })
} catch {
@@ -78,15 +78,11 @@ export abstract class WebviewProvider {
// The JS file from the React build output
const scriptUrl = this.getExtensionUrl("webview-ui", "build", "assets", "index.js")
// The CSS file from the React build output
// The CSS file from the React build output. The webview's own index.css
// @imports @vscode/codicons, so the codicon @font-face + codicon.ttf are
// bundled into these build assets — no separate codicons <link> needed.
const stylesUrl = this.getExtensionUrl("webview-ui", "build", "assets", "index.css")
// The codicon font from the React build output
// https://github.com/microsoft/vscode-extension-samples/blob/main/webview-codicons-sample/src/extension.ts
// we installed this package in the extension so that we can access it how its intended from the extension (the font file is likely bundled in vscode), and we just import the css fileinto our react app we don't have access to it
// don't forget to add font-src ${webview.cspSource};
const codiconsUrl = this.getExtensionUrl("node_modules", "@vscode", "codicons", "dist", "codicon.css")
// Use a nonce to only allow a specific script to be run.
/*
content security policy of your webview to only allow scripts that have a specific nonce
@@ -109,7 +105,6 @@ export abstract class WebviewProvider {
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<link rel="stylesheet" type="text/css" href="${stylesUrl}">
<link href="${codiconsUrl}" rel="stylesheet" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none';
connect-src https://*.posthog.com https://*.cline.bot;
font-src ${this.getCspSource()} data:;
@@ -182,7 +177,6 @@ export abstract class WebviewProvider {
const nonce = getNonce()
const stylesUrl = this.getExtensionUrl("webview-ui", "build", "assets", "index.css")
const codiconsUrl = this.getExtensionUrl("node_modules", "@vscode", "codicons", "dist", "codicon.css")
const scriptEntrypoint = "src/main.tsx"
const scriptUrl = `http://${localServerUrl}/${scriptEntrypoint}`
@@ -215,7 +209,6 @@ export abstract class WebviewProvider {
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
<meta http-equiv="Content-Security-Policy" content="${csp.join("; ")}">
<link rel="stylesheet" type="text/css" href="${stylesUrl}">
<link href="${codiconsUrl}" rel="stylesheet" />
<title>Cline</title>
</head>
<body>
@@ -3,9 +3,9 @@
* Tests the core functionality of path resolution in single and multi-root workspaces
*/
import { afterEach, beforeEach, describe, it } from "bun:test"
import { VcsType, WorkspaceRoot } from "@shared/multi-root/types"
import { expect } from "chai"
import { afterEach, beforeEach, describe, it } from "mocha"
import * as path from "path"
import * as sinon from "sinon"
import { createWorkspacePathAdapter, WorkspacePathAdapter } from "../WorkspacePathAdapter"
@@ -3,9 +3,9 @@
* These tests ensure behavior preservation during refactoring
*/
import { afterEach, beforeEach, describe, it } from "bun:test"
import { VcsType, WorkspaceRoot } from "@shared/multi-root/types"
import { expect } from "chai"
import { afterEach, beforeEach, describe, it } from "mocha"
import * as path from "path"
import * as sinon from "sinon"
import { Logger } from "@/shared/services/Logger"
@@ -1,5 +1,5 @@
import { describe, it } from "bun:test"
import { expect } from "chai"
import { describe, it } from "mocha"
import {
addWorkspaceHint,
hasWorkspaceHint,
+4 -4
View File
@@ -16,7 +16,7 @@ Designed to be driven from an agentic loop via `curl` commands.
```bash
# Terminal 1: Start the debug harness server
npx tsx src/dev/debug-harness/server.ts --auto-launch --skip-build
bun src/dev/debug-harness/server.ts --auto-launch --skip-build
# Terminal 2: Interact via curl
curl localhost:19229/api -d '{"method":"status"}'
@@ -27,7 +27,7 @@ curl localhost:19229/api -d '{"method":"ui.screenshot"}'
## Server Options
```
npx tsx src/dev/debug-harness/server.ts [options]
bun src/dev/debug-harness/server.ts [options]
Options:
--skip-build Skip building extension/webview (use existing dist/)
@@ -42,7 +42,7 @@ Options:
```bash
# This builds protos, extension (unminified+sourcemaps), webview (unminified+sourcemaps),
# downloads VSCode, launches it, and connects CDP to the extension host.
npx tsx src/dev/debug-harness/server.ts --auto-launch
bun src/dev/debug-harness/server.ts --auto-launch
```
## Data Isolation
@@ -126,7 +126,7 @@ curl localhost:19229/api -d '{"method":"ui.screenshot"}'
MCP servers that require OAuth use a different flow: the browser redirects
to a `vscode://` URI handled by the extension's URI handler. The auth provider
(e.g. Linear) decides the `code`; for end-to-end testing, pair this with the
local MCP OAuth test server (`npm run dev:mcp-oauth-test-server`, see
local MCP OAuth test server (`bun run dev:mcp-oauth-test-server`, see
`src/dev/mcp-oauth-test-server/README.md`), which mints real codes/tokens.
```bash
+43 -4
View File
@@ -10,15 +10,21 @@
* - UI automation (click, type, screenshot) via Playwright
*
* Usage:
* npx tsx src/dev/debug-harness/server.ts [options]
* bun src/dev/debug-harness/server.ts [options]
*
* Options:
* --skip-build Skip building extension/webview
* --auto-launch Automatically launch VSCode on startup
* --workspace PATH Workspace directory to open
* --port PORT Server port (default: 19229)
* --launch-timeout MS Playwright _electron.launch timeout (default: 120000)
* --no-browser-capture Let openExternal() open real browser windows (for interactive OAuth)
*
* Env:
* VSCODE_TEST_VERSION Debugee VSCode version to download (default: 1.103.0; the
* bundled Playwright cannot drive the Electron in the latest
* "stable"). Set to "stable" or a pinned x.y.z to override.
*
* Then send commands:
* curl localhost:19229/api -d '{"method":"launch"}'
* curl localhost:19229/api -d '{"method":"ui.screenshot"}'
@@ -49,6 +55,10 @@ function getArg(name: string): string | undefined {
const PORT = Number.parseInt(getArg("--port") || "19229", 10)
const EXT_INSPECT_PORT = 9230
// Playwright's _electron.launch() resolves once it controls the Electron main
// process. A cold launch (first-run profile init, slow CI/VM) can exceed the old
// 60s default, so allow more headroom and let it be overridden.
const LAUNCH_TIMEOUT_MS = Number.parseInt(getArg("--launch-timeout") || "120000", 10)
const PROJECT_ROOT = path.resolve(__script_dir, "..", "..", "..")
const SCREENSHOT_DIR = path.join(os.tmpdir(), "cline-debug")
const DEFAULT_WORKSPACE = path.join(os.tmpdir(), "cline-debug-workspace")
@@ -344,9 +354,13 @@ class DebugHarness {
}
}
// Download VSCode binary
// Download VSCode binary. Default to a version whose Electron the bundled
// Playwright can drive (the very latest "stable" can ship an Electron newer
// than playwright supports, making _electron.launch() hang). Override with
// VSCODE_TEST_VERSION (e.g. "stable" or a pinned x.y.z).
log("Ensuring VSCode binary is available...")
const executablePath = await downloadAndUnzipVSCode("stable", undefined, new SilentReporter())
const vscodeVersion = process.env.VSCODE_TEST_VERSION || "1.103.0"
const executablePath = await downloadAndUnzipVSCode(vscodeVersion, undefined, new SilentReporter())
log(`VSCode binary: ${executablePath}`)
// Resolve the CLINE_DIR for the debugee (separate from debugger's ~/.cline)
@@ -381,6 +395,15 @@ class DebugHarness {
"--disable-updates",
"--skip-welcome",
"--skip-release-notes",
// Headless/VM GPU stacks crash the renderer's GPU process
// ("Exiting GPU process during initialization" /
// "CreateCommandBuffer kTransientFailure"), which can kill the window
// before Playwright finishes attaching and trip the launch timeout.
// Force software rendering for a stable debugee.
"--disable-gpu",
"--disable-gpu-compositing",
"--disable-software-rasterizer",
"--disable-dev-shm-usage",
`--user-data-dir=${userDataDir}`,
workspace,
],
@@ -396,9 +419,13 @@ class DebugHarness {
CLINE_CAPTURE_BROWSER: BROWSER_CAPTURE ? "1" : "0",
CLINE_DEBUG_HARNESS_PORT: String(PORT),
},
timeout: 60000,
timeout: LAUNCH_TIMEOUT_MS,
})
} catch (e: any) {
// Best-effort: kill the orphaned Electron so a retry isn't blocked by a
// half-launched instance, and so Playwright stops emitting late rejections
// on the dead CDP transport.
await this.app?.close().catch(() => {})
this.app = null
throw new Error(`Failed to launch VSCode: ${e.message}`)
}
@@ -1572,3 +1599,15 @@ async function cleanShutdown() {
}
process.on("SIGINT", cleanShutdown)
process.on("SIGTERM", cleanShutdown)
// Keep the harness alive on stray async errors. A failed/aborted VSCode launch
// (e.g. Playwright's _electron.launch timing out) can emit a late rejection on
// the dead CDP transport AFTER we've already handled the launch error; without
// this, the default behavior would crash the whole server and you'd have to
// restart it just to retry. Log and keep serving so `launch` can be retried.
process.on("unhandledRejection", (reason: unknown) => {
log("Unhandled rejection (ignored, server stays up):", reason instanceof Error ? reason.message : String(reason))
})
process.on("uncaughtException", (err: Error) => {
log("Uncaught exception (ignored, server stays up):", err.message)
})
@@ -32,9 +32,9 @@ Exercises MCP OAuth failure modes without a real remote server:
```bash
cd apps/vscode
npm run dev:mcp-oauth-test-server -- --verbose
bun run dev:mcp-oauth-test-server -- --verbose
# or directly:
npx tsx src/dev/mcp-oauth-test-server/server.ts --verbose
bun src/dev/mcp-oauth-test-server/server.ts --verbose
```
Then in Cline, add an MCP server (StreamableHTTP) pointing at:
@@ -64,13 +64,13 @@ can click **Approve** or **Deny**.
10-minute state window:
```bash
npx tsx src/dev/mcp-oauth-test-server/server.ts --slow-authorize 605000 --verbose
bun src/dev/mcp-oauth-test-server/server.ts --slow-authorize 605000 --verbose
```
**Denied redirect** — always deny so every redirect carries `access_denied`:
```bash
npx tsx src/dev/mcp-oauth-test-server/server.ts --auto-deny --verbose
bun src/dev/mcp-oauth-test-server/server.ts --auto-deny --verbose
```
## Debug-harness integration
@@ -1,17 +1,31 @@
import { afterEach, describe, it } from "mocha"
import { afterEach, beforeEach, describe, it, mock } from "bun:test"
import "should"
import sinon from "sinon"
import * as gitUtils from "@/utils/git"
import * as actualGitUtils from "@/utils/git"
// bun loads real ESM, so sinon cannot stub the `@/utils/git` namespace export
// ("ES Modules cannot be stubbed"). Inject a module-level sinon stub for
// `getGitDiff` via mock.module so the full sinon stub API keeps working.
const getGitDiffStub: sinon.SinonStub = sinon.stub()
const gitUtilsMock = () => ({ ...actualGitUtils, getGitDiff: getGitDiffStub })
mock.module("@/utils/git", gitUtilsMock)
mock.module("@utils/git", gitUtilsMock)
import { getGitDiffStagedFirst } from "../commit-message-generator"
describe("commit-message-generator", () => {
describe("getGitDiffStagedFirst", () => {
beforeEach(() => {
getGitDiffStub.reset()
})
afterEach(() => {
sinon.restore()
getGitDiffStub.reset()
})
it("should return staged changes when they exist", async () => {
const stub = sinon.stub(gitUtils, "getGitDiff")
const stub = getGitDiffStub
stub.withArgs("/repo", true).resolves("staged diff content")
const result = await getGitDiffStagedFirst("/repo")
@@ -20,7 +34,7 @@ describe("commit-message-generator", () => {
})
it("should fall back to all changes when no staged changes exist", async () => {
const stub = sinon.stub(gitUtils, "getGitDiff")
const stub = getGitDiffStub
stub.withArgs("/repo", true).rejects(new Error("No changes in workspace for commit message"))
stub.withArgs("/repo", false).resolves("all diff content")
@@ -32,7 +46,7 @@ describe("commit-message-generator", () => {
})
it("should propagate error when both staged and all changes fail", async () => {
const stub = sinon.stub(gitUtils, "getGitDiff")
const stub = getGitDiffStub
stub.withArgs("/repo", true).rejects(new Error("No changes"))
stub.withArgs("/repo", false).rejects(new Error("No changes in workspace for commit message"))
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, it } from "mocha"
import { afterEach, beforeEach, describe, it } from "bun:test"
import "should"
import { ClineFileStorage } from "@shared/storage/ClineFileStorage"
import { createStorageContext, type StorageContext } from "@shared/storage/storage-context"
@@ -1,5 +1,5 @@
import { strict as assert } from "assert"
import { afterEach, describe, it } from "mocha"
import { strict as assert } from "assert"
import * as sinon from "sinon"
import * as vscode from "vscode"
import { ExtensionRegistryInfo } from "@/registry"
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, it } from "mocha"
import { strict as assert } from "assert"
import * as fs from "fs/promises"
import { afterEach, beforeEach, describe, it } from "mocha"
import * as os from "os"
import pWaitFor from "p-wait-for"
import * as path from "path"
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, it } from "mocha"
import { strict as assert } from "assert"
import * as fs from "fs/promises"
import { afterEach, beforeEach, describe, it } from "mocha"
import * as os from "os"
import * as path from "path"
import * as vscode from "vscode"
@@ -1,5 +1,5 @@
import { expect } from "chai"
import { describe, it } from "mocha"
import { expect } from "chai"
import * as vscode from "vscode"
import { DiagnosticSeverity } from "@/shared/proto/index.cline"
import { convertToFileDiagnostics, convertVscodeDiagnostics } from "./getDiagnostics"
@@ -1,6 +1,6 @@
import { after, before, beforeEach, describe, it } from "mocha"
import { expect } from "chai"
import * as fs from "fs/promises"
import { after, before, beforeEach, describe, it } from "mocha"
import * as os from "os"
import * as path from "path"
import * as vscode from "vscode"
@@ -1,8 +1,17 @@
import { afterEach, beforeEach, describe, it, mock } from "bun:test"
import { DiagnosticSeverity, FileDiagnostics } from "@shared/proto/index.cline"
import { expect } from "chai"
import { beforeEach, describe, it } from "mocha"
import * as sinon from "sinon"
import * as pathUtils from "@/utils/path"
import * as actualPathUtils from "@/utils/path"
// bun loads real ESM, so sinon cannot stub the `@/utils/path` namespace export
// ("ES Modules cannot be stubbed"). Inject a module-level sinon stub for
// `getCwd` via mock.module so the full sinon stub API keeps working.
const getCwdStub: sinon.SinonStub = sinon.stub()
const pathUtilsMock = () => ({ ...actualPathUtils, getCwd: getCwdStub })
mock.module("@/utils/path", pathUtilsMock)
mock.module("@utils/path", pathUtilsMock)
import { diagnosticsToProblemsString, getNewDiagnostics } from "../"
describe("Diagnostics Tests", () => {
@@ -187,14 +196,14 @@ describe("Diagnostics Tests", () => {
})
describe("diagnosticsToProblemsString", () => {
let _getCwdStub: sinon.SinonStub
beforeEach(() => {
_getCwdStub = sinon.stub(pathUtils, "getCwd").resolves("/workspace")
getCwdStub.reset()
getCwdStub.resolves("/workspace")
})
afterEach(() => {
sinon.restore()
getCwdStub.reset()
})
it("should return empty string when diagnostics array is empty", async () => {
@@ -1,5 +1,5 @@
import { describe, it } from "bun:test"
import * as assert from "assert"
import { describe, it } from "mocha"
import { DiffViewProvider } from "../DiffViewProvider"
class TestBoundaryDiffViewProvider extends DiffViewProvider {
@@ -26,7 +26,7 @@ class TestBoundaryDiffViewProvider extends DiffViewProvider {
return this.documentText
}
async saveDocument(): Promise<Boolean> {
async saveDocument(): Promise<boolean> {
return true
}
async closeAllDiffViews(): Promise<void> {}
@@ -215,7 +215,7 @@ describe("DiffViewProvider Update Throttling", () => {
return this.documentText
}
async saveDocument(): Promise<Boolean> {
async saveDocument(): Promise<boolean> {
return true
}
async closeAllDiffViews(): Promise<void> {}
@@ -277,9 +277,7 @@ describe("DiffViewProvider Update Throttling", () => {
assert.strictEqual(provider.replaceTextCallCount, 1, "Rapid updates should be throttled")
})
it("should allow update after throttle period", async function () {
this.timeout(500) // Allow time for the delay
it("should allow update after throttle period", async () => {
const provider = new ThrottleTestDiffViewProvider()
provider.setup("initial\n")
@@ -293,7 +291,7 @@ describe("DiffViewProvider Update Throttling", () => {
// Next update should go through
await provider.update("line1\nline2\n", false)
assert.strictEqual(provider.replaceTextCallCount, 2, "Update after throttle period should go through")
})
}, 500)
it("should always process final updates regardless of throttling", async () => {
const provider = new ThrottleTestDiffViewProvider()
@@ -333,9 +331,7 @@ describe("DiffViewProvider Update Throttling", () => {
assert.strictEqual(provider.replaceTextCallCount, 2, "Final update should bypass length check")
})
it("should reset throttle state on reset()", async function () {
this.timeout(500)
it("should reset throttle state on reset()", async () => {
const provider = new ThrottleTestDiffViewProvider()
provider.setup("initial\n")
@@ -356,7 +352,7 @@ describe("DiffViewProvider Update Throttling", () => {
// Update immediately after reset should go through (throttle state cleared)
await provider.update("newcontent\n", false)
assert.strictEqual(provider.replaceTextCallCount, 1, "Update after reset should go through immediately")
})
}, 500)
it("should allow first update to go through immediately", async () => {
const provider = new ThrottleTestDiffViewProvider()
@@ -367,9 +363,7 @@ describe("DiffViewProvider Update Throttling", () => {
assert.strictEqual(provider.replaceTextCallCount, 1, "First update should not be throttled")
})
it("should handle streaming simulation with many rapid updates", async function () {
this.timeout(500)
it("should handle streaming simulation with many rapid updates", async () => {
const provider = new ThrottleTestDiffViewProvider()
provider.setup("")
@@ -395,11 +389,9 @@ describe("DiffViewProvider Update Throttling", () => {
// Final update always goes through
await provider.update(contentAfterWait + "end", true)
assert.strictEqual(provider.replaceTextCallCount, 3, "Final update should go through")
})
it("should throttle by time regardless of content length changes", async function () {
this.timeout(500)
}, 500)
it("should throttle by time regardless of content length changes", async () => {
const provider = new ThrottleTestDiffViewProvider()
provider.setup("")
@@ -417,7 +409,7 @@ describe("DiffViewProvider Update Throttling", () => {
// Now should go through
await provider.update("line1\nline2\nline3\nline4\n", false)
assert.strictEqual(provider.replaceTextCallCount, 2, "Should update after throttle period")
})
}, 500)
})
describe("DiffViewProvider Newline Preservation", () => {
@@ -1,6 +1,6 @@
import { describe, it } from "mocha"
import assert from "node:assert/strict"
import { EventEmitter } from "events"
import { describe, it } from "mocha"
import { orchestrateCommandExecution } from "./CommandOrchestrator"
import type {
CommandExecutorCallbacks,
@@ -1,6 +1,6 @@
import { describe, it } from "bun:test"
import assert from "node:assert/strict"
import { EventEmitter } from "events"
import { describe, it } from "mocha"
import { orchestrateCommandExecution } from "../CommandOrchestrator"
import { CHUNK_DEBOUNCE_MS } from "../constants"
import type {
@@ -1,5 +1,5 @@
import { describe, it } from "bun:test"
import assert from "node:assert/strict"
import { describe, it } from "mocha"
import { getShellArgs, unwrapPowerShell } from "../shellArgs"
describe("unwrapPowerShell", () => {
@@ -1,3 +1,4 @@
import { afterEach, describe, it } from "bun:test"
/**
* Tests for selfHosted mode behavior across PostHog-based services.
* When ClineEndpoint.isSelfHosted() returns true, all PostHog functionality should be disabled.
@@ -3,9 +3,9 @@
* Tests API fetching, caching, auth updates, and rate limit backoff
*/
import { afterEach, beforeEach, describe, it } from "bun:test"
import type { BannerRules } from "@shared/ClineBanner"
import { expect } from "chai"
import { afterEach, beforeEach, describe, it } from "mocha"
import * as sinon from "sinon"
import { ClineEnv, Environment } from "@/config"
import { Controller } from "@/core/controller"
@@ -1,4 +1,4 @@
import { describe, it } from "mocha"
import { describe, it } from "bun:test"
import "should"
import { ClineError, ClineErrorType } from "../ClineError"
@@ -1,5 +1,5 @@
import { afterAll, describe, it } from "bun:test"
import * as fs from "fs/promises"
import { after, describe, it } from "mocha"
import * as os from "os"
import * as path from "path"
import "should"
@@ -12,7 +12,7 @@ function normalizeForComparison(filePath: string): string {
describe("listFiles", () => {
const tmpDir = path.join(os.tmpdir(), `cline-list-files-test-${Math.random().toString(36).slice(2)}`)
after(async () => {
afterAll(async () => {
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined)
})
@@ -44,7 +44,7 @@ describe("listFiles gitignore handling", () => {
// overwrite earlier .gitignore files and pass for the wrong reasons.
const baseDir = path.join(os.tmpdir(), `cline-gitignore-test-${Math.random().toString(36).slice(2)}`)
after(async () => {
afterAll(async () => {
await fs.rm(baseDir, { recursive: true, force: true }).catch(() => undefined)
})
@@ -1,8 +1,15 @@
import { afterEach, beforeEach, describe, it, mock } from "bun:test"
import { expect } from "chai"
import { afterEach, beforeEach, describe, it } from "mocha"
import * as nodeMachineId from "node-machine-id"
import * as actualNodeMachineId from "node-machine-id"
import * as sinon from "sinon"
import { HostProvider } from "@/hosts/host-provider"
// bun loads real ESM, so sinon cannot stub the `node-machine-id` namespace
// export ("ES Modules cannot be stubbed"). Inject a module-level sinon stub for
// `machineId` via mock.module so the full sinon stub API keeps working.
const machineIdStub: sinon.SinonStub = sinon.stub()
mock.module("node-machine-id", () => ({ ...actualNodeMachineId, machineId: machineIdStub }))
import { _GENERATED_MACHINE_ID_KEY, getDistinctId, initializeDistinctId, setDistinctId } from "@/services/logging/distinctId"
import { StorageContext } from "@/shared/storage"
@@ -58,6 +65,9 @@ describe("distinctId", () => {
// Mock extension storage
mockStorage = { globalState: mockGlobalState } as unknown as StorageContext
// Reset the module-level node-machine-id stub
machineIdStub.reset()
// Reset the distinctId module state
setDistinctId("")
})
@@ -74,7 +84,7 @@ describe("distinctId", () => {
it("should use id from extension globalstate if it exists", async () => {
mockGlobalState.get.withArgs(_GENERATED_MACHINE_ID_KEY).returns(MOCK_GLOBAL_STATE_ID)
const machineIdStub = sandbox.stub(nodeMachineId, "machineId")
// machineIdStub is the module-level stub (left unconfigured -> resolves undefined)
await initializeDistinctId(mockStorage, mockUuidGenerator)
@@ -85,7 +95,7 @@ describe("distinctId", () => {
it("should use the machine ID from node-machine-id", async () => {
// Mock node-machine-id to return a machine ID
const machineIdStub = sandbox.stub(nodeMachineId, "machineId").resolves(MOCK_MACHINE_ID)
machineIdStub.resolves(MOCK_MACHINE_ID)
await initializeDistinctId(mockStorage, mockUuidGenerator)
@@ -97,7 +107,7 @@ describe("distinctId", () => {
it("distinct ID should be stable", async () => {
mockGlobalState.get.withArgs(_GENERATED_MACHINE_ID_KEY).returns(undefined)
// Mock node-machine-id to return a machine ID
sandbox.stub(nodeMachineId, "machineId").resolves(MOCK_MACHINE_ID)
machineIdStub.resolves(MOCK_MACHINE_ID)
await initializeDistinctId(mockStorage, mockUuidGenerator)
expect(getDistinctId()).to.equal(MOCK_MACHINE_ID)
@@ -111,7 +121,7 @@ describe("distinctId", () => {
it("should generate and store UUID if node-machine-id returns empty string", async () => {
mockGlobalState.get.withArgs(_GENERATED_MACHINE_ID_KEY).returns(undefined)
// Mock node-machine-id to return empty string
const machineIdStub = sandbox.stub(nodeMachineId, "machineId").resolves("")
machineIdStub.resolves("")
await initializeDistinctId(mockStorage, mockUuidGenerator)
@@ -123,7 +133,7 @@ describe("distinctId", () => {
it("should handle node-machine-id errors gracefully", async () => {
mockGlobalState.get.withArgs(_GENERATED_MACHINE_ID_KEY).returns(undefined)
// Mock node-machine-id to throw an error
const machineIdStub = sandbox.stub(nodeMachineId, "machineId").rejects(new Error("Failed to get machine ID"))
machineIdStub.rejects(new Error("Failed to get machine ID"))
await initializeDistinctId(mockStorage, mockUuidGenerator)
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, it } from "mocha"
import { afterEach, beforeEach, describe, it } from "bun:test"
import "should"
import sinon from "sinon"
import { McpHub } from "../McpHub"
@@ -1,10 +1,30 @@
import { afterEach, beforeEach, describe, it } from "mocha"
import { afterEach, beforeEach, describe, it, mock } from "bun:test"
import "should"
import * as diskModule from "@core/storage/disk"
import fs from "fs/promises"
import * as actualDiskModule from "@core/storage/disk"
import fs, * as actualFsPromises from "fs/promises"
import os from "os"
import path from "path"
import sinon from "sinon"
// bun loads real ESM, so sinon cannot stub the `@core/storage/disk` namespace
// export, and `fs.promises` (the default import below) is NOT the same object as
// the SUT's `import * as fs from "fs/promises"`. Inject module-level sinon stubs
// via mock.module so the full sinon stub API keeps working on the exact
// specifiers the SUT imports. `writeFile` defaults to the real implementation so
// the test's own settings-file writes still hit disk.
// Capture the genuine writeFile before mock.module overrides `fs/promises`, so
// the test's pass-through stub does not recurse into itself.
const realWriteFile = actualFsPromises.writeFile
const getMcpSettingsFilePathStub: sinon.SinonStub = sinon.stub()
const writeFileStub: sinon.SinonStub = sinon.stub()
const diskMock = () => ({ ...actualDiskModule, getMcpSettingsFilePath: getMcpSettingsFilePathStub })
const fsPromisesNamespace = { ...actualFsPromises, writeFile: writeFileStub }
const fsPromisesMock = () => ({ ...fsPromisesNamespace, default: fsPromisesNamespace })
mock.module("@core/storage/disk", diskMock)
mock.module("@/core/storage/disk", diskMock)
mock.module("fs/promises", fsPromisesMock)
mock.module("node:fs/promises", fsPromisesMock)
import { McpHub } from "../McpHub"
// Regression tests for McpHub.deleteServerRPC(): deleting one server must not
@@ -45,7 +65,12 @@ describe("McpHub.deleteServerRPC", () => {
tempDir = path.join(os.tmpdir(), `mcp-delete-test-${Date.now()}-${Math.random().toString(36).slice(2)}`)
await fs.mkdir(tempDir, { recursive: true })
settingsPath = path.join(tempDir, "cline_mcp_settings.json")
sandbox.stub(diskModule, "getMcpSettingsFilePath").resolves(settingsPath)
getMcpSettingsFilePathStub.reset()
getMcpSettingsFilePathStub.resolves(settingsPath)
// Default writeFile to the real implementation; individual tests can wrap
// it to observe behavior.
writeFileStub.reset()
writeFileStub.callsFake((...args: unknown[]) => (realWriteFile as (...a: unknown[]) => Promise<void>)(...args))
hub = Object.create(McpHub.prototype) as McpHub
;(hub as any).getSettingsDirectoryPath = async () => tempDir
@@ -95,10 +120,11 @@ describe("McpHub.deleteServerRPC", () => {
const clock = sandbox.useFakeTimers()
await writeSettings({ alpha: { type: "stdio", command: "a" }, beta: { type: "stdio", command: "b" } })
// Capture the flag at the moment the settings file is written.
// Capture the flag at the moment the settings file is written. Wrap the
// module-level writeFile stub (mock.module) rather than sinon-stubbing the
// ESM `fs/promises` namespace, which bun forbids.
let flagDuringWrite: boolean | undefined
const realWriteFile = fs.writeFile.bind(fs)
sandbox.stub(fs, "writeFile").callsFake((...args: unknown[]) => {
writeFileStub.callsFake((...args: unknown[]) => {
flagDuringWrite = (hub as any).isUpdatingClineSettings
return (realWriteFile as (...a: unknown[]) => Promise<void>)(...args)
})
@@ -9,7 +9,7 @@
* We avoid importing McpHub directly (too many transitive deps for unit tests).
* Instead we extract the pure logic and test it in isolation.
*/
import { describe, it } from "mocha"
import { describe, it } from "bun:test"
import "should"
import sinon from "sinon"
@@ -1,4 +1,4 @@
import { describe, it } from "mocha"
import { describe, it } from "bun:test"
import "should"
import { type GetCallbackUrlFn, McpOAuthRedirectResolver } from "../McpOAuthRedirectResolver"
@@ -1,4 +1,4 @@
import { beforeEach, describe, it } from "mocha"
import { afterEach, beforeEach, describe, it } from "bun:test"
import "should"
import sinon from "sinon"
import {
@@ -1,4 +1,4 @@
import { describe, it } from "mocha"
import { describe, it } from "bun:test"
import "should"
import { shouldStartNewOAuthFlow } from "../mcpOAuthFlow"
@@ -1,4 +1,4 @@
import { describe, it } from "mocha"
import { describe, it } from "bun:test"
import "should"
import { McpSettingsSchema, ServerConfigSchema } from "../schemas"
@@ -7,6 +7,7 @@
* validates provider switching capabilities, and ensures NoOpTelemetryProvider functionality
*/
import { after, before, describe, it } from "mocha"
import * as assert from "assert"
import * as sinon from "sinon"
import { ClineEndpoint } from "@/config"
@@ -1,3 +1,4 @@
import { describe, it } from "bun:test"
import { ApiFormat } from "@shared/proto/cline/models"
import * as assert from "assert"
import type { ITelemetryProvider, TelemetryProperties, TelemetrySettings } from "../providers/ITelemetryProvider"
@@ -1,8 +1,23 @@
import { afterEach, beforeEach, describe, it, mock } from "bun:test"
import { InMemoryLogRecordExporter, LoggerProvider, SimpleLogRecordProcessor } from "@opentelemetry/sdk-logs"
import { expect } from "chai"
import * as sinon from "sinon"
import type { ClineAccountUserInfo } from "@/services/auth/AuthService"
import * as distinctIdModule from "@/services/logging/distinctId"
import * as actualDistinctIdModule from "@/services/logging/distinctId"
// bun loads real ESM, so sinon cannot stub the `@/services/logging/distinctId`
// namespace exports ("ES Modules cannot be stubbed"). Inject module-level sinon
// stubs via mock.module so the full sinon stub API keeps working.
const getDistinctIdStub: sinon.SinonStub = sinon.stub()
const setDistinctIdStub: sinon.SinonStub = sinon.stub()
const distinctIdMock = () => ({
...actualDistinctIdModule,
getDistinctId: getDistinctIdStub,
setDistinctId: setDistinctIdStub,
})
mock.module("@/services/logging/distinctId", distinctIdMock)
mock.module("@services/logging/distinctId", distinctIdMock)
import { OpenTelemetryTelemetryProvider } from "../OpenTelemetryTelemetryProvider"
function makeUserInfo(
@@ -32,8 +47,6 @@ describe("OpenTelemetryTelemetryProvider.identifyUser", () => {
let logExporter: InMemoryLogRecordExporter
let loggerProvider: LoggerProvider
let provider: OpenTelemetryTelemetryProvider
let getDistinctIdStub: sinon.SinonStub
let setDistinctIdStub: sinon.SinonStub
beforeEach(() => {
logExporter = new InMemoryLogRecordExporter()
@@ -45,12 +58,15 @@ describe("OpenTelemetryTelemetryProvider.identifyUser", () => {
bypassUserSettings: true,
})
getDistinctIdStub = sinon.stub(distinctIdModule, "getDistinctId")
setDistinctIdStub = sinon.stub(distinctIdModule, "setDistinctId")
// Reset the module-level sinon stubs (injected via mock.module above).
getDistinctIdStub.reset()
setDistinctIdStub.reset()
})
afterEach(() => {
sinon.restore()
getDistinctIdStub.reset()
setDistinctIdStub.reset()
})
it("should emit user_identified log and update distinct ID when ID changes", () => {
@@ -1,12 +1,27 @@
import { afterEach, beforeEach, describe, it, mock } from "bun:test"
import { expect } from "chai"
import * as fs from "fs/promises"
import { afterEach, beforeEach, describe, it } from "mocha"
import os from "os"
import path from "path"
import * as sinon from "sinon"
import { WebviewProvider } from "@/core/webview"
import * as webhookHooks from "@/services/lg-cns-integration/webhook-hooks"
import * as actualWebhookHooks from "@/services/lg-cns-integration/webhook-hooks"
import { Logger } from "@/shared/services/Logger"
// bun loads real ESM, so sinon cannot stub the
// `@/services/lg-cns-integration/webhook-hooks` namespace exports ("ES Modules
// cannot be stubbed"). Inject module-level sinon stubs via mock.module so the
// full sinon stub API keeps working.
const writeLgWebhookConfigStub: sinon.SinonStub = sinon.stub()
const writeLgWebhookHooksStub: sinon.SinonStub = sinon.stub()
const webhookHooksMock = () => ({
...actualWebhookHooks,
writeLgWebhookConfig: writeLgWebhookConfigStub,
writeLgWebhookHooks: writeLgWebhookHooksStub,
})
mock.module("@/services/lg-cns-integration/webhook-hooks", webhookHooksMock)
mock.module("@services/lg-cns-integration/webhook-hooks", webhookHooksMock)
import { ErrorService } from "../error"
import { SharedUriHandler } from "./SharedUriHandler"
@@ -121,8 +136,12 @@ describe("SharedUriHandler", () => {
const promptFilePath = path.join(tempDir, "lg-spec.md")
await fs.writeFile(promptFilePath, "Implement user registration flow", "utf-8")
const writeConfigStub = sandbox.stub(webhookHooks, "writeLgWebhookConfig").resolves()
const writeHooksStub = sandbox.stub(webhookHooks, "writeLgWebhookHooks").resolves()
const writeConfigStub = writeLgWebhookConfigStub
writeConfigStub.reset()
writeConfigStub.resolves()
const writeHooksStub = writeLgWebhookHooksStub
writeHooksStub.reset()
writeHooksStub.resolves()
const result = await SharedUriHandler.handleUri(
`vscode://cline.cline/lg-task?prompt-file=${encodeURIComponent(
@@ -144,8 +163,12 @@ describe("SharedUriHandler", () => {
})
it("should return false when LG task parameters are missing", async () => {
const writeConfigStub = sandbox.stub(webhookHooks, "writeLgWebhookConfig").resolves()
const writeHooksStub = sandbox.stub(webhookHooks, "writeLgWebhookHooks").resolves()
const writeConfigStub = writeLgWebhookConfigStub
writeConfigStub.reset()
writeConfigStub.resolves()
const writeHooksStub = writeLgWebhookHooksStub
writeHooksStub.reset()
writeHooksStub.resolves()
const result = await SharedUriHandler.handleUri(
"vscode://cline.cline/lg-task?prompt-file=%2Ftmp%2Fspec.md&webhook-url=https%3A%2F%2Fexample.com",
)
@@ -1,3 +1,4 @@
import { describe, it } from "bun:test"
import { expect } from "chai"
import { mentionRegex, mentionRegexGlobal } from "../context-mentions"
@@ -1,5 +1,5 @@
import { describe, it } from "bun:test"
import { strict as assert } from "node:assert"
import { describe, it } from "mocha"
import type { ClineMessage } from "../ExtensionMessage"
import { getApiMetrics, getLastApiReqTotalTokens } from "../getApiMetrics"
@@ -1,5 +1,5 @@
import { describe, it } from "bun:test"
import { expect } from "chai"
import { describe, it } from "mocha"
import {
AwsBedrockSettingsSchema,
ClineSettingsSchema,
@@ -1,6 +1,6 @@
import { describe, it } from "bun:test"
import { getProviderCollectionSync } from "@cline/llms"
import { expect } from "chai"
import { describe, it } from "mocha"
import { getProviderDefaultModelId, getProviderModelIdKey } from "../provider-keys"
describe("Provider key mapping", () => {
@@ -35,8 +35,8 @@
* ```
*/
import { describe, it } from "bun:test"
import { expect } from "chai"
import { describe, it } from "mocha"
import {
applyTransform,
+213
View File
@@ -0,0 +1,213 @@
// Preload for `bun test` (registered via bunfig.toml [test] preload). It makes
// the SDK-adapter and model-catalog unit tests — written against vitest's `vi`
// API and module aliases — run under `bun test`.
//
// TODO: migrate these suites to native `bun:test` (use `mock.module` / `spyOn`
// directly and stub `vscode`/`@cline/core` per-file) and delete this preload's
// `vi` shim. Until then the detail below documents exactly why the shim is shaped
// the way it is.
//
// Two specifiers have real on-disk implementations that must be shadowed with
// lightweight stubs in unit tests:
//
// vscode -> src/test/vscode-vitest-stub.ts (no VS Code host under bun)
// @cline/core -> src/test/cline-core-vitest-stub.ts (lightweight SDK stub)
//
// bun's runtime plugin `onResolve` hook does NOT intercept these (`vscode` is
// host/builtin-like and `@cline/core` is a symlinked workspace package — both
// resolve below the JS plugin resolver). `mock.module()` is what works: it
// registers an in-memory override that takes precedence for the whole test
// process. (Other workspace packages — @cline/llms, @cline/shared,
// @cline/shared/storage — and the tsconfig `paths` aliases resolve on their own.)
//
// bun's ESM linker statically validates every named import against the names on
// the mock namespace. The stub only implements the @cline/core exports these
// tests exercise, but other modules in the import graph statically import
// additional names (e.g. `prepareRemoteConfigCoreIntegration`, `ClineCore`,
// `createMcpTools`); a missing name is a hard "Export named 'X' not found" link
// error. So we seed the mock namespace with every name the real @cline/core
// exports (value `undefined`) and overlay the stub on top: stub names keep stub
// behavior, every other valid import links as `undefined`.
//
// Importing the real package here is safe: the preload runs before any test
// file, so this is the only point the real module is linked, and we only read
// its export *names*, never its behavior (the mock shadows it everywhere tests look).
import { vi as bunVi, mock } from "bun:test"
import * as realClineCore from "@cline/core"
import * as clineCoreStub from "./cline-core-vitest-stub"
import * as vscodeStub from "./vscode-vitest-stub"
const clineCoreNamespace: Record<string, unknown> = {}
for (const name of Object.keys(realClineCore)) {
clineCoreNamespace[name] = undefined
}
Object.assign(clineCoreNamespace, clineCoreStub)
mock.module("@cline/core", () => clineCoreNamespace)
// `vscode`: the stub provides both named exports (Position, Uri, …) and a
// default export (the namespace object). Preserve both shapes so `import * as
// vscode from "vscode"` and `import vscode from "vscode"` both work.
mock.module("vscode", () => ({ ...vscodeStub, default: vscodeStub.default }))
// `vitest`: the SDK-adapter and model-catalog unit tests import test primitives
// and the `vi` helper namespace from "vitest". bun test provides describe/it/
// expect/etc. and a partial `vi` object under `bun:test`, but does NOT register
// a `vitest` module, so we alias one here, backed by the real bun:test
// implementations wherever a 1:1 mapping exists. The remaining vitest-only
// helpers (`vi.hoisted`, `vi.mock`, `vi.waitFor`, `vi.importActual`,
// `vi.mocked`, `vi.resetModules`) are shimmed on top:
//
// • vi.hoisted(fn) — vitest hoists these above imports; under bun's ESM
// ordering all imports settle before top-level test
// code runs, so simply executing the factory in place
// is sufficient (the returned object is referenced by
// later vi.mock factory closures, which are lazy).
// • vi.mock(spec, fac) — mapped to bun's mock.module. bun applies module
// mocks retroactively to already-linked imports, which
// matches vitest's hoisted-mock observable behavior.
// • vi.waitFor(fn,opts) — poll fn until it resolves/returns without throwing.
// • vi.importActual(s) — the module as resolved by vitest's `resolve.alias`
// BEFORE `vi.mock` is layered on. For specifiers we
// substitute via `mock.module` in this preload
// (@cline/core, vscode), calling bun's `import()` from
// inside a `vi.mock(sameSpecifier)` factory re-enters
// the in-flight mock and DEADLOCKS. So we serve those
// from a registry of the pre-built "actual" namespaces
// (the stub modules vitest's alias points at) and only
// fall back to real `import()` for everything else.
// • vi.mocked(v) — identity (a TypeScript typing helper at runtime).
// • vi.resetModules() — no-op; bun has no module registry to reset and these
// suites re-establish their mocks per-test.
const viWaitFor = async <T>(predicate: () => T | Promise<T>, options?: { timeout?: number; interval?: number }): Promise<T> => {
const timeout = options?.timeout ?? 1000
const interval = options?.interval ?? 20
const deadline = Date.now() + timeout
let lastError: unknown
// eslint-disable-next-line no-constant-condition
while (true) {
try {
return await predicate()
} catch (error) {
lastError = error
if (Date.now() >= deadline) {
throw lastError
}
await new Promise((resolve) => setTimeout(resolve, interval))
}
}
}
// IMPORTANT: bun has a *built-in* `vitest` → `bun:test` compatibility shim that
// takes precedence over `mock.module("vitest", …)` (the alias does not fire for
// the `vitest` specifier — verified empirically). The `vi` object that bun
// hands to `import { vi } from "vitest"` is the SAME singleton as
// `import { vi } from "bun:test"`. So instead of registering a fake module, we
// augment that shared `vi` singleton in place with the vitest-only helpers
// bun's compat layer omits.
// Registry of pre-built "actual" namespaces for the specifiers this preload
// substitutes. `importActual` serves these directly to avoid the re-entrant
// mock-factory deadlock described above.
const actualNamespaceRegistry: Record<string, unknown> = {
"@cline/core": clineCoreNamespace,
vscode: { ...vscodeStub, default: vscodeStub.default },
}
// `vi.mock` → `mock.module`, with two adaptations to match vitest semantics
// that bun's `mock.module` lacks:
//
// (1) vitest passes an `importOriginal` helper to the factory; bun does not.
// We pass one (resolving from the "actual" registry, see importActual).
//
// (2) bun's `mock.module` DEADLOCKS if the factory is an async function that
// actually suspends on an `await` — bun blocks the importing thread on the
// returned promise without pumping the microtask queue (verified
// empirically; bun's docs only ever show async factories that return
// synchronously). vitest, by contrast, awaits async `vi.mock` factories
// (e.g. ones that `await importOriginal()` / `await vi.importActual(...)`).
// To bridge this without rewriting test files we invoke the factory
// OURSELVES:
// • sync result → register it directly.
// • promise result → register a synchronous placeholder FIRST (seeded
// with the original module's export names so consumers' named imports
// link), then re-register synchronously with the resolved namespace
// once the promise settles. A plain deferred-only register does NOT
// propagate to already-linked *named* imports — the up-front
// placeholder is what makes the live-binding update stick. `vi.mock`
// runs at module top level and tests only run after an await boundary,
// so the resolved registration is always in place before the first
// test executes.
const resolveOriginalNamespace = (specifier: string): unknown => {
if (specifier in actualNamespaceRegistry) {
return actualNamespaceRegistry[specifier]
}
try {
// bun resolves `require` synchronously for both ESM and CJS workspace deps.
return (globalThis as { require?: (id: string) => unknown }).require?.(specifier)
} catch {
return undefined
}
}
const viMock = (specifier: string, factory?: (importOriginal?: () => unknown) => unknown) => {
if (typeof factory !== "function") {
return mock.module(specifier, () => ({}))
}
const importOriginal = () => resolveOriginalNamespace(specifier)
const result = factory(importOriginal)
if (result && typeof (result as { then?: unknown }).then === "function") {
// Seed a synchronous placeholder carrying the original export names so
// consumers' named imports link before the async factory resolves.
const original = resolveOriginalNamespace(specifier)
const placeholder: Record<string, unknown> = {}
if (original && typeof original === "object") {
for (const name of Object.keys(original as object)) {
placeholder[name] = (original as Record<string, unknown>)[name]
}
}
mock.module(specifier, () => placeholder)
void (result as Promise<unknown>).then((resolved) => {
// Mutate the SAME placeholder object the live ESM bindings already
// reference, then re-register it. Copying onto the existing object
// (rather than swapping in a fresh one) is what makes already-linked
// *named* imports observe the resolved values — re-registering a brand
// new object can race the first test before the binding updates.
if (resolved && typeof resolved === "object") {
for (const name of Object.keys(resolved as object)) {
placeholder[name] = (resolved as Record<string, unknown>)[name]
}
}
mock.module(specifier, () => placeholder)
})
return
}
return mock.module(specifier, () => result as object)
}
const viExtensions: Record<string, unknown> = {
mocked: (value: unknown) => value,
mock: viMock,
hoisted: <T>(factory: () => T): T => factory(),
// NOTE: return the registry value *synchronously* (not via an async
// function) for known specifiers. bun blocks synchronously while awaiting an
// async `vi.mock` factory and does not pump the microtask queue, so an
// `async` importActual would never settle its continuation from inside a
// factory → deadlock. Returning the plain namespace lets the factory's
// `await` resolve in the same tick. Only the (rare) real-import fallback
// returns a Promise.
importActual: <T = unknown>(specifier: string): T | Promise<T> => {
if (specifier in actualNamespaceRegistry) {
return actualNamespaceRegistry[specifier] as T
}
return import(specifier) as Promise<T>
},
importMock: <T = unknown>(specifier: string): Promise<T> => import(specifier) as Promise<T>,
resetModules: () => bunVi,
waitFor: viWaitFor,
}
// Force-install every entry: these are intentional replacements/additions.
// In particular `vi.mock` ALREADY exists on bun's `vi` (and is the one that
// deadlocks on async factories), so it must be overwritten, not skipped.
for (const [name, impl] of Object.entries(viExtensions)) {
;(bunVi as Record<string, unknown>)[name] = impl
}

Some files were not shown because too many files have changed in this diff Show More