Local implementation of ParsoidClient (DirectParsoidClient)

* DirectParsoidClient makes use of parsoid directly for performing
transformations on both wikitext and/or HTML contents.

* Also, it's used to fetch HTML from parsoid's parser cache. Before,
this operation was done via RESTBase but now it's being fetched in
core's parsoid parser cache.

* This patch also enables VE clients to transform HTML to
Wikitext when switching from HTML to source mode on. It
makes use of the HtmlInputTransformHelper to perform this
transformation.

* Now, VE client can make use of core code for switching
between HTML to source mode and back without RESTBase.

Change-Id: I5c7cfcc4086d8da7905897194d8601aa07418b59
This commit is contained in:
daniel 2022-08-28 21:25:04 +02:00 committed by Derick Alangi
parent 80504b2c03
commit 35cb550747
6 changed files with 476 additions and 317 deletions

View file

@ -1,6 +1,6 @@
<?php
/**
* Helper functions for using the REST interface to Parsoid/RESTBase.
* Helper functions for using the REST interface to Parsoid.
*
* @file
* @ingroup Extensions
@ -10,22 +10,24 @@
namespace MediaWiki\Extension\VisualEditor;
use Exception;
use IBufferingStatsdDataFactory;
use Language;
use LocalizedException;
use MediaWiki\Edit\ParsoidOutputStash;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Parser\Parsoid\Config\PageConfigFactory;
use MediaWiki\Parser\Parsoid\HTMLTransformFactory;
use MediaWiki\Parser\Parsoid\ParsoidOutputAccess;
use MediaWiki\Permissions\Authority;
use MediaWiki\Rest\Handler\HtmlInputTransformHelper;
use MediaWiki\Rest\Handler\HtmlOutputRendererHelper;
use MediaWiki\Rest\HttpException;
use MediaWiki\Rest\LocalizedHttpException;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\SlotRecord;
use ParserOutput;
use RequestContext;
use Title;
use WikiMap;
use Wikimedia\Parsoid\Config\DataAccess;
use Wikimedia\Parsoid\Config\SiteConfig;
use Wikimedia\Parsoid\Core\SelserData;
use Wikimedia\Parsoid\Parsoid;
use Wikimedia\Parsoid\Utils\DOMUtils;
use Wikimedia\UUID\GlobalIdGenerator;
use RawMessage;
use User;
use WikitextContent;
class DirectParsoidClient implements ParsoidClient {
@ -35,103 +37,198 @@ class DirectParsoidClient implements ParsoidClient {
* ve.init.mw.ArticleTargetLoader.js
*/
public const PARSOID_VERSION = '2.4.0';
private const FLAVOR_DEFAULT = 'view';
/** @var array Parsoid-specific settings array from $config */
private $parsoidSettings;
/** @var ParsoidOutputStash */
private $parsoidOutputStash;
/** @var SiteConfig */
protected $siteConfig;
/** @var IBufferingStatsdDataFactory */
private $stats;
/** @var PageConfigFactory */
protected $pageConfigFactory;
/** @var ParsoidOutputAccess */
private $parsoidOutputAccess;
/** @var DataAccess */
protected $dataAccess;
/** @var Authority */
private $performer;
/** @var GlobalIdGenerator */
private $globalIdGenerator;
/** @var HTMLTransformFactory */
private $htmlTransformFactory;
/**
* @param array $parsoidSettings
* @param SiteConfig $siteConfig
* @param PageConfigFactory $pageConfigFactory
* @param DataAccess $dataAccess
* @param GlobalIdGenerator $globalIdGenerator
* @param ParsoidOutputStash $parsoidOutputStash
* @param IBufferingStatsdDataFactory $statsDataFactory
* @param ParsoidOutputAccess $parsoidOutputAccess
* @param HTMLTransformFactory $htmlTransformFactory
* @param Authority $performer
*/
public function __construct(
array $parsoidSettings,
SiteConfig $siteConfig,
PageConfigFactory $pageConfigFactory,
DataAccess $dataAccess,
GlobalIdGenerator $globalIdGenerator
ParsoidOutputStash $parsoidOutputStash,
IBufferingStatsdDataFactory $statsDataFactory,
ParsoidOutputAccess $parsoidOutputAccess,
HTMLTransformFactory $htmlTransformFactory,
Authority $performer
) {
$this->parsoidSettings = $parsoidSettings;
$this->siteConfig = $siteConfig;
$this->pageConfigFactory = $pageConfigFactory;
$this->dataAccess = $dataAccess;
$this->globalIdGenerator = $globalIdGenerator;
$this->parsoidOutputStash = $parsoidOutputStash;
$this->stats = $statsDataFactory;
$this->parsoidOutputAccess = $parsoidOutputAccess;
$this->htmlTransformFactory = $htmlTransformFactory;
$this->performer = $performer;
}
/**
* Request page HTML
* @param PageIdentity $page
* @param RevisionRecord|null $revision
* @param Language|null $pageLanguage
* @param bool $stash
* @param string $flavor
*
* @return HtmlOutputRendererHelper
*/
private function getHtmlOutputRendererHelper(
PageIdentity $page,
?RevisionRecord $revision = null,
Language $pageLanguage = null,
bool $stash = false,
string $flavor = self::FLAVOR_DEFAULT
): HtmlOutputRendererHelper {
$helper = new HtmlOutputRendererHelper(
$this->parsoidOutputStash,
$this->stats,
$this->parsoidOutputAccess
);
// Fake REST params
$params = [
'stash' => $stash,
'flavor' => $flavor,
];
$user = User::newFromIdentity( $this->performer->getUser() );
$helper->init( $page, $params, $user, $revision, $pageLanguage );
return $helper;
}
/**
* @param PageIdentity $page
* @param string $html
* @param int|null $oldid
* @param string|null $etag
* @param Language|null $pageLanguage
*
* @return HtmlInputTransformHelper
*/
private function getHtmlInputTransformHelper(
PageIdentity $page,
string $html,
int $oldid = null,
string $etag = null,
Language $pageLanguage = null
): HtmlInputTransformHelper {
$helper = new HtmlInputTransformHelper(
$this->stats,
$this->htmlTransformFactory,
$this->parsoidOutputStash,
$this->parsoidOutputAccess
);
// Fake REST body
$body = [
'html' => [
'body' => $html,
],
'original' => [
'revid' => $oldid,
'etag' => $etag,
]
];
$helper->init( $page, $body, [], null, $pageLanguage );
return $helper;
}
/**
* Request page HTML from Parsoid.
*
* @param RevisionRecord $revision Page revision
* @param ?Language $targetLanguage Desired output language
* @param ?Language $targetLanguage Page language (default: `null`)
*
* @return array The response
* @return array An array mimicking a RESTbase server's response,
* with keys: 'error', 'headers' and 'body'
*/
public function getPageHtml( RevisionRecord $revision, ?Language $targetLanguage ): array {
public function getPageHtml( RevisionRecord $revision, ?Language $targetLanguage = null ): array {
// In the VE client, we always want to stash.
$page = $revision->getPage();
$title = Title::castFromPageIdentity( $page );
$targetLanguage = $targetLanguage ?: $title->getPageLanguage();
$oldid = $revision->getId();
$lang = $targetLanguage->getCode();
// This is /page/html/$title/$revision?redirect=false&stash=true
// With Accept-Language: $lang
$envOptions = [
// $attribs['envOptions'] is created in ParsoidHandler::getRequestAttributes()
'prefix' => WikiMap::getCurrentWikiId(),
'pageName' => $title->getPrefixedDBkey(),
'htmlVariantLanguage' => $lang,
'outputContentVersion' => Parsoid::resolveContentVersion(
self::PARSOID_VERSION
) ?? Parsoid::defaultHTMLVersion(),
];
// $pageConfig originally created in
// ParsoidHandler::tryToCreatePageConfig
$user = RequestContext::getMain()->getUser();
// Note: Parsoid by design isn't supposed to use the user
// context right now, and all user state is expected to be
// introduced as a post-parse transform. So although we pass a
// User here, it only currently affects the output in obscure
// corner cases; see PageConfigFactory::create() for more.
$pageConfig = $this->pageConfigFactory->create(
$page, $user, $revision, null, $lang, $this->parsoidSettings
);
if ( $pageConfig->getRevisionContent() === null ) {
throw new \LogicException( "Specified revision does not exist" );
$helper = $this->getHtmlOutputRendererHelper( $page, $revision, $targetLanguage, true );
try {
$parserOutput = $helper->getHtml();
return $this->fakeRESTbaseHTMLResponse( $parserOutput->getRawText(), $helper );
} catch ( HttpException $ex ) {
return $this->fakeRESTbaseError( $ex );
}
$parsoid = new Parsoid( $this->siteConfig, $this->dataAccess );
$parserOutput = new ParserOutput();
// Note that $headers is an out parameter
// $envOptions originally included $opts['contentmodel'] here as well
$out = $parsoid->wikitext2html(
$pageConfig, $envOptions, $headers, $parserOutput
);
$tid = $this->globalIdGenerator->newUUIDv1();
$etag = "W/\"{$oldid}/{$tid}\"";
# XXX: we could cache this locally using the $etag as a key,
# then reuse it when transforming back to wikitext below.
return [
'body' => $out,
'headers' => $headers + [
'etag' => $etag,
],
];
}
/**
* Transform HTML to wikitext via Parsoid
* @param PageIdentity $page
* @param string $wikitext
*
* @return RevisionRecord
*/
private function makeFakeRevision(
PageIdentity $page,
string $wikitext
): RevisionRecord {
$rev = new MutableRevisionRecord( $page );
$rev->setId( 0 );
$rev->setPageId( $page->getId() );
$rev->setContent( SlotRecord::MAIN, new WikitextContent( $wikitext ) );
return $rev;
}
/**
* Transform wikitext to HTML with Parsoid. Wrapper for ::postData().
*
* @param PageIdentity $page The page the content belongs to use as the parsing context
* @param Language $targetLanguage Page language
* @param string $wikitext The wikitext fragment to parse
* @param bool $bodyOnly Whether to provide only the contents of the `<body>` tag
* @param int|null $oldid What oldid revision, if any, to base the request from (default: `null`)
* @param bool $stash Whether to stash the result in the server-side cache (default: `false`)
*
* @return array An array mimicking a RESTbase server's response,
* with keys 'code', 'reason', 'headers' and 'body'
*/
public function transformWikitext(
PageIdentity $page,
Language $targetLanguage,
string $wikitext,
bool $bodyOnly,
?int $oldid,
bool $stash
): array {
$revision = $this->makeFakeRevision( $page, $wikitext );
$helper = $this->getHtmlOutputRendererHelper( $page, $revision, $targetLanguage, $stash );
if ( $bodyOnly ) {
$helper->setFlavor( 'fragment' );
}
try {
$parserOutput = $helper->getHtml();
$html = $parserOutput->getRawText();
return $this->fakeRESTbaseHTMLResponse( $html, $helper );
} catch ( HttpException $ex ) {
return $this->fakeRESTbaseError( $ex );
}
}
/**
* Transform HTML to wikitext with Parsoid
*
* @param PageIdentity $page The page the content belongs to
* @param Language $targetLanguage The desired output language
@ -144,120 +241,63 @@ class DirectParsoidClient implements ParsoidClient {
public function transformHTML(
PageIdentity $page, Language $targetLanguage, string $html, ?int $oldid, ?string $etag
): array {
// This is POST /transform/html/to/wikitext/$title/$oldid
// with header If-Match: $etag
// and data: [ 'html' => $html ]
$lang = $targetLanguage->getCode();
// $pageConfig originally created in
// ParsoidHandler::tryToCreatePageConfig
$user = RequestContext::getMain()->getUser();
// Note: Parsoid by design isn't supposed to use the user
// context right now, and all user state is expected to be
// introduced as a post-parse transform. So although we pass a
// User here, it only currently affects the output in obscure
// corner cases; see PageConfigFactory::create() for more.
$pageConfig = $this->pageConfigFactory->create(
$page, $user, $oldid, null, $lang, $this->parsoidSettings
);
$doc = DOMUtils::parseHTML( $html, true );
$vEdited = DOMUtils::extractInlinedContentVersion( $doc ) ??
Parsoid::defaultHTMLVersion();
// T267990: This should be replaced by PET's ParserCache/stash
// mechanism. (RESTBase did the fetch based on the etag, and then
// compared vEdited to vOriginal to determine if it was usable.)
$oldHtml = null;
$selserData = ( $oldid === null ) ? null : new SelserData(
$pageConfig->getPageMainContent(), $oldHtml
);
$parsoid = new Parsoid( $this->siteConfig, $this->dataAccess );
$wikitext = $parsoid->dom2wikitext( $pageConfig, $doc, [
'inputContentVersion' => $vEdited,
'htmlSize' => mb_strlen( $html ),
], $selserData );
$helper = $this->getHtmlInputTransformHelper( $page, $html, $oldid, $etag, $targetLanguage );
try {
$content = $helper->getContent();
$format = $content->getDefaultFormat();
return [
'code' => 200,
'headers' => [
'Content-Type' => $format,
],
'body' => $content->serialize( $format ),
];
} catch ( HttpException $ex ) {
return $this->fakeRESTbaseError( $ex );
}
}
/**
* @param mixed $data
* @param HtmlOutputRendererHelper $helper
*
* @return array
*/
private function fakeRESTbaseHTMLResponse( $data, HtmlOutputRendererHelper $helper ): array {
return [
'body' => $wikitext,
'headers' => [],
'code' => 200,
'headers' => [
'content-language' => $helper->getHtmlOutputContentLanguage(),
'etag' => $helper->getETag()
],
'body' => $data,
];
}
/**
* Transform wikitext to HTML via Parsoid.
* @param Exception $ex
*
* @param PageIdentity $page The page the content belongs to
* @param Language $targetLanguage The desired output language
* @param string $wikitext The wikitext fragment to parse
* @param bool $bodyOnly Whether to provide only the contents of the `<body>` tag
* @param ?int $oldid What oldid revision, if any, to base the request from (default: `null`)
* @param bool $stash Whether to stash the result in the server-side cache (default: `false`)
*
* @return array The response, 'code', 'reason', 'headers' and 'body'
* @return array
*/
public function transformWikitext(
PageIdentity $page, Language $targetLanguage, string $wikitext,
bool $bodyOnly, ?int $oldid, bool $stash
): array {
$title = Title::castFromPageIdentity( $page );
private function fakeRESTbaseError( Exception $ex ): array {
if ( $ex instanceof LocalizedHttpException ) {
$msg = $ex->getMessageValue();
} elseif ( $ex instanceof LocalizedException ) {
$msg = $ex->getMessageObject();
} else {
$msg = new RawMessage( $ex->getMessage() );
}
// This is POST /transform/wikitext/to/html/$title/$oldid
// with data: [
// 'wikitext' => $wikitext,
// 'body_only' => $bodyOnly,
// 'stash' => $stash,
// ]
// T267990: Stashing features are not implemented in zero-conf mode;
// they will eventually be replaced by PET's ParserCache/stash mechanism
$lang = $targetLanguage->getCode();
$envOptions = [
// $attribs['envOptions'] is created in ParsoidHandler::getRequestAttributes()
'prefix' => WikiMap::getCurrentWikiId(),
'pageName' => $title->getPrefixedDBkey(),
'htmlVariantLanguage' => $lang,
'outputContentVersion' => Parsoid::resolveContentVersion(
self::PARSOID_VERSION
) ?? Parsoid::defaultHTMLVersion(),
'body_only' => $bodyOnly,
// When VE does a fragment expansion (for example when
// template arguments are edited and it wants an updated
// render of the template) it's not going to want section
// tags; in this case bodyOnly=true and wrapSections=false.
// (T181226)
// But on the other hand, VE doesn't do anything with section
// tags right now other than strip them, so we'll just always
// pass wrapSections=false for now.
'wrapSections' => false,
];
// $pageConfig originally created in
// ParsoidHandler::tryToCreatePageConfig
$user = RequestContext::getMain()->getUser();
// Note: Parsoid by design isn't supposed to use the user
// context right now, and all user state is expected to be
// introduced as a post-parse transform. So although we pass a
// User here, it only currently affects the output in obscure
// corner cases; see PageConfigFactory::create() for more.
// Create a mutable revision record and set to the desired wikitext.
$tmpRevision = new MutableRevisionRecord( $page );
$tmpRevision->setSlot(
SlotRecord::newUnsaved(
SlotRecord::MAIN,
new WikitextContent( $wikitext )
)
);
$pageConfig = $this->pageConfigFactory->create(
$page, $user, $tmpRevision, null, $lang, $this->parsoidSettings
);
$parsoid = new Parsoid( $this->siteConfig, $this->dataAccess );
$parserOutput = new ParserOutput();
// Note that $headers is an out parameter
$out = $parsoid->wikitext2html(
$pageConfig, $envOptions, $headers, $parserOutput
);
// No etag generation in this pathway, and no caching
// This is just used to update previews when you edit a template
return [
'body' => $out,
'headers' => $headers,
'error' => [
'message' => $msg->getKey() ?? '',
'params' => $msg->getParams() ?? []
],
'headers' => [],
'body' => $ex->getMessage(),
];
}
}

View file

@ -33,12 +33,12 @@ return [
VisualEditorParsoidClientFactory::ENABLE_COOKIE_FORWARDING => $isPrivateWiki
]
),
$services->getParsoidSiteConfig(),
$services->getParsoidPageConfigFactory(),
$services->getParsoidDataAccess(),
$services->getGlobalIdGenerator(),
$services->getHttpRequestFactory(),
LoggerFactory::getInstance( 'VisualEditor' )
LoggerFactory::getInstance( 'VisualEditor' ),
$services->getParsoidOutputStash(),
$services->getStatsdDataFactory(),
$services->getParsoidOutputAccess(),
$services->getHTMLTransformFactory()
);
},
];

