update
This commit is contained in:
201
tests/unit/main/dir-move-nested-subdir.test.ts
Normal file
201
tests/unit/main/dir-move-nested-subdir.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
// tests/unit/main/dir-move-nested-subdir.test.ts
|
||||
//
|
||||
// 守 r188「Windows 上重命名 / 搬移一个**含子目录**的文件夹」契约。
|
||||
//
|
||||
// 根因(本机实证,chokidar 4 + Windows 11 —— 见 utils/watcher-release.ts 头注):
|
||||
// chokidar 用 fs.watch(ReadDirectoryChangesW)递归监听目录树,对**每个**
|
||||
// 被监听的目录持有一个 OS 句柄。Windows 拒绝重命名一个**内部存在打开
|
||||
// 句柄**的目录 ⇒ 只要待搬动的目录里还有子目录(chokidar 正监听着它),
|
||||
// fs.rename 必然 EPERM。
|
||||
//
|
||||
// 四格对照实验:
|
||||
// 无 watcher + 扁平子结构 → rename OK
|
||||
// 无 watcher + 含子目录 → rename OK
|
||||
// 有 watcher + 扁平子结构 → rename OK
|
||||
// 有 watcher + 含子目录 → rename **EPERM** ← 旧行为
|
||||
//
|
||||
// 既有退避重试(retryOnWindowsTransient,5 次 / ~750ms)救不了:句柄是
|
||||
// **长期**的(chokidar 一直在监听那个子目录),不是 Defender 那种瞬时占用。
|
||||
//
|
||||
// 修法:物理搬目录前关掉 watcher(释放全部目录句柄),搬完重建。
|
||||
//
|
||||
// 本测锁:
|
||||
// 1. notes 域:重命名一个含子目录的文件夹 → 成功,磁盘结构对,子树里
|
||||
// 每个笔记的 id 跟着重算;
|
||||
// 2. literature 域:重命名 + 跨目录搬移,同款;
|
||||
// 3. **watcher 仍然活着** —— 搬完之后在**新位置**建文件,服务仍能感知
|
||||
// (修 EPERM 靠的是「关掉再重建」,若重建失败/不重建,监听就永久
|
||||
// 丢了 —— 外部改动再也进不来,而且没有任何报错);
|
||||
// 4. 扁平目录(无子目录)仍然能改名 —— 不回归。
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { promises as fs } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
const repo = vi.hoisted(() => ({
|
||||
upsertResource: vi.fn(),
|
||||
deleteResource: vi.fn(),
|
||||
listResources: vi.fn(() => [] as unknown[]),
|
||||
listResourceRelPaths: vi.fn(() => [] as string[]),
|
||||
listStaleIndexIds: vi.fn(() => [] as string[]),
|
||||
renameResource: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/main/db/resources-repo', () => ({
|
||||
upsertResource: repo.upsertResource,
|
||||
deleteResource: repo.deleteResource,
|
||||
listResources: repo.listResources,
|
||||
listResourceRelPaths: repo.listResourceRelPaths,
|
||||
listStaleIndexIds: repo.listStaleIndexIds,
|
||||
renameResource: repo.renameResource,
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: { handle: vi.fn() },
|
||||
shell: {
|
||||
trashItem: vi.fn(),
|
||||
showItemInFolder: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { setRootPath } from '../../../src/main/utils/safe-write';
|
||||
import { LiteratureService } from '../../../src/main/literature/literature-service';
|
||||
import { NotesService } from '../../../src/main/notes/service';
|
||||
|
||||
async function waitFor(
|
||||
predicate: () => boolean,
|
||||
timeoutMs = 8000,
|
||||
): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate()) return true;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
return predicate();
|
||||
}
|
||||
|
||||
describe('含子目录的文件夹改名 / 搬移(r188)', () => {
|
||||
let root: string;
|
||||
let lit: LiteratureService;
|
||||
let notes: NotesService;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await fs.mkdtemp(join(tmpdir(), 'rkms-dirmove-'));
|
||||
setRootPath(root);
|
||||
await fs.mkdir(join(root, 'notes'), { recursive: true });
|
||||
await fs.mkdir(join(root, 'literature'), { recursive: true });
|
||||
lit = new LiteratureService();
|
||||
notes = new NotesService();
|
||||
await lit.start({ skipInitialLoad: true });
|
||||
await notes.start({ skipInitialLoad: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await notes.stop();
|
||||
await lit.stop();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('notes:重命名含子目录的文件夹 → 成功,子树里每个笔记的 id 跟着重算', async () => {
|
||||
await fs.mkdir(join(root, 'notes', 'sub', 'deep'), { recursive: true });
|
||||
const top = await notes.create('n.md');
|
||||
await notes.moveFile(top.id, 'sub');
|
||||
const deep = await notes.create('n2.md');
|
||||
await notes.moveFile(deep.id, 'sub/deep');
|
||||
|
||||
const res = await notes.renameDir('sub', 'renamed');
|
||||
expect(res.relPath).toBe('renamed');
|
||||
|
||||
// 磁盘结构:整棵子树跟着走
|
||||
const entries = await fs.readdir(join(root, 'notes', 'renamed', 'deep'));
|
||||
expect(entries).toContain('n2.md');
|
||||
expect(await fs.stat(join(root, 'notes', 'renamed', 'n.md')).then(() => true)).toBe(true);
|
||||
// 旧位置没了
|
||||
expect(await fs.stat(join(root, 'notes', 'sub')).then(() => true, () => false)).toBe(false);
|
||||
|
||||
// 子树里两个笔记的 id 都重算了(路径绑定 id)
|
||||
const relPaths = notes.list().map((n) => n.relPath).sort();
|
||||
expect(relPaths).toEqual(['renamed/deep/n2.md', 'renamed/n.md']);
|
||||
const ids = notes.list().map((n) => n.id);
|
||||
expect(new Set(ids).size).toBe(2);
|
||||
expect(ids).not.toContain(top.id);
|
||||
expect(ids).not.toContain(deep.id);
|
||||
});
|
||||
|
||||
it('notes:跨目录搬移含子目录的文件夹 → 成功', async () => {
|
||||
await fs.mkdir(join(root, 'notes', 'sub', 'deep'), { recursive: true });
|
||||
await fs.mkdir(join(root, 'notes', 'arch'), { recursive: true });
|
||||
const n = await notes.create('n.md');
|
||||
await notes.moveFile(n.id, 'sub/deep');
|
||||
|
||||
const res = await notes.moveDir('sub', 'arch');
|
||||
expect(res.relPath).toBe('arch/sub');
|
||||
expect(
|
||||
await fs.stat(join(root, 'notes', 'arch', 'sub', 'deep', 'n.md')).then(() => true),
|
||||
).toBe(true);
|
||||
expect(notes.list().map((x) => x.relPath)).toEqual(['arch/sub/deep/n.md']);
|
||||
});
|
||||
|
||||
it('literature:重命名含子目录的文件夹 → 成功,子树里每个文献的 id 跟着重算', async () => {
|
||||
await fs.mkdir(join(root, 'literature', 'sub', 'deep'), { recursive: true });
|
||||
const src = join(root, 'paper.pdf');
|
||||
await fs.writeFile(src, 'PDF-BYTES', 'utf-8');
|
||||
const imported = await lit.importFile(src);
|
||||
const moved = await lit.moveFile(imported.id, 'sub/deep');
|
||||
|
||||
const res = await lit.renameDir('sub', 'renamed');
|
||||
expect(res.relPath).toBe('renamed');
|
||||
expect(res.idRemap).toHaveLength(1);
|
||||
expect(res.idRemap[0]!.oldId).toBe(moved.id);
|
||||
expect(res.idRemap[0]!.newId).not.toBe(moved.id);
|
||||
|
||||
const entries = await fs.readdir(join(root, 'literature', 'renamed', 'deep'));
|
||||
expect(entries).toContain('paper.pdf');
|
||||
expect(lit.list().map((r) => r.relPath)).toEqual(['renamed/deep/paper.pdf']);
|
||||
});
|
||||
|
||||
it('literature:跨目录搬移含子目录的文件夹 → 成功', async () => {
|
||||
await fs.mkdir(join(root, 'literature', 'sub', 'deep'), { recursive: true });
|
||||
await fs.mkdir(join(root, 'literature', 'arch'), { recursive: true });
|
||||
const src = join(root, 'paper.pdf');
|
||||
await fs.writeFile(src, 'PDF-BYTES', 'utf-8');
|
||||
const imported = await lit.importFile(src);
|
||||
await lit.moveFile(imported.id, 'sub/deep');
|
||||
|
||||
const res = await lit.moveDir('sub', 'arch');
|
||||
expect(res.relPath).toBe('arch/sub');
|
||||
expect(
|
||||
await fs.stat(join(root, 'literature', 'arch', 'sub', 'deep', 'paper.pdf')).then(
|
||||
() => true,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(lit.list().map((r) => r.relPath)).toEqual(['arch/sub/deep/paper.pdf']);
|
||||
});
|
||||
|
||||
it('搬完之后 watcher 仍然活着:新位置里新建的文件能被感知', async () => {
|
||||
await fs.mkdir(join(root, 'notes', 'sub', 'deep'), { recursive: true });
|
||||
const n = await notes.create('n.md');
|
||||
await notes.moveFile(n.id, 'sub/deep');
|
||||
|
||||
await notes.renameDir('sub', 'renamed');
|
||||
|
||||
// 修 EPERM 靠的是「关掉 watcher 再重建」—— 若重建不生效,监听就
|
||||
// 永久丢了:外部在**新位置**建文件,服务再也感知不到,而且**没有任何
|
||||
// 报错**(静默失聪比报错更难查)。
|
||||
await fs.writeFile(join(root, 'notes', 'renamed', 'deep', 'new.md'), '# new\n', 'utf-8');
|
||||
const seen = await waitFor(() =>
|
||||
notes.list().some((x) => x.relPath === 'renamed/deep/new.md'),
|
||||
);
|
||||
expect(seen, '重建后的 watcher 应仍能感知新位置的文件新增').toBe(true);
|
||||
});
|
||||
|
||||
it('扁平目录(无子目录)仍然能改名 —— 不回归', async () => {
|
||||
await fs.mkdir(join(root, 'notes', 'flat'), { recursive: true });
|
||||
const n = await notes.create('n.md');
|
||||
await notes.moveFile(n.id, 'flat');
|
||||
|
||||
const res = await notes.renameDir('flat', 'renamed');
|
||||
expect(res.relPath).toBe('renamed');
|
||||
expect(notes.list().map((x) => x.relPath)).toEqual(['renamed/n.md']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user