The final part — take a working extension from local code to the Marketplace and Open VSX, with automated tests and a release pipeline that ships it for you.
@vscode/test-cli.vscodeignore file.vsix with vsceFrom source to two registries: each stage gates the next, and CI/CD wraps the whole thing so a git tag ships your extension automatically.
Run automated tests inside a real VS Code Extension Host before anything ships.
Bundle, then build a single .vsix archive — the unit of distribution.
Push the same .vsix to both the Marketplace and Open VSX for full reach.
VS Code tests run inside a real, downloaded copy of VS Code — your test code executes in the Extension Host with the full vscode API available.
// .vscode-test.mjs
import { defineConfig } from '@vscode/test-cli';
export default defineConfig({
// glob(s) for your compiled test files
files: 'out/test/**/*.test.js',
// which VS Code build to download & run
version: 'stable', // or 'insiders', '1.90.0'
// workspace to open for the tests
workspaceFolder: './sampleWorkspace',
mocha: {
ui: 'tdd', // suite() / test()
timeout: 20000,
},
});
// package.json — scripts
{
"scripts": {
"compile": "tsc -p ./",
"watch": "tsc -watch -p ./",
"pretest": "npm run compile && npm run lint",
"test": "vscode-test"
}
}
@vscode/test-cliThe test runner. Reads .vscode-test.mjs, drives Mocha, and exposes the vscode-test command.
@vscode/test-electronDownloads the requested VS Code build and launches it with your extension loaded. Used under the hood by the CLI.
npm i -D @vscode/test-cli \
@vscode/test-electron \
mocha @types/mocha
The API only exists inside the host. Tests exercise commands, editors and the workspace exactly as users do.
Import * as vscode and * as assert, then drive the real API — open documents, run your commands, and assert on the result.
// src/test/extension.test.ts
import * as assert from 'assert';
import * as vscode from 'vscode';
suite('My Extension Test Suite', () => {
test('command is registered', async () => {
const cmds = await vscode.commands.getCommands(true);
assert.ok(cmds.includes('myExt.helloWorld'));
});
test('command shows a message', async () => {
// run the command the user would invoke
await vscode.commands.executeCommand('myExt.helloWorld');
// (assert on side-effects / state here)
});
test('opens and edits a document', async () => {
const doc = await vscode.workspace.openTextDocument({
content: 'hello',
language: 'plaintext',
});
const editor = await vscode.window.showTextDocument(doc);
await editor.edit(b =>
b.insert(new vscode.Position(0, 5), ' world'));
assert.strictEqual(doc.getText(), 'hello world');
});
});
suite() groups related teststest() is one case (Mocha TDD UI)suiteSetup / teardown for fixturesUse vscode.extensions.getExtension(id)?.activate() if a test needs activation before the API is ready.
# Local run — compiles, lints, then tests
npm test
# Or invoke the runner directly
npx vscode-test
# Run against a specific build / label
npx vscode-test --label unit
# On Linux CI there is no display server,
# so wrap the runner in a virtual one:
xvfb-run -a npm test
VS Code is an Electron app and needs a display. On Linux runners use xvfb-run to provide a virtual framebuffer. macOS and Windows runners need no wrapper.
The Extension Test Runner extension and a launch.json "Extension Tests" config let you set breakpoints in the host.
vscode API availablevscode moduleKeep logic out of activate() in pure modules — you can then unit-test most of it without launching VS Code, and reserve integration tests for the API surface.
Catch problems before they ship: lint for style and common bugs, type-check for correctness. Wire both into pretest so npm test always validates first.
// package.json — scripts
{
"scripts": {
"lint": "eslint src --ext ts",
"check-types": "tsc --noEmit",
"compile": "tsc -p ./",
"pretest": "npm run compile && npm run lint",
"test": "vscode-test"
}
}
// eslint.config.mjs (flat config)
import tseslint from 'typescript-eslint';
export default tseslint.config(
...tseslint.configs.recommended,
{
rules: {
'no-console': 'warn',
'@typescript-eslint/no-unused-vars': 'error',
},
},
);
eslintLints TypeScript via typescript-eslint. The generator scaffolds a flat eslint.config.mjs for you.
tsc --noEmitType-checks without producing output — fast, perfect for CI when a bundler (not tsc) emits the JS.
pretest hooknpm runs any pre<script> automatically. pretest compiles & lints, so a failing lint fails the test run.
Run the same lint + check-types in CI before packaging — never publish code that doesn't type-check.
Bundle your extension into a single file before packaging. Fewer files means a smaller .vsix, faster install, and faster activation.
# One-off production bundle with esbuild
esbuild src/extension.ts \
--bundle \
--outfile=dist/extension.js \
--external:vscode \
--format=cjs \
--platform=node \
--minify
// package.json
{
"main": "./dist/extension.js",
"scripts": {
"esbuild-base": "esbuild src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node",
"vscode:prepublish": "npm run esbuild-base -- --minify",
"watch": "npm run esbuild-base -- --sourcemap --watch"
}
}
Hundreds of files across node_modules. Slow to read from disk — activation drags.
One dist/extension.js. Smaller package, fewer file reads, noticeably faster activation.
vscode externalThe host provides the vscode module at runtime — never bundle it. Mark it --external:vscode.
vscode:prepublishvsce runs this script automatically before packaging, so the published bundle is always the minified build.
esbuild is fast and simple; webpack suits complex setups. Either works — the wins are the same.
.vscodeignore FileLike .gitignore, but for the package: .vscodeignore tells vsce what to leave out of the .vsix. Once you bundle, exclude source, tests, and dev dependencies.
# .vscodeignore
.vscode/**
.vscode-test/**
.vscode-test.mjs
# source & tests — only ship the bundle
src/**
out/**
**/*.ts
**/*.map
# tooling & config
.gitignore
.eslintrc*
eslint.config.mjs
tsconfig.json
esbuild.js
**/.github/**
# when bundling, drop node_modules entirely
node_modules/**
If esbuild inlines deps into dist/extension.js, you can ignore all of node_modules — the runtime deps are already in the bundle.
dist/** (your bundle)package.json, README.mdCHANGELOG.md, LICENSE, iconvsce ls lists exactly what the package will contain. Run it to confirm nothing private leaks in.
vscevsce (now published as @vscode/vsce) is the official packaging & publishing CLI. vsce package produces a single .vsix archive.
# Install the CLI globally
npm install -g @vscode/vsce
# Build the package → my-ext-0.0.1.vsix
vsce package
# List what will be included (dry run)
vsce ls
# Install the .vsix locally to test it
code --install-extension my-ext-0.0.1.vsix
# …or in VS Code: Extensions view →
# "…" menu → "Install from VSIX…"
.vsixA zip of your bundle + manifest + assets, named <name>-<version>.vsix. It is the unit you install or publish.
name, displayName, versionpublisher — your Marketplace publisher IDengines.vscode — min supported APIrepository — source URLicon — 128×128 PNGREADME.md — the Marketplace pageLICENSE — or vsce warnscategories, keywords for discoveryPackaging fails or warns on a missing repository, LICENSE, or a placeholder README — fix these before publishing.
Publishing to the Marketplace needs a publisher identity and an Azure DevOps Personal Access Token (PAT). This is a one-time setup.
In the Visual Studio Marketplace management portal (marketplace.visualstudio.com/manage), create a publisher. Its ID becomes the publisher field in package.json.
vsce login <publisher>
# paste the PAT when prompted
# Steps at a glance
# a) publisher created in the portal
# b) PAT scoped to Marketplace → Manage
# c) authenticate the CLI
vsce login my-publisher
# CI uses the PAT non-interactively
# via the VSCE_PAT environment variable
export VSCE_PAT=xxxxxxxxxxxxxxxx
vsce publish
VSCE_PAT)A verified publisher (domain check) earns a badge and builds user trust — optional but recommended.
With a publisher and PAT in place, vsce publish packages and uploads in one step. Pass a semver keyword to bump the version, commit and tag automatically.
# Publish the current version in package.json
vsce publish
# Bump version, then publish (semver):
vsce publish patch # 1.0.0 → 1.0.1
vsce publish minor # 1.0.1 → 1.1.0
vsce publish major # 1.1.0 → 2.0.0
# Or publish an exact version
vsce publish 1.2.3
# Publish a pre-built .vsix
vsce publish --packagePath my-ext-1.2.3.vsix
# CI: authenticate via env, no prompt
vsce publish -p $VSCE_PAT
vsce publish minor updates version in package.json, makes a git commit, and creates a tag — all before uploading.
The new version appears on the Marketplace within minutes and auto-updates for installed users. Push your tags with git push --follow-tags.
The Marketplace only serves Microsoft's VS Code build. Open VSX is the open registry used by VSCodium, Gitpod, Theia, Eclipse and others — dual-publish to reach those users too.
# Install the Open VSX CLI
npm install -g ovsx
# Publish an existing .vsix to Open VSX
npx ovsx publish my-ext-1.2.3.vsix -p <token>
# …or package + publish in one go
npx ovsx publish -p <token>
# In CI, read the token from a secret
npx ovsx publish *.vsix -p $OVSX_PAT
ovsx create-namespace <name>.vsix — no separate buildBuild the .vsix once with vsce package, then publish that exact file to both registries. No divergence between them.
VSCE_PAT for the Marketplace, OVSX_PAT for Open VSX. Store both as encrypted CI secrets.
Wire it all together: on a version tag, the workflow installs, lints, tests (headless via xvfb-run), packages, and publishes to both registries from repo secrets.
# .github/workflows/release.yml
name: Release
on:
push:
tags: ['v*']
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
# lint + type-check + tests
- run: npm run lint
- run: xvfb-run -a npm test
# build the .vsix once
- run: npm install -g @vscode/vsce ovsx
- run: vsce package -o extension.vsix
# publish to the VS Marketplace
- run: vsce publish -p $VSCE_PAT \
--packagePath extension.vsix
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
# publish the same file to Open VSX
- run: npx ovsx publish extension.vsix \
-p $OVSX_PAT
env:
OVSX_PAT: ${{ secrets.OVSX_PAT }}
git tag v1.2.3 && git push --tags triggers itxvfb-run gives the Linux runner a displayVSCE_PAT / OVSX_PAT stay encrypted.vsix, fast installactivate() leanCHANGELOG.md; follow semver disciplineYou can build, test and ship a VS Code extension end-to-end — from activate() to the Marketplace and Open VSX.
Scaffolding with Yeoman, the manifest, activate(), the dev loop & first command.
Commands, menus, keybindings, configuration & activation events.
Editors, the workspace, windows, status bar, events & disposables.
Webview UIs, completions, hovers, diagnostics & language providers.
Tests, bundling, the .vsix, vsce, Open VSX & a CI/CD release pipeline.