sotashimozono_obsidian-remo.../plugin/tests/RpcClient.test.ts
Souta dcfa2c0fa5 feat(plugin): RpcClient + RpcRemoteFsClient transport layer
Plumbing for the daemon-backed transport that will replace SftpClient
in a follow-up PR. Nothing here is wired into the runtime yet — every
new module is tree-shaken out of the production bundle until the
adapter switchover lands.

src/transport/framing.ts
- FramedDuplex wraps any Node Duplex (TCP socket, ssh2 stream) with
  LSP-style framing:
      Content-Length: <N>\r\n
      \r\n
      <N bytes of JSON>
- Emits 'message', 'close', 'error'. Copies the message body out of
  the shared rolling buffer so later shrinks don't mutate it.
- Configurable maxMessageBytes (default 16 MiB). Oversized payloads
  close the stream with an error.
- Tolerates bare LF framing for lenient senders, matching the
  parallel tolerance in the Go daemon's ReadFrame.
- Tests feed one byte at a time to exercise the partial-frame path.

src/transport/RpcError.ts
- Small typed Error carrying a code, optional data payload, and an
  `is(code)` helper that plays nicely with the ErrorCode constants
  already shipped in proto/types.ts.

src/transport/RpcClient.ts
- Correlates outbound call(method, params) requests with their reply
  by numeric id. Returns a typed Result<M> via Params<M>/Result<M>.
- Demultiplexes concurrent calls.
- onNotification(method, cb) dispatches server-push events (fs.changed
  lands in a later phase); returns a disposer.
- Stream close rejects every pending call with an RpcError(-32603),
  so no handler hangs when the SSH tunnel drops. onClose listeners
  see the underlying error when there was one.
- Malformed server messages are dropped rather than killing the
  session — the only way a pending call is never answered is via the
  close path, which always fires.

src/adapter/RemoteFsClient.ts
- Narrow interface for what SftpDataAdapter actually needs from its
  client: lifecycle (isAlive + onClose), read side (stat/exists/
  list/readBinary), and write side (writeBinary/mkdirp/remove/
  rmdir/rename/copy). Deliberately smaller than the full SFTP
  surface — atomic-write and parent-dir creation are the server's
  responsibility under the α model.

src/adapter/RpcRemoteFsClient.ts
- Implementation backed by RpcClient. Forwards each method to the
  matching daemon call, translating the wire DTOs (proto.Stat /
  proto.Entry / base64 string) into the existing RemoteStat /
  RemoteEntry / Buffer shapes so the adapter above is unchanged.
- stat returns null from the daemon → thrown as an RpcError with
  ErrorCode.FileNotFound so the adapter sees the same "missing"
  signal it used to get from ssh2.
- mkdirp → fs.mkdir{ recursive: true }; rmdir defaults recursive=false.

Tests (vitest)
- tests/framing.test.ts (7): duplex pair round-trip, multi-frame
  pipelining, byte-at-a-time reassembly, missing Content-Length,
  oversized-message rejection, bare-LF tolerance, close() refuses
  further writes.
- tests/RpcClient.test.ts (11): id correlation, error envelope →
  RpcError with matching code, out-of-order concurrent calls,
  notification delivery + disposer, pending-call rejection on
  close, onClose handler with/without error, call() after close,
  malformed payloads ignored without hanging other calls.
- tests/RpcRemoteFsClient.test.ts (11): DTO conversion for stat /
  list, exists / readBinary / writeBinary round-trip, mkdirp →
  fs.mkdir recursive, rmdir default, rename / copy param shapes,
  isAlive mirrors RpcClient.isClosed, stat(null) → FileNotFound.

Verification
- cd plugin && npx tsc --noEmit            clean
- cd plugin && npm test                    108 / 108 pass (29 new)
- cd plugin && npm run build               bundle unchanged at 369 KB
                                           (transport modules are not
                                           reachable from runtime code
                                           until Phase 5-D.2 wires
                                           them in).

Follow-up
- Phase 5-D.2: SshTunnel (ssh2 openssh_forwardOutStreamLocal wrap) +
  SftpDataAdapter refactor to RemoteFsClient + runtime wiring.
2026-04-25 11:59:06 +09:00

