feat(infra): introduce op pattern (#8734)

This commit is contained in:
liuyi
2024-11-09 15:23:38 +08:00
committed by GitHub
parent 571e25a7a1
commit d6618b6891
11 changed files with 1239 additions and 1 deletions

View File

@@ -0,0 +1,215 @@
import { afterEach } from 'node:test';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { OpClient } from '../client';
import { type MessageHandlers, transfer } from '../message';
import type { OpSchema } from '../types';
interface TestOps extends OpSchema {
add: [{ a: number; b: number }, number];
bin: [Uint8Array, Uint8Array];
sub: [Uint8Array, number];
}
declare module 'vitest' {
interface TestContext {
producer: OpClient<TestOps>;
handlers: MessageHandlers;
postMessage: ReturnType<typeof vi.fn>;
}
}
describe('op client', () => {
beforeEach(ctx => {
const { port1 } = new MessageChannel();
// @ts-expect-error patch postMessage
port1.postMessage = vi.fn(port1.postMessage);
// @ts-expect-error patch postMessage
ctx.postMessage = port1.postMessage;
ctx.producer = new OpClient(port1);
// @ts-expect-error internal api
ctx.handlers = ctx.producer.handlers;
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should send call op', async ctx => {
// @ts-expect-error internal api
const pendingCalls = ctx.producer.pendingCalls;
const result = ctx.producer.call('add', { a: 1, b: 2 });
expect(ctx.postMessage.mock.calls[0][0]).toMatchInlineSnapshot(`
{
"id": "add:1",
"name": "add",
"payload": {
"a": 1,
"b": 2,
},
"type": "call",
}
`);
expect(pendingCalls.has('add:1')).toBe(true);
// fake consumer return
ctx.handlers.return({ type: 'return', id: 'add:1', data: 3 });
await expect(result).resolves.toBe(3);
expect(pendingCalls.has('add:1')).toBe(false);
});
it('should transfer transferables with call op', async ctx => {
const data = new Uint8Array([1, 2, 3]);
const result = ctx.producer.call('bin', transfer(data, [data.buffer]));
expect(ctx.postMessage.mock.calls[0][1].transfer[0]).toBeInstanceOf(
ArrayBuffer
);
// fake consumer return
ctx.handlers.return({
type: 'return',
id: 'bin:1',
data: new Uint8Array([3, 2, 1]),
});
await expect(result).resolves.toEqual(new Uint8Array([3, 2, 1]));
expect(data.byteLength).toBe(0);
});
it('should cancel call', async ctx => {
const promise = ctx.producer.call('add', { a: 1, b: 2 });
promise.cancel();
expect(ctx.postMessage.mock.lastCall).toMatchInlineSnapshot(`
[
{
"id": "add:1",
"type": "cancel",
},
]
`);
await expect(promise).rejects.toThrow('canceled');
});
it('should timeout call', async ctx => {
const promise = ctx.producer.call('add', { a: 1, b: 2 });
vi.advanceTimersByTime(4000);
await expect(promise).rejects.toThrow('timeout');
});
it('should send subscribe op', async ctx => {
let ob = {
next: vi.fn(),
error: vi.fn(),
complete: vi.fn(),
};
// @ts-expect-error internal api
const subscriptions = ctx.producer.obs;
ctx.producer.subscribe('sub', new Uint8Array([1, 2, 3]), ob);
expect(ctx.postMessage.mock.calls[0][0]).toMatchInlineSnapshot(`
{
"id": "sub:1",
"name": "sub",
"payload": Uint8Array [
1,
2,
3,
],
"type": "subscribe",
}
`);
expect(subscriptions.has('sub:1')).toBe(true);
// fake consumer return
ctx.handlers.next({ type: 'next', id: 'sub:1', data: 1 });
ctx.handlers.next({ type: 'next', id: 'sub:1', data: 2 });
ctx.handlers.next({ type: 'next', id: 'sub:1', data: 3 });
expect(subscriptions.has('sub:1')).toBe(true);
ctx.handlers.complete({ type: 'complete', id: 'sub:1' });
expect(ob.next).toHaveBeenCalledTimes(3);
expect(ob.complete).toHaveBeenCalledTimes(1);
expect(subscriptions.has('sub:1')).toBe(false);
expect(ctx.postMessage.mock.lastCall).toMatchInlineSnapshot(`
[
{
"id": "sub:1",
"type": "unsubscribe",
},
]
`);
// smoking
ob = {
next: vi.fn(),
error: vi.fn(),
complete: vi.fn(),
};
ctx.producer.subscribe('sub', new Uint8Array([1, 2, 3]), ob);
expect(subscriptions.has('sub:2')).toBe(true);
ctx.handlers.next({ type: 'next', id: 'sub:2', data: 1 });
ctx.handlers.error({
type: 'error',
id: 'sub:2',
error: new Error('test'),
});
expect(ob.next).toHaveBeenCalledTimes(1);
expect(ob.error).toHaveBeenCalledTimes(1);
expect(subscriptions.has('sub')).toBe(false);
});
it('should transfer transferables with subscribe op', async ctx => {
const data = new Uint8Array([1, 2, 3]);
const unsubscribe = ctx.producer.subscribe(
'bin',
transfer(data, [data.buffer]),
{
next: vi.fn(),
}
);
expect(data.byteLength).toBe(0);
unsubscribe();
});
it('should unsubscribe subscription op', ctx => {
const unsubscribe = ctx.producer.subscribe(
'sub',
new Uint8Array([1, 2, 3]),
{
next: vi.fn(),
}
);
unsubscribe();
expect(ctx.postMessage.mock.lastCall).toMatchInlineSnapshot(`
[
{
"id": "sub:1",
"type": "unsubscribe",
},
]
`);
});
});

View File

@@ -0,0 +1,197 @@
import { afterEach } from 'node:test';
import { Observable } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { OpConsumer } from '../consumer';
import { type MessageHandlers, transfer } from '../message';
import type { OpSchema } from '../types';
interface TestOps extends OpSchema {
add: [{ a: number; b: number }, number];
any: [any, any];
}
declare module 'vitest' {
interface TestContext {
consumer: OpConsumer<TestOps>;
handlers: MessageHandlers;
postMessage: ReturnType<typeof vi.fn>;
}
}
describe('op consumer', () => {
beforeEach(ctx => {
const { port2 } = new MessageChannel();
// @ts-expect-error patch postMessage
port2.postMessage = vi.fn(port2.postMessage);
// @ts-expect-error patch postMessage
ctx.postMessage = port2.postMessage;
ctx.consumer = new OpConsumer(port2);
// @ts-expect-error internal api
ctx.handlers = ctx.consumer.handlers;
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should throw if no handler registered', async ctx => {
ctx.handlers.call({ type: 'call', id: 'add:1', name: 'add', payload: {} });
await vi.advanceTimersToNextTimerAsync();
expect(ctx.postMessage.mock.lastCall).toMatchInlineSnapshot(`
[
{
"error": [Error: Handler for operation [add] is not registered.],
"id": "add:1",
"type": "return",
},
]
`);
});
it('should handle call message', async ctx => {
ctx.consumer.register('add', ({ a, b }) => a + b);
ctx.handlers.call({
type: 'call',
id: 'add:1',
name: 'add',
payload: { a: 1, b: 2 },
});
await vi.advanceTimersToNextTimerAsync();
expect(ctx.postMessage.mock.calls[0][0]).toMatchInlineSnapshot(`
{
"data": 3,
"id": "add:1",
"type": "return",
}
`);
});
it('should handle cancel message', async ctx => {
ctx.consumer.register('add', ({ a, b }, { signal }) => {
const { reject, resolve, promise } = Promise.withResolvers<number>();
signal?.addEventListener('abort', () => {
reject(new Error('canceled'));
});
setTimeout(() => {
resolve(a + b);
}, Number.MAX_SAFE_INTEGER);
return promise;
});
ctx.handlers.call({
type: 'call',
id: 'add:1',
name: 'add',
payload: { a: 1, b: 2 },
});
ctx.handlers.cancel({ type: 'cancel', id: 'add:1' });
await vi.advanceTimersByTimeAsync(1);
expect(ctx.postMessage).not.toBeCalled();
});
it('should transfer transferables in return', async ctx => {
const data = new Uint8Array([1, 2, 3]);
const nonTransferred = new Uint8Array([4, 5, 6]);
ctx.consumer.register('any', () => {
return transfer({ data: { data, nonTransferred } }, [data.buffer]);
});
ctx.handlers.call({ type: 'call', id: 'any:1', name: 'any', payload: {} });
await vi.advanceTimersToNextTimerAsync();
expect(ctx.postMessage).toHaveBeenCalledOnce();
expect(data.byteLength).toBe(0);
expect(nonTransferred.byteLength).toBe(3);
});
it('should handle subscribe message', async ctx => {
ctx.consumer.register('any', data => {
return new Observable(observer => {
data.forEach((v: number) => observer.next(v));
observer.complete();
});
});
ctx.handlers.subscribe({
type: 'subscribe',
id: 'any:1',
name: 'any',
payload: transfer(new Uint8Array([1, 2, 3]), [
new Uint8Array([1, 2, 3]).buffer,
]),
});
await vi.advanceTimersToNextTimerAsync();
expect(ctx.postMessage.mock.calls.map(call => call[0]))
.toMatchInlineSnapshot(`
[
{
"data": 1,
"id": "any:1",
"type": "next",
},
{
"data": 2,
"id": "any:1",
"type": "next",
},
{
"data": 3,
"id": "any:1",
"type": "next",
},
{
"id": "any:1",
"type": "complete",
},
]
`);
});
it('should handle unsubscribe message', async ctx => {
ctx.consumer.register('any', data => {
return new Observable(observer => {
data.forEach((v: number) => {
setTimeout(() => {
observer.next(v);
}, 1);
});
setTimeout(() => {
observer.complete();
}, 1);
});
});
ctx.handlers.subscribe({
type: 'subscribe',
id: 'any:1',
name: 'any',
payload: transfer(new Uint8Array([1, 2, 3]), [
new Uint8Array([1, 2, 3]).buffer,
]),
});
ctx.handlers.unsubscribe({ type: 'unsubscribe', id: 'any:1' });
await vi.advanceTimersToNextTimerAsync();
expect(ctx.postMessage.mock.calls).toMatchInlineSnapshot(`
[
[
{
"id": "any:1",
"type": "complete",
},
],
]
`);
});
});

View File

@@ -0,0 +1,76 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
AutoMessageHandler,
ignoreUnknownEvent,
KNOWN_MESSAGE_TYPES,
type MessageCommunicapable,
type MessageHandlers,
} from '../message';
class CustomMessageHandler extends AutoMessageHandler {
public handlers: Partial<MessageHandlers> = {
call: vi.fn(),
cancel: vi.fn(),
subscribe: vi.fn(),
unsubscribe: vi.fn(),
return: vi.fn(),
next: vi.fn(),
error: vi.fn(),
complete: vi.fn(),
};
}
declare module 'vitest' {
interface TestContext {
sendPort: MessageCommunicapable;
receivePort: MessageCommunicapable;
handler: CustomMessageHandler;
}
}
describe('message', () => {
beforeEach(ctx => {
const listeners: ((event: MessageEvent) => void)[] = [];
ctx.sendPort = {
postMessage: (msg: any) => {
listeners.forEach(listener => {
listener(new MessageEvent('message', { data: msg }));
});
},
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};
ctx.receivePort = {
postMessage: vi.fn(),
addEventListener: vi.fn((_event, handler) => {
listeners.push(handler);
}),
removeEventListener: vi.fn(),
};
ctx.handler = new CustomMessageHandler(ctx.receivePort);
ctx.handler.listen();
});
it('should ignore unknown message type', ctx => {
const handler = vi.fn();
// @ts-expect-error internal api
ctx.handler.handleMessage = ignoreUnknownEvent(handler);
ctx.sendPort.postMessage('connected');
ctx.sendPort.postMessage({ type: 'call1' });
ctx.sendPort.postMessage(new Uint8Array());
ctx.sendPort.postMessage(null);
ctx.sendPort.postMessage(undefined);
expect(handler).not.toHaveBeenCalled();
});
it('should handle known message type', async ctx => {
for (const type of KNOWN_MESSAGE_TYPES) {
ctx.sendPort.postMessage({ type });
expect(ctx.handler.handlers[type]).toBeCalled();
}
});
});