mirror of
https://github.com/sotashimozono/obsidian-remote-ssh.git
synced 2026-07-22 06:52:07 +00:00
From the multi-agent review of #407: - connectProfile loud-fail test now asserts a Notice surfaced AND that the silent SFTP-downgrade path was NOT taken (the "loud" part was previously only inferred from ERROR state, which a return-early bug could fake). - both connectProfile tests assert conn.connectSsh was called, so a refactor that short-circuits the connect cannot leave them green. - reconnectAttempt test: corrected a stale comment ("re-prompt consent" no longer applies post-#406, which persists the decline) to the real harm (burns retries + a misleading "reconnect failed"). - makePlugin: one intermediate cast instead of four; replayOfflineQueue mock resolves a promise to match the fire-and-forget call shape. Tests-only. Affected suites green; lint + tsc clean. Refs #406, #407 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
236 lines
10 KiB
TypeScript
236 lines
10 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, DaemonUnavailableError, type ConnectionDeps } 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(deps: Partial<ConnectionDeps> = {}) {
|
|
return new ConnectionManager(makeClient(), {
|
|
locateDaemonBinary: () => '/local/daemon',
|
|
ensureDaemonBinary: vi.fn().mockResolvedValue(null),
|
|
...deps,
|
|
});
|
|
}
|
|
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` });
|
|
});
|
|
});
|
|
|
|
// ─── startRpcSession: daemon binary fallback (#397) ──────────────────────────
|
|
// The community-store path: no binary staged locally → download one via the
|
|
// injected `ensureDaemonBinary`. Pins `locateDaemonBinary() ?? ensureDaemonBinary()`
|
|
// and the DaemonUnavailableError → SFTP-downgrade signal, neither of which the
|
|
// existing reuse tests exercise (they always have a staged '/local/daemon').
|
|
|
|
describe('ConnectionManager.startRpcSession — daemon binary fallback (#397)', () => {
|
|
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];
|
|
}
|
|
|
|
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);
|
|
tryReuse.mockResolvedValue(null); // always fresh-deploy
|
|
});
|
|
|
|
it('downloads via ensureDaemonBinary when no binary is staged, then deploys that path', async () => {
|
|
const downloaded = '/cache/server-bin/obsidian-remote-server-linux-amd64';
|
|
const ensureDaemonBinary = vi.fn().mockResolvedValue(downloaded);
|
|
const mgr = new ConnectionManager(makeClient(), { locateDaemonBinary: () => null, ensureDaemonBinary });
|
|
|
|
await mgr.startRpcSession(profile, 'work');
|
|
|
|
expect(ensureDaemonBinary).toHaveBeenCalledTimes(1);
|
|
expect(deployMock.mock.calls[0][0]).toMatchObject({ localBinaryPath: downloaded });
|
|
});
|
|
|
|
it('throws DaemonUnavailableError (→ SFTP downgrade) when neither staged nor downloaded binary exists', async () => {
|
|
const ensureDaemonBinary = vi.fn().mockResolvedValue(null);
|
|
const mgr = new ConnectionManager(makeClient(), { locateDaemonBinary: () => null, ensureDaemonBinary });
|
|
|
|
await expect(mgr.startRpcSession(profile, 'work')).rejects.toBeInstanceOf(DaemonUnavailableError);
|
|
expect(deployMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does NOT call ensureDaemonBinary when a binary is staged locally (dev build)', async () => {
|
|
const ensureDaemonBinary = vi.fn().mockResolvedValue(null);
|
|
const mgr = new ConnectionManager(makeClient(), { locateDaemonBinary: () => '/local/daemon', ensureDaemonBinary });
|
|
|
|
await mgr.startRpcSession(profile, 'work');
|
|
|
|
expect(ensureDaemonBinary).not.toHaveBeenCalled();
|
|
expect(deployMock.mock.calls[0][0]).toMatchObject({ localBinaryPath: '/local/daemon' });
|
|
});
|
|
|
|
it('#406 I-1: a reconnect downgrades to SFTP when the daemon is unavailable (does NOT throw into the retry loop)', async () => {
|
|
const ensureDaemonBinary = vi.fn().mockResolvedValue(null);
|
|
const client = { isAlive: vi.fn().mockReturnValue(true) } as unknown as ConstructorParameters<typeof ConnectionManager>[0];
|
|
const mgr = new ConnectionManager(client, { locateDaemonBinary: () => null, ensureDaemonBinary });
|
|
// reconnectAttempt reads activeProfile; seed an RPC one directly.
|
|
(mgr as unknown as { activeProfile: SshProfile }).activeProfile = { ...profile, transport: 'rpc' } as SshProfile;
|
|
const hooks = {
|
|
swapClient: vi.fn(),
|
|
prepareListenerForReconnect: vi.fn(),
|
|
resumeListenerAfterReconnect: vi.fn(),
|
|
};
|
|
|
|
// reconnectAttempt is private — invoke via cast. It must RESOLVE, not
|
|
// reject: a DaemonUnavailableError on reconnect is caught and the session
|
|
// continues on SFTP, rather than bubbling to ReconnectManager's retry loop
|
|
// (which would burn maxRetries and surface a misleading "reconnect failed"
|
|
// instead of switching to SFTP).
|
|
await expect(
|
|
(mgr as unknown as { reconnectAttempt(h: typeof hooks): Promise<void> }).reconnectAttempt(hooks),
|
|
).resolves.toBeUndefined();
|
|
|
|
expect(ensureDaemonBinary).toHaveBeenCalledTimes(1);
|
|
expect(mgr.rpcConnection).toBeNull(); // stayed on SFTP
|
|
expect(hooks.swapClient).toHaveBeenCalledTimes(1); // rebound to the SFTP fs client
|
|
});
|
|
});
|