From 807384fc01dbf248b2baaed4284564efb4dcc3d2 Mon Sep 17 00:00:00 2001 From: Jake Jackson Date: Mon, 24 Aug 2026 12:56:44 +1000 Subject: [PATCH] Fix processing instructions gaining an extra "?" on serialization libxml's HTML parser stores the closing "?" of a `` block as part of the processing instruction's data, while its XML parser and the Masterminds HTML5 parser do not. Every serializer that appends its own "?>" therefore doubled it up, so `` came back out of html(), innerHTML(), innerXML(), innerXHTML(), xml(), html5(), innerHTML5(), and writeXML() as ``, gaining another "?" on every round trip. Reading $pi->data directly handed back source with a stray "?" glued to the end. QueryPath now strips it on load, giving every parser it supports the same invariant: processing instruction data never carries the closing "?". Exactly one "?" is removed, so a block whose content legitimately ends in one, ``, still round trips. The scan is skipped when the source contains no "?>" at all, which is almost every document. libxml's HTML serializer is then the one output path that needs the terminator back, since it writes a processing instruction verbatim and never appends one itself. saveDocumentHTML() restores it for the duration of the write and takes it off again afterwards, driving both passes from a single XPath walk. Knowing whether a document holds to the invariant cannot be worked out by inspecting it: the XML parser reading `` leaves exactly the trailing "?" that the HTML parser leaves for ``. Nor can it be re-established by normalising a second time, which would strip a "?" that belongs to the content. It is recorded instead by the document's type - documents QueryPath parses are QueryPath\Document - so it travels with the document rather than with the query object, and every route to a second DOMQuery over one document serializes correctly: iteration, add(), remove(), replaceAll(), branch(), QueryPath::with(), and the bundled extensions. registerNodeClass() keeps that type in place, because PHP otherwise rebuilds a document's wrapper as a plain DOMDocument once the original wrapper has been released. A DOMDocument supplied by the caller stays a plain DOMDocument, makes no such promise, and is written exactly as it was handed over. Note that html5qp() is unaffected by the doubling but still loses the terminator through html() and writeHTML(), which predates this change and is tracked separately in #86. Also adds TestCase::capture() for the many QueryPath methods that print rather than return, and folds the eight hand-rolled output-buffering blocks in DOMQueryTest onto it. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 + examples/parsing-php-source/index.php | 21 +- src/DOM.php | 123 ++++++++- src/DOMQuery.php | 45 ++- src/Document.php | 34 +++ tests/Issues/Issue65Test.php | 381 ++++++++++++++++++++++++++ tests/QueryPath/DOMQueryTest.php | 72 ++--- tests/QueryPath/TestCase.php | 22 ++ tests/processing-instruction.html | 1 + 9 files changed, 631 insertions(+), 70 deletions(-) create mode 100644 src/Document.php create mode 100644 tests/Issues/Issue65Test.php create mode 100644 tests/processing-instruction.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 59eb292..ac1c5e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ QueryPath Changelog - Add `QueryPathTests\ExamplesTest`, which runs every offline example on each supported PHP version and fails if one stops working. The examples that call third-party services are run by the new `Examples` workflow, weekly and whenever an example changes - Rewrite the cURL example against the PubMed E-utilities API. MusicBrainz throttles by IP address, which made the example unusable from any shared address - Add `composer run test:examples` (and `test:examples:network`) to run the examples locally +- Fix processing instructions gaining an extra `?` each time an HTML-parsed document was serialized, so `` came back out of `html()`, `innerHTML()`, `innerXML()`, `innerXHTML()`, `xml()`, `html5()`, `innerHTML5()`, and `writeXML()` as ``. libxml's HTML parser keeps the closing `?` as part of the node's data, unlike its XML parser and the Masterminds HTML5 parser, so QueryPath now strips it on load. `DOMProcessingInstruction::$data` consequently no longer has a stray `?` on the end for documents read with `htmlqp()` or `qp()` on an `.html` file. Note that this normalisation applies only to documents QueryPath parses itself, and that taking the underlying `DOMDocument` out of QueryPath and calling libxml's own `saveHTML()` on it will emit `` without the terminator, since libxml's HTML serializer never adds one +- Add `QueryPath\Document`, a `DOMDocument` subclass QueryPath parses into. The type is how QueryPath records that a document's processing instruction data does not carry the closing `?`, which cannot be determined by inspecting the document afterwards. It travels with the document, so every route to a second `DOMQuery` over one document -- iteration, `add()`, `remove()`, `replaceAll()`, `branch()`, `QueryPath::with()`, and the bundled extensions -- serializes it correctly. A `DOMDocument` supplied by the caller is a plain `DOMDocument`, makes no such promise, and is still serialized exactly as it was handed over # 4.1.0 diff --git a/examples/parsing-php-source/index.php b/examples/parsing-php-source/index.php index 90171e7..4adef77 100644 --- a/examples/parsing-php-source/index.php +++ b/examples/parsing-php-source/index.php @@ -42,9 +42,9 @@ * PHP blocks survive parsing as processing instruction nodes, so they can be * located with XPath and inspected like any other node. * - * The `data` property of the node holds the PHP source. libxml stores the - * trailing "?" of the closing tag as part of that data and puts the ">" back - * on when the document is written out, so trim it off before displaying it. + * The `data` property of the node holds the PHP source. QueryPath normalises + * it on load, so the closing "?" of the "?>" is never part of the data no + * matter which parser read the document. */ echo '

