342 lines
11 KiB
TypeScript

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<void>;
}
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<ISearchFixture> {
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<void>((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<void> {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
},
};
}
describe('FP-30 mx search command', () => {
const tempDirs: string[] = [];
const closeFns: Array<() => Promise<void>> = [];
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<string, unknown>;
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'), '<!doctype html><title>Semantic Chat</title>', '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);
});
});