263 lines
12 KiB
JavaScript
263 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
||
'use strict';
|
||
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const os = require('node:os');
|
||
const path = require('node:path');
|
||
const { readJsonSync, writeJsonSync, cloneJsonValue, jsonValueEqual } = require('../lib/json-persistence');
|
||
const { createSessionHistoryStore } = require('../lib/session-history-store');
|
||
|
||
const BLOCK_BYTES = 64 * 1024;
|
||
|
||
function nativeJson(value) {
|
||
return JSON.stringify(value, (key, item) => {
|
||
if (typeof item === 'bigint') return String(item);
|
||
if (item instanceof Map) return Object.fromEntries(item);
|
||
if (item instanceof Set) return [...item];
|
||
return item;
|
||
});
|
||
}
|
||
|
||
function temporaryRoot() {
|
||
if (process.platform === 'linux' && typeof fs.statfsSync === 'function') {
|
||
try {
|
||
const stat = fs.statfsSync('/dev/shm');
|
||
if (Number(stat.bavail) * Number(stat.bsize) >= 32 * 1024 * 1024) return '/dev/shm';
|
||
} catch {}
|
||
}
|
||
return os.tmpdir();
|
||
}
|
||
|
||
function runTests() {
|
||
const dir = fs.mkdtempSync(path.join(temporaryRoot(), 'ccweb-json-persistence-'));
|
||
const failures = [];
|
||
let checks = 0;
|
||
let sequence = 0;
|
||
function check(name, callback) {
|
||
try {
|
||
callback();
|
||
checks += 1;
|
||
console.log(`通过:${name}`);
|
||
} catch (error) {
|
||
failures.push(name);
|
||
console.error(`失败:${name}:${error.stack}`);
|
||
}
|
||
}
|
||
function rawFile(json) {
|
||
const filename = path.join(dir, `input-${++sequence}.json`);
|
||
fs.writeFileSync(filename, json);
|
||
return filename;
|
||
}
|
||
function encode(value, filesystem = fs) {
|
||
const filename = path.join(dir, `output-${++sequence}.json`);
|
||
const fd = fs.openSync(filename, 'wx', 0o600);
|
||
try { writeJsonSync(filesystem, fd, value); } finally { fs.closeSync(fd); }
|
||
return { filename, json: fs.readFileSync(filename, 'utf8') };
|
||
}
|
||
function assertNative(value) {
|
||
const expectedJson = nativeJson(value);
|
||
const expected = JSON.parse(expectedJson);
|
||
const output = encode(value);
|
||
assert.equal(output.json, expectedJson);
|
||
assert.deepEqual(readJsonSync(fs, output.filename), expected);
|
||
assert.deepEqual(cloneJsonValue(value), expected);
|
||
}
|
||
|
||
try {
|
||
check('JSON基本类型、数值边界与控制字符与原生持久化语义一致', () => {
|
||
const samples = [null, true, false, 0, -0, 1.25, 1e-100, 1e100, NaN, Infinity, -Infinity,
|
||
'', '中文🙂\u0000\b\f\n\r\t\\"/\u2028\u2029', '\ud800\udfff', 9007199254740993n];
|
||
samples.forEach(assertNative);
|
||
assertNative({ values: samples, boxed: [new Number(4), new String('包装文字'), new Boolean(false)] });
|
||
});
|
||
|
||
check('BigInt、Map、Set、日期与toJSON保持项目转换及键参数', () => {
|
||
function fixture() {
|
||
return {
|
||
bigint: 123456789012345678901234567890n,
|
||
map: new Map([['a', 1n], ['__proto__', { own: true }], ['set', new Set([2, 3])]]),
|
||
set: new Set(['x', 4n]),
|
||
date: new Date('2026-10-14T01:02:03.456Z'),
|
||
custom: { toJSON(key) { return { key, value: 9n }; } },
|
||
array: [{ toJSON(key) { return `数组键 ${key}`; } }],
|
||
};
|
||
}
|
||
assertNative(fixture());
|
||
assertNative({ toJSON(key) { return { rootKey: key, value: new Map([['ok', true]]) }; } });
|
||
for (const action of ['write', 'clone']) {
|
||
let reads = 0;
|
||
let calls = 0;
|
||
const custom = {
|
||
get toJSON() {
|
||
reads += 1;
|
||
return function (key) { calls += 1; assert.equal(this, custom); return { key, value: '原值' }; };
|
||
},
|
||
};
|
||
const result = action === 'write' ? JSON.parse(encode({ custom }).json) : cloneJsonValue({ custom });
|
||
assert.deepEqual(result, { custom: { key: 'custom', value: '原值' } });
|
||
assert.equal(reads, 1, 'toJSON属性只能读取一次');
|
||
assert.equal(calls, 1, 'toJSON只能执行一次');
|
||
}
|
||
});
|
||
|
||
check('对象省略与数组空洞置null、重复引用及危险属性名完整保存', () => {
|
||
const shared = { stable: '原值', nested: ['完整内容'] };
|
||
const object = JSON.parse('{"__proto__":{"value":"自己的属性"},"constructor":"构造键","prototype":"普通键"}');
|
||
object.missing = undefined;
|
||
object.callback = () => 1;
|
||
object.symbol = Symbol('省略');
|
||
object.array = [undefined, () => 1, Symbol('数组位置'), , shared, shared];
|
||
object.left = shared;
|
||
object.right = shared;
|
||
assertNative(object);
|
||
const cloned = cloneJsonValue(object);
|
||
assert.equal(Object.hasOwn(cloned, '__proto__'), true);
|
||
assert.deepEqual(cloned.__proto__, { value: '自己的属性' });
|
||
assert.equal(Object.hasOwn(cloned, 'missing'), false);
|
||
assert.equal(cloned.array.length, 6);
|
||
assert.deepEqual(cloned.array.slice(0, 4), [null, null, null, null]);
|
||
});
|
||
|
||
check('容器复制隔离原对象并保留不可变字符串值', () => {
|
||
const text = '必须保留完整正文🙂'.repeat(20000);
|
||
const source = { text, nested: { rows: [{ value: 1 }] } };
|
||
const cloned = cloneJsonValue(source);
|
||
assert.notEqual(cloned, source);
|
||
assert.notEqual(cloned.nested, source.nested);
|
||
assert.notEqual(cloned.nested.rows, source.nested.rows);
|
||
assert.notEqual(cloned.nested.rows[0], source.nested.rows[0]);
|
||
assert.equal(cloned.text, text);
|
||
cloned.nested.rows[0].value = 2;
|
||
assert.equal(source.nested.rows[0].value, 1);
|
||
assert.equal(jsonValueEqual(source, cloneJsonValue(source)), true);
|
||
assert.equal(jsonValueEqual(source, cloned), false);
|
||
assert.equal(jsonValueEqual(cloneJsonValue({ value: NaN, omitted: undefined }), { value: null }), true);
|
||
assert.equal(jsonValueEqual(cloneJsonValue([undefined, , Infinity]), [null, null, null]), true);
|
||
assert.equal(jsonValueEqual(cloneJsonValue(10n), '10'), true);
|
||
assert.equal(jsonValueEqual(cloneJsonValue(new Map([['value', 1]])), { value: 1 }), true);
|
||
assert.equal(jsonValueEqual({ left: 1, right: { value: 2 } }, { right: { value: 2 }, left: 1 }), true);
|
||
assert.equal(jsonValueEqual(undefined, undefined), true);
|
||
assert.equal(jsonValueEqual(undefined, null), false);
|
||
});
|
||
|
||
check('顶层不可持久化值拒绝提交且循环引用失败', () => {
|
||
for (const value of [undefined, () => 1, Symbol('顶层')]) {
|
||
assert.equal(cloneJsonValue(value), undefined);
|
||
assert.throws(() => encode(value), TypeError);
|
||
}
|
||
const cyclic = { text: '已经写出的字段' };
|
||
cyclic.self = cyclic;
|
||
assert.throws(() => encode(cyclic), TypeError);
|
||
assert.throws(() => cloneJsonValue(cyclic), TypeError);
|
||
});
|
||
|
||
check('64KiB边界跨UTF-8、JSON转义和长字符串时完整读取', () => {
|
||
const prefix = '{"text":"';
|
||
const utf8Json = prefix + 'a'.repeat(BLOCK_BYTES - Buffer.byteLength(prefix) - 1) + '🙂中文","tail":{"value":true}}';
|
||
const escapedJson = prefix + 'b'.repeat(BLOCK_BYTES - Buffer.byteLength(prefix) - 1) + '\\uD83D\\uDE42\\n\\"\\\\","tail":[1,2,3]}';
|
||
for (const json of [utf8Json, escapedJson, '\r\n ' + '[1e-3,true,null,{"duplicate":1,"duplicate":2}]' + '\t ']) {
|
||
const filename = rawFile(json);
|
||
let maxRead = 0;
|
||
const filesystem = new Proxy(fs, { get(target, key) {
|
||
if (key === 'readFileSync') return () => { throw new Error('流式读取禁止整文件readFileSync'); };
|
||
if (key === 'readSync') return (fd, buffer, offset, length, position) => {
|
||
maxRead = Math.max(maxRead, length);
|
||
assert(length <= BLOCK_BYTES);
|
||
return target.readSync(fd, buffer, offset, length, position);
|
||
};
|
||
return target[key];
|
||
} });
|
||
assert.deepEqual(readJsonSync(filesystem, filename), JSON.parse(json));
|
||
assert(maxRead > 0 && maxRead <= BLOCK_BYTES);
|
||
}
|
||
});
|
||
|
||
check('多次短读不丢UTF-8字节和转义,读取错误关闭文件', () => {
|
||
const json = '{"text":"中文🙂\\n\\uD83D\\uDE42\\\\\\\"","number":-2.5e+3,"rows":[true,null,{"own":1}]}';
|
||
const filename = rawFile(json);
|
||
let opened = 0;
|
||
let closed = 0;
|
||
let failRead = false;
|
||
const filesystem = new Proxy(fs, { get(target, key) {
|
||
if (key === 'openSync') return (...args) => { opened += 1; return target.openSync(...args); };
|
||
if (key === 'closeSync') return (...args) => { closed += 1; return target.closeSync(...args); };
|
||
if (key === 'readSync') return (fd, buffer, offset, length, position) => {
|
||
if (failRead) throw new Error('模拟读取中断');
|
||
return target.readSync(fd, buffer, offset, Math.min(length, 3), position);
|
||
};
|
||
return target[key];
|
||
} });
|
||
assert.deepEqual(readJsonSync(filesystem, filename), JSON.parse(json));
|
||
failRead = true;
|
||
assert.throws(() => readJsonSync(filesystem, filename), /模拟读取中断/);
|
||
assert.equal(closed, opened, '读取成功或失败均应关闭文件');
|
||
});
|
||
|
||
check('非法JSON、截断值、尾随内容与控制字符均拒绝', () => {
|
||
const invalid = ['', ' ', '{', '[1,', '{"a":}', '{"a":1,}', '[1,]', 'true false', 'nullx', '+1', '01', '1.', '1e',
|
||
'NaN', 'Infinity', 'undefined', '\ufeff{}', '{unquoted:1}', '"未闭合', '"\\x01"', '"\\u12"', '"\n"', '"\u0000"'];
|
||
for (const json of invalid) {
|
||
assert.throws(() => JSON.parse(json));
|
||
assert.throws(() => readJsonSync(fs, rawFile(json)), `非法JSON必须拒绝:${JSON.stringify(json)}`);
|
||
}
|
||
for (const json of ['1e400', '-0', '[1,2,3]', '{"__proto__":{"own":true},"constructor":4}']) {
|
||
assert.deepEqual(readJsonSync(fs, rawFile(json)), JSON.parse(json));
|
||
}
|
||
});
|
||
|
||
check('分段编码每次最多64KiB且正确处理短写', () => {
|
||
const value = { text: '中文🙂\\"\n\u0000'.repeat(25000), nested: { lines: ['保持原值'] } };
|
||
let maxWrite = 0;
|
||
let calls = 0;
|
||
const filesystem = new Proxy(fs, { get(target, key) {
|
||
if (key === 'writeFileSync') return () => { throw new Error('流式编码禁止整文件writeFileSync'); };
|
||
if (key === 'writeSync') return (fd, data, ...args) => {
|
||
calls += 1;
|
||
const bytes = typeof data === 'string' ? Buffer.byteLength(data, typeof args[1] === 'string' ? args[1] : 'utf8') : args[1] ?? data.length;
|
||
maxWrite = Math.max(maxWrite, bytes);
|
||
assert(bytes <= BLOCK_BYTES, `单次编码写入 ${bytes} 字节超出64KiB`);
|
||
// 固定短写发生在字节缓冲层,不能以UTF-16字符数量当作已写字节数。
|
||
if (typeof data === 'string') {
|
||
const encoded = Buffer.from(data, typeof args[1] === 'string' ? args[1] : 'utf8');
|
||
return target.writeSync(fd, encoded, 0, Math.min(11, encoded.length), args[0] ?? null);
|
||
}
|
||
return target.writeSync(fd, data, args[0] ?? 0, Math.min(11, bytes), args[2] ?? null);
|
||
};
|
||
return target[key];
|
||
} });
|
||
const output = encode(value, filesystem);
|
||
assert.equal(output.json, nativeJson(value));
|
||
assert(calls > 1);
|
||
assert(maxWrite <= BLOCK_BYTES);
|
||
});
|
||
|
||
check('JSON中途编码异常保留原子归档且无临时文件残留', () => {
|
||
const store = createSessionHistoryStore({ sessionsDir: dir });
|
||
const id = 'json-atomic-failure';
|
||
const original = { version: 1, agent: 'codexapp', sessionId: id, fullText: '上次完整运行态', toolCalls: [] };
|
||
store.saveTurn(id, original);
|
||
const filename = path.join(dir, '_history', '_turns', `${id}.json`);
|
||
const oldBytes = fs.readFileSync(filename);
|
||
const cyclic = { ...original, fullText: '新正文'.repeat(100000) };
|
||
cyclic.self = cyclic;
|
||
assert.throws(() => store.saveTurn(id, cyclic), TypeError);
|
||
const customFailure = { ...original, fullText: '已编码新正文'.repeat(100000), tail: { toJSON() { throw new Error('模拟toJSON异常'); } } };
|
||
assert.throws(() => store.saveTurn(id, customFailure), /模拟toJSON异常/);
|
||
assert.deepEqual(fs.readFileSync(filename), oldBytes);
|
||
assert.deepEqual(store.loadTurn(id), original);
|
||
assert.equal(fs.readdirSync(path.dirname(filename)).some(name => name.endsWith('.tmp')), false);
|
||
});
|
||
} finally {
|
||
fs.rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
if (failures.length) {
|
||
console.error(`JSON持久化回归失败:${failures.length} 项,已通过 ${checks} 项`);
|
||
process.exitCode = 1;
|
||
} else console.log(`JSON持久化回归通过:${checks} 项`);
|
||
return { checks, failures };
|
||
}
|
||
|
||
if (require.main === module) runTests();
|
||
module.exports = { runTests };
|