229 lines
8.5 KiB
TypeScript
229 lines
8.5 KiB
TypeScript
/**
|
||
* AriaEngine — OPFSBackend v2 测试
|
||
*
|
||
* 覆盖:
|
||
* 1. read/write/append/exists/delete/clear 基本语义
|
||
* 2. append 追加语义(keepExistingData + position)与字节顺序
|
||
* 3. 写队列串行化(并发写顺序一致)
|
||
* 4. 单次任务失败不中断队列链(后续写正常)
|
||
* 5. close 等待挂起写完成
|
||
* 6. open 清理崩溃残留临时文件(.crswap/.tmp)
|
||
* 7. writeMany/deleteMany 语义
|
||
*/
|
||
import { OPFSBackend } from '../../src/engine/aria/store/opfs_backend';
|
||
|
||
// ===================================================================
|
||
// 真实语义 OPFS mock:记录文件内容、支持 keepExistingData+position
|
||
// ===================================================================
|
||
interface MockFile {
|
||
content: ArrayBuffer;
|
||
writeCalls: { position?: number; data: ArrayBuffer; keepExistingData?: boolean }[];
|
||
}
|
||
|
||
function createOPFSMock() {
|
||
const files = new Map<string, MockFile>();
|
||
|
||
const getFileHandle = async (name: string, opts?: { create?: boolean }) => {
|
||
if (!files.has(name)) {
|
||
if (!opts?.create) throw new Error(`NotFoundError: ${name}`);
|
||
files.set(name, { content: new ArrayBuffer(0), writeCalls: [] });
|
||
}
|
||
const entry = files.get(name)!;
|
||
return {
|
||
getFile: async () => ({ size: entry.content.byteLength, arrayBuffer: async () => entry.content }),
|
||
createWritable: async (wOpts?: { keepExistingData?: boolean }) => {
|
||
const w: {
|
||
write: (arg: ArrayBuffer | { type: string; position: number; data: ArrayBuffer }) => Promise<void>;
|
||
close: () => Promise<void>;
|
||
} = {
|
||
write: async (arg) => {
|
||
const keepExisting = wOpts?.keepExistingData ?? false;
|
||
const isChunk = typeof arg !== 'object' || !('type' in (arg as object)) || (arg as { type?: string }).type === undefined
|
||
? { data: arg as ArrayBuffer, position: keepExisting ? entry.content.byteLength : 0 }
|
||
: { data: (arg as { data: ArrayBuffer }).data, position: (arg as { position: number }).position };
|
||
entry.writeCalls.push({ position: isChunk.position, data: isChunk.data, keepExistingData: keepExisting });
|
||
const merged = new Uint8Array(isChunk.position + isChunk.data.byteLength);
|
||
if (keepExisting || isChunk.position > 0) {
|
||
merged.set(new Uint8Array(entry.content.subarray ? entry.content : entry.content), 0);
|
||
}
|
||
merged.set(new Uint8Array(isChunk.data), isChunk.position);
|
||
entry.content = merged.buffer;
|
||
},
|
||
close: async () => { /* no-op */ },
|
||
};
|
||
return w;
|
||
},
|
||
};
|
||
};
|
||
|
||
const dir = {
|
||
getFileHandle,
|
||
entries: async function* () {
|
||
for (const [name] of files) yield [name];
|
||
},
|
||
removeEntry: async (name: string) => {
|
||
files.delete(name);
|
||
},
|
||
};
|
||
|
||
Object.defineProperty(globalThis, 'navigator', {
|
||
value: {
|
||
storage: {
|
||
getDirectory: async () => ({
|
||
getDirectoryHandle: async (_name: string, _opts?: unknown) => dir,
|
||
}),
|
||
},
|
||
},
|
||
configurable: true,
|
||
writable: true,
|
||
});
|
||
|
||
return { files, dir };
|
||
}
|
||
|
||
const enc = (s: string) => new Uint8Array(new TextEncoder().encode(s)).buffer;
|
||
|
||
describe('AriaEngine — OPFSBackend v2', () => {
|
||
beforeEach(() => {
|
||
createOPFSMock();
|
||
});
|
||
|
||
it('read/write/exists/delete 基本语义', async () => {
|
||
const backend = new OPFSBackend();
|
||
await backend.open('opfs-test-1');
|
||
expect(backend.isOpen()).toBe(true);
|
||
|
||
await backend.write('k1', enc('hello'));
|
||
expect(await backend.exists('k1')).toBe(true);
|
||
expect(new TextDecoder().decode(await backend.read('k1'))).toBe('hello');
|
||
expect(await backend.read('missing')).toBeNull();
|
||
|
||
await backend.delete('k1');
|
||
expect(await backend.exists('k1')).toBe(false);
|
||
await backend.close();
|
||
expect(backend.isOpen()).toBe(false);
|
||
});
|
||
|
||
it('append 追加语义:字节顺序与位置正确', async () => {
|
||
const backend = new OPFSBackend();
|
||
await backend.open('opfs-test-2');
|
||
await backend.append('wal', enc('AAA'));
|
||
await backend.append('wal', enc('BBB'));
|
||
await backend.append('wal', enc('CCC'));
|
||
const all = await backend.read('wal');
|
||
expect(new TextDecoder().decode(all)).toBe('AAABBBCCC');
|
||
await backend.close();
|
||
});
|
||
|
||
it('write 覆盖语义(COW 原子替换)', async () => {
|
||
const backend = new OPFSBackend();
|
||
await backend.open('opfs-test-3');
|
||
await backend.write('f', enc('OLD-CONTENT'));
|
||
await backend.write('f', enc('NEW'));
|
||
expect(new TextDecoder().decode(await backend.read('f'))).toBe('NEW');
|
||
await backend.close();
|
||
});
|
||
|
||
it('并发写串行化:写入顺序与调用顺序一致', async () => {
|
||
const backend = new OPFSBackend();
|
||
await backend.open('opfs-test-4');
|
||
// 同时发起多个写(不 await 中间结果)
|
||
const p1 = backend.append('log', enc('A'));
|
||
const p2 = backend.append('log', enc('B'));
|
||
const p3 = backend.append('log', enc('C'));
|
||
await Promise.all([p1, p2, p3]);
|
||
expect(new TextDecoder().decode(await backend.read('log'))).toBe('ABC');
|
||
await backend.close();
|
||
});
|
||
|
||
it('单次写失败不中断队列链,后续写正常', async () => {
|
||
const backend = new OPFSBackend();
|
||
await backend.open('opfs-test-5');
|
||
|
||
// 注入失败:让 write 抛错一次
|
||
let failNext = true;
|
||
const origWrite = backend.write.bind(backend);
|
||
const mockDir = (backend as any).dbDir;
|
||
const origGetFileHandle = mockDir.getFileHandle.bind(mockDir);
|
||
mockDir.getFileHandle = async (name: string, opts?: { create?: boolean }) => {
|
||
if (failNext && name === 'boom') {
|
||
failNext = false;
|
||
throw new Error('Injected write failure');
|
||
}
|
||
return origGetFileHandle(name, opts);
|
||
};
|
||
(backend as any).dbDir = mockDir;
|
||
|
||
await expect(origWrite('boom', enc('x'))).rejects.toThrow('Injected write failure');
|
||
// 队列链恢复:后续写成功
|
||
await backend.write('ok', enc('fine'));
|
||
expect(new TextDecoder().decode(await backend.read('ok'))).toBe('fine');
|
||
await backend.close();
|
||
});
|
||
|
||
it('close 等待挂起写完成(挂起写不丢失)', async () => {
|
||
const backend = new OPFSBackend();
|
||
await backend.open('opfs-test-6');
|
||
|
||
// 发起一个写但不 await,立即 close
|
||
const pending = backend.append('late', enc('LATE-DATA'));
|
||
await backend.close();
|
||
await pending; // close 已等队列 → 写已完成
|
||
expect(pending).resolves.toBeUndefined();
|
||
});
|
||
|
||
it('open 清理崩溃残留临时文件(.crswap/.tmp)', async () => {
|
||
const backend = new OPFSBackend();
|
||
await backend.open('opfs-test-7');
|
||
await backend.write('data', enc('real'));
|
||
// 手工制造残留
|
||
const mockDir = (backend as any).dbDir;
|
||
await mockDir.getFileHandle('data.crswap', { create: true });
|
||
await mockDir.getFileHandle('data.tmp', { create: true });
|
||
await backend.close();
|
||
|
||
// 重开:open 自动清理
|
||
const backend2 = new OPFSBackend();
|
||
await backend2.open('opfs-test-7');
|
||
const keys = await backend2.listKeys();
|
||
expect(keys).toEqual(['data']);
|
||
expect(keys.some((k) => k.endsWith('.crswap') || k.endsWith('.tmp'))).toBe(false);
|
||
// 正常数据不受影响
|
||
expect(new TextDecoder().decode(await backend2.read('data'))).toBe('real');
|
||
await backend2.close();
|
||
});
|
||
|
||
it('writeMany / deleteMany 语义', async () => {
|
||
const backend = new OPFSBackend();
|
||
await backend.open('opfs-test-8');
|
||
await backend.writeMany({ a: enc('AAA'), b: enc('BBB') });
|
||
expect(new TextDecoder().decode(await backend.read('a'))).toBe('AAA');
|
||
expect(new TextDecoder().decode(await backend.read('b'))).toBe('BBB');
|
||
await backend.deleteMany(['a']);
|
||
expect(await backend.exists('a')).toBe(false);
|
||
expect(await backend.exists('b')).toBe(true);
|
||
await backend.clear();
|
||
expect(await backend.listKeys()).toEqual([]);
|
||
await backend.close();
|
||
});
|
||
|
||
it('close 后所有操作安全返回(dbDir 为 null 不抛错)', async () => {
|
||
const backend = new OPFSBackend();
|
||
await backend.open('opfs-test-9');
|
||
await backend.write('k', enc('v'));
|
||
await backend.close();
|
||
|
||
expect(backend.isOpen()).toBe(false);
|
||
expect(await backend.read('k')).toBeNull();
|
||
await backend.write('late', enc('x')); // 静默忽略
|
||
await backend.append('late2', enc('x'));
|
||
await backend.writeMany({ a: enc('1') });
|
||
await backend.delete('k');
|
||
await backend.deleteMany(['a']);
|
||
expect(await backend.listKeys()).toEqual([]);
|
||
expect(await backend.exists('k')).toBe(false);
|
||
await backend.clear();
|
||
await backend.cleanupStaleFiles();
|
||
});
|
||
});
|