Files
MetonaSqlark/tests/engine/aria-opfs-unavailable.test.ts
thzxx c1c3036abd fix(site/v0.8.0): 站点版本同步 + 现场失败修复(file:// 打不开 OPFS 的可操作错误)
用户报告"站点演示失败了",实测复现并定位根因:

- 现象:直接双击 site/demo.html(file://)→ 点「🌲 Aria」→
  " 数据库初始化失败: Failed to open AriaEngine database "demo""(Memory 正常)。
- 根因:file:// 属不透明来源,Chromium 拒绝 navigator.storage.getDirectory()
  并抛 SecurityError;此时 isSecureContext 仍为 true、API 也存在,无法提前探测。
  引擎把它包成 ARIA_OPEN_ERROR 时丢掉了底层错误 → 消息对用户不可操作。
- 修复:OPFSBackend.open() 显式检查并抛 ARIA_OPFS_UNAVAILABLE,消息给出两条出路
  (用 http(s) 打开 / 改用 mode:'memory'),原始 SecurityError 挂 cause;
  site/demo.html 额外用中文说明"为什么失败 + 怎么修"。

顺带修掉一个更普遍的问题:DatabaseError 的第三个参数只进 details,err.cause
恒为 undefined,而文档/注释多处写"底层错误作为 cause 保留"。现在两者都成立
(details 语义不变;cause 声明为公开字段并接入标准错误链)。

站点版本同步:demo.html(title / 状态栏 / SQL 预置脚本 / console 日志)与
benchmark.html(title)此前仍是 v0.7.4(日志甚至是 v0.4.2)→ 统一 v0.8.0;
docs.html 的 AriaEngine 版本演进列表补上 v0.8.0 条目、错误码表补
ARIA_OPFS_UNAVAILABLE;README 补"OPFS 需要 http(s) 页面"的浏览器兼容说明。

回归与门禁:tests/engine/aria-opfs-unavailable.test.ts(5 项,含正常环境正控);
变异 R19 / R20 均被拦住(总计 42/42);93 套件 / 1985 用例;覆盖率
90.59 / 82.61 / 94.14 / 93.50(阈值 90/82/94/93);e2e 14/14;lint + 两份 tsc 干净;
dist 重建(251,731 B / gzip 63,431 B)并已同步全部体积宣称。
2026-09-15 17:09:29 +08:00

98 lines
4.5 KiB
TypeScript
Raw Permalink 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 回归 review 发现):用 file:// 直接打开站点演示页 → 切到 Aria
* 引擎 → `navigator.storage.getDirectory()` 抛 SecurityErrorChromium 认为本地
* 文件不适合 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 抛 SecurityErrorfile:// 页面)→ 带原始 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<string, unknown>();
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();
});
});