sotashimozono_obsidian-remo.../plugin/tests/ConflictResolver.test.ts
sotashimozono 34ea26255b
fix(tests): repair 6 CI failures + bump 1.0.36 (#278)
* fix(tests): repair 6 CI failures introduced by recent PR merges

ObsidianRegistry.test.ts + ShadowVaultBootstrap.test.ts:
  Wrap `import * as fs from "fs"` in `vi.mock("fs", () => ({ ...actual }))`
  so vitest creates a configurable namespace. The raw ESM `fs` namespace
  is non-configurable in Node + Vitest 4, which made the existing
  `vi.spyOn(fs, "writeFileSync"|"renameSync"|"symlinkSync")` calls throw
  `Cannot redefine property` at test setup time. The mock re-exports
  `actual` so all non-spied fs calls keep their real behaviour.

ShadowVaultBootstrap.test.ts (collectPendingPluginSuggestions):
  Two tests asserted `data.pendingPluginSuggestions` equal `[]` when
  source `community-plugins.json` is invalid/non-array. The implementation
  (ShadowVaultBootstrap.ts:121-126) deliberately omits the key entirely
  when `pending.length === 0` -- the absent key is the "no suggestions"
  signal that ShadowStartupCoordinator relies on for its no-op fast path.
  Aligned the test with the implementation contract.

ConflictResolver.test.ts:58:
  `swapClient` test asserted `writeBinary` was called with a third
  `undefined` arg. Other tests in the file (e.g. line 91) and the
  source (ConflictResolver.ts:88,92,129) confirm the call passes only
  two args. Removed the spurious trailing `undefined`.

* chore: bump version to 1.0.36

* ci: bump Node heap to 6 GiB to stop intermittent test-job OOMs

Vitest 4's default `forks` pool plus jsdom on every test file pushed
peak RSS just past Node's default ~4 GiB old-space ceiling once the
suite grew to ~70 files / ~1000 tests. The CI logs showed `FATAL ERROR:
Reached heap limit Allocation failed - JavaScript heap out of memory`
during ubuntu-latest, ubuntu-node22, macos-latest, and windows-latest
test jobs on the last several main-branch runs (it predates this PR;
the earlier 6 unit-test failures were just the first symptom).

Apply the same env to release.yml's test gate so a release tag push
does not flake on the same wall.

GitHub-hosted runners advertise 7+ GiB, so 6 GiB is safe headroom
without crowding the kernel/page-cache budget.

* fix(tests): SftpClient unit tests caused worker OOM via infinite BFS

Root cause of the CI test-job crashes:

  tests/SftpClient.unit.test.ts > rmdir() > recursive: removes all
  files and subdirs before root

mocked sftp.readdir with a path-agnostic `mockImplementation` that
returned `[file.md, sub (dir)]` for every call. SftpClient.rmdir's
recursive branch calls listRecursive, whose BFS then re-discovered
`sub` inside `/vault/sub`, then `/vault/sub/sub`, then
`/vault/sub/sub/sub`, ... -- growing the visited Set, the queue, and
the result array without bound until the worker hit
`FATAL ERROR: Reached heap limit`. Replaced with a path-aware mock so
only `/vault` has children; the BFS terminates after one level.

Also fixed `listRecursive() returns files from nested subdirectories
via BFS`: the assertion expected `['deep.md', ...]` but
`SftpClient.listRecursive` deliberately returns the full path under
root as relativePath (`sub/deep.md`), not the basename -- every
downstream consumer needs the unambiguous form.

Plus pass `--max-old-space-size=6144` to vitest workers via
`test.execArgv` so future legitimately-large suites have headroom
without needing per-job NODE_OPTIONS overrides. (In Vitest 4 the
top-level `poolOptions` was removed; both `pool` and `execArgv` are
now top-level keys inside `test:`.)
2026-05-09 23:26:01 +09:00

265 lines
11 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest';
import { ConflictResolver, type TextConflictDecision } from '../src/conflict/ConflictResolver';
import { AncestorTracker } from '../src/conflict/AncestorTracker';
import { ReadCache } from '../src/cache/ReadCache';
import type { RemoteFsClient } from '../src/adapter/RemoteFsClient';
import type { RemoteStat } from '../src/types';
// ─── minimal fake client ─────────────────────────────────────────────────────
function makeClient(overrides: Partial<RemoteFsClient> = {}): RemoteFsClient {
return {
isAlive: () => true,
onClose: () => () => {},
stat: async () => ({ isFile: true, isDirectory: false, isSymbolicLink: false, mtime: 1000, size: 10, mode: 0o644 } as RemoteStat),
exists: async () => true,
list: async () => [],
readBinary: async () => Buffer.from('remote content'),
writeBinary:async () => {},
mkdirp: async () => {},
remove: async () => {},
rmdir: async () => {},
rename: async () => {},
copy: async () => {},
...overrides,
};
}
const ORIGINAL_ERROR = new Error('PreconditionFailed');
const MINE = Buffer.from('my edit');
const REMOTE_PATH = 'vault/note.md';
const NORM_PATH = 'note.md';
// ─── swapClient ──────────────────────────────────────────────────────────────
describe('ConflictResolver.swapClient', () => {
it('switches the client used for subsequent resolves', async () => {
const tracker = new AncestorTracker();
tracker.remember(NORM_PATH, 'ancestor text', 500);
const firstWrite = vi.fn(async () => {});
const secondWrite = vi.fn(async () => {});
const first = makeClient({ writeBinary: firstWrite });
const second = makeClient({ writeBinary: secondWrite });
const resolver = new ConflictResolver(
first,
new ReadCache(),
tracker,
async () => ({ decision: 'keep-mine' }),
null,
);
resolver.swapClient(second);
await resolver.resolve(NORM_PATH, REMOTE_PATH, MINE, true, ORIGINAL_ERROR);
expect(firstWrite).not.toHaveBeenCalled();
expect(secondWrite).toHaveBeenCalledWith(REMOTE_PATH, MINE);
});
});
// ─── 3-way text conflict ─────────────────────────────────────────────────────
describe('ConflictResolver.resolve — 3-way text path', () => {
function makeText3Way(
decision: () => Promise<TextConflictDecision>,
clientOverrides?: Partial<RemoteFsClient>,
) {
const tracker = new AncestorTracker();
tracker.remember(NORM_PATH, 'ancestor text', 500);
const cache = new ReadCache();
const client = makeClient(clientOverrides);
const resolver = new ConflictResolver(
client,
cache,
tracker,
decision,
null,
);
return { resolver, cache, tracker, client };
}
it('keep-mine: writes mine to remote and returns mine', async () => {
const writeSpy = vi.fn(async () => {});
const { resolver } = makeText3Way(
async () => ({ decision: 'keep-mine' }),
{ writeBinary: writeSpy },
);
const result = await resolver.resolve(NORM_PATH, REMOTE_PATH, MINE, true, ORIGINAL_ERROR);
expect(result).toBe(MINE);
expect(writeSpy).toHaveBeenCalledWith(REMOTE_PATH, MINE);
});
it('merged: writes merged content and returns the merged buffer', async () => {
const writeSpy = vi.fn(async () => {});
const { resolver } = makeText3Way(
async () => ({ decision: 'merged', content: 'hand-merged result' }),
{ writeBinary: writeSpy },
);
const result = await resolver.resolve(NORM_PATH, REMOTE_PATH, MINE, true, ORIGINAL_ERROR);
expect(result.toString('utf8')).toBe('hand-merged result');
expect(writeSpy).toHaveBeenCalledWith(
REMOTE_PATH,
Buffer.from('hand-merged result', 'utf8'),
);
});
it('keep-theirs: updates read cache, updates ancestor, and rethrows originalError', async () => {
const remoteBuf = Buffer.from('their version');
const statSpy = vi.fn(async (): Promise<RemoteStat> => ({ isFile: true, isDirectory: false, isSymbolicLink: false, mtime: 9999, size: 13, mode: 0o644 }));
const { resolver, cache, tracker } = makeText3Way(
async () => ({ decision: 'keep-theirs' }),
{ readBinary: async () => remoteBuf, stat: statSpy },
);
await expect(resolver.resolve(NORM_PATH, REMOTE_PATH, MINE, true, ORIGINAL_ERROR))
.rejects.toBe(ORIGINAL_ERROR);
// Cache must reflect the remote version.
expect(cache.get(REMOTE_PATH)?.data).toEqual(remoteBuf);
expect(cache.get(REMOTE_PATH)?.mtime).toBe(9999);
// Ancestor must be updated to the remote content.
expect(tracker.get(NORM_PATH)?.content).toBe('their version');
expect(tracker.get(NORM_PATH)?.mtime).toBe(9999);
});
it('keep-theirs: stat failure is best-effort — still updates cache with mtime 0', async () => {
const remoteBuf = Buffer.from('their version');
const { resolver, cache } = makeText3Way(
async () => ({ decision: 'keep-theirs' }),
{
readBinary: async () => remoteBuf,
stat: async () => { throw new Error('stat failed'); },
},
);
await expect(resolver.resolve(NORM_PATH, REMOTE_PATH, MINE, true, ORIGINAL_ERROR))
.rejects.toBe(ORIGINAL_ERROR);
expect(cache.get(REMOTE_PATH)?.mtime).toBe(0);
});
it('cancel: rethrows originalError', async () => {
const { resolver } = makeText3Way(async () => ({ decision: 'cancel' }));
await expect(resolver.resolve(NORM_PATH, REMOTE_PATH, MINE, true, ORIGINAL_ERROR))
.rejects.toBe(ORIGINAL_ERROR);
});
it('onTextConflict rejection is treated as cancel — rethrows originalError', async () => {
const { resolver } = makeText3Way(async () => { throw new Error('modal closed'); });
await expect(resolver.resolve(NORM_PATH, REMOTE_PATH, MINE, true, ORIGINAL_ERROR))
.rejects.toBe(ORIGINAL_ERROR);
});
it('readBinary failure falls back to two-choice modal (no onWriteConflict → rethrows)', async () => {
const tracker = new AncestorTracker();
tracker.remember(NORM_PATH, 'ancestor', 500);
const resolver = new ConflictResolver(
makeClient({ readBinary: async () => { throw new Error('SFTP fail'); } }),
new ReadCache(),
tracker,
async () => ({ decision: 'keep-mine' }),
null, // no onWriteConflict
);
await expect(resolver.resolve(NORM_PATH, REMOTE_PATH, MINE, true, ORIGINAL_ERROR))
.rejects.toBe(ORIGINAL_ERROR);
});
it('readBinary failure falls back to two-choice modal (onWriteConflict true → returns mine)', async () => {
const writeSpy = vi.fn(async () => {});
const tracker = new AncestorTracker();
tracker.remember(NORM_PATH, 'ancestor', 500);
const resolver = new ConflictResolver(
makeClient({ readBinary: async () => { throw new Error('SFTP fail'); }, writeBinary: writeSpy }),
new ReadCache(),
tracker,
async () => ({ decision: 'keep-mine' }),
async () => true,
);
const result = await resolver.resolve(NORM_PATH, REMOTE_PATH, MINE, true, ORIGINAL_ERROR);
expect(result).toBe(MINE);
expect(writeSpy).toHaveBeenCalledWith(REMOTE_PATH, MINE);
});
it('ancestor === null falls through to two-choice modal', async () => {
// tracker has no entry for NORM_PATH → ancestor is null.
const resolver = new ConflictResolver(
makeClient(),
new ReadCache(),
new AncestorTracker(), // empty — no ancestor
async () => ({ decision: 'keep-mine' }),
async () => false, // user cancels
);
await expect(resolver.resolve(NORM_PATH, REMOTE_PATH, MINE, true, ORIGINAL_ERROR))
.rejects.toBe(ORIGINAL_ERROR);
});
});
// ─── two-choice fallback ─────────────────────────────────────────────────────
describe('ConflictResolver.resolve — two-choice fallback (binary / no ancestor callback)', () => {
it('no onWriteConflict → rethrows originalError', async () => {
const resolver = new ConflictResolver(
makeClient(), new ReadCache(), null, null, null,
);
await expect(resolver.resolve(NORM_PATH, REMOTE_PATH, MINE, false, ORIGINAL_ERROR))
.rejects.toBe(ORIGINAL_ERROR);
});
it('onWriteConflict returns false → rethrows originalError', async () => {
const resolver = new ConflictResolver(
makeClient(), new ReadCache(), null, null, async () => false,
);
await expect(resolver.resolve(NORM_PATH, REMOTE_PATH, MINE, false, ORIGINAL_ERROR))
.rejects.toBe(ORIGINAL_ERROR);
});
it('onWriteConflict rejects → treated as false → rethrows originalError', async () => {
const resolver = new ConflictResolver(
makeClient(), new ReadCache(), null, null,
async () => { throw new Error('modal error'); },
);
await expect(resolver.resolve(NORM_PATH, REMOTE_PATH, MINE, false, ORIGINAL_ERROR))
.rejects.toBe(ORIGINAL_ERROR);
});
it('onWriteConflict returns true → writes mine and returns mine', async () => {
const writeSpy = vi.fn(async () => {});
const resolver = new ConflictResolver(
makeClient({ writeBinary: writeSpy }),
new ReadCache(), null, null, async () => true,
);
const result = await resolver.resolve(NORM_PATH, REMOTE_PATH, MINE, false, ORIGINAL_ERROR);
expect(result).toBe(MINE);
expect(writeSpy).toHaveBeenCalledWith(REMOTE_PATH, MINE);
});
it('binary content (isText=false) always takes two-choice path even with ancestorTracker wired', async () => {
const tracker = new AncestorTracker();
tracker.remember(NORM_PATH, 'ancestor', 500);
const onTextConflict = vi.fn(async (): Promise<TextConflictDecision> => ({ decision: 'keep-mine' }));
const resolver = new ConflictResolver(
makeClient(), new ReadCache(), tracker, onTextConflict, async () => false,
);
await expect(resolver.resolve(NORM_PATH, REMOTE_PATH, MINE, false, ORIGINAL_ERROR))
.rejects.toBe(ORIGINAL_ERROR);
expect(onTextConflict).not.toHaveBeenCalled();
});
it('no ancestorTracker takes two-choice path even when isText=true', async () => {
const onTextConflict = vi.fn(async (): Promise<TextConflictDecision> => ({ decision: 'keep-mine' }));
const resolver = new ConflictResolver(
makeClient(), new ReadCache(), null, onTextConflict, async () => false,
);
await expect(resolver.resolve(NORM_PATH, REMOTE_PATH, MINE, true, ORIGINAL_ERROR))
.rejects.toBe(ORIGINAL_ERROR);
expect(onTextConflict).not.toHaveBeenCalled();
});
it('no onTextConflict takes two-choice path even when isText=true and ancestor exists', async () => {
const tracker = new AncestorTracker();
tracker.remember(NORM_PATH, 'ancestor', 500);
const resolver = new ConflictResolver(
makeClient(), new ReadCache(), tracker, null, async () => false,
);
await expect(resolver.resolve(NORM_PATH, REMOTE_PATH, MINE, true, ORIGINAL_ERROR))
.rejects.toBe(ORIGINAL_ERROR);
});
});