sotashimozono_obsidian-remo.../plugin/tests/RpcClient.test.ts
sotashimozono bdd31864cf
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 22:09:54 +09:00

170 lines
6.2 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;
});
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();
});
});