sotashimozono_obsidian-remo.../plugin/tests/RpcClient.test.ts

171 lines
6.2 KiB
TypeScript
Raw Permalink Normal View History

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 02:59:06 +00:00
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;
});
test: second coverage pass — Logger/RpcClient/RpcConnection/WindowSpawner (#260) * test: second coverage pass — Logger/RpcClient/RpcConnection/WindowSpawner Extended four existing test suites to cover previously-untested branches: Logger.test.ts: - setDebug / setMaxLines / clear API methods - wrapConsole: captures console.warn/error to the file sink as external lines, skips [RemoteSSH]-prefixed messages (own plugin traffic), idempotency guard - unwrapConsole: stops capturing after unwrap, is a no-op when unused - captureExternal: no-op when no file sink is installed - formatArg: Error → stack/message; non-JSON-serialisable → String() - installFileSink: mkdirSync failure → fallbackError + early return RpcClient.test.ts: - isClosed() false before / true after close - writeMessage throws → call() rejects with the thrown error (catch block) - onClose disposer removes the handler before close fires RpcConnection.test.ts: - auth RPC result ok:false → AuthInvalid (line 56-57 branch) - server.info error envelope → cleanupOnFailure (line 65-68 branch) WindowSpawner.test.ts: - default opener (no arg constructor) calls window.open with _blank vitest.config.ts: - raise thresholds to lines:76 / branches:69 / functions:73 Coverage delta (measured locally): Statements: 73.5 % → 74.8 % Branches: 68.7 % → 69.7 % Functions: 71.1 % → 73.2 % Lines: 75.1 % → 76.2 % Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fixup! test(Logger): afterEach closes file sink + strengthen captureExternal assertion * afterEach now awaits uninstallFileSink() to prevent fd leaks on test failure — especially important on Windows where open handles block rmSync * captureExternal no-op test: add getLines().toHaveLength(0) to verify the ring buffer was truly untouched, not just that no throw occurred Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 13:09:54 +00:00
it('isClosed() returns false before close and true after', () => {
const { client } = setup();
expect(client.isClosed()).toBe(false);
client.close();
expect(client.isClosed()).toBe(true);
});
it('rejects the call when writeMessage throws', async () => {
const { framed, client } = setup();
vi.spyOn(framed, 'writeMessage').mockImplementationOnce(() => {
throw new Error('write failed');
});
await expect(client.call('server.info', {})).rejects.toThrow('write failed');
expect(client.isClosed()).toBe(false);
});
it('onClose disposer removes the handler so it is not called on close', () => {
const { framed, client } = setup();
const cb = vi.fn();
const off = client.onClose(cb);
off();
framed.close();
expect(cb).not.toHaveBeenCalled();
});
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 02:59:06 +00:00
});