test(P0): v0.8.0 验证基座与工程门禁根治
工作流 C-1 / C-3 前半 + 测试代码类型检查。 【故障注入基座】新增 tests/helpers/storage-harness.ts + faulty-backend.ts - TransactionalFileStore:忠实 OPFS 提交语义(close 才可见)+ 字节级故障注入 (failNextWrite/Append/Delete、truncateAppendTo 撕裂写、crashPending 真崩溃) - 删除旧 opfs-mock:读返回内部引用、keepExistingData:false 不截断、close 空实现 导致"提交前可见"等真实缺陷无法被测出(31 个测试文件迁移至新 harness) - 删除 aria-opfs-backend 内的第三份重复 mock(含从未被断言使用的 writeCalls 死代码 与 entry.content.subarray 恒等分支) - FaultyBackend:包装任意 IStorageBackend 注入故障;crash() 明确区别于 close() (后者是优雅停机,会刷完写队列 —— 这正是此前所有"崩溃恢复"测试的真相) - 16 条基座自测证明注入真的生效(含 close 不能当崩溃的对照组) 【覆盖率口径】jest.config.cjs - 移除 '!src/**/index.ts'(该 glob 把 AriaEngine 主实现等 15 个实现文件整体 排除出统计,与 v0.2.6 曾承认过的问题同源),改为只排除纯类型声明文件并附理由 - 新增 coverageThreshold 门禁(此前完全不存在) - 真实基线:语句 90.66% / 分支 82.94% / 函数 94.36% / 行 93.43% - 修正 testMatch 使 tests/helpers 下的测试可被发现 【测试代码类型检查】tsconfig.test.json + npm run typecheck:tests - 修复 103 个测试代码类型错误(此前 babel 剥离类型 + tsconfig 排除 tests,全部隐藏) - 新增 tests/helpers/assertions.ts:nonNull/decode/rows/object/engineMethod/expectCode 以断言收窄替代 as any - 消除 21 个 lint warning(含 v043-hardening 中定义后从未调用的 mockOPFS 死代码) - parser.test.ts 12 处 toBeDefined() 空断言升级为结构断言(并新增 AND/OR 优先级用例, 当前红灯,对应总账第 11 项,将在工作流 A 修复) 【版本契约】新增 tests/version-contract.test.ts - 校验 src VERSION / package.json / dist 三者一致,替代两处硬编码版本字面量 【CI 门禁】.gitea/workflows/ci.yml - lint 去掉 continue-on-error(此前永远不让 CI 变红) - 新增 tests 类型检查、--coverage 覆盖率门禁、dist 与源码同步校验 - 版本 0.7.4 升至 0.8.0
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 测试共享 — 类型化断言辅助(v0.8.0)
|
||||
*
|
||||
* 背景:v0.8.0 起 tests/ 纳入类型检查(tsconfig.test.json)。测试里最常见的两类
|
||||
* 类型错误是:
|
||||
* 1. `backend.read(key)` 返回 `ArrayBuffer | null` → 直接喂给 TextDecoder 报错;
|
||||
* 2. `db.query()` 返回 `unknown` → 取 `.length` / `[0].field` 报错。
|
||||
* 旧做法是 `as any`,那等于把类型检查关掉;这里提供**断言 + 收窄**的辅助函数:
|
||||
* 断言失败会明确报出"期望非空",而不是让后续断言落空。
|
||||
*
|
||||
* 用法:
|
||||
* expect(decode(await backend.read('k'))).toBe('v');
|
||||
* const rows = rows<{ id: string }>(await db.query('SELECT * FROM t'));
|
||||
*/
|
||||
|
||||
/** 断言非空并收窄(失败信息指向调用点,便于定位) */
|
||||
export function nonNull<T>(value: T | null | undefined, what = 'value'): T {
|
||||
if (value === null || value === undefined) {
|
||||
throw new Error(`expected ${what} to be non-null, got ${String(value)}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 解码字节为字符串(自动处理 null → 明确失败) */
|
||||
export function decode(value: ArrayBuffer | Uint8Array | null | undefined, what = 'buffer'): string {
|
||||
if (value === null || value === undefined) {
|
||||
throw new Error(`expected ${what} to be non-null for decoding`);
|
||||
}
|
||||
return new TextDecoder().decode(value instanceof Uint8Array ? value : new Uint8Array(value));
|
||||
}
|
||||
|
||||
/** 把 query/executor 的 unknown 结果收窄为行数组 */
|
||||
export function rows<T = Record<string, unknown>>(value: unknown): T[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(`expected query result to be an array, got ${typeof value}`);
|
||||
}
|
||||
return value as T[];
|
||||
}
|
||||
|
||||
/** 把 query 结果收窄为单行(断言至少一行并返回首行) */
|
||||
export function firstRow<T = Record<string, unknown>>(value: unknown): T {
|
||||
const list = rows<T>(value);
|
||||
if (list.length === 0) throw new Error('expected at least one row, got empty result');
|
||||
return list[0];
|
||||
}
|
||||
|
||||
/** 把 query 结果收窄为对象(EXPLAIN 等返回单对象的场景) */
|
||||
export function object<T = Record<string, unknown>>(value: unknown): T {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new Error(`expected an object result, got ${Array.isArray(value) ? 'array' : typeof value}`);
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
/** 断言并收窄错误码(替代 `catch { threw = true }` 这种无法区分原因的写法) */
|
||||
export async function expectCode(promise: Promise<unknown>, code: string): Promise<void> {
|
||||
try {
|
||||
await promise;
|
||||
} catch (error) {
|
||||
const actual = (error as { code?: string }).code;
|
||||
if (actual !== code) {
|
||||
throw new Error(`expected error code "${code}", got "${actual ?? '(none)'}": ${(error as Error).message}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw new Error(`expected rejection with code "${code}", but the promise resolved`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取用引擎上的**可选**方法(如 clearAll / backup / repair / createIndex)。
|
||||
*
|
||||
* `IStorageEngine` 把这些方法声明为可选,直接 `engine.clearAll()` 在类型上不成立。
|
||||
* 测试里需要它们时用本函数:缺失即明确失败,而不是运行时 TypeError。
|
||||
*/
|
||||
export function engineMethod<M>(engine: unknown, name: string): M {
|
||||
const fn = (engine as Record<string, unknown> | null)?.[name];
|
||||
if (typeof fn !== 'function') {
|
||||
throw new Error(`engine is missing required test method "${name}"`);
|
||||
}
|
||||
return (fn as (...a: unknown[]) => unknown).bind(engine) as M;
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* 测试共享 — 故障注入后端包装器(v0.8.0 工作流 C-1)
|
||||
*
|
||||
* ============================================================================
|
||||
* 为什么需要它(PLAN-v0.7.5.md 根因 5)
|
||||
* ============================================================================
|
||||
* 项目所有"崩溃恢复"测试用的都是 `await engine.backend.close()`,而 OPFSBackend.close()
|
||||
* 会 `await this.writeQueue` 把在途写**全部刷完** —— 那是优雅停机,不是崩溃。
|
||||
* 加上 mock 永远原子、绝不撕裂,崩溃相关的声称在结构上无法被验证。
|
||||
*
|
||||
* 本包装器让任意 IStorageBackend 具备可编程故障:
|
||||
* - failNextWrite(n) / failNextAppend(n) / failNextDelete(n) 抛错注入
|
||||
* - truncateNextAppendTo(n) 撕裂写(只落前 n 字节)
|
||||
* - dropNextAppend(n) / dropNextWrite(n) 静默丢弃(模拟掉电丢失)
|
||||
* - corruptNextWrite(mutator) 写入落盘后篡改字节
|
||||
* - crash() 丢弃未提交写(委托介质)
|
||||
*
|
||||
* 用法:
|
||||
* const faulty = new FaultyBackend(new OPFSBackend());
|
||||
* await faulty.open('db');
|
||||
* faulty.failNextAppend(); // 下一次追加失败
|
||||
* ... 触发写入 ...
|
||||
* await expect(...).rejects.toThrow();
|
||||
* faulty.clearFaults();
|
||||
*/
|
||||
|
||||
import type { IStorageBackend } from '../../src/engine/aria/store/backend';
|
||||
|
||||
type Mutator = (bytes: Uint8Array) => void;
|
||||
|
||||
interface Faults {
|
||||
failWrite: number;
|
||||
failAppend: number;
|
||||
failDelete: number;
|
||||
dropWrite: number;
|
||||
dropAppend: number;
|
||||
truncateAppendTo: number;
|
||||
corruptWrite: Mutator | null;
|
||||
}
|
||||
|
||||
/** 支持崩溃模拟的介质(OPFS mock / TransactionalFileStore 等) */
|
||||
export interface CrashableMedium {
|
||||
crashPending(): void;
|
||||
hasPending(): boolean;
|
||||
}
|
||||
|
||||
/** 支持崩溃模拟的后端(OPFSBackend 等在 v0.8.0 提供了 simulateCrash) */
|
||||
export interface CrashableBackend {
|
||||
simulateCrash(): void;
|
||||
}
|
||||
|
||||
function isCrashableMedium(m: unknown): m is CrashableMedium {
|
||||
return typeof (m as CrashableMedium)?.crashPending === 'function';
|
||||
}
|
||||
|
||||
function isCrashableBackend(m: unknown): m is CrashableBackend {
|
||||
return typeof (m as CrashableBackend)?.simulateCrash === 'function';
|
||||
}
|
||||
|
||||
export class FaultyBackend implements IStorageBackend {
|
||||
private readonly inner: IStorageBackend;
|
||||
private faults: Faults = {
|
||||
failWrite: 0,
|
||||
failAppend: 0,
|
||||
failDelete: 0,
|
||||
dropWrite: 0,
|
||||
dropAppend: 0,
|
||||
truncateAppendTo: -1,
|
||||
corruptWrite: null,
|
||||
};
|
||||
|
||||
/** 注入统计(测试可断言注入真的生效了,避免"注入了但没走到"的假绿) */
|
||||
readonly injected = { write: 0, append: 0, delete: 0, dropped: 0, truncated: 0, corrupted: 0 };
|
||||
|
||||
constructor(inner: IStorageBackend) {
|
||||
this.inner = inner;
|
||||
}
|
||||
|
||||
/** 暴露内层(需要访问具体 backing store 时使用,例如 crash()) */
|
||||
unwrap<T extends IStorageBackend>(): T {
|
||||
return this.inner as T;
|
||||
}
|
||||
|
||||
// ---- 故障注入 API ----
|
||||
|
||||
failNextWrite(n = 1): void { this.faults.failWrite = n; }
|
||||
failNextAppend(n = 1): void { this.faults.failAppend = n; }
|
||||
failNextDelete(n = 1): void { this.faults.failDelete = n; }
|
||||
/** 静默丢弃接下来 n 次 write(不抛错、"看起来成功",模拟掉电丢失) */
|
||||
dropNextWrite(n = 1): void { this.faults.dropWrite = n; }
|
||||
/** 静默丢弃接下来 n 次 append */
|
||||
dropNextAppend(n = 1): void { this.faults.dropAppend = n; }
|
||||
/** 下一次 append 只落前 n 字节(撕裂写) */
|
||||
truncateNextAppendTo(n: number): void { this.faults.truncateAppendTo = n; }
|
||||
/** 下一次写入的字节落盘后被就地篡改(模拟 bit flip) */
|
||||
corruptNextWrite(mutator: Mutator): void { this.faults.corruptWrite = mutator; }
|
||||
|
||||
clearFaults(): void {
|
||||
this.faults = {
|
||||
failWrite: 0, failAppend: 0, failDelete: 0,
|
||||
dropWrite: 0, dropAppend: 0, truncateAppendTo: -1, corruptWrite: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟崩溃:优先让内层后端自己处理(OPFSBackend.simulateCrash 会丢弃未提交的 swap 写入),
|
||||
* 否则若内层介质本身可崩溃(TransactionalFileStore 等)则直接委托。
|
||||
*
|
||||
* 注意:**不要**用 inner.close() 代替崩溃 —— 那会把写队列刷完(优雅停机)。
|
||||
* 返回 false 表示"该后端无法模拟崩溃",调用方应据此改用丢写注入表达崩溃。
|
||||
*/
|
||||
crash(): boolean {
|
||||
if (isCrashableBackend(this.inner)) {
|
||||
this.inner.simulateCrash();
|
||||
return true;
|
||||
}
|
||||
if (isCrashableMedium(this.inner)) {
|
||||
this.inner.crashPending();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---- IStorageBackend 委托 ----
|
||||
|
||||
open(name: string): Promise<void> {
|
||||
return this.inner.open(name);
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
return this.inner.close();
|
||||
}
|
||||
|
||||
isOpen(): boolean {
|
||||
return this.inner.isOpen();
|
||||
}
|
||||
|
||||
read(key: string): Promise<ArrayBuffer | null> {
|
||||
return this.inner.read(key);
|
||||
}
|
||||
|
||||
async write(key: string, data: ArrayBuffer): Promise<void> {
|
||||
if (this.faults.dropWrite > 0) {
|
||||
this.faults.dropWrite--;
|
||||
this.injected.dropped++;
|
||||
return; // 静默成功但没落盘
|
||||
}
|
||||
if (this.faults.failWrite > 0) {
|
||||
this.faults.failWrite--;
|
||||
this.injected.write++;
|
||||
throw new Error(`[FaultyBackend] injected write failure: ${key}`);
|
||||
}
|
||||
await this.inner.write(key, data);
|
||||
if (this.faults.corruptWrite) {
|
||||
const mutator = this.faults.corruptWrite;
|
||||
this.faults.corruptWrite = null;
|
||||
const stored = await this.inner.read(key);
|
||||
if (stored) {
|
||||
mutator(new Uint8Array(stored));
|
||||
await this.inner.write(key, stored);
|
||||
this.injected.corrupted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async append(key: string, data: ArrayBuffer): Promise<void> {
|
||||
if (this.faults.dropAppend > 0) {
|
||||
this.faults.dropAppend--;
|
||||
this.injected.dropped++;
|
||||
return;
|
||||
}
|
||||
if (this.faults.failAppend > 0) {
|
||||
this.faults.failAppend--;
|
||||
this.injected.append++;
|
||||
throw new Error(`[FaultyBackend] injected append failure: ${key}`);
|
||||
}
|
||||
if (this.faults.truncateAppendTo >= 0) {
|
||||
const n = this.faults.truncateAppendTo;
|
||||
this.faults.truncateAppendTo = -1;
|
||||
this.injected.truncated++;
|
||||
// 撕裂写:只把前 n 字节交给介质
|
||||
// (不用 ArrayBuffer.slice —— jsdom 下可能被 Blob.slice 语义遮蔽,见 storage-harness 注释)
|
||||
const len = Math.max(0, Math.min(n, data.byteLength));
|
||||
const torn = new Uint8Array(len);
|
||||
torn.set(new Uint8Array(data, 0, len));
|
||||
const tornBuf = torn.buffer as ArrayBuffer;
|
||||
if (typeof this.inner.append === 'function') {
|
||||
await this.inner.append(key, tornBuf);
|
||||
} else {
|
||||
const existing = await this.inner.read(key);
|
||||
const merged = new Uint8Array((existing?.byteLength ?? 0) + tornBuf.byteLength);
|
||||
if (existing) merged.set(new Uint8Array(existing), 0);
|
||||
merged.set(new Uint8Array(tornBuf), existing?.byteLength ?? 0);
|
||||
await this.inner.write(key, merged.buffer as ArrayBuffer);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof this.inner.append === 'function') {
|
||||
await this.inner.append(key, data);
|
||||
} else {
|
||||
const existing = await this.inner.read(key);
|
||||
const merged = new Uint8Array((existing?.byteLength ?? 0) + data.byteLength);
|
||||
if (existing) merged.set(new Uint8Array(existing), 0);
|
||||
merged.set(new Uint8Array(data), existing?.byteLength ?? 0);
|
||||
await this.inner.write(key, merged.buffer as ArrayBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
async writeMany(entries: Record<string, ArrayBuffer>): Promise<void> {
|
||||
// 逐 key 走出本包装的 write,使注入对批量写同样生效
|
||||
for (const [key, data] of Object.entries(entries)) {
|
||||
await this.write(key, data);
|
||||
}
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
if (this.faults.failDelete > 0) {
|
||||
this.faults.failDelete--;
|
||||
this.injected.delete++;
|
||||
throw new Error(`[FaultyBackend] injected delete failure: ${key}`);
|
||||
}
|
||||
await this.inner.delete(key);
|
||||
}
|
||||
|
||||
async deleteMany(keys: string[]): Promise<void> {
|
||||
for (const key of keys) {
|
||||
await this.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
listKeys(): Promise<string[]> {
|
||||
return this.inner.listKeys();
|
||||
}
|
||||
|
||||
exists(key: string): Promise<boolean> {
|
||||
return this.inner.exists(key);
|
||||
}
|
||||
|
||||
clear(): Promise<void> {
|
||||
return this.inner.clear();
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
/**
|
||||
* 测试共享 — 真实语义 OPFS mock
|
||||
*
|
||||
* 模拟 OPFS 的完整语义:
|
||||
* - createWritable({ keepExistingData }) + position 追加写
|
||||
* - 文件内容以 ArrayBuffer 存储(跨 backend 实例共享于同一 Map)
|
||||
* - entries() 迭代、removeEntry 删除
|
||||
*
|
||||
* 用法:
|
||||
* const files = installOPFSMock(new Map());
|
||||
* const backend = new OPFSBackend();
|
||||
* await backend.open('any');
|
||||
*/
|
||||
|
||||
export interface MockOpfsFile {
|
||||
content: ArrayBuffer;
|
||||
}
|
||||
|
||||
export function installOPFSMock(files: Map<string, MockOpfsFile>): {
|
||||
files: Map<string, MockOpfsFile>;
|
||||
dir: {
|
||||
getFileHandle: (name: string, opts?: { create?: boolean }) => Promise<{
|
||||
getFile: () => Promise<{ size: number; arrayBuffer: () => Promise<ArrayBuffer> }>;
|
||||
createWritable: (wOpts?: { keepExistingData?: boolean }) => Promise<{
|
||||
write: (arg: ArrayBuffer | { type: string; position: number; data: ArrayBuffer }) => Promise<void>;
|
||||
close: () => Promise<void>;
|
||||
}>;
|
||||
}>;
|
||||
entries: () => AsyncGenerator<[string]>;
|
||||
removeEntry: (name: string) => Promise<void>;
|
||||
};
|
||||
} {
|
||||
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) });
|
||||
}
|
||||
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 chunk = (arg as { type?: string }).type === 'write'
|
||||
? { data: (arg as { data: ArrayBuffer }).data, position: (arg as { position: number }).position }
|
||||
: { data: arg as ArrayBuffer, position: keepExisting ? entry.content.byteLength : 0 };
|
||||
const merged = new Uint8Array(chunk.position + chunk.data.byteLength);
|
||||
if (keepExisting || chunk.position > 0) {
|
||||
merged.set(new Uint8Array(entry.content), 0);
|
||||
}
|
||||
merged.set(new Uint8Array(chunk.data), chunk.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 };
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* 故障注入基座自测(v0.8.0 工作流 C-1)
|
||||
*
|
||||
* 这些测试的作用不是测产品代码,而是**证明测试基座本身是有效的**:
|
||||
* 如果故障注入不能稳定复现"崩溃丢数据",那么后面所有崩溃相关测试都是空头支票。
|
||||
*/
|
||||
|
||||
import { TransactionalFileStore, installOPFSMock, clearRegistry, CrashableStoreBackend } from './storage-harness';
|
||||
import { FaultyBackend } from './faulty-backend';
|
||||
import { OPFSBackend } from '../../src/engine/aria/store/opfs_backend';
|
||||
import { decode as decodeBytes } from './assertions';
|
||||
|
||||
describe('[v0.8.0] 测试基座:TransactionalFileStore', () => {
|
||||
let store: TransactionalFileStore;
|
||||
|
||||
beforeEach(() => {
|
||||
clearRegistry();
|
||||
store = new TransactionalFileStore({ dbName: 'unit' });
|
||||
});
|
||||
|
||||
test('提交语义:未提交的写入对读不可见(真实 OPFS 是 close 才原子替换)', async () => {
|
||||
await store.write('k', new TextEncoder().encode('v1').buffer as ArrayBuffer);
|
||||
expect(await store.read('k')).toBeNull(); // 未 commit → 读不到
|
||||
store.commitAll();
|
||||
expect(decodeBytes(await store.read('k'))).toBe('v1');
|
||||
});
|
||||
|
||||
test('崩溃语义:丢弃未提交写入,已提交内容保持', async () => {
|
||||
await store.write('k', new TextEncoder().encode('committed').buffer as ArrayBuffer);
|
||||
store.commitAll();
|
||||
|
||||
await store.write('k', new TextEncoder().encode('pending').buffer as ArrayBuffer);
|
||||
expect(store.hasPending()).toBe(true);
|
||||
store.crashPending(); // 崩溃:丢掉 pending
|
||||
|
||||
expect(decodeBytes(await store.read('k'))).toBe('committed');
|
||||
expect(store.hasPending()).toBe(false);
|
||||
});
|
||||
|
||||
test('读返回副本:调用方原地修改不会污染"磁盘"(旧 mock 的缺陷 1)', async () => {
|
||||
await store.write('k', new TextEncoder().encode('original').buffer as ArrayBuffer);
|
||||
store.commitAll();
|
||||
|
||||
const first = await store.read('k');
|
||||
new Uint8Array(first!)[0] = 0x58; // 'X'
|
||||
|
||||
const second = await store.read('k');
|
||||
expect(decodeBytes(second)).toBe('original');
|
||||
});
|
||||
|
||||
test('故障注入:failNextWrite / failNextAppend / failNextDelete 精确计数', async () => {
|
||||
store.failNextWrite();
|
||||
await expect(store.write('k', new ArrayBuffer(4))).rejects.toThrow(/injected write failure/);
|
||||
await expect(store.write('k', new ArrayBuffer(4))).resolves.toBeUndefined();
|
||||
|
||||
store.failNextAppend(2);
|
||||
await expect(store.append('k', new ArrayBuffer(2))).rejects.toThrow(/injected append failure/);
|
||||
await expect(store.append('k', new ArrayBuffer(2))).rejects.toThrow(/injected append failure/);
|
||||
await expect(store.append('k', new ArrayBuffer(2))).resolves.toBeUndefined();
|
||||
|
||||
store.failNextDelete();
|
||||
await expect(store.delete('k')).rejects.toThrow(/injected delete failure/);
|
||||
});
|
||||
|
||||
test('撕裂写:truncateNextAppendTo 只落前 n 字节', async () => {
|
||||
const text = new TextEncoder();
|
||||
await store.write('log', text.encode('AAAA').buffer as ArrayBuffer);
|
||||
store.commitAll();
|
||||
|
||||
store.truncateNextAppendTo(2);
|
||||
await store.append('log', text.encode('BBBB').buffer as ArrayBuffer);
|
||||
store.commitAll();
|
||||
|
||||
// 已提交的 'AAAA' 保留 + 撕裂后只追加了 'BB' → 'AAAABB'
|
||||
expect(decodeBytes(await store.read('log'))).toBe('AAAABB');
|
||||
});
|
||||
|
||||
test('篡改已提交字节:corruptCommitted 用于 bit-flip 场景', async () => {
|
||||
await store.write('k', new TextEncoder().encode('hello').buffer as ArrayBuffer);
|
||||
store.commitAll();
|
||||
|
||||
const ok = store.corruptCommitted('k', (bytes) => { bytes[0] ^= 0xff; });
|
||||
expect(ok).toBe(true);
|
||||
const after = new Uint8Array((await store.read('k'))!);
|
||||
expect(after[0]).not.toBe('h'.charCodeAt(0));
|
||||
});
|
||||
|
||||
test('提交原子性:commitAll 一次性生效全部 pending', async () => {
|
||||
await store.write('a', new ArrayBuffer(1));
|
||||
await store.write('b', new ArrayBuffer(1));
|
||||
expect(await store.listKeys()).toEqual(['a', 'b']);
|
||||
store.commitAll();
|
||||
expect((await store.listKeys()).sort()).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('[v0.8.0] 测试基座:installOPFSMock 真实语义', () => {
|
||||
beforeEach(() => clearRegistry());
|
||||
|
||||
test('keepExistingData:false 会截断(旧 mock 的缺陷 2)', async () => {
|
||||
installOPFSMock('trunc');
|
||||
const backend = new OPFSBackend();
|
||||
await backend.open('trunc');
|
||||
await backend.write('f', new TextEncoder().encode('LONGCONTENT').buffer as ArrayBuffer);
|
||||
|
||||
// 覆盖写更短内容 → 真实 OPFS 应截断为 5 字节
|
||||
await backend.write('f', new TextEncoder().encode('short').buffer as ArrayBuffer);
|
||||
const read = await backend.read('f');
|
||||
expect(decodeBytes(read)).toBe('short');
|
||||
});
|
||||
|
||||
test('append 是真追加且不破坏已有内容', async () => {
|
||||
installOPFSMock('app');
|
||||
const backend = new OPFSBackend();
|
||||
await backend.open('app');
|
||||
await backend.write('log', new TextEncoder().encode('AAA').buffer as ArrayBuffer);
|
||||
await backend.append('log', new TextEncoder().encode('BBB').buffer as ArrayBuffer);
|
||||
expect(decodeBytes(await backend.read('log'))).toBe('AAABBB');
|
||||
});
|
||||
|
||||
test('读返回副本,跨 backend 实例隔离', async () => {
|
||||
installOPFSMock('iso');
|
||||
const b1 = new OPFSBackend();
|
||||
await b1.open('iso');
|
||||
await b1.write('k', new TextEncoder().encode('v').buffer as ArrayBuffer);
|
||||
|
||||
const b2 = new OPFSBackend();
|
||||
await b2.open('iso');
|
||||
const buf = await b2.read('k');
|
||||
new Uint8Array(buf!)[0] = 0x5a;
|
||||
|
||||
expect(decodeBytes(await b1.read('k'))).toBe('v');
|
||||
});
|
||||
|
||||
test('崩溃后重开:已提交数据在,未提交的不在', async () => {
|
||||
const installed = installOPFSMock('crash');
|
||||
const store = installed.store;
|
||||
store.restoreCommitted(new Map()); // 清空
|
||||
|
||||
await store.write('committed', new TextEncoder().encode('yes').buffer as ArrayBuffer);
|
||||
store.commitAll();
|
||||
await store.write('pending', new TextEncoder().encode('no').buffer as ArrayBuffer);
|
||||
store.crashPending();
|
||||
|
||||
expect(decodeBytes(await store.read('committed'))).toBe('yes');
|
||||
expect(await store.read('pending')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('[v0.8.0] 测试基座:FaultyBackend', () => {
|
||||
beforeEach(() => clearRegistry());
|
||||
|
||||
test('注入的写失败必须真的让调用方拿到 rejection', async () => {
|
||||
installOPFSMock('faulty');
|
||||
const faulty = new FaultyBackend(new OPFSBackend());
|
||||
await faulty.open('faulty');
|
||||
|
||||
faulty.failNextWrite();
|
||||
await expect(faulty.write('k', new ArrayBuffer(4))).rejects.toThrow(/injected write failure/);
|
||||
expect(faulty.injected.write).toBe(1);
|
||||
|
||||
faulty.clearFaults();
|
||||
await expect(faulty.write('k', new ArrayBuffer(4))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test('静默丢弃写:调用方看到成功,但介质上没有(掉电场景)', async () => {
|
||||
installOPFSMock('drop');
|
||||
const faulty = new FaultyBackend(new OPFSBackend());
|
||||
await faulty.open('drop');
|
||||
|
||||
faulty.dropNextWrite();
|
||||
await expect(faulty.write('k', new ArrayBuffer(4))).resolves.toBeUndefined();
|
||||
expect(await faulty.read('k')).toBeNull(); // 静默丢失
|
||||
expect(faulty.injected.dropped).toBe(1);
|
||||
});
|
||||
|
||||
test('撕裂追加:写到一半的 WAL 记录可被构造', async () => {
|
||||
installOPFSMock('torn');
|
||||
const faulty = new FaultyBackend(new OPFSBackend());
|
||||
await faulty.open('torn');
|
||||
|
||||
const record = new Uint8Array(100).fill(7);
|
||||
faulty.truncateNextAppendTo(37);
|
||||
await faulty.append('__wal_000000.bin', record.buffer as ArrayBuffer);
|
||||
|
||||
const stored = await faulty.read('__wal_000000.bin');
|
||||
expect(stored!.byteLength).toBe(37);
|
||||
expect(faulty.injected.truncated).toBe(1);
|
||||
});
|
||||
|
||||
test('crash() 丢弃未提交写(这才是崩溃,不是 close 优雅停机)', async () => {
|
||||
const store = new TransactionalFileStore({ dbName: 'crash2' });
|
||||
const backend = new CrashableStoreBackend(store);
|
||||
const faulty = new FaultyBackend(backend);
|
||||
|
||||
const text = new TextEncoder();
|
||||
await faulty.write('saved', text.encode('KEEP').buffer as ArrayBuffer);
|
||||
backend.commit(); // 模拟 createWritable.close() 完成
|
||||
|
||||
await faulty.write('unsaved', text.encode('LOST').buffer as ArrayBuffer);
|
||||
expect(store.hasPending()).toBe(true); // 存在真实的"提交前崩溃窗口"
|
||||
|
||||
expect(faulty.crash()).toBe(true);
|
||||
|
||||
expect(store.hasPending()).toBe(false);
|
||||
expect(await store.read('unsaved')).toBeNull();
|
||||
expect(decodeBytes(await store.read('saved'))).toBe('KEEP');
|
||||
});
|
||||
|
||||
test('对照组:close() 是优雅停机,会把未提交写刷完 —— 因此不能用来模拟崩溃', async () => {
|
||||
const store = new TransactionalFileStore({ dbName: 'graceful' });
|
||||
const backend = new CrashableStoreBackend(store);
|
||||
|
||||
await backend.write('k', new TextEncoder().encode('PENDING').buffer as ArrayBuffer);
|
||||
expect(store.hasPending()).toBe(true);
|
||||
|
||||
// close() 不丢弃 pending(真实 OPFSBackend.close 还会等写队列排空)
|
||||
await backend.close();
|
||||
expect(store.hasPending()).toBe(true);
|
||||
|
||||
// 提交后才可见 —— 证明"用 close 当崩溃"会掩盖崩溃窗口
|
||||
backend.commit();
|
||||
expect(decodeBytes(await store.read('k'))).toBe('PENDING');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,452 @@
|
||||
/**
|
||||
* 测试共享 — 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<string, ArrayBuffer>();
|
||||
/** 未提交写入(= OPFS createWritable 的 swap 区) */
|
||||
private pending = new Map<string, ArrayBuffer | null>();
|
||||
|
||||
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<ArrayBuffer | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await this.write(key, data);
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
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<string[]> {
|
||||
const keys = new Set<string>(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<boolean> {
|
||||
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<string, ArrayBuffer> {
|
||||
const out = new Map<string, ArrayBuffer>();
|
||||
for (const [k, v] of this.committed) out.set(k, copyBuffer(v));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 用快照替换已提交内容(模拟外部损坏/离线篡改) */
|
||||
restoreCommitted(snapshot: Map<string, ArrayBuffer>): 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<string, TransactionalFileStore>();
|
||||
|
||||
/** 取(或创建)某库的共享 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<string[]>;
|
||||
/**
|
||||
* 直接创建(或覆盖)一个文件 —— 用于制造"崩溃残留临时文件"等场景,
|
||||
* 替代旧 API 暴露内部 dir/files 的做法。
|
||||
*/
|
||||
createFile(name: string, content?: ArrayBuffer): Promise<void>;
|
||||
/** 文件是否存在 */
|
||||
hasFile(name: string): Promise<boolean>;
|
||||
/** 读取文件内容(返回副本) */
|
||||
readFile(name: string): Promise<ArrayBuffer | null>;
|
||||
/** 删除文件 */
|
||||
removeFile(name: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装 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<void> { return Promise.resolve(); }
|
||||
close(): Promise<void> { 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<string, ArrayBuffer>) {
|
||||
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<void> { this.store.clear(); return Promise.resolve(); }
|
||||
|
||||
/** 崩溃:丢弃全部未提交写入 */
|
||||
simulateCrash(): void {
|
||||
this.store.crashPending();
|
||||
}
|
||||
|
||||
/** 正常提交(模拟所有 createWritable 完成 close) */
|
||||
commit(): void {
|
||||
this.store.commitAll();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user