View file

@ -3,19 +3,20 @@
namespace MediaWiki\Extension\VisualEditor;
use ConfigException;
use IBufferingStatsdDataFactory;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Edit\ParsoidOutputStash;
use MediaWiki\Http\HttpRequestFactory;
use MediaWiki\MainConfigNames;
use MediaWiki\Parser\Parsoid\Config\PageConfigFactory;
use MediaWiki\Parser\Parsoid\HTMLTransformFactory;
use MediaWiki\Parser\Parsoid\ParsoidOutputAccess;
use MediaWiki\Permissions\Authority;
use ParsoidVirtualRESTService;
use Psr\Log\LoggerInterface;
use RequestContext;
use RestbaseVirtualRESTService;
use VirtualRESTService;
use VirtualRESTServiceClient;
use Wikimedia\Assert\Assert;
use Wikimedia\Parsoid\Config\DataAccess;
use Wikimedia\Parsoid\Config\SiteConfig;
use Wikimedia\UUID\GlobalIdGenerator;
/**
* @since 1.40
@ -40,24 +41,11 @@ class VisualEditorParsoidClientFactory {
* @var array
*/
public const CONSTRUCTOR_OPTIONS = [
MainConfigNames::ParsoidSettings,
MainConfigNames::VirtualRestConfig,
self::USE_AUTO_CONFIG,
self::ENABLE_COOKIE_FORWARDING
];
/** @var SiteConfig */
private $siteConfig;
/** @var PageConfigFactory */
private $pageConfigFactory;
/** @var DataAccess */
private $dataAccess;
/** @var GlobalIdGenerator */
private $globalIdGenerator;
/** @var HttpRequestFactory */
private $httpRequestFactory;
@ -70,45 +58,61 @@ class VisualEditorParsoidClientFactory {
/** @var LoggerInterface */
private $logger;
/** @var ParsoidOutputStash */
private $parsoidOutputStash;
/** @var IBufferingStatsdDataFactory */
private $statsDataFactory;
/** @var ParsoidOutputAccess */
private $parsoidOutputAccess;
/** @var HTMLTransformFactory */
private $htmlTransformFactory;
/**
* @param ServiceOptions $options
* @param SiteConfig $siteConfig
* @param PageConfigFactory $pageConfigFactory
* @param DataAccess $dataAccess
* @param GlobalIdGenerator $globalIdGenerator
* @param HttpRequestFactory $httpRequestFactory
* @param LoggerInterface $logger
* @param ParsoidOutputStash $parsoidOutputStash
* @param IBufferingStatsdDataFactory $statsDataFactory
* @param ParsoidOutputAccess $parsoidOutputAccess
* @param HTMLTransformFactory $htmlTransformFactory
*/
public function __construct(
ServiceOptions $options,
SiteConfig $siteConfig,
PageConfigFactory $pageConfigFactory,
DataAccess $dataAccess,
GlobalIdGenerator $globalIdGenerator,
HttpRequestFactory $httpRequestFactory,
LoggerInterface $logger
LoggerInterface $logger,
ParsoidOutputStash $parsoidOutputStash,
IBufferingStatsdDataFactory $statsDataFactory,
ParsoidOutputAccess $parsoidOutputAccess,
HTMLTransformFactory $htmlTransformFactory
) {
$this->options = $options;
$this->options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
$this->siteConfig = $siteConfig;
$this->pageConfigFactory = $pageConfigFactory;
$this->dataAccess = $dataAccess;
$this->globalIdGenerator = $globalIdGenerator;
$this->httpRequestFactory = $httpRequestFactory;
$this->logger = $logger;
$this->parsoidOutputStash = $parsoidOutputStash;
$this->statsDataFactory = $statsDataFactory;
$this->parsoidOutputAccess = $parsoidOutputAccess;
$this->htmlTransformFactory = $htmlTransformFactory;
}
/**
* Create a ParsoidClient for accessing Parsoid.
*
* @param string|string[]|false $cookiesToForward
* @param Authority|null $performer
*
* @return ParsoidClient
*/
public function createParsoidClient( $cookiesToForward ): ParsoidClient {
public function createParsoidClient( $cookiesToForward, ?Authority $performer = null ): ParsoidClient {
if ( $performer === null ) {
$performer = RequestContext::getMain()->getAuthority();
}
// Default to using the direct client.
$client = $this->createDirectClient();
$client = $this->createDirectClient( $performer );
if ( !$client ) {
// Default to using the direct client.
@ -122,9 +126,13 @@ class VisualEditorParsoidClientFactory {
}
/**
* Create a ParsoidClient for accessing Parsoid.
*
* @param Authority $performer
*
* @return ?ParsoidClient
*/
private function createDirectClient(): ?ParsoidClient {
private function createDirectClient( Authority $performer ): ?ParsoidClient {
// We haven't checked configuration yet.
// Check to see if any of the restbase-related configuration
// variables are set, and bail if so:
@ -142,11 +150,11 @@ class VisualEditorParsoidClientFactory {
}
return new DirectParsoidClient(
$this->options->get( MainConfigNames::ParsoidSettings ),
$this->siteConfig,
$this->pageConfigFactory,
$this->dataAccess,
$this->globalIdGenerator
$this->parsoidOutputStash,
$this->statsDataFactory,
$this->parsoidOutputAccess,
$this->htmlTransformFactory,
$performer
);
}
@ -165,11 +173,6 @@ class VisualEditorParsoidClientFactory {
* @return VirtualRESTService the VirtualRESTService object to use
*/
private function createVRSObject( $forwardCookies ): VirtualRESTService {
Assert::precondition(
!$this->createDirectClient(),
"Direct Parsoid access is configured but the VirtualRESTService was used"
);
// the params array to create the service object with
$params = [];
// the VRS class to use, defaults to Parsoid

View file

@ -66,63 +66,105 @@ describe( 'Visual Editor API', function () {
// VisualEditor edit: 'visualeditoredit' action API ///
const page = utils.title( 'VisualEditorNew' );
it( 'Should create page, edit and save page', async () => {
const token = await alice.token();
const html = '<p>save paction</p>';
const summary = 'save test workflow';
const result = await alice.action(
'visualeditoredit',
{
page: page,
paction: 'save',
token: token,
html: html,
summary: summary
},
'post'
);
describe( 'Editing', function () {
it( 'Should create page, edit and save page with HTML', async () => {
const token = await alice.token();
const html = '<p>save paction</p>';
const summary = 'save test workflow';
const result = await alice.action(
'visualeditoredit',
{
page: page,
paction: 'save',
token: token,
html: html,
summary: summary
},
'post'
);
assert.equal( result.visualeditoredit.result, 'success' );
} );
assert.equal( result.visualeditoredit.result, 'success' );
} );
it( 'Should refuse to edit with a bad token', async () => {
const token = 'dshfkjdsakf';
const html = '<p>save paction</p>';
const summary = 'save test workflow';
const error = await alice.actionError(
'visualeditoredit',
{
page: page,
paction: 'save',
token: token,
html: html,
summary: summary
},
'post'
);
it( 'Should refuse to edit with a bad token', async () => {
const token = 'dshfkjdsakf';
const html = '<p>save paction</p>';
const summary = 'save test workflow';
const error = await alice.actionError(
'visualeditoredit',
{
page: page,
paction: 'save',
token: token,
html: html,
summary: summary
},
'post'
);
assert.equal( error.code, 'badtoken' );
} );
assert.equal( error.code, 'badtoken' );
} );
it( 'Should edit page and save with Wikitext', async () => {
const token = await alice.token();
const html = '<p>save paction</p>';
const summary = 'save test workflow';
const wikitext = 'wikitext string in page test';
const result = await alice.action(
'visualeditoredit',
{
page: page,
paction: 'save',
token: token,
html: html,
wikitext: wikitext,
summary: summary
},
'post'
);
assert.equal( result.visualeditoredit.result, 'success' );
assert.include( result.visualeditoredit.content, wikitext );
it( 'Should use selser when editing', async () => {
const token = await alice.token();
let result;
// Create a page with messy wikitext
const originalWikitext = '*a\n* b\n* <i>c</I>';
result = await alice.action(
'visualeditoredit',
{
page,
paction: 'save',
token,
wikitext: originalWikitext,
summary: 'editing wikitext'
},
'post'
);
assert.equal( result.visualeditoredit.result, 'success' );
// Fetch HTML for editing
result = await alice.action( 'visualeditor', { page, paction: 'parse' } );
assert.equal( result.visualeditor.result, 'success' );
let html = result.visualeditor.content;
const etag = result.visualeditor.etag;
const oldid = result.visualeditor.oldid;
// Append to HTML
html = html.replace( '</body>', '<p>More Text</p></body>' );
result = await alice.action(
'visualeditoredit',
{
page,
paction: 'save',
token,
html,
etag,
oldid,
summary: 'appending html'
},
'post'
);
// TODO: Make a test that will fail if the etag is not used to look up stashed HTML.
// This test will pass even if stashing is not used, because in that case
// the base revision will be re-rendered, and the HTML will still match.
assert.equal( result.visualeditoredit.result, 'success' );
// Fetch wikitext to check
result = await alice.action( 'visualeditor', { page, paction: 'wikitext' } );
assert.equal( result.visualeditor.result, 'success' );
// Make sure the new content was appended, but the wikitext was kept
// in its original messy state.
const newWikitext = result.visualeditor.content;
assert.include( newWikitext, originalWikitext );
assert.include( newWikitext, 'More Text' );
} );
} );
it( 'Should show page diff', async () => {
@ -132,7 +174,7 @@ describe( 'Visual Editor API', function () {
const result = await alice.action(
'visualeditoredit',
{
page: page,
page: title,
paction: 'diff',
token: token,
html: html,
@ -150,7 +192,7 @@ describe( 'Visual Editor API', function () {
const result = await alice.action(
'visualeditoredit',
{
page: page,
page: title,
paction: 'serialize',
token: token,
html: html,
@ -171,7 +213,7 @@ describe( 'Visual Editor API', function () {
const result = await alice.action(
'visualeditoredit',
{
page: page,
page: title,
paction: 'serializeforcache',
token: token,
html: html,

View file

@ -6,6 +6,8 @@ use Generator;
use Language;
use MediaWiki\Extension\VisualEditor\DirectParsoidClient;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Parser\Parsoid\ParsoidOutputAccess;
use MediaWiki\Revision\RevisionRecord;
use MediaWikiIntegrationTestCase;
/**
@ -20,11 +22,11 @@ class DirectParsoidClientTest extends MediaWikiIntegrationTestCase {
private function createDirectClient(): DirectParsoidClient {
$services = $this->getServiceContainer();
$directClient = new DirectParsoidClient(
[],
$services->getParsoidSiteConfig(),
$services->getParsoidPageConfigFactory(),
$services->getParsoidDataAccess(),
$services->getGlobalIdGenerator()
$services->getParsoidOutputStash(),
$services->getStatsdDataFactory(),
$services->getParsoidOutputAccess(),
$services->getHTMLTransformFactory(),
$services->getUserFactory()->newAnonymous()
);
return $directClient;
@ -81,7 +83,7 @@ class DirectParsoidClientTest extends MediaWikiIntegrationTestCase {
$this->assertStringContainsString( 'lang="' . $langCode . '"', $pageHtml );
$this->assertArrayHasKey( 'etag', $headers );
$this->assertStringContainsString( 'W/"' . $revision->getId(), $headers['etag'] );
$this->assertStringContainsString( (string)$revision->getId(), $headers['etag'] );
}
/**
@ -100,19 +102,19 @@ class DirectParsoidClientTest extends MediaWikiIntegrationTestCase {
$html = '<h2>Hello World</h2>';
$oldid = $pageIdentity->getId();
$etag = 'W/"' . $oldid . '/abc-123"';
$response = $directClient->transformHTML(
$pageIdentity,
$language,
$html,
$oldid,
$etag
// Supplying "null" will use the $oldid and look at recent rendering in ParserCache.
null
);
$this->assertIsArray( $response );
$this->assertArrayHasKey( 'headers', $response );
$this->assertSame( [], $response['headers'] );
$this->assertArrayHasKey( 'Content-Type', $response['headers'] );
$this->assertArrayHasKey( 'body', $response );
// Trim to remove trailing newline
@ -128,15 +130,16 @@ class DirectParsoidClientTest extends MediaWikiIntegrationTestCase {
$directClient = $this->createDirectClient();
$page = $this->getExistingTestPage( 'DirectParsoidClient' );
$pageRecord = $page->toPageRecord();
$wikitext = '== Hello World ==';
[ $language, $langCode ] = $this->createLanguage( $langCode );
$response = $directClient->transformWikitext(
$page,
$pageRecord,
$language,
$wikitext,
false,
$page->getId(),
$pageRecord->getLatest(),
false
);
@ -152,4 +155,75 @@ class DirectParsoidClientTest extends MediaWikiIntegrationTestCase {
$this->assertStringContainsString( '>Hello World</h2>', $html );
}
/** @covers ::transformHTML */
public function testRoundTripSelserWithETag() {
$directClient = $this->createDirectClient();
// Nasty wikitext that would be reformated without selser.
$originalWikitext = '*a\n* b\n* <i>c</I>';
/** @var RevisionRecord $revision */
$revision = $this->editPage( 'RoundTripSelserWithETag', $originalWikitext )
->getValue()['revision-record'];
$pageHtmlResponse = $directClient->getPageHtml( $revision );
$eTag = $pageHtmlResponse['headers']['etag'];
$oldHtml = $pageHtmlResponse['body'];
$updatedHtml = str_replace( '</body>', '<p>More Text</p></body>', $oldHtml );
// Now make a new client object, so we can mock the ParsoidOutputAccess.
$parsoidOutputAccess = $this->createNoOpMock( ParsoidOutputAccess::class );
$services = $this->getServiceContainer();
$directClient = new DirectParsoidClient(
$services->getParsoidOutputStash(),
$services->getStatsdDataFactory(),
$parsoidOutputAccess,
$services->getHTMLTransformFactory(),
$services->getUserFactory()->newAnonymous()
);
[ $targetLanguage, ] = $this->createLanguage( 'en' );
$transformHtmlResponse = $directClient->transformHTML(
$revision->getPage(),
$targetLanguage,
$updatedHtml,
$revision->getId(),
$eTag
);
$updatedWikitext = $transformHtmlResponse['body'];
$this->assertStringContainsString( $originalWikitext, $updatedWikitext );
$this->assertStringContainsString( 'More Text', $updatedWikitext );
}
/** @covers ::transformHTML */
public function testRoundTripSelserWithoutETag() {
$directClient = $this->createDirectClient();
// Nasty wikitext that would be reformated without selser.
$originalWikitext = '*a\n* b\n* <i>c</I>';
/** @var RevisionRecord $revision */
$revision = $this->editPage( 'RoundTripSelserWithoutETag', $originalWikitext )
->getValue()['revision-record'];
$pageHtmlResponse = $directClient->getPageHtml( $revision );
$oldHtml = $pageHtmlResponse['body'];
$updatedHtml = str_replace( '</body>', '<p>More Text</p></body>', $oldHtml );
[ $targetLanguage, ] = $this->createLanguage( 'en' );
$transformHtmlResponse = $directClient->transformHTML(
$revision->getPage(),
$targetLanguage,
$updatedHtml,
$revision->getId(),
null
);
// Selser should still work, because the current rendering of the page still matches.
$updatedWikitext = $transformHtmlResponse['body'];
$this->assertStringContainsString( $originalWikitext, $updatedWikitext );
$this->assertStringContainsString( 'More Text', $updatedWikitext );
}
}

View file

@ -2,21 +2,21 @@
namespace MediaWiki\Extension\VisualEditor\Tests;
use IBufferingStatsdDataFactory;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Edit\ParsoidOutputStash;
use MediaWiki\Extension\VisualEditor\DirectParsoidClient;
use MediaWiki\Extension\VisualEditor\VisualEditorParsoidClientFactory;
use MediaWiki\Extension\VisualEditor\VRSParsoidClient;
use MediaWiki\Http\HttpRequestFactory;
use MediaWiki\MainConfigNames;
use MediaWiki\Parser\Parsoid\Config\DataAccess;
use MediaWiki\Parser\Parsoid\Config\PageConfigFactory;
use MediaWiki\Parser\Parsoid\Config\SiteConfig;
use MediaWiki\Parser\Parsoid\HTMLTransformFactory;
use MediaWiki\Parser\Parsoid\ParsoidOutputAccess;
use MediaWikiIntegrationTestCase;
use MultiHttpClient;
use ParsoidVirtualRESTService;
use Psr\Log\NullLogger;
use Wikimedia\TestingAccessWrapper;
use Wikimedia\UUID\GlobalIdGenerator;
/**
* @coversDefaultClass \MediaWiki\Extension\VisualEditor\VisualEditorParsoidClientFactory
@ -43,12 +43,12 @@ class VisualEditorParsoidClientFactoryTest extends MediaWikiIntegrationTestCase
return new VisualEditorParsoidClientFactory(
$options,
$this->createNoOpMock( SiteConfig::class ),
$this->createNoOpMock( PageConfigFactory::class ),
$this->createNoOpMock( DataAccess::class ),
$this->createNoOpMock( GlobalIdGenerator::class ),
$httpRequestFactory,
new NullLogger()
new NullLogger(),
$this->createNoOpMock( ParsoidOutputStash::class ),
$this->createNoOpMock( IBufferingStatsdDataFactory::class ),
$this->createNoOpMock( ParsoidOutputAccess::class ),
$this->createNoOpMock( HTMLTransformFactory::class )
);
}