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)并已同步全部体积宣称。
This commit is contained in:
thzxx
2026-09-15 17:09:29 +08:00
parent 0b44620721
commit c1c3036abd
25 changed files with 439 additions and 55 deletions
@@ -0,0 +1,97 @@
/**
* 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();
});
});