工作流 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
243 lines
8.3 KiB
TypeScript
243 lines
8.3 KiB
TypeScript
/**
|
||
* 测试共享 — 故障注入后端包装器(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();
|
||
}
|
||
}
|