mirror of
https://github.com/sotashimozono/obsidian-remote-ssh.git
synced 2026-07-22 06:52:07 +00:00
N1 (Important — my prior justification was factually wrong): I'd
claimed the reuse→mismatch→redeploy branch was "e2e-covered", but
the connect e2e always fresh-deploys and never exercises a
pre-existing daemon. The branch + the `?? ''` guard were genuinely
unpinned. Added 4 ConnectionManager.startRpcSession unit tests
(vi.mock DaemonProbe/RpcConnection/ServerDeployer, REAL
resolveRemotePath): reuse-on-match (no redeploy), mismatch →
reused.close() + deploy({remoteVaultRoot: absolute}), absent
vaultRoot (`?? ''`) → redeploy + no throw, no-daemon → fresh deploy.
N2 (Medium — type was lying): `ServerInfo.vaultRoot: string` while
the runtime can be `undefined` for an older/3rd-party daemon — a
future reader would delete the `?? ''` guard trusting the type and
regress. Now `vaultRoot?: string` with a doc-comment forbidding
dropping the `?`. Zero ripple: the sole runtime reader
(ConnectionManager:106) already `?? ''`-guards; tsc stays green.
Advisory: inline note that `path.posix.normalize` PRESERVES trailing
slashes so the manual strip loop is load-bearing (don't delete);
trimmed the over-explaining absVaultRoot block comment to a
cross-ref; reuse-success log now prints absVaultRoot (was the
relative effectivePath) — consistent with the deploy/mismatch logs.
Deliberately NOT done: a user Notice on the mismatch-triggered
reconnect. ConnectionManager is the transport layer and doesn't
import obsidian's Notice; surfacing it correctly is a caller-side
(main.ts) concern. Pre-existing, not worsened by this PR (now fires
only on a real mismatch, not spuriously) — tracked as a separate
caller-side UX follow-up rather than a layering violation here.
tsc clean, eslint clean, vitest 70 files / 1065 passed / 1 skipped.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
145 lines
5.6 KiB
TypeScript
145 lines
5.6 KiB
TypeScript
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|
|
|
// Mock the three transport collaborators startRpcSession touches.
|
|
// `resolveRemotePath` is kept REAL (pure) so the absolute-vault-root
|
|
// computation under test is exercised for real, not stubbed.
|
|
vi.mock('../src/transport/DaemonProbe', () => ({
|
|
tryReuseExistingDaemon: vi.fn(),
|
|
}));
|
|
vi.mock('../src/transport/RpcConnection', () => ({
|
|
establishRpcConnection: vi.fn(),
|
|
}));
|
|
const deployMock = vi.fn();
|
|
vi.mock('../src/transport/ServerDeployer', async (orig) => {
|
|
const actual = await orig<typeof import('../src/transport/ServerDeployer')>();
|
|
return {
|
|
...actual, // keep the real resolveRemotePath
|
|
// A class (not an arrow) — ConnectionManager does `new ServerDeployer(client)`.
|
|
ServerDeployer: class { deploy = deployMock; },
|
|
};
|
|
});
|
|
|
|
import { ConnectionManager } from '../src/ConnectionManager';
|
|
import { tryReuseExistingDaemon } from '../src/transport/DaemonProbe';
|
|
import { establishRpcConnection } from '../src/transport/RpcConnection';
|
|
import type { SshProfile } from '../src/types';
|
|
|
|
describe('ConnectionManager static helpers', () => {
|
|
it('resolveClientId sanitizes explicit clientId overrides', () => {
|
|
expect(ConnectionManager.resolveClientId({ clientId: ' laptop / dev ' })).toBe('laptop-dev');
|
|
});
|
|
|
|
it('resolveClientId falls back to defaultClientId when clientId is blank or omitted', () => {
|
|
const fromBlank = ConnectionManager.resolveClientId({ clientId: ' ' });
|
|
const fromEmpty = ConnectionManager.resolveClientId({ clientId: '' });
|
|
const fromOmitted = ConnectionManager.resolveClientId({});
|
|
expect(fromBlank).toBe(fromEmpty);
|
|
expect(fromEmpty).toBe(fromOmitted);
|
|
expect(fromBlank).toMatch(/^[A-Za-z0-9._-]+$/);
|
|
expect(fromBlank).not.toMatch(/^-|-$/);
|
|
});
|
|
|
|
it('formatUserLabel uses trimmed userName + resolved clientId', () => {
|
|
expect(ConnectionManager.formatUserLabel({
|
|
userName: ' alice ',
|
|
clientId: 'desk 01',
|
|
})).toBe('alice@desk-01');
|
|
});
|
|
|
|
it('formatUserLabel falls back when values are blank', () => {
|
|
const label = ConnectionManager.formatUserLabel({ userName: ' ', clientId: ' ' });
|
|
const [name, id] = label.split('@');
|
|
expect(name).toBeTruthy();
|
|
expect(id).toBeTruthy();
|
|
expect(id).toMatch(/^[A-Za-z0-9._-]+$/);
|
|
expect(id).not.toMatch(/^-|-$/);
|
|
});
|
|
});
|
|
|
|
// ─── startRpcSession: daemon-reuse vault-root validation (#355) ───────────────
|
|
// Pins the branch the connect e2e does NOT exercise (it always
|
|
// fresh-deploys): reuse-on-match, kill+redeploy-on-mismatch, and the
|
|
// `reused.info.vaultRoot ?? ''` guard against an old daemon omitting
|
|
// the field.
|
|
|
|
describe('ConnectionManager.startRpcSession — daemon-reuse vault-root guard', () => {
|
|
const tryReuse = vi.mocked(tryReuseExistingDaemon);
|
|
const estRpc = vi.mocked(establishRpcConnection);
|
|
|
|
const HOME = '/home/souta';
|
|
const profile = { id: 'p', name: 'P', remotePath: '~/work' } as unknown as SshProfile;
|
|
|
|
function makeClient() {
|
|
return {
|
|
getRemoteHome: vi.fn().mockResolvedValue(HOME),
|
|
openUnixStream: vi.fn().mockResolvedValue({}),
|
|
} as unknown as ConstructorParameters<typeof ConnectionManager>[0];
|
|
}
|
|
function makeMgr() {
|
|
return new ConnectionManager(makeClient(), { locateDaemonBinary: () => '/local/daemon' });
|
|
}
|
|
function reusedConn(vaultRoot: string | undefined) {
|
|
return {
|
|
info: { version: '0', protocolVersion: 1, capabilities: [], vaultRoot },
|
|
close: vi.fn(),
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
tryReuse.mockReset();
|
|
estRpc.mockReset();
|
|
deployMock.mockReset();
|
|
deployMock.mockResolvedValue({ token: 'tok', remoteSocketPath: 'sock' });
|
|
estRpc.mockResolvedValue({
|
|
info: { version: '0', protocolVersion: 1, capabilities: [], vaultRoot: `${HOME}/work` },
|
|
close: vi.fn(),
|
|
} as never);
|
|
});
|
|
|
|
it('reuses the daemon when its vaultRoot matches the profile root (no redeploy)', async () => {
|
|
const reused = reusedConn(`${HOME}/work`); // resolveRemotePath('work', HOME) === /home/souta/work
|
|
tryReuse.mockResolvedValue(reused as never);
|
|
const mgr = makeMgr();
|
|
|
|
await mgr.startRpcSession(profile, 'work');
|
|
|
|
expect(mgr.rpcConnection).toBe(reused);
|
|
expect(deployMock).not.toHaveBeenCalled();
|
|
expect(reused.close).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('closes the stale daemon and redeploys at the correct root on mismatch', async () => {
|
|
const reused = reusedConn(`${HOME}/work/OldVaultDev`); // different root
|
|
tryReuse.mockResolvedValue(reused as never);
|
|
const mgr = makeMgr();
|
|
|
|
await mgr.startRpcSession(profile, 'work');
|
|
|
|
expect(reused.close).toHaveBeenCalledTimes(1);
|
|
expect(deployMock).toHaveBeenCalledTimes(1);
|
|
// deploy() must get the ABSOLUTE resolved root, not the relative form.
|
|
expect(deployMock.mock.calls[0][0]).toMatchObject({
|
|
remoteVaultRoot: `${HOME}/work`,
|
|
});
|
|
});
|
|
|
|
it('treats an absent vaultRoot (old/3rd-party daemon) as a mismatch — redeploys, no throw', async () => {
|
|
const reused = reusedConn(undefined); // `?? ''` guard path
|
|
tryReuse.mockResolvedValue(reused as never);
|
|
const mgr = makeMgr();
|
|
|
|
await expect(mgr.startRpcSession(profile, 'work')).resolves.toBeUndefined();
|
|
expect(reused.close).toHaveBeenCalledTimes(1);
|
|
expect(deployMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('deploys fresh when there is no daemon to reuse', async () => {
|
|
tryReuse.mockResolvedValue(null);
|
|
const mgr = makeMgr();
|
|
|
|
await mgr.startRpcSession(profile, 'work');
|
|
|
|
expect(deployMock).toHaveBeenCalledTimes(1);
|
|
expect(deployMock.mock.calls[0][0]).toMatchObject({ remoteVaultRoot: `${HOME}/work` });
|
|
});
|
|
});
|