Files
cc-web/scripts/json-persistence-unit.js

263 lines
12 KiB
JavaScript
Raw 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.

#!/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 };