diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 1e901ec48..af3f9d350 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -6,6 +6,8 @@ This change log covers only the command line interface (CLI) of Open VSX. #### Added +- Add `search` command to search the registry for extensions, mirroring the web UI's search: `--category`, `--target`, `--sort-by` and `--sort-order` narrow the query, `--size` and `--offset` page through the results, and `--json` prints the registry's raw response ([#2154](https://github.com/eclipse-openvsx/openvsx/pull/2154)) +- Add `list` command to print the extensions a namespace holds, sorted by name so the output stays stable across registries, with `--json` for the raw namespace metadata ([#2154](https://github.com/eclipse-openvsx/openvsx/pull/2154)) - Add `show` command to print an extension's metadata, mirroring `vsce show`: identity, publisher, rating, notices and a version history listing each version's target platforms ([#2149](https://github.com/eclipse-openvsx/openvsx/issues/2149)). `namespace.extension@version` reports a single version, `--target` scopes the report to one target platform, `--all-versions` lists every published version instead of the most recent few, and `--json` prints the registry's raw metadata - Add `unpublish` command to delete an extension or some of its versions, mirroring `vsce unpublish` ([#1958](https://github.com/eclipse-openvsx/openvsx/issues/1958)); requires a registry running version 1.2.0 or later, which `unpublish` checks for before deleting - `publish` checks the packaged extension's size against the limit reported by the registry's `/api/version` endpoint before uploading, instead of failing only after the upload completes ([#1953](https://github.com/eclipse-openvsx/openvsx/issues/1953)) diff --git a/cli/src/list-options.ts b/cli/src/list-options.ts new file mode 100644 index 000000000..a3e5b9fc2 --- /dev/null +++ b/cli/src/list-options.ts @@ -0,0 +1,25 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { RegistryOptions } from './registry-options'; + +export interface ListOptions extends RegistryOptions { + /** + * Namespace whose extensions should be listed. + */ + namespace: string; + /** + * Print the raw namespace metadata as JSON instead of a list. + */ + json?: boolean; +} diff --git a/cli/src/list.ts b/cli/src/list.ts new file mode 100644 index 000000000..d015a8390 --- /dev/null +++ b/cli/src/list.ts @@ -0,0 +1,48 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { ListOptions } from './list-options'; +import { Registry } from './registry'; +import { formatCount } from './table'; +import { addEnvOptions } from './util'; + +/** + * Lists the extensions published in a namespace. + */ +export async function list(options: ListOptions): Promise { + addEnvOptions(options); + const registry = new Registry(options); + const namespace = await registry.getNamespace(options.namespace); + if (namespace.error) { + throw new Error(namespace.error); + } + + if (options.json) { + console.log(JSON.stringify(namespace, null, 4)); + return; + } + + // Sorted here rather than relying on the response's key order, so the output is stable and + // scriptable regardless of how the registry happens to serialise the map. + const names = Object.keys(namespace.extensions ?? {}).sort((a, b) => a.localeCompare(b)); + const verified = namespace.verified ? ' (verified)' : ''; + console.log(`${namespace.name}${verified} - ${formatCount(names.length, 'extension')}`); + if (names.length === 0) { + return; + } + + console.log(); + for (const name of names) { + console.log(` ${namespace.name}.${name}`); + } +} diff --git a/cli/src/main.ts b/cli/src/main.ts index 2a94e3c37..98e5f7a6e 100644 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -14,8 +14,10 @@ import { createNamespace } from './create-namespace'; import { verifyPat } from './verify-pat'; import { publish } from './publish'; import { unpublish } from './unpublish'; -import { handleError } from './util'; +import { handleError, parseNonNegativeInt } from './util'; import { getExtension } from './get'; +import { list } from './list'; +import { DEFAULT_SEARCH_SIZE, SORT_KEYS, SORT_ORDERS, search } from './search'; import { show } from './show'; import { verify } from './verify'; import { verifySignature } from './verify-signature'; @@ -117,6 +119,33 @@ module.exports = function (argv: string[]): void { }).catch(handleError(program.debug)); }); + const searchCmd = program.command('search [text]'); + searchCmd.description('Search the registry for extensions.') + .option('-c, --category ', 'Only return extensions in this category.') + .option('-t, --target ', 'Only return extensions built for this target architecture.') + .option( + '-s, --size ', + `Number of results to return (default ${DEFAULT_SEARCH_SIZE}).`, + parseNonNegativeInt + ) + .option('-o, --offset ', 'Index of the first result, for paging.', parseNonNegativeInt) + .option('--sort-by ', `Sort key: ${SORT_KEYS.join(', ')}.`) + .option('--sort-order ', `Sort order: ${SORT_ORDERS.join(', ')}.`) + .option('--json', 'Print the raw results as JSON.') + .action((text: string | undefined, { category, target, size, offset, sortBy, sortOrder, json }) => { + const { registryUrl } = program.opts(); + search({ text, category, target, size, offset, sortBy, sortOrder, json, registryUrl }) + .catch(handleError(program.debug)); + }); + + const listCmd = program.command('list '); + listCmd.description('List the extensions published in a namespace.') + .option('--json', 'Print the raw namespace metadata as JSON.') + .action((namespace: string, { json }) => { + const { registryUrl } = program.opts(); + list({ namespace, json, registryUrl }).catch(handleError(program.debug)); + }); + const showCmd = program.command('show '); showCmd.description('Show an extension\'s metadata.') .option('-t, --target ', 'Only report on the given target architecture.') diff --git a/cli/src/registry.ts b/cli/src/registry.ts index bf10fc67a..25f54228b 100644 --- a/cli/src/registry.ts +++ b/cli/src/registry.ts @@ -165,6 +165,46 @@ export class Registry { } } + /** + * Full-text search across the registry. Returns the purpose-built summary shape rather than + * whole extension records, so a page of results stays small. + */ + search(options: SearchQuery): Promise { + try { + const query: Record = { + size: String(options.size), + offset: String(options.offset) + }; + if (options.query) { + query.query = options.query; + } + if (options.category) { + query.category = options.category; + } + if (options.targetPlatform) { + query.targetPlatform = options.targetPlatform; + } + if (options.sortBy) { + query.sortBy = options.sortBy; + } + if (options.sortOrder) { + query.sortOrder = options.sortOrder; + } + return this.getJson(this.getUrl(['api', '-', 'search'], query)); + } catch (err) { + return rejectError(err); + } + } + + /** Returns a namespace and the extensions published in it. */ + getNamespace(namespace: string): Promise { + try { + return this.getJson(this.getUrl(['api', namespace])); + } catch (err) { + return rejectError(err); + } + } + download(file: string, url: URL): Promise { return new Promise((resolve, reject) => { const stream = fs.createWriteStream(file); @@ -405,6 +445,45 @@ export interface VersionReferences extends Response { versions?: VersionReference[]; } +export interface SearchQuery { + query?: string; + category?: string; + targetPlatform?: string; + sortBy?: string; + sortOrder?: string; + size: number; + offset: number; +} + +export interface SearchEntry { + url: string; + files: { [type: string]: string }; + name: string; + namespace: string; + version: string; + timestamp: string; + verified?: boolean; + averageRating?: number; + reviewCount?: number; + downloadCount: number; + displayName?: string; + description?: string; + deprecated?: boolean; +} + +export interface SearchResult extends Response { + offset: number; + totalSize: number; + extensions?: SearchEntry[]; +} + +export interface Namespace extends Response { + name: string; + verified?: boolean; + // key: extension name, value: url + extensions?: { [name: string]: string }; +} + export interface ExtensionReference { url: string; namespace: string; diff --git a/cli/src/search-options.ts b/cli/src/search-options.ts new file mode 100644 index 000000000..222ce2058 --- /dev/null +++ b/cli/src/search-options.ts @@ -0,0 +1,49 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { RegistryOptions } from './registry-options'; + +export interface SearchOptions extends RegistryOptions { + /** + * Text to search for. Omit to browse, which is what makes `--category` useful on its own. + */ + text?: string; + /** + * Restrict results to a category, e.g. `Programming Languages`. + */ + category?: string; + /** + * Restrict results to extensions published for a target platform. + */ + target?: string; + /** + * Sort key: `relevance`, `timestamp`, `rating` or `downloadCount`. + */ + sortBy?: string; + /** + * `asc` or `desc`. + */ + sortOrder?: string; + /** + * Number of results to return. + */ + size?: number; + /** + * Index of the first result, for paging through a large result set. + */ + offset?: number; + /** + * Print the raw results as JSON instead of a table. + */ + json?: boolean; +} diff --git a/cli/src/search.ts b/cli/src/search.ts new file mode 100644 index 000000000..68b4dae78 --- /dev/null +++ b/cli/src/search.ts @@ -0,0 +1,95 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { Registry, SearchEntry } from './registry'; +import { SearchOptions } from './search-options'; +import { formatNumber, printRows, truncate } from './table'; +import { addEnvOptions } from './util'; + +/** Sort keys the registry accepts, for an up-front check with a better message than a 400. */ +export const SORT_KEYS = ['relevance', 'timestamp', 'rating', 'downloadCount']; + +export const SORT_ORDERS = ['asc', 'desc']; + +export const DEFAULT_SEARCH_SIZE = 20; + +/** Keeps a row from wrapping on a normal terminal, where the other columns take about half of it. */ +const DESCRIPTION_WIDTH = 60; + +/** + * Searches the registry for extensions. + */ +export async function search(options: SearchOptions): Promise { + addEnvOptions(options); + if (options.sortBy && !SORT_KEYS.includes(options.sortBy)) { + throw new Error(`Sort key must be one of ${SORT_KEYS.join(', ')}.`); + } + if (options.sortOrder && !SORT_ORDERS.includes(options.sortOrder)) { + throw new Error(`Sort order must be one of ${SORT_ORDERS.join(', ')}.`); + } + + const size = options.size ?? DEFAULT_SEARCH_SIZE; + const offset = options.offset ?? 0; + const registry = new Registry(options); + const result = await registry.search({ + query: options.text, + category: options.category, + targetPlatform: options.target, + sortBy: options.sortBy, + sortOrder: options.sortOrder, + size, + offset + }); + if (result.error) { + throw new Error(result.error); + } + + if (options.json) { + console.log(JSON.stringify(result, null, 4)); + return; + } + + printResults(result.extensions ?? [], result.totalSize, offset); +} + +function printResults(entries: SearchEntry[], totalSize: number, offset: number): void { + if (entries.length === 0) { + console.log('No extensions found.'); + return; + } + + const rows = entries.map(entry => [ + `${entry.namespace}.${entry.name}`, + entry.version, + formatNumber(entry.downloadCount), + entry.averageRating !== undefined ? entry.averageRating.toFixed(1) : '-', + describe(entry) + ]); + printRows([['Extension', 'Version', 'Downloads', 'Rating', 'Description'], ...rows]); + + const last = offset + entries.length; + console.log(); + console.log(`Showing ${offset + 1}-${last} of ${formatNumber(totalSize)}.`); + if (last < totalSize) { + console.log(`Pass --offset ${last} for the next page.`); + } +} + +/** + * The description, prefixed with anything a reader should weigh before installing. Deprecation is + * the one flag worth spending row width on here; `show` reports the rest. + */ +function describe(entry: SearchEntry): string { + const description = truncate(entry.description ?? '', DESCRIPTION_WIDTH); + return entry.deprecated ? `(deprecated) ${description}`.trimEnd() : description; +} diff --git a/cli/src/show.ts b/cli/src/show.ts index b45e17340..144a4c89e 100644 --- a/cli/src/show.ts +++ b/cli/src/show.ts @@ -13,6 +13,7 @@ import * as semver from 'semver'; import { Extension, Registry, VersionReference } from './registry'; +import { formatCount, formatNumber, printRows } from './table'; import { ShowOptions } from './show-options'; import { addEnvOptions, matchExtensionId } from './util'; @@ -279,11 +280,11 @@ function registryInfo(extension: Extension): string[][] { } function statistics(extension: Extension): string[][] { - const rows: string[][] = [['Downloads', (extension.downloadCount ?? 0).toLocaleString('en-US')]]; + const rows: string[][] = [['Downloads', formatNumber(extension.downloadCount)]]; if (extension.averageRating !== undefined) { rows.push(['Average Rating', `${extension.averageRating.toFixed(1)}/5`]); } - rows.push(['Reviews', Number(extension.reviewCount ?? 0).toLocaleString('en-US')]); + rows.push(['Reviews', formatNumber(Number(extension.reviewCount ?? 0))]); return rows; } @@ -315,26 +316,6 @@ function printTable(title: string, rows: string[][]): void { printRows(rows); } -/** Pads every column but the last, so trailing empty cells don't leave ragged whitespace. */ -function printRows(rows: string[][]): void { - const widths: number[] = []; - for (const row of rows) { - row.forEach((cell, i) => widths[i] = Math.max(widths[i] ?? 0, cell.length)); - } - for (const row of rows) { - const line = row - .map((cell, i) => (i === row.length - 1 ? cell : cell.padEnd(widths[i]))) - .join(' ') - .trimEnd(); - console.log(` ${line}`); - } -} - -function formatCount(count: number | undefined, noun: string): string { - const value = count ?? 0; - return `${value.toLocaleString('en-US')} ${value === 1 ? noun : noun + 's'}`; -} - function formatRating(extension: Extension): string { if (extension.averageRating === undefined) { return 'no ratings'; diff --git a/cli/src/table.ts b/cli/src/table.ts new file mode 100644 index 000000000..89acb98df --- /dev/null +++ b/cli/src/table.ts @@ -0,0 +1,53 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +/** + * Prints rows as an aligned table, indented by two spaces. Every column but the last is padded, + * so a trailing empty cell doesn't leave ragged whitespace behind. + */ +export function printRows(rows: string[][]): void { + const widths: number[] = []; + for (const row of rows) { + row.forEach((cell, i) => widths[i] = Math.max(widths[i] ?? 0, cell.length)); + } + for (const row of rows) { + const line = row + .map((cell, i) => (i === row.length - 1 ? cell : cell.padEnd(widths[i]))) + .join(' ') + .trimEnd(); + console.log(` ${line}`); + } +} + +/** Formats a count with thousands separators, and the noun pluralised to match. */ +export function formatCount(count: number | undefined, noun: string): string { + const value = count ?? 0; + return `${formatNumber(value)} ${value === 1 ? noun : noun + 's'}`; +} + +export function formatNumber(value: number | undefined): string { + return (value ?? 0).toLocaleString('en-US'); +} + +/** + * Shortens `text` to at most `max` characters, marking that it was cut. A `max` of zero or less + * leaves no room for the marker either, so it yields nothing rather than an ellipsis wider than the + * budget it was given. + */ +export function truncate(text: string, max: number): string { + if (max <= 0) { + return ''; + } + const collapsed = text.replace(/\s+/g, ' ').trim(); + return collapsed.length <= max ? collapsed : `${collapsed.substring(0, max - 1)}…`; +} diff --git a/cli/src/util.ts b/cli/src/util.ts index f13ae38b9..e74126625 100644 --- a/cli/src/util.ts +++ b/cli/src/util.ts @@ -37,6 +37,18 @@ function parseBooleanEnv(value?: string): boolean | undefined { return ['true', '1', 'yes'].includes(value.trim().toLowerCase()); } +/** + * Parses a commander option that must be a non-negative whole number, so a typo is reported by the + * CLI rather than sent to the registry as a bad request. + */ +export function parseNonNegativeInt(value: string): number { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new Error(`Expected a non-negative whole number, got '${value}'.`); + } + return parsed; +} + export function matchExtensionId(id: string): RegExpExecArray | null { return /^([\w-]+)(?:\.|\/)([\w-]+)$/.exec(id); } diff --git a/cli/test/unit/list.spec.ts b/cli/test/unit/list.spec.ts new file mode 100644 index 000000000..793bf0979 --- /dev/null +++ b/cli/test/unit/list.spec.ts @@ -0,0 +1,134 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import * as http from 'http'; +import { AddressInfo } from 'net'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { list } from '../../src/list'; + +interface ListStub { + url: string; + requests: string[]; + close: () => Promise; +} + +async function startNamespaceStub(body?: unknown): Promise { + const requests: string[] = []; + const server = http.createServer((req, res) => { + const url = new URL(req.url ?? '/', 'http://127.0.0.1'); + requests.push(url.pathname); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body ?? { + name: 'redhat', + verified: true, + extensions: { + java: 'https://example.test/api/redhat/java', + ansible: 'https://example.test/api/redhat/ansible' + } + })); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as AddressInfo).port; + return { + url: `http://127.0.0.1:${port}`, + requests, + close: () => new Promise(resolve => server.close(() => resolve())) + }; +} + +describe('list', () => { + + const stubs: ListStub[] = []; + let log: ReturnType; + + beforeEach(() => { + log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + }); + + afterEach(async () => { + log.mockRestore(); + await Promise.all(stubs.splice(0).map(stub => stub.close())); + }); + + async function givenRegistry(body?: unknown): Promise { + const stub = await startNamespaceStub(body); + stubs.push(stub); + return stub; + } + + function lines(): string[] { + return log.mock.calls.map(call => String(call[0] ?? '')); + } + + it('lists the namespace and its extensions', async () => { + const registry = await givenRegistry(); + + await list({ namespace: 'redhat', registryUrl: registry.url }); + + expect(registry.requests[0]).toBe('/api/redhat'); + const printed = lines().join('\n'); + expect(printed).toContain('redhat (verified) - 2 extensions'); + expect(printed).toContain('redhat.java'); + expect(printed).toContain('redhat.ansible'); + }); + + // The response is a JSON object, whose key order is not something to depend on for output that + // people diff and pipe. + it('sorts the extensions by name', async () => { + const registry = await givenRegistry(); + + await list({ namespace: 'redhat', registryUrl: registry.url }); + + const printed = lines().join('\n'); + expect(printed.indexOf('redhat.ansible')).toBeLessThan(printed.indexOf('redhat.java')); + }); + + it('pluralises a single extension', async () => { + const registry = await givenRegistry({ name: 'solo', extensions: { only: 'https://example.test' } }); + + await list({ namespace: 'solo', registryUrl: registry.url }); + + expect(lines()[0]).toBe('solo - 1 extension'); + }); + + it('handles an empty namespace', async () => { + const registry = await givenRegistry({ name: 'empty', extensions: {} }); + + await list({ namespace: 'empty', registryUrl: registry.url }); + + expect(lines()).toEqual(['empty - 0 extensions']); + }); + + it('omits the verified marker when the namespace is not verified', async () => { + const registry = await givenRegistry({ name: 'redhat', verified: false, extensions: {} }); + + await list({ namespace: 'redhat', registryUrl: registry.url }); + + expect(lines()[0]).not.toContain('verified'); + }); + + it('reports the error the registry returns', async () => { + const registry = await givenRegistry({ error: 'Namespace not found: nope' }); + + await expect(list({ namespace: 'nope', registryUrl: registry.url })) + .rejects.toThrow('Namespace not found: nope'); + }); + + it('prints raw JSON with --json', async () => { + const registry = await givenRegistry(); + + await list({ namespace: 'redhat', json: true, registryUrl: registry.url }); + + expect(JSON.parse(lines().join('\n'))).toMatchObject({ name: 'redhat' }); + }); +}); diff --git a/cli/test/unit/search.spec.ts b/cli/test/unit/search.spec.ts new file mode 100644 index 000000000..9f8ed49e5 --- /dev/null +++ b/cli/test/unit/search.spec.ts @@ -0,0 +1,196 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import * as http from 'http'; +import { AddressInfo } from 'net'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { search } from '../../src/search'; + +interface SearchStub { + url: string; + requests: { pathname: string; query: URLSearchParams }[]; + close: () => Promise; +} + +const entry = { + url: 'https://example.test/api/redhat/java', + files: {}, + namespace: 'redhat', + name: 'java', + version: '1.2.0', + timestamp: '2026-08-01T10:00:00Z', + downloadCount: 40086502, + averageRating: 4.75, + reviewCount: 16, + displayName: 'Language Support for Java', + description: 'Java Linting, Intellisense, formatting, refactoring and more', + deprecated: false +}; + +async function startSearchStub(status = 200, body?: unknown): Promise { + const requests: { pathname: string; query: URLSearchParams }[] = []; + const server = http.createServer((req, res) => { + const url = new URL(req.url ?? '/', 'http://127.0.0.1'); + requests.push({ pathname: url.pathname, query: url.searchParams }); + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body ?? { offset: 0, totalSize: 1, extensions: [entry] })); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as AddressInfo).port; + return { + url: `http://127.0.0.1:${port}`, + requests, + close: () => new Promise(resolve => server.close(() => resolve())) + }; +} + +describe('search', () => { + + const stubs: SearchStub[] = []; + let log: ReturnType; + + beforeEach(() => { + log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + }); + + afterEach(async () => { + log.mockRestore(); + await Promise.all(stubs.splice(0).map(stub => stub.close())); + }); + + async function givenRegistry(status?: number, body?: unknown): Promise { + const stub = await startSearchStub(status, body); + stubs.push(stub); + return stub; + } + + function output(): string { + return log.mock.calls.map(call => String(call[0] ?? '')).join('\n'); + } + + it('prints a result row per extension', async () => { + const registry = await givenRegistry(); + + await search({ text: 'java', registryUrl: registry.url }); + + expect(registry.requests[0].pathname).toBe('/api/-/search'); + expect(registry.requests[0].query.get('query')).toBe('java'); + const printed = output(); + expect(printed).toContain('redhat.java'); + expect(printed).toContain('1.2.0'); + expect(printed).toContain('40,086,502'); + expect(printed).toContain('4.8'); + }); + + it('reports where the page sits in the result set', async () => { + const registry = await givenRegistry(200, { offset: 20, totalSize: 57, extensions: [entry] }); + + await search({ text: 'java', offset: 20, registryUrl: registry.url }); + + const printed = output(); + expect(printed).toContain('Showing 21-21 of 57.'); + expect(printed).toContain('Pass --offset 21 for the next page.'); + }); + + it('does not offer a next page on the last one', async () => { + const registry = await givenRegistry(200, { offset: 0, totalSize: 1, extensions: [entry] }); + + await search({ text: 'java', registryUrl: registry.url }); + + expect(output()).not.toContain('--offset'); + }); + + it('says so when nothing matched', async () => { + const registry = await givenRegistry(200, { offset: 0, totalSize: 0, extensions: [] }); + + await search({ text: 'nothing-matches-this', registryUrl: registry.url }); + + expect(output()).toContain('No extensions found.'); + }); + + it('marks a deprecated result', async () => { + const registry = await givenRegistry(200, { + offset: 0, + totalSize: 1, + extensions: [{ ...entry, deprecated: true }] + }); + + await search({ text: 'java', registryUrl: registry.url }); + + expect(output()).toContain('(deprecated)'); + }); + + it('passes the filters and paging through', async () => { + const registry = await givenRegistry(); + + await search({ + text: 'java', + category: 'Programming Languages', + target: 'linux-x64', + sortBy: 'downloadCount', + sortOrder: 'desc', + size: 5, + offset: 10, + registryUrl: registry.url + }); + + const query = registry.requests[0].query; + expect(query.get('category')).toBe('Programming Languages'); + expect(query.get('targetPlatform')).toBe('linux-x64'); + expect(query.get('sortBy')).toBe('downloadCount'); + expect(query.get('sortOrder')).toBe('desc'); + expect(query.get('size')).toBe('5'); + expect(query.get('offset')).toBe('10'); + }); + + // Browsing by category alone is a legitimate use, so the text is optional. + it('searches without any text', async () => { + const registry = await givenRegistry(); + + await search({ category: 'Snippets', registryUrl: registry.url }); + + expect(registry.requests[0].query.has('query')).toBe(false); + expect(registry.requests[0].query.get('category')).toBe('Snippets'); + }); + + // Caught locally rather than sent on, since the registry answers a bad key with a bare 400. + it('rejects an unknown sort key before making a request', async () => { + const registry = await givenRegistry(); + + await expect(search({ text: 'java', sortBy: 'downloads', registryUrl: registry.url })) + .rejects.toThrow('Sort key must be one of relevance, timestamp, rating, downloadCount.'); + expect(registry.requests).toHaveLength(0); + }); + + it('rejects an unknown sort order before making a request', async () => { + const registry = await givenRegistry(); + + await expect(search({ text: 'java', sortOrder: 'sideways', registryUrl: registry.url })) + .rejects.toThrow('Sort order must be one of asc, desc.'); + expect(registry.requests).toHaveLength(0); + }); + + it('reports the error the registry returns', async () => { + const registry = await givenRegistry(200, { error: 'Invalid category', offset: 0, totalSize: 0 }); + + await expect(search({ text: 'java', registryUrl: registry.url })).rejects.toThrow('Invalid category'); + }); + + it('prints raw JSON with --json', async () => { + const registry = await givenRegistry(); + + await search({ text: 'java', json: true, registryUrl: registry.url }); + + expect(JSON.parse(output())).toMatchObject({ totalSize: 1 }); + }); +}); diff --git a/cli/test/unit/table.spec.ts b/cli/test/unit/table.spec.ts new file mode 100644 index 000000000..35485ad92 --- /dev/null +++ b/cli/test/unit/table.spec.ts @@ -0,0 +1,50 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { describe, expect, it } from 'vitest'; +import { truncate } from '../../src/table'; + +/** + * `table.ts` exists to be shared between commands, so its helpers are asserted at their boundaries + * here rather than only through whichever command happens to call them with a comfortable width. + */ +describe('truncate', () => { + + it('leaves text that fits alone', () => { + expect(truncate('short', 10)).toBe('short'); + }); + + it('collapses whitespace and trims', () => { + expect(truncate(' two words\n', 20)).toBe('two words'); + }); + + it('keeps the result within the given width, marker included', () => { + expect(truncate('abcdefghij', 5)).toBe('abcd…'); + expect(truncate('abcdefghij', 5)).toHaveLength(5); + }); + + it('spends its whole budget on the marker when only one character fits', () => { + expect(truncate('abcdefghij', 1)).toBe('…'); + }); + + // A zero budget leaves no room for the marker either. Returning the ellipsis anyway would be one + // character wider than asked for, which is how a truncating helper ends up wrapping a table. + it('yields nothing for a width of zero or less', () => { + expect(truncate('abcdefghij', 0)).toBe(''); + expect(truncate('abcdefghij', -1)).toBe(''); + }); + + it('yields nothing for text that is only whitespace', () => { + expect(truncate(' ', 10)).toBe(''); + }); +});