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:
thzxx
2026-09-14 21:03:06 +08:00
parent c8b59bd16f
commit 0dba1abf2a
65 changed files with 1533 additions and 454 deletions
+242
View File
@@ -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();
}
}