From df9756b9a11e5768b649431a991487540497a895 Mon Sep 17 00:00:00 2001 From: "C. Scott Ananian" Date: Tue, 1 Jul 2025 15:06:59 -0400 Subject: [PATCH] Make Content JsonCodecable By default this uses the existing ContentHandler::serializeContent() and ::unserializeContent() methods. But in cases where existing PHP serialization preserved fields that ::serializeContent() did not, provide an additional ContentHandler::serializeContentToJsonArray() and ContentHandler::deserializeContentFromJsonArray() methods which can be used. Use these in WikitextContentHandler to preserve the PST flags. Added test cases and a ContentSerializationTestTrait to make it easy to ensure forward- and backward-compatibility in accord with https://www.mediawiki.org/wiki/Manual:Parser_cache/Serialization_compatibility The new JsonCodecable codec will be used to improve PageEditStashContent serialization, which no longer has to PHP-serialize its Content object. New test case added demonstrating compatibility. Bug: T264389 Bug: T161647 Change-Id: I544625136088164561b9169a63aed7450cce82f5 (cherry picked from commit 21576d6c1893079777a1a51d0f81c4941c58e376) --- autoload.php | 1 + includes/MediaWikiServices.php | 8 ++ includes/ServiceWiring.php | 7 + includes/content/AbstractContent.php | 12 +- includes/content/ContentHandler.php | 51 ++++++++ includes/content/ContentJsonCodec.php | 69 ++++++++++ includes/content/WikitextContentHandler.php | 14 ++ tests/common/TestsAutoLoader.php | 6 +- .../data/Content/1.45-CssContent-basic.json | 1 + .../Content/1.45-CssContent-redirect.json | 1 + .../Content/1.45-JavaScriptContent-basic.json | 1 + .../1.45-JavaScriptContent-redirect.json | 1 + .../Content/1.45-WikitextContent-basic.json | 1 + .../1.45-WikitextContent-withPstFlags.json | 1 + ...5_content-PageEditStashContents-basic.json | 1 + .../content/ContentSerializationTestTrait.php | 28 ++++ .../includes/content/CssContentTest.php | 36 ++++++ .../content/JavaScriptContentTest.php | 38 ++++++ .../includes/content/TextContentTest.php | 5 + .../includes/content/WikitextContentTest.php | 39 ++++++ .../content/validateContentTestData.php | 122 ++++++++++++++++++ 21 files changed, 441 insertions(+), 2 deletions(-) create mode 100644 includes/content/ContentJsonCodec.php create mode 100644 tests/phpunit/data/Content/1.45-CssContent-basic.json create mode 100644 tests/phpunit/data/Content/1.45-CssContent-redirect.json create mode 100644 tests/phpunit/data/Content/1.45-JavaScriptContent-basic.json create mode 100644 tests/phpunit/data/Content/1.45-JavaScriptContent-redirect.json create mode 100644 tests/phpunit/data/Content/1.45-WikitextContent-basic.json create mode 100644 tests/phpunit/data/Content/1.45-WikitextContent-withPstFlags.json create mode 100644 tests/phpunit/data/PageEditStashContents/1.45_content-PageEditStashContents-basic.json create mode 100644 tests/phpunit/includes/content/ContentSerializationTestTrait.php create mode 100644 tests/phpunit/includes/content/validateContentTestData.php diff --git a/autoload.php b/autoload.php index 62694be2b36..f9b3d2b5011 100644 --- a/autoload.php +++ b/autoload.php @@ -1142,6 +1142,7 @@ $wgAutoloadLocalClasses = [ 'MediaWiki\\Content\\Content' => __DIR__ . '/includes/content/Content.php', 'MediaWiki\\Content\\ContentHandler' => __DIR__ . '/includes/content/ContentHandler.php', 'MediaWiki\\Content\\ContentHandlerFactory' => __DIR__ . '/includes/content/ContentHandlerFactory.php', + 'MediaWiki\\Content\\ContentJsonCodec' => __DIR__ . '/includes/content/ContentJsonCodec.php', 'MediaWiki\\Content\\ContentModelChange' => __DIR__ . '/includes/content/ContentModelChange.php', 'MediaWiki\\Content\\CssContent' => __DIR__ . '/includes/content/CssContent.php', 'MediaWiki\\Content\\CssContentHandler' => __DIR__ . '/includes/content/CssContentHandler.php', diff --git a/includes/MediaWikiServices.php b/includes/MediaWikiServices.php index 861aa7b5918..2a040a7861a 100644 --- a/includes/MediaWikiServices.php +++ b/includes/MediaWikiServices.php @@ -62,6 +62,7 @@ use MediaWiki\Config\Config; use MediaWiki\Config\ConfigFactory; use MediaWiki\Config\ConfigRepository; use MediaWiki\Config\GlobalVarConfig; +use MediaWiki\Content\ContentJsonCodec; use MediaWiki\Content\IContentHandlerFactory; use MediaWiki\Content\Renderer\ContentRenderer; use MediaWiki\Content\Transform\ContentTransformer; @@ -966,6 +967,13 @@ class MediaWikiServices extends ServiceContainer { return $this->getService( 'ContentHandlerFactory' ); } + /** + * @since 1.45 + */ + public function getContentJsonCodec(): ContentJsonCodec { + return $this->getService( 'ContentJsonCodec' ); + } + /** * @since 1.32 */ diff --git a/includes/ServiceWiring.php b/includes/ServiceWiring.php index a619daf1e88..1e9e7927af1 100644 --- a/includes/ServiceWiring.php +++ b/includes/ServiceWiring.php @@ -83,6 +83,7 @@ use MediaWiki\Config\ConfigFactory; use MediaWiki\Config\ConfigRepository; use MediaWiki\Config\ServiceOptions; use MediaWiki\Content\ContentHandlerFactory; +use MediaWiki\Content\ContentJsonCodec; use MediaWiki\Content\IContentHandlerFactory; use MediaWiki\Content\Renderer\ContentRenderer; use MediaWiki\Content\Transform\ContentTransformer; @@ -630,6 +631,12 @@ return [ ); }, + 'ContentJsonCodec' => static function ( MediaWikiServices $services ): ContentJsonCodec { + return new ContentJsonCodec( + $services->getContentHandlerFactory(), + ); + }, + 'ContentLanguage' => static function ( MediaWikiServices $services ): Language { return $services->getLanguageFactory()->getLanguage( $services->getMainConfig()->get( MainConfigNames::LanguageCode ) ); diff --git a/includes/content/AbstractContent.php b/includes/content/AbstractContent.php index 2535ba92b89..7086f305899 100644 --- a/includes/content/AbstractContent.php +++ b/includes/content/AbstractContent.php @@ -34,6 +34,10 @@ use MediaWiki\MediaWikiServices; use MediaWiki\Parser\MagicWord; use MediaWiki\Title\Title; use MWException; +use Psr\Container\ContainerInterface; +use Wikimedia\JsonCodec\JsonClassCodec; +use Wikimedia\JsonCodec\JsonCodecable; +use Wikimedia\JsonCodec\JsonCodecInterface; /** * Base implementation for content objects. @@ -42,7 +46,7 @@ use MWException; * * @ingroup Content */ -abstract class AbstractContent implements Content { +abstract class AbstractContent implements Content, JsonCodecable { /** * Name of the content model this Content object represents. * Use with CONTENT_MODEL_XXX constants @@ -419,6 +423,12 @@ abstract class AbstractContent implements Content { return $result; } + public static function jsonClassCodec( + JsonCodecInterface $codec, + ContainerInterface $serviceContainer + ): JsonClassCodec { + return $serviceContainer->get( 'ContentJsonCodec' ); + } } /** @deprecated class alias since 1.43 */ diff --git a/includes/content/ContentHandler.php b/includes/content/ContentHandler.php index 63e88194424..3b0da27807c 100644 --- a/includes/content/ContentHandler.php +++ b/includes/content/ContentHandler.php @@ -32,6 +32,7 @@ use Action; use DifferenceEngine; use DifferenceEngineSlotDiffRenderer; use InvalidArgumentException; +use JsonException; use LogicException; use MediaWiki\CommentStore\CommentStore; use MediaWiki\Content\Renderer\ContentParseParams; @@ -354,6 +355,25 @@ abstract class ContentHandler { */ abstract public function serializeContent( Content $content, $format = null ); + /** + * Serializes a Content object of the type supported by this + * ContentHandler to an array which is JsonCodecable. + * + * @since 1.45 + * + * @param Content $content The Content object to serialize + * + * @return array An array of JsonCodecable content + */ + public function serializeContentToJsonArray( Content $content ): array { + $format = $this->getJsonFormat(); + $blob = $this->serializeContent( $content, $format ); + return [ + 'format' => $format, + 'blob' => $blob, + ]; + } + /** * Applies transformations on export (returns the blob unchanged by default). * Subclasses may override this to perform transformations such as conversion @@ -381,9 +401,30 @@ abstract class ContentHandler { * * @return Content The Content object created by deserializing $blob * @throws MWContentSerializationException + * @see ContentJsonCodec */ abstract public function unserializeContent( $blob, $format = null ); + /** + * Deserializes a Content object of the type supported by this + * ContentHandler from a JsonCodecable array. + * + * @since 1.45 + * + * @param array $json Serialized form of the content + * + * @return Content The Content object created by deserializing $blob + * @throws JsonException + * @see ContentJsonCodec + */ + public function deserializeContentFromJsonArray( array $json ): Content { + try { + return $this->unserializeContent( $json['blob'], $json['format'] ); + } catch ( MWContentSerializationException $e ) { + throw new JsonException( $e->getMessage() ); + } + } + /** * Apply import transformation (by default, returns $blob unchanged). * This gives subclasses an opportunity to transform data blobs on import. @@ -491,6 +532,16 @@ abstract class ContentHandler { return $this->mSupportedFormats[0]; } + /** + * Allow ContentHandler to chose a non-default format for JSON + * serialization. + * + * In most cases will return the same as `::getDefaultFormat()`. + */ + public function getJsonFormat(): string { + return $this->getDefaultFormat(); + } + /** * Returns true if $format is a serialization format supported by this * ContentHandler, and false otherwise. diff --git a/includes/content/ContentJsonCodec.php b/includes/content/ContentJsonCodec.php new file mode 100644 index 00000000000..c76dd30ae2e --- /dev/null +++ b/includes/content/ContentJsonCodec.php @@ -0,0 +1,69 @@ + + * @internal + */ +class ContentJsonCodec implements JsonClassCodec { + + public function __construct( + private IContentHandlerFactory $contentHandlerFactory + ) { + } + + /** @inheritDoc */ + public function toJsonArray( $content ): array { + '@phan-var Content $content'; /** @var Content $content */ + // To serialize content we need a handler. + $model = $content->getModel(); + $handler = $this->contentHandlerFactory->getContentHandler( + $model + ); + return [ 'model' => $model ] + + $handler->serializeContentToJsonArray( $content ); + } + + /** @inheritDoc */ + public function newFromJsonArray( string $className, array $json ) { + // To deserialize content we need a handler. + $model = $json['model']; + $handler = $this->contentHandlerFactory->getContentHandler( + $model + ); + $content = $handler->deserializeContentFromJsonArray( $json ); + // phan's support for generics is broken :( + // @phan-suppress-next-line PhanTypeMismatchReturn + return $content; + } + + /** @inheritDoc */ + public function jsonClassHintFor( string $className, string $keyName ) { + return null; + } +} diff --git a/includes/content/WikitextContentHandler.php b/includes/content/WikitextContentHandler.php index 18879ea09af..d86c0ab13a0 100644 --- a/includes/content/WikitextContentHandler.php +++ b/includes/content/WikitextContentHandler.php @@ -424,6 +424,20 @@ class WikitextContentHandler extends TextContentHandler { $parserOutput->setOutputFlag( ParserOutputFlags::USER_SIGNATURE ); } } + + public function serializeContentToJsonArray( Content $content ): array { + '@phan-var WikitextContent $content'; /** @var WikitextContent $content */ + return parent::serializeContentToJsonArray( $content ) + [ + 'pstFlags' => $content->getPreSaveTransformFlags(), + ]; + } + + public function deserializeContentFromJsonArray( array $json ): WikitextContent { + $content = parent::deserializeContentFromJsonArray( $json ); + '@phan-var WikitextContent $content'; /** @var WikitextContent $content */ + $content->setPreSaveTransformFlags( $json['pstFlags'] ?? [] ); + return $content; + } } /** @deprecated class alias since 1.43 */ diff --git a/tests/common/TestsAutoLoader.php b/tests/common/TestsAutoLoader.php index c11d7b4a897..2d6307e3b09 100644 --- a/tests/common/TestsAutoLoader.php +++ b/tests/common/TestsAutoLoader.php @@ -120,6 +120,7 @@ $wgAutoloadClasses += [ 'LoggedServiceOptions' => "$testDir/phpunit/includes/config/LoggedServiceOptions.php", # tests/phpunit/includes/content + 'MediaWiki\\Tests\\Content\\CssContentTest' => "$testDir/phpunit/includes/content/CssContentTest.php", 'DummyContentHandlerForTesting' => "$testDir/phpunit/mocks/content/DummyContentHandlerForTesting.php", 'DummyContentForTesting' => "$testDir/phpunit/mocks/content/DummyContentForTesting.php", @@ -127,10 +128,13 @@ $wgAutoloadClasses += [ 'DummyNonTextContent' => "$testDir/phpunit/mocks/content/DummyNonTextContent.php", 'DummySerializeErrorContentHandler' => "$testDir/phpunit/mocks/content/DummySerializeErrorContentHandler.php", + 'MediaWiki\\Tests\\Content\\JavaScriptContentTest' => "$testDir/phpunit/includes/content/JavaScriptContentTest.php", 'TextContentTest' => "$testDir/phpunit/includes/content/TextContentTest.php", + 'MediaWiki\\Tests\\Content\\TextContentTest' => "$testDir/phpunit/includes/content/TextContentTest.php", 'TextContentHandlerIntegrationTest' => "$testDir/phpunit/includes/content/TextContentHandlerIntegrationTest.php", - 'WikitextContentTest' => "$testDir/phpunit/includes/content/WikitextContentTest.php", + 'MediaWiki\\Tests\\Content\\WikitextContentTest' => "$testDir/phpunit/includes/content/WikitextContentTest.php", 'JavaScriptContentHandlerTest' => "$testDir/phpunit/includes/content/JavaScriptContentHandlerTest.php", + 'MediaWiki\\Tests\\Content\\ContentSerializationTestTrait' => "$testDir/phpunit/includes/content/ContentSerializationTestTrait.php", # tests/phpunit/includes/db 'DatabaseTestHelper' => "$testDir/phpunit/includes/db/DatabaseTestHelper.php", diff --git a/tests/phpunit/data/Content/1.45-CssContent-basic.json b/tests/phpunit/data/Content/1.45-CssContent-basic.json new file mode 100644 index 00000000000..14356fd8ce2 --- /dev/null +++ b/tests/phpunit/data/Content/1.45-CssContent-basic.json @@ -0,0 +1 @@ +{"model":"css","format":"text/css","blob":"/* hello */","_type_":"MediaWiki\\Content\\CssContent","_complex_":true} \ No newline at end of file diff --git a/tests/phpunit/data/Content/1.45-CssContent-redirect.json b/tests/phpunit/data/Content/1.45-CssContent-redirect.json new file mode 100644 index 00000000000..f45c7a0508a --- /dev/null +++ b/tests/phpunit/data/Content/1.45-CssContent-redirect.json @@ -0,0 +1 @@ +{"model":"css","format":"text/css","blob":"/* #REDIRECT */@import url(//example.org/w/index.php?title=MediaWiki:MonoBook.css&action=raw&ctype=text/css);","_type_":"MediaWiki\\Content\\CssContent","_complex_":true} \ No newline at end of file diff --git a/tests/phpunit/data/Content/1.45-JavaScriptContent-basic.json b/tests/phpunit/data/Content/1.45-JavaScriptContent-basic.json new file mode 100644 index 00000000000..5ab96a8eecb --- /dev/null +++ b/tests/phpunit/data/Content/1.45-JavaScriptContent-basic.json @@ -0,0 +1 @@ +{"model":"javascript","format":"text/javascript","blob":"/* hello */","_type_":"MediaWiki\\Content\\JavaScriptContent","_complex_":true} \ No newline at end of file diff --git a/tests/phpunit/data/Content/1.45-JavaScriptContent-redirect.json b/tests/phpunit/data/Content/1.45-JavaScriptContent-redirect.json new file mode 100644 index 00000000000..e84d72955ba --- /dev/null +++ b/tests/phpunit/data/Content/1.45-JavaScriptContent-redirect.json @@ -0,0 +1 @@ +{"model":"javascript","format":"text/javascript","blob":"/* #REDIRECT */mw.loader.load(\"//example.org/w/index.php?title=MediaWiki:MonoBook.js&action=raw&ctype=text/javascript\");","_type_":"MediaWiki\\Content\\JavaScriptContent","_complex_":true} \ No newline at end of file diff --git a/tests/phpunit/data/Content/1.45-WikitextContent-basic.json b/tests/phpunit/data/Content/1.45-WikitextContent-basic.json new file mode 100644 index 00000000000..b0caaa52041 --- /dev/null +++ b/tests/phpunit/data/Content/1.45-WikitextContent-basic.json @@ -0,0 +1 @@ +{"model":"wikitext","format":"text/x-wiki","blob":"hello","pstFlags":[],"_type_":"MediaWiki\\Content\\WikitextContent","_complex_":true} \ No newline at end of file diff --git a/tests/phpunit/data/Content/1.45-WikitextContent-withPstFlags.json b/tests/phpunit/data/Content/1.45-WikitextContent-withPstFlags.json new file mode 100644 index 00000000000..aeec7bcb3bb --- /dev/null +++ b/tests/phpunit/data/Content/1.45-WikitextContent-withPstFlags.json @@ -0,0 +1 @@ +{"model":"wikitext","format":"text/x-wiki","blob":"with PST flags","pstFlags":["show-toc","vary-revision"],"_type_":"MediaWiki\\Content\\WikitextContent","_complex_":true} \ No newline at end of file diff --git a/tests/phpunit/data/PageEditStashContents/1.45_content-PageEditStashContents-basic.json b/tests/phpunit/data/PageEditStashContents/1.45_content-PageEditStashContents-basic.json new file mode 100644 index 00000000000..7939b518555 --- /dev/null +++ b/tests/phpunit/data/PageEditStashContents/1.45_content-PageEditStashContents-basic.json @@ -0,0 +1 @@ +{"pstContent":{"model":"wikitext","format":"text/x-wiki","blob":"hello","_type_":"MediaWiki\\Content\\WikitextContent","_complex_":true},"output":{"Text":null,"LanguageLinks":[],"Categories":[],"Indicators":[],"TitleText":"","Links":[],"LinksSpecial":[],"Templates":[],"TemplateIds":[],"Images":[],"FileSearchOptions":[],"ExternalLinks":[],"InterwikiLinks":[],"ExistenceLinks":[],"NewSection":false,"HideNewSection":false,"NoGallery":false,"HeadItems":[],"Modules":[],"ModuleStyles":[],"JsConfigVars":[],"Warnings":[],"WarningMsgs":[],"Sections":[],"Properties":[],"Timestamp":null,"EnableOOUI":false,"IndexPolicy":"","ExtensionData":{"test1":"test2"},"LimitReportData":[],"LimitReportJSData":[],"CacheMessage":"","TimeProfile":[],"ParseStartTime":[],"PreventClickjacking":false,"ExtraScriptSrcs":[],"ExtraDefaultSrcs":[],"ExtraStyleSrcs":[],"Flags":[],"SpeculativeRevId":null,"SpeculativePageIdUsed":null,"RevisionTimestampUsed":null,"RevisionUsedSha1Base36":null,"WrapperDivClasses":[],"ParseUsedOptions":[],"CacheExpiry":null,"CacheTime":"20250701173710","CacheRevisionId":null,"_type_":"MediaWiki\\Parser\\ParserOutput","_complex_":true},"timestamp":"20250701173710","edits":42,"_type_":"MediaWiki\\Storage\\PageEditStashContents","_complex_":true} \ No newline at end of file diff --git a/tests/phpunit/includes/content/ContentSerializationTestTrait.php b/tests/phpunit/includes/content/ContentSerializationTestTrait.php new file mode 100644 index 00000000000..bd28c925b1d --- /dev/null +++ b/tests/phpunit/includes/content/ContentSerializationTestTrait.php @@ -0,0 +1,28 @@ +getJsonCodec(); + return [ [ + 'ext' => 'json', + 'serializer' => static function ( $obj ) use ( $jsonCodec ) { + return $jsonCodec->serialize( $obj ); + }, + 'deserializer' => static function ( $data ) use ( $jsonCodec ) { + return $jsonCodec->deserialize( $data ); + }, + ] ]; + } +} diff --git a/tests/phpunit/includes/content/CssContentTest.php b/tests/phpunit/includes/content/CssContentTest.php index bfaad5d9b64..0e24b1ade44 100644 --- a/tests/phpunit/includes/content/CssContentTest.php +++ b/tests/phpunit/includes/content/CssContentTest.php @@ -1,4 +1,7 @@ assertEquals( $equal, $a->equals( $b ) ); } + + public static function getClassToTest(): string { + return CssContent::class; + } + + public static function getTestInstancesAndAssertions(): array { + $redirects = self::provideGetRedirectTarget(); + [ $redirectTitle, $redirectBlob ] = $redirects[0]; + return [ + 'basic' => [ + 'instance' => new CssContent( '/* hello */' ), + 'assertions' => static function ( $testCase, $obj ) { + $testCase->assertInstanceof( CssContent::class, $obj ); + $testCase->assertSame( '/* hello */', $obj->getText() ); + $testCase->assertNull( $obj->getRedirectTarget() ); + }, + ], + 'redirect' => [ + 'instance' => new CssContent( $redirectBlob ), + 'assertions' => static function ( $testCase, $obj ) use ( $redirectTitle, $redirectBlob ) { + $testCase->overrideConfigValues( [ + MainConfigNames::Server => '//example.org', + MainConfigNames::ScriptPath => '/w', + MainConfigNames::Script => '/w/index.php', + ] ); + $testCase->assertInstanceof( CssContent::class, $obj ); + $testCase->assertSame( $redirectBlob, $obj->getText() ); + $testCase->assertSame( $redirectTitle, $obj->getRedirectTarget()->getPrefixedText() ); + }, + ], + ]; + } } diff --git a/tests/phpunit/includes/content/JavaScriptContentTest.php b/tests/phpunit/includes/content/JavaScriptContentTest.php index 8eeb7e675c0..0d0ad7ea207 100644 --- a/tests/phpunit/includes/content/JavaScriptContentTest.php +++ b/tests/phpunit/includes/content/JavaScriptContentTest.php @@ -1,5 +1,9 @@ [ + 'instance' => new JavaScriptContent( '/* hello */' ), + 'assertions' => static function ( $testCase, $obj ) { + $testCase->assertInstanceof( JavaScriptContent::class, $obj ); + $testCase->assertSame( '/* hello */', $obj->getText() ); + $testCase->assertNull( $obj->getRedirectTarget() ); + }, + ], + 'redirect' => [ + 'instance' => new JavaScriptContent( $redirectBlob ), + 'assertions' => static function ( $testCase, $obj ) use ( $redirectTitle, $redirectBlob ) { + $testCase->overrideConfigValues( [ + MainConfigNames::Server => '//example.org', + MainConfigNames::ScriptPath => '/w', + MainConfigNames::Script => '/w/index.php', + MainConfigNames::ResourceBasePath => '/w', + ] ); + $testCase->assertInstanceof( JavaScriptContent::class, $obj ); + $testCase->assertSame( $redirectBlob, $obj->getText() ); + $testCase->assertSame( $redirectTitle, $obj->getRedirectTarget()->getPrefixedText() ); + }, + ], + ]; + } } diff --git a/tests/phpunit/includes/content/TextContentTest.php b/tests/phpunit/includes/content/TextContentTest.php index 0d5235f0041..b6906f1904c 100644 --- a/tests/phpunit/includes/content/TextContentTest.php +++ b/tests/phpunit/includes/content/TextContentTest.php @@ -1,4 +1,7 @@ setPreSaveTransformFlags( $pstFlags ); + + return [ + 'basic' => [ + 'instance' => new WikitextContent( 'hello' ), + 'assertions' => static function ( $testCase, $obj ) { + $testCase->assertInstanceof( WikitextContent::class, $obj ); + $testCase->assertSame( 'hello', $obj->getText() ); + $testCase->assertArrayEquals( [], $obj->getPreSaveTransformFlags() ); + }, + ], + 'withPstFlags' => [ + 'instance' => $withPstFlags, + 'assertions' => static function ( $testCase, $obj ) use ( $pstFlags ) { + $testCase->assertInstanceof( WikitextContent::class, $obj ); + $testCase->assertSame( 'with PST flags', $obj->getText() ); + $testCase->assertArrayEquals( $pstFlags, $obj->getPreSaveTransformFlags() ); + }, + ], + ]; + } } diff --git a/tests/phpunit/includes/content/validateContentTestData.php b/tests/phpunit/includes/content/validateContentTestData.php new file mode 100644 index 00000000000..e50ffdb66a8 --- /dev/null +++ b/tests/phpunit/includes/content/validateContentTestData.php @@ -0,0 +1,122 @@ +addArg( + 'path', + 'Path of serialization files.', + false + ); + $this->addOption( 'create', 'Create missing serialization' ); + $this->addOption( 'update', 'Update mismatching serialization files' ); + $this->addOption( 'version', 'Specify version for which to check serialization. ' + . 'Also determines which files may be created or updated if ' + . 'the respective options are set.' + . 'Unserialization is always checked against all versions. ', false, true ); + } + + public function execute() { + $tests = [ + WikitextContentTest::class, + JavaScriptContentTest::class, + CssContentTest::class, + ]; + foreach ( $tests as $testClass ) { + $objClass = $testClass::getClassToTest(); + $this->validateSerialization( + $objClass, + $testClass::getSerializedDataPath(), + $testClass::getSupportedSerializationFormats(), + array_map( static function ( $testCase ) { + return $testCase['instance']; + }, $testClass::getTestInstancesAndAssertions() ) + ); + } + } + + /** + * Ensures that objects will serialize into the form expected for the given version. + * If the respective options are set in the constructor, this will create missing files or + * update mismatching files. + * + * @param string $className + * @param string $defaultDirectory + * @param array $supportedFormats + * @param array $testInstances + */ + public function validateSerialization( + string $className, + string $defaultDirectory, + array $supportedFormats, + array $testInstances + ) { + $ok = true; + foreach ( $supportedFormats as $serializationFormat ) { + $serializationUtils = new SerializationTestUtils( + $this->getArg( 1 ) ?: $defaultDirectory, + $testInstances, + $serializationFormat['ext'], + $serializationFormat['serializer'], + $serializationFormat['deserializer'] + ); + $serializationUtils->setLogger( new ConsoleLogger( 'validator' ) ); + foreach ( $serializationUtils->getSerializedInstances() as $testCaseName => $currentSerialized ) { + $expected = $serializationUtils + ->getStoredSerializedInstance( $className, $testCaseName, $this->getOption( 'version' ) ); + $ok = $this->validateSerializationData( $currentSerialized, $expected ) && $ok; + } + } + if ( !$ok ) { + $this->output( "\n\n" ); + $this->fatalError( "Serialization data mismatch! " + . "If this was expected, rerun the script with the --update option " + . "to update the expected serialization. WARNING: make sure " + . "a forward compatible version of the code is live before deploying a " + . "serialization change!\n" + ); + } + } + + private function validateSerializationData( string $data, \stdClass $fileInfo ): bool { + if ( !$fileInfo->data ) { + if ( $this->hasOption( 'create' ) ) { + $this->output( 'Creating file: ' . $fileInfo->path . "\n" ); + file_put_contents( $fileInfo->path, $data ); + } else { + $this->fatalError( "File not found: {$fileInfo->path}. " + . "Rerun the script with the --create option set to create it." + ); + } + } else { + if ( $data !== $fileInfo->data ) { + if ( $this->hasOption( 'update' ) ) { + $this->output( 'Data mismatch, updating file: ' . $fileInfo->currentVersionPath . "\n" ); + file_put_contents( $fileInfo->currentVersionPath, $data ); + } else { + $this->output( 'Serialization MISMATCH: ' . $fileInfo->path . "\n" ); + return false; + } + } else { + $this->output( "Serialization OK: " . $fileInfo->path . "\n" ); + } + } + return true; + } +} + +return ValidateContentTestData::class;