145 lines
5.4 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { EventEmitter } from 'events';
import { RpcClient } from '../src/transport/RpcClient';
import { RpcError } from '../src/transport/RpcError';
/**
* A FramedDuplex stand-in just rich enough for RpcClient: it exposes
* the same events (`message`, `close`, `error`) and captures anything
* written via `writeMessage` so tests can assert the wire shape.
*/
class FakeFramed extends EventEmitter {
public sent: Buffer[] = [];
public closed = false;
writeMessage(body: Buffer): boolean {
if (this.closed) throw new Error('closed');
this.sent.push(body);
return true;
}
close(): void {
if (this.closed) return;
this.closed = true;
this.emit('close');
}
/** Drive a response back to the client; for tests only. */
pushMessage(envelope: unknown): void {
this.emit('message', Buffer.from(JSON.stringify(envelope), 'utf8'));
}
}
function setup() {
const framed = new FakeFramed();
const client = new RpcClient(framed as unknown as import('../src/transport/framing').FramedDuplex);
return { framed, client };
}
describe('RpcClient', () => {
it('correlates a call with its response by id', async () => {
const { framed, client } = setup();
const pending = client.call('server.info', {});
expect(framed.sent.length).toBe(1);
const req = JSON.parse(framed.sent[0].toString('utf8')) as { id: number };
framed.pushMessage({ jsonrpc: '2.0', id: req.id, result: { version: '1.0.0', protocolVersion: 1, capabilities: [], vaultRoot: '/v' } });
const result = await pending;
expect(result.version).toBe('1.0.0');
});
it('rejects with RpcError when the response is an error envelope', async () => {
const { framed, client } = setup();
const pending = client.call('fs.stat', { path: 'missing.md' });
const req = JSON.parse(framed.sent[0].toString('utf8')) as { id: number };
framed.pushMessage({ jsonrpc: '2.0', id: req.id, error: { code: -32010, message: 'no such file' } });
await expect(pending).rejects.toBeInstanceOf(RpcError);
try {
await pending;
} catch (e) {
expect((e as RpcError).code).toBe(-32010);
expect((e as RpcError).is(-32010 as never)).toBe(true);
}
});
it('demultiplexes concurrent calls', async () => {
const { framed, client } = setup();
const p1 = client.call('fs.stat', { path: 'a.md' });
const p2 = client.call('fs.stat', { path: 'b.md' });
expect(framed.sent.length).toBe(2);
const [id1, id2] = framed.sent.map(b => (JSON.parse(b.toString('utf8')) as { id: number }).id);
// Deliver out of order.
framed.pushMessage({ jsonrpc: '2.0', id: id2, result: null });
framed.pushMessage({ jsonrpc: '2.0', id: id1, result: { type: 'file', mtime: 1, size: 0, mode: 0 } });
const [r1, r2] = await Promise.all([p1, p2]);
expect(r2).toBeNull();
expect(r1).not.toBeNull();
});
it('delivers notifications to registered handlers', () => {
const { framed, client } = setup();
const handler = vi.fn();
client.onNotification('fs.changed', handler);
framed.pushMessage({
jsonrpc: '2.0',
method: 'fs.changed',
params: { subscriptionId: 's1', path: 'a.md', event: 'modified', mtime: 99 },
});
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0][0]).toMatchObject({ path: 'a.md', event: 'modified' });
});
it('unregisters a notification handler via the returned disposer', () => {
const { framed, client } = setup();
const handler = vi.fn();
const off = client.onNotification('fs.changed', handler);
off();
framed.pushMessage({
jsonrpc: '2.0',
method: 'fs.changed',
params: { subscriptionId: 's1', path: 'a.md', event: 'modified' },
});
expect(handler).not.toHaveBeenCalled();
});
it('rejects pending calls when the stream closes', async () => {
const { framed, client } = setup();
const pending = client.call('fs.stat', { path: 'a.md' });
framed.close();
await expect(pending).rejects.toBeInstanceOf(RpcError);
});
it('fires onClose handlers with no error on a clean close', () => {
const { framed, client } = setup();
const cb = vi.fn();
client.onClose(cb);
framed.close();
expect(cb).toHaveBeenCalledTimes(1);
expect(cb.mock.calls[0][0]).toBeUndefined();
});
it('fires onClose handlers with the error on an abort', () => {
const { framed, client } = setup();
const cb = vi.fn();
client.onClose(cb);
framed.emit('error', new Error('boom'));
expect(cb).toHaveBeenCalledTimes(1);
expect((cb.mock.calls[0][0] as Error).message).toBe('boom');
});
it('rejects call() after close()', async () => {
const { client } = setup();
client.close();
await expect(client.call('server.info', {})).rejects.toBeInstanceOf(RpcError);
});
it('ignores malformed server responses without killing the session', async () => {
const { framed, client } = setup();
const pending = client.call('server.info', {});
const req = JSON.parse(framed.sent[0].toString('utf8')) as { id: number };
// Garbage message first — should be silently dropped.
framed.emit('message', Buffer.from('not-json-at-all', 'utf8'));
// Valid response afterwards — promise still resolves.
framed.pushMessage({ jsonrpc: '2.0', id: req.id, result: { version: '1.0.0', protocolVersion: 1, capabilities: [], vaultRoot: '' } });
await pending;
});
});