sotashimozono_obsidian-remo.../plugin/tests/QueueReplayer.test.ts
Souta f51804e97b feat(plugin): Phase E2-β.3 — QueueReplayer + reconnect/connect drain (0.4.34)
Closes the offline-write loop. After an SSH session recovers (or
on a fresh connect that finds leftover entries from a previous
Electron session), the queued ops drain serially against the live
adapter. PreconditionFailed during drain routes through the same
3-way merge UI from E2-α; transport errors leave entries queued
for the next reconnect.

Pieces:

- `plugin/src/adapter/SftpDataAdapter.ts`
  - New `replayQueuedOp(op)` method. Dispatches by op kind; for
    text/binary writes it threads the queued op's `expectedMtime`
    through `writeBuffer` (new optional `expectedMtimeOverride`
    parameter) so PreconditionFailed checks fire against the mtime
    the user actually saw at enqueue time, not the synthetic mtime
    the offline cache update wrote.
  - Returns a discriminated outcome: `ok` / `conflict` / `error`.
    `conflict` is treated as user-decided (queue advances);
    `error` halts the drain (queue retries next reconnect).
  - `queueOrThrowText` no longer refreshes the ancestor tracker
    on offline writes — keeping the original "what user read"
    snapshot is what makes the conflict modal useful at replay
    time. Without this, the modal would show (mine, mine, theirs)
    instead of (ancestor, mine, theirs).

- `plugin/src/offline/QueueReplayer.ts` (new)
  - One-shot driver: instantiate, call `run()`, throw away.
  - Drains oldest-first.
  - Reports `{ drained, conflicts, errors }`.
  - Stops on first `error` so a chain of edits to the same file
    keeps order (skipping risks a write on a path that depends on
    an earlier rename / mkdir).

- `plugin/src/main.ts`
  - New `replayOfflineQueue(label)` helper.
  - Triggered fire-and-forget on:
    - Reconnect 'recovered' state (`onReconnectStateChange`).
    - Initial connect success (so a previous-session crash leaves
      pending entries that drain when the user reconnects).
  - Surfaces "replayed N edits" / "N failed to replay" notices.

Tests:

- `plugin/tests/QueueReplayer.test.ts` (7 cases): empty queue,
  oldest-first happy path, conflict counts as decided, error
  stops the drain, target throw treated as error, partial drain
  resumes on next replayer instance, all op kinds pass through.
- 5 new SftpDataAdapter cases for `replayQueuedOp`: write honours
  queued mtime, conflict outcome on PreconditionFailed without
  ancestor, mkdir/remove/rename/copy dispatch, error outcome on
  non-precondition throw, refuses to run while reconnecting.
- All 412 unit tests pass (400 prior + 12 new).

Phase E2-β functionality complete; β.4 (StatusBar pending-edits
indicator + discard modal) is the polish PR that follows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:58:36 +09:00

