用户报告"站点演示失败了",实测复现并定位根因: - 现象:直接双击 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)并已同步全部体积宣称。
98 lines
4.5 KiB
TypeScript
98 lines
4.5 KiB
TypeScript
/**
|
||
* 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<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();
|
||
});
|
||
});
|