mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-25 10:22:55 +08:00
feat(infra): introduce op pattern (#8734)
This commit is contained in:
215
packages/common/infra/src/op/__tests__/client.spec.ts
Normal file
215
packages/common/infra/src/op/__tests__/client.spec.ts
Normal 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",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
197
packages/common/infra/src/op/__tests__/consumer.spec.ts
Normal file
197
packages/common/infra/src/op/__tests__/consumer.spec.ts
Normal 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",
|
||||
},
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
76
packages/common/infra/src/op/__tests__/message.spec.ts
Normal file
76
packages/common/infra/src/op/__tests__/message.spec.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user