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('