The PHP blocks in the template

'; @@ -53,9 +53,9 @@ echo '
    '; foreach ($blocks as $block) { - $code = rtrim(trim($block->get(0)->data), '?'); + $code = trim($block->get(0)->data); - echo '
  1. ' . htmlspecialchars(trim($code)) . '
  2. '; + echo '
  3. ' . htmlspecialchars($code) . '
  4. '; } echo '
'; @@ -64,11 +64,12 @@ * Because it is a normal DOM, the template can be rewritten too. Here a new * menu item is added and the heading is retitled. * - * Use writeHTML() (or writeXML()) to serialize a template containing PHP - * blocks - it hands the document back to libxml, which restores the closing - * "?>" correctly. Capturing it with an output buffer makes it easy to send - * the result somewhere other than standard output, such as back to disk with - * file_put_contents(). + * Every serializer restores the closing "?>" of a PHP block, so a template + * can be written back out in whichever format suits: writeHTML(), writeXML(), + * and writeHTML5() print it, and html(), xml(), and html5() return it as a + * string. Capturing writeHTML() with an output buffer, as below, makes it + * easy to send the result somewhere other than standard output, such as back + * to disk with file_put_contents(). */ echo '

Rewriting the template

'; diff --git a/src/DOM.php b/src/DOM.php index 076fad9..b9e95eb 100644 --- a/src/DOM.php +++ b/src/DOM.php @@ -5,6 +5,7 @@ use Countable; use DOMDocument; use DOMNode; +use DOMXPath; use IteratorAggregate; use Masterminds\HTML5; use QueryPath\CSS\DOMTraverser; @@ -91,10 +92,9 @@ public function __construct($document = null, $selector = null, $options = []) // Empty: Just create an empty QP. if (empty($document)) { - $this->document = isset($this->options['encoding']) ? new DOMDocument( - '1.0', - $this->options['encoding'] - ) : new DOMDocument(); + $this->document = isset($this->options['encoding']) + ? self::createDocument('1.0', $this->options['encoding']) + : self::createDocument(); $this->setMatches(new SplObjectStorage()); } // Figure out if document is DOM, HTML/XML, or a filename elseif (is_object($document)) { @@ -175,7 +175,7 @@ public function __construct($document = null, $selector = null, $options = []) private function parseXMLString($string, $flags = 0) { - $document = new DOMDocument('1.0'); + $document = self::createDocument('1.0'); $lead = strtolower(substr($string, 0, 5)); // errTypes); @@ -206,7 +206,7 @@ private function parseXMLString($string, $flags = 0) // If HTML parser is requested, we use it. if ($useParser === 'html') { - $document->loadHTML($string); + self::loadHTMLString($document, $string); } // Parse as XML if it looks like XML, or if XML parser is requested. elseif ($lead === 'options['replace_entities']) { @@ -215,7 +215,7 @@ private function parseXMLString($string, $flags = 0) $document->loadXML($string, $flags); } // In all other cases, we try the HTML parser. else { - $document->loadHTML($string); + self::loadHTMLString($document, $string); } } // Emulate 'finally' behavior. catch (Exception $e) { @@ -231,6 +231,111 @@ private function parseXMLString($string, $flags = 0) return $document; } + /** + * Create the document QueryPath parses into. + * + * registerNodeClass() is what makes the marker type stick. PHP rebuilds the wrapper object for + * a document whenever it is reached through $node->ownerDocument and the original wrapper has + * since been released, and without the registration it rebuilds it as a plain DOMDocument -- + * losing exactly the fact the type is there to record. + * + * @param string $version + * @param string|null $encoding + * + * @return Document + */ + private static function createDocument($version = '1.0', $encoding = null) + { + $document = $encoding === null ? new Document($version) : new Document($version, $encoding); + $document->registerNodeClass(DOMDocument::class, Document::class); + + return $document; + } + + /** + * Parse a string with libxml's HTML parser, keeping QueryPath's processing instruction invariant. + * + * @param DOMDocument $document + * @param string $string + * + * @return void + * + * @see normalizeProcessingInstructions() + */ + private static function loadHTMLString(DOMDocument $document, $string) + { + $document->loadHTML($string); + + // Processing instruction data can only end in "?" if a "?" sat immediately before the + // closing ">", so a source without that sequence -- almost every document -- has nothing + // to normalize, and looking for two bytes is far cheaper than walking the tree. + if (strpos($string, '?>') !== false) { + self::normalizeProcessingInstructions($document); + } + } + + /** + * Parse a file with libxml's HTML parser, keeping QueryPath's processing instruction invariant. + * + * The counterpart of loadHTMLString(). There is no source string to scan here, so the document + * is always walked; the walk costs roughly 3% of the parse it follows. + * + * @param DOMDocument $document + * @param string $filename + * + * @return void + * + * @see normalizeProcessingInstructions() + */ + private static function loadHTMLFile(DOMDocument $document, $filename) + { + $document->loadHTMLFile($filename); + self::normalizeProcessingInstructions($document); + } + + /** + * Strip the closing "?" that libxml's HTML parser leaves in processing instruction data. + * + * libxml's HTML parser stores the terminating "?" of a `` block as part of the + * node's data, while its XML parser -- and the Masterminds HTML5 parser -- do not. Leaving it + * in place corrupts every serializer that appends its own "?>": `saveXML()` turns + * `` into ``, and each further round trip adds another "?". + * + * Normalising on load gives every parser QueryPath supports the same invariant -- processing + * instruction data never contains the closing "?" -- which fixes the serializers and also means + * a caller reading `$pi->data` gets usable source rather than source with a stray "?" glued to + * the end. + * + * Exactly one "?" is removed, so a processing instruction whose content legitimately ends in + * "?" (``) still round trips correctly. + * + * @param DOMDocument $document + * + * @return void + */ + protected static function normalizeProcessingInstructions(DOMDocument $document) + { + foreach (self::processingInstructions($document) as $instruction) { + if (substr($instruction->data, -1) === '?') { + $instruction->data = substr($instruction->data, 0, -1); + } + } + } + + /** + * Every processing instruction in a document, as a static node list. + * + * @param DOMDocument $document + * + * @return iterable + */ + protected static function processingInstructions(DOMDocument $document): iterable + { + $xpath = new DOMXPath($document); + + return $xpath->query('//processing-instruction()'); + } + /** * EXPERT: Be very, very careful using this. * A utility function for setting the current set of matches. @@ -454,7 +559,7 @@ private function parseXMLFile($filename, $flags = 0, $context = null) return $this->parseXMLString($contents, $flags); } - $document = new DOMDocument(); + $document = self::createDocument(); $lastDot = strrpos($filename, '.'); $htmlExtensions = [ @@ -480,7 +585,7 @@ private function parseXMLFile($filename, $flags = 0, $context = null) } // Otherwise, see if it looks like HTML. elseif ($useParser === 'html' || isset($htmlExtensions[$ext])) { // Try parsing it as HTML. - $document->loadHTMLFile($filename); + self::loadHTMLFile($document, $filename); } // Default to XML. else { $document->load($filename, $flags); diff --git a/src/DOMQuery.php b/src/DOMQuery.php index 5f2eb87..d4b395d 100644 --- a/src/DOMQuery.php +++ b/src/DOMQuery.php @@ -700,13 +700,52 @@ public function html($markup = null) } if ($first instanceof DOMDocument || $first->isSameNode($first->ownerDocument->documentElement)) { - return $this->document->saveHTML(); + return $this->saveDocumentHTML(); } // saveHTML cannot take a node and serialize it. return $this->document->saveXML($first); } + /** + * Serialize the whole document with libxml's HTML serializer. + * + * libxml writes a processing instruction verbatim as `` and never appends the + * closing "?" itself, so the terminator has to be put back for the duration of the write and + * taken off again afterwards. A document QueryPath did not parse is written as-is, because it + * makes no promise about where its terminators are. + * + * @param string|null $path + * When given, the document is written to this file rather than returned. + * + * @return string|int|false + * The serialized document, or the number of bytes written when $path is given. + * + * @see \QueryPath\Document + */ + protected function saveDocumentHTML($path = null) + { + // One walk drives both passes. DOMXPath::query() hands back a static node list and + // serializing does not move nodes, so the same list is still good for the restore. + $instructions = $this->document instanceof Document + ? self::processingInstructions($this->document) + : []; + + foreach ($instructions as $instruction) { + $instruction->data .= '?'; + } + + try { + return $path === null + ? $this->document->saveHTML() + : $this->document->saveHTMLFile($path); + } finally { + foreach ($instructions as $instruction) { + $instruction->data = substr($instruction->data, 0, -1); + } + } + } + /** * Set or get the markup for an element using the HTML5 parser * @@ -1335,11 +1374,11 @@ public function writeXML($path = null, $options = 0) public function writeHTML($path = null) { if ($path === null) { - print $this->document->saveHTML(); + print $this->saveDocumentHTML(); } else { try { set_error_handler(['\QueryPath\ParseException', 'initializeFromError']); - $this->document->saveHTMLFile($path); + $this->saveDocumentHTML($path); } catch (Exception $e) { restore_error_handler(); throw $e; diff --git a/src/Document.php b/src/Document.php new file mode 100644 index 0000000..dd303fa --- /dev/null +++ b/src/Document.php @@ -0,0 +1,34 @@ +". Every parser QueryPath drives satisfies it, libxml's XML + * parser and Masterminds natively and libxml's HTML parser once + * DOM::normalizeProcessingInstructions() has run over the result. + * + * The invariant cannot be established by inspection after the fact, because the XML parser reading + * `` leaves exactly the trailing "?" that the HTML parser leaves for + * ``. Nor can it be re-established by normalising again, which would strip a "?" + * that legitimately belongs to the content. It has to be recorded when the document is built, and + * recording it on the document rather than on the query object means it survives every route by + * which a second DOMQuery comes to share the same document -- iteration, add(), remove(), + * replaceAll(), branch(), and the extensions. + * + * A DOMDocument supplied by the caller is a plain DOMDocument and makes no such promise, so + * QueryPath serializes it exactly as it was handed over. + * + * @see DOM::normalizeProcessingInstructions() + * @see DOMQuery::saveDocumentHTML() + * + * @ingroup querypath_core + */ +class Document extends DOMDocument +{ +} diff --git a/tests/Issues/Issue65Test.php b/tests/Issues/Issue65Test.php new file mode 100644 index 0000000..c0ececf --- /dev/null +++ b/tests/Issues/Issue65Test.php @@ -0,0 +1,381 @@ +" therefore doubled it up, so `` came back out as + * `` and grew another "?" on every round trip. + */ + +namespace QueryPathTests; + +use DOMDocument; +use DOMProcessingInstruction; +use QueryPath\Document; + +class Issue65Test extends TestCase +{ + private const PI_FILE_HTML = 'tests/processing-instruction.html'; + + /** + * A document with a PHP block. + */ + private const HTML = '

