8
0

utils.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469
  1. /**
  2. * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. /* globals document, atob, Blob, URL */
  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. * @param {Boolean} [config.withBlobsHandling = false] If special flow with generating and asserting blob URLs data
  48. * should be used. This param has effect only for integration tests.
  49. */
  50. export function generateTests( config ) {
  51. if ( [ 'normalization', 'integration' ].indexOf( config.type ) === -1 ) {
  52. throw new Error( `Invalid tests type - \`config.type\`: '${ config.type }'.` );
  53. }
  54. if ( !config.input ) {
  55. throw new Error( 'No `config.input` option provided.' );
  56. }
  57. if ( !config.browsers || !config.browsers.length ) {
  58. throw new Error( 'No or empty `config.browsers` option provided.' );
  59. }
  60. const withBlobsHandling = config.withBlobsHandling || false;
  61. const groups = groupFixturesByBrowsers( config.browsers, config.input, config.skip );
  62. const generateSuiteFn = config.type === 'normalization' ? generateNormalizationTests : generateIntegrationTests;
  63. describe( config.type, () => {
  64. describe( config.input, () => {
  65. const editorConfig = config.editorConfig || {};
  66. for ( const group of Object.keys( groups ) ) {
  67. const skip = config.skip && config.skip[ group ] ? config.skip[ group ] : [];
  68. if ( groups[ group ] ) {
  69. generateSuiteFn( group, groups[ group ], editorConfig, skip, withBlobsHandling );
  70. }
  71. }
  72. } );
  73. } );
  74. }
  75. // Creates browser groups combining all browsers using same fixtures. Each browser which have
  76. // some fixtures marked to be skipped automatically create separate groups.
  77. //
  78. // @param {Array.<String>} browsers List of all browsers for which fixture groups will be created.
  79. // @param {String} fixturesGroup Fixtures group name.
  80. // @returns {Object} Object containing browsers groups where key is the name of the group and value is fixtures object:
  81. //
  82. // {
  83. // 'safari': { ... }
  84. // 'edge': { ... }
  85. // 'chrome, firefox': { ... }
  86. // }
  87. function groupFixturesByBrowsers( browsers, fixturesGroup, skipBrowsers ) {
  88. const browsersGroups = {};
  89. const browsersGeneric = browsers.slice( 0 );
  90. // Create separate groups for browsers with browser-specific fixtures available.
  91. for ( const browser of browsers ) {
  92. if ( browserFixtures[ fixturesGroup ] && browserFixtures[ fixturesGroup ][ browser ] ) {
  93. browsersGroups[ browser ] = browserFixtures[ fixturesGroup ][ browser ];
  94. browsersGeneric.splice( browsersGeneric.indexOf( browser ), 1 );
  95. }
  96. }
  97. // Create separate groups for browsers with skipped tests.
  98. if ( skipBrowsers ) {
  99. for ( const browser of Object.keys( skipBrowsers ) ) {
  100. if ( browsersGeneric.indexOf( browser ) !== -1 ) {
  101. browsersGroups[ browser ] = fixtures[ fixturesGroup ] ? fixtures[ fixturesGroup ] : null;
  102. browsersGeneric.splice( browsersGeneric.indexOf( browser ), 1 );
  103. }
  104. }
  105. }
  106. // Use generic fixtures (if available) for browsers left.
  107. if ( browsersGeneric.length ) {
  108. browsersGroups[ browsersGeneric.join( ', ' ) ] = fixtures[ fixturesGroup ] ? fixtures[ fixturesGroup ] : null;
  109. }
  110. return browsersGroups;
  111. }
  112. // Generates normalization tests based on a provided fixtures. For each input fixture one test is generated.
  113. //
  114. // @param {String} title Tests group title.
  115. // @param {Object} fixtures Object containing fixtures.
  116. // @param {Object} editorConfig Editor config with which test editor will be created.
  117. // @param {Array.<String>} skip Array of fixtures names which tests should be skipped.
  118. function generateNormalizationTests( title, fixtures, editorConfig, skip ) {
  119. describe( title, () => {
  120. let editor, pasteFromOfficePlugin;
  121. beforeEach( () => {
  122. return VirtualTestEditor
  123. .create( editorConfig )
  124. .then( newEditor => {
  125. editor = newEditor;
  126. pasteFromOfficePlugin = editor.plugins.get( 'PasteFromOffice' );
  127. } );
  128. } );
  129. for ( const name of Object.keys( fixtures.input ) ) {
  130. ( skip.indexOf( name ) !== -1 ? it.skip : it )( name, () => {
  131. const dataTransfer = createDataTransfer( {
  132. 'text/rtf': fixtures.inputRtf && fixtures.inputRtf[ name ]
  133. } );
  134. expectNormalized(
  135. pasteFromOfficePlugin._normalizeWordInput( fixtures.input[ name ], dataTransfer ),
  136. fixtures.normalized[ name ]
  137. );
  138. } );
  139. }
  140. } );
  141. }
  142. // Generates integration tests based on a provided fixtures. For each input fixture one test is generated.
  143. //
  144. // @param {String} title Tests group title.
  145. // @param {Object} fixtures Object containing fixtures.
  146. // @param {Object} editorConfig Editor config with which test editor will be created.
  147. // @param {Array.<String>} skip Array of fixtures names which tests should be skipped.
  148. // @param {Boolean} [withBlobsHandling = false] If special `expectModelWithBlobs()` function should be used to assert model data.
  149. function generateIntegrationTests( title, fixtures, editorConfig, skip, withBlobsHandling ) {
  150. describe( title, () => {
  151. let element, editor;
  152. let data = {};
  153. before( () => {
  154. element = document.createElement( 'div' );
  155. document.body.appendChild( element );
  156. return ClassicTestEditor
  157. .create( element, editorConfig )
  158. .then( editorInstance => {
  159. editor = editorInstance;
  160. } );
  161. } );
  162. beforeEach( () => {
  163. setData( editor.model, '<paragraph>[]</paragraph>' );
  164. const editorModel = editor.model;
  165. const insertContent = editorModel.insertContent;
  166. data = {};
  167. sinon.stub( editorModel, 'insertContent' ).callsFake( ( content, selection ) => {
  168. // Save model string representation now as it may change after `insertContent()` function call
  169. // so accessing it later may not work as it may have emptied/changed structure.
  170. data.actual = stringifyModel( content );
  171. insertContent.call( editorModel, content, selection );
  172. } );
  173. } );
  174. afterEach( () => {
  175. sinon.restore();
  176. } );
  177. after( () => {
  178. editor.destroy();
  179. element.remove();
  180. } );
  181. for ( const name of Object.keys( fixtures.input ) ) {
  182. if ( !withBlobsHandling ) {
  183. ( skip.indexOf( name ) !== -1 ? it.skip : it )( name, () => {
  184. data.input = fixtures.input[ name ];
  185. data.model = fixtures.model[ name ];
  186. expectModel( data, editor, fixtures.inputRtf && fixtures.inputRtf[ name ] );
  187. } );
  188. } else {
  189. ( skip.indexOf( name ) !== -1 ? it.skip : it )( name, done => {
  190. data.input = fixtures.input[ name ];
  191. data.model = fixtures.model[ name ];
  192. expectModelWithBlobs( data, editor, fixtures.inputBlob && fixtures.inputBlob[ name ], done );
  193. } );
  194. }
  195. }
  196. } );
  197. }
  198. // Checks if provided view element instance equals expected HTML. The element is stringified
  199. // before comparing so its entire structure can be compared.
  200. // If the given `actual` or `expected` structure contains base64 encoded images,
  201. // these images are extracted (so HTML diff is readable) and compared
  202. // one by one separately (so it is visible if base64 representation is malformed).
  203. //
  204. // This function is designed for comparing normalized data so expected input is preprocessed before comparing:
  205. //
  206. // * Tabs on the lines beginnings are removed.
  207. // * Line breaks and empty lines are removed.
  208. //
  209. // The expected input should be prepared in the above in mind which means every element containing text nodes must start
  210. // and end in the same line. So expected input may be formatted like:
  211. //
  212. // <span lang=PL style='mso-ansi-language:PL'> 03<span style='mso-spacerun:yes'> </span><o:p></o:p></span>
  213. //
  214. // but not like:
  215. //
  216. // <span lang=PL style='mso-ansi-language:PL'>
  217. // 03<span style='mso-spacerun:yes'> </span>
  218. // <o:p></o:p>
  219. // </span>
  220. //
  221. // because tab preceding `03` text will be treated as formatting character and will be removed.
  222. //
  223. // @param {module:engine/view/text~Text|module:engine/view/element~Element|module:engine/view/documentfragment~DocumentFragment}
  224. // actualView Actual HTML.
  225. // @param {String} expectedHtml Expected HTML.
  226. function expectNormalized( actualView, expectedHtml ) {
  227. // We are ok with both spaces and non-breaking spaces in the actual content.
  228. // Replace `&nbsp;` with regular spaces to align with expected content.
  229. const actualNormalized = stringifyView( actualView ).replace( /\u00A0/g, ' ' );
  230. const expectedNormalized = normalizeHtml( inlineData( expectedHtml ) );
  231. compareContentWithBase64Images( actualNormalized, expectedNormalized );
  232. }
  233. // Compares two models string representations. The input HTML is processed through paste
  234. // pipeline where it is transformed into model. This function hooks into {@link module:engine/model/model~Model#insertContent}
  235. // to get the model representation before it is inserted.
  236. //
  237. // @param {Object} data
  238. // @param {String} data.input Input HTML which will be pasted into the editor.
  239. // @param {String} data.actual Actual model data.
  240. // @param {String} data.model Expected model data.
  241. // @param {module:core/editor/editor~Editor} editor Editor instance.
  242. // @param {String} [inputRtf] Additional RTF input data which will be pasted into the editor as `text/rtf` together with regular input data.
  243. function expectModel( data, editor, inputRtf = null ) {
  244. firePasteEvent( editor, {
  245. 'text/html': data.input,
  246. 'text/rtf': inputRtf
  247. } );
  248. compareContentWithBase64Images( data.actual, inlineData( data.model ) );
  249. }
  250. // Compares two models string representations. The input HTML is processed through paste
  251. // pipeline where it is transformed into model. This function hooks into {@link module:engine/model/model~Model#insertContent}
  252. // to get the model representation before it is inserted.
  253. //
  254. // @param {Object} data
  255. // @param {String} data.input Input HTML which will be pasted into the editor.
  256. // @param {String} data.actual Actual model data.
  257. // @param {String} data.model Expected model data.
  258. // @param {module:core/editor/editor~Editor} editor Editor instance.
  259. // @param {String} inputBlobs Additional data which will be used to generate blobs for a test.
  260. // @param {Function} done The callback function which should be called when test has finished.
  261. function expectModelWithBlobs( data, editor, inputBlobs, done ) {
  262. // If the browser stores images as blob urls, we need to generate blobs based on a provided images base64 data
  263. // and then replace original blob urls with the local ones. This allows Paste from Office to correctly extract
  264. // data checking if the transformations flow works in real use cases.
  265. const base64 = inputBlobs.split( '------' ).map( item => item.replace( /\s/g, '' ) );
  266. const blobUrls = createBlobsFromBase64Data( base64 );
  267. const input = replaceBlobUrls( data.input, blobUrls );
  268. const expected = replaceBlobUrls( inlineData( data.model ), blobUrls );
  269. let counter = 0;
  270. const onChange = function() {
  271. counter++;
  272. // Each blob is fetched asynchronously generating separate `change` event. Also first content insertion
  273. // (with blob urls still) generates one `change` event. This means the content is fully transformed when
  274. // number of change events is number of blob urls in the content + 1.
  275. if ( counter > blobUrls.length ) {
  276. editor.editing.model.document.off( 'change', onChange );
  277. counter = 0;
  278. const expectedData = replaceBlobUrls( expected, base64 );
  279. const actualData = replaceBlobUrls( data.actual, base64 );
  280. try {
  281. compareContentWithBase64Images( actualData, expectedData );
  282. done();
  283. } catch ( err ) {
  284. done( err );
  285. }
  286. }
  287. };
  288. editor.editing.model.document.on( 'change', onChange );
  289. firePasteEvent( editor, {
  290. 'text/html': input
  291. } );
  292. // In some rare cases there might be `&nbsp;` in a model data
  293. // (see https://github.com/ckeditor/ckeditor5-paste-from-office/issues/27).
  294. data.actual = data.actual.replace( /\u00A0/g, ' ' );
  295. // Check if initial data with blob urls is correct.
  296. expect( data.actual ).to.equal( expected );
  297. }
  298. // Compares actual and expected content. Before comparison the base64 images data is extracted so data diff is more readable.
  299. // If there were any images extracted their base64 data is also compared.
  300. //
  301. // @param {String} actual Actual content.
  302. // @param {String} expected Expected content.
  303. function compareContentWithBase64Images( actual, expected ) {
  304. // Extract base64 images so they do not pollute model diff and can be compared separately.
  305. const { data: actualModel, images: actualImages } = extractBase64Srcs( actual );
  306. const { data: expectedModel, images: expectedImages } = extractBase64Srcs( expected );
  307. expect( actualModel ).to.equal( expectedModel );
  308. if ( actualImages.length > 0 && expectedImages.length > 0 ) {
  309. expect( actualImages.length ).to.equal( expectedImages.length );
  310. expect( actualImages ).to.deep.equal( expectedImages );
  311. }
  312. }
  313. // Inlines given HTML / model representation string by removing preceding tabs and line breaks.
  314. //
  315. // @param {String} data Data to be inlined.
  316. function inlineData( data ) {
  317. return data
  318. // Replace tabs on the lines beginning as normalized input files are formatted.
  319. .replace( /^\t*</gm, '<' )
  320. // Replace line breaks (after closing tags) too.
  321. .replace( /[\r\n]/gm, '' );
  322. }
  323. // Extracts base64 part representing an image from the given HTML / model representation.
  324. //
  325. // @param {String} data Data from which bas64 strings will be extracted.
  326. // @returns {Object} result
  327. // @returns {String} result.data Data without bas64 strings.
  328. // @returns {Array.<String>} result.images Array of extracted base64 strings.
  329. function extractBase64Srcs( data ) {
  330. const regexp = /src="data:image\/(png|jpe?g);base64,([^"]*)"/gm;
  331. const images = [];
  332. const replacements = [];
  333. let match;
  334. while ( ( match = regexp.exec( data ) ) !== null ) {
  335. images.push( match[ 2 ].toLowerCase() );
  336. replacements.push( match[ 2 ] );
  337. }
  338. for ( const replacement of replacements ) {
  339. data = data.replace( replacement, '' );
  340. }
  341. return { data, images };
  342. }
  343. // Fires paste event on a given editor instance with a specific HTML data.
  344. //
  345. // @param {module:core/editor/editor~Editor} editor Editor instance on which paste event will be fired.
  346. // @param {Object} data Object with `type: content` pairs used as data transfer data in the fired paste event.
  347. function firePasteEvent( editor, data ) {
  348. editor.editing.view.document.fire( 'paste', {
  349. dataTransfer: createDataTransfer( data ),
  350. preventDefault() {}
  351. } );
  352. }
  353. // Replaces all blob urls (`blob:`) in the given HTML with given replacements data.
  354. //
  355. // @param {String} html The HTML data in which blob urls will be replaced.
  356. // @param {Array.<String>} replacements Array of string which will replace found blobs in the order of occurrence.
  357. // @returns {String} The HTML data with all blob urls replaced.
  358. function replaceBlobUrls( html, replacements ) {
  359. const blobRegexp = /src="(blob:[^"]*)"/g;
  360. const toReplace = [];
  361. let match;
  362. while ( ( match = blobRegexp.exec( html ) ) !== null ) {
  363. toReplace.push( match[ 1 ] );
  364. }
  365. for ( let i = 0; i < toReplace.length; i++ ) {
  366. if ( replacements[ i ] ) {
  367. html = html.replace( toReplace[ i ], replacements[ i ] );
  368. }
  369. }
  370. return html;
  371. }
  372. // Creates blob urls from the given base64 data.
  373. //
  374. // @param {Array.<String>} base64Data Array of base64 strings from which blob urls will be generated.
  375. // @returns {Array} Array of generated blob urls.
  376. function createBlobsFromBase64Data( base64Data ) {
  377. const blobUrls = [];
  378. for ( const data of base64Data ) {
  379. blobUrls.push( URL.createObjectURL( base64toBlob( data.trim() ) ) );
  380. }
  381. return blobUrls;
  382. }
  383. // Transforms base64 data into a blob object.
  384. //
  385. // @param {String} The base64 data to be transformed.
  386. // @returns {Blob} Blob object representing given base64 data.
  387. function base64toBlob( base64Data ) {
  388. const [ type, data ] = base64Data.split( ',' );
  389. const byteCharacters = atob( data );
  390. const byteArrays = [];
  391. for ( let offset = 0; offset < byteCharacters.length; offset += 512 ) {
  392. const slice = byteCharacters.slice( offset, offset + 512 );
  393. const byteNumbers = new Array( slice.length );
  394. for ( let i = 0; i < slice.length; i++ ) {
  395. byteNumbers[ i ] = slice.charCodeAt( i );
  396. }
  397. byteArrays.push( new Uint8Array( byteNumbers ) );
  398. }
  399. return new Blob( byteArrays, { type } );
  400. }