Utiliser, créer et publier une extension VS Code en TypeScript : commandes, configuration, vues, webviews, diagnostics, tests, bundling et publication sur le Marketplace et Open VSX.
Une extension se teste à deux niveaux. La logique pure (le scanner) se teste comme n'importe quel module Node.js, en quelques millisecondes. L'intégration avec VS Code (commandes, diagnostics) demande de lancer un vrai VS Code, piloté par le framework de test officiel. Puis on réduit le paquet à un seul fichier JavaScript.
C'est le bénéfice d'avoir isolé scanner.ts au chapitre 2 : il n'importe pas vscode, donc il se teste avec le test runner intégré à Node.js, sans rien installer.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { scanText, hasTicket } from '../../scanner'
test('détecte les trois marqueurs par défaut', () => {
const source = [
'const a = 1 // TODO: renommer',
'// FIXME fuite mémoire',
'/* HACK contournement IE */',
'const todoList = []',
].join('\n')
const matches = scanText(source)
assert.deepEqual(matches.map((m) => m.tag), ['TODO', 'FIXME', 'HACK'])
assert.equal(matches[0].text, 'renommer')
assert.equal(matches[1].line, 1)
})
test('ignore les mots qui contiennent le marqueur', () => {
assert.equal(scanText('const todoList = []; const TODOS = 3').length, 0)
})
test('reconnaît les références de ticket', () => {
const [withHash, withJira, without] = scanText(
'// TODO #142 pagination\n// TODO PROJ-87 cache\n// TODO nettoyer'
)
assert.equal(hasTicket(withHash), true)
assert.equal(hasTicket(withJira), true)
assert.equal(hasTicket(without), false)
})"scripts": {
"test:unit": "node --import tsx --test src/test/unit/*.test.ts"
}Il faut tsx en dépendance de développement pour exécuter le TypeScript directement (npm install -D tsx). Ces tests tournent en une fraction de seconde : on les lance à chaque enregistrement.
Pour vérifier qu'une commande existe, que les diagnostics apparaissent ou qu'un réglage est pris en compte, il faut l'API vscode. Elle n'existe qu'à l'intérieur de VS Code. @vscode/test-cli télécharge une instance de VS Code, y charge l'extension et exécute les tests Mocha dedans.
Le générateur a déjà installé @vscode/test-cli et @vscode/test-electron. La configuration :
import { defineConfig } from '@vscode/test-cli'
export default defineConfig({
files: 'out/test/integration/**/*.test.js',
workspaceFolder: './test-fixtures',
mocha: {
ui: 'tdd',
timeout: 20000,
},
})workspaceFolder ouvre un dossier de test qui contient des fichiers avec des marqueurs connus :
// TODO #12 : avec ticket
// TODO : sans ticket
export const value = 42 // FIXME valeur en durEt un réglage qui active la règle des tickets pour ce dossier :
{
"todoRadar.requireTicket": true
}Les tests :
import * as assert from 'assert'
import * as vscode from 'vscode'
const EXTENSION_ID = 'iducation.todo-radar'
suite('TODO Radar', () => {
suiteSetup(async () => {
await vscode.extensions.getExtension(EXTENSION_ID)!.activate()
})
test('les commandes sont enregistrées', async () => {
const commands = await vscode.commands.getCommands(true)
for (const id of ['todo-radar.countInFile', 'todo-radar.refresh', 'todo-radar.showStats']) {
assert.ok(commands.includes(id), `commande manquante : ${id}`)
}
})
test('signale le TODO sans ticket', async () => {
const [uri] = await vscode.workspace.findFiles('sample.ts')
await vscode.window.showTextDocument(uri)
// Laisser le temps au debounce et au calcul
await new Promise((resolve) => setTimeout(resolve, 600))
const diagnostics = vscode.languages
.getDiagnostics(uri)
.filter((d) => d.source === 'TODO Radar')
assert.equal(diagnostics.length, 1)
assert.equal(diagnostics[0].range.start.line, 1)
})
})EXTENSION_ID est publisher.name. On fixera le publisher au chapitre 8 ; tant qu'il manque, VS Code utilise undefined_publisher.
Les tests d'intégration sont compilés par tsc dans out/, séparément du bundle :
"scripts": {
"compile-tests": "tsc -p . --outDir out",
"pretest": "npm run compile-tests && npm run compile",
"test": "vscode-test"
}npm test télécharge VS Code au premier lancement (dans .vscode-test/, à ajouter au .gitignore), puis exécute la suite. Pour lancer les tests depuis l'éditeur avec points d'arrêt, installez l'extension Extension Test Runner : les tests apparaissent dans le panneau Testing.
Sur un serveur Linux sans écran, VS Code a besoin d'un affichage virtuel : préfixez la commande par xvfb-run -a. On s'en sert dans la CI du chapitre suivant.
Sans bundler, le paquet publié contient out/ et tout node_modules de production. VS Code doit charger des centaines de petits fichiers à l'activation. Avec esbuild, tout tient dans un dist/extension.js minifié.
L'écart est concret. Sur une extension qui dépend d'une seule librairie moyenne, on passe couramment de plusieurs milliers de fichiers à quelques-uns, et d'un temps d'activation de plusieurs centaines de millisecondes à quelques dizaines. Developer: Show Running Extensions permet de mesurer l'activation avant et après.
Le générateur a créé esbuild.js. Version simplifiée pour comprendre chaque option :
const esbuild = require('esbuild')
const production = process.argv.includes('--production')
const watch = process.argv.includes('--watch')
async function main() {
const ctx = await esbuild.context({
entryPoints: ['src/extension.ts'],
bundle: true,
format: 'cjs',
platform: 'node',
outfile: 'dist/extension.js',
external: ['vscode'],
minify: production,
sourcemap: !production,
sourcesContent: false,
logLevel: 'info',
})
if (watch) {
await ctx.watch()
} else {
await ctx.rebuild()
await ctx.dispose()
}
}
main().catch((error) => {
console.error(error)
process.exit(1)
})Les trois options à ne pas toucher :
external: ['vscode'] : le module vscode n'existe pas sur npm, il est fourni par l'éditeur à l'exécution. Le bundler doit le laisser tel quel ;format: 'cjs' : VS Code charge les extensions comme des modules CommonJS ;platform: 'node' : les modules Node (path, crypto) restent des imports natifs.esbuild ne vérifie pas les types, il les retire. C'est pour ça que le script package lance tsc --noEmit avant : sans lui, une erreur de type passerait jusqu'en production.
Pour une web extension (vscode.dev), on ajoute un second build avec platform: 'browser', format: 'cjs' et une sortie déclarée dans le champ browser du manifeste. Le code ne peut alors utiliser ni fs ni child_process : c'est une raison de plus d'avoir utilisé vscode.workspace.fs au chapitre 4.
.vscodeignore liste ce qu'il ne faut pas publier. Avec un bundle, presque tout s'exclut :
.vscode/**
.vscode-test/**
src/**
out/**
test-fixtures/**
node_modules/**
esbuild.js
tsconfig.json
.vscode-test.mjs
**/*.map
**/*.ts
.gitignorenode_modules/** peut être exclu entièrement parce que tout a été intégré au bundle. Attention à l'exception : une dépendance avec un binaire natif (.node) ne se bundle pas. Elle doit rester dans node_modules et être réincluse avec une ligne !node_modules/ma-dependance/**.
Pour vérifier le contenu exact avant publication :
npx @vscode/vsce lsLa commande liste chaque fichier qui partira dans le paquet. Si vous y voyez src/ ou des fichiers .map, .vscodeignore est incomplet.
Testée, bundlée, légère : l'extension est prête à sortir de votre machine. Le dernier chapitre la publie sur le Marketplace de Microsoft et sur Open VSX, puis automatise les publications suivantes.