Path: blob/main/build/azure-pipelines/common/versionCompatibility.ts
5241 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import assert from 'assert';67export interface IExtensionManifest {8name: string;9publisher: string;10version: string;11engines: { vscode: string };12main?: string;13browser?: string;14enabledApiProposals?: string[];15}1617export function isEngineCompatible(productVersion: string, engineVersion: string): { compatible: boolean; error?: string } {18if (engineVersion === '*') {19return { compatible: true };20}2122const versionMatch = engineVersion.match(/^(\^|>=)?(\d+)\.(\d+)\.(\d+)/);23if (!versionMatch) {24return { compatible: false, error: `Could not parse engines.vscode value: ${engineVersion}` };25}2627const [, prefix, major, minor, patch] = versionMatch;28const productMatch = productVersion.match(/^(\d+)\.(\d+)\.(\d+)/);29if (!productMatch) {30return { compatible: false, error: `Could not parse product version: ${productVersion}` };31}3233const [, prodMajor, prodMinor, prodPatch] = productMatch;3435const reqMajor = parseInt(major);36const reqMinor = parseInt(minor);37const reqPatch = parseInt(patch);38const pMajor = parseInt(prodMajor);39const pMinor = parseInt(prodMinor);40const pPatch = parseInt(prodPatch);4142if (prefix === '>=') {43// Minimum version check44if (pMajor > reqMajor) { return { compatible: true }; }45if (pMajor < reqMajor) { return { compatible: false, error: `Extension requires VS Code >=${engineVersion}, but product version is ${productVersion}` }; }46if (pMinor > reqMinor) { return { compatible: true }; }47if (pMinor < reqMinor) { return { compatible: false, error: `Extension requires VS Code >=${engineVersion}, but product version is ${productVersion}` }; }48if (pPatch >= reqPatch) { return { compatible: true }; }49return { compatible: false, error: `Extension requires VS Code >=${engineVersion}, but product version is ${productVersion}` };50}5152// Caret or exact version check53if (pMajor !== reqMajor) {54return { compatible: false, error: `Extension requires VS Code ${engineVersion}, but product version is ${productVersion} (major version mismatch)` };55}5657if (prefix === '^') {58// Caret: same major, minor and patch must be >= required59if (pMinor > reqMinor) { return { compatible: true }; }60if (pMinor < reqMinor) { return { compatible: false, error: `Extension requires VS Code ${engineVersion}, but product version is ${productVersion}` }; }61if (pPatch >= reqPatch) { return { compatible: true }; }62return { compatible: false, error: `Extension requires VS Code ${engineVersion}, but product version is ${productVersion}` };63}6465// Exact or default behavior66if (pMinor < reqMinor) { return { compatible: false, error: `Extension requires VS Code ${engineVersion}, but product version is ${productVersion}` }; }67if (pMinor > reqMinor) { return { compatible: true }; }68if (pPatch >= reqPatch) { return { compatible: true }; }69return { compatible: false, error: `Extension requires VS Code ${engineVersion}, but product version is ${productVersion}` };70}7172export function parseApiProposals(enabledApiProposals: string[]): { proposalName: string; version?: number }[] {73return enabledApiProposals.map(proposal => {74const [proposalName, version] = proposal.split('@');75return { proposalName, version: version ? parseInt(version) : undefined };76});77}7879export function areApiProposalsCompatible(80apiProposals: string[],81productApiProposals: Readonly<{ [proposalName: string]: Readonly<{ proposal: string; version?: number }> }>82): { compatible: boolean; errors: string[] } {83if (apiProposals.length === 0) {84return { compatible: true, errors: [] };85}8687const errors: string[] = [];88const parsedProposals = parseApiProposals(apiProposals);8990for (const { proposalName, version } of parsedProposals) {91if (!version) {92continue;93}94const existingProposal = productApiProposals[proposalName];95if (!existingProposal) {96errors.push(`API proposal '${proposalName}' does not exist in this version of VS Code`);97} else if (existingProposal.version !== version) {98errors.push(`API proposal '${proposalName}' version mismatch: extension requires version ${version}, but VS Code has version ${existingProposal.version ?? 'unversioned'}`);99}100}101102return { compatible: errors.length === 0, errors };103}104105export function parseApiProposalsFromSource(content: string): { [proposalName: string]: { proposal: string; version?: number } } {106const allApiProposals: { [proposalName: string]: { proposal: string; version?: number } } = {};107108// Match proposal blocks like: proposalName: {\n\t\tproposal: '...',\n\t\tversion: N\n\t}109// or: proposalName: {\n\t\tproposal: '...',\n\t}110const proposalBlockRegex = /\t(\w+):\s*\{([^}]+)\}/g;111const versionRegex = /version:\s*(\d+)/;112113let match;114while ((match = proposalBlockRegex.exec(content)) !== null) {115const [, name, block] = match;116const versionMatch = versionRegex.exec(block);117allApiProposals[name] = {118proposal: '',119version: versionMatch ? parseInt(versionMatch[1]) : undefined120};121}122123return allApiProposals;124}125126export function areAllowlistedApiProposalsMatching(127extensionId: string,128productAllowlistedProposals: string[] | undefined,129manifestEnabledProposals: string[] | undefined130): { compatible: boolean; errors: string[] } {131// Normalize undefined to empty arrays for easier comparison132const productProposals = productAllowlistedProposals || [];133const manifestProposals = manifestEnabledProposals || [];134135// If extension doesn't declare any proposals, it's always compatible136// (product.json can allowlist more than the extension uses)137if (manifestProposals.length === 0) {138return { compatible: true, errors: [] };139}140141// If extension declares API proposals but product.json doesn't allowlist them142if (productProposals.length === 0) {143return {144compatible: false,145errors: [146`Extension '${extensionId}' declares API proposals in package.json (${manifestProposals.join(', ')}) ` +147`but product.json does not allowlist any API proposals for this extension`148]149};150}151152// Check that all proposals in manifest are allowlisted in product.json153// (product.json can have extra proposals that the extension doesn't use)154// Note: Strip version suffixes from manifest proposals (e.g., "chatParticipant@2" -> "chatParticipant")155// because product.json only contains base proposal names156const productSet = new Set(productProposals);157const errors: string[] = [];158159for (const proposal of manifestProposals) {160// Strip version suffix if present (e.g., "chatParticipant@2" -> "chatParticipant")161const proposalName = proposal.split('@')[0];162if (!productSet.has(proposalName)) {163errors.push(`API proposal '${proposal}' is declared in extension '${extensionId}' package.json but is not allowlisted in product.json`);164}165}166167return { compatible: errors.length === 0, errors };168}169170export function checkExtensionCompatibility(171productVersion: string,172productApiProposals: Readonly<{ [proposalName: string]: Readonly<{ proposal: string; version?: number }> }>,173manifest: IExtensionManifest174): { compatible: boolean; errors: string[] } {175const errors: string[] = [];176177// Check engine compatibility178const engineResult = isEngineCompatible(productVersion, manifest.engines.vscode);179if (!engineResult.compatible) {180errors.push(engineResult.error!);181}182183// Check API proposals compatibility184if (manifest.enabledApiProposals?.length) {185const apiResult = areApiProposalsCompatible(manifest.enabledApiProposals, productApiProposals);186if (!apiResult.compatible) {187errors.push(...apiResult.errors);188}189}190191return { compatible: errors.length === 0, errors };192}193194if (import.meta.main) {195console.log('Running version compatibility tests...\n');196197// isEngineCompatible tests198console.log('Testing isEngineCompatible...');199200// Wildcard201assert.strictEqual(isEngineCompatible('1.50.0', '*').compatible, true);202203// Invalid engine version204assert.strictEqual(isEngineCompatible('1.50.0', 'invalid').compatible, false);205206// Invalid product version207assert.strictEqual(isEngineCompatible('invalid', '1.50.0').compatible, false);208209// >= prefix210assert.strictEqual(isEngineCompatible('1.50.0', '>=1.50.0').compatible, true);211assert.strictEqual(isEngineCompatible('1.50.1', '>=1.50.0').compatible, true);212assert.strictEqual(isEngineCompatible('1.51.0', '>=1.50.0').compatible, true);213assert.strictEqual(isEngineCompatible('2.0.0', '>=1.50.0').compatible, true);214assert.strictEqual(isEngineCompatible('1.49.0', '>=1.50.0').compatible, false);215assert.strictEqual(isEngineCompatible('1.50.0', '>=1.50.1').compatible, false);216assert.strictEqual(isEngineCompatible('0.50.0', '>=1.50.0').compatible, false);217218// ^ prefix (caret)219assert.strictEqual(isEngineCompatible('1.50.0', '^1.50.0').compatible, true);220assert.strictEqual(isEngineCompatible('1.50.1', '^1.50.0').compatible, true);221assert.strictEqual(isEngineCompatible('1.51.0', '^1.50.0').compatible, true);222assert.strictEqual(isEngineCompatible('1.49.0', '^1.50.0').compatible, false);223assert.strictEqual(isEngineCompatible('1.50.0', '^1.50.1').compatible, false);224assert.strictEqual(isEngineCompatible('2.0.0', '^1.50.0').compatible, false);225226// Exact/default (no prefix)227assert.strictEqual(isEngineCompatible('1.50.0', '1.50.0').compatible, true);228assert.strictEqual(isEngineCompatible('1.50.1', '1.50.0').compatible, true);229assert.strictEqual(isEngineCompatible('1.51.0', '1.50.0').compatible, true);230assert.strictEqual(isEngineCompatible('1.49.0', '1.50.0').compatible, false);231assert.strictEqual(isEngineCompatible('1.50.0', '1.50.1').compatible, false);232assert.strictEqual(isEngineCompatible('2.0.0', '1.50.0').compatible, false);233234console.log(' ✓ isEngineCompatible tests passed\n');235236// parseApiProposals tests237console.log('Testing parseApiProposals...');238239assert.deepStrictEqual(parseApiProposals([]), []);240assert.deepStrictEqual(parseApiProposals(['proposalA']), [{ proposalName: 'proposalA', version: undefined }]);241assert.deepStrictEqual(parseApiProposals(['proposalA@1']), [{ proposalName: 'proposalA', version: 1 }]);242assert.deepStrictEqual(parseApiProposals(['proposalA@1', 'proposalB', 'proposalC@3']), [243{ proposalName: 'proposalA', version: 1 },244{ proposalName: 'proposalB', version: undefined },245{ proposalName: 'proposalC', version: 3 }246]);247248console.log(' ✓ parseApiProposals tests passed\n');249250// areApiProposalsCompatible tests251console.log('Testing areApiProposalsCompatible...');252253const productProposals = {254proposalA: { proposal: '', version: 1 },255proposalB: { proposal: '', version: 2 },256proposalC: { proposal: '' } // unversioned257};258259// Empty proposals260assert.strictEqual(areApiProposalsCompatible([], productProposals).compatible, true);261262// Unversioned extension proposals (always compatible)263assert.strictEqual(areApiProposalsCompatible(['proposalA', 'proposalB'], productProposals).compatible, true);264assert.strictEqual(areApiProposalsCompatible(['unknownProposal'], productProposals).compatible, true);265266// Versioned proposals - matching267assert.strictEqual(areApiProposalsCompatible(['proposalA@1'], productProposals).compatible, true);268assert.strictEqual(areApiProposalsCompatible(['proposalA@1', 'proposalB@2'], productProposals).compatible, true);269270// Versioned proposals - version mismatch271assert.strictEqual(areApiProposalsCompatible(['proposalA@2'], productProposals).compatible, false);272assert.strictEqual(areApiProposalsCompatible(['proposalB@1'], productProposals).compatible, false);273274// Versioned proposals - missing proposal275assert.strictEqual(areApiProposalsCompatible(['unknownProposal@1'], productProposals).compatible, false);276277// Versioned proposals - product has unversioned278assert.strictEqual(areApiProposalsCompatible(['proposalC@1'], productProposals).compatible, false);279280// Mixed versioned and unversioned281assert.strictEqual(areApiProposalsCompatible(['proposalA@1', 'proposalB'], productProposals).compatible, true);282assert.strictEqual(areApiProposalsCompatible(['proposalA@2', 'proposalB'], productProposals).compatible, false);283284console.log(' ✓ areApiProposalsCompatible tests passed\n');285286// parseApiProposalsFromSource tests287console.log('Testing parseApiProposalsFromSource...');288289const sampleSource = `290export const allApiProposals = {291authSession: {292proposal: 'vscode.proposed.authSession.d.ts',293},294chatParticipant: {295proposal: 'vscode.proposed.chatParticipant.d.ts',296version: 2297},298testProposal: {299proposal: 'vscode.proposed.testProposal.d.ts',300version: 15301}302};303`;304305const parsedSource = parseApiProposalsFromSource(sampleSource);306assert.strictEqual(Object.keys(parsedSource).length, 3);307assert.strictEqual(parsedSource['authSession']?.version, undefined);308assert.strictEqual(parsedSource['chatParticipant']?.version, 2);309assert.strictEqual(parsedSource['testProposal']?.version, 15);310311// Empty source312assert.strictEqual(Object.keys(parseApiProposalsFromSource('')).length, 0);313314console.log(' ✓ parseApiProposalsFromSource tests passed\n');315316// checkExtensionCompatibility tests317console.log('Testing checkExtensionCompatibility...');318319const testApiProposals = {320authSession: { proposal: '', version: undefined },321chatParticipant: { proposal: '', version: 2 },322testProposal: { proposal: '', version: 15 }323};324325// Compatible extension - matching engine and proposals326assert.strictEqual(checkExtensionCompatibility('1.90.0', testApiProposals, {327name: 'test-ext',328publisher: 'test',329version: '1.0.0',330engines: { vscode: '^1.90.0' },331enabledApiProposals: ['chatParticipant@2']332}).compatible, true);333334// Compatible - no API proposals335assert.strictEqual(checkExtensionCompatibility('1.90.0', testApiProposals, {336name: 'test-ext',337publisher: 'test',338version: '1.0.0',339engines: { vscode: '^1.90.0' }340}).compatible, true);341342// Compatible - unversioned API proposals343assert.strictEqual(checkExtensionCompatibility('1.90.0', testApiProposals, {344name: 'test-ext',345publisher: 'test',346version: '1.0.0',347engines: { vscode: '^1.90.0' },348enabledApiProposals: ['authSession', 'chatParticipant']349}).compatible, true);350351// Incompatible - engine version too new352assert.strictEqual(checkExtensionCompatibility('1.89.0', testApiProposals, {353name: 'test-ext',354publisher: 'test',355version: '1.0.0',356engines: { vscode: '^1.90.0' },357enabledApiProposals: ['chatParticipant@2']358}).compatible, false);359360// Incompatible - API proposal version mismatch361assert.strictEqual(checkExtensionCompatibility('1.90.0', testApiProposals, {362name: 'test-ext',363publisher: 'test',364version: '1.0.0',365engines: { vscode: '^1.90.0' },366enabledApiProposals: ['chatParticipant@3']367}).compatible, false);368369// Incompatible - missing API proposal370assert.strictEqual(checkExtensionCompatibility('1.90.0', testApiProposals, {371name: 'test-ext',372publisher: 'test',373version: '1.0.0',374engines: { vscode: '^1.90.0' },375enabledApiProposals: ['unknownProposal@1']376}).compatible, false);377378// Incompatible - both engine and API proposal issues379assert.strictEqual(checkExtensionCompatibility('1.89.0', testApiProposals, {380name: 'test-ext',381publisher: 'test',382version: '1.0.0',383engines: { vscode: '^1.90.0' },384enabledApiProposals: ['chatParticipant@3']385}).compatible, false);386387console.log(' ✓ checkExtensionCompatibility tests passed\n');388389// areAllowlistedApiProposalsMatching tests390console.log('Testing areAllowlistedApiProposalsMatching...');391392// Both undefined - compatible393assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', undefined, undefined).compatible, true);394395// Both empty arrays - compatible396assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', [], []).compatible, true);397398// Exact match - compatible399assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA', 'proposalB'], ['proposalA', 'proposalB']).compatible, true);400401// Match regardless of order - compatible402assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalB', 'proposalA'], ['proposalA', 'proposalB']).compatible, true);403404// Extension declares but product.json doesn't allowlist - incompatible405assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', undefined, ['proposalA']).compatible, false);406assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', [], ['proposalA']).compatible, false);407408// Product.json allowlists but extension doesn't declare - COMPATIBLE (product.json can have extras)409assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA'], undefined).compatible, true);410assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA'], []).compatible, true);411412// Extension declares more than allowlisted - incompatible413assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA'], ['proposalA', 'proposalB']).compatible, false);414415// Product.json allowlists more than declared - COMPATIBLE (product.json can have extras)416assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA', 'proposalB'], ['proposalA']).compatible, true);417418// Completely different sets - incompatible (manifest has proposals not in allowlist)419assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA'], ['proposalB']).compatible, false);420421// Product.json has extras and manifest matches subset - compatible422assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA', 'proposalB', 'proposalC'], ['proposalA', 'proposalB']).compatible, true);423424// Versioned proposals - should strip version and match base name425assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['chatParticipant'], ['chatParticipant@2']).compatible, true);426assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA', 'proposalB'], ['proposalA@1', 'proposalB@3']).compatible, true);427428// Versioned proposal not in allowlist - incompatible429assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA'], ['proposalB@2']).compatible, false);430431// Mix of versioned and unversioned proposals432assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA', 'proposalB'], ['proposalA', 'proposalB@2']).compatible, true);433434console.log(' ✓ areAllowlistedApiProposalsMatching tests passed\n');435436console.log('All tests passed! ✓');437}438439440