166 lines
6.7 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as os from 'node:os';
import { OfflineQueue, type QueuedOp } from '../src/offline/OfflineQueue';
import { QueueReplayer, type ReplayTarget } from '../src/offline/QueueReplayer';
async function tempQueue(label: string, ops: QueuedOp[] = []): Promise<OfflineQueue> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), `remote-ssh-replayer-${label}-`));
const q = await OfflineQueue.open(dir);
for (const op of ops) await q.enqueue(op);
return q;
}
function textWrite(p: string, content: string): QueuedOp {
return { kind: 'write', path: p, contentBase64: Buffer.from(content, 'utf8').toString('base64') };
}
function makeTarget(answers: Array<Awaited<ReturnType<ReplayTarget['replayQueuedOp']>>>): {
target: ReplayTarget;
calls: QueuedOp[];
} {
const calls: QueuedOp[] = [];
let i = 0;
return {
calls,
target: {
async replayQueuedOp(op) {
calls.push(op);
if (i >= answers.length) {
throw new Error(`fake target: no more answers programmed (call #${i + 1})`);
}
return answers[i++];
},
},
};
}
describe('QueueReplayer', () => {
// ─── happy paths ────────────────────────────────────────────────────────
it('drains an empty queue without calling the target', async () => {
const q = await tempQueue('empty');
const { target, calls } = makeTarget([]);
const report = await new QueueReplayer(q, target).run();
expect(report).toEqual({ drained: 0, conflicts: 0, errors: [] });
expect(calls).toHaveLength(0);
});
it('drains a 3-entry queue oldest-first when every op succeeds', async () => {
const q = await tempQueue('happy', [
textWrite('a.md', 'one'),
textWrite('b.md', 'two'),
textWrite('c.md', 'three'),
]);
const { target, calls } = makeTarget([
{ result: 'ok' }, { result: 'ok' }, { result: 'ok' },
]);
const report = await new QueueReplayer(q, target).run();
expect(report.drained).toBe(3);
expect(report.conflicts).toBe(0);
expect(report.errors).toEqual([]);
expect(calls.map(o => (o as { path: string }).path)).toEqual(['a.md', 'b.md', 'c.md']);
expect(q.pending()).toEqual([]);
});
// ─── conflict counts as decided, not queued ─────────────────────────────
it('marks conflict-resolved entries completed (does not re-enqueue)', async () => {
const q = await tempQueue('conflict', [
textWrite('a.md', 'one'),
textWrite('b.md', 'two'),
]);
const { target } = makeTarget([
{ result: 'conflict' }, // user picked keep-theirs
{ result: 'ok' },
]);
const report = await new QueueReplayer(q, target).run();
expect(report.drained).toBe(1);
expect(report.conflicts).toBe(1);
expect(q.pending()).toEqual([]);
});
// ─── error stops the drain ──────────────────────────────────────────────
it('stops on the first error and leaves later entries queued for next reconnect', async () => {
const q = await tempQueue('error', [
textWrite('a.md', 'one'),
textWrite('b.md', 'two'),
textWrite('c.md', 'three'),
]);
const { target, calls } = makeTarget([
{ result: 'ok' },
{ result: 'error', message: 'connection reset' },
{ result: 'ok' }, // never reached
]);
const report = await new QueueReplayer(q, target).run();
expect(report.drained).toBe(1);
expect(report.errors).toEqual([{ id: expect.any(Number), message: 'connection reset' }]);
expect(calls).toHaveLength(2); // 'c.md' never attempted
const remaining = q.pending().map(e => (e.op as { path: string }).path);
expect(remaining).toEqual(['b.md', 'c.md']);
});
// ─── target throw treated as error ──────────────────────────────────────
it('treats a thrown target as an error and stops', async () => {
const q = await tempQueue('throw', [
textWrite('a.md', 'one'),
textWrite('b.md', 'two'),
]);
const target: ReplayTarget = {
async replayQueuedOp() { throw new Error('disk fire'); },
};
const report = await new QueueReplayer(q, target).run();
expect(report.errors).toEqual([{ id: expect.any(Number), message: 'disk fire' }]);
expect(report.drained).toBe(0);
expect(q.pending()).toHaveLength(2);
});
// ─── partial drain across reconnects ────────────────────────────────────
it('next reconnect resumes from the entry that errored', async () => {
const q = await tempQueue('resume', [
textWrite('a.md', 'one'),
textWrite('b.md', 'two'),
textWrite('c.md', 'three'),
]);
// First attempt: 'a' ok, 'b' errors → stop.
const first = makeTarget([
{ result: 'ok' },
{ result: 'error', message: 'transient' },
]);
const r1 = await new QueueReplayer(q, first.target).run();
expect(r1.drained).toBe(1);
expect(q.pending()).toHaveLength(2);
// Second attempt: 'b' ok, 'c' ok.
const second = makeTarget([{ result: 'ok' }, { result: 'ok' }]);
const r2 = await new QueueReplayer(q, second.target).run();
expect(r2.drained).toBe(2);
expect(q.pending()).toEqual([]);
expect(second.calls.map(o => (o as { path: string }).path)).toEqual(['b.md', 'c.md']);
});
// ─── op dispatching is left to the target ───────────────────────────────
it('passes every op kind through verbatim to the target', async () => {
const allKinds: QueuedOp[] = [
{ kind: 'write', path: 'a', contentBase64: 'YQ==' },
{ kind: 'writeBinary', path: 'b', contentBase64: 'Yg==' },
{ kind: 'append', path: 'c', contentBase64: 'Yw==' },
{ kind: 'appendBinary', path: 'd', contentBase64: 'ZA==' },
{ kind: 'mkdir', path: 'e' },
{ kind: 'remove', path: 'f' },
{ kind: 'rmdir', path: 'g', recursive: false },
{ kind: 'rename', oldPath: 'h', newPath: 'i' },
{ kind: 'copy', srcPath: 'j', dstPath: 'k' },
{ kind: 'trashLocal', path: 'l' },
];
const q = await tempQueue('kinds', allKinds);
const { target, calls } = makeTarget(allKinds.map(() => ({ result: 'ok' as const })));
await new QueueReplayer(q, target).run();
expect(calls).toEqual(allKinds);
});
});