Files
MetonaSqlark/tests/helpers/storage-harness.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

453 lines
17 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.
/**
* 测试共享 — 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();
}
}