Files
reverse-engineering-anything/tests/boundary/providers/desktopXmlTextPython.test.ts
T
0dcd0dfc20 feat(desktop): check text kept in attributes, name a record by a child's text, and count members (#9114)
* fix(desktop): keep a lone lock a document and drop one beside its file

A selected name such as notes.lck stays a document, so launch and the draft
still treat it as the caller's file. A lock beside a recorded sibling is left
out of the seed unless that lock path itself was selected.

* fix(desktop): require the added record and leave save times out of list checks

A record a selector names is checked itself, so its words have to be in that
record. When an unnamed record only repeats words the seed already has, a
list that holds a save time is checked by the text around that time. A later
save of the same records still passes, and a missing or rewritten record fails.

* fix(desktop): judge the older index helper by pointer segments only

A leading zero in a file name or a URL does not change how the older index
helper reads a pointer. The witness still rejects that helper when a pointer
segment is a number such as 01.

* fix(desktop): keep a document whose name ends in .lock

A lock beside a recorded file still stays out of the seed unless that lock
was selected. A document such as Cargo.lock in the home is the user's file,
so the seed keeps it. An application lock in the home, such as poedit.lock,
stays out.

* fix(desktop): do not suggest list text the seed already passes

A repeated record is shown by text the seed does not hold. The seed's own
text is not a check. A pointer inside a check value is not a pointer segment
for the older index helper.

* fix(desktop): do not let a left-out lock hide settings checks

A sibling lock the seed omits is not the document the draft follows. Settings
changes stay suggested. The unused sibling-lock name helper is removed.

* fix(desktop): keep a linked settings file in the folder the link points at

A selected ~/.config alias no longer moves MuseScore settings to that alias.
The seed keeps settings/MuseScore or Documents, which is the path the export
opens the note from.

* fix(desktop): split home fetches by answer size and keep a size refusal

A batch of tiny files stops before each path's envelope exceeds the host
answer budget. A capture output past that ceiling stays a resource error
instead of becoming a generic exit.

* fix(desktop): seed a selected link under the path the app uses

The selected spelling wins, so ~/.config/MuseScore is not rewritten to the
folder it points at and the note stays the one launched document. A database
wal and journal follow that database. A launch argument is respelled the same
way. Teardown signals a detached PID only when its start identity still matches.

* fix(desktop): keep a witness's actions and an incomplete checkpoint

A failed witness save retries the action log it first recorded. A deletion
left unconfirmed between the seed and the end is not reported as no change.
Windows packs npm through a shell, which can start npm.cmd.

* fix(desktop): read verifier pointers from the parsed checks

A brace inside a check value is not an object boundary. A leading-zero
pointer after that value still rejects the older index helper.

* fix(desktop): leave out a sibling lock under a selected folder

A file under a selected folder inherits selected_path. That does not make
the lock beside its map a chosen document, so a settings change stays drafted.

* fix(desktop): prefer the most specific home spelling and retarget launch files

A selected parent directory no longer hides a settings folder walked through
a link. A launch filename under that folder is replaced with the seeded
file's absolute path.

* fix(desktop): pack the build runtime through npm's JavaScript entry

npm pack and npm ci no longer spawn npm.cmd through a shell, so a temporary
directory whose path contains spaces stays one argument.

* fix(desktop): spell launch settings and keep a database log exact

A database log matches only that file, so a neighboring folder is not moved.
A launch option and the working directory use the same home spelling as the seed.

* fix(desktop): draft a lock the export selected exactly

The draft uses the same selected paths as the export. A lock chosen by
itself stays in the draft. A lock under a selected folder stays out.

* fix(desktop): move a state database's logs with the database

The write-ahead log and journal follow the spelling the database received,
including a walked state link. A neighboring folder stays put. A separate
--config argument uses that spelling too.

* fix(desktop): keep a selected file ahead of a database-log guess

A file selected as itself keeps that spelling even when its name ends in
-journal. Relative launch arguments are resolved in the captured working
directory before that directory moves.

* fix(desktop): keep outside-home database logs and option values

A selected database outside the home keeps its write-ahead log. Launch
options are rewritten only when they name a captured file or settings path.

* 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.

* 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): 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): 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 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): 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

* 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

* 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

* 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

* 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(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): 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>
2026-11-09 22:53:18 +08:00

280 lines
9.2 KiB
TypeScript

