342 lines
11 KiB
TypeScript
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);
|
||
|
|
});
|
||
|
|
});
|