/** * 测试共享 — OPFS 事务性文件存储(v0.8.0 根治版) * * ============================================================================ * 为什么需要重写(审计结论,见 PLAN-v0.7.5.md 工作流 C-1) * ============================================================================ * 旧 mock(tests/helpers/opfs-mock.ts)有三处与真实 OPFS 语义不符,会掩盖真实缺陷: * 1. `read()` 返回内部 ArrayBuffer **引用**(真实 OPFS 返回快照副本) * → 调用方原地修改会"污染磁盘",而真实环境不会; * 2. `write()` 中 `if (keepExisting || position > 0)` 会在 keepExistingData:false * 时也保留旧字节(真实 OPFS 该场景应截断); * 3. `close()` 是空函数、写入**立即对读可见**(真实 OPFS 是写 swap 文件、 * close 时才原子替换)。因此"提交前可见"这类缺陷在旧 mock 下永远测不出来。 * * 更关键的是:**没有任何 mock 能表达"半写/撕裂/丢失一次写/部分删除失败"**, * 而项目所有"崩溃恢复"测试用的都是 `backend.close()`(优雅停机,会把写队列刷完), * 于是崩溃相关的声称在结构上无法被验证。 * * ============================================================================ * 本实现的两层设计 * ============================================================================ * 第 1 层 `TransactionalFileStore`: * - 纯数据 + 字节级故障注入 + 崩溃模拟(commit/discard),**不依赖任何浏览器 API**。 * 可直接用于单元测试:`store.write('k', buf); store.crashPending(); store.commitAll();` * - 忠实实现 OPFS 的写语义:createWritable 后写入进入 pending 区,close 时原子提交; * crash 丢弃全部 pending,已提交内容保持崩溃前状态。 * * 第 2 层 `installOPFSMock()`: * - 把 store 包装成 `navigator.storage.getDirectory()` 的 OPFS 门面,供 OPFSBackend 使用。 * - 读返回**副本**(不再泄漏内部引用)、keepExistingData:false 时截断、 * close 前不可见(与真实 OPFS 一致)。 * * 故障注入 API(供故障矩阵测试使用): * - `failNextWrite(n)` / `failNextAppend(n)` / `failNextDelete(n)` * - `truncateNextAppendTo(n)` —— 追加只写入前 n 字节(撕裂写) * - `crashPending()` —— 丢弃全部未提交写入(模拟进程崩溃) * - `commitAll()` —— 提交全部 pending(模拟 createWritable.close 全部完成) */ import type { IStorageBackend } from '../../src/engine/aria/store/backend'; // --------------------------------------------------------------------------- // 第 1 层:事务性文件存储(无浏览器依赖,可直接单测) // --------------------------------------------------------------------------- export interface TransactionalFileStoreOptions { /** 库名(仅用于日志/调试) */ dbName?: string; } /** 一次写入的故障注入配置 */ interface FaultConfig { failWrite: number; failAppend: number; failDelete: number; /** 追加写只落盘前 N 字节(撕裂写);-1 表示不启用 */ truncateAppendTo: number; } export class TransactionalFileStore { /** 已提交内容(= 真实"磁盘"状态) */ private committed = new Map(); /** 未提交写入(= OPFS createWritable 的 swap 区) */ private pending = new Map(); private readonly dbName: string; private faults: FaultConfig = { failWrite: 0, failAppend: 0, failDelete: 0, truncateAppendTo: -1 }; /** 统计(供断言"到底发生了几次 IO") */ readonly stats = { writes: 0, appends: 0, deletes: 0, reads: 0, commits: 0, crashes: 0 }; constructor(options: TransactionalFileStoreOptions = {}) { this.dbName = options.dbName ?? 'mock'; } get name(): string { return this.dbName; } // ---- 故障注入 ---- /** 接下来 n 次 write() 抛错 */ failNextWrite(n = 1): void { this.faults.failWrite = n; } /** 接下来 n 次 append() 抛错 */ failNextAppend(n = 1): void { this.faults.failAppend = n; } /** 接下来 n 次 delete() 抛错(模拟 OPFS removeEntry 失败) */ failNextDelete(n = 1): void { this.faults.failDelete = n; } /** 下一次 append() 只落盘前 n 字节(撕裂写:模拟写入中途断电) */ truncateNextAppendTo(n: number): void { this.faults.truncateAppendTo = n; } /** 清空全部故障注入 */ clearFaults(): void { this.faults = { failWrite: 0, failAppend: 0, failDelete: 0, truncateAppendTo: -1 }; } // ---- 崩溃 / 提交 ---- /** * 模拟进程崩溃:丢弃**全部**未提交写入。 * 已提交内容保持崩溃前状态(这正是 OPFS copy-on-write 的保证)。 */ crashPending(): void { this.pending.clear(); this.stats.crashes++; } /** 模拟所有未完成的 createWritable 正常 close:把 pending 原子提交 */ commitAll(): void { for (const [key, value] of this.pending) { if (value === null) this.committed.delete(key); else this.committed.set(key, value); } this.pending.clear(); this.stats.commits++; } /** 是否存在未提交写入(测试可断言"崩溃窗口"是否真的存在) */ hasPending(): boolean { return this.pending.size > 0; } // ---- 数据操作(与 IStorageBackend 语义对齐,供 FaultyBackend 复用) ---- async read(key: string): Promise { this.stats.reads++; // 读取只看已提交内容 —— 未 close 的写入对读不可见(真实 OPFS 语义) const buf = this.committed.get(key); if (!buf) return null; return copyBuffer(buf); // 返回副本,杜绝调用方原地修改污染"磁盘" } async write(key: string, data: ArrayBuffer): Promise { if (this.faults.failWrite > 0) { this.faults.failWrite--; throw new Error(`[OPFS-mock] injected write failure on "${key}"`); } this.stats.writes++; this.pending.set(key, copyBuffer(data)); } async append(key: string, data: ArrayBuffer): Promise { if (this.faults.failAppend > 0) { this.faults.failAppend--; throw new Error(`[OPFS-mock] injected append failure on "${key}"`); } this.stats.appends++; let chunk = copyBuffer(data); if (this.faults.truncateAppendTo >= 0) { // 撕裂写:只落盘前 n 字节 chunk = copyBufferHead(chunk, this.faults.truncateAppendTo); this.faults.truncateAppendTo = -1; } // 追加目标是"已提交内容 + pending 中同 key 的写入"(同一 swap 文件内的续写) const base = this.pending.has(key) ? (this.pending.get(key) as ArrayBuffer | null) : this.committed.get(key) ?? null; if (!base) { this.pending.set(key, chunk); return; } const merged = new Uint8Array(base.byteLength + chunk.byteLength); merged.set(new Uint8Array(base), 0); merged.set(new Uint8Array(chunk), base.byteLength); this.pending.set(key, merged.buffer as ArrayBuffer); } /** 覆盖写(keepExistingData: false,真实 OPFS 会截断) */ async overwrite(key: string, data: ArrayBuffer): Promise { await this.write(key, data); } async delete(key: string): Promise { if (this.faults.failDelete > 0) { this.faults.failDelete--; throw new Error(`[OPFS-mock] injected delete failure on "${key}"`); } this.stats.deletes++; this.pending.set(key, null); // 删除也在提交时才生效 } async listKeys(): Promise { const keys = new Set(this.committed.keys()); for (const [key, value] of this.pending) { if (value === null) keys.delete(key); else keys.add(key); } return [...keys]; } async has(key: string): Promise { if (this.pending.has(key)) return this.pending.get(key) !== null; return this.committed.has(key); } /** 直接查看已提交字节数(不经过 read 的副本语义,供结构断言用) */ committedSize(key: string): number { return this.committed.get(key)?.byteLength ?? 0; } /** 已提交内容的只读快照(供"篡改磁盘"类测试:写坏字节后重开观察恢复) */ snapshotCommitted(): Map { const out = new Map(); for (const [k, v] of this.committed) out.set(k, copyBuffer(v)); return out; } /** 用快照替换已提交内容(模拟外部损坏/离线篡改) */ restoreCommitted(snapshot: Map): void { this.committed = new Map(); for (const [k, v] of snapshot) this.committed.set(k, copyBuffer(v)); this.pending.clear(); } /** 就地篡改已提交字节(模拟 bit flip / 部分覆盖),不做副本 */ corruptCommitted(key: string, mutate: (bytes: Uint8Array) => void): boolean { const buf = this.committed.get(key); if (!buf) return false; mutate(new Uint8Array(buf)); return true; } clear(): void { this.committed.clear(); this.pending.clear(); } } /** * 复制 ArrayBuffer(杜绝引用泄漏 —— 旧 mock 的缺陷 1) * * 注意:不能用 `buf.slice(0)` —— 在 jsdom 环境下 `ArrayBuffer.prototype.slice` 可能被 * Blob/File 的 slice 语义遮蔽,导致 `slice(0, n)` 的参数被忽略、返回整段内容。 * 这里用 Uint8Array 显式复制,行为在任何环境都确定。 */ function copyBuffer(buf: ArrayBuffer): ArrayBuffer { const out = new Uint8Array(buf.byteLength); out.set(new Uint8Array(buf)); return out.buffer as ArrayBuffer; } /** 复制前 n 字节(撕裂写用;同上不使用 ArrayBuffer.slice) */ function copyBufferHead(buf: ArrayBuffer, n: number): ArrayBuffer { const len = Math.max(0, Math.min(n, buf.byteLength)); const out = new Uint8Array(len); out.set(new Uint8Array(buf, 0, len)); return out.buffer as ArrayBuffer; } /** * 结构判别:是否为 `createWritable().write({type,position,data})` 形式。 * 不用 `instanceof ArrayBuffer` —— 跨 realm 时会误判(见 write 内注释)。 */ function isPositionedWrite( arg: ArrayBuffer | { type: string; position: number; data: ArrayBuffer }, ): arg is { type: string; position: number; data: ArrayBuffer } { const anyArg = arg as { type?: unknown; position?: unknown; data?: unknown }; return typeof anyArg?.type === 'string' && typeof anyArg?.position === 'number' && anyArg?.data != null; } // --------------------------------------------------------------------------- // 第 2 层:OPFS 门面(供 OPFSBackend 使用) // --------------------------------------------------------------------------- /** 全局注册表:库名 → store(跨 backend 实例共享 = 持久化语义) */ const registry = new Map(); /** 取(或创建)某库的共享 store */ export function getStore(dbName: string): TransactionalFileStore { let store = registry.get(dbName); if (!store) { store = new TransactionalFileStore({ dbName }); registry.set(dbName, store); } return store; } /** 清空全局注册表(测试隔离) */ export function clearRegistry(): void { registry.clear(); } /** * 每次调用都重置的 OPFS 安装(推荐在 `beforeEach` 中使用)。 * * 等价于 `clearRegistry(); installOPFSMock(dbName);`。 * 命名里强调 reset 是为了避免旧写法的误导:此前测试写 `installOPFSMock(new Map())`, * 传入的 Map 会被 mock **静默忽略**(各测试文件各自传 Map 并不能隔离共享状态)。 */ export function resetOPFSMock(dbName = 'mock'): InstalledOPFSMock { clearRegistry(); return installOPFSMock(dbName); } export interface InstalledOPFSMock { store: TransactionalFileStore; /** 当前 mock 看到的所有文件名 */ listKeys(): Promise; /** * 直接创建(或覆盖)一个文件 —— 用于制造"崩溃残留临时文件"等场景, * 替代旧 API 暴露内部 dir/files 的做法。 */ createFile(name: string, content?: ArrayBuffer): Promise; /** 文件是否存在 */ hasFile(name: string): Promise; /** 读取文件内容(返回副本) */ readFile(name: string): Promise; /** 删除文件 */ removeFile(name: string): Promise; } /** * 安装 OPFS mock 到 globalThis.navigator.storage.getDirectory()。 * * @param dbName 库名(决定共享 store;同名多次安装拿到同一个 store) */ export function installOPFSMock(dbName = 'mock'): InstalledOPFSMock { const store = getStore(dbName); const makeFileHandle = async (name: string, opts?: { create?: boolean }) => { if (!(await store.has(name)) && !opts?.create) { throw new Error(`NotFoundError: ${name}`); } return { /** * 真实 OPFS 的 getFile() 返回 File,读的是**已提交**内容(size 与 arrayBuffer 一致)。 * append 路径会读 `existing.size` 来定位追加位置,因此这里保持二者同源。 */ getFile: async () => { const content = (await store.read(name)) ?? new ArrayBuffer(0); return { size: content.byteLength, arrayBuffer: async () => content, }; }, createWritable: async (wOpts?: { keepExistingData?: boolean }) => { const keepExisting = wOpts?.keepExistingData ?? false; // OPFS 写语义:keepExistingData:false 时从空缓冲开始(旧 mock 会保留旧字节) let buffer = keepExisting ? (await store.read(name)) ?? new ArrayBuffer(0) : new ArrayBuffer(0); return { write: async (arg: ArrayBuffer | { type: string; position: number; data: ArrayBuffer }) => { // 注意:不能用 `arg instanceof ArrayBuffer` 判别 —— 跨 realm / 跨 Buffer 实现时 // 会失效(jsdom 与 Node 的 ArrayBuffer 可能不是同一个构造函数), // 从而把 ArrayBuffer 误当成 {position,data} 分支。改用结构判别。 if (!isPositionedWrite(arg)) { buffer = copyBuffer(arg as ArrayBuffer); return; } const chunk = arg; const end = chunk.position + chunk.data.byteLength; const merged = new Uint8Array(Math.max(end, buffer.byteLength)); merged.set(new Uint8Array(buffer), 0); merged.set(new Uint8Array(chunk.data), chunk.position); buffer = merged.buffer as ArrayBuffer; }, close: async () => { // 仅在 close 时提交 —— 未 close 的写入对读不可见(旧 mock 会立即可见) await store.write(name, buffer); store.commitAll(); }, }; }, }; }; const dir = { getFileHandle: makeFileHandle, entries: async function* () { for (const key of await store.listKeys()) yield [key]; }, removeEntry: async (name: string) => { await store.delete(name); store.commitAll(); }, }; Object.defineProperty(globalThis, 'navigator', { value: { storage: { getDirectory: async () => ({ getDirectoryHandle: async (_name: string, _opts?: unknown) => dir, }), }, }, configurable: true, writable: true, }); return { store, listKeys: () => store.listKeys(), createFile: async (name, content) => { await store.write(name, content ?? new ArrayBuffer(0)); store.commitAll(); }, hasFile: (name) => store.has(name), readFile: (name) => store.read(name), removeFile: async (name) => { await store.delete(name); store.commitAll(); }, }; } /** * 直接由 TransactionalFileStore 驱动的可崩溃后端。 * * 用途:验证"真崩溃"(丢弃未提交写入)而非"优雅停机"(close 刷完队列)。 * OPFSBackend 之上无法表达 pending 语义(它每次 write 都会 close 提交), * 因此需要这一层直连介质的后端来构造"写入进行中崩溃"的窗口。 */ export class CrashableStoreBackend implements IStorageBackend { private readonly store: TransactionalFileStore; constructor(store: TransactionalFileStore) { this.store = store; } open(_name: string): Promise { return Promise.resolve(); } close(): Promise { return Promise.resolve(); } isOpen(): boolean { return true; } read(key: string) { return this.store.read(key); } write(key: string, data: ArrayBuffer) { return this.store.write(key, data); } append(key: string, data: ArrayBuffer) { return this.store.append(key, data); } writeMany(entries: Record) { return (async () => { for (const [k, v] of Object.entries(entries)) await this.store.write(k, v); })(); } delete(key: string) { return this.store.delete(key); } deleteMany(keys: string[]) { return (async () => { for (const k of keys) await this.store.delete(k); })(); } listKeys() { return this.store.listKeys(); } exists(key: string) { return this.store.has(key); } clear(): Promise { this.store.clear(); return Promise.resolve(); } /** 崩溃:丢弃全部未提交写入 */ simulateCrash(): void { this.store.crashPending(); } /** 正常提交(模拟所有 createWritable 完成 close) */ commit(): void { this.store.commitAll(); } }