Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions src/listeners/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Map<string, string>> {
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<string, string>();
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<void>}
*/
export async function checkOutdatedExtensions(): Promise<void> {
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
*
Expand Down Expand Up @@ -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,
Expand All @@ -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");
}
Expand Down
234 changes: 234 additions & 0 deletions src/test/listeners/extensions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -44,6 +54,230 @@ suite("Extensions Listeners Test Suite", () => {
}
});

function withMockedExtensions(
mockedAll: unknown[],
fn: () => Promise<void>,
): Promise<void> {
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<Response> => {
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;
Expand Down
Loading