utils.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366
  1. /**
  2. * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  4. */
  5. /* globals document */
  6. import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
  7. import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
  8. import ViewDocument from '@ckeditor/ckeditor5-engine/src/view/document';
  9. import HtmlDataProcessor from '@ckeditor/ckeditor5-engine/src/dataprocessor/htmldataprocessor';
  10. import normalizeClipboardData from '@ckeditor/ckeditor5-clipboard/src/utils/normalizeclipboarddata';
  11. import normalizeHtml from '@ckeditor/ckeditor5-utils/tests/_utils/normalizehtml';
  12. import { setData, stringify as stringifyModel } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
  13. import { stringify as stringifyView } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
  14. import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
  15. import { fixtures, browserFixtures } from './fixtures';
  16. import { StylesProcessor } from '@ckeditor/ckeditor5-engine/src/view/stylesmap';
  17. const htmlDataProcessor = new HtmlDataProcessor( new ViewDocument( new StylesProcessor() ) );
  18. /**
  19. * Mocks dataTransfer object which can be used for simulating paste.
  20. *
  21. * @param {Object} data Object containing 'mime type - data' pairs.
  22. * @returns {Object} DataTransfer mock object.
  23. */
  24. export function createDataTransfer( data ) {
  25. return {
  26. getData( type ) {
  27. return data[ type ];
  28. }
  29. };
  30. }
  31. /**
  32. * Generates test groups based on provided parameters. Generated tests are specifically designed
  33. * to test pasted content transformations.
  34. *
  35. * This function generates test groups based on available fixtures:
  36. *
  37. * 1. If only generic fixtures are available they will be used for all listed browsers and combined into one test group.
  38. * 2. If there are browser-specific fixtures available they will be used for matching browser resulting in a separate
  39. * test group. All unmatched browsers will use generic fixtures combined into one separate test group.
  40. * 3. If some fixtures are marked to be skipped for a specific browser, the separate test group will be created for this browser.
  41. *
  42. * @param {Object} config
  43. * @param {String} config.type Type of tests to generate, could be 'normalization' or 'integration'.
  44. * @param {String} config.input Name of the fixtures group. Usually stored in `/tests/_data/groupname/`.
  45. * @param {Array.<String>} config.browsers List of all browsers for which to generate tests.
  46. * @param {Object} [config.editorConfig] Editor config which is passed to editor `create()` method.
  47. * @param {Object} [config.skip] List of fixtures for any browser to skip. The supported format is:
  48. *
  49. * {
  50. * browserName: [ fixtureName1, fixtureName2 ]
  51. * }
  52. */
  53. export function generateTests( config ) {
  54. if ( [ 'normalization', 'integration' ].indexOf( config.type ) === -1 ) {
  55. throw new Error( `Invalid tests type - \`config.type\`: '${ config.type }'.` );
  56. }
  57. if ( !config.input ) {
  58. throw new Error( 'No `config.input` option provided.' );
  59. }
  60. if ( !config.browsers || !config.browsers.length ) {
  61. throw new Error( 'No or empty `config.browsers` option provided.' );
  62. }
  63. const groups = groupFixturesByBrowsers( config.browsers, config.input, config.skip );
  64. const generateSuiteFn = config.type === 'normalization' ? generateNormalizationTests : generateIntegrationTests;
  65. describe( config.type, () => {
  66. describe( config.input, () => {
  67. const editorConfig = config.editorConfig || {};
  68. for ( const group of Object.keys( groups ) ) {
  69. const skip = config.skip && config.skip[ group ] ? config.skip[ group ] : [];
  70. if ( groups[ group ] ) {
  71. generateSuiteFn( group, groups[ group ], editorConfig, skip );
  72. }
  73. }
  74. } );
  75. } );
  76. }
  77. // Creates browser groups combining all browsers using same fixtures. Each browser which have
  78. // some fixtures marked to be skipped automatically create separate groups.
  79. //
  80. // @param {Array.<String>} browsers List of all browsers for which fixture groups will be created.
  81. // @param {String} fixturesGroup Fixtures group name.
  82. // @returns {Object} Object containing browsers groups where key is the name of the group and value is fixtures object:
  83. //
  84. // {
  85. // 'safari': { ... }
  86. // 'edge': { ... }
  87. // 'chrome, firefox': { ... }
  88. // }
  89. function groupFixturesByBrowsers( browsers, fixturesGroup, skipBrowsers ) {
  90. const browsersGroups = {};
  91. const browsersGeneric = browsers.slice( 0 );
  92. // Create separate groups for browsers with browser-specific fixtures available.
  93. for ( const browser of browsers ) {
  94. if ( browserFixtures[ fixturesGroup ] && browserFixtures[ fixturesGroup ][ browser ] ) {
  95. browsersGroups[ browser ] = browserFixtures[ fixturesGroup ][ browser ];
  96. browsersGeneric.splice( browsersGeneric.indexOf( browser ), 1 );
  97. }
  98. }
  99. // Create separate groups for browsers with skipped tests.
  100. if ( skipBrowsers ) {
  101. for ( const browser of Object.keys( skipBrowsers ) ) {
  102. if ( browsersGeneric.indexOf( browser ) !== -1 ) {
  103. browsersGroups[ browser ] = fixtures[ fixturesGroup ] ? fixtures[ fixturesGroup ] : null;
  104. browsersGeneric.splice( browsersGeneric.indexOf( browser ), 1 );
  105. }
  106. }
  107. }
  108. // Use generic fixtures (if available) for browsers left.
  109. if ( browsersGeneric.length ) {
  110. browsersGroups[ browsersGeneric.join( ', ' ) ] = fixtures[ fixturesGroup ] ? fixtures[ fixturesGroup ] : null;
  111. }
  112. return browsersGroups;
  113. }
  114. // Generates normalization tests based on a provided fixtures. For each input fixture one test is generated.
  115. // Please notice that normalization compares generated Views, not DOM. That's why there might appear some not familiar structures,
  116. // like closing tags for void tags, for example `<br></br>`.
  117. //
  118. // @param {String} title Tests group title.
  119. // @param {Object} fixtures Object containing fixtures.
  120. // @param {Object} editorConfig Editor config with which test editor will be created.
  121. // @param {Array.<String>} skip Array of fixtures names which tests should be skipped.
  122. function generateNormalizationTests( title, fixtures, editorConfig, skip ) {
  123. describe( title, () => {
  124. let editor;
  125. beforeEach( () => {
  126. return VirtualTestEditor
  127. .create( editorConfig )
  128. .then( newEditor => {
  129. editor = newEditor;
  130. } );
  131. } );
  132. afterEach( () => {
  133. editor.destroy();
  134. } );
  135. for ( const name of Object.keys( fixtures.input ) ) {
  136. const testRunner = skip.indexOf( name ) !== -1 ? it.skip : it;
  137. testRunner( name, () => {
  138. // Simulate data from Clipboard event
  139. const clipboardPlugin = editor.plugins.get( 'Clipboard' );
  140. const content = htmlDataProcessor.toView( normalizeClipboardData( fixtures.input[ name ] ) );
  141. const dataTransfer = createDataTransfer( {
  142. 'text/html': fixtures.input[ name ],
  143. 'text/rtf': fixtures.inputRtf && fixtures.inputRtf[ name ]
  144. } );
  145. // data.content might be completely overwritten with a new object, so we need obtain final result for comparison.
  146. const data = { content, dataTransfer };
  147. clipboardPlugin.fire( 'inputTransformation', data );
  148. const transformedContent = data.content;
  149. expectNormalized(
  150. transformedContent,
  151. fixtures.normalized[ name ]
  152. );
  153. } );
  154. }
  155. } );
  156. }
  157. // Generates integration tests based on a provided fixtures. For each input fixture one test is generated.
  158. //
  159. // @param {String} title Tests group title.
  160. // @param {Object} fixtures Object containing fixtures.
  161. // @param {Object} editorConfig Editor config with which test editor will be created.
  162. // @param {Array.<String>} skip Array of fixtures names which tests should be skipped.
  163. function generateIntegrationTests( title, fixtures, editorConfig, skip ) {
  164. describe( title, () => {
  165. let element, editor;
  166. let data = {};
  167. before( () => {
  168. element = document.createElement( 'div' );
  169. document.body.appendChild( element );
  170. return ClassicTestEditor
  171. .create( element, editorConfig )
  172. .then( editorInstance => {
  173. editor = editorInstance;
  174. } );
  175. } );
  176. beforeEach( () => {
  177. setData( editor.model, '<paragraph>[]</paragraph>' );
  178. const editorModel = editor.model;
  179. const insertContent = editorModel.insertContent;
  180. data = {};
  181. sinon.stub( editorModel, 'insertContent' ).callsFake( ( content, selection ) => {
  182. // Save model string representation now as it may change after `insertContent()` function call
  183. // so accessing it later may not work as it may have emptied/changed structure.
  184. data.actual = stringifyModel( content );
  185. insertContent.call( editorModel, content, selection );
  186. } );
  187. } );
  188. afterEach( () => {
  189. sinon.restore();
  190. } );
  191. after( () => {
  192. return editor.destroy()
  193. .then( () => {
  194. element.remove();
  195. } );
  196. } );
  197. for ( const name of Object.keys( fixtures.input ) ) {
  198. const testRunner = skip.indexOf( name ) !== -1 ? it.skip : it;
  199. testRunner( name, () => {
  200. data.input = fixtures.input[ name ];
  201. data.model = fixtures.model[ name ];
  202. expectModel( data, editor, fixtures.inputRtf && fixtures.inputRtf[ name ] );
  203. } );
  204. }
  205. } );
  206. }
  207. // Checks if provided view element instance equals expected HTML. The element is stringified
  208. // before comparing so its entire structure can be compared.
  209. // If the given `actual` or `expected` structure contains base64 encoded images,
  210. // these images are extracted (so HTML diff is readable) and compared
  211. // one by one separately (so it is visible if base64 representation is malformed).
  212. //
  213. // This function is designed for comparing normalized data so expected input is preprocessed before comparing:
  214. //
  215. // * Tabs on the lines beginnings are removed.
  216. // * Line breaks and empty lines are removed.
  217. //
  218. // The expected input should be prepared in the above in mind which means every element containing text nodes must start
  219. // and end in the same line. So expected input may be formatted like:
  220. //
  221. // <span lang=PL style='mso-ansi-language:PL'> 03<span style='mso-spacerun:yes'> </span><o:p></o:p></span>
  222. //
  223. // but not like:
  224. //
  225. // <span lang=PL style='mso-ansi-language:PL'>
  226. // 03<span style='mso-spacerun:yes'> </span>
  227. // <o:p></o:p>
  228. // </span>
  229. //
  230. // because tab preceding `03` text will be treated as formatting character and will be removed.
  231. //
  232. // @param {module:engine/view/text~Text|module:engine/view/element~Element|module:engine/view/documentfragment~DocumentFragment}
  233. // actualView Actual HTML.
  234. // @param {String} expectedHtml Expected HTML.
  235. function expectNormalized( actualView, expectedHtml ) {
  236. // We are ok with both spaces and non-breaking spaces in the actual content.
  237. // Replace `&nbsp;` with regular spaces to align with expected content.
  238. const actualNormalized = stringifyView( actualView ).replace( /\u00A0/g, ' ' );
  239. const expectedNormalized = normalizeHtml( inlineData( expectedHtml ) );
  240. compareContentWithBase64Images( actualNormalized, expectedNormalized );
  241. }
  242. // Compares two models string representations. The input HTML is processed through paste
  243. // pipeline where it is transformed into model. This function hooks into {@link module:engine/model/model~Model#insertContent}
  244. // to get the model representation before it is inserted.
  245. //
  246. // @param {Object} data
  247. // @param {String} data.input Input HTML which will be pasted into the editor.
  248. // @param {String} data.actual Actual model data.
  249. // @param {String} data.model Expected model data.
  250. // @param {module:core/editor/editor~Editor} editor Editor instance.
  251. // @param {String} [inputRtf] Additional RTF input data which will be pasted into the editor as `text/rtf` together with regular input data.
  252. function expectModel( data, editor, inputRtf = null ) {
  253. firePasteEvent( editor, {
  254. 'text/html': data.input,
  255. 'text/rtf': inputRtf
  256. } );
  257. compareContentWithBase64Images( data.actual, inlineData( data.model ) );
  258. }
  259. // Compares actual and expected content. Before comparison the base64 images data is extracted so data diff is more readable.
  260. // If there were any images extracted their base64 data is also compared.
  261. //
  262. // @param {String} actual Actual content.
  263. // @param {String} expected Expected content.
  264. function compareContentWithBase64Images( actual, expected ) {
  265. // Extract base64 images so they do not pollute model diff and can be compared separately.
  266. const { data: actualModel, images: actualImages } = extractBase64Srcs( actual );
  267. const { data: expectedModel, images: expectedImages } = extractBase64Srcs( expected );
  268. // In some rare cases there might be `&nbsp;` in a model data
  269. // (see https://github.com/ckeditor/ckeditor5-paste-from-office/issues/27).
  270. assertEqualMarkup( actualModel.replace( /\u00A0/g, ' ' ), expectedModel );
  271. if ( actualImages.length > 0 && expectedImages.length > 0 ) {
  272. expect( actualImages.length ).to.equal( expectedImages.length );
  273. expect( actualImages ).to.deep.equal( expectedImages );
  274. }
  275. }
  276. // Inlines given HTML / model representation string by removing preceding tabs and line breaks.
  277. //
  278. // @param {String} data Data to be inlined.
  279. function inlineData( data ) {
  280. return data
  281. // Replace tabs on the lines beginning as normalized input files are formatted.
  282. .replace( /^\t*</gm, '<' )
  283. // Replace line breaks (after closing tags) too.
  284. .replace( /[\r\n]/gm, '' );
  285. }
  286. // Extracts base64 part representing an image from the given HTML / model representation.
  287. //
  288. // @param {String} data Data from which bas64 strings will be extracted.
  289. // @returns {Object} result
  290. // @returns {String} result.data Data without bas64 strings.
  291. // @returns {Array.<String>} result.images Array of extracted base64 strings.
  292. function extractBase64Srcs( data ) {
  293. const regexp = /src="data:image\/(png|jpe?g);base64,([^"]*)"/gm;
  294. const images = [];
  295. const replacements = [];
  296. let match;
  297. while ( ( match = regexp.exec( data ) ) !== null ) {
  298. images.push( match[ 2 ].toLowerCase() );
  299. replacements.push( match[ 2 ] );
  300. }
  301. for ( const replacement of replacements ) {
  302. data = data.replace( replacement, '' );
  303. }
  304. return { data, images };
  305. }
  306. // Fires paste event on a given editor instance with a specific HTML data.
  307. //
  308. // @param {module:core/editor/editor~Editor} editor Editor instance on which paste event will be fired.
  309. // @param {Object} data Object with `type: content` pairs used as data transfer data in the fired paste event.
  310. function firePasteEvent( editor, data ) {
  311. editor.editing.view.document.fire( 'paste', {
  312. dataTransfer: createDataTransfer( data ),
  313. stopPropagation() {},
  314. preventDefault() {}
  315. } );
  316. }