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
+452
View File
@@ -0,0 +1,452 @@
/**
* 测试共享 — OPFS 事务性文件存储(v0.8.0 根治版)
*
* ============================================================================
* 为什么需要重写(审计结论,见 PLAN-v0.7.5.md 工作流 C-1
* ============================================================================
* 旧 mocktests/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();
}
}