mirror of
https://github.com/morluto/rea.git
synced 2026-11-10 08:47:00 +00:00
* fix(desktop): follow a database mapping and own limitation text A selected database keeps its log when a parent folder moves or the file sits outside the home. Trash and seed-lock tests compare the owning notice with the paths, and launch options that are not captured paths stay as written. * test(desktop): pass the selected log into the draft The export's selected home paths decide which log stays in the draft. The line-guard test now names that log instead of relying on its reason. * fix(desktop): retarget a settings folder that holds captured files A nested profile directory is a captured path when a seeded file is inside it. An option value that only resolves under the home stays as written. * fix(desktop): keep a program word and a directory's neighbor The program after env stays the program even when a captured file sits in a folder of that name. A selected directory is not a database, so a neighboring file keeps the parent folder's spelling. * fix(desktop): record whether a selected path is a directory Capture stores the walked path's kind. A neighboring file of a selected directory keeps the parent folder's spelling even when that directory never changes and its children are not content roots. * fix(desktop): mark containing paths without a pairwise scan A recorded path marks its known ancestors as directories in one walk up the path, instead of comparing every recorded path with every other. * fix(desktop): keep a selected folder that is named like a database log A folder the caller selected stays a selected root when its name ends in -journal or -wal. A generated log beside a database file still does not. * fix(desktop): refuse a build command that exceeds the argument vector Splitting one argument still leaves the whole command under Linux's E2BIG limit. A request past that budget is refused before the process starts. * fix(desktop): remap a captured path in an env assignment The program word stays the program. A NAME=value assignment keeps its name and follows a captured path value when that path's spelling moves. * fix(desktop): keep oversized text out of draft guards A value or line longer than the draft's text bound is not copied into a guard, so one large frame cannot fill the task by itself. * docs(desktop): correct dconf and check-preview guidance A dconf version kept without its bytes is unreadable, not an empty key list. A values preview is copied into a check only when it is an uncut scalar. * fix(desktop): keep an empty env value empty An empty assignment is not a path. Resolving it named the working directory and rewrote the value when that directory's spelling moved. * fix(desktop): keep a run's font on the run, not the frame's text A formatting attribute such as Scribus FONT is not the document's words. Joining it across runs made a frame-wide text check of ItalicBold that failed when a run was split. The font stays a value check on its own run. * fix(desktop): do not pair JSON records by a selector-shaped field A field named @record_id matched the id suffix and became [@record_id=one], which reads an XML attribute. Records paired while their pointers matched nothing. Names that start with @ or > are not id fields. * fix(desktop): count a save-numbered id once when pairing members xml:id and draw:id values such as id123456 were both generated and numbered, so the same selector was counted twice and refused. A text edit of two elements keyed only by those ids now keeps its check. * fix(desktop): preserve selected state directory aliases * fix(desktop): align nested state selections with aliases * fix(desktop): check build runtime export capability * fix(desktop): provide complete runtime repack command * fix(desktop): retain runtime probe failures * test(desktop): identify capture independently of schema preflight * fix(desktop): preserve document URLs and value formats * fix(desktop): bound added checks and omit noisy text guards * fix(desktop): exclude unseeded locks from task checks * fix(desktop): preserve draft bounds and launch URLs * fix(desktop): render draft values using their source format * fix(desktop): preserve launch URLs and JSON draft values * fix(desktop): recognize captured file URL documents * fix(desktop): render captured file URLs for runtime home * fix(desktop): distinguish phone numbers from save metadata * fix(desktop): preserve launch URLs and user text * fix(desktop): preserve captured URLs and user text * fix(desktop): preserve local file URL authority * fix(desktop): preserve local file URL authority * fix(desktop): preserve local file URL authority * fix(desktop): render captured file URLs at launch * fix(desktop): classify numeric timestamps using field context * fix(desktop): classify numeric timestamps using field context * fix(desktop): classify numeric timestamps using field context * fix(desktop): retain context for generated draft values * fix(desktop): retain context for generated draft values * fix(desktop): retain context for generated draft values * test(desktop): cover generated IDs in text guards * test(desktop): cover generated IDs in text guards * test(desktop): cover generated IDs in text guards * fix(desktop): isolate launcher URL encoding * fix(desktop): isolate launcher URL encoding * fix(desktop): isolate launcher URL encoding * fix(desktop): isolate launcher URL encoding * fix(desktop): retain clean text in noisy record fields * fix(desktop): classify added line values in one pass * fix(desktop): apply XML quote rules to text drafts * fix(desktop): distinguish XML attributes from quoted text * fix(desktop): preserve parsed XML line context * fix(desktop): retain partial XML syntax facts * fix(desktop): recognize markdown autolinks in drafts * fix(desktop): reuse export exclusions in task drafts * docs(desktop): clarify gzip scrub limits * style(desktop): format the noisy-record draft files with the pinned oxfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(desktop): move launch execution to boundary suite * fix(desktop): redact build schema failure output * refactor(desktop): normalize schema failure once * fix(desktop): omit unsafe schema parser snippets * fix(desktop): preserve draft change and selected path evidence * test(desktop): group retained dconf note regressions * fix(desktop): respect archive entry format in draft checks * test(desktop): separate unowned-file draft note cases * fix(desktop): preserve explicitly selected journal documents * fix(xml): avoid scanning selected ancestors per tag * fix(desktop): exclude covered text runs from draft counts * fix: preserve text when drafting desktop checks * fix: restore nested desktop state aliases * refactor: share UUID recognition across draft checks * fix: retain multiline XML attribute context * fix: retain relationship identifiers in task drafts * docs: clarify desktop draft identifier handling * test(desktop): type formatting run cases as pairs * test(desktop): separate markup boundary regression group * docs(desktop): clarify task checks before scratch export * fix(desktop): respell selected paths in file URLs * test(desktop): group captured document spelling cases * fix(desktop): bound draft preview serialization * fix(desktop): restore selected state aliases * fix(desktop): respect entry format in draft noise * test(desktop): match exported alias evidence at its JSON boundary * fix(desktop): retain app state logs in exported seeds * fix: align desktop numeric seed comparisons * test(desktop): keep one draft exclusion fixture import * test(desktop): retain log eligibility across draft test groups * style: fix desktop launch test formatting * style: format native command path schema * fix(desktop): map selected external launch paths * style(desktop): format seed document regressions * test(desktop): group launch URL regressions * fix(desktop): omit redundant nested state aliases * fix(desktop): compare added free text as a whole * fix(desktop): check recompressed seeds as shipped * docs(desktop): clarify recovered seed format * test(desktop): group shipped gzip seed checks * fix: retain quoted identifier text in task drafts * fix: recognize Unicode XML in desktop state readers * fix(desktop): bound nested draft checks * fix(desktop): explain missing arrays in truncated views * fix(desktop): find XML type references in archives * test(desktop): remove duplicated capture assertions * fix(desktop): retain nested omitted text boundaries * style(desktop): format draft value imports * test(desktop): group model and draft value checks * fix(desktop): map captured launch paths once * refactor: remove unused whole-text guard state * refactor: stop passing unused text children * test: align free-text checks with whole-note policy * fix: validate recompressed desktop archive seeds * style(desktop): format archive seed regression * fix(desktop): retain placed-item sibling guards * fix(desktop): redact runtime schema errors * fix(desktop): normalize captured file URLs * fix(desktop): report seeded files by capture path * fix(desktop): retain startup readiness failure stage * fix(desktop): retain JSON date checks * fix(desktop): preserve archive formatting checks * fix(desktop): preserve explicitly selected journal files * fix(desktop): report build command transport limits * docs(desktop): clarify unreadable dconf versions * style(desktop): format capture review fixes --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
572 lines
19 KiB
TypeScript
572 lines
19 KiB
TypeScript
import {
|
||
execFileSync,
|
||
type ExecFileSyncOptionsWithStringEncoding,
|
||
} from "node:child_process";
|
||
import { mkdtempSync, writeFileSync, unlinkSync, rmdirSync } from "node:fs";
|
||
import { tmpdir } from "node:os";
|
||
import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
||
import { join } from "node:path";
|
||
|
||
import { expect, it } from "vitest";
|
||
|
||
import {
|
||
PYTHON_HELPERS,
|
||
PYTHON_IMPORTS,
|
||
} from "../../../src/domain/desktop/appExportPython.js";
|
||
import {
|
||
detectStateFormat,
|
||
keyfileView,
|
||
isJsonText,
|
||
jsonView,
|
||
textualView,
|
||
} from "../../../src/domain/desktop/stateFormats.js";
|
||
import { safeParseJson } from "../../../src/domain/safeJson.js";
|
||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||
|
||
/** Keep the exported Python helpers out of the host command-line size limit. */
|
||
const execPython = (
|
||
args: string[],
|
||
options: ExecFileSyncOptionsWithStringEncoding,
|
||
): string => {
|
||
const directory = mkdtempSync(join(tmpdir(), "rea-export-python-"));
|
||
const script = join(directory, "fixture.py");
|
||
const codeIndex = args.indexOf("-c");
|
||
try {
|
||
writeFileSync(script, args[codeIndex + 1]!, "utf8");
|
||
return execFileSync(
|
||
process.platform === "win32" ? "python" : "python3",
|
||
[
|
||
"-X",
|
||
"utf8",
|
||
...args.slice(0, codeIndex),
|
||
script,
|
||
...args.slice(codeIndex + 2),
|
||
],
|
||
options,
|
||
);
|
||
} finally {
|
||
unlinkSync(script);
|
||
rmdirSync(directory);
|
||
}
|
||
};
|
||
|
||
/** The exported module's view format for each file, run with only its standard library imports. */
|
||
const pythonFormats = (files: readonly (readonly [string, string])[]) => {
|
||
const imports = PYTHON_IMPORTS.split("\n")
|
||
.filter((line) => !line.includes("agentenv_protocol"))
|
||
.join("\n");
|
||
const program = `${imports}${PYTHON_HELPERS}
|
||
for name, text in json.load(sys.stdin):
|
||
print(_text_view(name, text, len(text.encode()))["format"])
|
||
`;
|
||
return execPython(["-I", "-B", "-c", program], {
|
||
input: JSON.stringify(files),
|
||
encoding: "utf8",
|
||
})
|
||
.trim()
|
||
.split(/\r?\n/u);
|
||
};
|
||
|
||
it("detects Unicode XML names in both state readers while rejecting non-XML text", () => {
|
||
const files = [
|
||
["map.dat", '<地图 名称="甲"><节点/></地图>'],
|
||
["map.dat", '<!DOCTYPE 地图 SYSTEM "map.dtd"><地图/>'],
|
||
["map.dat", "<máp/>"],
|
||
["map.dat", "<地图><节点></地图>"],
|
||
["note.dat", "<ada@example.test>\nBuy milk"],
|
||
] as const;
|
||
const expected = ["xml", "xml", "xml", "text", "text"];
|
||
expect(pythonFormats(files)).toEqual(expected);
|
||
expect(
|
||
files.map(([name, text]) =>
|
||
detectStateFormat(name, new TextEncoder().encode(text)),
|
||
),
|
||
).toEqual(expected);
|
||
});
|
||
|
||
it("detects key files by the state view's rule rather than a name or any group header", () => {
|
||
const files = [
|
||
["list.txt", "Groceries\n[x]\nmilk\n"],
|
||
["settings.ini", "free text\n"],
|
||
["app.conf", "wrap=false\n"],
|
||
["settings", "# saved\n[ui]\nwrap=false\n\n; end\n"],
|
||
["notes.rc", "[a]\r\nkey = value\r\n"],
|
||
] as const;
|
||
|
||
const formats = pythonFormats(files);
|
||
|
||
expect(formats).toEqual(["text", "text", "text", "keyfile", "keyfile"]);
|
||
expect(formats).toEqual(
|
||
files.map(([name, text]) =>
|
||
detectStateFormat(name, new TextEncoder().encode(text)),
|
||
),
|
||
);
|
||
});
|
||
|
||
it("keeps JSON Lines holding a number it cannot read exactly as text, as REA does", () => {
|
||
const lines = '{"id":9007199254740993}\n{"id":2}\n';
|
||
const files = [
|
||
["events.jsonl", lines],
|
||
["events.json", lines],
|
||
["events.JSON", "42"],
|
||
["big.json", '{"id":9007199254740993}'],
|
||
["long.json", `{"id":${"9".repeat(5000)}}`],
|
||
] as const;
|
||
|
||
const formats = pythonFormats(files);
|
||
|
||
expect(formats).toEqual(["text", "text", "json", "json", "json"]);
|
||
expect(formats).toEqual(
|
||
files.map(([name, text]) =>
|
||
detectStateFormat(name, new TextEncoder().encode(text)),
|
||
),
|
||
);
|
||
});
|
||
|
||
/** The exported module's UTF-16 seed respelling of each file, from /home/ada to /home/user, as hex. */
|
||
const pythonUtf16Respelled = (files: readonly string[]) => {
|
||
const imports = PYTHON_IMPORTS.split("\n")
|
||
.filter((line) => !line.includes("agentenv_protocol"))
|
||
.join("\n");
|
||
const program = `${imports}
|
||
from pathlib import PurePosixPath
|
||
SEED_HOME = "/home/ada"
|
||
${PYTHON_HELPERS}
|
||
for data in json.load(sys.stdin):
|
||
print(_respelled_utf16(bytes.fromhex(data), PurePosixPath("/home/user")).hex())
|
||
`;
|
||
return execPython(["-I", "-B", "-c", program], {
|
||
input: JSON.stringify(files),
|
||
encoding: "utf8",
|
||
})
|
||
.trim()
|
||
.split(/\r?\n/u);
|
||
};
|
||
|
||
const utf16 = (text: string, order: "le" | "be") => {
|
||
const le = Buffer.from(`${text}`, "utf16le");
|
||
return order === "le" ? le : Buffer.from(le).swap16();
|
||
};
|
||
|
||
it("respells the captured home in UTF-16 seeds with a byte-order mark, keeping the mark and order", () => {
|
||
const work = "# build\n20260216;1;/home/ada/.varicad\n";
|
||
const files = [
|
||
utf16(work, "le"),
|
||
utf16(work, "be"),
|
||
utf16("/home/adam/x\n", "le"),
|
||
utf16("no home here\n", "le"),
|
||
Buffer.from([0xff, 0xfe, 0x41]),
|
||
Buffer.from("/home/ada/plain\n"),
|
||
];
|
||
|
||
const respelled = pythonUtf16Respelled(
|
||
files.map((file) => file.toString("hex")),
|
||
);
|
||
|
||
const expected = "# build\n20260216;1;/home/user/.varicad\n";
|
||
expect(respelled).toEqual([
|
||
utf16(expected, "le").toString("hex"),
|
||
utf16(expected, "be").toString("hex"),
|
||
...files.slice(2).map((file) => file.toString("hex")),
|
||
]);
|
||
});
|
||
|
||
it("seeds each file the bundle lists in SEED_MODES with that mode, as a lock whose app checks it stays 0600", async () => {
|
||
const root = await createTestTempDirectory("desktop-seed-modes-");
|
||
await mkdir(join(root, "seed", ".config", "notes"), { recursive: true });
|
||
await writeFile(
|
||
join(root, "seed", ".config", "notes", "secret.ini"),
|
||
"[s]\n",
|
||
);
|
||
await writeFile(join(root, "seed", "run.sh"), "#!/bin/sh\n");
|
||
const imports = PYTHON_IMPORTS.split("\n")
|
||
.filter((line) => !line.includes("agentenv_protocol"))
|
||
.join("\n");
|
||
const program = `${imports}
|
||
SEED_HOME = "/home/ada"
|
||
TEXT_SEEDS = frozenset()
|
||
SEED_MODES = {".config/notes/secret.ini": 0o600, "run.sh": 0o755}
|
||
HERE = Path(sys.argv[1])
|
||
${PYTHON_HELPERS}
|
||
_seed_home(Path(sys.argv[2]))
|
||
`;
|
||
|
||
execFileSync("python3", [
|
||
"-I",
|
||
"-B",
|
||
"-c",
|
||
program,
|
||
root,
|
||
join(root, "home"),
|
||
]);
|
||
|
||
const mode = async (path: string) =>
|
||
(await stat(join(root, "home", path))).mode & 0o777;
|
||
expect(await mode(".config/notes/secret.ini")).toBe(0o600);
|
||
expect(await mode("run.sh")).toBe(0o755);
|
||
});
|
||
|
||
it.each([true, false])(
|
||
"restores nested selected aliases in either selection order (%s)",
|
||
async (childFirst) => {
|
||
const root = await createTestTempDirectory("desktop-nested-alias-");
|
||
const home = join(root, "home");
|
||
await mkdir(join(home, ".config", "notes"), { recursive: true });
|
||
await writeFile(join(home, ".config", "notes", "profile.ini"), "profile");
|
||
const imports = PYTHON_IMPORTS.split("\n")
|
||
.filter((line) => !line.includes("agentenv_protocol"))
|
||
.join("\n");
|
||
const pairs = childFirst
|
||
? '[("settings/profile", ".config/notes"), ("settings", ".local/share/notes")]'
|
||
: '[("settings", ".local/share/notes"), ("settings/profile", ".config/notes")]';
|
||
const program = `${imports}
|
||
STATE_ALIASES = ${pairs}
|
||
${PYTHON_HELPERS}
|
||
home = Path(sys.argv[1])
|
||
_ensure_state_aliases(home)
|
||
_ensure_state_aliases(home)
|
||
`;
|
||
execPython(["-I", "-B", "-c", program, home], { encoding: "utf8" });
|
||
expect(
|
||
await readFile(join(home, "settings", "profile", "profile.ini"), "utf8"),
|
||
).toBe("profile");
|
||
},
|
||
);
|
||
|
||
it("restores a selected settings alias without replacing an existing path", async () => {
|
||
const root = await createTestTempDirectory("desktop-state-alias-");
|
||
const home = join(root, "home");
|
||
await mkdir(join(root, "seed", ".config", "notes"), { recursive: true });
|
||
await writeFile(
|
||
join(root, "seed", ".config", "notes", "settings.ini"),
|
||
"saved",
|
||
);
|
||
await mkdir(join(root, "seed", ".config", "notes", "profiles", "work"), {
|
||
recursive: true,
|
||
});
|
||
await writeFile(
|
||
join(root, "seed", ".config", "notes", "profiles", "work", "profile.ini"),
|
||
"profile",
|
||
);
|
||
const imports = PYTHON_IMPORTS.split("\n")
|
||
.filter((line) => !line.includes("agentenv_protocol"))
|
||
.join("\n");
|
||
const program = `${imports}
|
||
SEED_HOME = "/home/ada"
|
||
TEXT_SEEDS = frozenset()
|
||
SEED_MODES = {}
|
||
STATE_ALIASES = [("settings", ".config/notes")]
|
||
HERE = Path(sys.argv[1])
|
||
${PYTHON_HELPERS}
|
||
home = Path(sys.argv[2])
|
||
_seed_home(home)
|
||
_ensure_state_aliases(home)
|
||
_ensure_state_aliases(home)
|
||
`;
|
||
execPython(["-I", "-B", "-c", program, root, home], { encoding: "utf8" });
|
||
expect(await readFile(join(home, "settings", "settings.ini"), "utf8")).toBe(
|
||
"saved",
|
||
);
|
||
expect(
|
||
await readFile(
|
||
join(home, "settings", "profiles", "work", "profile.ini"),
|
||
"utf8",
|
||
),
|
||
).toBe("profile");
|
||
const other = join(root, "other-home");
|
||
await mkdir(other);
|
||
await writeFile(join(other, "settings"), "preserve");
|
||
expect(() =>
|
||
execPython(["-I", "-B", "-c", program, root, other], {
|
||
encoding: "utf8",
|
||
stdio: "pipe",
|
||
}),
|
||
).toThrow();
|
||
expect(await readFile(join(other, "settings"), "utf8")).toBe("preserve");
|
||
});
|
||
|
||
/** A stand-in xprop answering the root window list and each window's class and state from a table. */
|
||
const fakeXprop = async (windows: Record<string, string>) => {
|
||
const bin = await createTestTempDirectory("desktop-xprop-");
|
||
const table = JSON.stringify(windows);
|
||
await writeFile(
|
||
join(bin, "xprop"),
|
||
`#!/usr/bin/env python3
|
||
import json, sys
|
||
windows = json.loads(${JSON.stringify(table)})
|
||
if sys.argv[1:3] == ["-root", "_NET_CLIENT_LIST"]:
|
||
print("_NET_CLIENT_LIST(WINDOW): window id # " + ", ".join(windows))
|
||
else:
|
||
print(windows[sys.argv[2]])
|
||
`,
|
||
);
|
||
await chmod(join(bin, "xprop"), 0o755);
|
||
return bin;
|
||
};
|
||
|
||
const taskbarWindow = (bin: string, windowClass: string): string => {
|
||
const imports = PYTHON_IMPORTS.split("\n")
|
||
.filter((line) => !line.includes("agentenv_protocol"))
|
||
.join("\n");
|
||
return execPython(
|
||
[
|
||
"-I",
|
||
"-B",
|
||
"-c",
|
||
`${imports}\nfrom pathlib import PurePosixPath
|
||
SEED_HOME = "/home/ada"\n${PYTHON_HELPERS}\nprint(_taskbar_window(sys.argv[1]))`,
|
||
windowClass,
|
||
],
|
||
{
|
||
encoding: "utf8",
|
||
env: { ...process.env, PATH: `${bin}:${process.env.PATH ?? ""}` },
|
||
},
|
||
).trim();
|
||
};
|
||
|
||
it("holds readiness until a window of the app's class that the taskbar lists is shown, not its splash", async () => {
|
||
const splash =
|
||
'WM_CLASS(STRING) = "varicad", "varicad"\n_NET_WM_STATE(ATOM) = _NET_WM_STATE_SKIP_PAGER, _NET_WM_STATE_SKIP_TASKBAR, _NET_WM_STATE_ABOVE';
|
||
const main =
|
||
'WM_CLASS(STRING) = "varicad", "varicad"\n_NET_WM_STATE(ATOM) = _NET_WM_STATE_MAXIMIZED_HORZ, _NET_WM_STATE_FOCUSED';
|
||
const other =
|
||
'WM_CLASS(STRING) = "mousepad", "Mousepad"\n_NET_WM_STATE(ATOM) = ';
|
||
|
||
expect(taskbarWindow(await fakeXprop({ "0x1": splash }), "varicad")).toBe(
|
||
"False",
|
||
);
|
||
expect(
|
||
taskbarWindow(await fakeXprop({ "0x1": splash, "0x2": main }), "varicad"),
|
||
).toBe("True");
|
||
expect(
|
||
taskbarWindow(await fakeXprop({ "0x1": splash, "0x3": other }), "varicad"),
|
||
).toBe("False");
|
||
expect(taskbarWindow(await fakeXprop({ "0x3": other }), "Mousepad")).toBe(
|
||
"True",
|
||
);
|
||
const typedSplash =
|
||
'WM_CLASS(STRING) = "varicad", "varicad"\n_NET_WM_STATE(ATOM) = \n_NET_WM_WINDOW_TYPE(ATOM) = _NET_WM_WINDOW_TYPE_SPLASH';
|
||
expect(
|
||
taskbarWindow(await fakeXprop({ "0x4": typedSplash }), "varicad"),
|
||
).toBe("False");
|
||
});
|
||
|
||
/** The exported module's JSON verdict for each text, with only its standard library imports: its view or its error. */
|
||
const pythonJson = (texts: readonly string[]) => {
|
||
const imports = PYTHON_IMPORTS.split("\n")
|
||
.filter((line) => !line.includes("agentenv_protocol"))
|
||
.join("\n");
|
||
const program = `${imports}${PYTHON_HELPERS}
|
||
for text in json.load(sys.stdin):
|
||
print(json.dumps(_text_view("a.json", text, len(text.encode()))))
|
||
`;
|
||
return execPython(["-I", "-B", "-c", program], {
|
||
input: JSON.stringify(texts),
|
||
encoding: "utf8",
|
||
})
|
||
.trim()
|
||
.split(/\r?\n/u)
|
||
.map((line) => JSON.parse(line) as { view: unknown; error?: string });
|
||
};
|
||
|
||
it("refuses the JSON numbers REA's state view refuses, as JSON with an error rather than an infinity", () => {
|
||
const texts = [
|
||
'{"a": 1e999}',
|
||
'{"a": -1e999}',
|
||
'{"a": 12345678901234567890}',
|
||
'{"a": -0}',
|
||
'{"a": -0.0}',
|
||
'{"a": 0.12345678901234567890}',
|
||
'{"a": 1e20}',
|
||
'{"a": 0.1, "b": 1e21, "c": 42, "d": 9007199254740991}',
|
||
'{"a": 9007199254740993, "a": 1}',
|
||
'{"a": 1, "a": 9007199254740993}',
|
||
];
|
||
|
||
const python = pythonJson(texts);
|
||
|
||
texts.forEach((text, index) => {
|
||
const typescript = jsonView(text);
|
||
const verdict = python[index];
|
||
expect(verdict?.error === undefined).toBe(typescript.ok);
|
||
if (typescript.ok) expect(verdict?.view).toEqual(typescript.value);
|
||
});
|
||
});
|
||
|
||
/** The exported module's format, view and error for each named text, and REA's for the same bytes. */
|
||
const textViews = (files: readonly (readonly [string, string])[]) => {
|
||
const imports = PYTHON_IMPORTS.split("\n")
|
||
.filter((line) => !line.includes("agentenv_protocol"))
|
||
.join("\n");
|
||
const program = `${imports}${PYTHON_HELPERS}
|
||
for name, text in json.load(sys.stdin):
|
||
view = _text_view(name, text, len(text.encode()))
|
||
print(json.dumps({"format": view["format"], "view": view["view"], "error": view.get("error")}))
|
||
`;
|
||
const python = execPython(["-I", "-B", "-c", program], {
|
||
input: JSON.stringify(files),
|
||
encoding: "utf8",
|
||
maxBuffer: 64 * 1024 * 1024,
|
||
})
|
||
.trim()
|
||
.split("\n")
|
||
.map((line) => JSON.parse(line) as unknown);
|
||
const rea = files.map(([name, text]) => {
|
||
const bytes = new TextEncoder().encode(text);
|
||
const format = detectStateFormat(name, bytes);
|
||
if (format !== "text" && format !== "json" && format !== "keyfile")
|
||
throw new Error(`${name} reads as ${format}`);
|
||
const read = textualView(format, bytes);
|
||
return read.ok
|
||
? { format, view: read.value, error: null }
|
||
: { format, view: null, error: read.error };
|
||
});
|
||
return { python, rea };
|
||
};
|
||
|
||
it("reads text nested past Python's recursion limit as JSON only when all of it is, as REA does", () => {
|
||
const brackets = (open: number, close: number) =>
|
||
`${"[".repeat(open)}${"]".repeat(close)}`;
|
||
const files = [
|
||
["deep.txt", brackets(1100, 1099)],
|
||
["deep.json", brackets(1100, 1099)],
|
||
["deep.txt", brackets(1100, 1100)],
|
||
["deep.json", `${brackets(1100, 1100)} x`],
|
||
] as const;
|
||
|
||
const { python, rea } = textViews(files);
|
||
|
||
expect(python).toEqual(rea);
|
||
expect(rea.map(({ format, error }) => [format, error])).toEqual([
|
||
["text", null],
|
||
["text", null],
|
||
["json", "nested deeper than the 500 levels a JSON view keeps"],
|
||
["text", null],
|
||
]);
|
||
});
|
||
|
||
it("refuses JSON and key files over 4 MiB as REA does, telling JSON apart from text without parsing it", () => {
|
||
const objects = `[{}${",{}".repeat(1_400_000)}]`;
|
||
const files = [
|
||
["objects.json", objects],
|
||
["objects", `${objects.slice(0, -1)}}`],
|
||
["settings", `[ui]\n${"wrap=false\n".repeat(400_000)}`],
|
||
["log", `[0] ${"x".repeat(4_200_000)}`],
|
||
] as const;
|
||
|
||
const { python, rea } = textViews(files);
|
||
|
||
expect(python).toEqual(rea);
|
||
const refused = "larger than the 4 MiB limit for JSON and key file views";
|
||
expect(rea.map(({ format, error }) => [format, error])).toEqual([
|
||
["json", refused],
|
||
["text", null],
|
||
["keyfile", refused],
|
||
["text", null],
|
||
]);
|
||
});
|
||
|
||
it("checks text for JSON the way REA does, token by token, without parsing it", () => {
|
||
const [bom, nbsp, lineSeparator] = [0xfeff, 0xa0, 0x2028].map((code) =>
|
||
String.fromCharCode(code),
|
||
);
|
||
const texts = [
|
||
["-0", "01", "1.", "1E+5", "-", "[1.5e+]", "truex", "[NaN]"],
|
||
['"\\u00e9"', '"\\u00g9"', '"\\x"', '"\t"', '"a', '"\\uD800"'],
|
||
["", " [1] ", "[1] x", "{}{}", `${bom}[1]`, `[1]${nbsp}`],
|
||
[`["${lineSeparator}"]`, "[]", "[1,]", "[,1]", "[1 2]", "[1]]", "[[1]"],
|
||
["[}", "{}", '{"a":1}', '{"a" 1}', "{a:1}", '{"a":1,}', '{"a"::1}'],
|
||
["{1:2}", '{"a":[1,{"b":null}],"c":-1.5e-3}'],
|
||
].flat();
|
||
const imports = PYTHON_IMPORTS.split("\n")
|
||
.filter((line) => !line.includes("agentenv_protocol"))
|
||
.join("\n");
|
||
|
||
const python = JSON.parse(
|
||
execPython(
|
||
[
|
||
"-I",
|
||
"-B",
|
||
"-c",
|
||
`${imports}${PYTHON_HELPERS}\ntexts = json.load(sys.stdin)\nprint(json.dumps({"syntax": [_read_json(text, build=False) is not _INVALID_JSON for text in texts], "built": [_read_json(text) is not _INVALID_JSON for text in texts]}))`,
|
||
],
|
||
{ input: JSON.stringify(texts), encoding: "utf8" },
|
||
),
|
||
) as { syntax: boolean[]; built: boolean[] };
|
||
|
||
const parsed = texts.map((text) => safeParseJson(text).ok);
|
||
expect(texts.map((text) => isJsonText(text))).toEqual(parsed);
|
||
expect(python.syntax).toEqual(parsed);
|
||
expect(python.built).toEqual(parsed);
|
||
expect(parsed.filter(Boolean)).toHaveLength(10);
|
||
});
|
||
|
||
it("checks JSON depth with one iterator per level, copying no wide list", () => {
|
||
const imports = PYTHON_IMPORTS.split("\n")
|
||
.filter((line) => !line.includes("agentenv_protocol"))
|
||
.join("\n");
|
||
const program = `${imports}
|
||
import tracemalloc
|
||
${PYTHON_HELPERS}
|
||
wide = [None] * 800_000
|
||
tracemalloc.start()
|
||
deep = _deeper_than(wide, 500)
|
||
peak = tracemalloc.get_traced_memory()[1]
|
||
tracemalloc.stop()
|
||
nested = []
|
||
for _ in range(501):
|
||
nested = [nested]
|
||
print(json.dumps({"deep": deep, "peak": peak, "edge": _deeper_than(nested, 501), "over": _deeper_than(nested, 500)}))
|
||
`;
|
||
|
||
const result = JSON.parse(
|
||
execPython(["-I", "-B", "-c", program], { encoding: "utf8" }),
|
||
) as { deep: boolean; peak: number; edge: boolean; over: boolean };
|
||
|
||
expect(result).toMatchObject({ deep: false, edge: false, over: true });
|
||
// Queuing each member of the 800,000-item list traced about 51 MB.
|
||
expect(result.peak).toBeLessThan(1024 * 1024);
|
||
});
|
||
|
||
it("spells infinite and negative-zero SQLite reals as REA's SQLite reader does", () => {
|
||
const imports = PYTHON_IMPORTS.split("\n")
|
||
.filter((line) => !line.includes("agentenv_protocol"))
|
||
.join("\n");
|
||
const cells = execPython(
|
||
[
|
||
"-I",
|
||
"-B",
|
||
"-c",
|
||
`${imports}${PYTHON_HELPERS}\nprint(json.dumps([_cell(v, "utf-8") for v in (math.inf, -math.inf, -0.0, 0.0, 1.5)]))`,
|
||
],
|
||
{ encoding: "utf8" },
|
||
);
|
||
|
||
expect(JSON.parse(cells)).toEqual(["Infinity", "-Infinity", "-0", 0, 1.5]);
|
||
});
|
||
|
||
it("keeps the same keyfile whitespace as the retained state view", async () => {
|
||
const inputs = [
|
||
"[ui]\nsuffix=hello \nunicode=\u00a0hello\u00a0\npadded= hello\n",
|
||
"[ui]\r\nsuffix=hello \r\nunicode=\u00a0hello\u00a0\u00a0\r\npadded=\thello\r\n",
|
||
];
|
||
const imports = PYTHON_IMPORTS.split("\n")
|
||
.filter((line) => !line.includes("agentenv_protocol"))
|
||
.join("\n");
|
||
const program = `${imports}${PYTHON_HELPERS}
|
||
print(json.dumps([_text_view("settings.ini", text, len(text.encode()))["view"]["groups"] for text in json.load(sys.stdin)]))
|
||
`;
|
||
const root = await createTestTempDirectory("rea-keyfile-python-");
|
||
const script = join(root, "fixture.py");
|
||
await writeFile(script, program, "utf8");
|
||
const output = execFileSync(
|
||
process.platform === "win32" ? "python" : "python3",
|
||
["-I", "-B", "-X", "utf8", script],
|
||
{
|
||
input: JSON.stringify(inputs),
|
||
encoding: "utf8",
|
||
},
|
||
);
|
||
expect(JSON.parse(output)).toEqual(inputs.map(keyfileView));
|
||
});
|