/** * OPFS 不可用时的错误必须"可操作" * * 现场复现(v0.8.0 回归 review 发现):用 file:// 直接打开站点演示页 → 切到 Aria * 引擎 → `navigator.storage.getDirectory()` 抛 SecurityError(Chromium 认为本地 * 文件不适合 Web 应用访问)→ 引擎把它包成 `ARIA_OPEN_ERROR`,**cause 没往上带**, * 用户只看到 "Failed to open AriaEngine database" —— 既不知道是环境问题, * 也不知道该怎么办(实测:`isSecureContext` 仍是 true、API 也**存在**, * 只有真正调用才会发现;所以不能靠"API 是否存在"提前判断)。 */ import { describe, it, expect, afterEach } from '@jest/globals'; import { OPFSBackend } from '../../src/engine/aria/store/opfs_backend'; const realNavigator = globalThis.navigator; afterEach(() => { Object.defineProperty(globalThis, 'navigator', { value: realNavigator, configurable: true }); }); function stubStorage(storage: unknown): void { Object.defineProperty(globalThis, 'navigator', { value: { ...(realNavigator as object), storage }, configurable: true, }); } describe('OPFSBackend.open — 环境不可用时给可操作的错误', () => { it('navigator.storage 缺失(老浏览器 / 非安全上下文)→ ARIA_OPFS_UNAVAILABLE', async () => { stubStorage(undefined); const backend = new OPFSBackend(); await expect(backend.open('demo')).rejects.toMatchObject({ code: 'ARIA_OPFS_UNAVAILABLE' }); try { await backend.open('demo'); } catch (error) { const message = (error as Error).message; expect(message).toContain('navigator.storage.getDirectory is missing'); expect(message).toContain('http(s)'); // 必须告诉用户怎么修 expect(message).toContain('memory'); } }); it('getDirectory 抛 SecurityError(file:// 页面)→ 带原始 cause + 可操作建议', async () => { const security = new Error('It was determined that certain files are unsafe for access within a Web application'); security.name = 'SecurityError'; stubStorage({ getDirectory: async () => { throw security; } }); const backend = new OPFSBackend(); const error = await backend.open('demo').then(() => null, (e: unknown) => e) as (Error & { code?: string; cause?: unknown }) | null; expect(error).not.toBeNull(); expect(error!.code).toBe('ARIA_OPFS_UNAVAILABLE'); expect(error!.cause).toBe(security); // 原始错误必须保留(cause 非枚举,直接断言) expect(error!.message).toContain('SecurityError'); expect(error!.message).toContain('file://'); // 点明最可能的成因 expect(error!.message).toContain('http(s)'); expect(error!.message).toContain('memory'); }); it('正常环境照旧可用(正控:不能把好环境也拒了)', async () => { const files = new Map(); const dirHandle = { async getFileHandle(name: string) { return { async createWritable() { return { async write() {}, async close() { files.set(name, 1); } }; } }; }, async getDirectoryHandle() { return dirHandle; }, async removeEntry(name: string) { files.delete(name); }, async *entries() { /* 无残留文件 */ }, }; stubStorage({ getDirectory: async () => dirHandle }); const backend = new OPFSBackend(); await backend.open('demo'); expect(backend.isOpen()).toBe(true); await backend.close(); expect(backend.isOpen()).toBe(false); }); }); describe('DatabaseError — 根因必须可通过标准 cause 取到', () => { it('第三个参数同时进 details 与 Error.cause(两者语义都成立)', async () => { const { DatabaseError } = await import('../../src/constants'); const root = new Error('underlying failure'); const err = new DatabaseError('wrapper message', 'WRAP_CODE', root); expect(err.code).toBe('WRAP_CODE'); expect(err.details).toBe(root); // 既有契约(文档示例打印 err.details) expect(err.cause).toBe(root); // 标准错误链(v0.8.0 修复前恒为 undefined) expect(err.message).toBe('wrapper message'); expect(err.name).toBe('DatabaseError'); expect(err instanceof Error).toBe(true); }); it('没有底层错误时不制造 cause(保持 undefined)', async () => { const { DatabaseError } = await import('../../src/constants'); const err = new DatabaseError('plain', 'PLAIN_CODE'); expect(err.details).toBeUndefined(); expect(err.cause).toBeUndefined(); }); });