'; + + /** + * The expected serialization of self::HTML's

, for every serializer. + */ + private const EXPECTED = '

'; + + /** + * Keeps the serialized output compact enough to compare exactly. + */ + private const OPTIONS = ['format_output' => false]; + + /** + * Every serializer that appends its own "?>", with the selector each one is called on. + * + * @return array + */ + public function serializerProvider(): array + { + return [ + 'innerHTML' => ['body', 'innerHTML'], + 'innerXML' => ['body', 'innerXML'], + 'innerXHTML' => ['body', 'innerXHTML'], + 'innerHTML5' => ['body', 'innerHTML5'], + 'html' => ['h1', 'html'], + 'html5' => ['h1', 'html5'], + 'xml' => ['h1', 'xml'], + ]; + } + + /** + * @dataProvider serializerProvider + * + * @param string $selector + * @param string $method + */ + public function testSerializersDoNotDoubleUpTheProcessingInstructionTerminator($selector, $method) + { + $qp = htmlqp(self::HTML, null, self::OPTIONS); + + $this->assertSame(self::EXPECTED, $qp->top()->find($selector)->$method()); + } + + public function testHtmlOfTheWholeDocumentKeepsASingleTerminator() + { + $qp = htmlqp(self::HTML, null, self::OPTIONS); + + $this->assertStringContainsString(self::EXPECTED, $qp->top()->html()); + } + + /** + * A document parsed from a .html file goes through loadHTMLFile() rather than loadHTML(). + */ + public function testHtmlFileOnDiskIsNormalisedToo() + { + $qp = qp(self::PI_FILE_HTML, null, self::OPTIONS); + + $this->assertSame(self::EXPECTED, $qp->top()->find('body')->innerHTML()); + } + + /** + * The bug compounded: every extra round trip used to add another "?". + */ + public function testRepeatedRoundTripsAreStable() + { + $markup = self::HTML; + + for ($i = 0; $i < 3; $i++) { + $markup = htmlqp($markup, null, self::OPTIONS)->top()->html(); + $this->assertStringContainsString(self::EXPECTED, $markup); + $this->assertStringNotContainsString('??>', $markup); + } + } + + public function testRepeatedInnerHtmlRoundTripsAreStable() + { + $markup = self::HTML; + + for ($i = 0; $i < 3; $i++) { + $markup = htmlqp($markup, null, self::OPTIONS)->top()->find('body')->innerHTML(); + $this->assertSame(self::EXPECTED, $markup); + } + } + + /** + * Reading the node directly should hand back usable PHP, not source with a stray "?" glued on. + */ + public function testProcessingInstructionDataHasNoTrailingQuestionMark() + { + $instruction = htmlqp(self::HTML, null, self::OPTIONS)->top()->find('h1')->get(0)->firstChild; + + $this->assertInstanceOf(DOMProcessingInstruction::class, $instruction); + $this->assertSame('php', $instruction->target); + $this->assertSame('echo $title; ', $instruction->data); + } + + /** + * Exactly one "?" is stripped, so content that legitimately ends in "?" still round trips. + */ + public function testProcessingInstructionEndingInAQuestionMarkIsNotDoubleStripped() + { + $markup = '

