@@ -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