ade1d68015
- iios-kernel-client: RestClient.discoverThreads (GET /v1/threads/discover) + leaveThread (DELETE /v1/threads/:id/me); DiscoveredThread type - KernelClientAdapter implements the four channel methods: browseChannels maps discovered public channels → ChannelSummary; createChannel → createThread (membership=channel, ADMIN, visibility/topic metadata); joinChannel → socket.openThread (governed public self-join); leaveChannel → rest.leaveThread - adapter channel test over the fake transport (13 kernel-adapter tests); 57 green Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
154 lines
4.9 KiB
TypeScript
154 lines
4.9 KiB
TypeScript
// Runs the shared adapter conformance suite against KernelClientAdapter — proving it satisfies
|
|
// the same contract as the mock, over the REAL MessageSocket facade. Only the lowest socket.io
|
|
// layer is faked (via kernel-client's own SocketLike seam), so the facade's wire mapping is
|
|
// exercised for real. No live IIOS required.
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
import { MessageSocket } from '@insignia/iios-kernel-client';
|
|
import type { Message as KernelMessage, SocketLike } from '@insignia/iios-kernel-client';
|
|
import { runAdapterConformance } from '../conformance';
|
|
import { KernelClientAdapter, type RestPort } from './kernel-client';
|
|
|
|
const ME = 'me';
|
|
const SEEDED = 'th_seed';
|
|
|
|
/** An in-memory stand-in for the /message socket.io namespace: stores messages, echoes 'message'. */
|
|
function makeFakeSocket(): SocketLike {
|
|
const threads = new Map<string, KernelMessage[]>([
|
|
[
|
|
SEEDED,
|
|
[
|
|
{
|
|
id: 'seed_1',
|
|
threadId: SEEDED,
|
|
senderActorId: 'actor_other',
|
|
senderId: 'pp_other',
|
|
senderName: 'Other',
|
|
content: 'seeded message',
|
|
createdAt: new Date(0).toISOString(),
|
|
},
|
|
],
|
|
],
|
|
]);
|
|
const handlers = new Map<string, Set<(...a: unknown[]) => void>>();
|
|
let seq = 1;
|
|
|
|
const fire = (event: string, payload: unknown): void => handlers.get(event)?.forEach((h) => h(payload));
|
|
|
|
return {
|
|
on(event, handler) {
|
|
if (!handlers.has(event)) handlers.set(event, new Set());
|
|
handlers.get(event)!.add(handler);
|
|
return undefined;
|
|
},
|
|
off(event, handler) {
|
|
handlers.get(event)?.delete(handler);
|
|
return undefined;
|
|
},
|
|
emit() {
|
|
return undefined; // typing/focus — no echo needed for conformance
|
|
},
|
|
async emitWithAck(event, payload) {
|
|
const p = (payload ?? {}) as {
|
|
threadId: string;
|
|
content?: string;
|
|
parentInteractionId?: string;
|
|
interactionId?: string;
|
|
type?: string;
|
|
value?: string;
|
|
};
|
|
if (event === 'open_thread') {
|
|
if (!threads.has(p.threadId)) threads.set(p.threadId, []);
|
|
return { threadId: p.threadId, status: 'OPEN', history: [...threads.get(p.threadId)!] };
|
|
}
|
|
if (event === 'send_message') {
|
|
const msg: KernelMessage = {
|
|
id: `m_${seq++}`,
|
|
threadId: p.threadId,
|
|
senderActorId: `actor_${ME}`,
|
|
senderId: ME,
|
|
senderName: ME,
|
|
content: p.content ?? '',
|
|
createdAt: new Date().toISOString(),
|
|
...(p.parentInteractionId ? { parentInteractionId: p.parentInteractionId } : {}),
|
|
};
|
|
if (!threads.has(p.threadId)) threads.set(p.threadId, []);
|
|
threads.get(p.threadId)!.push(msg);
|
|
fire('message', msg);
|
|
return msg;
|
|
}
|
|
if (event === 'read') return { ok: true };
|
|
if (event === 'annotate') {
|
|
fire('annotation', {
|
|
threadId: p.threadId,
|
|
interactionId: p.interactionId,
|
|
type: p.type,
|
|
value: p.value,
|
|
op: 'add',
|
|
users: [ME],
|
|
userId: ME,
|
|
});
|
|
return {};
|
|
}
|
|
return {};
|
|
},
|
|
connect() {
|
|
return undefined;
|
|
},
|
|
disconnect() {
|
|
return undefined;
|
|
},
|
|
connected: true,
|
|
};
|
|
}
|
|
|
|
function makeFakeRest(): RestPort {
|
|
let seq = 1;
|
|
return {
|
|
async listThreads() {
|
|
return [];
|
|
},
|
|
async createThread() {
|
|
return { threadId: `th_new_${seq++}` };
|
|
},
|
|
async addParticipant() {
|
|
/* governed server-side; a fake always allows */
|
|
},
|
|
async discoverThreads() {
|
|
return [
|
|
{
|
|
threadId: 'th_pub',
|
|
subject: 'general',
|
|
metadata: { membership: 'channel', visibility: 'public', topic: 'Company-wide' },
|
|
participantCount: 3,
|
|
joined: false,
|
|
},
|
|
];
|
|
},
|
|
async leaveThread(threadId) {
|
|
return { threadId, participantCount: 0 };
|
|
},
|
|
};
|
|
}
|
|
|
|
function makeAdapter(): KernelClientAdapter {
|
|
const socket = new MessageSocket({ serviceUrl: 'http://iios.test', token: 'tok', autoConnect: false }, makeFakeSocket());
|
|
return new KernelClientAdapter({ currentUserId: ME, socket, rest: makeFakeRest() });
|
|
}
|
|
|
|
runAdapterConformance({ makeAdapter, seededThreadId: SEEDED, openWith: ['pp_a'] });
|
|
|
|
describe('KernelClientAdapter channels', () => {
|
|
it('browse maps discovered public channels; create/join/leave delegate to the transport', async () => {
|
|
const adapter = makeAdapter();
|
|
const list = await adapter.browseChannels!();
|
|
expect(list[0]).toMatchObject({ threadId: 'th_pub', name: 'general', visibility: 'public', joined: false, memberCount: 3, topic: 'Company-wide' });
|
|
|
|
const { threadId } = await adapter.createChannel!({ name: 'design', topic: 'UI', visibility: 'public' });
|
|
expect(typeof threadId).toBe('string');
|
|
|
|
await expect(adapter.joinChannel!('th_pub')).resolves.toBeUndefined();
|
|
await expect(adapter.leaveChannel!('th_pub')).resolves.toBeUndefined();
|
|
});
|
|
});
|