1<?php
2
3namespace MediaWiki\Tests\Maintenance;
4
5use PHPUnit\Framework\Assert;
6use XMLReader;
7
8/**
9 * Helper for asserting the structure of an XML dump stream.
10 */
11class DumpAsserter {
12
13	/**
14	 * Holds the XMLReader used for analyzing an XML dump
15	 *
16	 * @var XMLReader|null
17	 */
18	protected $xml = null;
19
20	/**
21	 * XML dump schema version
22	 *
23	 * @var string
24	 */
25	protected $schemaVersion;
26
27	/**
28	 * @var array
29	 */
30	private $varMapping = [];
31
32	/**
33	 * @param string $schemaVersion see XML_DUMP_SCHEMA_VERSION_XX
34	 */
35	public function __construct( $schemaVersion ) {
36		$this->schemaVersion = $schemaVersion;
37	}
38
39	/**
40	 * Step the current XML reader until node start of given name is found.
41	 *
42	 * @param string $name Name of the element to look for
43	 *   (e.g.: "text" when looking for <text>)
44	 *
45	 * @param bool $allowAscend Whether the search should continue in parent
46	 *   nodes of the current position. If false (the default), the search will be aborted
47	 *   on the next closing element.
48	 *
49	 * @return bool True if the node could be found. false otherwise.
50	 */
51	public function skipToNode( $name, $allowAscend = false ) {
52		$depth = 0;
53		while ( true ) {
54			$current = $this->xml->name;
55			if ( $this->xml->nodeType == XMLReader::ELEMENT ) {
56				if ( $current == $name ) {
57					return true;
58				}
59
60				if ( !$this->xml->isEmptyElement ) {
61					$depth++;
62				}
63			}
64
65			if ( $this->xml->nodeType == XMLReader::END_ELEMENT ) {
66				$depth--;
67				if ( $depth < 0 && !$allowAscend )
68				return false;
69			}
70
71			if ( !$this->xml->read() ) {
72				break;
73			}
74		}
75
76		return false;
77	}
78
79	/**
80	 * Step the current XML reader until node start of given name is found,
81	 * and advance to the first child node.
82	 *
83	 * @param string $name Name of the element to look for
84	 *   (e.g.: "text" when looking for <text>)
85	 *
86	 * @param bool $allowAscend Whether the search should continue in parent
87	 *   nodes of the current position. If false (the default), the search will be aborted
88	 *   on the next closing element.
89	 */
90	public function skipIntoNode( $name, $allowAscend = false ) {
91		Assert::assertTrue( $this->skipToNode( $name, $allowAscend ),
92			"Skipping to $name" );
93
94		Assert::assertTrue( !$this->xml->isEmptyElement,
95			"Skipping into $name" );
96
97		$this->xml->read();
98	}
99
100	/**
101	 * Step the current XML reader until node end of given name is found.
102	 *
103	 * @param string $name Name of the closing element to look for
104	 *   (e.g.: "mediawiki" when looking for </mediawiki>)
105	 *
106	 * @return bool True if the end node could be found. false otherwise.
107	 */
108	public function skipToNodeEnd( $name ) {
109		while ( $this->xml->read() ) {
110			if ( $this->xml->nodeType == XMLReader::END_ELEMENT &&
111				$this->xml->name == $name
112			) {
113				return true;
114			}
115		}
116
117		return false;
118	}
119
120	/**
121	 * Step the current XML reader to the first element start after the node
122	 * end of a given name.
123	 *
124	 * @param string $name Name of the closing element to look for
125	 *   (e.g.: "mediawiki" when looking for </mediawiki>)
126	 *
127	 * @return bool True if new element after the closing of $name could be
128	 *   found. false otherwise.
129	 */
130	public function skipPastNodeEnd( $name ) {
131		Assert::assertTrue( $this->skipToNodeEnd( $name ),
132			"Skipping to end of $name" );
133		while ( $this->xml->read() ) {
134			if ( $this->xml->nodeType == XMLReader::ELEMENT ) {
135				return true;
136			}
137		}
138
139		return false;
140	}
141
142	/**
143	 * Opens an XML file to analyze.
144	 *
145	 * @param string $fname Name of file to analyze
146	 */
147	public function open( $fname ) {
148		$this->xml = new XMLReader();
149
150		Assert::assertTrue( $this->xml->open( $fname ),
151			"Opening temporary file $fname via XMLReader failed" );
152	}
153
154	/**
155	 * Opens an XML file to analyze, verifies the top level tags,
156	 * and skips past <siteinfo>.
157	 *
158	 * The contents of the <siteinfo> tag can be checked if $siteInfoTemplate
159	 * is given. See assertDumpHead().
160	 *
161	 * @param string $fname Name of file to analyze
162	 *
163	 * @param string|null $siteInfoTemplate
164	 * @param string $language
165	 */
166	public function assertDumpStart( $fname, $siteInfoTemplate = null, $language = 'en' ) {
167		$this->open( $fname );
168		$this->assertDumpHead( $siteInfoTemplate, $language );
169	}
170
171	/**
172	 * Asserts that the head of a dump is valid.
173	 * This checks the attributes of the top level <mediawiki> tag.
174	 *
175	 * If $siteInfoTemplate is given, it is interpreted as the file name
176	 * of an XML template that will be used with assertDOM() to check the contents
177	 * of the <siteinfo> tag, which is expected to be the first child of
178	 * the top level <mediawiki>. Variable substitution applies as defined by
179	 * calling setVarMapping().
180	 *
181	 * After this method returns, the XML reader's position will be after
182	 * the closing </siteinfo> tag, before the next tag.
183	 *
184	 * @param string|null $siteInfoTemplate
185	 * @param string $language
186	 */
187	public function assertDumpHead( $siteInfoTemplate = null, $language = 'en' ) {
188		$this->assertNodeStart( 'mediawiki', false );
189		$this->assertAttributes( [
190			"xmlns" => "http://www.mediawiki.org/xml/export-{$this->schemaVersion}/",
191			"xmlns:xsi" => "http://www.w3.org/2001/XMLSchema-instance",
192			"xsi:schemaLocation" => "http://www.mediawiki.org/xml/export-{$this->schemaVersion}/ "
193				. "http://www.mediawiki.org/xml/export-{$this->schemaVersion}.xsd",
194			"version" => "{$this->schemaVersion}",
195			"xml:lang" => "{$language}"
196		] );
197
198		$this->assertNodeStart( 'siteinfo', false );
199
200		if ( $siteInfoTemplate ) {
201			// Checking site info
202			$this->assertDOM( $siteInfoTemplate );
203		}
204
205		// skip past extra namespaces
206		$this->skipPastNodeEnd( 'siteinfo' );
207	}
208
209	/**
210	 * Asserts that the xml reader is at the final closing tag of an xml file and
211	 * closes the reader.
212	 *
213	 * @param string $name (optional) the name of the final tag
214	 *   (e.g.: "mediawiki" for </mediawiki>)
215	 */
216	public function assertDumpEnd( $name = "mediawiki" ) {
217		$this->assertNodeEnd( $name, false );
218		if ( $this->xml->read() ) {
219			$this->skipWhitespace();
220		}
221		Assert::assertEquals( $this->xml->nodeType, XMLReader::NONE,
222			"No proper entity left to parse" );
223		$this->close();
224	}
225
226	public function close() {
227		$this->xml->close();
228	}
229
230	/**
231	 * Steps the xml reader over white space
232	 */
233	public function skipWhitespace() {
234		$cont = true;
235		while ( $cont && ( ( $this->xml->nodeType == XMLReader::NONE )
236			|| ( $this->xml->nodeType == XMLReader::WHITESPACE )
237			|| ( $this->xml->nodeType == XMLReader::SIGNIFICANT_WHITESPACE ) ) ) {
238			$cont = $this->xml->read();
239		}
240	}
241
242	/**
243	 * Asserts that the xml reader is at an element of given name, and optionally
244	 * skips past it. If the reader is at a whitespace element, the whitespace is
245	 * skipped first.
246	 *
247	 * @param string $name The name of the element to check for
248	 *   (e.g.: "mediawiki" for <mediawiki>)
249	 * @param bool $skip (optional) if true, skip past the found element
250	 */
251	public function assertNodeStart( $name, $skip = true ) {
252		$this->skipWhitespace();
253		Assert::assertEquals( $name, $this->xml->name, "Node name" );
254		Assert::assertEquals( XMLReader::ELEMENT, $this->xml->nodeType, "Node type" );
255		if ( $skip ) {
256			Assert::assertTrue( $this->xml->read(), "Skipping past start tag" );
257		}
258	}
259
260	/**
261	 * Asserts that the XML reader is at an element start, and that the element
262	 * has the given attributes with the given values.
263	 * Variable substitution applies for variables set via setVarMapping().
264	 *
265	 * @param array $attributes
266	 * @param bool $skip (optional) if true, skip past the found element
267	 */
268	public function assertAttributes( $attributes, $skip = true ) {
269		Assert::assertEquals( XMLReader::ELEMENT, $this->xml->nodeType, "Node type" );
270		$actualAttributes = $this->getAttributeArray( $this->xml );
271
272		$attributes = array_map(
273			function ( $v ) {
274				return $this->resolveVars( $v );
275			},
276			$attributes
277		);
278		$actualAttributes = array_intersect_key( $actualAttributes, $attributes );
279
280		Assert::assertEquals( $attributes, $actualAttributes, "Attributes" );
281
282		if ( $skip ) {
283			Assert::assertTrue( $this->xml->read(), "Skipping past start tag" );
284		}
285	}
286
287	/**
288	 * Asserts that the xml reader is at an element of given name, and that element
289	 * is an empty tag.
290	 *
291	 * @param string $name The name of the element to check for
292	 *   (e.g.: "text" for <text/>)
293	 * @param bool $skip (optional) if true, skip past the found element
294	 * @param bool $skip_ws (optional) if true, also skip past white spaces that trail the
295	 *   closing element.
296	 */
297	public function assertEmptyNode( $name, $skip = true, $skip_ws = true ) {
298		$this->assertNodeStart( $name, false );
299		Assert::assertFalse( !$this->xml->isEmptyElement, "$name tag has content" );
300
301		if ( $skip ) {
302			Assert::assertTrue( $this->xml->read(), "Skipping $name tag" );
303			if ( ( $this->xml->nodeType == XMLReader::END_ELEMENT )
304				&& ( $this->xml->name == $name )
305			) {
306				$this->xml->read();
307			}
308
309			if ( $skip_ws ) {
310				$this->skipWhitespace();
311			}
312		}
313	}
314
315	/**
316	 * Asserts that the xml reader is at an closing element of given name, and optionally
317	 * skips past it. If the reader is at a whitespace element, the whitespace is
318	 * skipped first.
319	 *
320	 * @param string $name The name of the closing element to check for
321	 *   (e.g.: "mediawiki" for </mediawiki>)
322	 * @param bool $skip (optional) if true, skip past the found element
323	 */
324	public function assertNodeEnd( $name, $skip = true ) {
325		$this->skipWhitespace();
326		Assert::assertEquals( $name, $this->xml->name, "Node name" );
327		Assert::assertEquals( XMLReader::END_ELEMENT, $this->xml->nodeType, "Node type" );
328		if ( $skip ) {
329			// note: if there is no more content after the tag and read() returns false,
330			// that's fine.
331			$this->xml->read();
332		}
333	}
334
335	/**
336	 * Asserts that the xml reader is at an element of given tag that contains a given text,
337	 * and skips over the element.
338	 *
339	 * @param string $name The name of the element to check for
340	 *   (e.g.: "mediawiki" for <mediawiki>...</mediawiki>)
341	 * @param string|bool $text If string, check if it equals the elements text.
342	 *   Variable substitution applies. If false, ignore the element's text.
343	 * @param bool $skip_ws (optional) if true, skip past white spaces that trail the
344	 *   closing element.
345	 */
346	public function assertTextNode( $name, $text, $skip_ws = true ) {
347		$this->assertNodeStart( $name );
348
349		if ( $text !== false ) {
350			$text = $this->resolveVars( $text );
351			$actual = $this->resolveVars( $this->xml->value );
352			Assert::assertEquals( $text, $actual, "Text of node " . $name );
353		}
354		Assert::assertTrue( $this->xml->read(), "Skipping past processed text of " . $name );
355		$this->assertNodeEnd( $name );
356
357		if ( $skip_ws ) {
358			$this->skipWhitespace();
359		}
360	}
361
362	/**
363	 * Asserts that the xml reader is at the start of a page element and skips over the first
364	 * tags, after checking them.
365	 *
366	 * Besides the opening page element, this function also checks for and skips over the
367	 * title, ns, and id tags. Hence after this function, the xml reader is at the first
368	 * revision of the current page.
369	 *
370	 * @param int $id Id of the page to assert
371	 * @param int $ns Number of namespage to assert
372	 * @param string $name Title of the current page
373	 */
374	public function assertPageStart( $id, $ns, $name ) {
375		$this->assertNodeStart( "page" );
376		$this->assertTextNode( "title", $name );
377		$this->assertTextNode( "ns", $ns );
378		$this->assertTextNode( "id", $id );
379	}
380
381	/**
382	 * Asserts that the xml reader is at the page's closing element and skips to the next
383	 * element.
384	 */
385	public function assertPageEnd() {
386		$this->assertNodeEnd( "page" );
387	}
388
389	/**
390	 * Checks and skips tags that represent the properties of a revision.
391	 *
392	 * @param int $id Id of the revision
393	 * @param string $summary Summary of the revision
394	 * @param string $text_sha1 The base36 SHA-1 of the revision's text
395	 * @param string $hasEarlyText Whether a text tag is expected before the <sha1> tag.
396	 *               Must be one of 'yes', 'no', or maybe.
397	 * @param int|bool $parentid (optional) id of the parent revision
398	 * @param string $model The expected content model id (default: CONTENT_MODEL_WIKITEXT)
399	 * @param string $format The expected format model id (default: CONTENT_FORMAT_WIKITEXT)
400	 * @param bool &$foundText Output, whether a text tag was found before the SHA1 tag.
401	 *        If this returns false, the text tag should be the next tag after the method returns.
402	 */
403	public function assertRevisionProperties( $id, $summary,
404		$text_sha1, $hasEarlyText = 'maybe', $parentid = false,
405		$model = CONTENT_MODEL_WIKITEXT, $format = CONTENT_FORMAT_WIKITEXT,
406		&$foundText = ''
407	) {
408		$this->assertTextNode( "id", $id );
409		if ( $parentid !== false ) {
410			$this->assertTextNode( "parentid", $parentid );
411		}
412		$this->assertTextNode( "timestamp", false );
413
414		$this->assertNodeStart( "contributor" );
415		$this->assertTextNode( "username", false );
416		$this->assertTextNode( "id", false );
417		$this->assertNodeEnd( "contributor" );
418
419		$this->assertTextNode( "comment", $summary );
420
421		if ( $this->schemaVersion >= XML_DUMP_SCHEMA_VERSION_11 ) {
422			$this->assertTextNode( "origin", false );
423		}
424
425		$this->assertTextNode( "model", $model );
426
427		$this->assertTextNode( "format", $format );
428
429		if ( $hasEarlyText === 'yes' || ( $this->xml->name == "text" && $hasEarlyText === 'maybe' ) ) {
430			$foundText = true;
431			$this->assertNodeStart( "text", false );
432			$this->xml->next();
433			$this->skipWhitespace();
434		} else {
435			$foundText = false;
436		}
437
438		if ( $text_sha1 ) {
439			$this->assertTextNode( "sha1", $text_sha1 );
440		} else {
441			$this->assertEmptyNode( "sha1" );
442		}
443	}
444
445	/**
446	 * Asserts that the xml reader is at a revision and checks its representation before
447	 * skipping over it.
448	 *
449	 * @param int $id Id of the revision
450	 * @param string $summary Summary of the revision
451	 * @param int $text_id Id of the revision's text
452	 * @param int $text_bytes Number of bytes in the revision's text
453	 * @param string $text_sha1 The base36 SHA-1 of the revision's text
454	 * @param string|bool $text (optional) The revision's string, or false to check for a
455	 *            revision stub
456	 * @param int|bool $parentid (optional) id of the parent revision
457	 * @param string $model The expected content model id (default: CONTENT_MODEL_WIKITEXT)
458	 * @param string $format The expected format model id (default: CONTENT_FORMAT_WIKITEXT)
459	 */
460	public function assertRevision( $id, $summary, $text_id, $text_bytes,
461		$text_sha1, $text = false, $parentid = false,
462		$model = CONTENT_MODEL_WIKITEXT, $format = CONTENT_FORMAT_WIKITEXT
463	) {
464		$this->assertNodeStart( "revision" );
465
466		$this->assertRevisionProperties(
467			$id,
468			$summary,
469			$text_sha1,
470			'maybe',
471			$parentid,
472			$model,
473			$format,
474			$text_found
475		);
476
477		if ( !$text_found ) {
478			$this->assertText( $id, $text_id, $text_bytes, $text );
479		}
480
481		$this->assertNodeEnd( "revision" );
482		$this->skipWhitespace();
483	}
484
485	public function assertText( $id, $text_id, $text_bytes, $text ) {
486		$this->assertNodeStart( "text", false );
487		if ( $text_bytes !== false ) {
488			Assert::assertEquals( $this->xml->getAttribute( "bytes" ), $text_bytes,
489				"Attribute 'bytes' of revision " . $id );
490		}
491
492		if ( $text === false ) {
493			Assert::assertEquals( $this->xml->getAttribute( "id" ), $text_id,
494				"Text id of revision " . $id );
495			Assert::assertNull( $this->xml->getAttribute( "xml:space" ),
496				"xml:space attribute shout not be present" );
497			$this->assertEmptyNode( "text" );
498		} else {
499			// Testing for a real dump
500			Assert::assertEquals( $this->xml->getAttribute( "xml:space" ), "preserve",
501				"xml:space=preserve should be present" );
502			Assert::assertTrue( $this->xml->read(), "Skipping text start tag" );
503			Assert::assertEquals( $text, $this->xml->value, "Text of revision " . $id );
504			Assert::assertTrue( $this->xml->read(), "Skipping past text" );
505			$this->assertNodeEnd( "text" );
506			$this->skipWhitespace();
507		}
508	}
509
510	/**
511	 * asserts that the xml reader is at the beginning of a log entry and skips over
512	 * it while analyzing it.
513	 *
514	 * @param int $id Id of the log entry
515	 * @param string $user_name User name of the log entry's performer
516	 * @param int $user_id User id of the log entry 's performer
517	 * @param string|null $comment Comment of the log entry. If null, the comment text is ignored.
518	 * @param string $type Type of the log entry
519	 * @param string $subtype Subtype of the log entry
520	 * @param string $title Title of the log entry's target
521	 * @param array $parameters (optional) unserialized data accompanying the log entry
522	 */
523	public function assertLogItem( $id, $user_name, $user_id, $comment, $type,
524		$subtype, $title, $parameters = []
525	) {
526		$this->assertNodeStart( "logitem" );
527
528		$this->assertTextNode( "id", $id );
529		$this->assertTextNode( "timestamp", false );
530
531		$this->assertNodeStart( "contributor" );
532		$this->assertTextNode( "username", $user_name );
533		$this->assertTextNode( "id", $user_id );
534		$this->assertNodeEnd( "contributor" );
535
536		if ( $comment !== null ) {
537			$this->assertTextNode( "comment", $comment );
538		}
539		$this->assertTextNode( "type", $type );
540		$this->assertTextNode( "action", $subtype );
541		$this->assertTextNode( "logtitle", $title );
542
543		$this->assertNodeStart( "params" );
544		$parameters_xml = unserialize( $this->xml->value );
545		Assert::assertEquals( $parameters, $parameters_xml );
546		Assert::assertTrue( $this->xml->read(), "Skipping past processed text of params" );
547		$this->assertNodeEnd( "params" );
548
549		$this->assertNodeEnd( "logitem" );
550	}
551
552	/**
553	 * Returns the XMLReader's current line number for reporting.
554	 *
555	 * @param XMLReader|null $xml
556	 *
557	 * @return int
558	 */
559	public function getLineNumber( XMLReader $xml = null ) {
560		if ( !$xml ) {
561			$xml = $this->xml;
562		}
563
564		if ( $xml->nodeType == XMLReader::NONE ) {
565			return 0;
566		}
567
568		return $xml->expand()->getLineNo();
569	}
570
571	/**
572	 * Opens an XML template file and compares it to the XML structure at the current position of
573	 * this asserter.
574	 *
575	 * If the outer-most tag of the template file is <test:data>, that tag is
576	 * ignored during comparison. This allows template files to contain arbitrary snippets of XML.
577	 * When the tag <test:end/> is encountered in the template, the comparison is ended.
578	 * This allows template files to be written to match the beginning of a structure,
579	 * without the need for subsequent contents to match.
580	 *
581	 * The contents of $file are subject to variable substitution based on
582	 * the values provided via setVarMapping().
583	 *
584	 * @param string $file Name of file to analyze
585	 */
586	public function assertDOM( $file ) {
587		$exXml = new XMLReader();
588
589		Assert::assertTrue( $exXml->open( $file ),
590			"Opening fixture file $file via XMLReader failed" );
591
592		$line = 0;
593		while ( true ) {
594			$line = max( $line, $this->getLineNumber( $exXml ) );
595			$location = "[$file line $line] ";
596
597			while ( $exXml->nodeType == XMLReader::NONE
598				|| $exXml->nodeType == XMLReader::WHITESPACE
599				|| $exXml->nodeType == XMLReader::SIGNIFICANT_WHITESPACE
600				|| $exXml->nodeType == XMLReader::COMMENT
601				|| ( $exXml->nodeType == XMLReader::ELEMENT && $exXml->name === 'test:data' ) ) {
602
603				// Reached the end of the template file, so we are done here.
604				if ( !$exXml->read() ) {
605					break 2;
606				}
607
608				// Reached the end of the test data, so we are done here.
609				if ( $exXml->nodeType == XMLReader::END_ELEMENT && $exXml->name === 'test:data' ) {
610					break 2;
611				}
612			}
613
614			while ( $this->xml->nodeType == XMLReader::NONE
615				|| $this->xml->nodeType == XMLReader::WHITESPACE
616				|| $this->xml->nodeType == XMLReader::SIGNIFICANT_WHITESPACE
617				|| $this->xml->nodeType == XMLReader::COMMENT ) {
618				Assert::assertTrue( $this->xml->read(), $location . 'Document ended unexpectedly' );
619			}
620
621			// End comparison early, ignore the rest of the contents of the template file.
622			if ( $exXml->nodeType == XMLReader::ELEMENT && $exXml->name === 'test:end' ) {
623				break;
624			}
625
626			$line = max( $line, $this->getLineNumber( $exXml ) );
627			$location = "[$file line $line] ";
628
629			Assert::assertSame( $exXml->nodeType, $this->xml->nodeType, $location . 'Node type' );
630			Assert::assertSame( $exXml->name, $this->xml->name, $location . 'Node type' );
631			Assert::assertSame(
632				$exXml->hasValue,
633				$this->xml->hasValue,
634				$location . 'Node has value?'
635			);
636			Assert::assertSame(
637				$exXml->hasAttributes,
638				$this->xml->hasAttributes,
639				$location . 'Node has attributes?'
640			);
641
642			if ( $exXml->hasValue ) {
643				$expValue = $this->resolveVars( $exXml->value );
644				$actValue = $this->resolveVars( $this->xml->value );
645				Assert::assertSame( $expValue, $actValue, $location . 'Node value' );
646			}
647
648			if ( $exXml->hasAttributes ) {
649				$expectedAttributes = $this->getAttributeArray( $exXml );
650				$actualAttributes = $this->getAttributeArray( $this->xml );
651
652				Assert::assertEquals( $expectedAttributes, $actualAttributes, $location . 'Attributes' );
653			}
654
655			// Reached the end of the template file, so we are done here.
656			if ( !$exXml->read() ) {
657				break;
658			}
659
660			// Reached the end of the test data, so we are done here.
661			if ( $exXml->nodeType == XMLReader::END_ELEMENT && $exXml->name === 'test:data' ) {
662				break;
663			}
664
665			Assert::assertTrue( $this->xml->read(), $location . 'Document ended unexpectedly' );
666		}
667
668		$exXml->close();
669	}
670
671	/**
672	 * Strip any <test:...> tags from a string.
673	 *
674	 * @param string $text
675	 *
676	 * @return string
677	 */
678	public function stripTestTags( $text ) {
679		$text = preg_replace( '@<!--.*?-->@s', '', $text );
680		$text = preg_replace( '@</?test:[^>]+>@s', '', $text );
681		return $text;
682	}
683
684	private function getAttributeArray( XMLReader $xml = null ) {
685		if ( !$xml ) {
686			$xml = $this->xml;
687		}
688
689		if ( $xml->nodeType !== XMLReader::ELEMENT ) {
690			return null;
691		}
692
693		if ( !$xml->hasAttributes ) {
694			return [];
695		}
696
697		$attr = [];
698		while ( $xml->moveToNextAttribute() ) {
699			$attr[$xml->name] = $this->resolveVars( $xml->value );
700		}
701
702		return $attr;
703	}
704
705	/**
706	 * @param string $text
707	 *
708	 * @return string
709	 */
710	public function resolveVars( $text ) {
711		return str_replace(
712			array_keys( $this->varMapping ),
713			array_values( $this->varMapping ),
714			$text
715		);
716	}
717
718	/**
719	 * Define a variable mapping to be applied by assertDOM
720	 *
721	 * @param string $name
722	 * @param string $value
723	 */
724	public function setVarMapping( $name, $value ) {
725		$key = '{{' . $name . '}}';
726		$this->varMapping[$key] = $value;
727	}
728
729	/**
730	 * @return string
731	 */
732	public function getSchemaVersion() {
733		return $this->schemaVersion;
734	}
735
736}
737