1
0

I18n.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. 'use strict';
  2. const common = require('../common');
  3. const fc = require('fast-check');
  4. describe('I18n', function () {
  5. describe('translate', function () {
  6. this.timeout(30000);
  7. before(function () {
  8. PrivateBin.I18n.reset();
  9. });
  10. it('returns message ID unchanged if no translation found', () => {
  11. fc.assert(fc.property(
  12. fc.string(),
  13. function (messageId) {
  14. messageId = messageId.replace(/%(s|d)/g, '%%');
  15. const plurals = [messageId, messageId + 's'],
  16. fake = [messageId],
  17. result = PrivateBin.I18n.translate(messageId);
  18. PrivateBin.I18n.reset();
  19. const alias = PrivateBin.I18n._(messageId);
  20. PrivateBin.I18n.reset();
  21. const pluralResult = PrivateBin.I18n.translate(plurals);
  22. PrivateBin.I18n.reset();
  23. const pluralAlias = PrivateBin.I18n._(plurals);
  24. PrivateBin.I18n.reset();
  25. const fakeResult = PrivateBin.I18n.translate(fake);
  26. PrivateBin.I18n.reset();
  27. const fakeAlias = PrivateBin.I18n._(fake);
  28. PrivateBin.I18n.reset();
  29. if (messageId.indexOf('<a') === -1) {
  30. messageId = PrivateBin.Helper.htmlEntities(messageId);
  31. } else {
  32. messageId = DOMPurify.sanitize(
  33. messageId, {
  34. ALLOWED_TAGS: ['a', 'i', 'span'],
  35. ALLOWED_ATTR: ['href', 'id']
  36. }
  37. );
  38. }
  39. return messageId === result && messageId === alias &&
  40. messageId === pluralResult && messageId === pluralAlias &&
  41. messageId === fakeResult && messageId === fakeAlias;
  42. }
  43. ));
  44. });
  45. it('replaces %s in strings with first given parameter, encoding all, when no link is in the messageID', () => {
  46. fc.assert(fc.property(
  47. fc.string(),
  48. fc.array(fc.string(), {minLength: 1}),
  49. fc.string(),
  50. function (prefix, params, postfix) {
  51. prefix = prefix.replace(/%(s|d)/g, '%%').replace(/<a/g, '');
  52. params[0] = params[0].replace(/%(s|d)/g, '%%');
  53. postfix = postfix.replace(/%(s|d)/g, '%%').replace(/<a/g, '');
  54. const translation = PrivateBin.Helper.htmlEntities(prefix + params[0] + postfix);
  55. params.unshift(prefix + '%s' + postfix);
  56. const result = PrivateBin.I18n.translate.apply(this, params);
  57. PrivateBin.I18n.reset();
  58. const alias = PrivateBin.I18n._.apply(this, params);
  59. PrivateBin.I18n.reset();
  60. return translation === result && translation === alias;
  61. }
  62. ));
  63. });
  64. it('replaces %s in strings with first given parameter, encoding params only, when a link is part of the messageID', () => {
  65. fc.assert(fc.property(
  66. fc.string(),
  67. fc.array(fc.string(), {minLength: 1}),
  68. fc.string(),
  69. function (prefix, params, postfix) {
  70. prefix = prefix.replace(/%(s|d)/g, '%%');
  71. params[0] = params[0].replace(/%(s|d)/g, '%%');
  72. postfix = postfix.replace(/%(s|d)/g, '%%');
  73. const translation = DOMPurify.sanitize(
  74. prefix + '<a href="' + params[0] + '"></a>' + postfix, {
  75. ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|magnet):)/i,
  76. ALLOWED_TAGS: ['a', 'i', 'span', 'kbd'],
  77. ALLOWED_ATTR: ['href', 'id']
  78. }
  79. );
  80. params.unshift(prefix + '<a href="%s"></a>' + postfix);
  81. const result = PrivateBin.I18n.translate.apply(this, params);
  82. PrivateBin.I18n.reset();
  83. const alias = PrivateBin.I18n._.apply(this, params);
  84. PrivateBin.I18n.reset();
  85. return translation === result && translation === alias;
  86. }
  87. ));
  88. });
  89. it('replaces %s in strings with first given parameter into an element, encoding all, when no link is in the messageID', () => {
  90. fc.assert(fc.property(
  91. fc.string(),
  92. fc.array(fc.string(), {minLength: 1}),
  93. fc.string(),
  94. function (prefix, params, postfix) {
  95. prefix = prefix.replace(/%(s|d)/g, '%%').replace(/<a/g, '');
  96. params[0] = params[0].replace(/%(s|d)/g, '%%');
  97. postfix = postfix.replace(/%(s|d)/g, '%%').replace(/<a/g, '');
  98. const tempDiv = document.createElement('textarea');
  99. tempDiv.textContent = (prefix + params[0] + postfix);
  100. const translation = tempDiv.textContent;
  101. let args = Array.prototype.slice.call(params);
  102. args.unshift(prefix + '%s' + postfix);
  103. let clean = globalThis.cleanup();
  104. document.body.innerHTML = '<div id="i18n"></div>';
  105. const i18nElement = document.getElementById('i18n');
  106. args.unshift(i18nElement);
  107. PrivateBin.I18n.translate.apply(this, args);
  108. const result = i18nElement.textContent;
  109. PrivateBin.I18n.reset();
  110. clean();
  111. clean = globalThis.cleanup();
  112. document.body.innerHTML = '<div id="i18n"></div>';
  113. args[0] = document.getElementById('i18n');
  114. PrivateBin.I18n._.apply(this, args);
  115. const alias = document.getElementById('i18n').textContent;
  116. PrivateBin.I18n.reset();
  117. clean();
  118. return translation === result && translation === alias;
  119. }
  120. ));
  121. });
  122. it('replaces %s in strings with first given parameter into an element, encoding params only, when a link is part of the messageID inserted', () => {
  123. fc.assert(fc.property(
  124. fc.string(),
  125. fc.array(fc.string(), {minLength: 1}),
  126. fc.string(),
  127. function (prefix, params, postfix) {
  128. prefix = prefix.replace(/%(s|d)/g, '%%').trim();
  129. params[0] = params[0].replace(/%(s|d)/g, '%%').trim();
  130. postfix = postfix.replace(/%(s|d)/g, '%%').trim();
  131. const translation = DOMPurify.sanitize(
  132. prefix + '<a href="' + params[0] + '"></a>' + postfix, {
  133. ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|magnet):)/i,
  134. ALLOWED_TAGS: ['a', 'i', 'span', 'kbd'],
  135. ALLOWED_ATTR: ['href', 'id']
  136. }
  137. );
  138. let args = Array.prototype.slice.call(params);
  139. args.unshift(prefix + '<a href="%s"></a>' + postfix);
  140. let clean = globalThis.cleanup();
  141. document.body.innerHTML = '<div id="i18n"></div>';
  142. const i18nElement2 = document.getElementById('i18n');
  143. args.unshift(i18nElement2);
  144. PrivateBin.I18n.translate.apply(this, args);
  145. const result = i18nElement2.innerHTML;
  146. PrivateBin.I18n.reset();
  147. clean();
  148. clean = globalThis.cleanup();
  149. document.body.innerHTML = '<div id="i18n"></div>';
  150. args[0] = document.getElementById('i18n');
  151. PrivateBin.I18n._.apply(this, args);
  152. const alias = document.getElementById('i18n').innerHTML;
  153. PrivateBin.I18n.reset();
  154. clean();
  155. return translation === result && translation === alias;
  156. }
  157. ));
  158. });
  159. });
  160. describe('getPluralForm', function () {
  161. before(function () {
  162. PrivateBin.I18n.reset();
  163. });
  164. it('returns valid key for plural form', () => {
  165. fc.assert(fc.property(
  166. common.fcSupportedLanguages(),
  167. fc.integer(),
  168. function(language, n) {
  169. PrivateBin.I18n.reset(language);
  170. const result = PrivateBin.I18n.getPluralForm(n);
  171. // arabic seems to have the highest plural count with 6 forms
  172. return result >= 0 && result <= 5;
  173. }
  174. ));
  175. });
  176. });
  177. // loading of JSON via AJAX needs to be tested in the browser, this just mocks it
  178. // TODO: This needs to be tested using a browser.
  179. describe('loadTranslations', function () {
  180. this.timeout(30000);
  181. before(function () {
  182. PrivateBin.I18n.reset();
  183. });
  184. it('downloads and handles any supported language', () => {
  185. fc.assert(fc.property(
  186. common.fcSupportedLanguages(),
  187. function(language) {
  188. // cleanup
  189. let clean = globalThis.cleanup('', {cookie: ['lang=en']});
  190. PrivateBin.I18n.reset('en');
  191. PrivateBin.I18n.loadTranslations();
  192. clean();
  193. // mock
  194. clean = globalThis.cleanup('', {cookie: ['lang=' + language]});
  195. // eslint-disable-next-line global-require
  196. PrivateBin.I18n.reset(language, require('../../i18n/' + language + '.json'));
  197. const loadedLang = PrivateBin.I18n.getLanguage(),
  198. result = PrivateBin.I18n.translate('Never'),
  199. alias = PrivateBin.I18n._('Never');
  200. clean();
  201. return language === loadedLang && result === alias;
  202. }
  203. ));
  204. });
  205. it('maps Traditional Chinese browser languages to zh-tw', async function () {
  206. const clean = globalThis.cleanup('<script src="js/privatebin.js"></script>', {url: 'https://privatebin.net/'});
  207. // loadTranslations() loads the translation file via fetch() API,
  208. // which is asynchronous. Stub it, so the test does not perform a
  209. // real network request, and await the 'languageLoaded' event that
  210. // loadTranslations() dispatches once the fetch has completed.
  211. const originalFetch = globalThis.fetch;
  212. globalThis.fetch = function (url) {
  213. assert.strictEqual(url, 'i18n/zh-tw.json');
  214. return Promise.resolve({
  215. ok: true,
  216. status: 200,
  217. statusText: 'OK',
  218. json: function () {
  219. return Promise.resolve({});
  220. }
  221. });
  222. };
  223. try {
  224. for (const language of ['zh-TW', 'zh-TW-u-nu-hanidec', 'zh-Hant', 'zh-Hant-TW', 'zh-HK', 'zh-MO']) {
  225. Object.defineProperty(navigator, 'language', {
  226. value: language,
  227. configurable: true
  228. });
  229. PrivateBin.I18n.reset('en');
  230. const loaded = new Promise(function (resolve) {
  231. document.addEventListener('languageLoaded', resolve, {once: true});
  232. });
  233. PrivateBin.I18n.loadTranslations();
  234. await loaded;
  235. assert.strictEqual(PrivateBin.I18n.getLanguage(), 'zh-tw', language);
  236. }
  237. } finally {
  238. globalThis.fetch = originalFetch;
  239. delete navigator.language;
  240. PrivateBin.I18n.reset();
  241. clean();
  242. }
  243. });
  244. it('should default to en', () => {
  245. const clean = globalThis.cleanup('', {url: 'https://privatebin.net/'});
  246. // when navigator.userLanguage is undefined and no default language
  247. // is specified, it would throw an error
  248. [ 'language', 'userLanguage' ].forEach(function (key) {
  249. Object.defineProperty(navigator, key, {
  250. value: undefined,
  251. configurable: true,
  252. enumerable: true,
  253. writable: false
  254. });
  255. });
  256. PrivateBin.I18n.reset('en');
  257. PrivateBin.I18n.loadTranslations();
  258. const result = PrivateBin.I18n.translate('Never'),
  259. alias = PrivateBin.I18n._('Never');
  260. clean();
  261. return 'Never' === result && 'Never' === alias;
  262. });
  263. });
  264. describe('onLanguageLoaded', function () {
  265. before(function () {
  266. PrivateBin.I18n.reset();
  267. });
  268. it('invokes the callback when the languageLoaded event is dispatched', function () {
  269. let called = false;
  270. PrivateBin.I18n.onLanguageLoaded(function () {
  271. called = true;
  272. });
  273. assert.strictEqual(called, false);
  274. document.dispatchEvent(new CustomEvent('languageLoaded'));
  275. assert.strictEqual(called, true);
  276. });
  277. });
  278. });