utils.js 12 KB

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