'; + $qp = htmlqp($markup, null, self::OPTIONS); + + $this->assertSame('

', $qp->top()->find('body')->innerHTML()); + $this->assertStringContainsString('

', $qp->top()->html()); + } + + /** + * Every method that prints the whole document, all of which must emit exactly one terminator. + * + * @return array + */ + public function writerProvider(): array + { + return [ + 'writeHTML' => ['writeHTML'], + 'writeHTML5' => ['writeHTML5'], + 'writeXML' => ['writeXML'], + ]; + } + + /** + * @dataProvider writerProvider + * + * @param string $method + */ + public function testWritersEmitASingleTerminator($method) + { + $qp = htmlqp(self::HTML, null, self::OPTIONS); + + $output = $this->capture(function () use ($qp, $method) { + $qp->top()->$method(); + }); + + $this->assertStringContainsString(self::EXPECTED, $output); + $this->assertStringNotContainsString('??>', $output); + } + + public function testWriteHtmlToAFileEmitsASingleTerminator() + { + $qp = htmlqp(self::HTML, null, self::OPTIONS); + $path = tempnam(sys_get_temp_dir(), 'qp65'); + + try { + $qp->top()->writeHTML($path); + $output = file_get_contents($path); + } finally { + unlink($path); + } + + $this->assertStringContainsString(self::EXPECTED, $output); + $this->assertStringNotContainsString('??>', $output); + } + + /** + * The temporary terminator writeHTML() needs must not leak into the document afterwards. + */ + public function testWriteHtmlLeavesTheDocumentUnchanged() + { + $qp = htmlqp(self::HTML, null, self::OPTIONS); + + $this->capture(function () use ($qp) { + $qp->top()->writeHTML(); + }); + + $this->assertSame(self::EXPECTED, $qp->top()->find('body')->innerHTML()); + } + + /** + * html5qp() was never affected, because the Masterminds parser does not keep the "?". + */ + public function testHtml5ParserIsUnaffected() + { + $qp = html5qp(self::HTML, null, self::OPTIONS); + + $this->assertSame(self::EXPECTED, $qp->top()->find('body')->innerHTML()); + $this->assertSame(self::EXPECTED, $qp->top()->find('body')->innerHTML5()); + } + + /** + * A document parsed in XML mode was never affected either. + */ + public function testXmlParserIsUnaffected() + { + $xml = ''; + $qp = qp($xml, null, self::OPTIONS); + + $this->assertSame('', $qp->top()->find('item')->innerXML()); + $this->assertSame('', $qp->top()->find('item')->xml()); + + $instruction = $qp->top()->find('item')->get(0)->firstChild; + $this->assertSame('echo $title; ', $instruction->data); + } + + /** + * writeHTML() on an XML-parsed document used to drop the terminator entirely, because libxml's + * HTML serializer writes a processing instruction verbatim and never adds one of its own. + */ + public function testWriteHtmlOnAnXmlParsedDocumentEmitsATerminator() + { + $xml = ''; + $qp = qp($xml, null, self::OPTIONS); + + $output = $this->capture(function () use ($qp) { + $qp->top()->writeHTML(); + }); + + $this->assertStringContainsString('', $output); + } + + /** + * An HTML processing instruction with no "?" before the ">" has nothing to strip. + */ + public function testProcessingInstructionWithoutATerminatorIsLeftAlone() + { + $qp = htmlqp('

', null, self::OPTIONS); + + $instruction = $qp->top()->find('h1')->get(0)->firstChild; + $this->assertSame('foo', $instruction->target); + $this->assertSame('bar', $instruction->data); + } + + /** + * Documents with no processing instructions must serialize exactly as they did before. + */ + public function testDocumentsWithoutProcessingInstructionsAreUntouched() + { + $qp = htmlqp('

hi

', null, self::OPTIONS); + + $this->assertSame('

hi

', $qp->top()->find('#d')->innerHTML()); + + $output = $this->capture(function () use ($qp) { + $qp->top()->writeHTML(); + }); + + $this->assertStringContainsString('

hi

', $output); + } + + /** + * Normalisation only happens on documents QueryPath parses, so the HTML serializer must leave a + * caller-supplied document alone -- its processing instructions still carry their own "?". + */ + public function testCallerSuppliedHtmlDocumentIsSerializedAsIs() + { + $doc = new DOMDocument(); + @$doc->loadHTML(self::HTML); + + $this->assertStringContainsString(self::EXPECTED, qp($doc, null, self::OPTIONS)->top()->html()); + $this->assertStringContainsString(self::EXPECTED, qp($doc->documentElement, null, self::OPTIONS)->top()->html()); + } + + /** + * branch() assigns $this->document directly rather than going through the constructor. + */ + public function testBranchKeepsTheDocumentSerializingCorrectly() + { + $qp = htmlqp(self::HTML, null, self::OPTIONS); + + $this->assertStringContainsString(self::EXPECTED, $qp->top()->branch()->html()); + } + + /** + * A DOMQuery built from another DOMQuery inherits its document, and with it the invariant. + */ + public function testDocumentCopiedFromAnotherQueryPathSerializesCorrectly() + { + $qp = qp(htmlqp(self::HTML, null, self::OPTIONS), null, self::OPTIONS); + + $this->assertStringContainsString(self::EXPECTED, $qp->top()->html()); + } + + /** + * Iterating a match set builds a fresh DOMQuery per element over the same document. + */ + public function testIteratingAMatchSetKeepsTheDocumentSerializingCorrectly() + { + $qp = htmlqp(self::HTML, null, self::OPTIONS); + + $output = $this->capture(function () use ($qp) { + foreach ($qp->top()->find('html') as $element) { + $element->writeHTML(); + } + }); + + $this->assertStringContainsString(self::EXPECTED, $output); + $this->assertStringNotContainsString('??>', $output); + } + + /** + * Every route to a second query over one document, none of which run the parser again. + * + * @return array + */ + public function sharedDocumentProvider(): array + { + return [ + 'from the document' => [true], + 'from a node' => [false], + ]; + } + + /** + * @dataProvider sharedDocumentProvider + * + * @param bool $fromDocument + */ + public function testASecondQueryOverTheSameDocumentSerializesCorrectly($fromDocument) + { + $element = htmlqp(self::HTML, null, self::OPTIONS)->top()->get(0); + $source = $fromDocument ? $element->ownerDocument : $element; + + $this->assertStringContainsString(self::EXPECTED, qp($source, null, self::OPTIONS)->top()->html()); + } + + /** + * remove() runs the legacy selector engine and returns a new DOMQuery over the same document. + */ + public function testRemoveKeepsTheDocumentSerializingCorrectly() + { + $html = '

gone

'; + $qp = htmlqp($html, null, self::OPTIONS); + $qp->top()->find('p')->remove(); + + $this->assertStringContainsString(self::EXPECTED, $qp->top()->html()); + } + + /** + * The marker is the document's type, and PHP rebuilds that wrapper object whenever a document + * is reached through ownerDocument after the original wrapper has been released. + */ + public function testTheDocumentKeepsItsTypeWhenReachedThroughOwnerDocument() + { + $element = htmlqp(self::HTML, null, self::OPTIONS)->top()->get(0); + + $this->assertInstanceOf(Document::class, $element->ownerDocument); + } + + /** + * Normalisation runs once, when the document is parsed. Re-using it must not strip a second + * "?" from content that legitimately ends in one. + */ + public function testProcessingInstructionEndingInAQuestionMarkSurvivesReuse() + { + $markup = '

'; + $expected = '

'; + $qp = htmlqp($markup, null, self::OPTIONS); + + $this->assertStringContainsString($expected, $qp->top()->html()); + $this->assertStringContainsString($expected, $qp->top()->branch()->html()); + $this->assertStringContainsString( + $expected, + qp($qp->top()->get(0)->ownerDocument, null, self::OPTIONS)->top()->html() + ); + } +} diff --git a/tests/QueryPath/DOMQueryTest.php b/tests/QueryPath/DOMQueryTest.php index 4e4027f..228b662 100644 --- a/tests/QueryPath/DOMQueryTest.php +++ b/tests/QueryPath/DOMQueryTest.php @@ -1383,12 +1383,9 @@ public function testWriteXML() { $xml = 'foobar'; - if (! ob_start()) { - die("Could not start OB."); - } - qp($xml, 'tml')->writeXML(); - $out = ob_get_contents(); - ob_end_clean(); + $out = $this->capture(function () use ($xml) { + qp($xml, 'tml')->writeXML(); + }); // We expect an XML declaration at the top. $this->assertEquals(' foobar'; - if (! ob_start()) { - die("Could not start OB."); - } - qp($xml, 'tml')->writeXML(); - $out = ob_get_contents(); - ob_end_clean(); + $out = $this->capture(function () use ($xml) { + qp($xml, 'tml')->writeXML(); + }); // We expect an XML declaration at the top. $this->assertEquals('foobar'; - if (! ob_start()) { - die("Could not start OB."); - } - qp($xml, 'tml')->writeXHTML(); - $out = ob_get_contents(); - ob_end_clean(); + $out = $this->capture(function () use ($xml) { + qp($xml, 'tml')->writeXHTML(); + }); // We expect an XML declaration at the top. $this->assertEquals(' foobar'; - if (! ob_start()) { - die('Could not start OB.'); - } - qp($xml, 'html')->writeXHTML(); - $out = ob_get_contents(); - ob_end_clean(); + $out = $this->capture(function () use ($xml) { + qp($xml, 'html')->writeXHTML(); + }); // We expect an XML declaration at the top. $this->assertEquals('foo
bar'; - if (! ob_start()) { - die("Could not start OB."); - } - qp($xhtml, 'html')->writeXHTML(); - $out = ob_get_contents(); - ob_end_clean(); + $out = $this->capture(function () use ($xhtml) { + qp($xhtml, 'html')->writeXHTML(); + }); $pattern = '/<\/script>/'; $this->assertMatchesRegularExpression($pattern, $out, 'Should be closing script tag.'); @@ -1514,12 +1499,9 @@ public function testWriteHTML() { $xml = 'foobar'; - if (! ob_start()) { - die("Could not start OB."); - } - qp($xml, 'tml')->writeHTML(); - $out = ob_get_contents(); - ob_end_clean(); + $out = $this->capture(function () use ($xml) { + qp($xml, 'tml')->writeHTML(); + }); // We expect a doctype declaration at the top. $this->assertEquals(' bar'; - if (! ob_start()) { - die("Could not start OB."); - } - qp($xml, 'tml')->writeHTML(); - $out = ob_get_contents(); - ob_end_clean(); + $out = $this->capture(function () use ($xml) { + qp($xml, 'tml')->writeHTML(); + }); // We expect a doctype declaration at the top. $this->assertEquals(' bar'; - if (! ob_start()) { - die("Could not start OB."); - } - qp($xml, 'tml')->writeHTML(); - $out = ob_get_contents(); - ob_end_clean(); + $out = $this->capture(function () use ($xml) { + qp($xml, 'tml')->writeHTML(); + }); // We expect a doctype declaration at the top. $this->assertEquals('