mirror of
https://github.com/sotashimozono/obsidian-remote-ssh.git
synced 2026-07-22 06:52:07 +00:00
Adds the writer-side counterpart to the existing M9 sync-reflect E2E
matrix. Refactors `HarnessVault` + `asArrayBuffer` out of
`sync.e2e.test.ts` so the new self-reflect suite (and the upcoming
restart + invariant suites) can share them without duplicating the
event-bus semantics.
Layer 1 ships:
- `helpers/harnessVault.ts` — `HarnessVault`/`HarnessTFile`/
`HarnessTFolder`/`asArrayBuffer` extracted from `sync.e2e.test.ts`.
No behaviour change.
- `helpers/assertSelfReflect.ts` — writer-side variant of
`assertSyncReflect`. Same span-and-budget contract, but the
FakeFileExplorer is attached to the *writer's* vault rather than
a reader peer.
- `self-reflect.e2e.test.ts` — 5 `it.fails(...)` cases documenting
the contract `SftpDataAdapter` should satisfy:
* adapter.write fires vault.trigger('create')
* adapter.write (overwrite) fires vault.trigger('modify')
* adapter.rename fires vault.trigger('rename') ← issue #341
* adapter.remove fires vault.trigger('delete')
* post-rename writer.vault.fileMap reflects the new path
The `.fails` markers come off in the #341 fix PR; that PR's diff
then proves the framework caught the regression.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
607 lines
25 KiB
TypeScript
607 lines
25 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
|
|
import * as fs from 'node:fs';
|
|
import type { Vault } from 'obsidian';
|
|
import { perfTracer } from '../../src/util/PerfTracer';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { SftpDataAdapter } from '../../src/adapter/SftpDataAdapter';
|
|
import { RpcRemoteFsClient } from '../../src/adapter/RpcRemoteFsClient';
|
|
import { ReadCache } from '../../src/cache/ReadCache';
|
|
import { DirCache } from '../../src/cache/DirCache';
|
|
import { ConflictResolver, type ThreeWayPanes } from '../../src/conflict/ConflictResolver';
|
|
import { AncestorTracker } from '../../src/conflict/AncestorTracker';
|
|
import { OfflineQueue, type QueuedOp } from '../../src/offline/OfflineQueue';
|
|
import { QueueReplayer } from '../../src/offline/QueueReplayer';
|
|
import { VaultModelBuilder, type ObsidianClassDeps } from '../../src/vault/VaultModelBuilder';
|
|
import type { FsChangedParams } from '../../src/proto/types';
|
|
import { deployTestDaemon, LOCAL_DAEMON_BINARY, type DeployedDaemon } from './helpers/deployDaemonOnce';
|
|
import { TEST_PRIVATE_KEY, TEST_VAULT } from './helpers/makeAdapter';
|
|
import { buildRpcClient, type RpcClientHandle } from './helpers/multiclientRpc';
|
|
import { FakeFileExplorer } from '../helpers/FakeFileExplorer';
|
|
import { assertSyncReflect } from './helpers/assertSyncReflect';
|
|
import { HarnessVault, HarnessTFile, HarnessTFolder, asArrayBuffer } from './helpers/harnessVault';
|
|
|
|
/**
|
|
* Phase C M9 — sync-reflect E2E matrix (foundational slice).
|
|
*
|
|
* Composes the full Phase C pipeline end-to-end in one Node process:
|
|
*
|
|
* writer SftpDataAdapter (M2 spans) ← writer-side
|
|
* → RpcRemoteFsClient.write* (M2 spans)
|
|
* → daemon (M3 cid correlator + atomicWriteFile)
|
|
* → fsnotify
|
|
* → fs.changed notification (M3 envelope meta)
|
|
* → reader RpcClient.onNotification
|
|
* → applyFsChange-equivalent (T4a + S.app spans)
|
|
* → VaultModelBuilder mutator (T5a span)
|
|
* → fakeVault.trigger(create|modify|...)
|
|
* → FakeFileExplorer (M7) — the T5 observation
|
|
* → assertSyncReflect (M8) — single assertion
|
|
*
|
|
* Cases shipped in this PR (the foundational slice):
|
|
*
|
|
* - create: new file at a never-seen path
|
|
* - delete: remove a previously-created file
|
|
* - rename: move within the same parent
|
|
*
|
|
* Intentionally deferred (will land in M9b/M9c follow-ups):
|
|
*
|
|
* - modify: same fsnotify "no IN_MOVED_TO across-watcher
|
|
* atomic-rename" quirk that M6's bench skipped
|
|
* on; the per-iter re-subscribe workaround needs
|
|
* its own design pass.
|
|
* - large (10 MB) / binary content-hash: bandwidth bench
|
|
* - 3-way conflict: ThreeWayMergeModal
|
|
* stub or real flow
|
|
* - disconnect/reconnect: OfflineQueue +
|
|
* ReconnectManager
|
|
* orchestration
|
|
*
|
|
* Runs only when the test keypair + daemon binary are staged
|
|
* (`npm run sshd:start` + `npm run build:server`); skipped otherwise.
|
|
*/
|
|
|
|
if (!fs.existsSync(TEST_PRIVATE_KEY)) {
|
|
throw new Error(
|
|
`Integration test keypair missing at ${TEST_PRIVATE_KEY}. ` +
|
|
'Run `npm run sshd:start` from the repo root before `npm run test:integration`.',
|
|
);
|
|
}
|
|
if (!fs.existsSync(LOCAL_DAEMON_BINARY)) {
|
|
throw new Error(
|
|
`Daemon binary missing at ${LOCAL_DAEMON_BINARY}. ` +
|
|
'Run `npm run build:server` before `npm run test:integration`.',
|
|
);
|
|
}
|
|
|
|
// ── per-suite plumbing ─────────────────────────────────────────────────
|
|
|
|
const PER_CASE_BUDGET_MS = 3_000;
|
|
|
|
describe('Phase C E2E — sync reflect matrix', () => {
|
|
let daemon: DeployedDaemon;
|
|
let writer: RpcClientHandle;
|
|
let reader: RpcClientHandle;
|
|
let writerAdapter: SftpDataAdapter;
|
|
let readerAdapter: SftpDataAdapter;
|
|
|
|
let harnessVault: HarnessVault;
|
|
let fakeFE: FakeFileExplorer;
|
|
let detachFE: (() => void) | null = null;
|
|
let watchSubId: string | null = null;
|
|
let unsubscribeNotify: (() => void) | null = null;
|
|
|
|
const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
const subdirRel = `e2e-${stamp}`;
|
|
|
|
beforeAll(async () => {
|
|
perfTracer.clear();
|
|
perfTracer.setEnabled(true);
|
|
|
|
daemon = await deployTestDaemon({ label: 'e2e' });
|
|
writer = await buildRpcClient(daemon.result.remoteSocketPath, daemon.result.token, 'e2e-writer');
|
|
reader = await buildRpcClient(daemon.result.remoteSocketPath, daemon.result.token, 'e2e-reader');
|
|
|
|
writerAdapter = new SftpDataAdapter(
|
|
new RpcRemoteFsClient(writer.conn.rpc),
|
|
'',
|
|
new ReadCache({ maxBytes: 64 * 1024 * 1024 }),
|
|
new DirCache(),
|
|
'e2e-writer',
|
|
);
|
|
readerAdapter = new SftpDataAdapter(
|
|
new RpcRemoteFsClient(reader.conn.rpc),
|
|
'',
|
|
new ReadCache({ maxBytes: 64 * 1024 * 1024 }),
|
|
new DirCache(),
|
|
'e2e-reader',
|
|
);
|
|
|
|
// Build the reader's synthetic vault + file-explorer surface and
|
|
// wire the M9 reader pipeline (notification → builder → vault.trigger
|
|
// → FakeFileExplorer.observe via attach).
|
|
harnessVault = new HarnessVault();
|
|
fakeFE = new FakeFileExplorer();
|
|
detachFE = fakeFE.attach(harnessVault as unknown as Vault);
|
|
|
|
const builder = new VaultModelBuilder(
|
|
harnessVault as unknown as Vault,
|
|
{ TFile: HarnessTFile as unknown as ObsidianClassDeps['TFile'],
|
|
TFolder: HarnessTFolder as unknown as ObsidianClassDeps['TFolder'] },
|
|
);
|
|
|
|
unsubscribeNotify = reader.conn.rpc.onNotification('fs.changed', (params: FsChangedParams) => {
|
|
// Filter out events from outside our subdir so other integration
|
|
// tests' detritus (or our own setup mkdir) doesn't pollute the
|
|
// assertions.
|
|
if (!params.path.startsWith(subdirRel)) return;
|
|
handleFsChangedForReader(params, builder, readerAdapter);
|
|
});
|
|
|
|
// Subscribe to fs.changed on the entire vault (parent dir is
|
|
// already watched by the daemon's startup walk; recursive=true
|
|
// catches new sub-tree creation). Per-case re-subscription is
|
|
// not needed for create/delete/rename cells — only modify hits
|
|
// the "across-watcher atomic-rename swallows IN_MOVED_TO" quirk.
|
|
const subResult = await reader.conn.rpc.call('fs.watch', { path: '', recursive: true });
|
|
watchSubId = subResult.subscriptionId;
|
|
|
|
// Bootstrap the FakeVault with the subdir folder so per-case
|
|
// VaultModelBuilder.insertOne(file) can resolve its parent.
|
|
// Done via the writer so the daemon emits a `created` notification
|
|
// → reader handler → builder.insertOne → fakeFE.observe.
|
|
await writerAdapter.mkdir(subdirRel);
|
|
await fakeFE.awaitReflect(subdirRel, 'create', 5_000);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
try { detachFE?.(); } catch { /* best effort */ }
|
|
try { unsubscribeNotify?.(); } catch { /* best effort */ }
|
|
if (watchSubId) {
|
|
try { await reader.conn.rpc.call('fs.unwatch', { subscriptionId: watchSubId }); }
|
|
catch { /* daemon may already be down */ }
|
|
}
|
|
try { await writer.close(); } catch { /* best effort */ }
|
|
try { await reader.close(); } catch { /* best effort */ }
|
|
if (daemon) await daemon.teardown();
|
|
|
|
perfTracer.setEnabled(false);
|
|
perfTracer.clear();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
perfTracer.clear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Don't reset fakeFE — between cases, the synthetic vault should
|
|
// accumulate state the same way Obsidian's real vault does, so a
|
|
// delete-after-create case can look up the previously-created file.
|
|
// The per-suite stamp keeps cases from colliding on path names.
|
|
});
|
|
|
|
// ── cases ───────────────────────────────────────────────────────────
|
|
|
|
it('create — writer create reflects on reader\'s FakeFileExplorer', async () => {
|
|
const target = `${subdirRel}/note-create.bin`;
|
|
const data = Buffer.from('hello-create');
|
|
|
|
const r = await assertSyncReflect({
|
|
label: 'create',
|
|
op: () => writerAdapter.writeBinary(target, asArrayBuffer(data)),
|
|
reader: { fakeFE },
|
|
expect: { path: target, event: 'create' },
|
|
budgetMs: PER_CASE_BUDGET_MS,
|
|
});
|
|
|
|
expect(r.e2eMs).toBeGreaterThan(0);
|
|
expect(fakeFE.snapshot().paths).toContain(target);
|
|
});
|
|
|
|
it('delete — writer delete reflects as a `delete` event on reader\'s FE', async () => {
|
|
const target = `${subdirRel}/note-delete.bin`;
|
|
const data = Buffer.from('to-be-deleted');
|
|
// Pre-create through the same E2E pipeline so the FakeFE knows
|
|
// the path exists before the deletion under test.
|
|
await assertSyncReflect({
|
|
label: 'delete (pre-create)',
|
|
op: () => writerAdapter.writeBinary(target, asArrayBuffer(data)),
|
|
reader: { fakeFE },
|
|
expect: { path: target, event: 'create' },
|
|
budgetMs: PER_CASE_BUDGET_MS,
|
|
});
|
|
|
|
const r = await assertSyncReflect({
|
|
label: 'delete',
|
|
op: () => writerAdapter.remove(target),
|
|
reader: { fakeFE },
|
|
expect: { path: target, event: 'delete' },
|
|
budgetMs: PER_CASE_BUDGET_MS,
|
|
});
|
|
|
|
expect(r.e2eMs).toBeGreaterThan(0);
|
|
expect(fakeFE.snapshot().paths).not.toContain(target);
|
|
});
|
|
|
|
it('rename — writer rename reflects as a `rename` event on the new path', async () => {
|
|
const oldPath = `${subdirRel}/note-rename-src.bin`;
|
|
const newPath = `${subdirRel}/note-rename-dst.bin`;
|
|
const data = Buffer.from('renamed');
|
|
await assertSyncReflect({
|
|
label: 'rename (pre-create)',
|
|
op: () => writerAdapter.writeBinary(oldPath, asArrayBuffer(data)),
|
|
reader: { fakeFE },
|
|
expect: { path: oldPath, event: 'create' },
|
|
budgetMs: PER_CASE_BUDGET_MS,
|
|
});
|
|
|
|
// The daemon's atomic-rename fires `deleted` on the old path AND
|
|
// `created` on the new path; the M3 cid correlator stamps the
|
|
// same cid on both notifications. The reader handler dispatches
|
|
// the `deleted` to builder.removeOne (fires `delete` on FE) and
|
|
// the `created` to builder.insertOne (fires `create` on FE).
|
|
// We assert the `create` on the new path — the visible vault
|
|
// outcome — and verify the old path is gone afterwards.
|
|
const r = await assertSyncReflect({
|
|
label: 'rename',
|
|
op: () => writerAdapter.rename(oldPath, newPath),
|
|
reader: { fakeFE },
|
|
expect: { path: newPath, event: 'create' },
|
|
budgetMs: PER_CASE_BUDGET_MS,
|
|
});
|
|
|
|
expect(r.e2eMs).toBeGreaterThan(0);
|
|
const snap = fakeFE.snapshot().paths;
|
|
expect(snap).toContain(newPath);
|
|
// Old path may take a beat longer to purge if the `deleted`
|
|
// notification trails behind the `created` one. Give it the
|
|
// same budget as a separate awaitReflect.
|
|
await fakeFE.awaitReflect(oldPath, 'delete', PER_CASE_BUDGET_MS).catch(() => undefined);
|
|
expect(fakeFE.snapshot().paths).not.toContain(oldPath);
|
|
});
|
|
|
|
// nested-folder: previously skipped because the daemon's fsnotify
|
|
// auto-watch dropped IN_CREATE for descendants of a directory
|
|
// created via os.MkdirAll (the kernel created the children before
|
|
// the dispatch goroutine got a chance to call notify.Add on the
|
|
// parent). Closed by #107 — the watcher now walks the new sub-tree
|
|
// and emits synthetic `created` events for any descendants caught
|
|
// by the race window. See server/internal/watcher/watcher.go's
|
|
// catchUpAfterRace.
|
|
it('nested-folder — writer write into a deep new sub-tree reflects parents + child', async () => {
|
|
const dir1 = `${subdirRel}/level1`;
|
|
const dir2 = `${subdirRel}/level1/level2`;
|
|
const target = `${dir2}/leaf.bin`;
|
|
const data = Buffer.from('deep');
|
|
|
|
const r = await assertSyncReflect({
|
|
label: 'nested-folder',
|
|
op: () => writerAdapter.writeBinary(target, asArrayBuffer(data)),
|
|
reader: { fakeFE },
|
|
expect: { path: target, event: 'create' },
|
|
budgetMs: PER_CASE_BUDGET_MS * 2,
|
|
});
|
|
|
|
expect(r.e2eMs).toBeGreaterThan(0);
|
|
const snap = fakeFE.snapshot().paths;
|
|
expect(snap).toContain(dir1);
|
|
expect(snap).toContain(dir2);
|
|
expect(snap).toContain(target);
|
|
});
|
|
// ── M9b cases (previously deferred) ──────────────────────────────
|
|
|
|
it('modify — writer overwrite reflects as a `modify` event on reader', async () => {
|
|
const target = `${subdirRel}/note-modify.bin`;
|
|
await assertSyncReflect({
|
|
label: 'modify (pre-create)',
|
|
op: () => writerAdapter.writeBinary(target, asArrayBuffer(Buffer.from('v1'))),
|
|
reader: { fakeFE },
|
|
expect: { path: target, event: 'create' },
|
|
budgetMs: PER_CASE_BUDGET_MS,
|
|
});
|
|
|
|
const r = await assertSyncReflect({
|
|
label: 'modify',
|
|
op: () => writerAdapter.writeBinary(target, asArrayBuffer(Buffer.from('v2'))),
|
|
reader: { fakeFE },
|
|
expect: { path: target, event: 'modify' },
|
|
budgetMs: PER_CASE_BUDGET_MS,
|
|
});
|
|
|
|
expect(r.e2eMs).toBeGreaterThan(0);
|
|
expect(fakeFE.snapshot().paths).toContain(target);
|
|
});
|
|
|
|
it('large file (10 MB) — bandwidth path exercises daemon + reader stat', async () => {
|
|
// #109 spec: 10 MB pushes the wire-transfer envelope and verifies
|
|
// the daemon doesn't time out on long writes + the reader's
|
|
// applyFsChange survives a slow stat against a freshly-flushed
|
|
// multi-MB inode. Budget x20 (vs x5 for the old 1 MB case) gives
|
|
// generous headroom for CI's typical SSH RTT without burying real
|
|
// regressions: at ~50 MB/s effective sftp throughput the wire
|
|
// transfer alone is ~200 ms, plus the reader's fs.changed → stat
|
|
// round-trip.
|
|
const target = `${subdirRel}/note-large.bin`;
|
|
const data = Buffer.alloc(10 * 1024 * 1024, 0x42);
|
|
|
|
const r = await assertSyncReflect({
|
|
label: 'large-file-10mb',
|
|
op: () => writerAdapter.writeBinary(target, asArrayBuffer(data)),
|
|
reader: { fakeFE },
|
|
expect: { path: target, event: 'create' },
|
|
budgetMs: PER_CASE_BUDGET_MS * 20,
|
|
});
|
|
|
|
expect(r.e2eMs).toBeGreaterThan(0);
|
|
expect(fakeFE.snapshot().paths).toContain(target);
|
|
});
|
|
|
|
it('binary content-hash round-trip — SHA-256 matches after write → read', async () => {
|
|
const crypto = await import('node:crypto');
|
|
const target = `${subdirRel}/note-binary.bin`;
|
|
// Non-ASCII binary blob with a mix of byte values
|
|
const data = Buffer.from(Array.from({ length: 4096 }, (_, i) => i % 256));
|
|
const expectedHash = crypto.createHash('sha256').update(data).digest('hex');
|
|
|
|
await assertSyncReflect({
|
|
label: 'binary-hash (create)',
|
|
op: () => writerAdapter.writeBinary(target, asArrayBuffer(data)),
|
|
reader: { fakeFE },
|
|
expect: { path: target, event: 'create' },
|
|
budgetMs: PER_CASE_BUDGET_MS,
|
|
});
|
|
|
|
// Read back through the reader adapter and compare SHA-256
|
|
const readBack = await readerAdapter.readBinary(target);
|
|
const actualHash = crypto.createHash('sha256').update(Buffer.from(readBack)).digest('hex');
|
|
expect(actualHash).toBe(expectedHash);
|
|
});
|
|
it('3-way conflict — stale expectedMtime triggers PreconditionFailed from daemon', async () => {
|
|
const target = `${subdirRel}/note-conflict.md`;
|
|
|
|
// Writer A creates the file; its readCache learns the mtime.
|
|
await writerAdapter.write(target, 'version-1');
|
|
|
|
// Writer B (readerAdapter) reads → primes its own cache with mtime M1.
|
|
const v1 = await readerAdapter.read(target);
|
|
expect(v1).toBe('version-1');
|
|
|
|
// Writer A overwrites → the remote mtime advances to M2.
|
|
await writerAdapter.write(target, 'version-2');
|
|
|
|
// Writer B tries to write with its stale cached mtime M1.
|
|
// The daemon sees actual mtime M2 ≠ M1 → PreconditionFailed (-32020).
|
|
// readerAdapter has no ConflictResolver, so it rethrows.
|
|
await expect(readerAdapter.write(target, 'version-3'))
|
|
.rejects.toMatchObject({ code: -32020 });
|
|
|
|
// The remote still holds writer A's content.
|
|
const final = await writerAdapter.read(target);
|
|
expect(final).toBe('version-2');
|
|
});
|
|
|
|
it('3-way conflict — onTextConflict receives {ancestor, mine, theirs} and keep-mine wins', async () => {
|
|
// #109: deeper variant — the previous test asserted the daemon
|
|
// rejects with PreconditionFailed; this one wires a real
|
|
// ConflictResolver + AncestorTracker on a third "Bob" adapter
|
|
// and verifies the full 3-way merge pipeline:
|
|
//
|
|
// Alice creates "version-1" (becomes Bob's ancestor)
|
|
// Bob reads → ancestor remembered
|
|
// Alice overwrites with "alice-edit" (becomes "theirs" on Bob)
|
|
// Bob writes "bob-edit" → PreconditionFailed
|
|
// → ConflictResolver.resolve → onTextConflict({...})
|
|
// → stub returns keep-mine
|
|
// Remote ends up holding "bob-edit"
|
|
//
|
|
// Validates the production wiring used by ThreeWayMergeModal —
|
|
// the previous test only proved the daemon rejects, not that the
|
|
// resolver chain runs end-to-end.
|
|
const target = `${subdirRel}/note-conflict-3way.md`;
|
|
|
|
const bobReadCache = new ReadCache({ maxBytes: 64 * 1024 * 1024 });
|
|
const bobDirCache = new DirCache();
|
|
const bobAncestor = new AncestorTracker();
|
|
const recorded: Array<{ vaultPath: string; panes: ThreeWayPanes }> = [];
|
|
const onTextConflict = async (vaultPath: string, panes: ThreeWayPanes) => {
|
|
recorded.push({ vaultPath, panes });
|
|
return { decision: 'keep-mine' as const };
|
|
};
|
|
const bobConflictResolver = new ConflictResolver(
|
|
new RpcRemoteFsClient(reader.conn.rpc),
|
|
bobReadCache,
|
|
bobAncestor,
|
|
onTextConflict,
|
|
null, // no two-choice fallback — text path is what we're exercising
|
|
);
|
|
const bobAdapter = new SftpDataAdapter(
|
|
new RpcRemoteFsClient(reader.conn.rpc),
|
|
'',
|
|
bobReadCache,
|
|
bobDirCache,
|
|
'e2e-bob',
|
|
null, // pathMapper
|
|
null, // resourceBridge
|
|
bobConflictResolver,
|
|
bobAncestor,
|
|
);
|
|
|
|
// Alice creates "version-1" (Bob's eventual ancestor pane).
|
|
await writerAdapter.write(target, 'version-1');
|
|
|
|
// Bob reads — primes both readCache (mtime M1) and ancestorTracker.
|
|
const v1 = await bobAdapter.read(target);
|
|
expect(v1).toBe('version-1');
|
|
|
|
// Alice overwrites — remote mtime advances to M2; this becomes
|
|
// Bob's "theirs" pane.
|
|
await writerAdapter.write(target, 'alice-edit');
|
|
|
|
// Bob writes with stale M1 → PreconditionFailed → ConflictResolver
|
|
// sees ancestor + onTextConflict wired → calls the stub.
|
|
await bobAdapter.write(target, 'bob-edit');
|
|
|
|
// Stub got called exactly once with the expected panes.
|
|
expect(recorded).toHaveLength(1);
|
|
expect(recorded[0].vaultPath).toBe(target);
|
|
expect(recorded[0].panes).toEqual({
|
|
ancestor: 'version-1',
|
|
mine: 'bob-edit',
|
|
theirs: 'alice-edit',
|
|
});
|
|
|
|
// keep-mine landed on the remote.
|
|
const final = await writerAdapter.read(target);
|
|
expect(final).toBe('bob-edit');
|
|
});
|
|
|
|
it('disconnect → queue → reconnect: pre-queued ops drain in order, reader observes them', async () => {
|
|
// #109 final case: validates F3 (offline queue) + F6 (replay) +
|
|
// F19 (in-order delivery) by pre-populating the OfflineQueue and
|
|
// running QueueReplayer against the connected writerAdapter.
|
|
//
|
|
// The "disconnect" half of the spec (forcibly killing the SSH
|
|
// socket so SftpDataAdapter.write* enqueues internally) involves
|
|
// AdapterManager orchestration that's already covered by the
|
|
// unit-test suite for OfflineQueue + ReconnectManager. Here we
|
|
// focus on the integration assertion the unit suites can't make:
|
|
// queued ops, drained in order, surface on the reader's
|
|
// FakeFileExplorer with the same ordering the user typed.
|
|
const queueDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rssh-queue-e2e-'));
|
|
let queue: OfflineQueue | null = null;
|
|
// F19 in-order delivery is asserted via this arrival-order log, not
|
|
// via the FakeFileExplorer.snapshot().paths shape (that snapshot is
|
|
// lex-sorted, so a/b/c paths would appear ordered even if the daemon
|
|
// delivered them out of order — a false-positive vector).
|
|
const arrivalOrder: string[] = [];
|
|
const baseStamp = `${stamp}-drain`;
|
|
const subRoot = `${subdirRel}/${baseStamp}`;
|
|
const orderRef = harnessVault.on('create', (file: unknown) => {
|
|
const p = (file as { path?: string } | null)?.path;
|
|
if (typeof p === 'string' && p.startsWith(subRoot)) arrivalOrder.push(p);
|
|
});
|
|
try {
|
|
queue = await OfflineQueue.open(queueDir);
|
|
|
|
const ops: QueuedOp[] = [
|
|
{ kind: 'mkdir', path: subRoot },
|
|
{
|
|
kind: 'writeBinary',
|
|
path: `${subRoot}/a.bin`,
|
|
contentBase64: Buffer.from('queued-a').toString('base64'),
|
|
},
|
|
{
|
|
kind: 'writeBinary',
|
|
path: `${subRoot}/b.bin`,
|
|
contentBase64: Buffer.from('queued-b').toString('base64'),
|
|
},
|
|
{
|
|
kind: 'writeBinary',
|
|
path: `${subRoot}/c.bin`,
|
|
contentBase64: Buffer.from('queued-c').toString('base64'),
|
|
},
|
|
];
|
|
for (const op of ops) await queue.enqueue(op);
|
|
expect(queue.pending()).toHaveLength(ops.length);
|
|
|
|
// "Reconnect" — drain via QueueReplayer against the live writer
|
|
// adapter. SftpDataAdapter.replayQueuedOp is the same hook the
|
|
// production reconnect path uses.
|
|
const replayer = new QueueReplayer(queue, writerAdapter);
|
|
const report = await replayer.run();
|
|
expect(report.errors).toEqual([]);
|
|
expect(report.drained).toBe(ops.length);
|
|
expect(queue.pending()).toEqual([]);
|
|
|
|
// Wait for each path to reflect on the reader so we don't race
|
|
// the assertion against an in-flight fs.changed notification.
|
|
// QueuedOp is a discriminated union; pathOf narrows each variant
|
|
// to its observable target path (rename → newPath, copy → dstPath,
|
|
// everything else → .path).
|
|
const pathOf = (o: QueuedOp): string =>
|
|
o.kind === 'rename' ? o.newPath
|
|
: o.kind === 'copy' ? o.dstPath
|
|
: o.path;
|
|
for (const op of ops) {
|
|
await fakeFE.awaitReflect(pathOf(op), 'create', PER_CASE_BUDGET_MS * 5);
|
|
}
|
|
|
|
// Falsifiable F19 assertion: arrivalOrder is the actual sequence
|
|
// of `vault.trigger('create', file)` calls observed by the reader's
|
|
// pipeline. Equality with the source `ops` order proves both
|
|
// (a) all 4 ops landed and (b) the daemon → reader pipeline
|
|
// preserved write order.
|
|
expect(arrivalOrder).toEqual(ops.map(pathOf));
|
|
} finally {
|
|
harnessVault.offref(orderRef);
|
|
await Promise.allSettled([
|
|
queue?.clear() ?? Promise.resolve(),
|
|
fs.promises.rm(queueDir, { recursive: true, force: true }),
|
|
]);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── reader pipeline (mimics main.ts handleFsChanged + applyFsChange) ───
|
|
|
|
function handleFsChangedForReader(
|
|
params: FsChangedParams,
|
|
builder: VaultModelBuilder,
|
|
readerAdapter: SftpDataAdapter,
|
|
): void {
|
|
// T4a — first thing the reader sees after the push frame decodes.
|
|
perfTracer.point('T4a', perfTracer.newCid(), {
|
|
path: params.path,
|
|
event: params.event,
|
|
subscriptionId: params.subscriptionId,
|
|
});
|
|
|
|
// applyFsChange runs async (stat for created/modified); fire-and-
|
|
// forget with internal error swallowing so a slow stat doesn't
|
|
// bubble through the RpcClient.
|
|
void (async () => {
|
|
const __t = perfTracer.begin('S.app');
|
|
try {
|
|
switch (params.event) {
|
|
case 'created': {
|
|
const stat = await readerAdapter.stat(params.path).catch(() => null);
|
|
if (!stat) return;
|
|
builder.insertOne({
|
|
path: params.path,
|
|
isDirectory: stat.type === 'folder',
|
|
ctime: stat.ctime ?? 0,
|
|
mtime: stat.mtime ?? 0,
|
|
size: stat.size ?? 0,
|
|
}, { ensureParents: true });
|
|
return;
|
|
}
|
|
case 'modified': {
|
|
const stat = await readerAdapter.stat(params.path).catch(() => null);
|
|
if (stat) {
|
|
builder.modifyOne(params.path, {
|
|
ctime: stat.ctime ?? 0, mtime: stat.mtime ?? 0, size: stat.size ?? 0,
|
|
});
|
|
} else {
|
|
builder.modifyOne(params.path);
|
|
}
|
|
return;
|
|
}
|
|
case 'deleted': {
|
|
builder.removeOne(params.path);
|
|
return;
|
|
}
|
|
case 'renamed': {
|
|
if (!params.newPath) return;
|
|
builder.renameOne(params.path, params.newPath);
|
|
return;
|
|
}
|
|
}
|
|
} finally {
|
|
perfTracer.end(__t, { event: params.event, path: params.path });
|
|
}
|
|
})();
|
|
}
|
|
|
|
// HarnessVault, HarnessTFile, HarnessTFolder, asArrayBuffer extracted
|
|
// to ./helpers/harnessVault for reuse by the self-reflect / restart /
|
|
// invariant E2E suites. See that file for the contract.
|