From d77442228d769ba20d7006bf4befea3a4c2f06d9 Mon Sep 17 00:00:00 2001 From: hhang <17309584214@163.com> Date: Fri, 6 Nov 2026 14:52:32 +0800 Subject: [PATCH] fix(desktop): align exported XML formatting whitespace (#9020) * fix(desktop): align exported XML formatting whitespace * test(process): align symlink comparison snapshots * test(boundary): align inherited CLI and MCP fixtures --------- Co-authored-by: hhang <169027046+xu91102@users.noreply.github.com> --- docs/desktop-application-state.md | 3 + src/domain/desktop/appExportPythonXml.ts | 5 +- .../desktopXmlWhitespacePython.test.ts | 59 +++++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 tests/boundary/providers/desktopXmlWhitespacePython.test.ts diff --git a/docs/desktop-application-state.md b/docs/desktop-application-state.md index 605d43c728..7ab24697a0 100644 --- a/docs/desktop-application-state.md +++ b/docs/desktop-application-state.md @@ -86,6 +86,9 @@ element that holds elements is dropped unless `xml:space="preserve"` applies. The view keeps up to 5,000 nodes, comments and processing instructions counted, nested up to 64 levels, and sets `truncated` when it leaves some out. +Exported XML views use REA's same formatting-whitespace classification, so +retained XML text and the task checks that address it stay aligned. + SQLite databases are read through REA's SQLite worker from a private copy, with any retained `-wal` or `-journal` file staged beside it; the worker accepts an inactive rollback journal and refuses a hot one. A version is not diff --git a/src/domain/desktop/appExportPythonXml.ts b/src/domain/desktop/appExportPythonXml.ts index fadd5d40d3..30c7571c48 100644 --- a/src/domain/desktop/appExportPythonXml.ts +++ b/src/domain/desktop/appExportPythonXml.ts @@ -15,6 +15,9 @@ class _XmlReader: """REA's XML view built as expat reads: names as spelled, declarations as attributes, text split where REA's DOM splits it (an empty CDATA section joins the text around it), and each node counted against REA's limits.""" + #: Match REA String.trim(); default strip also drops U+0085 and keeps U+FEFF. + FORMATTING_SPACE = "\t\n\v\f\r \u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff" + def __init__(self) -> None: self.remaining, self.truncated, self.root = MAX_XML_NODES, False, None #: Per open element: its view or None when left out, whether xml:space="preserve" applies inside it, and @@ -83,7 +86,7 @@ class _XmlReader: self._flush() view, preserve, holds_element = self.open.pop() if view is not None and holds_element and not preserve: - view["children"] = [child for child in view["children"] if not isinstance(child, str) or child.strip()] + view["children"] = [child for child in view["children"] if not isinstance(child, str) or child.strip(self.FORMATTING_SPACE)] if not self.open: self.root = view diff --git a/tests/boundary/providers/desktopXmlWhitespacePython.test.ts b/tests/boundary/providers/desktopXmlWhitespacePython.test.ts new file mode 100644 index 0000000000..6ea9560acd --- /dev/null +++ b/tests/boundary/providers/desktopXmlWhitespacePython.test.ts @@ -0,0 +1,59 @@ +import { execFileSync } from "node:child_process"; +import { expect, it } from "vitest"; +import { + PYTHON_HELPERS, + PYTHON_IMPORTS, +} from "../../../src/domain/desktop/appExportPython.js"; +import { xmlView } from "../../../src/domain/desktop/stateFormats.js"; + +/** Execute the unmodified generated XML reader with its standard-library imports. */ +const pythonViews = (texts: readonly string[]) => { + const imports = PYTHON_IMPORTS.split("\n") + .filter((line) => !line.includes("agentenv_protocol")) + .join("\n"); + return JSON.parse( + execFileSync( + "python3", + [ + "-I", + "-B", + "-c", + `${imports}${PYTHON_HELPERS}\nprint(json.dumps([_xml_view(text) for text in json.load(sys.stdin)]))`, + ], + { input: JSON.stringify(texts), encoding: "utf8" }, + ), + ); +}; + +it.each(["\u0085", "\ufeff"])( + "matches REA's formatting decision for %j between elements", + (text) => { + const xml = `${text}`; + expect(pythonViews([xml])).toEqual([xmlView(xml)]); + }, +); + +it("matches the ECMAScript whitespace set for XML-valid characters and preserves other text", () => { + const characters = + "\t\n\r \u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff\u0085\u180e\u200b"; + const texts = [...characters].map((text) => `${text}`); + expect(pythonViews(texts)).toEqual(texts.map(xmlView)); +}); + +it("keeps leaf text and xml:space-preserved text, with the original parsing refusals", () => { + const texts = [ + "\ufeff", + "\u0085", + '\ufeff', + '\u0085', + ]; + const actual = pythonViews(texts) as { root: { children: unknown[] } }[]; + const expected = texts.map(xmlView) as { root: { children: unknown[] } }[]; + expect(actual.map((view) => view.root.children)).toEqual( + expected.map((view) => view.root.children), + ); + expect(pythonViews(["", ""])).toEqual([ + null, + null, + ]); +});