import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { describe, expect, it } from "vitest";
import type { DesktopTaskCheck } from "../../../src/domain/desktop/appTask.js";
import { xmlView } from "../../../src/domain/desktop/stateFormats.js";
import { elementText } from "../../../src/domain/desktop/viewPointer.js";
import type { JsonValue } from "../../../src/domain/jsonValue.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import {
expectSameCheckResults,
expectSameFileViews,
odtOf,
pythonXmlViews,
savedEnds,
typescriptFileView,
} from "../../support/desktopExportPython.js";
/** The meeting note as AbiWord saves it, before and after its time was changed. */
const notes = () => savedEnds("abiword", "meeting.abw");
/** Writer's flyer before and after an edit split its body paragraph into runs, zipped with its mimetype. */
const flyers = () =>
savedEnds("libreoffice-runs", "content.xml", "flyer.odt", odtOf);
const NOTE = "Documents/meeting.abw";
const PARAGRAPH = "/root/children/[name=section]/children/[name=p][@xid=3]";
const MEETING = "The team meeting starts at 3:30 in room 4.";
/** The paragraph's whole text, a time within it, and its first text node, which the edit split. */
const NOTE_CHECKS: DesktopTaskCheck[] = [
{
id: "time",
kind: "text_equals",
file: NOTE,
pointer: PARAGRAPH,
value: MEETING,
},
{
id: "within",
kind: "text_contains",
file: NOTE,
pointer: PARAGRAPH,
value: "at 3:30 in",
},
{
id: "first-node",
kind: "value_equals",
file: NOTE,
pointer: `${PARAGRAPH}/children/0`,
value: MEETING,
},
];
const FLYER = "Documents/flyer.odt";
const BODY =
"/entries/[name=content.xml]/view/root/children/[name=office:body]/children/[name=office:text]/children/[name=text:p][@text:style-name=Standard]";
const CAKES = "Cakes, biscuits and bread from 9:30 in the village green.";
const FLYER_CHECKS: DesktopTaskCheck[] = [
{ id: "body", kind: "text_equals", file: FLYER, pointer: BODY, value: CAKES },
{
id: "time",
kind: "text_contains",
file: FLYER,
pointer: BODY,
value: " 9:30\n",
normalize_whitespace: true,
},
{
id: "reflowed",
kind: "text_equals",
file: FLYER,
pointer: BODY,
value: " Cakes, biscuits and bread\n from 9:30 in the village green. ",
normalize_whitespace: true,
},
{
id: "first-run",
kind: "value_equals",
file: FLYER,
pointer: `${BODY}/children/0`,
value: CAKES,
},
];
const passes = (outcomes: readonly { passed: boolean }[] | undefined) =>
outcomes?.map(({ passed }) => passed);
describe("XML with a DOCTYPE in the exported module", () => {
it("reads an AbiWord document, which names its DTD, as REA does: the element tree and the declaration", async () => {
const files = await notes();
await expectSameFileViews(files);
const [, after] = files;
if (after === undefined) throw new Error("fixture");
const { format, view } = await typescriptFileView(after);
expect(format).toBe("xml");
expect(view).toMatchObject({
doctype: {
name: "abiword",
public_id: "-//ABISOURCE//DTD AWML 1.0 Strict//EN",
system_id: "http://www.abisource.com/awml.dtd",
},
root: { name: "abiword", attributes: { "xml:space": "preserve" } },
truncated: false,
});
});
it("reads a DOCTYPE that only names a DTD alike, and refuses an internal subset or an undeclared entity in both", () => {
const accepted = [
"<!DOCTYPE a><a/>",
'<?xml version="1.0"?><!--c--><!DOCTYPE b SYSTEM "x[1].dtd"><a>t</a>',
"<!DOCTYPE a PUBLIC ' -//A\r\n B//EN ' 's y'><a/>",
'<!DOCTYPE a PUBLIC "" ""><a b="&lt;&#65;">&amp;&quot;&apos;&gt;</a>',
'<!DOCTYPE svg:svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg:svg xmlns:svg="http://www.w3.org/2000/svg"/>',
'<!DOCTYPE a SYSTEM "x\r\ny\rz\n"><a/>',
];
const refused = [
"<!DOCTYPE a []><a/>",
'<!DOCTYPE a [<!ENTITY e "boom">]><a/>',
'<!DOCTYPE a [<!ENTITY e "boom">]><a>&e;</a>',
'<!DOCTYPE a SYSTEM "a.dtd" [<!ENTITY e SYSTEM "file:///etc/hostname">]><a>&e;</a>',
'<!DOCTYPE a [<!ENTITY % p SYSTEM "file:///etc/hostname"> %p;]><a/>',
'<!DOCTYPE a SYSTEM "a.dtd"><a>&e;</a>',
'<!DOCTYPE a SYSTEM "a.dtd"><a><b c="x &e; y"/></a>',
'<!DOCTYPE a PUBLIC "-//A//EN"><a/>',
'<!DOCTYPE a PUBLIC "x{" "y"><a/>',
"<a/><!DOCTYPE a>",
"<!DOCTYPE a><!DOCTYPE a><a/>",
];
const texts = [...accepted, ...refused];
const ours = texts.map((text) => xmlView(text) ?? null);
expect(pythonXmlViews(texts)).toEqual(ours);
expect(ours.slice(0, accepted.length)).not.toContain(null);
expect(ours.slice(accepted.length)).toEqual(refused.map(() => null));
expect(ours[2]).toMatchObject({
doctype: { name: "a", public_id: "-//A B//EN", system_id: "s y" },
});
expect(ours[5]).toMatchObject({
doctype: { name: "a", public_id: null, system_id: "x\ny\nz\n" },
});
});
it("never reads the DTD a DOCTYPE names: neither its entities nor its default attributes apply", async () => {
const folder = await createTestTempDirectory("desktop-xml-dtd-");
const dtd = join(folder, "a.dtd");
await writeFile(
dtd,
'<!ENTITY e "from the DTD"><!ATTLIST a injected CDATA "from the DTD">',
);
const system = pathToFileURL(dtd).href;
const texts = [
`<!DOCTYPE a SYSTEM "${system}"><a/>`,
`<!DOCTYPE a SYSTEM "${system}"><a>&e;</a>`,
`<!DOCTYPE a SYSTEM "${system}"><a b="&e;"/>`,
];
const ours = texts.map((text) => xmlView(text) ?? null);
expect(pythonXmlViews(texts)).toEqual(ours);
expect(ours).toEqual([
{
doctype: { name: "a", public_id: null, system_id: system },
root: { name: "a", attributes: {}, children: [] },
truncated: false,
},
null,
null,
]);
});
});
describe("whole-text checks in both evaluators", () => {
it("checks the meeting time across AbiWord's runs: the whole text fails before and passes after, its first node does not", async () => {
const [before, after] = await expectSameCheckResults(
NOTE,
NOTE_CHECKS,
await notes(),
);
expect(passes(before)).toEqual([false, false, false]);
for (const fact of [
PARAGRAPH,
JSON.stringify("The team meeting starts at 2:00 in room 4."),
])
expect(before?.[0]?.description).toContain(fact);
expect(passes(after)).toEqual([true, true, false]);
// The first text node holds only the text before the first run.
expect(after?.[2]?.description).toContain(
JSON.stringify("The team meeting starts at "),
);
});
it("reads Writer's runs in its archive alike, the space between two runs included", async () => {
const files = await flyers();
await expectSameFileViews(files);
const [before, after] = await expectSameCheckResults(
FLYER,
FLYER_CHECKS,
files,
);
expect(passes(before)).toEqual([false, false, false, false]);
expect(passes(after)).toEqual([true, true, true, false]);
// The value searched for is the collapsed one, " 9:30\n" read as "9:30".
expect(after?.[1]?.description).toContain(JSON.stringify("9:30"));
});
it("leaves an element's text undecided where the readers dropped text from between its runs, and its attribute decided", async () => {
const deep = "Documents/deep.xml";
/** A paragraph of `before`, then `dropped` nested past the 64 levels both readers keep, then `after`. */
const paragraph = (before: string, dropped: string, after: string) => ({
name: "deep.xml",
bytes: new TextEncoder().encode(
`<p id="t">${before}${"<b>".repeat(65)}${dropped}${"</b>".repeat(65)}${after}</p>`,
),
});
const files = [
paragraph("at 3", "WRONG", ":30 PM"),
paragraph("alpha", "zzz", "beta"),
];
await expectSameFileViews(files);
// The kept runs join into text neither file has.
const kept = await Promise.all(
files.map(async (file) => {
const { view } = await typescriptFileView(file);
return elementText((view as { root: JsonValue }).root);
}),
);
expect(kept).toEqual(["at 3:30 PM", "alphabeta"]);
const results = await expectSameCheckResults(
deep,
[
{
id: "time",
kind: "text_contains",
file: deep,
pointer: "/root",
value: "at 3:30 PM",
},
{
id: "word",
kind: "text_contains",
file: deep,
pointer: "/root",
value: "alphabeta",
},
{
id: "id",
kind: "text_equals",
file: deep,
pointer: "/root/attributes/id",
value: "t",
},
],
files,
);
expect(results).toHaveLength(files.length);
for (const outcomes of results) {
expect(
outcomes.map(({ id, passed, undecided }) => [id, passed, undecided]),
).toEqual([
["time", false, true],
["word", false, true],
["id", true, undefined],
]);
for (const { description } of outcomes)
expect(description).toContain(deep);
}
});
});