import { afterEach, describe, it } from 'node:test'; import { strict as assert } from 'node:assert'; import * as fs from 'node:fs'; import * as http from 'node:http'; import * as os from 'node:os'; import * as path from 'node:path'; import { runMxCli, parseLastJson, assertExitCode } from '../helpers/cli-harness.js'; import { createMatrixPackage } from '../helpers/package-fixtures.js'; interface ISearchFixture { readonly registryUrl: string; readonly searchRequests: () => number; readonly authorizationHeaders: () => readonly string[]; readonly searchQueries: () => readonly string[]; close(): Promise; } function addressPort(server: http.Server): number { const address = server.address(); if (!address || typeof address === 'string') { throw new Error('Search registry fixture is not listening on a TCP port'); } return address.port; } async function startSearchRegistryFixture(): Promise { let searchRequestCount = 0; const authorizationHeaders: string[] = []; const searchQueries: string[] = []; const server = http.createServer((request, response) => { const registryUrl = `http://127.0.0.1:${addressPort(server)}/`; const requestUrl = new URL(request.url ?? '/', registryUrl); const auth = request.headers.authorization; if (typeof auth === 'string') { authorizationHeaders.push(auth); } if (requestUrl.pathname === '/-/v1/search') { searchRequestCount += 1; searchQueries.push(requestUrl.searchParams.get('text') ?? ''); response.writeHead(200, { 'content-type': 'application/json' }); response.end(JSON.stringify({ objects: [ { package: { name: '@open-matrix/chat', version: '0.2.3', description: 'Chat package', keywords: ['matrix', 'chat'], }, }, { package: { scope: '@open-matrix', name: 'flowpad', version: '0.3.0', description: 'FlowPad package', keywords: ['matrix', 'flowpad'], }, }, { package: { name: '@open-matrix/director', version: '0.1.0', description: 'Director package', keywords: ['matrix', 'director'], }, }, ], })); return; } response.writeHead(404, { 'content-type': 'application/json' }); response.end(JSON.stringify({ error: `Not found: ${requestUrl.pathname}` })); }); await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', () => { server.off('error', reject); resolve(); }); }); return { registryUrl: `http://127.0.0.1:${addressPort(server)}/`, searchRequests: () => searchRequestCount, authorizationHeaders: () => [...authorizationHeaders], searchQueries: () => [...searchQueries], async close(): Promise { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); }, }; } describe('FP-30 mx search command', () => { const tempDirs: string[] = []; const closeFns: Array<() => Promise> = []; const restoreEnvFns: Array<() => void> = []; afterEach(async () => { while (restoreEnvFns.length > 0) { const restore = restoreEnvFns.pop(); if (restore) { restore(); } } while (closeFns.length > 0) { const close = closeFns.pop(); if (close) { await close(); } } while (tempDirs.length > 0) { const dir = tempDirs.pop(); if (dir) { fs.rmSync(dir, { recursive: true, force: true }); } } }); function useMatrixHome(matrixHome: string): void { const previousMatrixHome = process.env.MATRIX_HOME; const previousCliConfig = process.env.MATRIX_CLI_CONFIG; const previousPackageRegistry = process.env.MATRIX_PACKAGE_REGISTRY; const previousRegistryUrl = process.env.MATRIX_REGISTRY_URL; process.env.MATRIX_HOME = matrixHome; delete process.env.MATRIX_CLI_CONFIG; delete process.env.MATRIX_PACKAGE_REGISTRY; delete process.env.MATRIX_REGISTRY_URL; restoreEnvFns.push(() => { restoreEnv('MATRIX_HOME', previousMatrixHome); restoreEnv('MATRIX_CLI_CONFIG', previousCliConfig); restoreEnv('MATRIX_PACKAGE_REGISTRY', previousPackageRegistry); restoreEnv('MATRIX_REGISTRY_URL', previousRegistryUrl); }); } function restoreEnv(key: string, value: string | undefined): void { if (value === undefined) { delete process.env[key]; return; } process.env[key] = value; } it('searches the configured npm registry with stored registry credentials', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-search-')); tempDirs.push(root); useMatrixHome(path.join(root, '.matrix')); const workspaceDir = path.join(root, 'workspace'); fs.mkdirSync(workspaceDir, { recursive: true }); const credentialsFile = path.join(root, '.matrix', 'credentials.json'); const registry = await startSearchRegistryFixture(); closeFns.push(registry.close); const setConfig = await runMxCli([ 'node', 'mx', 'config', 'set', 'registry', registry.registryUrl, '--json', ], { cwd: workspaceDir }); assertExitCode(setConfig, 0); const loginResult = await runMxCli([ 'node', 'mx', 'login', '--username', 'alice', '--token', 'token-search', '--credentials-file', credentialsFile, '--json', ], { cwd: workspaceDir }); assertExitCode(loginResult, 0); const searchResult = await runMxCli([ 'node', 'mx', 'search', 'chat', '--credentials-file', credentialsFile, '--limit', '5', '--json', ], { cwd: workspaceDir }); assertExitCode(searchResult, 0); const payload = parseLastJson(searchResult.logs) as { query: string; registry: string; registrySource: string; results: Array<{ packageName: string; version: string; description?: string; keywords: string[] }>; }; assert.equal(payload.query, 'chat'); assert.equal(payload.registry, registry.registryUrl); assert.equal(payload.registrySource, 'config'); assert.equal(payload.results.length, 3); assert.equal(payload.results[0]?.packageName, '@open-matrix/chat'); assert.equal(payload.results[0]?.version, '0.2.3'); assert.deepEqual(payload.results[0]?.keywords, ['matrix', 'chat']); assert.equal(payload.results[1]?.packageName, '@open-matrix/flowpad'); assert.equal(payload.results[1]?.version, '0.3.0'); assert.equal(registry.searchRequests(), 1); assert.deepEqual(registry.searchQueries(), ['chat']); assert.equal( registry.authorizationHeaders().some((header) => header.includes('token-search')), true, ); }); it('searches the local package discovery index created by publish --registry-dir', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-discovery-search-')); tempDirs.push(root); useMatrixHome(path.join(root, '.matrix')); const workspaceDir = path.join(root, 'workspace'); fs.mkdirSync(workspaceDir, { recursive: true }); const registryDir = path.join(root, '.matrix', 'registry'); const credentialsFile = path.join(root, '.matrix', 'credentials.json'); const fixture = createMatrixPackage(path.join(root, 'source'), { packageName: '@acme/semantic-chat', version: '1.2.3', componentType: 'SemanticChatActor', mount: 'semantic.chat', }); const manifestPath = path.join(fixture.packageDir, 'matrix.json'); const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Record; manifest.description = 'Semantic package for chat workflows'; manifest.class = 'hybrid'; manifest.tags = ['communication']; manifest.capabilities = ['conversation']; manifest.webapp = { distDir: 'dist', entry: 'index.html', appName: 'chat', displayName: 'Semantic Chat', icon: '💬', navOrder: 30, description: 'Conversation workspace', shells: ['platform', 'edge'], }; fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8'); fs.mkdirSync(path.join(fixture.packageDir, 'dist'), { recursive: true }); fs.writeFileSync(path.join(fixture.packageDir, 'dist', 'index.html'), 'Semantic Chat', 'utf8'); fs.writeFileSync( path.join(fixture.packageDir, 'src', 'index.ts'), `export class SemanticChatActor { static description = 'Coordinates indexed chat conversations'; static kind = 'service'; static accepts = { 'chat.status': { description: 'Read chat status' }, 'chat.send': { description: 'Send a chat message' } }; static emits = { 'chat.message.created': { description: 'Message created' } }; static skills = { 'conversation-management': { description: 'Manage conversation state' } }; static tags = ['chat', 'semantic']; static capabilities = ['messaging']; } `, 'utf8', ); const loginResult = await runMxCli([ 'node', 'mx', 'login', '--username', 'alice', '--token', 'token-discovery-search', '--credentials-file', credentialsFile, '--json', ], { cwd: workspaceDir }); assertExitCode(loginResult, 0); const publishResult = await runMxCli([ 'node', 'mx', 'publish', '--registry-dir', registryDir, '--credentials-file', credentialsFile, '--json', ], { cwd: fixture.packageDir }); assertExitCode(publishResult, 0); assert.equal(fs.existsSync(path.join(registryDir, 'discovery-index.json')), true); const searchResult = await runMxCli([ 'node', 'mx', 'search', 'conversation', '--registry-dir', registryDir, '--limit', '5', '--json', ], { cwd: workspaceDir }); assertExitCode(searchResult, 0); const payload = parseLastJson(searchResult.logs) as { query: string; registry: string; registrySource: string; results: Array<{ packageName: string; version: string; displayName?: string; inferredKind?: string; keywords: readonly string[]; matchedFields?: readonly string[]; }>; }; assert.equal(payload.query, 'conversation'); assert.equal(payload.registry, registryDir); assert.equal(payload.registrySource, 'local'); assert.equal(payload.results.length, 1); assert.equal(payload.results[0]?.packageName, '@acme/semantic-chat'); assert.equal(payload.results[0]?.version, '1.2.3'); assert.equal(payload.results[0]?.displayName, 'Semantic Chat'); assert.equal(payload.results[0]?.inferredKind, 'app'); assert.equal(payload.results[0]?.keywords.includes('conversation-management'), true); assert.equal(payload.results[0]?.keywords.includes('edge'), true); assert.equal(payload.results[0]?.matchedFields?.includes('component.skills'), true); }); });