Skip to content

Commit 10a5d66

Browse files
committed
behathathat
1 parent cfb69e4 commit 10a5d66

1 file changed

Lines changed: 85 additions & 37 deletions

File tree

tests/Behat/XmlContext.php

Lines changed: 85 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -34,36 +34,26 @@ public function theXmlShouldBeEqualTo(PyStringNode $content): void
3434
$expectedXml = (string) $content;
3535
$actualXml = $this->getSession()->getPage()->getContent();
3636

37-
$decodeContext = [
38-
'remove_empty_tags' => false,
39-
];
40-
$expectedArr = $this->xmlEncoder->decode($expectedXml, 'xml', $decodeContext);
41-
$actualArr = $this->xmlEncoder->decode($actualXml, 'xml', $decodeContext);
42-
43-
// Optional normalization to avoid null/'' mismatches after decode
44-
$expectedArr = $this->normalizePhpFromXml($expectedArr);
45-
$actualArr = $this->normalizePhpFromXml($actualArr);
46-
47-
// 2) Re-encode with the SAME encoder & options to stabilize output shape
48-
$encodeContext = [
49-
'format_output' => false,
50-
'remove_empty_tags' => false,
51-
];
52-
$expectedStableXml = $this->xmlEncoder->encode($expectedArr, 'xml', $encodeContext);
53-
$actualStableXml = $this->xmlEncoder->encode($actualArr, 'xml', $encodeContext);
54-
55-
// 3) Canonicalize via DOM C14N and compare
56-
$expectedC14n = $this->canonicalizeXml($expectedStableXml);
57-
$actualC14n = $this->canonicalizeXml($actualStableXml);
37+
// (Optional) validate via XmlEncoder to keep using it in the pipeline
38+
$decodeContext = ['remove_empty_tags' => false];
39+
$this->xmlEncoder->decode($expectedXml, 'xml', $decodeContext);
40+
$this->xmlEncoder->decode($actualXml, 'xml', $decodeContext);
41+
42+
$expectedC14n = $this->canonicalizeXmlOrderInsensitive($expectedXml);
43+
$actualC14n = $this->canonicalizeXmlOrderInsensitive($actualXml);
5844

5945
$this->assertEquals(
6046
$expectedC14n,
6147
$actualC14n,
62-
"The XML is equal to:\n{$actualC14n}"
48+
"The XML is equal to (order-insensitive):\n{$actualC14n}"
6349
);
6450
}
6551

66-
private function canonicalizeXml(string $xml): string
52+
/**
53+
* Load XML, remove ignorable whitespace, sort children of every element
54+
* deterministically (by tag name, then by serialized attributes), then C14N.
55+
*/
56+
private function canonicalizeXmlOrderInsensitive(string $xml): string
6757
{
6858
$dom = new \DOMDocument('1.0', 'UTF-8');
6959
$dom->preserveWhiteSpace = false;
@@ -72,9 +62,14 @@ private function canonicalizeXml(string $xml): string
7262
if (!@$dom->loadXML($xml, $opts)) {
7363
throw new \RuntimeException("Invalid XML provided:\n".$xml);
7464
}
65+
66+
if ($dom->documentElement) {
67+
$this->sortElementChildrenRecursively($dom->documentElement);
68+
}
69+
7570
$dom->normalizeDocument();
7671

77-
$c14n = $dom->C14N(false, false);
72+
$c14n = $dom->C14N(false, false); // inclusive, no comments
7873
if (false !== $c14n) {
7974
return $c14n;
8075
}
@@ -85,28 +80,81 @@ private function canonicalizeXml(string $xml): string
8580
}
8681

8782
/**
88-
* Normalize values produced by XmlEncoder to avoid null/'' and list/dict drift.
83+
* Recursively sort only ELEMENT_NODE children by (localName, attributes string, inner text).
84+
* Keeps non-element nodes (text, cdata) in place; NOBLANKS removes ignorable whitespace.
8985
*/
90-
private function normalizePhpFromXml(mixed $value): mixed
86+
private function sortElementChildrenRecursively(\DOMElement $el): void
9187
{
92-
if (\is_array($value)) {
93-
// Sort keys for deterministic re-encode and normalize children
94-
$isList = array_keys($value) === range(0, \count($value) - 1);
95-
if (!$isList) {
96-
ksort($value);
88+
// Recurse first so deeper trees become stable
89+
for ($n = $el->firstChild; $n; $n = $n->nextSibling) {
90+
if ($n instanceof \DOMElement) {
91+
$this->sortElementChildrenRecursively($n);
92+
}
93+
}
94+
95+
// Collect element children
96+
$elements = [];
97+
$others = []; // text, comments, etc.
98+
for ($n = $el->firstChild; $n; $n = $n->nextSibling) {
99+
if ($n instanceof \DOMElement) {
100+
$elements[] = $n;
101+
} else {
102+
$others[] = $n;
103+
}
104+
}
105+
106+
if (\count($elements) <= 1) {
107+
return;
108+
}
109+
110+
// Stable sort: by tag localName, then serialized attributes, then textContent
111+
usort($elements, static function (\DOMElement $a, \DOMElement $b): int {
112+
$na = $a->localName ?? $a->nodeName;
113+
$nb = $b->localName ?? $b->nodeName;
114+
if ($na !== $nb) {
115+
return $na <=> $nb;
97116
}
98-
foreach ($value as $k => $v) {
99-
$value[$k] = $this->normalizePhpFromXml($v);
117+
118+
$aa = $a->attributes ? self::serializeAttributes($a) : '';
119+
$ab = $b->attributes ? self::serializeAttributes($b) : '';
120+
if ($aa !== $ab) {
121+
return $aa <=> $ab;
100122
}
101123

102-
return $value;
124+
// last tie-breaker: text content (trimmed)
125+
return trim($a->textContent) <=> trim($b->textContent);
126+
});
127+
128+
// Remove all children then re-append in deterministic order:
129+
// - First non-element nodes in original order
130+
// - Then sorted element nodes
131+
while ($el->firstChild) {
132+
$el->removeChild($el->firstChild);
133+
}
134+
foreach ($others as $n) {
135+
$el->appendChild($n);
103136
}
137+
foreach ($elements as $n) {
138+
$el->appendChild($n);
139+
}
140+
}
104141

105-
// Treat null vs empty string as equivalent for empty XML elements
106-
if (null === $value) {
142+
private static function serializeAttributes(\DOMElement $el): string
143+
{
144+
if (!$el->hasAttributes()) {
107145
return '';
108146
}
147+
$pairs = [];
148+
/** @var \DOMAttr $attr */
149+
foreach (iterator_to_array($el->attributes) as $attr) {
150+
$pairs[$attr->name] = $attr->value;
151+
}
152+
ksort($pairs);
153+
$out = [];
154+
foreach ($pairs as $k => $v) {
155+
$out[] = $k.'='.$v;
156+
}
109157

110-
return $value;
158+
return implode(';', $out);
111159
}
112160
}

0 commit comments

Comments
 (0)