Files
MetonaSqlark/tests/helpers/faulty-backend.ts
T
thzxx 0dba1abf2a 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
2026-09-14 21:03:06 +08:00

243 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 测试共享 — 故障注入后端包装器(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();
}
}