From 58d2654b6db3b9ab9e553aa338845262af0ba1f4 Mon Sep 17 00:00:00 2001 From: BoxBoxJason Date: Sat, 30 May 2026 17:50:11 +0200 Subject: [PATCH] feat: add outdated extensions achievement tracking Signed-off-by: BoxBoxJason --- src/listeners/extensions.ts | 122 ++++++++++++++ src/test/listeners/extensions.test.ts | 234 ++++++++++++++++++++++++++ 2 files changed, 356 insertions(+) diff --git a/src/listeners/extensions.ts b/src/listeners/extensions.ts index 522cad4..34d47c8 100644 --- a/src/listeners/extensions.ts +++ b/src/listeners/extensions.ts @@ -44,6 +44,120 @@ export namespace extensionsListeners { ); } + /** + * Fetch the latest versions of extensions from the VS Marketplace. + * Throws on network or API errors (caller must handle offline scenario). + * + * @param extensionIds - List of "publisher.name" identifiers + * @returns Map of lowercase "publisher.name" → latest version string + */ + async function fetchLatestExtensionVersions( + extensionIds: string[], + ): Promise> { + const response = await fetch( + "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json;api-version=3.0-preview.1", + }, + body: JSON.stringify({ + filters: [ + { + criteria: extensionIds.map((id) => ({ + filterType: 7, + value: id, + })), + }, + ], + flags: 512, + }), + signal: AbortSignal.timeout(5000), + }, + ); + + if (!response.ok) { + throw new Error( + `Marketplace API returned ${response.status}: ${response.statusText}`, + ); + } + + const data = (await response.json()) as { + results?: Array<{ + extensions?: Array<{ + publisher?: { publisherName?: string }; + extensionName?: string; + versions?: Array<{ version?: string }>; + }>; + }>; + }; + + const versionMap = new Map(); + for (const ext of data.results?.[0]?.extensions ?? []) { + const publisherName = ext.publisher?.publisherName ?? ""; + const extensionName = ext.extensionName ?? ""; + const version = ext.versions?.[0]?.version; + if (publisherName && extensionName && version) { + versionMap.set( + `${publisherName}.${extensionName}`.toLowerCase(), + version, + ); + } + } + + return versionMap; + } + + /** + * Check how many installed extensions are outdated by querying the VS Marketplace. + * Silently skips the update when offline or if the API is unreachable. + * + * @memberof extensionsListeners + * @returns {Promise} + */ + export async function checkOutdatedExtensions(): Promise { + try { + const nonBuiltinExtensions = vscode.extensions.all.filter( + (ext) => !ext.id.startsWith("vscode."), + ); + + if (nonBuiltinExtensions.length === 0) { + await ProgressionController.updateProgression( + constants.criteria.EXTENSIONS_OUTDATED, + 0, + true, + ); + return; + } + + const extensionIds = nonBuiltinExtensions.map( + (ext) => + `${ext.packageJSON.publisher as string}.${ext.packageJSON.name as string}`, + ); + + const latestVersions = await fetchLatestExtensionVersions(extensionIds); + + const outdatedCount = nonBuiltinExtensions.filter((ext) => { + const id = + `${ext.packageJSON.publisher as string}.${ext.packageJSON.name as string}`.toLowerCase(); + const latestVersion = latestVersions.get(id); + return ( + latestVersion !== undefined && + latestVersion !== (ext.packageJSON.version as string) + ); + }).length; + + await ProgressionController.updateProgression( + constants.criteria.EXTENSIONS_OUTDATED, + outdatedCount, + true, + ); + } catch (err) { + logger.debug(`Unable to check for outdated extensions: ${String(err)}`); + } + } + /** * Handle theme change event * @@ -75,6 +189,13 @@ export namespace extensionsListeners { context.subscriptions, ); + vscode.extensions.onDidChange( + () => + checkOutdatedExtensions().catch((err: unknown) => logger.error(err)), + null, + context.subscriptions, + ); + vscode.window.onDidChangeActiveColorTheme( handleThemeChange, null, @@ -85,6 +206,7 @@ export namespace extensionsListeners { // Check the total number of installed extensions at the boot checkExtensions().catch((err: unknown) => logger.error(err)); + checkOutdatedExtensions().catch((err: unknown) => logger.error(err)); } else { logger.info("Extensions events listeners are disabled"); } diff --git a/src/test/listeners/extensions.test.ts b/src/test/listeners/extensions.test.ts index b628c92..574581f 100644 --- a/src/test/listeners/extensions.test.ts +++ b/src/test/listeners/extensions.test.ts @@ -5,6 +5,16 @@ import { ProgressionController } from "../../database/controller/progressions"; import { constants } from "../../constants"; import { config } from "../../config/config"; +type MarketplaceResponse = { + results: Array<{ + extensions: Array<{ + publisher: { publisherName: string }; + extensionName: string; + versions: Array<{ version: string }>; + }>; + }>; +}; + suite("Extensions Listeners Test Suite", () => { test("activate should register listeners", () => { const originalIsListenerEnabled = config.isListenerEnabled; @@ -44,6 +54,230 @@ suite("Extensions Listeners Test Suite", () => { } }); + function withMockedExtensions( + mockedAll: unknown[], + fn: () => Promise, + ): Promise { + const originalDescriptor = Object.getOwnPropertyDescriptor( + vscode.extensions, + "all", + ); + Object.defineProperty(vscode.extensions, "all", { + get: () => mockedAll, + configurable: true, + }); + return fn().finally(() => { + if (originalDescriptor) { + Object.defineProperty(vscode.extensions, "all", originalDescriptor); + } + }); + } + + test("checkOutdatedExtensions should update EXTENSIONS_OUTDATED when marketplace responds", async () => { + const updatedCriteria: Array<{ criteria: string; value: string | number | boolean | Date }> = []; + const originalUpdate = ProgressionController.updateProgression; + ProgressionController.updateProgression = async (criteria, value) => { + updatedCriteria.push({ criteria, value }); + }; + + const mockResponse: MarketplaceResponse = { + results: [ + { + extensions: [ + { + publisher: { publisherName: "testpublisher" }, + extensionName: "testextension", + versions: [{ version: "2.0.0" }], + }, + ], + }, + ], + }; + + const originalFetch = global.fetch; + global.fetch = async () => + ({ + ok: true, + json: async () => mockResponse, + }) as Response; + + try { + await withMockedExtensions( + [ + { + id: "testpublisher.testextension", + packageJSON: { + publisher: "testpublisher", + name: "testextension", + version: "1.0.0", + }, + }, + ], + async () => { + await extensionsListeners.checkOutdatedExtensions(); + const outdatedCall = updatedCriteria.find( + (c) => c.criteria === constants.criteria.EXTENSIONS_OUTDATED, + ); + assert.ok( + outdatedCall, + "Should have called updateProgression with EXTENSIONS_OUTDATED", + ); + assert.strictEqual( + outdatedCall.value, + 1, + "Should count 1 outdated extension (1.0.0 vs 2.0.0)", + ); + }, + ); + } finally { + ProgressionController.updateProgression = originalUpdate; + global.fetch = originalFetch; + } + }); + + test("checkOutdatedExtensions should report 0 when all extensions are up to date", async () => { + const updatedCriteria: Array<{ criteria: string; value: string | number | boolean | Date }> = []; + const originalUpdate = ProgressionController.updateProgression; + ProgressionController.updateProgression = async (criteria, value) => { + updatedCriteria.push({ criteria, value }); + }; + + const mockResponse: MarketplaceResponse = { + results: [ + { + extensions: [ + { + publisher: { publisherName: "testpublisher" }, + extensionName: "testextension", + versions: [{ version: "2.0.0" }], + }, + ], + }, + ], + }; + + const originalFetch = global.fetch; + global.fetch = async () => + ({ + ok: true, + json: async () => mockResponse, + }) as Response; + + try { + await withMockedExtensions( + [ + { + id: "testpublisher.testextension", + packageJSON: { + publisher: "testpublisher", + name: "testextension", + version: "2.0.0", + }, + }, + ], + async () => { + await extensionsListeners.checkOutdatedExtensions(); + const outdatedCall = updatedCriteria.find( + (c) => c.criteria === constants.criteria.EXTENSIONS_OUTDATED, + ); + assert.ok(outdatedCall, "Should have called updateProgression"); + assert.strictEqual( + outdatedCall.value, + 0, + "Should count 0 outdated extensions", + ); + }, + ); + } finally { + ProgressionController.updateProgression = originalUpdate; + global.fetch = originalFetch; + } + }); + + test("checkOutdatedExtensions should not throw when offline", async () => { + const originalFetch = global.fetch; + global.fetch = async (): Promise => { + throw new Error("Network error: ECONNREFUSED"); + }; + + try { + await assert.doesNotReject( + async () => extensionsListeners.checkOutdatedExtensions(), + "checkOutdatedExtensions should not throw when offline", + ); + } finally { + global.fetch = originalFetch; + } + }); + + test("checkOutdatedExtensions should not throw on non-OK marketplace response", async () => { + const originalFetch = global.fetch; + global.fetch = async () => + ({ + ok: false, + status: 503, + statusText: "Service Unavailable", + }) as Response; + + try { + await assert.doesNotReject( + async () => extensionsListeners.checkOutdatedExtensions(), + "checkOutdatedExtensions should not throw on HTTP error", + ); + } finally { + global.fetch = originalFetch; + } + }); + + test("checkOutdatedExtensions should skip built-in vscode.* extensions", async () => { + let fetchCalled = false; + const originalFetch = global.fetch; + global.fetch = async () => { + fetchCalled = true; + return { + ok: true, + json: async () => ({ results: [] }), + } as unknown as Response; + }; + + const updatedCriteria: Array<{ criteria: string; value: string | number | boolean | Date }> = []; + const originalUpdate = ProgressionController.updateProgression; + ProgressionController.updateProgression = async (criteria, value) => { + updatedCriteria.push({ criteria, value }); + }; + + try { + await withMockedExtensions( + [ + { + id: "vscode.typescript-language-features", + packageJSON: { + publisher: "vscode", + name: "typescript-language-features", + version: "1.0.0", + }, + }, + ], + async () => { + await extensionsListeners.checkOutdatedExtensions(); + assert.strictEqual( + fetchCalled, + false, + "Should not call fetch when only built-in extensions exist", + ); + const outdatedCall = updatedCriteria.find( + (c) => c.criteria === constants.criteria.EXTENSIONS_OUTDATED, + ); + assert.ok(outdatedCall, "Should still update progression"); + assert.strictEqual(outdatedCall.value, 0); + }, + ); + } finally { + ProgressionController.updateProgression = originalUpdate; + global.fetch = originalFetch; + } + }); + test("handleThemeChange should increase THEME_CHANGED", async () => { let increasedCriteria: string | undefined; const originalIncrease = ProgressionController.increaseProgression;