import { afterEach, before, describe, it } from '@ephox/bedrock-client';
import { Arr } from '@ephox/katamari';
import { LegacyUnit, TinyAssertions, TinyHooks } from '@ephox/wrap-mcagar';
import { assert } from 'chai';
import DOMUtils from 'tinymce/core/api/dom/DOMUtils';
import Editor from 'tinymce/core/api/Editor';
import { UploadResult } from 'tinymce/core/api/EditorUpload';
import Env from 'tinymce/core/api/Env';
import { BlobInfo } from 'tinymce/core/api/file/BlobCache';
import { EditorEvent } from 'tinymce/core/api/util/EventDispatcher';
import * as Conversions from 'tinymce/core/file/Conversions';
const assertResult = (editor: Editor, title: string, uploadUri: string, uploadedBlobInfo: BlobInfo, result: UploadResult[], ext: string = '.png') => {
const firstResult = result[0];
assert.lengthOf(result, 1, title);
assert.isTrue(firstResult.status, title);
assert.include(firstResult.element.src, uploadedBlobInfo.id() + ext, title);
assert.equal(uploadUri, firstResult.uploadUri, title);
assert.equal(uploadedBlobInfo.id(), firstResult.blobInfo.id(), title);
assert.equal(uploadedBlobInfo.name(), firstResult.blobInfo.name(), title);
assert.equal(uploadedBlobInfo.filename(), firstResult.blobInfo.filename(), title);
assert.equal(uploadedBlobInfo.blob(), firstResult.blobInfo.blob(), title);
assert.equal(uploadedBlobInfo.base64(), firstResult.blobInfo.base64(), title);
assert.equal(uploadedBlobInfo.blobUri(), firstResult.blobInfo.blobUri(), title);
assert.equal(uploadedBlobInfo.uri(), firstResult.blobInfo.uri(), title);
assert.equal(editor.getContent(), '
', title);
return result;
};
const random = (min: number, max: number) =>
Math.round(Math.random() * (max - min) + min);
const randBlobDataUri = (width: number, height: number) => {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
const imageData = ctx.createImageData(width, height);
imageData.data.set(Arr.range(imageData.data.length, () => random(0, 255)));
ctx.putImageData(imageData, 0, 0);
return canvas.toDataURL();
};
const hasBlobAsSource = (elm: HTMLImageElement) => elm.src.indexOf('blob:') === 0;
const imageHtml = (uri: string) => DOMUtils.DOM.createHTML('img', { src: uri });
describe('browser.tinymce.core.EditorUploadTest', () => {
let testBlobDataUri: string;
let changeEvents: Array> = [];
const appendEvent = (event: EditorEvent<{}>) => changeEvents.push(event);
const clearEvents = () => changeEvents = [];
const assertEventsLength = (length: number) => assert.lengthOf(changeEvents, length, 'Correct events length');
const setInitialContent = (editor: Editor, content: string) => {
editor.resetContent(content);
clearEvents();
};
before(() => {
const canvas = document.createElement('canvas');
canvas.width = 320;
canvas.height = 200;
const context = canvas.getContext('2d') as CanvasRenderingContext2D;
context.fillStyle = '#ff0000';
context.fillRect(0, 0, 160, 100);
context.fillStyle = '#00ff00';
context.fillRect(160, 0, 160, 100);
context.fillStyle = '#0000ff';
context.fillRect(0, 100, 160, 100);
context.fillStyle = '#ff00ff';
context.fillRect(160, 100, 160, 100);
testBlobDataUri = canvas.toDataURL();
return Conversions.uriToBlob(testBlobDataUri);
});
const hook = TinyHooks.bddSetupLight({
selector: 'textarea',
add_unload_trigger: false,
disable_nodechange: true,
automatic_uploads: false,
entities: 'raw',
indent: false,
base_url: '/project/tinymce/js/tinymce',
setup: (ed: Editor) => ed.on('change', appendEvent)
}, []);
afterEach(() => {
const editor = hook.editor();
editor.editorUpload.destroy();
editor.options.set('automatic_uploads', false);
editor.options.unset('images_replace_blob_uris');
clearEvents();
});
it('TBA: _scanForImages', () => {
const editor = hook.editor();
setInitialContent(editor, imageHtml(testBlobDataUri));
return editor._scanForImages().then((result) => {
const blobInfo = result[0].blobInfo;
assert.equal('data:' + blobInfo.blob().type + ';base64,' + blobInfo.base64(), testBlobDataUri, '_scanForImages');
assert.equal(editor.getBody().innerHTML, '
', '_scanForImages');
assert.equal(
editor.getContent(),
'
',
'_scanForImages'
);
assert.deepEqual(blobInfo, editor.editorUpload.blobCache.get(blobInfo.id()), '_scanForImages');
});
});
it('TBA: replace uploaded blob uri with result uri (copy/paste of an uploaded blob uri)', () => {
const editor = hook.editor();
editor.options.set('automatic_uploads', true);
editor.options.set('images_upload_handler', (_data: BlobInfo) => {
return Promise.resolve('file.png');
});
setInitialContent(editor, imageHtml(testBlobDataUri));
return editor._scanForImages().then((result) => {
const blobUri = result[0].blobInfo.blobUri();
assertEventsLength(0);
return editor.uploadImages().then(() => {
editor.setContent(imageHtml(blobUri));
assert.isFalse(hasBlobAsSource(editor.dom.select('img')[0]), 'replace uploaded blob uri with result uri (copy/paste of an uploaded blob uri)');
assert.equal(editor.getContent(), '
', 'replace uploaded blob uri with result uri (copy/paste of an uploaded blob uri)');
assertEventsLength(1);
});
});
});
it(`TBA: don't replace uploaded blob uri with result uri (copy/paste of an uploaded blob uri) since blob uris are retained`, () => {
const editor = hook.editor();
editor.options.set('images_replace_blob_uris', false);
setInitialContent(editor, imageHtml(testBlobDataUri));
editor.options.set('images_upload_handler', (_data: BlobInfo) => {
return Promise.resolve('file.png');
});
return editor._scanForImages().then((result) => {
const blobUri = result[0].blobInfo.blobUri();
assertEventsLength(0);
return editor.uploadImages().then(() => {
assertEventsLength(0);
editor.setContent(imageHtml(blobUri));
assert.isTrue(hasBlobAsSource(editor.dom.select('img')[0]), 'Has blob');
assert.equal(editor.getContent(), '
', 'contains image');
});
});
});
it('TBA: uploadImages (callback)', () => {
const editor = hook.editor();
let uploadedBlobInfo: BlobInfo;
let uploadUri: string;
setInitialContent(editor, imageHtml(testBlobDataUri));
editor.options.set('images_upload_handler', (data: BlobInfo) => {
uploadedBlobInfo = data;
uploadUri = data.id() + '.png';
return Promise.resolve(uploadUri);
});
assertEventsLength(0);
return editor.uploadImages().then((firstResult) => {
assertResult(editor, 'Upload the images', uploadUri, uploadedBlobInfo, firstResult);
assertEventsLength(1);
return editor.uploadImages().then((secondResult) => {
assert.lengthOf(secondResult, 0, 'Upload the images');
});
});
});
it('TBA: uploadImages (promise)', () => {
const editor = hook.editor();
let uploadedBlobInfo: BlobInfo | null;
let uploadUri: string;
setInitialContent(editor, imageHtml(testBlobDataUri));
editor.options.set('images_upload_handler', (data: BlobInfo) => {
uploadedBlobInfo = data;
uploadUri = data.id() + '.png';
return Promise.resolve(uploadUri);
});
assertEventsLength(0);
return editor.uploadImages().then((result) => {
assertResult(editor, 'Upload the images', uploadUri, uploadedBlobInfo as BlobInfo, result);
assertEventsLength(1);
}).then(() => {
uploadedBlobInfo = null;
return editor.uploadImages().then((result) => {
assert.lengthOf(result, 0, 'Upload the images');
assert.isNull(uploadedBlobInfo, 'Upload the images');
});
});
});
it('TBA: uploadImages retain blob urls after upload', () => {
const editor = hook.editor();
let uploadedBlobInfo: BlobInfo | null;
const assertResultRetainsUrl = (results: UploadResult[]) => {
assert.isTrue(results[0].status, 'uploadImages retain blob urls after upload');
assert.isTrue(hasBlobAsSource(results[0].element), 'Not a blob url');
assert.equal(editor.getContent(), '
', 'uploadImages retain blob urls after upload');
return results;
};
setInitialContent(editor, imageHtml(testBlobDataUri));
editor.options.set('images_replace_blob_uris', false);
editor.options.set('images_upload_handler', (data: BlobInfo) => {
uploadedBlobInfo = data;
return Promise.resolve(data.id() + '.png');
});
assertEventsLength(0);
return editor.uploadImages().then((results) => {
assertResultRetainsUrl(results);
uploadedBlobInfo = null;
assertEventsLength(0);
return editor.uploadImages().then((result) => {
assert.lengthOf(result, 0, 'uploadImages retain blob urls after upload');
assert.isNull(uploadedBlobInfo, 'uploadImages retain blob urls after upload');
});
});
});
it('TBA: uploadImages reuse filename', () => {
const editor = hook.editor();
let uploadedBlobInfo: BlobInfo;
editor.options.set('images_reuse_filename', true);
setInitialContent(editor, imageHtml(testBlobDataUri));
editor.options.set('images_upload_handler', (data: BlobInfo) => {
uploadedBlobInfo = data;
return Promise.resolve('custom.png?size=small');
});
const assertResultReusesFilename = (editor: Editor, _uploadedBlobInfo: BlobInfo, result: UploadResult[]) => {
assert.lengthOf(result, 1, 'uploadImages reuse filename');
assert.isTrue(result[0].status, 'uploadImages reuse filename');
assert.equal(editor.getContent(), '
', 'uploadImages reuse filename');
return result;
};
assertEventsLength(0);
return editor.uploadImages().then((result) => {
assertResultReusesFilename(editor, uploadedBlobInfo, result);
editor.uploadImages().then((_result) => {
const img = editor.dom.select('img')[0];
assertEventsLength(1);
assert.isFalse(hasBlobAsSource(img), 'uploadImages reuse filename');
assert.include(img.src, 'custom.png?size=small&', 'Check the cache invalidation string was added');
assert.equal(editor.getContent(), '
', 'uploadImages reuse filename');
editor.options.unset('images_reuse_filename');
});
});
});
it('TBA: uploadConcurrentImages', () => {
const editor = hook.editor();
let uploadCount = 0, callCount = 0;
const uploadDone = (result: UploadResult[]) => {
callCount++;
assertEventsLength(1);
if (callCount === 2) {
assert.equal(uploadCount, 1, 'Should only be one upload.');
}
assert.equal(editor.getContent(), '
', 'uploadConcurrentImages');
LegacyUnit.equalDom(result[0].element, editor.dom.select('img')[0]);
assert.isTrue(result[0].status, 'uploadConcurrentImages');
};
setInitialContent(editor, imageHtml(testBlobDataUri));
editor.options.set('images_upload_handler', (_data: BlobInfo) => {
uploadCount++;
return new Promise((resolve) => {
setTimeout(() => {
resolve('myimage.png');
}, 0);
});
});
assertEventsLength(0);
return Promise.all([
editor.uploadImages().then(uploadDone),
editor.uploadImages().then(uploadDone)
]);
});
it('TBA: uploadConcurrentImages (fail)', () => {
const editor = hook.editor();
let uploadCount = 0, callCount = 0;
const uploadDone = (result: UploadResult[]) => {
callCount++;
assertEventsLength(0);
if (callCount === 2) {
// This is in exact since the status of the image can be pending or failed meaning it should try again
assert.isAtLeast(uploadCount, 1, 'Should at least be one.');
}
LegacyUnit.equalDom(result[0].element, editor.dom.select('img')[0]);
assert.isFalse(result[0].status, 'uploadConcurrentImages (fail)');
assert.isEmpty(result[0].uploadUri, 'uploadConcurrentImages (fail)');
};
setInitialContent(editor, imageHtml(testBlobDataUri));
editor.options.set('images_upload_handler', (_data: BlobInfo) => {
uploadCount++;
return new Promise((_resolve, reject) => {
setTimeout(() => {
reject('Error');
}, 0);
});
});
assertEventsLength(0);
return Promise.all([
editor.uploadImages().then(uploadDone),
editor.uploadImages().then(uploadDone)
]);
});
it('TINY-6011: uploadConcurrentImages fails, with remove', () => {
const editor = hook.editor();
let uploadCount = 0;
let callCount = 0;
const uploadDone = (result: UploadResult[]) => {
callCount++;
if (callCount === 2) {
// Note: This is 1 as only the removal of the image triggers the addition of an undo level and a change event
assertEventsLength(1);
// This is in exact since the status of the image can be pending or failed meaning it should try again
assert.isAtLeast(uploadCount, 1, 'Should at least be one.');
}
assert.isUndefined(editor.dom.select('img')[0], 'No element in the editor');
assert.isFalse(result[0].status, 'Status is false');
assert.isEmpty(result[0].uploadUri, 'Uri is empty');
assert.equal(editor.undoManager.data[0].content, '
', 'content is correct');
assert.lengthOf(editor.undoManager.data, 2, 'Suitable number of stacks added');
};
editor.resetContent(imageHtml(testBlobDataUri));
editor.options.set('images_upload_handler', (_data: BlobInfo) => {
uploadCount++;
return new Promise((_resolve, reject) => {
setTimeout(() => {
reject({ message: 'Error', remove: true });
}, 0);
});
});
assertEventsLength(0);
return Promise.all([
editor.uploadImages().then(uploadDone),
editor.uploadImages().then(uploadDone)
]);
});
it('TINY-8641: 2 successful upload simultaneous should trigger 1 change', () => {
const editor = hook.editor();
setInitialContent(editor, `
${imageHtml(testBlobDataUri)}
${imageHtml(testBlobDataUri + 'someFakeString')}
`);
editor.options.set('images_upload_handler', (data: BlobInfo) => {
return Promise.resolve(data.id() + '.png');
});
assertEventsLength(0);
return editor.uploadImages().then(() => assertEventsLength(1));
});
it('TINY-8641: 1 successful upload and 1 fail upload simultaneous should trigger 1 change', () => {
const editor = hook.editor();
let firstUploadDone = false;
setInitialContent(editor, `
${imageHtml(testBlobDataUri)}
${imageHtml(testBlobDataUri + 'someFakeString')}
`);
editor.options.set('images_upload_handler', (data: BlobInfo) => {
if (!firstUploadDone) {
firstUploadDone = true;
return Promise.resolve(data.id() + '.png');
} else {
return Promise.reject({ message: 'Error', remove: true });
}
});
assertEventsLength(0);
return editor.uploadImages().then(() => assertEventsLength(1));
});
it('TINY-8641: multiple successful upload and multiple fail upload simultaneous should trigger 1 change', () => {
const editor = hook.editor();
let successfulUploadsCounter = 0;
setInitialContent(editor, `
${imageHtml(testBlobDataUri)}
${imageHtml(testBlobDataUri + 'someFakeString1')}
${imageHtml(testBlobDataUri + 'someFakeString2')}
${imageHtml(testBlobDataUri + 'someFakeString3')}
`);
editor.options.set('images_upload_handler', (data: BlobInfo) => {
successfulUploadsCounter++;
if (successfulUploadsCounter < 2) {
return Promise.resolve(data.id() + '.png');
} else {
return Promise.reject({ message: 'Error', remove: true });
}
});
assertEventsLength(0);
return editor.uploadImages().then(() => assertEventsLength(1));
});
it(`TBA: Don't upload transparent image`, () => {
const editor = hook.editor();
let uploadCount = 0;
assertEventsLength(0);
const uploadDone = () => {
assertEventsLength(0);
assert.equal(uploadCount, 0, 'Should not upload.');
};
editor.setContent(imageHtml(Env.transparentSrc));
editor.options.set('images_upload_handler', (_data: BlobInfo) => {
uploadCount++;
return Promise.resolve('url');
});
return editor.uploadImages().then(uploadDone);
});
it(`TBA: Don't upload bogus image`, () => {
const editor = hook.editor();
let uploadCount = 0;
assertEventsLength(0);
const uploadDone = () => {
assertEventsLength(0);
assert.equal(uploadCount, 0, 'Should not upload.');
};
editor.setContent(' ');
editor.options.set('images_upload_handler', (_data: BlobInfo) => {
uploadCount++;
return Promise.resolve('url');
});
return editor.uploadImages().then(uploadDone);
});
it(`TBA: Don't upload api filtered image`, () => {
const editor = hook.editor();
let uploadCount = 0, filterCount = 0;
assertEventsLength(0);
const uploadDone = () => {
assertEventsLength(0);
assert.equal(uploadCount, 0, 'Should not upload.');
assert.equal(filterCount, 1, 'Should have filtered one item.');
};
editor.editorUpload.addFilter((img) => {
filterCount++;
return !img.hasAttribute('data-skip');
});
editor.setContent(' ');
filterCount = 0;
editor.options.set('images_upload_handler', (_data: BlobInfo) => {
uploadCount++;
return Promise.resolve('url');
});
return editor.uploadImages().then(uploadDone);
});
it('TBA: Retain blobs not in blob cache', () => {
const editor = hook.editor();
editor.setContent(' ');
assert.equal(editor.getContent(), '
', 'Retain blobs not in blob cache');
});
it('TINY-7735: UploadResult should contain the removed flag if the {remove: true} option was passed to the failure callback', () => {
const editor = hook.editor();
let uploadCount = 0;
const uploadDone = (result: UploadResult[]) => {
assert.isTrue(result[0].status, 'first image upload is successful');
assert.isFalse(result[0].removed, 'removed flag is false');
assert.isFalse(result[1].status, 'second image upload is failed');
assert.isFalse(result[1].removed, 'removed flag is false');
assert.isFalse(result[2].status, 'third image upload is failed');
assert.isTrue(result[2].removed, 'removed flag is true');
};
const imgHtml1 = imageHtml(randBlobDataUri(10, 10));
const imgHtml2 = imageHtml(randBlobDataUri(10, 10));
const imgHtml3 = imageHtml(randBlobDataUri(10, 10));
setInitialContent(editor, imgHtml1 + imgHtml2 + imgHtml3);
editor.options.set('images_upload_handler', (_data: BlobInfo) => {
uploadCount++;
return new Promise((resolve, reject) => {
if (uploadCount === 1) {
setTimeout(() => {
resolve('file.png');
}, 0);
} else if (uploadCount === 2) {
setTimeout(() => {
reject('Error');
}, 0);
} else if (uploadCount === 3) {
setTimeout(() => {
reject({ message: 'Error', remove: true });
}, 0);
}
});
});
return editor.uploadImages().then(uploadDone);
});
it('TINY-8337: Images with a data URI that does not use base64 encoding are uploaded correctly and not corrupted', () => {
const editor = hook.editor();
const svg = ` `;
let uploadedBlobInfo: BlobInfo, uploadUri: string;
setInitialContent(editor, imageHtml('data:image/svg+xml,' + encodeURIComponent(svg)));
editor.options.set('images_upload_handler', (data: BlobInfo) => {
uploadedBlobInfo = data;
uploadUri = data.id() + '.svg';
return Promise.resolve(uploadUri);
});
assertEventsLength(0);
return editor.uploadImages().then((firstResult) => {
assert.equal(uploadedBlobInfo.base64(), btoa(svg), 'base64 data is correctly encoded');
assert.isAbove(uploadedBlobInfo.blob().size, 100, 'Blob data should not be empty');
assertResult(editor, 'Upload the images', uploadUri, uploadedBlobInfo, firstResult, '.svg');
assertEventsLength(1);
return editor.uploadImages().then((secondResult) => {
assert.lengthOf(secondResult, 0, 'Upload the images');
});
});
});
it('TINY-8337: Images with a data URI that are not base64 encoded are not processed if filtered out', () => {
const editor = hook.editor();
const svg = ` `;
const dataUri = 'data:image/svg+xml,' + encodeURIComponent(svg);
editor.editorUpload.addFilter((img) => img.src !== dataUri);
setInitialContent(editor, imageHtml(dataUri));
editor.options.set('images_upload_handler', () => {
return Promise.reject('Should not be called');
});
assertEventsLength(0);
return editor.uploadImages().then(() => {
assertEventsLength(0);
TinyAssertions.assertContent(editor, `
`);
});
});
});